Repository: RetroAchievements/RAIntegration
Pull request #644 (2020-12-07 02:22:53)

prevent exception if no games are registered for the requested system
The `gameslist` API returns an empty array (instead of an empty dictionary) when no games exist for the queried system. That causes an exception when the code tries to parse the response. This adds an additional verification to ensure the response is a dictionary before trying to process it.


Patch:
diff --git a/src/RA_Integration.vcxproj b/src/RA_Integration.vcxproj
index 5415120c..234319f5 100644
--- a/src/RA_Integration.vcxproj
+++ b/src/RA_Integration.vcxproj
@@ -138,6 +138,7 @@
     <ClCompile Include="ui\ViewModelCollection.cpp" />
     <ClCompile Include="ui\viewmodels\AssetEditorViewModel.cpp" />
     <ClCompile Include="ui\viewmodels\AssetListViewModel.cpp" />
+    <ClCompile Include="ui\viewmodels\AssetUploadViewModel.cpp" />
     <ClCompile Include="ui\viewmodels\BrokenAchievementsViewModel.cpp" />
     <ClCompile Include="ui\viewmodels\CodeNotesViewModel.cpp" />
     <ClCompile Include="ui\viewmodels\FileDialogViewModel.cpp" />
@@ -159,6 +160,7 @@
     <ClCompile Include="ui\viewmodels\OverlayViewModel.cpp" />
     <ClCompile Include="ui\viewmodels\PopupMessageViewModel.cpp" />
     <ClCompile Include="ui\viewmodels\PopupViewModelBase.cpp" />
+    <ClCompile Include="ui\viewmodels\ProgressViewModel.cpp" />
     <ClCompile Include="ui\viewmodels\RichPresenceMonitorViewModel.cpp" />
     <ClCompile Include="ui\viewmodels\ScoreboardViewModel.cpp" />
     <ClCompile Include="ui\viewmodels\ScoreTrackerViewModel.cpp" />
@@ -188,6 +190,7 @@
     <ClCompile Include="ui\win32\MessageBoxDialog.cpp" />
     <ClCompile Include="ui\win32\OverlaySettingsDialog.cpp" />
     <ClCompile Include="ui\win32\OverlayWindow.cpp" />
+    <ClCompile Include="ui\win32\ProgressDialog.cpp" />
     <ClCompile Include="ui\win32\RichPresenceDialog.cpp" />
     <ClCompile Include="ui\win32\UnknownGameDialog.cpp" />
     <ClCompile Include="ui\WindowViewModelBase.cpp" />
@@ -311,6 +314,7 @@
     <ClInclude Include="ui\ViewModelCollection.hh" />
     <ClInclude Include="ui\viewmodels\AssetEditorViewModel.hh" />
     <ClInclude Include="ui\viewmodels\AssetListViewModel.hh" />
+    <ClInclude Include="ui\viewmodels\AssetUploadViewModel.hh" />
     <ClInclude Include="ui\viewmodels\BrokenAchievementsViewModel.hh" />
     <ClInclude Include="ui\viewmodels\CodeNotesViewModel.hh" />
     <ClInclude Include="ui\viewmodels\EmulatorViewModel.hh" />
@@ -333,6 +337,7 @@
     <ClInclude Include="ui\viewmodels\OverlayViewModel.hh" />
     <ClInclude Include="ui\viewmodels\PopupMessageViewModel.hh" />
     <ClInclude Include="ui\viewmodels\PopupViewModelBase.hh" />
+    <ClInclude Include="ui\viewmodels\ProgressViewModel.hh" />
     <ClInclude Include="ui\viewmodels\RichPresenceMonitorViewModel.hh" />
     <ClInclude Include="ui\viewmodels\ScoreboardViewModel.hh" />
     <ClInclude Include="ui\viewmodels\ScoreTrackerViewModel.hh" />
@@ -357,6 +362,7 @@
     <ClInclude Include="ui\win32\bindings\MemoryViewerControlBinding.hh" />
     <ClInclude Include="ui\win32\bindings\MultiLineGridBinding.hh" />
     <ClInclude Include="ui\win32\bindings\NumericTextBoxBinding.hh" />
+    <ClInclude Include="ui\win32\bindings\ProgressBarBinding.hh" />
     <ClInclude Include="ui\win32\bindings\RadioButtonBinding.hh" />
     <ClInclude Include="ui\win32\bindings\TextBoxBinding.hh" />
     <ClInclude Include="ui\win32\bindings\WindowBinding.hh" />
@@ -372,6 +378,7 @@
     <ClInclude Include="ui\win32\MessageBoxDialog.hh" />
     <ClInclude Include="ui\win32\OverlaySettingsDialog.hh" />
     <ClInclude Include="ui\win32\OverlayWindow.hh" />
+    <ClInclude Include="ui\win32\ProgressDialog.hh" />
     <ClInclude Include="ui\win32\RichPresenceDialog.hh" />
     <ClInclude Include="ui\win32\IDialogPresenter.hh" />
     <ClInclude Include="ui\win32\UnknownGameDialog.hh" />
diff --git a/src/RA_Integration.vcxproj.filters b/src/RA_Integration.vcxproj.filters
index 77836d41..77b804f3 100644
--- a/src/RA_Integration.vcxproj.filters
+++ b/src/RA_Integration.vcxproj.filters
@@ -372,6 +372,15 @@
     <ClCompile Include="data\context\GameAssets.cpp">
       <Filter>Data\Context</Filter>
     </ClCompile>
+    <ClCompile Include="ui\viewmodels\ProgressViewModel.cpp">
+      <Filter>UI\ViewModels</Filter>
+    </ClCompile>
+    <ClCompile Include="ui\viewmodels\AssetUploadViewModel.cpp">
+      <Filter>UI\ViewModels</Filter>
+    </ClCompile>
+    <ClCompile Include="ui\win32\ProgressDialog.cpp">
+      <Filter>UI\Win32</Filter>
+    </ClCompile>
     <ClCompile Include="data\models\LocalBadgesModel.cpp">
       <Filter>Data\Models</Filter>
     </ClCompile>
@@ -926,6 +935,18 @@
     <ClInclude Include="data\context\GameAssets.hh">
       <Filter>Data\Context</Filter>
     </ClInclude>
+    <ClInclude Include="ui\viewmodels\ProgressViewModel.hh">
+      <Filter>UI\ViewModels</Filter>
+    </ClInclude>
+    <ClInclude Include="ui\viewmodels\AssetUploadViewModel.hh">
+      <Filter>UI\ViewModels</Filter>
+    </ClInclude>
+    <ClInclude Include="ui\win32\bindings\ProgressBarBinding.hh">
+      <Filter>UI\Win32\Bindings</Filter>
+    </ClInclude>
+    <ClInclude Include="ui\win32\ProgressDialog.hh">
+      <Filter>UI\Win32</Filter>
+    </ClInclude>
     <ClInclude Include="data\models\LocalBadgesModel.hh">
       <Filter>Data\Models</Filter>
     </ClInclude>
diff --git a/src/RA_Resource.h b/src/RA_Resource.h
index 1d5620d3..67f7f80e 100644
--- a/src/RA_Resource.h
+++ b/src/RA_Resource.h
@@ -67,6 +67,8 @@
 #define IDC_RA_LBL_POINTS               1162
 #define IDC_RA_FREEZE                   1163
 #define IDC_RA_DISPLAY_INFORMATION      1164
+#define IDC_RA_MESSAGE                  1165
+#define IDC_RA_PROGRESSBAR              1166
 #define IDD_RA_MEMORY                   1501
 #define IDD_RA_ACHIEVEMENTS             1502
 #define IDD_RA_ACHIEVEMENTEDITOR        1503
@@ -78,6 +80,7 @@
 #define IDD_RA_GAMELIBRARY              1509
 #define IDD_RA_ROMCHECKSUM              1510
 #define IDD_RA_OVERLAYSETTINGS          1511
+#define IDD_RA_PROGRESS                 1512
 #define IDC_RA_UPLOAD_BADGE             1517
 #define IDC_RA_ADDBOOKMARK              1518
 #define IDC_RA_INFO_GG                  1519
@@ -169,9 +172,9 @@
 // 
 #ifdef APSTUDIO_INVOKED
 #ifndef APSTUDIO_READONLY_SYMBOLS
-#define _APS_NEXT_RESOURCE_VALUE        121
+#define _APS_NEXT_RESOURCE_VALUE        122
 #define _APS_NEXT_COMMAND_VALUE         40001
-#define _APS_NEXT_CONTROL_VALUE         1165
+#define _APS_NEXT_CONTROL_VALUE         1167
 #define _APS_NEXT_SYMED_VALUE           101
 #endif
 #endif
diff --git a/src/RA_Shared.rc b/src/RA_Shared.rc
index 91f22d4c..720ec639 100644
--- a/src/RA_Shared.rc
+++ b/src/RA_Shared.rc
@@ -241,6 +241,16 @@ BEGIN
     LTEXT           "No Rich Presence Defined",IDC_RA_RICHPRESENCERESULTTEXT,7,7,135,36,SS_NOPREFIX
 END
 
+IDD_RA_PROGRESS DIALOGEX 0, 0, 183, 52
+STYLE DS_SETFONT | DS_MODALFRAME | DS_FIXEDSYS | WS_POPUP | WS_CAPTION
+CAPTION "Progress Monitor"
+FONT 8, "MS Shell Dlg", 400, 0, 0x1
+BEGIN
+    LTEXT           "Progress Message",IDC_RA_MESSAGE,4,4,175,9,SS_NOPREFIX
+    CONTROL         "",IDC_RA_PROGRESSBAR,"msctls_progress32",WS_BORDER,4,17,175,14
+    PUSHBUTTON      "Cancel",IDCLOSE,129,37,50,14
+END
+
 IDD_RA_MEMBOOKMARK DIALOGEX 0, 0, 341, 154
 STYLE DS_SETFONT | DS_FIXEDSYS | WS_POPUP | WS_CAPTION | WS_SYSMENU | WS_THICKFRAME
 EXSTYLE WS_EX_NOACTIVATE
@@ -474,6 +484,12 @@ BEGIN
         BOTTOMMARGIN, 43
     END
 
+    IDD_RA_PROGRESS, DIALOG
+    BEGIN
+        LEFTMARGIN, 4
+        RIGHTMARGIN, 179
+    END
+
     IDD_RA_MEMBOOKMARK, DIALOG
     BEGIN
         RIGHTMARGIN, 340
@@ -603,6 +619,11 @@ BEGIN
     0
 END
 
+IDD_RA_PROGRESS AFX_DIALOG_LAYOUT
+BEGIN
+    0
+END
+
 #endif    // English (United Kingdom) resources
 /////////////////////////////////////////////////////////////////////////////
 
diff --git a/src/api/impl/ConnectedServer.cpp b/src/api/impl/ConnectedServer.cpp
index 155200cb..307c8ac7 100644
--- a/src/api/impl/ConnectedServer.cpp
+++ b/src/api/impl/ConnectedServer.cpp
@@ -997,38 +997,48 @@ FetchGamesList::Response ConnectedServer::FetchGamesList(const FetchGamesList::R
             response.Result = ApiResult::Success;
 
             auto& Data = document["Response"];
-            for (auto iter = Data.MemberBegin(); iter != Data.MemberEnd(); ++iter)
+            if (!Data.IsObject())
             {
-                if (!iter->name.IsString())
+                // Response should be a dictionary of ID/Name pairs. If we got anything else, it's an error
+                response.Result = ApiResult::Error;
+                if (response.ErrorMessage.empty())
+                    response.ErrorMessage = ra::StringPrintf("%s is not a dictionary", "Response");
+            }
+            else
+            {
+                for (auto iter = Data.MemberBegin(); iter != Data.MemberEnd(); ++iter)
                 {
-                    response.Result = ApiResult::Error;
-                    if (response.ErrorMessage.empty())
-                        response.ErrorMessage = "Non-string key found in response";
+                    if (!iter->name.IsString())
+                    {
+                        response.Result = ApiResult::Error;
+                        if (response.ErrorMessage.empty())
+                            response.ErrorMessage = "Non-string key found in response";
 
-                    break;
-                }
+                        break;
+                    }
 
-                if (!iter->value.IsString())
-                {
-                    response.Result = ApiResult::Error;
-                    if (response.ErrorMessage.empty())
-                        response.ErrorMessage = ra::BuildString("Non-string value found in response for key ", iter->name.GetString());
+                    if (!iter->value.IsString())
+                    {
+                        response.Result = ApiResult::Error;
+                        if (response.ErrorMessage.empty())
+                            response.ErrorMessage = ra::BuildString("Non-string value found in response for key ", iter->name.GetString());
 
-                    break;
-                }
+                        break;
+                    }
 
-                char* pEnd;
-                const auto nGameId = strtoul(iter->name.GetString(), &pEnd, 10);
-                if (nGameId == 0 || (pEnd && *pEnd))
-                {
-                    response.Result = ApiResult::Error;
-                    if (response.ErrorMessage.empty())
-                        response.ErrorMessage = ra::BuildString("Invalid game ID: ", iter->name.GetString());
+                    char* pEnd;
+                    const auto nGameId = strtoul(iter->name.GetString(), &pEnd, 10);
+                    if (nGameId == 0 || (pEnd && *pEnd))
+                    {
+                        response.Result = ApiResult::Error;
+                        if (response.ErrorMessage.empty())
+                            response.ErrorMessage = ra::BuildString("Invalid game ID: ", iter->name.GetString());
 
-                    break;
-                }
+                        break;
+                    }
 
-                response.Games.emplace_back(nGameId, ra::Widen(iter->value.GetString()));
+                    response.Games.emplace_back(nGameId, ra::Widen(iter->value.GetString()));
+                }
             }
         }
     }
diff --git a/src/data/context/GameAssets.hh b/src/data/context/GameAssets.hh
index fe0725e8..9eac0977 100644
--- a/src/data/context/GameAssets.hh
+++ b/src/data/context/GameAssets.hh
@@ -48,7 +48,7 @@ public:
     }
 
     /// <summary>
-    /// Finds the achievement asset for the specified ID.
+    /// Creates a new achievement asset.
     /// </summary>
     ra::data::models::AchievementModel& NewAchievement();
 
diff --git a/src/data/models/LocalBadgesModel.cpp b/src/data/models/LocalBadgesModel.cpp
index fa5da4a9..d69add80 100644
--- a/src/data/models/LocalBadgesModel.cpp
+++ b/src/data/models/LocalBadgesModel.cpp
@@ -5,6 +5,8 @@
 #include "services\IFileSystem.hh"
 #include "services\ServiceLocator.hh"
 
+#include "ui\ImageReference.hh"
+
 namespace ra {
 namespace data {
 namespace models {
@@ -94,18 +96,22 @@ int LocalBadgesModel::GetReferenceCount(const std::wstring& sBadgeName) const
 
 void LocalBadgesModel::Commit(const std::wstring& sPreviousBadgeName, const std::wstring& sNewBadgeName)
 {
+    // the old badge will have already been dereferenced by RemoveReference. if the reference count is 0
+    // go ahead and get rid ofit.
     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)
+        if (pIter->second.nCommittedCount == 0 && pIter->second.nUncommittedCount == 0)
         {
+            const auto& pImageRepository = ra::services::ServiceLocator::Get<ra::ui::IImageRepository>();
+            const auto sFilename = pImageRepository.GetFilename(ra::ui::ImageType::Badge, ra::Narrow(pIter->first));
             const auto& pFileSystem = ra::services::ServiceLocator::Get<ra::services::IFileSystem>();
-            pFileSystem.DeleteFile(pIter->first);
+            pFileSystem.DeleteFile(sFilename);
             m_mReferences.erase(pIter);
         }
     }
 
+    // update the new badge reference from uncommitted to committed
     pIter = m_mReferences.find(sNewBadgeName);
     if (pIter != m_mReferences.end())
     {
diff --git a/src/ui/ImageReference.hh b/src/ui/ImageReference.hh
index f890cb21..3b70c07f 100644
--- a/src/ui/ImageReference.hh
+++ b/src/ui/ImageReference.hh
@@ -57,6 +57,14 @@ public:
     /// <returns>Generated name for referencing the image.</param>
     virtual std::string StoreImage(ImageType nType, const std::wstring& sPath) = 0;
 
+    /// <summary>
+    /// Gets the full path to the file for the specified image.
+    /// </summary>
+    /// <param name="nType">Type of the image.</param>
+    /// <param name="sName">Path to image.</param>
+    /// <returns>Path to the file, or empty string if a file is not available.</returns>
+    virtual std::wstring GetFilename(ImageType nType, const std::string& sName) const = 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 71b61b6b..8f46c7c1 100644
--- a/src/ui/drawing/gdi/ImageRepository.cpp
+++ b/src/ui/drawing/gdi/ImageRepository.cpp
@@ -79,7 +79,29 @@ ImageRepository::~ImageRepository() noexcept
         CoUninitialize();
 }
 
-std::wstring ImageRepository::GetFilename(ImageType nType, const std::string& sName)
+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" || sExtension == ".jpeg" || sExtension == ".png")
+    {
+        // name contains a supported extension, remove ".png" appended by GetFilename
+        sFilename.pop_back();
+        sFilename.pop_back();
+        sFilename.pop_back();
+        sFilename.pop_back();
+    }
+}
+
+std::wstring ImageRepository::GetFilename(ImageType nType, const std::string& sName) const
 {
     auto& pFileSystem = ra::services::ServiceLocator::Get<ra::services::IFileSystem>();
     std::wstring sFilename = pFileSystem.BaseDirectory();
@@ -88,6 +110,8 @@ std::wstring ImageRepository::GetFilename(ImageType nType, const std::string& sN
     {
         case ImageType::Badge:
             sFilename += RA_DIR_BADGE + ra::Widen(sName) + L".png";
+            // if sName contains a supported file format extension, remove the ".png"
+            RemoveRedundantFileExtension(sFilename, sName);
             break;
         case ImageType::Icon:
             sFilename += RA_DIR_BADGE + std::wstring(L"i") + ra::Widen(sName) + L".png";
@@ -217,23 +241,6 @@ std::string ImageRepository::StoreImage(ImageType nType, const std::wstring& sPa
 
     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))
@@ -457,35 +464,6 @@ 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())
@@ -505,9 +483,6 @@ 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 b63d652c..9a55eae0 100644
--- a/src/ui/drawing/gdi/ImageRepository.hh
+++ b/src/ui/drawing/gdi/ImageRepository.hh
@@ -47,8 +47,9 @@ public:
 
     bool HasReferencedImageChanged(ImageReference& pImage) const override;
 
+    std::wstring GetFilename(ImageType nType, const std::string& sName) const override;
+
 private:
-    static std::wstring GetFilename(ImageType nType, const std::string& sName);
     static HBITMAP LoadLocalPNG(const std::wstring& sFilename, unsigned int nWidth, unsigned int nHeight);
 
     HBITMAP GetImage(ImageType nType, const std::string& sName);
diff --git a/src/ui/viewmodels/AssetListViewModel.cpp b/src/ui/viewmodels/AssetListViewModel.cpp
index ebd220f7..06f731b8 100644
--- a/src/ui/viewmodels/AssetListViewModel.cpp
+++ b/src/ui/viewmodels/AssetListViewModel.cpp
@@ -7,6 +7,7 @@
 #include "services\IThreadPool.hh"
 #include "services\ServiceLocator.hh"
 
+#include "ui\viewmodels\AssetUploadViewModel.hh"
 #include "ui\viewmodels\MessageBoxViewModel.hh"
 #include "ui\viewmodels\WindowManager.hh"
 
@@ -545,7 +546,7 @@ void AssetListViewModel::DoUpdateButtons()
     }
     else if (bHasUnpublished)
     {
-        SetValue(SaveButtonTextProperty, L"&Publish All");
+        SetValue(SaveButtonTextProperty, L"Publi&sh All");
         SetValue(CanSaveProperty, true);
     }
     else
@@ -603,7 +604,7 @@ void AssetListViewModel::GetSelectedAssets(std::vector<ra::data::models::AssetMo
     }
 
     // no selected assets found, return all assets
-    if (vSelectedAssets.size() == 0)
+    if (vSelectedAssets.empty())
     {
         for (size_t i = 0; i < m_vFilteredAssets.Count(); ++i)
         {
@@ -646,18 +647,62 @@ void AssetListViewModel::SaveSelected()
     auto& pGameContext = ra::services::ServiceLocator::GetMutable<ra::data::context::GameContext>();
     std::vector<ra::data::models::AssetModelBase*> vSelectedAssets;
 
-    for (size_t i = 0; i < m_vFilteredAssets.Count(); ++i)
+    if (GetSaveButtonText().at(0) == 'P')
     {
-        const auto* pItem = m_vFilteredAssets.GetItemAt(i);
-        if (pItem != nullptr && pItem->IsSelected())
+        // publish - find the selected unpublished items
+        GetSelectedAssets(vSelectedAssets);
+
+        auto pIter = vSelectedAssets.begin();
+        while (pIter != vSelectedAssets.end())
         {
-            auto* pAsset = pGameContext.Assets().FindAsset(pItem->GetType(), pItem->GetId());
-            if (pAsset != nullptr)
-                vSelectedAssets.push_back(pAsset);
+            if ((*pIter)->GetChanges() == ra::data::models::AssetChanges::Unpublished)
+                ++pIter;
+            else
+                pIter = vSelectedAssets.erase(pIter);
+        }
+
+        if (vSelectedAssets.empty())
+            return;
+
+        ra::ui::viewmodels::MessageBoxViewModel vmMessageBox;
+        vmMessageBox.SetMessage(ra::StringPrintf(L"Are you sure you want to publish %d items?", vSelectedAssets.size()));
+        vmMessageBox.SetButtons(ra::ui::viewmodels::MessageBoxViewModel::Buttons::YesNo);
+        if (vmMessageBox.ShowModal(*this) != DialogResult::Yes)
+            return;
+
+        Publish(vSelectedAssets);
+    }
+    else
+    {
+        // save - find the selected items (allow empty to mean all)
+        for (size_t i = 0; i < m_vFilteredAssets.Count(); ++i)
+        {
+            const auto* pItem = m_vFilteredAssets.GetItemAt(i);
+            if (pItem != nullptr && pItem->IsSelected())
+            {
+                auto* pAsset = pGameContext.Assets().FindAsset(pItem->GetType(), pItem->GetId());
+                if (pAsset != nullptr)
+                    vSelectedAssets.push_back(pAsset);
+            }
         }
     }
 
+    // update the local file
     pGameContext.Assets().SaveAssets(vSelectedAssets);
+    UpdateButtons();
+}
+
+void AssetListViewModel::Publish(std::vector<ra::data::models::AssetModelBase*>& vAssets)
+{
+    AssetUploadViewModel vmAssetUpload;
+    for (auto* pAsset : vAssets)
+    {
+        Expects(pAsset != nullptr);
+        vmAssetUpload.QueueAsset(*pAsset);
+    }
+
+    vmAssetUpload.ShowModal(*this);
+    vmAssetUpload.ShowResults();
 }
 
 void AssetListViewModel::ResetSelected()
diff --git a/src/ui/viewmodels/AssetListViewModel.hh b/src/ui/viewmodels/AssetListViewModel.hh
index ac57abfb..d0c035b5 100644
--- a/src/ui/viewmodels/AssetListViewModel.hh
+++ b/src/ui/viewmodels/AssetListViewModel.hh
@@ -183,6 +183,9 @@ public:
 
     static const ra::AchievementID FirstLocalId = 111000001;
 
+protected:
+    virtual void Publish(std::vector<ra::data::models::AssetModelBase*>& vAssets);
+
 private:
     // DataModelCollectionBase::NotifyTarget
     void OnDataModelIntValueChanged(gsl::index nIndex, const IntModelProperty::ChangeArgs& args) override;
diff --git a/src/ui/viewmodels/AssetUploadViewModel.cpp b/src/ui/viewmodels/AssetUploadViewModel.cpp
new file mode 100644
index 00000000..9f0b55e1
--- /dev/null
+++ b/src/ui/viewmodels/AssetUploadViewModel.cpp
@@ -0,0 +1,229 @@
+#include "AssetUploadViewModel.hh"
+
+#include "api\UpdateAchievement.hh"
+#include "api\UploadBadge.hh"
+
+#include "data\context\GameContext.hh"
+
+#include "ui\ImageReference.hh"
+#include "ui\viewmodels\MessageBoxViewModel.hh"
+
+namespace ra {
+namespace ui {
+namespace viewmodels {
+
+AssetUploadViewModel::AssetUploadViewModel() noexcept
+{
+    SetWindowTitle(L"Uploading Assets");
+}
+
+void AssetUploadViewModel::QueueAsset(ra::data::models::AssetModelBase& pAsset)
+{
+    switch (pAsset.GetType())
+    {
+        case ra::data::models::AssetType::Achievement:
+            QueueAchievement(*(dynamic_cast<ra::data::models::AchievementModel*>(&pAsset)));
+            break;
+
+        default:
+            Expects(!"Unsupported asset type");
+            break;
+    }
+}
+
+void AssetUploadViewModel::QueueAchievement(ra::data::models::AchievementModel& pAchievement)
+{
+    auto& pItem = m_vUploadQueue.emplace_back();
+    pItem.pAsset = &pAchievement;
+
+    const auto& sBadge = pAchievement.GetBadge();
+    if (ra::StringStartsWith(sBadge, L"local\\"))
+    {
+        pItem.nState = UploadState::WaitingForImage;
+
+        for (const auto& pQueuedItem : m_vUploadQueue)
+        {
+            const auto* pQueuedAchievement = dynamic_cast<const ra::data::models::AchievementModel*>(pQueuedItem.pAsset);
+            if (pQueuedAchievement && pQueuedAchievement != &pAchievement && pQueuedAchievement->GetBadge() == sBadge)
+            {
+                // badge request already queued, do nothing
+                return;
+            }
+        }
+
+        QueueTask([this, sBadge]()
+        {
+            UploadBadge(sBadge);
+        });
+
+        return;
+    }
+
+    QueueTask([this, pAchievement = &pAchievement]()
+    {
+        UploadAchievement(*pAchievement);
+    });
+}
+
+void AssetUploadViewModel::OnBegin()
+{
+    SetMessage(ra::StringPrintf(L"Uploading %d items...", TaskCount()));
+}
+
+void AssetUploadViewModel::UploadBadge(const std::wstring& sBadge)
+{
+    const auto& pImageRepository = ra::services::ServiceLocator::Get<ra::ui::IImageRepository>();
+    const std::wstring sFilename = pImageRepository.GetFilename(ra::ui::ImageType::Badge, ra::Narrow(sBadge));
+
+    ra::api::UploadBadge::Request request;
+    request.ImageFilePath = sFilename;
+    const auto response = request.Call();
+
+    // if the upload failed, set the error message on all the associated achievements (including this one)
+    if (!response.Succeeded())
+    {
+        std::lock_guard<std::mutex> pLock(m_pMutex);
+        for (auto& pItem : m_vUploadQueue)
+        {
+            if (pItem.nState == UploadState::WaitingForImage)
+            {
+                const auto* pQueuedAchievement = dynamic_cast<ra::data::models::AchievementModel*>(pItem.pAsset);
+                if (pQueuedAchievement && pQueuedAchievement->GetBadge() == sBadge)
+                {
+                    pItem.sErrorMessage = response.ErrorMessage;
+                    pItem.nState = UploadState::Failed;
+                }
+            }
+        }
+
+        return;
+    }
+
+    // upload succeeded. update the badge property for each associated achievement and identify any achievements that
+    // need to be reprocessed because the image wasn't ready when they were previously given a chance to process.
+    std::vector<ra::data::models::AchievementModel*> vAffectedAchievements;
+
+    {
+        std::lock_guard<std::mutex> pLock(m_pMutex);
+        for (auto& pItem : m_vUploadQueue)
+        {
+            if (pItem.nState == UploadState::WaitingForImage)
+            {
+                auto* pQueuedAchievement = dynamic_cast<ra::data::models::AchievementModel*>(pItem.pAsset);
+                if (pQueuedAchievement && pQueuedAchievement->GetBadge() == sBadge)
+                {
+                    pQueuedAchievement->SetBadge(ra::Widen(response.BadgeId));
+                    vAffectedAchievements.push_back(pQueuedAchievement);
+                }
+            }
+        }
+    }
+
+    // the affected achievements can be uploaded now
+    for (auto* pAchievement : vAffectedAchievements)
+    {
+        QueueTask([this, pAchievement]()
+        {
+            UploadAchievement(*pAchievement);
+        });
+    }
+}
+
+void AssetUploadViewModel::UploadAchievement(ra::data::models::AchievementModel& pAchievement)
+{
+    ra::api::UpdateAchievement::Request request;
+    request.GameId = ra::services::ServiceLocator::Get<ra::data::context::GameContext>().GameId();
+    request.Title = pAchievement.GetName();
+    request.Description = pAchievement.GetDescription();
+    request.Trigger = pAchievement.GetTrigger();
+    request.Points = pAchievement.GetPoints();
+    request.Badge = ra::Narrow(pAchievement.GetBadge());
+
+    if (pAchievement.GetCategory() == ra::data::models::AssetCategory::Local)
+    {
+        request.Category = ra::to_unsigned(ra::etoi(ra::data::models::AssetCategory::Unofficial));
+    }
+    else
+    {
+        request.Category = ra::to_unsigned(ra::etoi(pAchievement.GetCategory()));
+        request.AchievementId = pAchievement.GetID();
+    }
+
+    UploadItem* pItem = nullptr;
+    for (auto& pScan : m_vUploadQueue)
+    {
+        if (pScan.pAsset == &pAchievement)
+        {
+            pItem = &pScan;
+            break;
+        }
+    }
+    Expects(pItem != nullptr);
+
+    const auto& response = request.Call();
+    if (response.Succeeded())
+    {
+        if (pAchievement.GetCategory() == ra::data::models::AssetCategory::Local)
+        {
+            pAchievement.SetCategory(ra::data::models::AssetCategory::Unofficial);
+            pAchievement.SetID(response.AchievementId);
+        }
+
+        pAchievement.UpdateLocalCheckpoint();
+        pAchievement.UpdateServerCheckpoint();
+
+        pItem->nState = UploadState::Success;
+    }
+    else
+    {
+        pItem->sErrorMessage = response.ErrorMessage;
+        pItem->nState = UploadState::Failed;
+    }
+}
+
+void AssetUploadViewModel::ShowResults() const
+{
+    int nSuccessful = 0;
+    int nFailed = 0;
+    bool bAborted = false;
+    for (const auto& pItem : m_vUploadQueue)
+    {
+        switch (pItem.nState)
+        {
+            case UploadState::Success:
+                ++nSuccessful;
+                break;
+
+            case UploadState::Failed:
+                ++nFailed;
+                break;
+
+            default:
+                bAborted = true;
+                break;
+        }
+    }
+
+    auto sMessage = ra::StringPrintf(L"%d items successfully uploaded.", nSuccessful);
+    if (nFailed)
+    {
+        sMessage.append(ra::StringPrintf(L"\n\n%d items failed:", nFailed));
+
+        for (const auto& pItem : m_vUploadQueue)
+        {
+            if (pItem.nState == UploadState::Failed)
+                sMessage.append(ra::StringPrintf(L"\n* %s: %s", pItem.pAsset->GetName(), pItem.sErrorMessage));
+        }
+    }
+
+    if (bAborted)
+        ra::ui::viewmodels::MessageBoxViewModel::ShowWarningMessage(L"Publish canceled.", sMessage);
+    else if (nFailed)
+        ra::ui::viewmodels::MessageBoxViewModel::ShowErrorMessage(L"Publish failed.", sMessage);
+    else
+        ra::ui::viewmodels::MessageBoxViewModel::ShowMessage(L"Publish succeeded.", sMessage);
+}
+
+} // namespace viewmodels
+} // namespace ui
+} // namespace ra
diff --git a/src/ui/viewmodels/AssetUploadViewModel.hh b/src/ui/viewmodels/AssetUploadViewModel.hh
new file mode 100644
index 00000000..00bf4f86
--- /dev/null
+++ b/src/ui/viewmodels/AssetUploadViewModel.hh
@@ -0,0 +1,54 @@
+#ifndef RA_UI_ASSETUPLOAD_VIEW_MODEL_H
+#define RA_UI_ASSETUPLOAD_VIEW_MODEL_H
+#pragma once
+
+#include "ProgressViewModel.hh"
+
+#include "data\models\AchievementModel.hh"
+
+namespace ra {
+namespace ui {
+namespace viewmodels {
+
+class AssetUploadViewModel : public ProgressViewModel
+{
+public:
+    GSL_SUPPRESS_F6 AssetUploadViewModel() noexcept;
+
+    void QueueAsset(ra::data::models::AssetModelBase& pAsset);
+    void QueueAchievement(ra::data::models::AchievementModel& pAchievement);
+
+    void ShowResults() const;
+
+protected:
+    void OnBegin() override;
+
+private:
+    enum class UploadState
+    {
+        None = 0,
+        Uploading,
+        WaitingForImage,
+        Success,
+        Failed
+    };
+
+    struct UploadItem
+    {
+        ra::data::models::AssetModelBase* pAsset = nullptr;
+        std::string sErrorMessage;
+        UploadState nState = UploadState::None;
+    };
+
+    void UploadBadge(const std::wstring& sBadge);
+    void UploadAchievement(ra::data::models::AchievementModel& pAchievement);
+
+    std::vector<UploadItem> m_vUploadQueue;
+    std::mutex m_pMutex;
+};
+
+} // namespace viewmodels
+} // namespace ui
+} // namespace ra
+
+#endif RA_UI_PROGRESS_VIEW_MODEL_H
diff --git a/src/ui/viewmodels/MessageBoxViewModel.hh b/src/ui/viewmodels/MessageBoxViewModel.hh
index d8182ba7..8b39f0d7 100644
--- a/src/ui/viewmodels/MessageBoxViewModel.hh
+++ b/src/ui/viewmodels/MessageBoxViewModel.hh
@@ -116,6 +116,26 @@ public:
         viewModel.ShowModal();
     }
 
+    /// <summary>
+    /// Shows a generic message.
+    /// </summary>
+    static void ShowMessage(const std::wstring& sHeader, const std::wstring& sMessage)
+    {
+        MessageBoxViewModel viewModel(sMessage);
+        viewModel.SetHeader(sHeader);
+        viewModel.ShowModal();
+    }
+
+    /// <summary>
+    /// Shows a generic message.
+    /// </summary>
+    static void ShowMessage(std::wstring&& sHeader, std::wstring&& sMessage)
+    {
+        MessageBoxViewModel viewModel(std::move(sMessage));
+        viewModel.SetHeader(std::move(sHeader));
+        viewModel.ShowModal();
+    }
+
     /// <summary>
     /// Shows an informational message.
     /// </summary>
diff --git a/src/ui/viewmodels/ProgressViewModel.cpp b/src/ui/viewmodels/ProgressViewModel.cpp
new file mode 100644
index 00000000..e62c80ff
--- /dev/null
+++ b/src/ui/viewmodels/ProgressViewModel.cpp
@@ -0,0 +1,103 @@
+#include "ProgressViewModel.hh"
+
+#include "services\IThreadPool.hh"
+#include "services\ServiceLocator.hh"
+
+namespace ra {
+namespace ui {
+namespace viewmodels {
+
+const StringModelProperty ProgressViewModel::MessageProperty("ProgressViewModel", "Message", L"");
+const IntModelProperty ProgressViewModel::ProgressProperty("ProgressViewModel", "Progress", 0);
+
+void ProgressViewModel::QueueTask(std::function<void()>&& fTaskHandler)
+{
+    auto pItem = std::make_unique<TaskItem>();
+    pItem->fTaskHandler = std::move(fTaskHandler);
+
+    std::lock_guard<std::mutex> pGuard(m_pMutex);
+    m_vTasks.emplace_back(std::move(pItem));
+}
+
+void ProgressViewModel::OnValueChanged(const BoolModelProperty::ChangeArgs& args)
+{
+    if (args.Property == IsVisibleProperty && args.tNewValue)
+        BeginTasks();
+}
+
+void ProgressViewModel::BeginTasks()
+{
+    if (m_vTasks.empty())
+    {
+        SetDialogResult(DialogResult::OK);
+        return;
+    }
+
+    OnBegin();
+
+    // use up to four threads to process the queue
+    constexpr int MAX_THREADS = 4;
+    auto& pThreadPool = ra::services::ServiceLocator::GetMutable<ra::services::IThreadPool>();
+    for (int i = 0; i < std::min(gsl::narrow_cast<int>(m_vTasks.size()), MAX_THREADS); ++i)
+    {
+        pThreadPool.RunAsync([this, pAsyncHandle = CreateAsyncHandle()]()
+        {
+            ra::data::AsyncKeepAlive pKeepAlive(*pAsyncHandle);
+            if (!pAsyncHandle->IsDestroyed())
+                ProcessQueue();
+        });
+    }
+}
+
+void ProgressViewModel::ProcessQueue()
+{
+    do
+    {
+        TaskItem* pTask = nullptr;
+        int nComplete = 0;
+        {
+            std::lock_guard<std::mutex> pGuard(m_pMutex);
+            if (m_bQueueComplete || GetDialogResult() != DialogResult::None)
+                return;
+
+            for (auto& pItem : m_vTasks)
+            {
+                if (pItem->nState == TaskState::None && !pTask)
+                {
+                    pItem->nState = TaskState::Running;
+                    pTask = pItem.get();
+                }
+                else if (pItem->nState == TaskState::Done)
+                {
+                    ++nComplete;
+                }
+            }
+
+            if (pTask == nullptr)
+                m_bQueueComplete = true;
+
+            nComplete = nComplete * 100 / gsl::narrow_cast<int>(m_vTasks.size());
+        }
+
+        SetValue(ProgressProperty, nComplete);
+
+        if (pTask != nullptr)
+        {
+            pTask->fTaskHandler();
+            pTask->nState = TaskState::Done;
+        }
+        else if (m_bQueueComplete)
+        {
+            OnComplete();
+        }
+    } while (true);
+}
+
+void ProgressViewModel::OnComplete()
+{
+    SetDialogResult(DialogResult::OK);
+}
+
+} // namespace viewmodels
+} // namespace ui
+} // namespace ra
diff --git a/src/ui/viewmodels/ProgressViewModel.hh b/src/ui/viewmodels/ProgressViewModel.hh
new file mode 100644
index 00000000..d600e0ae
--- /dev/null
+++ b/src/ui/viewmodels/ProgressViewModel.hh
@@ -0,0 +1,91 @@
+#ifndef RA_UI_PROGRESS_VIEW_MODEL_H
+#define RA_UI_PROGRESS_VIEW_MODEL_H
+#pragma once
+
+#include "data\AsyncObject.hh"
+
+#include "ui\WindowViewModelBase.hh"
+
+#include "data\models\AchievementModel.hh"
+
+namespace ra {
+namespace ui {
+namespace viewmodels {
+
+class ProgressViewModel : public WindowViewModelBase, protected ra::data::AsyncObject
+{
+public:
+    ProgressViewModel() noexcept {}
+    ~ProgressViewModel()
+    {
+        ra::data::AsyncObject::BeginDestruction();
+    }
+
+    ProgressViewModel(const ProgressViewModel&) noexcept = delete;
+    ProgressViewModel& operator=(const ProgressViewModel&) noexcept = delete;
+    ProgressViewModel(ProgressViewModel&&) noexcept = delete;
+    ProgressViewModel& operator=(ProgressViewModel&&) noexcept = delete;
+
+
+    /// <summary>
+    /// The <see cref="ModelProperty" /> for the message.
+    /// </summary>
+    static const StringModelProperty MessageProperty;
+
+    /// <summary>
+    /// Gets the message.
+    /// </summary>
+    const std::wstring& GetMessage() const { return GetValue(MessageProperty); }
+
+    /// <summary>
+    /// Sets the message.
+    /// </summary>
+    void SetMessage(const std::wstring& sValue) { SetValue(MessageProperty, sValue); }
+
+    /// <summary>
+    /// The <see cref="ModelProperty" /> for the current progress.
+    /// </summary>
+    static const IntModelProperty ProgressProperty;
+
+    /// <summary>
+    /// Gets the current progress (0-100).
+    /// </summary>
+    const int GetProgress() const { return GetValue(ProgressProperty); }
+
+    void QueueTask(std::function<void()>&& fTaskHandler);
+
+    const size_t TaskCount() const noexcept { return m_vTasks.size(); }
+
+protected:
+    void OnValueChanged(const BoolModelProperty::ChangeArgs& args) override;
+
+    virtual void OnBegin() noexcept(false) {}
+    virtual void OnComplete();
+
+private:
+    void BeginTasks();
+    void ProcessQueue();
+
+    enum class TaskState
+    {
+        None = 0,
+        Running,
+        Done
+    };
+
+    struct TaskItem
+    {
+        std::function<void()> fTaskHandler;
+        TaskState nState = TaskState::None;
+    };
+
+    std::vector<std::unique_ptr<TaskItem>> m_vTasks;
+    std::mutex m_pMutex;
+    bool m_bQueueComplete = false;
+};
+
+} // namespace viewmodels
+} // namespace ui
+} // namespace ra
+
+#endif RA_UI_PROGRESS_VIEW_MODEL_H
diff --git a/src/ui/win32/Desktop.cpp b/src/ui/win32/Desktop.cpp
index f90f5ad1..b40374c2 100644
--- a/src/ui/win32/Desktop.cpp
+++ b/src/ui/win32/Desktop.cpp
@@ -17,6 +17,7 @@
 #include "ui/win32/MemoryBookmarksDialog.hh"
 #include "ui/win32/MemoryInspectorDialog.hh"
 #include "ui/win32/OverlaySettingsDialog.hh"
+#include "ui/win32/ProgressDialog.hh"
 #include "ui/win32/RichPresenceDialog.hh"
 #include "ui/win32/UnknownGameDialog.hh"
 
@@ -46,6 +47,7 @@ Desktop::Desktop() noexcept
     m_vDialogPresenters.emplace_back(new (std::nothrow) OverlaySettingsDialog::Presenter);
     m_vDialogPresenters.emplace_back(new (std::nothrow) BrokenAchievementsDialog::Presenter);
     m_vDialogPresenters.emplace_back(new (std::nothrow) UnknownGameDialog::Presenter);
+    m_vDialogPresenters.emplace_back(new (std::nothrow) ProgressDialog::Presenter);
 
     ra::ui::win32::bindings::MemoryViewerControlBinding::RegisterControlClass();
 }
@@ -158,7 +160,7 @@ void Desktop::SetMainHWnd(HWND hWnd)
         m_pWindowBinding = std::make_unique<ra::ui::win32::bindings::WindowBinding>(pWindowManager.Emulator);
     }
 
-    m_pWindowBinding->SetHWND(hWnd);
+    m_pWindowBinding->SetHWND(nullptr, hWnd);
 
     g_RAMainWnd = hWnd;
 }
diff --git a/src/ui/win32/DialogBase.cpp b/src/ui/win32/DialogBase.cpp
index 8ffc9be6..59adda10 100644
--- a/src/ui/win32/DialogBase.cpp
+++ b/src/ui/win32/DialogBase.cpp
@@ -119,7 +119,10 @@ INT_PTR CALLBACK DialogBase::DialogProc(HWND hDlg, UINT uMsg, WPARAM wParam, LPA
                 // binding the window will resize it, make sure to capture sizes beforehand
                 InitializeAnchors();
 
-                m_bindWindow.SetHWND(m_hWnd);
+                // make sure to reset the DialogResult if a viewmodel is reshown
+                m_vmWindow.SetDialogResult(DialogResult::None);
+
+                m_bindWindow.SetHWND(this, m_hWnd);
             }
 
             return OnInitDialog();
@@ -444,7 +447,7 @@ void DialogBase::OnShown()
 
 void DialogBase::OnDestroy()
 {
-    m_bindWindow.SetHWND(nullptr);
+    m_bindWindow.SetHWND(nullptr, nullptr);
     m_hWnd = nullptr;
 
     auto* pClosableDialog = dynamic_cast<IClosableDialogPresenter*>(m_pDialogPresenter);
@@ -479,10 +482,17 @@ void DialogBase::SetDialogResult(DialogResult nResult)
 {
     m_vmWindow.SetDialogResult(nResult);
 
-    if (m_bModal)
-        EndDialog(m_hWnd, 0); // DialogBox call in CreateModalWindow() ignores return value
-    else
-        DestroyWindow(m_hWnd);
+    // make sure the dialog is closed on the UI thread
+    QueueFunction([this]() noexcept
+    {
+        if (::IsWindowVisible(m_hWnd))
+        {
+            if (m_bModal)
+                EndDialog(m_hWnd, 0); // DialogBox call in CreateModalWindow() ignores return value
+            else
+                DestroyWindow(m_hWnd);
+        }
+    });
 }
 
 _Use_decl_annotations_
diff --git a/src/ui/win32/DialogBase.hh b/src/ui/win32/DialogBase.hh
index 2044de00..9196ecf7 100644
--- a/src/ui/win32/DialogBase.hh
+++ b/src/ui/win32/DialogBase.hh
@@ -59,6 +59,11 @@ public:
         return true;
     }
 
+    /// <summary>
+    /// Sets the specified <see cref="DialogResult"/> for the view model and closes the window.
+    /// </summary>
+    void SetDialogResult(DialogResult nResult);
+
 protected:
     explicit DialogBase(_Inout_ ra::ui::WindowViewModelBase& vmWindow) noexcept;
     ~DialogBase() noexcept;
@@ -106,11 +111,6 @@ protected:
     /// <param name="oNewPosition">The new size of the client area.</param>
     virtual void OnSize(_In_ const ra::ui::Size& oNewSize);
 
-    /// <summary>
-    /// Sets the specified <see cref="DialogResult"/> for the view model and closes the window.
-    /// </summary>
-    void SetDialogResult(DialogResult nResult);
-
     ra::ui::win32::bindings::WindowBinding m_bindWindow;
 
     ra::ui::WindowViewModelBase& m_vmWindow;
diff --git a/src/ui/win32/ProgressDialog.cpp b/src/ui/win32/ProgressDialog.cpp
new file mode 100644
index 00000000..e74f5961
--- /dev/null
+++ b/src/ui/win32/ProgressDialog.cpp
@@ -0,0 +1,80 @@
+#include "ProgressDialog.hh"
+
+#include "RA_Log.h"
+#include "RA_Resource.h"
+
+using ra::ui::viewmodels::ProgressViewModel;
+
+namespace ra {
+namespace ui {
+namespace win32 {
+
+bool ProgressDialog::Presenter::IsSupported(const ra::ui::WindowViewModelBase& oViewModel) noexcept
+{
+    return (dynamic_cast<const ProgressViewModel*>(&oViewModel) != nullptr);
+}
+
+void ProgressDialog::Presenter::ShowModal(ra::ui::WindowViewModelBase& vmViewModel, HWND hParentWnd)
+{
+    auto* vmUnknownGame = dynamic_cast<ra::ui::viewmodels::ProgressViewModel*>(&vmViewModel);
+    Expects(vmUnknownGame != nullptr);
+
+    ProgressDialog oDialog(*vmUnknownGame);
+    oDialog.CreateModalWindow(MAKEINTRESOURCE(IDD_RA_PROGRESS), this, hParentWnd);
+}
+
+void ProgressDialog::Presenter::ShowWindow(ra::ui::WindowViewModelBase& vmViewModel)
+{
+    auto* vmProgress = dynamic_cast<ProgressViewModel*>(&vmViewModel);
+    Expects(vmProgress != nullptr);
+
+    if (m_pDialog == nullptr)
+    {
+        m_pDialog.reset(new ProgressDialog(*vmProgress));
+        if (!m_pDialog->CreateDialogWindow(MAKEINTRESOURCE(IDD_RA_PROGRESS), this))
+            RA_LOG_ERR("Could not create Progress dialog!");
+    }
+
+    m_pDialog->ShowDialogWindow();
+}
+
+void ProgressDialog::Presenter::OnClosed() noexcept { m_pDialog.reset(); }
+
+// ------------------------------------
+
+ProgressDialog::ProgressDialog(ProgressViewModel& vmProgress)
+    : DialogBase(vmProgress),
+      m_bindProgress(vmProgress)
+{
+    m_bindWindow.BindLabel(IDC_RA_MESSAGE, ProgressViewModel::MessageProperty);
+
+    m_bindProgress.BindProgress(ProgressViewModel::ProgressProperty);
+}
+
+BOOL ProgressDialog::OnInitDialog()
+{
+    m_bindProgress.SetControl(*this, IDC_RA_PROGRESSBAR);
+
+    return DialogBase::OnInitDialog();
+}
+
+BOOL ProgressDialog::OnCommand(WORD nCommand)
+{
+    switch (nCommand)
+    {
+        case IDC_RA_ADDCOND:
+        {
+            auto* vmProgress = dynamic_cast<ProgressViewModel*>(&m_vmWindow);
+            if (vmProgress)
+                vmProgress->SetDialogResult(ra::ui::DialogResult::Cancel);
+
+            return TRUE;
+        }
+    }
+
+    return DialogBase::OnCommand(nCommand);
+}
+
+} // namespace win32
+} // namespace ui
+} // namespace ra
diff --git a/src/ui/win32/ProgressDialog.hh b/src/ui/win32/ProgressDialog.hh
new file mode 100644
index 00000000..bb8a9757
--- /dev/null
+++ b/src/ui/win32/ProgressDialog.hh
@@ -0,0 +1,50 @@
+#ifndef RA_UI_WIN32_DLG_PROGRESS_H
+#define RA_UI_WIN32_DLG_PROGRESS_H
+#pragma once
+
+#include "ui/viewmodels/ProgressViewModel.hh"
+
+#include "ui/win32/DialogBase.hh"
+#include "ui/win32/IDialogPresenter.hh"
+
+#include "ui/win32/bindings/ProgressBarBinding.hh"
+
+namespace ra {
+namespace ui {
+namespace win32 {
+
+class ProgressDialog : public DialogBase
+{
+public:
+    explicit ProgressDialog(ra::ui::viewmodels::ProgressViewModel& vmProgress);
+    virtual ~ProgressDialog() noexcept = default;
+    ProgressDialog(const ProgressDialog&) noexcept = delete;
+    ProgressDialog& operator=(const ProgressDialog&) noexcept = delete;
+    ProgressDialog(ProgressDialog&&) noexcept = delete;
+    ProgressDialog& operator=(ProgressDialog&&) noexcept = delete;
+
+    class Presenter : public IClosableDialogPresenter
+    {
+    public:
+        bool IsSupported(const ra::ui::WindowViewModelBase& viewModel) noexcept override;
+        void ShowWindow(ra::ui::WindowViewModelBase& viewModel) override;
+        void ShowModal(ra::ui::WindowViewModelBase& viewModel, HWND hParentWnd) override;
+        void OnClosed() noexcept override;
+
+    private:
+        std::unique_ptr<ProgressDialog> m_pDialog;
+    };
+
+protected:
+    BOOL OnInitDialog() override;
+    BOOL OnCommand(WORD nCommand) override;
+
+private:
+    ra::ui::win32::bindings::ProgressBarBinding m_bindProgress;
+};
+
+} // namespace win32
+} // namespace ui
+} // namespace ra
+
+#endif // !RA_UI_WIN32_DLG_PROGRESS_H
diff --git a/src/ui/win32/bindings/GridBinding.cpp b/src/ui/win32/bindings/GridBinding.cpp
index 16dc74a3..400a8810 100644
--- a/src/ui/win32/bindings/GridBinding.cpp
+++ b/src/ui/win32/bindings/GridBinding.cpp
@@ -155,10 +155,8 @@ void GridBinding::UpdateLayout()
                 break;
         }
 
-        if (i < ra::to_signed(nColumns))
-            ListView_SetColumn(m_hWnd, i, &col);
-        else
-            ListView_InsertColumn(m_hWnd, i, &col);
+        GSL_SUPPRESS_TYPE1
+        SNDMSG(m_hWnd, (i < ra::to_signed(nColumns)) ? LVM_SETCOLUMNW : LVM_INSERTCOLUMNW, i, reinterpret_cast<LPARAM>(&col));
     }
 
     // remove any extra columns
diff --git a/src/ui/win32/bindings/ProgressBarBinding.hh b/src/ui/win32/bindings/ProgressBarBinding.hh
new file mode 100644
index 00000000..e81a0ac4
--- /dev/null
+++ b/src/ui/win32/bindings/ProgressBarBinding.hh
@@ -0,0 +1,52 @@
+#ifndef RA_UI_WIN32_PROGRESSBARBINDING_H
+#define RA_UI_WIN32_PROGRESSBARBINDING_H
+#pragma once
+
+#include "ControlBinding.hh"
+
+namespace ra {
+namespace ui {
+namespace win32 {
+namespace bindings {
+
+class ProgressBarBinding : public ControlBinding
+{
+public:
+    explicit ProgressBarBinding(ViewModelBase& vmViewModel) noexcept : ControlBinding(vmViewModel) {}
+
+    void SetHWND(DialogBase& pDialog, HWND hControl) override
+    {
+        ControlBinding::SetHWND(pDialog, hControl);
+
+        if (m_pProgressProperty)
+            ::SendMessage(hControl, PBM_SETPOS, GetValue(*m_pProgressProperty), 0);
+    }
+
+    void BindProgress(const IntModelProperty& pSourceProperty)
+    {
+        m_pProgressProperty = &pSourceProperty;
+
+        if (m_hWnd)
+            ::SendMessage(m_hWnd, PBM_SETPOS, GetValue(*m_pProgressProperty), 0);
+    }
+
+protected:
+    void OnViewModelIntValueChanged(const IntModelProperty::ChangeArgs& args) override
+    {
+        if (m_pProgressProperty && *m_pProgressProperty == args.Property)
+        {
+            ::SendMessage(m_hWnd, PBM_SETPOS, GetValue(*m_pProgressProperty), 0);
+            OnValueChanged();
+        }
+    }
+
+private:
+    const IntModelProperty* m_pProgressProperty = nullptr;
+};
+
+} // namespace bindings
+} // namespace win32
+} // namespace ui
+} // namespace ra
+
+#endif // !RA_UI_WIN32_PROGRESSBARBINDING_H
diff --git a/src/ui/win32/bindings/WindowBinding.cpp b/src/ui/win32/bindings/WindowBinding.cpp
index f46d470d..85431d74 100644
--- a/src/ui/win32/bindings/WindowBinding.cpp
+++ b/src/ui/win32/bindings/WindowBinding.cpp
@@ -18,8 +18,9 @@ namespace bindings {
 
 std::vector<WindowBinding*> WindowBinding::s_vKnownBindings;
 
-void WindowBinding::SetHWND(HWND hWnd)
+void WindowBinding::SetHWND(DialogBase* pDialog, HWND hWnd)
 {
+    m_pDialog = pDialog;
     m_hWnd = hWnd;
 
     if (m_hWnd)
@@ -290,6 +291,9 @@ void WindowBinding::OnViewModelIntValueChanged(const IntModelProperty::ChangeArg
         const auto sText = std::to_wstring(args.tNewValue);
         SetDlgItemTextW(m_hWnd, pIter->second, sText.c_str());
     }
+
+    if (m_pDialog && args.Property == WindowViewModelBase::DialogResultProperty)
+        m_pDialog->SetDialogResult(ra::itoe<DialogResult>(args.tNewValue));
 }
 
 void WindowBinding::BindLabel(int nDlgItemId, const IntModelProperty& pSourceProperty)
diff --git a/src/ui/win32/bindings/WindowBinding.hh b/src/ui/win32/bindings/WindowBinding.hh
index 825b8ad5..b5da620e 100644
--- a/src/ui/win32/bindings/WindowBinding.hh
+++ b/src/ui/win32/bindings/WindowBinding.hh
@@ -8,6 +8,9 @@
 namespace ra {
 namespace ui {
 namespace win32 {
+
+class DialogBase;
+
 namespace bindings {
 
 class WindowBinding : protected BindingBase
@@ -63,8 +66,9 @@ public:
     /// <summary>
     /// Associates the <see cref="HWND" /> of a window for binding.
     /// </summary>
+    /// <param name="pDialog">The dialog containing the binding.</param>
     /// <param name="hWnd">The window handle.</param>
-    void SetHWND(HWND hWnd);
+    void SetHWND(ra::ui::win32::DialogBase* pDialog, HWND hWnd);
     
     /// <summary>
     /// Gets the HWND bound to the window.
@@ -174,6 +178,7 @@ private:
     RelativePosition m_nDefaultHorizontalLocation{ RelativePosition::None };
     RelativePosition m_nDefaultVerticalLocation{ RelativePosition::None };
     
+    ra::ui::win32::DialogBase* m_pDialog = nullptr;
     HWND m_hWnd{};
 
     static std::vector<WindowBinding*> s_vKnownBindings;


Test patch:
diff --git a/tests/RA_Integration.Tests.vcxproj b/tests/RA_Integration.Tests.vcxproj
index 4c71160b..286985d2 100644
--- a/tests/RA_Integration.Tests.vcxproj
+++ b/tests/RA_Integration.Tests.vcxproj
@@ -324,6 +324,7 @@
     <ClCompile Include="..\src\ui\ViewModelCollection.cpp" />
     <ClCompile Include="..\src\ui\viewmodels\AssetEditorViewModel.cpp" />
     <ClCompile Include="..\src\ui\viewmodels\AssetListViewModel.cpp" />
+    <ClCompile Include="..\src\ui\viewmodels\AssetUploadViewModel.cpp" />
     <ClCompile Include="..\src\ui\viewmodels\BrokenAchievementsViewModel.cpp" />
     <ClCompile Include="..\src\ui\viewmodels\CodeNotesViewModel.cpp" />
     <ClCompile Include="..\src\ui\viewmodels\FileDialogViewModel.cpp" />
@@ -343,6 +344,7 @@
     <ClCompile Include="..\src\ui\viewmodels\PopupMessageViewModel.cpp" />
     <ClCompile Include="..\src\ui\viewmodels\LoginViewModel.cpp" />
     <ClCompile Include="..\src\ui\viewmodels\PopupViewModelBase.cpp" />
+    <ClCompile Include="..\src\ui\viewmodels\ProgressViewModel.cpp" />
     <ClCompile Include="..\src\ui\viewmodels\ScoreboardViewModel.cpp" />
     <ClCompile Include="..\src\ui\viewmodels\ScoreTrackerViewModel.cpp" />
     <ClCompile Include="..\src\ui\viewmodels\TriggerConditionViewModel.cpp" />
@@ -388,6 +390,7 @@
     <ClCompile Include="ui\ViewModelCollection_Tests.cpp" />
     <ClCompile Include="ui\viewmodels\AssetEditorViewModel_Tests.cpp" />
     <ClCompile Include="ui\viewmodels\AssetListViewModel_Tests.cpp" />
+    <ClCompile Include="ui\viewmodels\AssetUploadViewModel_Tests.cpp" />
     <ClCompile Include="ui\viewmodels\BrokenAchievementsViewModel_Tests.cpp" />
     <ClCompile Include="ui\viewmodels\CodeNotesViewModel_Tests.cpp" />
     <ClCompile Include="ui\viewmodels\GameChecksumViewModel_Tests.cpp" />
@@ -404,6 +407,7 @@
     <ClCompile Include="ui\viewmodels\OverlayManager_Tests.cpp" />
     <ClCompile Include="ui\viewmodels\OverlayRecentGamesPageViewModel_Tests.cpp" />
     <ClCompile Include="ui\viewmodels\OverlaySettingsViewModel_Tests.cpp" />
+    <ClCompile Include="ui\viewmodels\ProgressViewModel_Tests.cpp" />
     <ClCompile Include="ui\viewmodels\RichPresenceMonitorViewModel_Tests.cpp" />
     <ClCompile Include="ui\viewmodels\TriggerConditionViewModel_Tests.cpp" />
     <ClCompile Include="ui\viewmodels\TriggerViewModel_Tests.cpp" />
diff --git a/tests/RA_Integration.Tests.vcxproj.filters b/tests/RA_Integration.Tests.vcxproj.filters
index 3815694a..32a013be 100644
--- a/tests/RA_Integration.Tests.vcxproj.filters
+++ b/tests/RA_Integration.Tests.vcxproj.filters
@@ -411,6 +411,18 @@
     <ClCompile Include="data\context\GameAssets_Tests.cpp">
       <Filter>Tests\Data\Context</Filter>
     </ClCompile>
+    <ClCompile Include="..\src\ui\viewmodels\AssetUploadViewModel.cpp">
+      <Filter>Code</Filter>
+    </ClCompile>
+    <ClCompile Include="..\src\ui\viewmodels\ProgressViewModel.cpp">
+      <Filter>Code</Filter>
+    </ClCompile>
+    <ClCompile Include="ui\viewmodels\ProgressViewModel_Tests.cpp">
+      <Filter>Tests\UI\ViewModels</Filter>
+    </ClCompile>
+    <ClCompile Include="ui\viewmodels\AssetUploadViewModel_Tests.cpp">
+      <Filter>Tests\UI\ViewModels</Filter>
+    </ClCompile>
     <ClCompile Include="data\models\LocalBadgesModel_Tests.cpp">
       <Filter>Tests\Data\Models</Filter>
     </ClCompile>
diff --git a/tests/data/models/LocalBadgesModel_Tests.cpp b/tests/data/models/LocalBadgesModel_Tests.cpp
index 062d52a2..344934d1 100644
--- a/tests/data/models/LocalBadgesModel_Tests.cpp
+++ b/tests/data/models/LocalBadgesModel_Tests.cpp
@@ -9,6 +9,7 @@
 
 #include "tests\mocks\MockFileSystem.hh"
 #include "tests\mocks\MockGameContext.hh"
+#include "tests\mocks\MockImageRepository.hh"
 
 using namespace Microsoft::VisualStudio::CppUnitTestFramework;
 
@@ -27,6 +28,7 @@ private:
 
         ra::data::context::mocks::MockGameContext mockGameContext;
         ra::services::mocks::MockFileSystem mockFileSystem;
+        ra::ui::mocks::MockImageRepository mockImageRepository;
     };
 
 public:
@@ -142,13 +144,13 @@ public:
         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);
+        badges.mockFileSystem.MockFileSize(L"RACache\\Badges\\local\\123-A.png", 1234);
+        badges.mockFileSystem.MockFileSize(L"RACache\\Badges\\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.RemoveReference(L"local\\123-A.png", true);
         badges.AddReference(L"local\\123-C.png", false);
         badges.Commit(L"local\\123-A.png", L"local\\123-C.png");
 
@@ -156,8 +158,8 @@ public:
         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"));
+        Assert::AreEqual({ -1 }, badges.mockFileSystem.GetFileSize(L"RACache\\Badges\\local\\123-A.png"));
+        Assert::AreEqual({ 2345 }, badges.mockFileSystem.GetFileSize(L"RACache\\Badges\\local\\123-B.png"));
     }
 
     TEST_METHOD(TestDeserializePublished)
@@ -166,13 +168,13 @@ public:
         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);
+        badges.mockFileSystem.MockFileSize(L"RACache\\Badges\\local\\123-A.png", 1234);
+        badges.mockFileSystem.MockFileSize(L"RACache\\Badges\\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.RemoveReference(L"local\\123-A.png", true);
         badges.Commit(L"local\\123-A.png", L"22222");
 
         Assert::IsTrue(badges.NeedsSerialized());
@@ -180,8 +182,8 @@ public:
         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"));
+        Assert::AreEqual({ -1 }, badges.mockFileSystem.GetFileSize(L"RACache\\Badges\\local\\123-A.png"));
+        Assert::AreEqual({ 2345 }, badges.mockFileSystem.GetFileSize(L"RACache\\Badges\\local\\123-B.png"));
     }
 
     TEST_METHOD(TestDeserializeChangedStillReferenced)
@@ -190,13 +192,13 @@ public:
         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);
+        badges.mockFileSystem.MockFileSize(L"RACache\\Badges\\local\\123-A.png", 1234);
+        badges.mockFileSystem.MockFileSize(L"RACache\\Badges\\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.RemoveReference(L"local\\123-A.png", true);
         badges.AddReference(L"local\\123-C.png", false);
         badges.Commit(L"local\\123-A.png", L"local\\123-C.png");
 
@@ -204,8 +206,8 @@ public:
         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"));
+        Assert::AreEqual({ 1234 }, badges.mockFileSystem.GetFileSize(L"RACache\\Badges\\local\\123-A.png"));
+        Assert::AreEqual({ 2345 }, badges.mockFileSystem.GetFileSize(L"RACache\\Badges\\local\\123-B.png"));
     }
 
     TEST_METHOD(TestDeserializeChangedNotCommitted)
diff --git a/tests/mocks/MockImageRepository.hh b/tests/mocks/MockImageRepository.hh
index 48052b3f..e10573e4 100644
--- a/tests/mocks/MockImageRepository.hh
+++ b/tests/mocks/MockImageRepository.hh
@@ -49,6 +49,11 @@ public:
         return ra::Narrow(sPath);
     }
 
+    std::wstring GetFilename(_UNUSED ImageType nType, const std::string& sName) const override
+    {
+        return L"RACache\\Badges\\" + ra::Widen(sName);
+    }
+
     void AddReference(_UNUSED const ImageReference& pImage) noexcept override
     {
 
diff --git a/tests/ui/viewmodels/AssetListViewModel_Tests.cpp b/tests/ui/viewmodels/AssetListViewModel_Tests.cpp
index 123935ac..dbba0653 100644
--- a/tests/ui/viewmodels/AssetListViewModel_Tests.cpp
+++ b/tests/ui/viewmodels/AssetListViewModel_Tests.cpp
@@ -98,22 +98,22 @@ private:
             switch (nActivateButtonState)
             {
                 case ActivateButtonState::Activate:
-                    SetValue(ActivateButtonTextProperty, L"&Activate");
+                    Assert::AreEqual(std::wstring(L"&Activate"), GetValue(ActivateButtonTextProperty));
                     Assert::IsTrue(CanActivate());
                     break;
 
                 case ActivateButtonState::Deactivate:
-                    SetValue(ActivateButtonTextProperty, L"De&activate");
+                    Assert::AreEqual(std::wstring(L"De&activate"), GetValue(ActivateButtonTextProperty));
                     Assert::IsTrue(CanActivate());
                     break;
 
                 case ActivateButtonState::ActivateAll:
-                    SetValue(ActivateButtonTextProperty, L"&Activate All");
+                    Assert::AreEqual(std::wstring(L"&Activate All"), GetValue(ActivateButtonTextProperty));
                     Assert::IsTrue(CanActivate());
                     break;
 
                 case ActivateButtonState::Disabled:
-                    SetValue(ActivateButtonTextProperty, L"&Activate All");
+                    Assert::AreEqual(std::wstring(L"&Activate All"), GetValue(ActivateButtonTextProperty));
                     Assert::IsFalse(CanActivate());
                     break;
             }
@@ -124,32 +124,32 @@ private:
             switch (nSaveButtonState)
             {
                 case SaveButtonState::Save:
-                    SetValue(ActivateButtonTextProperty, L"&Save");
+                    Assert::AreEqual(std::wstring(L"&Save"), GetValue(SaveButtonTextProperty));
                     Assert::IsTrue(CanSave());
                     break;
 
                 case SaveButtonState::Publish:
-                    SetValue(ActivateButtonTextProperty, L"Publi&sh");
+                    Assert::AreEqual(std::wstring(L"Publi&sh"), GetValue(SaveButtonTextProperty));
                     Assert::IsTrue(CanSave());
                     break;
 
                 case SaveButtonState::SaveDisabled:
-                    SetValue(ActivateButtonTextProperty, L"&Save");
+                    Assert::AreEqual(std::wstring(L"&Save"), GetValue(SaveButtonTextProperty));
                     Assert::IsFalse(CanSave());
                     break;
 
                 case SaveButtonState::SaveAll:
-                    SetValue(ActivateButtonTextProperty, L"&Save All");
+                    Assert::AreEqual(std::wstring(L"&Save All"), GetValue(SaveButtonTextProperty));
                     Assert::IsTrue(CanSave());
                     break;
 
                 case SaveButtonState::PublishAll:
-                    SetValue(ActivateButtonTextProperty, L"Publi&sh All");
+                    Assert::AreEqual(std::wstring(L"Publi&sh All"), GetValue(SaveButtonTextProperty));
                     Assert::IsTrue(CanSave());
                     break;
 
                 case SaveButtonState::SaveAllDisabled:
-                    SetValue(ActivateButtonTextProperty, L"&Save All");
+                    Assert::AreEqual(std::wstring(L"&Save All"), GetValue(SaveButtonTextProperty));
                     Assert::IsFalse(CanSave());
                     break;
             }
@@ -160,17 +160,17 @@ private:
             switch (nResetButtonState)
             {
                 case ResetButtonState::Reset:
-                    SetValue(ActivateButtonTextProperty, L"&Reset");
+                    Assert::AreEqual(std::wstring(L"&Reset"), GetValue(ResetButtonTextProperty));
                     Assert::IsTrue(CanReset());
                     break;
 
                 case ResetButtonState::ResetAll:
-                    SetValue(ActivateButtonTextProperty, L"&Reset All");
+                    Assert::AreEqual(std::wstring(L"&Reset All"), GetValue(ResetButtonTextProperty));
                     Assert::IsTrue(CanReset());
                     break;
 
                 case ResetButtonState::Disabled:
-                    SetValue(ActivateButtonTextProperty, L"&Reset All");
+                    Assert::AreEqual(std::wstring(L"&Reset All"), GetValue(ResetButtonTextProperty));
                     Assert::IsFalse(CanReset());
                     break;
             }
@@ -181,22 +181,22 @@ private:
             switch (nRevertButtonState)
             {
                 case RevertButtonState::Revert:
-                    SetValue(ActivateButtonTextProperty, L"Re&vert");
+                    Assert::AreEqual(std::wstring(L"Re&vert"), GetValue(RevertButtonTextProperty));
                     Assert::IsTrue(CanRevert());
                     break;
 
                 case RevertButtonState::RevertAll:
-                    SetValue(ActivateButtonTextProperty, L"Re&vert All");
+                    Assert::AreEqual(std::wstring(L"Re&vert All"), GetValue(RevertButtonTextProperty));
                     Assert::IsTrue(CanRevert());
                     break;
 
                 case RevertButtonState::Delete:
-                    SetValue(ActivateButtonTextProperty, L"&Delete");
+                    Assert::AreEqual(std::wstring(L"&Delete"), GetValue(RevertButtonTextProperty));
                     Assert::IsTrue(CanRevert());
                     break;
 
                 case RevertButtonState::Disabled:
-                    SetValue(ActivateButtonTextProperty, L"Re&vert All");
+                    Assert::AreEqual(std::wstring(L"Re&vert All"), GetValue(RevertButtonTextProperty));
                     Assert::IsFalse(CanRevert());
                     break;
             }
@@ -320,6 +320,19 @@ private:
         {
             MockUserFile("0.0.0.0\nGameName\n" + sContents);
         }
+
+        void Publish(std::vector<ra::data::models::AssetModelBase*>& vAssets) override
+        {
+            PublishedAssets = vAssets;
+
+            for (auto* pAsset : vAssets)
+            {
+                Expects(pAsset != nullptr);
+                pAsset->UpdateServerCheckpoint();
+            }
+        }
+
+        std::vector<ra::data::models::AssetModelBase*> PublishedAssets;
     };
 
 public:
@@ -787,7 +800,7 @@ public:
         vmAssetList.ForceUpdateButtons();
 
         vmAssetList.AssertButtonState(
-            ActivateButtonState::Activate, SaveButtonState::SaveAllDisabled,
+            ActivateButtonState::Activate, SaveButtonState::SaveDisabled,
             ResetButtonState::Reset, RevertButtonState::Revert,
             CreateButtonState::Enabled, CloneButtonState::Enabled
         );
@@ -809,7 +822,7 @@ public:
         vmAssetList.ForceUpdateButtons();
 
         vmAssetList.AssertButtonState(
-            ActivateButtonState::Deactivate, SaveButtonState::SaveAllDisabled,
+            ActivateButtonState::Deactivate, SaveButtonState::SaveDisabled,
             ResetButtonState::Reset, RevertButtonState::Revert,
             CreateButtonState::Enabled, CloneButtonState::Enabled
         );
@@ -833,7 +846,7 @@ public:
         vmAssetList.ForceUpdateButtons();
 
         vmAssetList.AssertButtonState(
-            ActivateButtonState::Activate, SaveButtonState::SaveAllDisabled,
+            ActivateButtonState::Activate, SaveButtonState::SaveDisabled,
             ResetButtonState::Reset, RevertButtonState::Revert,
             CreateButtonState::Enabled, CloneButtonState::Enabled
         );
@@ -864,7 +877,7 @@ public:
         vmAssetList.ForceUpdateButtons();
 
         vmAssetList.AssertButtonState(
-            ActivateButtonState::Deactivate, SaveButtonState::SaveAllDisabled,
+            ActivateButtonState::Deactivate, SaveButtonState::SaveDisabled,
             ResetButtonState::Reset, RevertButtonState::Revert,
             CreateButtonState::Enabled, CloneButtonState::Enabled
         );
@@ -873,7 +886,7 @@ public:
         vmAssetList.ForceUpdateButtons();
 
         vmAssetList.AssertButtonState(
-            ActivateButtonState::Activate, SaveButtonState::SaveAllDisabled,
+            ActivateButtonState::Activate, SaveButtonState::SaveDisabled,
             ResetButtonState::Reset, RevertButtonState::Revert,
             CreateButtonState::Enabled, CloneButtonState::Enabled
         );
@@ -882,7 +895,7 @@ public:
         vmAssetList.ForceUpdateButtons();
 
         vmAssetList.AssertButtonState(
-            ActivateButtonState::Activate, SaveButtonState::SaveAllDisabled,
+            ActivateButtonState::Activate, SaveButtonState::SaveDisabled,
             ResetButtonState::Reset, RevertButtonState::Revert,
             CreateButtonState::Enabled, CloneButtonState::Enabled
         );
@@ -914,7 +927,7 @@ public:
         vmAssetList.ForceUpdateButtons();
 
         vmAssetList.AssertButtonState(
-            ActivateButtonState::Deactivate, SaveButtonState::SaveAllDisabled,
+            ActivateButtonState::Deactivate, SaveButtonState::SaveDisabled,
             ResetButtonState::Reset, RevertButtonState::Revert,
             CreateButtonState::Enabled, CloneButtonState::Enabled
         );
@@ -966,7 +979,7 @@ public:
         vmAssetList.ForceUpdateButtons();
 
         vmAssetList.AssertButtonState(
-            ActivateButtonState::Deactivate, SaveButtonState::SaveAllDisabled,
+            ActivateButtonState::Deactivate, SaveButtonState::SaveDisabled,
             ResetButtonState::Reset, RevertButtonState::Revert,
             CreateButtonState::Enabled, CloneButtonState::Enabled
         );
@@ -1042,7 +1055,7 @@ public:
         vmAssetList.ForceUpdateButtons();
 
         vmAssetList.AssertButtonState(
-            ActivateButtonState::Activate, SaveButtonState::SaveAllDisabled,
+            ActivateButtonState::Activate, SaveButtonState::SaveDisabled,
             ResetButtonState::Reset, RevertButtonState::Revert,
             CreateButtonState::Enabled, CloneButtonState::Enabled
         );
@@ -1060,7 +1073,7 @@ public:
         vmAssetList.ForceUpdateButtons();
 
         vmAssetList.AssertButtonState(
-            ActivateButtonState::Activate, SaveButtonState::SaveAllDisabled,
+            ActivateButtonState::Activate, SaveButtonState::SaveDisabled,
             ResetButtonState::Reset, RevertButtonState::Revert,
             CreateButtonState::Enabled, CloneButtonState::Enabled
         );
@@ -1083,7 +1096,7 @@ public:
 
         vmAssetList.AssertButtonState(
             ActivateButtonState::Activate, SaveButtonState::Save,
-            ResetButtonState::Reset, RevertButtonState::Revert,
+            ResetButtonState::Reset, RevertButtonState::Delete,
             CreateButtonState::Enabled, CloneButtonState::Enabled
         );
     }
@@ -1128,7 +1141,7 @@ public:
         vmAssetList.ForceUpdateButtons();
 
         vmAssetList.AssertButtonState(
-            ActivateButtonState::Activate, SaveButtonState::SaveAllDisabled,
+            ActivateButtonState::Activate, SaveButtonState::SaveDisabled,
             ResetButtonState::Reset, RevertButtonState::Revert,
             CreateButtonState::Enabled, CloneButtonState::Enabled
         );
@@ -1148,7 +1161,7 @@ public:
         vmAssetList.ForceUpdateButtons();
 
         vmAssetList.AssertButtonState(
-            ActivateButtonState::Activate, SaveButtonState::SaveAllDisabled,
+            ActivateButtonState::Activate, SaveButtonState::SaveDisabled,
             ResetButtonState::Reset, RevertButtonState::Revert,
             CreateButtonState::Enabled, CloneButtonState::Enabled
         );
@@ -1168,7 +1181,7 @@ public:
         vmAssetList.ForceUpdateButtons();
 
         vmAssetList.AssertButtonState(
-            ActivateButtonState::Activate, SaveButtonState::SaveAllDisabled,
+            ActivateButtonState::Activate, SaveButtonState::SaveDisabled,
             ResetButtonState::Reset, RevertButtonState::Revert,
             CreateButtonState::Enabled, CloneButtonState::Enabled
         );
@@ -1390,6 +1403,224 @@ public:
         Assert::AreEqual(AssetChanges::None, pItem->GetChanges());
     }
 
+    TEST_METHOD(TestSaveSelectedPublishCoreModified)
+    {
+        AssetListViewModelHarness vmAssetList;
+        vmAssetList.MockGameId(22U);
+        vmAssetList.AddAchievement(AssetCategory::Core, 5, L"Test1", L"Desc1", L"12345", "0xH1234=1");
+
+        bool bDialogSeen = false;
+        vmAssetList.mockDesktop.ExpectWindow<ra::ui::viewmodels::MessageBoxViewModel>([&bDialogSeen](ra::ui::viewmodels::MessageBoxViewModel& vmMessageBox)
+        {
+            bDialogSeen = true;
+            Assert::AreEqual(std::wstring(L"Are you sure you want to publish 1 items?"), vmMessageBox.GetMessage());
+            Assert::AreEqual(ra::ui::viewmodels::MessageBoxViewModel::Buttons::YesNo, vmMessageBox.GetButtons());
+            return DialogResult::Yes;
+        });
+
+        auto* pItem = dynamic_cast<ra::data::models::AchievementModel*>(vmAssetList.mockGameContext.Assets().GetItemAt(0));
+        Expects(pItem != nullptr);
+        pItem->SetName(L"Test1b");
+        pItem->UpdateLocalCheckpoint();
+        Assert::AreEqual(AssetChanges::Unpublished, pItem->GetChanges());
+
+        vmAssetList.FilteredAssets().GetItemAt(0)->SetSelected(true);
+        vmAssetList.ForceUpdateButtons();
+        vmAssetList.AssertButtonState(SaveButtonState::Publish);
+        vmAssetList.SaveSelected();
+
+        Assert::IsTrue(bDialogSeen);
+
+        Assert::AreEqual({ 1U }, vmAssetList.PublishedAssets.size());
+        Assert::IsTrue(pItem == dynamic_cast<ra::data::models::AchievementModel*>(vmAssetList.PublishedAssets.at(0)));
+        Assert::AreEqual(AssetChanges::None, pItem->GetChanges());
+
+        vmAssetList.ForceUpdateButtons();
+        vmAssetList.AssertButtonState(SaveButtonState::SaveDisabled);
+    }
+
+    TEST_METHOD(TestSaveSelectedPublishCoreModifiedCancel)
+    {
+        AssetListViewModelHarness vmAssetList;
+        vmAssetList.MockGameId(22U);
+        vmAssetList.AddAchievement(AssetCategory::Core, 5, L"Test1", L"Desc1", L"12345", "0xH1234=1");
+
+        bool bDialogSeen = false;
+        vmAssetList.mockDesktop.ExpectWindow<ra::ui::viewmodels::MessageBoxViewModel>([&bDialogSeen](ra::ui::viewmodels::MessageBoxViewModel& vmMessageBox)
+        {
+            bDialogSeen = true;
+            Assert::AreEqual(std::wstring(L"Are you sure you want to publish 1 items?"), vmMessageBox.GetMessage());
+            Assert::AreEqual(ra::ui::viewmodels::MessageBoxViewModel::Buttons::YesNo, vmMessageBox.GetButtons());
+            return DialogResult::No;
+        });
+
+        auto* pItem = dynamic_cast<ra::data::models::AchievementModel*>(vmAssetList.mockGameContext.Assets().GetItemAt(0));
+        Expects(pItem != nullptr);
+        pItem->SetName(L"Test1b");
+        pItem->UpdateLocalCheckpoint();
+        Assert::AreEqual(AssetChanges::Unpublished, pItem->GetChanges());
+
+        vmAssetList.FilteredAssets().GetItemAt(0)->SetSelected(true);
+        vmAssetList.ForceUpdateButtons();
+        vmAssetList.AssertButtonState(SaveButtonState::Publish);
+        vmAssetList.SaveSelected();
+
+        Assert::IsTrue(bDialogSeen);
+
+        Assert::AreEqual({ 0U }, vmAssetList.PublishedAssets.size());
+        Assert::AreEqual(AssetChanges::Unpublished, pItem->GetChanges());
+
+        vmAssetList.ForceUpdateButtons();
+        vmAssetList.AssertButtonState(SaveButtonState::Publish);
+    }
+
+    TEST_METHOD(TestSaveSelectedPublishCoreAll)
+    {
+        AssetListViewModelHarness vmAssetList;
+        vmAssetList.MockGameId(22U);
+        vmAssetList.AddAchievement(AssetCategory::Core, 5, L"Test1", L"Desc1", L"12345", "0xH1234=1");
+        vmAssetList.AddAchievement(AssetCategory::Core, 5, L"Test2", L"Desc2", L"12345", "0xH1234=1");
+        vmAssetList.AddAchievement(AssetCategory::Core, 5, L"Test3", L"Desc3", L"12345", "0xH1234=1");
+
+        bool bDialogSeen = false;
+        vmAssetList.mockDesktop.ExpectWindow<ra::ui::viewmodels::MessageBoxViewModel>([&bDialogSeen](ra::ui::viewmodels::MessageBoxViewModel& vmMessageBox)
+        {
+            bDialogSeen = true;
+            Assert::AreEqual(std::wstring(L"Are you sure you want to publish 3 items?"), vmMessageBox.GetMessage());
+            Assert::AreEqual(ra::ui::viewmodels::MessageBoxViewModel::Buttons::YesNo, vmMessageBox.GetButtons());
+            return DialogResult::Yes;
+        });
+
+        auto* pItem1 = dynamic_cast<ra::data::models::AchievementModel*>(vmAssetList.mockGameContext.Assets().GetItemAt(0));
+        Expects(pItem1 != nullptr);
+        pItem1->SetName(L"Test1b");
+        pItem1->UpdateLocalCheckpoint();
+        Assert::AreEqual(AssetChanges::Unpublished, pItem1->GetChanges());
+
+        auto* pItem2 = dynamic_cast<ra::data::models::AchievementModel*>(vmAssetList.mockGameContext.Assets().GetItemAt(1));
+        Expects(pItem2 != nullptr);
+        pItem2->SetName(L"Test2b");
+        pItem2->UpdateLocalCheckpoint();
+        Assert::AreEqual(AssetChanges::Unpublished, pItem2->GetChanges());
+
+        auto* pItem3 = dynamic_cast<ra::data::models::AchievementModel*>(vmAssetList.mockGameContext.Assets().GetItemAt(2));
+        Expects(pItem3 != nullptr);
+        pItem3->SetName(L"Test3b");
+        pItem3->UpdateLocalCheckpoint();
+        Assert::AreEqual(AssetChanges::Unpublished, pItem3->GetChanges());
+
+        vmAssetList.ForceUpdateButtons();
+        vmAssetList.AssertButtonState(SaveButtonState::PublishAll);
+        vmAssetList.SaveSelected();
+
+        Assert::IsTrue(bDialogSeen);
+
+        Assert::AreEqual({ 3U }, vmAssetList.PublishedAssets.size());
+        Assert::IsTrue(pItem1 == dynamic_cast<ra::data::models::AchievementModel*>(vmAssetList.PublishedAssets.at(0)));
+        Assert::IsTrue(pItem2 == dynamic_cast<ra::data::models::AchievementModel*>(vmAssetList.PublishedAssets.at(1)));
+        Assert::IsTrue(pItem3 == dynamic_cast<ra::data::models::AchievementModel*>(vmAssetList.PublishedAssets.at(2)));
+        Assert::AreEqual(AssetChanges::None, pItem1->GetChanges());
+        Assert::AreEqual(AssetChanges::None, pItem2->GetChanges());
+        Assert::AreEqual(AssetChanges::None, pItem3->GetChanges());
+
+        vmAssetList.ForceUpdateButtons();
+        vmAssetList.AssertButtonState(SaveButtonState::SaveAllDisabled);
+    }
+
+    TEST_METHOD(TestSaveSelectedPublishCoreAllOnlyUnpublished)
+    {
+        AssetListViewModelHarness vmAssetList;
+        vmAssetList.MockGameId(22U);
+        vmAssetList.AddAchievement(AssetCategory::Core, 5, L"Test1", L"Desc1", L"12345", "0xH1234=1");
+        vmAssetList.AddAchievement(AssetCategory::Core, 5, L"Test2", L"Desc2", L"12345", "0xH1234=1");
+        vmAssetList.AddAchievement(AssetCategory::Core, 5, L"Test3", L"Desc3", L"12345", "0xH1234=1");
+
+        bool bDialogSeen = false;
+        vmAssetList.mockDesktop.ExpectWindow<ra::ui::viewmodels::MessageBoxViewModel>([&bDialogSeen](ra::ui::viewmodels::MessageBoxViewModel& vmMessageBox)
+        {
+            bDialogSeen = true;
+            Assert::AreEqual(std::wstring(L"Are you sure you want to publish 2 items?"), vmMessageBox.GetMessage());
+            Assert::AreEqual(ra::ui::viewmodels::MessageBoxViewModel::Buttons::YesNo, vmMessageBox.GetButtons());
+            return DialogResult::Yes;
+        });
+
+        auto* pItem1 = dynamic_cast<ra::data::models::AchievementModel*>(vmAssetList.mockGameContext.Assets().GetItemAt(0));
+        Expects(pItem1 != nullptr);
+        pItem1->SetName(L"Test1b");
+        pItem1->UpdateLocalCheckpoint();
+        Assert::AreEqual(AssetChanges::Unpublished, pItem1->GetChanges());
+
+        const auto* pItem2 = dynamic_cast<ra::data::models::AchievementModel*>(vmAssetList.mockGameContext.Assets().GetItemAt(1));
+        Expects(pItem2 != nullptr);
+        Assert::AreEqual(AssetChanges::None, pItem2->GetChanges());
+
+        auto* pItem3 = dynamic_cast<ra::data::models::AchievementModel*>(vmAssetList.mockGameContext.Assets().GetItemAt(2));
+        Expects(pItem3 != nullptr);
+        pItem3->SetName(L"Test3b");
+        pItem3->UpdateLocalCheckpoint();
+        Assert::AreEqual(AssetChanges::Unpublished, pItem3->GetChanges());
+
+        vmAssetList.ForceUpdateButtons();
+        vmAssetList.AssertButtonState(SaveButtonState::PublishAll);
+        vmAssetList.SaveSelected();
+
+        Assert::IsTrue(bDialogSeen);
+
+        Assert::AreEqual({ 2U }, vmAssetList.PublishedAssets.size());
+        Assert::IsTrue(pItem1 == dynamic_cast<ra::data::models::AchievementModel*>(vmAssetList.PublishedAssets.at(0)));
+        Assert::IsTrue(pItem3 == dynamic_cast<ra::data::models::AchievementModel*>(vmAssetList.PublishedAssets.at(1)));
+        Assert::AreEqual(AssetChanges::None, pItem1->GetChanges());
+        Assert::AreEqual(AssetChanges::None, pItem2->GetChanges());
+        Assert::AreEqual(AssetChanges::None, pItem3->GetChanges());
+
+        vmAssetList.ForceUpdateButtons();
+        vmAssetList.AssertButtonState(SaveButtonState::SaveAllDisabled);
+    }
+
+    TEST_METHOD(TestSaveSelectedPublishLocal)
+    {
+        AssetListViewModelHarness vmAssetList;
+        vmAssetList.MockGameId(22U);
+        vmAssetList.mockGameContext.SetGameTitle(L"GameName");
+        vmAssetList.SetFilterCategory(AssetCategory::Local);
+
+        vmAssetList.MockUserFileContents("111000001:\"0xH2345=0\":Test2:::::User:0:0:0:::00000\n");
+
+        std::vector<ra::data::models::AssetModelBase*> vEmptyList;
+        vmAssetList.mockGameContext.Assets().ReloadAssets(vEmptyList);
+
+        bool bDialogSeen = false;
+        vmAssetList.mockDesktop.ExpectWindow<ra::ui::viewmodels::MessageBoxViewModel>([&bDialogSeen](ra::ui::viewmodels::MessageBoxViewModel& vmMessageBox)
+        {
+            bDialogSeen = true;
+            Assert::AreEqual(std::wstring(L"Are you sure you want to publish 1 items?"), vmMessageBox.GetMessage());
+            Assert::AreEqual(ra::ui::viewmodels::MessageBoxViewModel::Buttons::YesNo, vmMessageBox.GetButtons());
+            return DialogResult::Yes;
+        });
+
+        const auto* pItem = dynamic_cast<ra::data::models::AchievementModel*>(vmAssetList.mockGameContext.Assets().GetItemAt(0));
+        Expects(pItem != nullptr);
+        Assert::AreEqual(AssetChanges::Unpublished, pItem->GetChanges());
+
+        vmAssetList.FilteredAssets().GetItemAt(0)->SetSelected(true);
+        vmAssetList.ForceUpdateButtons();
+        vmAssetList.AssertButtonState(SaveButtonState::Publish);
+        vmAssetList.SaveSelected();
+
+        Assert::IsTrue(bDialogSeen);
+
+        Assert::AreEqual({ 1U }, vmAssetList.PublishedAssets.size());
+        Assert::IsTrue(pItem == dynamic_cast<ra::data::models::AchievementModel*>(vmAssetList.PublishedAssets.at(0)));
+        Assert::AreEqual(AssetChanges::None, pItem->GetChanges());
+
+        vmAssetList.ForceUpdateButtons();
+        vmAssetList.AssertButtonState(SaveButtonState::SaveDisabled);
+
+        // local file should be updated without the local achievement
+        const auto& sText = vmAssetList.GetUserFile(L"22");
+        Assert::AreEqual(std::string("0.0.0.0\nGameName\n"), sText);
+    }
+
     TEST_METHOD(TestDoFrameUpdatesAssets)
     {
         AssetListViewModelHarness vmAssetList;
@@ -1603,7 +1834,7 @@ public:
         vmAssetList.MockGameId(22U);
         vmAssetList.AddThreeAchievements();
         vmAssetList.ForceUpdateButtons();
-        vmAssetList.AssertButtonState(ResetButtonState::Reset);
+        vmAssetList.AssertButtonState(ResetButtonState::ResetAll);
 
         bool bDialogShown = false;
         vmAssetList.mockDesktop.ExpectWindow<MessageBoxViewModel>([&bDialogShown](MessageBoxViewModel& vmMessageBox)
@@ -1781,13 +2012,13 @@ public:
         Assert::IsNull(vmAssetList.mockGameContext.Assets().FindAchievement({ 1U }));
     }
 
-    TEST_METHOD(TestResetSelectedNewFromFile)
+    TEST_METHOD(TestResetSelectedAllNewFromFile)
     {
         AssetListViewModelHarness vmAssetList;
         vmAssetList.MockGameId(22U);
         vmAssetList.AddThreeAchievements();
         vmAssetList.ForceUpdateButtons();
-        vmAssetList.AssertButtonState(ResetButtonState::Reset);
+        vmAssetList.AssertButtonState(ResetButtonState::ResetAll);
 
         vmAssetList.MockUserFileContents("111000001:\"0xH2345=0\":Test2:::::User:0:0:0:::00000\n");
 
diff --git a/tests/ui/viewmodels/AssetUploadViewModel_Tests.cpp b/tests/ui/viewmodels/AssetUploadViewModel_Tests.cpp
new file mode 100644
index 00000000..aec02b02
--- /dev/null
+++ b/tests/ui/viewmodels/AssetUploadViewModel_Tests.cpp
@@ -0,0 +1,784 @@
+#include "CppUnitTest.h"
+
+#include "ui\viewmodels\AssetUploadViewModel.hh"
+
+#include "data\context\GameAssets.hh"
+#include "data\models\AchievementModel.hh"
+
+#include "tests\data\DataAsserts.hh"
+#include "tests\ui\UIAsserts.hh"
+#include "tests\mocks\MockDesktop.hh"
+#include "tests\mocks\MockGameContext.hh"
+#include "tests\mocks\MockImageRepository.hh"
+#include "tests\mocks\MockServer.hh"
+#include "tests\mocks\MockThreadPool.hh"
+
+using namespace Microsoft::VisualStudio::CppUnitTestFramework;
+
+namespace ra {
+namespace ui {
+namespace viewmodels {
+namespace tests {
+
+using ra::data::models::AchievementModel;
+using ra::data::models::AssetCategory;
+using ra::data::models::AssetChanges;
+
+TEST_CLASS(AssetUploadViewModel_Tests)
+{
+private:
+    class AssetUploadViewModelHarness : public AssetUploadViewModel
+    {
+    public:
+        static constexpr unsigned GameId = 22U;
+
+        ra::api::mocks::MockServer mockServer;
+        ra::data::context::mocks::MockGameContext mockGameContext;
+        ra::services::mocks::MockThreadPool mockThreadPool;
+        ra::ui::mocks::MockImageRepository mockImageRepository;
+        ra::ui::mocks::MockDesktop mockDesktop;
+
+        void DoUpload(int nExpectedProgress = 100)
+        {
+            mockGameContext.SetGameId(GameId);
+
+            Assert::AreEqual(std::wstring(), GetMessage());
+            Assert::AreEqual(0, GetProgress());
+            Assert::AreEqual({ 0U }, mockThreadPool.PendingTasks());
+
+            // setting visible starts the task threads
+            SetIsVisible(true);
+            Assert::AreEqual(DialogResult::None, GetDialogResult());
+
+            Assert::AreEqual(ra::StringPrintf(L"Uploading %d items...", TaskCount()), GetMessage());
+            Assert::AreEqual(0, GetProgress());
+
+            // "run" the background thread - all pending tasks will be processed in one worker because the threads are virtual
+            Assert::AreNotEqual({ 0U }, mockThreadPool.PendingTasks());
+            mockThreadPool.ExecuteNextTask();
+
+            // clean up any other workers that were spawned
+            for (size_t i = mockThreadPool.PendingTasks(); i > 0; --i)
+                mockThreadPool.ExecuteNextTask();
+
+            // expect all tasks to have been processed
+            Assert::AreEqual(nExpectedProgress, GetProgress());
+
+            // dialog should automatically close once all tasks are completed
+            if (nExpectedProgress == 100)
+                Assert::AreEqual(DialogResult::OK, GetDialogResult());
+            else
+                Assert::AreEqual(DialogResult::Cancel, GetDialogResult());
+
+            // simulate the hiding - tasks should not restart
+            SetIsVisible(false);
+            Assert::AreEqual({ 0U }, mockThreadPool.PendingTasks());
+
+        }
+
+        AchievementModel& AddAchievement(AssetCategory nCategory, unsigned nPoints, const std::wstring& sTitle,
+            const std::wstring& sDescription, const std::wstring& sBadge, const std::string& sTrigger)
+        {
+            auto vmAchievement = std::make_unique<AchievementModel>();
+            if (nCategory == AssetCategory::Local)
+            {
+                vmAchievement->SetID(gsl::narrow_cast<unsigned>(m_pAssets.Count()) + ra::data::context::GameAssets::FirstLocalId);
+                vmAchievement->CreateServerCheckpoint();
+            }
+            else
+            {
+                vmAchievement->SetID(gsl::narrow_cast<unsigned>(m_pAssets.Count()) + 1);
+            }
+
+            vmAchievement->SetCategory(nCategory);
+            vmAchievement->SetDescription(sDescription);
+            vmAchievement->SetPoints(nPoints);
+            vmAchievement->SetBadge(sBadge);
+            vmAchievement->SetTrigger(sTrigger);
+
+            if (nCategory != AssetCategory::Local)
+                vmAchievement->CreateServerCheckpoint();
+
+            vmAchievement->SetName(sTitle);
+
+            vmAchievement->CreateLocalCheckpoint();
+
+            return dynamic_cast<AchievementModel&>(m_pAssets.Append(std::move(vmAchievement)));
+        }
+
+        void AssertSuccess(int nItems)
+        {
+            bool bDialogSeen = false;
+            mockDesktop.ExpectWindow<ra::ui::viewmodels::MessageBoxViewModel>([&bDialogSeen, nItems](ra::ui::viewmodels::MessageBoxViewModel& vmMessageBox)
+            {
+                bDialogSeen = true;
+
+                Assert::AreEqual(std::wstring(L"Publish succeeded."), vmMessageBox.GetHeader());
+
+                const auto sMessage = ra::StringPrintf(L"%d items successfully uploaded.", nItems);
+                Assert::AreEqual(sMessage, vmMessageBox.GetMessage());
+
+                return DialogResult::OK;
+            });
+
+            ShowResults();
+
+            Assert::IsTrue(bDialogSeen);
+        }
+
+        void AssertFailed(int nSuccessfulItems, int nFailedItems, const std::wstring& sFailedDetail)
+        {
+            bool bDialogSeen = false;
+            const auto sMessage = ra::StringPrintf(L"%d items successfully uploaded.\n\n%d items failed:\n%s", nSuccessfulItems, nFailedItems, sFailedDetail);
+
+            mockDesktop.ExpectWindow<ra::ui::viewmodels::MessageBoxViewModel>([&bDialogSeen, &sMessage](ra::ui::viewmodels::MessageBoxViewModel& vmMessageBox)
+            {
+                bDialogSeen = true;
+
+                Assert::AreEqual(std::wstring(L"Publish failed."), vmMessageBox.GetHeader());
+                Assert::AreEqual(sMessage, vmMessageBox.GetMessage());
+
+                return DialogResult::OK;
+            });
+
+            ShowResults();
+
+            Assert::IsTrue(bDialogSeen);
+        }
+
+        void AssertAbort(int nSuccessfulItems, int nFailedItems, const std::wstring& sFailedDetail)
+        {
+            bool bDialogSeen = false;
+            auto sMessage = ra::StringPrintf(L"%d items successfully uploaded.", nSuccessfulItems);
+            if (nFailedItems)
+                sMessage += ra::StringPrintf(L"\n\n%d items failed:\n%s", nFailedItems, sFailedDetail);
+
+            mockDesktop.ExpectWindow<ra::ui::viewmodels::MessageBoxViewModel>([&bDialogSeen, &sMessage](ra::ui::viewmodels::MessageBoxViewModel& vmMessageBox)
+            {
+                bDialogSeen = true;
+
+                Assert::AreEqual(std::wstring(L"Publish canceled."), vmMessageBox.GetHeader());
+                Assert::AreEqual(sMessage, vmMessageBox.GetMessage());
+
+                return DialogResult::OK;
+            });
+
+            ShowResults();
+
+            Assert::IsTrue(bDialogSeen);
+        }
+
+    private:
+        ra::data::context::GameAssets m_pAssets;
+    };
+
+public:
+    TEST_METHOD(TestSingleUnofficialAchievement)
+    {
+        AssetUploadViewModelHarness vmUpload;
+        auto& pAchievement = vmUpload.AddAchievement(AssetCategory::Unofficial, 5, L"Title1", L"Desc1", L"12345", "0xH1234=1");
+        Assert::AreEqual(AssetChanges::Unpublished, pAchievement.GetChanges());
+
+        vmUpload.QueueAsset(pAchievement);
+        Assert::AreEqual({ 1U }, vmUpload.TaskCount());
+
+        bool bApiCalled = false;
+        vmUpload.mockServer.HandleRequest<ra::api::UpdateAchievement>([&bApiCalled]
+                (const ra::api::UpdateAchievement::Request& pRequest, ra::api::UpdateAchievement::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.Trigger);
+            Assert::AreEqual(5U, pRequest.Points);
+            Assert::AreEqual(std::string("12345"), pRequest.Badge);
+            Assert::AreEqual(5U, pRequest.Category);
+            Assert::AreEqual(1U, pRequest.AchievementId);
+
+            pResponse.AchievementId = pRequest.AchievementId;
+            pResponse.Result = ra::api::ApiResult::Success;
+            return true;
+        });
+
+        vmUpload.DoUpload();
+
+        Assert::IsTrue(bApiCalled);
+        Assert::AreEqual(AssetChanges::None, pAchievement.GetChanges());
+
+        vmUpload.AssertSuccess(1);
+    }
+
+    TEST_METHOD(TestSingleCoreAchievement)
+    {
+        AssetUploadViewModelHarness vmUpload;
+        auto& pAchievement = vmUpload.AddAchievement(AssetCategory::Core, 5, L"Title1", L"Desc1", L"12345", "0xH1234=1");
+        Assert::AreEqual(AssetChanges::Unpublished, pAchievement.GetChanges());
+
+        vmUpload.QueueAsset(pAchievement);
+        Assert::AreEqual({ 1U }, vmUpload.TaskCount());
+
+        bool bApiCalled = false;
+        vmUpload.mockServer.HandleRequest<ra::api::UpdateAchievement>([&bApiCalled]
+                (const ra::api::UpdateAchievement::Request& pRequest, ra::api::UpdateAchievement::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.Trigger);
+            Assert::AreEqual(5U, pRequest.Points);
+            Assert::AreEqual(std::string("12345"), pRequest.Badge);
+            Assert::AreEqual(3U, pRequest.Category);
+            Assert::AreEqual(1U, pRequest.AchievementId);
+
+            pResponse.AchievementId = pRequest.AchievementId;
+            pResponse.Result = ra::api::ApiResult::Success;
+            return true;
+        });
+
+        vmUpload.DoUpload();
+
+        Assert::IsTrue(bApiCalled);
+        Assert::AreEqual(AssetChanges::None, pAchievement.GetChanges());
+
+        vmUpload.AssertSuccess(1);
+    }
+
+    TEST_METHOD(TestSingleLocalAchievement)
+    {
+        AssetUploadViewModelHarness vmUpload;
+        auto& pAchievement = vmUpload.AddAchievement(AssetCategory::Local, 5, L"Title1", L"Desc1", L"12345", "0xH1234=1");
+        Assert::AreEqual(AssetChanges::Unpublished, pAchievement.GetChanges());
+
+        vmUpload.QueueAsset(pAchievement);
+        Assert::AreEqual({ 1U }, vmUpload.TaskCount());
+
+        bool bApiCalled = false;
+        vmUpload.mockServer.HandleRequest<ra::api::UpdateAchievement>([&bApiCalled]
+                (const ra::api::UpdateAchievement::Request& pRequest, ra::api::UpdateAchievement::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.Trigger);
+            Assert::AreEqual(5U, pRequest.Points);
+            Assert::AreEqual(std::string("12345"), pRequest.Badge);
+            Assert::AreEqual(5U, pRequest.Category);
+            Assert::AreEqual(0U, pRequest.AchievementId);
+
+            pResponse.AchievementId = 7716U;
+            pResponse.Result = ra::api::ApiResult::Success;
+            return true;
+        });
+
+        vmUpload.DoUpload();
+
+        Assert::IsTrue(bApiCalled);
+
+        // published local achievement should be changed to unofficial and have it's ID updated
+        Assert::AreEqual(AssetCategory::Unofficial, pAchievement.GetCategory());
+        Assert::AreEqual(7716U, pAchievement.GetID());
+        Assert::AreEqual(AssetChanges::None, pAchievement.GetChanges());
+
+        vmUpload.AssertSuccess(1);
+    }
+
+    TEST_METHOD(TestSingleCoreAchievementError)
+    {
+        AssetUploadViewModelHarness vmUpload;
+        auto& pAchievement = vmUpload.AddAchievement(AssetCategory::Core, 5, L"Title1", L"Desc1", L"12345", "0xH1234=1");
+        Assert::AreEqual(AssetChanges::Unpublished, pAchievement.GetChanges());
+
+        vmUpload.QueueAsset(pAchievement);
+        Assert::AreEqual({ 1U }, vmUpload.TaskCount());
+
+        bool bApiCalled = false;
+        vmUpload.mockServer.HandleRequest<ra::api::UpdateAchievement>([&bApiCalled]
+                (const ra::api::UpdateAchievement::Request&, ra::api::UpdateAchievement::Response& pResponse)
+        {
+            bApiCalled = true;
+            pResponse.Result = ra::api::ApiResult::Error;
+            pResponse.ErrorMessage = "You must be a developer to modify values in Core!";
+            return true;
+        });
+
+        vmUpload.DoUpload();
+
+        Assert::IsTrue(bApiCalled);
+        Assert::AreEqual(AssetChanges::Unpublished, pAchievement.GetChanges());
+
+        vmUpload.AssertFailed(0, 1, L"* Title1: You must be a developer to modify values in Core!");
+    }
+
+    TEST_METHOD(TestMultipleCoreAchievements)
+    {
+        AssetUploadViewModelHarness vmUpload;
+        auto& pAchievement1 = vmUpload.AddAchievement(AssetCategory::Core, 5, L"Title1", L"Desc1", L"12345", "0xH1234=1");
+        auto& pAchievement2 = vmUpload.AddAchievement(AssetCategory::Core, 5, L"Title2", L"Desc2", L"12345", "0xH1234=1");
+        auto& pAchievement3 = vmUpload.AddAchievement(AssetCategory::Core, 5, L"Title3", L"Desc3", L"12345", "0xH1234=1");
+        auto& pAchievement4 = vmUpload.AddAchievement(AssetCategory::Core, 5, L"Title4", L"Desc4", L"12345", "0xH1234=1");
+        auto& pAchievement5 = vmUpload.AddAchievement(AssetCategory::Core, 5, L"Title5", L"Desc5", L"12345", "0xH1234=1");
+        auto& pAchievement6 = vmUpload.AddAchievement(AssetCategory::Core, 5, L"Title6", L"Desc6", L"12345", "0xH1234=1");
+        Assert::AreEqual(AssetChanges::Unpublished, pAchievement1.GetChanges());
+        Assert::AreEqual(AssetChanges::Unpublished, pAchievement2.GetChanges());
+        Assert::AreEqual(AssetChanges::Unpublished, pAchievement3.GetChanges());
+        Assert::AreEqual(AssetChanges::Unpublished, pAchievement4.GetChanges());
+        Assert::AreEqual(AssetChanges::Unpublished, pAchievement5.GetChanges());
+        Assert::AreEqual(AssetChanges::Unpublished, pAchievement6.GetChanges());
+
+        vmUpload.QueueAsset(pAchievement1);
+        vmUpload.QueueAsset(pAchievement2);
+        vmUpload.QueueAsset(pAchievement3);
+        vmUpload.QueueAsset(pAchievement4);
+        vmUpload.QueueAsset(pAchievement5);
+        vmUpload.QueueAsset(pAchievement6);
+        Assert::AreEqual({ 6U }, vmUpload.TaskCount());
+
+        int nApiCount = 0;
+        vmUpload.mockServer.HandleRequest<ra::api::UpdateAchievement>([&nApiCount]
+                (const ra::api::UpdateAchievement::Request& pRequest, ra::api::UpdateAchievement::Response& pResponse)
+        {
+            ++nApiCount;
+            Assert::AreEqual(AssetUploadViewModelHarness::GameId, pRequest.GameId);
+            Assert::AreEqual(3U, pRequest.Category);
+
+            pResponse.AchievementId = pRequest.AchievementId;
+            pResponse.Result = ra::api::ApiResult::Success;
+            return true;
+        });
+
+        vmUpload.DoUpload();
+
+        Assert::AreEqual(6, nApiCount);
+        Assert::AreEqual(AssetChanges::None, pAchievement1.GetChanges());
+        Assert::AreEqual(AssetChanges::None, pAchievement2.GetChanges());
+        Assert::AreEqual(AssetChanges::None, pAchievement3.GetChanges());
+        Assert::AreEqual(AssetChanges::None, pAchievement4.GetChanges());
+        Assert::AreEqual(AssetChanges::None, pAchievement5.GetChanges());
+        Assert::AreEqual(AssetChanges::None, pAchievement6.GetChanges());
+
+        vmUpload.AssertSuccess(6);
+    }
+
+    TEST_METHOD(TestMultipleCoreAchievementsPartialFailure)
+    {
+        AssetUploadViewModelHarness vmUpload;
+        auto& pAchievement1 = vmUpload.AddAchievement(AssetCategory::Core, 5, L"Title1", L"Desc1", L"12345", "0xH1234=1");
+        auto& pAchievement2 = vmUpload.AddAchievement(AssetCategory::Core, 5, L"Title2", L"Desc2", L"12345", "0xH1234=1");
+        auto& pAchievement3 = vmUpload.AddAchievement(AssetCategory::Core, 5, L"Title3", L"Desc3", L"12345", "0xH1234=1");
+        auto& pAchievement4 = vmUpload.AddAchievement(AssetCategory::Core, 5, L"Title4", L"Desc4", L"12345", "0xH1234=1");
+        auto& pAchievement5 = vmUpload.AddAchievement(AssetCategory::Core, 5, L"Title5", L"Desc5", L"12345", "0xH1234=1");
+        auto& pAchievement6 = vmUpload.AddAchievement(AssetCategory::Core, 5, L"Title6", L"Desc6", L"12345", "0xH1234=1");
+        Assert::AreEqual(AssetChanges::Unpublished, pAchievement1.GetChanges());
+        Assert::AreEqual(AssetChanges::Unpublished, pAchievement2.GetChanges());
+        Assert::AreEqual(AssetChanges::Unpublished, pAchievement3.GetChanges());
+        Assert::AreEqual(AssetChanges::Unpublished, pAchievement4.GetChanges());
+        Assert::AreEqual(AssetChanges::Unpublished, pAchievement5.GetChanges());
+        Assert::AreEqual(AssetChanges::Unpublished, pAchievement6.GetChanges());
+
+        vmUpload.QueueAsset(pAchievement1);
+        vmUpload.QueueAsset(pAchievement2);
+        vmUpload.QueueAsset(pAchievement3);
+        vmUpload.QueueAsset(pAchievement4);
+        vmUpload.QueueAsset(pAchievement5);
+        vmUpload.QueueAsset(pAchievement6);
+        Assert::AreEqual({ 6U }, vmUpload.TaskCount());
+
+        int nApiCount = 0;
+        vmUpload.mockServer.HandleRequest<ra::api::UpdateAchievement>([&nApiCount]
+                (const ra::api::UpdateAchievement::Request& pRequest, ra::api::UpdateAchievement::Response& pResponse)
+        {
+            ++nApiCount;
+
+            if (nApiCount == 4)
+            {
+                pResponse.Result = ra::api::ApiResult::Failed;
+                pResponse.ErrorMessage = "Timeout";
+            }
+            else
+            {
+                pResponse.AchievementId = pRequest.AchievementId;
+                pResponse.Result = ra::api::ApiResult::Success;
+            }
+
+            return true;
+        });
+
+        vmUpload.DoUpload();
+
+        Assert::AreEqual(6, nApiCount);
+        Assert::AreEqual(AssetChanges::None, pAchievement1.GetChanges());
+        Assert::AreEqual(AssetChanges::None, pAchievement2.GetChanges());
+        Assert::AreEqual(AssetChanges::None, pAchievement3.GetChanges());
+        Assert::AreEqual(AssetChanges::Unpublished, pAchievement4.GetChanges());
+        Assert::AreEqual(AssetChanges::None, pAchievement5.GetChanges());
+        Assert::AreEqual(AssetChanges::None, pAchievement6.GetChanges());
+
+        vmUpload.AssertFailed(5, 1, L"* Title4: Timeout");
+    }
+
+    TEST_METHOD(TestMultipleCoreAchievementsAborted)
+    {
+        AssetUploadViewModelHarness vmUpload;
+        auto& pAchievement1 = vmUpload.AddAchievement(AssetCategory::Core, 5, L"Title1", L"Desc1", L"12345", "0xH1234=1");
+        auto& pAchievement2 = vmUpload.AddAchievement(AssetCategory::Core, 5, L"Title2", L"Desc2", L"12345", "0xH1234=1");
+        auto& pAchievement3 = vmUpload.AddAchievement(AssetCategory::Core, 5, L"Title3", L"Desc3", L"12345", "0xH1234=1");
+        auto& pAchievement4 = vmUpload.AddAchievement(AssetCategory::Core, 5, L"Title4", L"Desc4", L"12345", "0xH1234=1");
+        auto& pAchievement5 = vmUpload.AddAchievement(AssetCategory::Core, 5, L"Title5", L"Desc5", L"12345", "0xH1234=1");
+        auto& pAchievement6 = vmUpload.AddAchievement(AssetCategory::Core, 5, L"Title6", L"Desc6", L"12345", "0xH1234=1");
+        Assert::AreEqual(AssetChanges::Unpublished, pAchievement1.GetChanges());
+        Assert::AreEqual(AssetChanges::Unpublished, pAchievement2.GetChanges());
+        Assert::AreEqual(AssetChanges::Unpublished, pAchievement3.GetChanges());
+        Assert::AreEqual(AssetChanges::Unpublished, pAchievement4.GetChanges());
+        Assert::AreEqual(AssetChanges::Unpublished, pAchievement5.GetChanges());
+        Assert::AreEqual(AssetChanges::Unpublished, pAchievement6.GetChanges());
+
+        vmUpload.QueueAsset(pAchievement1);
+        vmUpload.QueueAsset(pAchievement2);
+        vmUpload.QueueAsset(pAchievement3);
+        vmUpload.QueueAsset(pAchievement4);
+        vmUpload.QueueAsset(pAchievement5);
+        vmUpload.QueueAsset(pAchievement6);
+        Assert::AreEqual({ 6U }, vmUpload.TaskCount());
+
+        int nApiCount = 0;
+        vmUpload.mockServer.HandleRequest<ra::api::UpdateAchievement>([&vmUpload, &nApiCount]
+                (const ra::api::UpdateAchievement::Request& pRequest, ra::api::UpdateAchievement::Response& pResponse)
+        {
+            ++nApiCount;
+
+            if (nApiCount == 4)
+                vmUpload.SetDialogResult(DialogResult::Cancel);
+
+            pResponse.AchievementId = pRequest.AchievementId;
+            pResponse.Result = ra::api::ApiResult::Success;
+            return true;
+        });
+
+        vmUpload.DoUpload(50); // fourth item will complete, but isn't tallied
+
+        Assert::AreEqual(4, nApiCount);
+        Assert::AreEqual(AssetChanges::None, pAchievement1.GetChanges());
+        Assert::AreEqual(AssetChanges::None, pAchievement2.GetChanges());
+        Assert::AreEqual(AssetChanges::None, pAchievement3.GetChanges());
+        Assert::AreEqual(AssetChanges::None, pAchievement4.GetChanges());
+        Assert::AreEqual(AssetChanges::Unpublished, pAchievement5.GetChanges());
+        Assert::AreEqual(AssetChanges::Unpublished, pAchievement6.GetChanges());
+
+        vmUpload.AssertAbort(4, 0, L"");
+    }
+
+    TEST_METHOD(TestSingleCoreAchievementWithImage)
+    {
+        AssetUploadViewModelHarness vmUpload;
+        auto& pAchievement = vmUpload.AddAchievement(AssetCategory::Core, 5, L"Title1", L"Desc1", L"local\\12345", "0xH1234=1");
+        Assert::AreEqual(AssetChanges::Unpublished, pAchievement.GetChanges());
+
+        vmUpload.QueueAsset(pAchievement);
+        Assert::AreEqual({ 1U }, vmUpload.TaskCount());
+
+        bool bImageUploaded = false;
+        vmUpload.mockServer.HandleRequest<ra::api::UploadBadge>([&bImageUploaded]
+                (const ra::api::UploadBadge::Request& pRequest, ra::api::UploadBadge::Response& pResponse)
+        {
+            bImageUploaded = true;
+            Assert::AreEqual(std::wstring(L"RACache\\Badges\\local\\12345"), pRequest.ImageFilePath);
+
+            pResponse.BadgeId = "76543";
+            pResponse.Result = ra::api::ApiResult::Success;
+            return true;
+        });
+
+        bool bAchievementUploaded = false;
+        vmUpload.mockServer.HandleRequest<ra::api::UpdateAchievement>([&bAchievementUploaded]
+                (const ra::api::UpdateAchievement::Request& pRequest, ra::api::UpdateAchievement::Response& pResponse)
+        {
+            bAchievementUploaded = 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.Trigger);
+            Assert::AreEqual(5U, pRequest.Points);
+            Assert::AreEqual(std::string("76543"), pRequest.Badge);
+            Assert::AreEqual(3U, pRequest.Category);
+            Assert::AreEqual(1U, pRequest.AchievementId);
+
+            pResponse.AchievementId = pRequest.AchievementId;
+            pResponse.Result = ra::api::ApiResult::Success;
+            return true;
+        });
+
+        vmUpload.DoUpload();
+
+        Assert::IsTrue(bImageUploaded);
+        Assert::IsTrue(bAchievementUploaded);
+        Assert::AreEqual(AssetChanges::None, pAchievement.GetChanges());
+        Assert::AreEqual(std::wstring(L"76543"), pAchievement.GetBadge());
+
+        vmUpload.AssertSuccess(1);
+    }
+
+    TEST_METHOD(TestMultipleCoreAchievementsWithImages)
+    {
+        AssetUploadViewModelHarness vmUpload;
+        auto& pAchievement1 = vmUpload.AddAchievement(AssetCategory::Core, 5, L"Title1", L"Desc1", L"local\\12345", "0xH1234=1");
+        auto& pAchievement2 = vmUpload.AddAchievement(AssetCategory::Core, 5, L"Title2", L"Desc2", L"local\\22222", "0xH1234=1");
+        Assert::AreEqual(AssetChanges::Unpublished, pAchievement1.GetChanges());
+        Assert::AreEqual(AssetChanges::Unpublished, pAchievement2.GetChanges());
+
+        vmUpload.QueueAsset(pAchievement1);
+        vmUpload.QueueAsset(pAchievement2);
+        Assert::AreEqual({ 2U }, vmUpload.TaskCount());
+
+        int nImagesUploaded = 0;
+        vmUpload.mockServer.HandleRequest<ra::api::UploadBadge>([&nImagesUploaded]
+                (const ra::api::UploadBadge::Request& pRequest, ra::api::UploadBadge::Response& pResponse)
+        {
+            ++nImagesUploaded;
+
+            if (pRequest.ImageFilePath == L"RACache\\Badges\\local\\12345")
+                pResponse.BadgeId = "76543";
+            else
+                pResponse.BadgeId = "55555";
+
+            pResponse.Result = ra::api::ApiResult::Success;
+            return true;
+        });
+
+        int nAchievementsUploaded = 0;
+        vmUpload.mockServer.HandleRequest<ra::api::UpdateAchievement>([&nAchievementsUploaded]
+                (const ra::api::UpdateAchievement::Request& pRequest, ra::api::UpdateAchievement::Response& pResponse)
+        {
+            ++nAchievementsUploaded;
+
+            if (pRequest.Title == L"Title1")
+                Assert::AreEqual(std::string("76543"), pRequest.Badge);
+            else
+                Assert::AreEqual(std::string("55555"), pRequest.Badge);
+
+            pResponse.AchievementId = pRequest.AchievementId;
+            pResponse.Result = ra::api::ApiResult::Success;
+            return true;
+        });
+
+        vmUpload.DoUpload();
+
+        Assert::AreEqual(2, nImagesUploaded);
+        Assert::AreEqual(2, nAchievementsUploaded);
+        Assert::AreEqual(AssetChanges::None, pAchievement1.GetChanges());
+        Assert::AreEqual(std::wstring(L"76543"), pAchievement1.GetBadge());
+        Assert::AreEqual(AssetChanges::None, pAchievement2.GetChanges());
+        Assert::AreEqual(std::wstring(L"55555"), pAchievement2.GetBadge());
+
+        vmUpload.AssertSuccess(2);
+    }
+
+    TEST_METHOD(TestMultipleCoreAchievementsWithSameImage)
+    {
+        AssetUploadViewModelHarness vmUpload;
+        auto& pAchievement1 = vmUpload.AddAchievement(AssetCategory::Core, 5, L"Title1", L"Desc1", L"local\\12345", "0xH1234=1");
+        auto& pAchievement2 = vmUpload.AddAchievement(AssetCategory::Core, 5, L"Title2", L"Desc2", L"local\\12345", "0xH1234=1");
+        Assert::AreEqual(AssetChanges::Unpublished, pAchievement1.GetChanges());
+        Assert::AreEqual(AssetChanges::Unpublished, pAchievement2.GetChanges());
+
+        vmUpload.QueueAsset(pAchievement1);
+        vmUpload.QueueAsset(pAchievement2);
+        Assert::AreEqual({ 1U }, vmUpload.TaskCount()); // single task for image upload - that will queue the two achievement tasks
+
+        int nImagesUploaded = 0;
+        vmUpload.mockServer.HandleRequest<ra::api::UploadBadge>([&nImagesUploaded]
+                (const ra::api::UploadBadge::Request& pRequest, ra::api::UploadBadge::Response& pResponse)
+        {
+            ++nImagesUploaded;
+
+            if (pRequest.ImageFilePath == L"RACache\\Badges\\local\\12345")
+                pResponse.BadgeId = "76543";
+            else
+                pResponse.BadgeId = "55555";
+
+            pResponse.Result = ra::api::ApiResult::Success;
+            return true;
+        });
+
+        int nAchievementsUploaded = 0;
+        vmUpload.mockServer.HandleRequest<ra::api::UpdateAchievement>([&nAchievementsUploaded]
+                (const ra::api::UpdateAchievement::Request& pRequest, ra::api::UpdateAchievement::Response& pResponse)
+        {
+            ++nAchievementsUploaded;
+            Assert::AreEqual(std::string("76543"), pRequest.Badge);
+            pResponse.AchievementId = pRequest.AchievementId;
+            pResponse.Result = ra::api::ApiResult::Success;
+            return true;
+        });
+
+        vmUpload.DoUpload();
+
+        Assert::AreEqual(1, nImagesUploaded);
+        Assert::AreEqual(2, nAchievementsUploaded);
+        Assert::AreEqual(AssetChanges::None, pAchievement1.GetChanges());
+        Assert::AreEqual(std::wstring(L"76543"), pAchievement1.GetBadge());
+        Assert::AreEqual(AssetChanges::None, pAchievement2.GetChanges());
+        Assert::AreEqual(std::wstring(L"76543"), pAchievement2.GetBadge());
+
+        vmUpload.AssertSuccess(2);
+    }
+
+    TEST_METHOD(TestMultipleCoreAchievementsWithFailedImages)
+    {
+        AssetUploadViewModelHarness vmUpload;
+        auto& pAchievement1 = vmUpload.AddAchievement(AssetCategory::Core, 5, L"Title1", L"Desc1", L"local\\12345", "0xH1234=1");
+        auto& pAchievement2 = vmUpload.AddAchievement(AssetCategory::Core, 5, L"Title2", L"Desc2", L"local\\22222", "0xH1234=1");
+        Assert::AreEqual(AssetChanges::Unpublished, pAchievement1.GetChanges());
+        Assert::AreEqual(AssetChanges::Unpublished, pAchievement2.GetChanges());
+
+        vmUpload.QueueAsset(pAchievement1);
+        vmUpload.QueueAsset(pAchievement2);
+        Assert::AreEqual({ 2U }, vmUpload.TaskCount());
+
+        int nImagesUploaded = 0;
+        vmUpload.mockServer.HandleRequest<ra::api::UploadBadge>([&nImagesUploaded]
+                (const ra::api::UploadBadge::Request&, ra::api::UploadBadge::Response& pResponse)
+        {
+            ++nImagesUploaded;
+            pResponse.Result = ra::api::ApiResult::Error;
+            pResponse.ErrorMessage = "Unsupported image format";
+            return true;
+        });
+
+        int nAchievementsUploaded = 0;
+        vmUpload.mockServer.HandleRequest<ra::api::UpdateAchievement>([&nAchievementsUploaded]
+                (const ra::api::UpdateAchievement::Request& pRequest, ra::api::UpdateAchievement::Response& pResponse)
+        {
+            ++nAchievementsUploaded;
+            pResponse.AchievementId = pRequest.AchievementId;
+            pResponse.Result = ra::api::ApiResult::Success;
+            return true;
+        });
+
+        vmUpload.DoUpload();
+
+        Assert::AreEqual(2, nImagesUploaded);
+        Assert::AreEqual(0, nAchievementsUploaded);
+        Assert::AreEqual(AssetChanges::Unpublished, pAchievement1.GetChanges());
+        Assert::AreEqual(std::wstring(L"local\\12345"), pAchievement1.GetBadge());
+        Assert::AreEqual(AssetChanges::Unpublished, pAchievement2.GetChanges());
+        Assert::AreEqual(std::wstring(L"local\\22222"), pAchievement2.GetBadge());
+
+        vmUpload.AssertFailed(0, 2, L"* Title1: Unsupported image format\n* Title2: Unsupported image format");
+    }
+
+    TEST_METHOD(TestMultipleCoreAchievementsWithSameFailedImage)
+    {
+        AssetUploadViewModelHarness vmUpload;
+        auto& pAchievement1 = vmUpload.AddAchievement(AssetCategory::Core, 5, L"Title1", L"Desc1", L"local\\12345", "0xH1234=1");
+        auto& pAchievement2 = vmUpload.AddAchievement(AssetCategory::Core, 5, L"Title2", L"Desc2", L"local\\12345", "0xH1234=1");
+        Assert::AreEqual(AssetChanges::Unpublished, pAchievement1.GetChanges());
+        Assert::AreEqual(AssetChanges::Unpublished, pAchievement2.GetChanges());
+
+        vmUpload.QueueAsset(pAchievement1);
+        vmUpload.QueueAsset(pAchievement2);
+        Assert::AreEqual({ 1U }, vmUpload.TaskCount());
+
+        int nImagesUploaded = 0;
+        vmUpload.mockServer.HandleRequest<ra::api::UploadBadge>([&nImagesUploaded]
+        (const ra::api::UploadBadge::Request&, ra::api::UploadBadge::Response& pResponse)
+        {
+            ++nImagesUploaded;
+            pResponse.Result = ra::api::ApiResult::Error;
+            pResponse.ErrorMessage = "Unsupported image format";
+            return true;
+        });
+
+        int nAchievementsUploaded = 0;
+        vmUpload.mockServer.HandleRequest<ra::api::UpdateAchievement>([&nAchievementsUploaded]
+        (const ra::api::UpdateAchievement::Request& pRequest, ra::api::UpdateAchievement::Response& pResponse)
+        {
+            ++nAchievementsUploaded;
+            pResponse.AchievementId = pRequest.AchievementId;
+            pResponse.Result = ra::api::ApiResult::Success;
+            return true;
+        });
+
+        vmUpload.DoUpload();
+
+        Assert::AreEqual(1, nImagesUploaded);
+        Assert::AreEqual(0, nAchievementsUploaded);
+        Assert::AreEqual(AssetChanges::Unpublished, pAchievement1.GetChanges());
+        Assert::AreEqual(std::wstring(L"local\\12345"), pAchievement1.GetBadge());
+        Assert::AreEqual(AssetChanges::Unpublished, pAchievement2.GetChanges());
+        Assert::AreEqual(std::wstring(L"local\\12345"), pAchievement2.GetBadge());
+
+        vmUpload.AssertFailed(0, 2, L"* Title1: Unsupported image format\n* Title2: Unsupported image format");
+    }
+
+    TEST_METHOD(TestMultipleCoreAchievementsWithImagesOneFailed)
+    {
+        AssetUploadViewModelHarness vmUpload;
+        auto& pAchievement1 = vmUpload.AddAchievement(AssetCategory::Core, 5, L"Title1", L"Desc1", L"local\\12345", "0xH1234=1");
+        auto& pAchievement2 = vmUpload.AddAchievement(AssetCategory::Core, 5, L"Title2", L"Desc2", L"local\\22222", "0xH1234=1");
+        Assert::AreEqual(AssetChanges::Unpublished, pAchievement1.GetChanges());
+        Assert::AreEqual(AssetChanges::Unpublished, pAchievement2.GetChanges());
+
+        vmUpload.QueueAsset(pAchievement1);
+        vmUpload.QueueAsset(pAchievement2);
+        Assert::AreEqual({ 2U }, vmUpload.TaskCount());
+
+        int nImagesUploaded = 0;
+        vmUpload.mockServer.HandleRequest<ra::api::UploadBadge>([&nImagesUploaded]
+        (const ra::api::UploadBadge::Request& pRequest, ra::api::UploadBadge::Response& pResponse)
+        {
+            ++nImagesUploaded;
+
+            if (pRequest.ImageFilePath == L"RACache\\Badges\\local\\12345")
+                pResponse.BadgeId = "76543";
+            else
+                pResponse.BadgeId = "55555";
+
+            pResponse.Result = ra::api::ApiResult::Success;
+            return true;
+        });
+
+        int nAchievementsUploaded = 0;
+        vmUpload.mockServer.HandleRequest<ra::api::UpdateAchievement>([&nAchievementsUploaded]
+        (const ra::api::UpdateAchievement::Request& pRequest, ra::api::UpdateAchievement::Response& pResponse)
+        {
+            ++nAchievementsUploaded;
+
+            if (pRequest.Title == L"Title1")
+            {
+                Assert::AreEqual(std::string("76543"), pRequest.Badge);
+                pResponse.AchievementId = pRequest.AchievementId;
+                pResponse.Result = ra::api::ApiResult::Success;
+            }
+            else
+            {
+                Assert::AreEqual(std::string("55555"), pRequest.Badge);
+                pResponse.Result = ra::api::ApiResult::Error;
+                pResponse.ErrorMessage = "Timeout";
+            }
+
+            return true;
+        });
+
+        vmUpload.DoUpload();
+
+        Assert::AreEqual(2, nImagesUploaded);
+        Assert::AreEqual(2, nAchievementsUploaded);
+        Assert::AreEqual(AssetChanges::None, pAchievement1.GetChanges());
+        Assert::AreEqual(std::wstring(L"76543"), pAchievement1.GetBadge());
+
+        // badge was successfully uploaded, but achievement failed so it should remain modified
+        // the AssetListViewModel will follow up with a save local, which will change the state to Unpublished
+        Assert::AreEqual(AssetChanges::Modified, pAchievement2.GetChanges());
+        Assert::AreEqual(std::wstring(L"55555"), pAchievement2.GetBadge());
+
+        vmUpload.AssertFailed(1, 1, L"* Title2: Timeout");
+    }
+};
+
+} // namespace tests
+} // namespace viewmodels
+} // namespace ui
+} // namespace ra
diff --git a/tests/ui/viewmodels/ProgressViewModel_Tests.cpp b/tests/ui/viewmodels/ProgressViewModel_Tests.cpp
new file mode 100644
index 00000000..50a49c03
--- /dev/null
+++ b/tests/ui/viewmodels/ProgressViewModel_Tests.cpp
@@ -0,0 +1,176 @@
+#include "CppUnitTest.h"
+
+#include "ui\viewmodels\ProgressViewModel.hh"
+
+#include "tests\ui\UIAsserts.hh"
+#include "tests\mocks\MockThreadPool.hh"
+
+using namespace Microsoft::VisualStudio::CppUnitTestFramework;
+
+namespace ra {
+namespace ui {
+namespace viewmodels {
+namespace tests {
+
+TEST_CLASS(ProgressViewModel_Tests)
+{
+private:
+    class ProgressViewModelHarness : public ProgressViewModel
+    {
+    public:
+        ra::services::mocks::MockThreadPool mockThreadPool;
+    };
+
+public:
+    TEST_METHOD(TestInitialValues)
+    {
+        ProgressViewModelHarness vmProgress;
+        Assert::AreEqual(std::wstring(), vmProgress.GetMessage());
+        Assert::AreEqual(0, vmProgress.GetProgress());
+    }
+
+    TEST_METHOD(TestSingleTask)
+    {
+        ProgressViewModelHarness vmProgress;
+        int nTasksCompleted = 0;
+        vmProgress.QueueTask([&nTasksCompleted]() { ++nTasksCompleted; });
+        Assert::AreEqual({ 1U }, vmProgress.TaskCount());
+
+        Assert::AreEqual(std::wstring(), vmProgress.GetMessage());
+        Assert::AreEqual(0, vmProgress.GetProgress());
+        Assert::AreEqual({ 0U }, vmProgress.mockThreadPool.PendingTasks());
+
+        // setting visible starts the task threads
+        vmProgress.SetIsVisible(true);
+        Assert::AreEqual(DialogResult::None, vmProgress.GetDialogResult());
+
+        Assert::AreEqual({ 1U }, vmProgress.TaskCount());
+        Assert::AreEqual(0, vmProgress.GetProgress());
+
+        // "run" the background thread - will execute the single task and then go away
+        Assert::AreEqual({ 1U }, vmProgress.mockThreadPool.PendingTasks());
+        vmProgress.mockThreadPool.ExecuteNextTask();
+
+        Assert::AreEqual({ 0U }, vmProgress.mockThreadPool.PendingTasks());
+        Assert::AreEqual(1, nTasksCompleted);
+        Assert::AreEqual(100, vmProgress.GetProgress());
+
+        // dialog should automatically close once all tasks are completed
+        Assert::AreEqual(DialogResult::OK, vmProgress.GetDialogResult());
+
+        // simulate the hiding - tasks should not restart
+        vmProgress.SetIsVisible(false);
+        Assert::AreEqual({ 0U }, vmProgress.mockThreadPool.PendingTasks());
+    }
+
+    TEST_METHOD(TestManyTasks)
+    {
+        ProgressViewModelHarness vmProgress;
+        int nTasksCompleted = 0;
+        for (int i = 0; i < 20; ++i)
+        {
+            vmProgress.QueueTask([&vmProgress, &nTasksCompleted, i]()
+            {
+                Assert::AreEqual(i * 5, vmProgress.GetProgress());
+                ++nTasksCompleted;
+            });
+        }
+        Assert::AreEqual({ 20U }, vmProgress.TaskCount());
+
+        Assert::AreEqual(std::wstring(), vmProgress.GetMessage());
+        Assert::AreEqual(0, vmProgress.GetProgress());
+        Assert::AreEqual({ 0U }, vmProgress.mockThreadPool.PendingTasks());
+
+        // setting visible starts the task threads
+        vmProgress.SetIsVisible(true);
+        Assert::AreEqual(DialogResult::None, vmProgress.GetDialogResult());
+
+        Assert::AreEqual({ 20U }, vmProgress.TaskCount());
+        Assert::AreEqual(0, vmProgress.GetProgress());
+
+        // there should be four background threads queued, but because they're only mocked
+        // threads, as soon as we start the first thread, it will process the entire queue.
+        Assert::AreEqual({ 4U }, vmProgress.mockThreadPool.PendingTasks());
+        vmProgress.mockThreadPool.ExecuteNextTask();
+
+        Assert::AreEqual(20, nTasksCompleted);
+        Assert::AreEqual(100, vmProgress.GetProgress());
+
+        // dialog should automatically close once all tasks are completed
+        Assert::AreEqual(DialogResult::OK, vmProgress.GetDialogResult());
+
+        // the other three background threads will be starved in the mock pool. flush them too
+        Assert::AreEqual({ 3U }, vmProgress.mockThreadPool.PendingTasks());
+        vmProgress.mockThreadPool.ExecuteNextTask();
+        vmProgress.mockThreadPool.ExecuteNextTask();
+        vmProgress.mockThreadPool.ExecuteNextTask();
+        Assert::AreEqual({ 0U }, vmProgress.mockThreadPool.PendingTasks());
+
+        // make sure we're still in the "end" state
+        Assert::AreEqual(20, nTasksCompleted);
+        Assert::AreEqual(100, vmProgress.GetProgress());
+        Assert::AreEqual(DialogResult::OK, vmProgress.GetDialogResult());
+    }
+
+    TEST_METHOD(TestCancel)
+    {
+        ProgressViewModelHarness vmProgress;
+        int nTasksCompleted = 0;
+        for (int i = 0; i < 20; ++i)
+        {
+            vmProgress.QueueTask([&vmProgress, &nTasksCompleted, i]()
+            {
+                ++nTasksCompleted;
+                if (i == 8) // 0-8 is 9 completed tasks
+                    vmProgress.SetDialogResult(DialogResult::Cancel);
+            });
+        }
+        Assert::AreEqual({ 20U }, vmProgress.TaskCount());
+
+        Assert::AreEqual(std::wstring(), vmProgress.GetMessage());
+        Assert::AreEqual(0, vmProgress.GetProgress());
+        Assert::AreEqual({ 0U }, vmProgress.mockThreadPool.PendingTasks());
+
+        // setting visible starts the task threads
+        vmProgress.SetIsVisible(true);
+        Assert::AreEqual(DialogResult::None, vmProgress.GetDialogResult());
+
+        Assert::AreEqual({ 20U }, vmProgress.TaskCount());
+        Assert::AreEqual(0, vmProgress.GetProgress());
+
+        // there should be four background threads queued, but because they're only mocked
+        // threads, as soon as we start the first thread, it will process the entire queue.
+        Assert::AreEqual({ 4U }, vmProgress.mockThreadPool.PendingTasks());
+        vmProgress.mockThreadPool.ExecuteNextTask();
+
+        Assert::AreEqual(9, nTasksCompleted);
+        Assert::AreEqual(40, vmProgress.GetProgress()); // the 9th task won't be counted because the loop aborts first
+
+        // dialog should automatically close once all tasks are completed
+        Assert::AreEqual(DialogResult::Cancel, vmProgress.GetDialogResult());
+
+        // the other three background threads will be starved in the mock pool. flush them too
+        Assert::AreEqual({ 3U }, vmProgress.mockThreadPool.PendingTasks());
+        vmProgress.mockThreadPool.ExecuteNextTask();
+        vmProgress.mockThreadPool.ExecuteNextTask();
+        vmProgress.mockThreadPool.ExecuteNextTask();
+        Assert::AreEqual({ 0U }, vmProgress.mockThreadPool.PendingTasks());
+
+        // make sure we're still in the "end" state
+        Assert::AreEqual(9, nTasksCompleted);
+        Assert::AreEqual(40, vmProgress.GetProgress());
+        Assert::AreEqual(DialogResult::Cancel, vmProgress.GetDialogResult());
+
+        // simulate the hiding - tasks should not restart
+        vmProgress.SetIsVisible(false);
+        Assert::AreEqual({ 0U }, vmProgress.mockThreadPool.PendingTasks());
+        Assert::AreEqual(9, nTasksCompleted);
+        Assert::AreEqual(40, vmProgress.GetProgress());
+        Assert::AreEqual(DialogResult::Cancel, vmProgress.GetDialogResult());
+    }
+};
+
+} // namespace tests
+} // namespace viewmodels
+} // namespace ui
+} // namespace ra