Repository: RetroAchievements/RAIntegration
Pull request #600 (2020-08-31 02:05:54)

populate achievement view model list from server data


Patch:
diff --git a/src/data/GameContext.cpp b/src/data/GameContext.cpp
index d5632689..555dac38 100644
--- a/src/data/GameContext.cpp
+++ b/src/data/GameContext.cpp
@@ -30,9 +30,11 @@
 
 #include "ui\ImageReference.hh"
 
+#include "ui\viewmodels\AchievementViewModel.hh"
 #include "ui\viewmodels\MessageBoxViewModel.hh"
 #include "ui\viewmodels\OverlayManager.hh"
 #include "ui\viewmodels\ScoreboardViewModel.hh"
+#include "ui\viewmodels\WindowManager.hh"
 
 namespace ra {
 namespace data {
@@ -126,6 +128,8 @@ void GameContext::LoadGame(unsigned int nGameId, Mode nMode)
     const bool bWasPaused = pRuntime.IsPaused();
     pRuntime.SetPaused(true);
 
+    auto& vmAssets = ra::services::ServiceLocator::GetMutable<ra::ui::viewmodels::WindowManager>().AssetList;
+
     unsigned int nNumCoreAchievements = 0;
     unsigned int nTotalCoreAchievementPoints = 0;
     for (const auto& pAchievementData : response.Achievements)
@@ -139,6 +143,17 @@ void GameContext::LoadGame(unsigned int nGameId, Mode nMode)
         pAchievement.SetID(pAchievementData.Id);
         CopyAchievementData(pAchievement, pAchievementData);
 
+        auto vmAchievement = std::make_unique<ra::ui::viewmodels::AchievementViewModel>();
+        vmAchievement->SetID(pAchievementData.Id);
+        vmAchievement->SetName(ra::Widen(pAchievementData.Title));
+        vmAchievement->SetDescription(ra::Widen(pAchievementData.Description));
+        vmAchievement->SetCategory(ra::itoe<ra::ui::viewmodels::AssetCategory>(pAchievementData.CategoryId));
+        vmAchievement->SetPoints(pAchievementData.Points);
+        vmAchievement->SetBadge(ra::Widen(pAchievementData.BadgeName));
+        vmAchievement->SetTrigger(pAchievementData.Definition);
+        vmAchievement->CreateServerCheckpoint();
+        vmAssets.Assets().Append(std::move(vmAchievement));
+
 #ifndef RA_UTEST
         // prefetch the achievement image
         pImageRepository.FetchImage(ra::ui::ImageType::Badge, pAchievementData.BadgeName);
@@ -166,6 +181,13 @@ void GameContext::LoadGame(unsigned int nGameId, Mode nMode)
     m_nNextLocalId = GameContext::FirstLocalId;
     MergeLocalAchievements(0);
 
+    for (gsl::index i = 0; i < gsl::narrow_cast<gsl::index>(vmAssets.Assets().Count()); ++i)
+    {
+        auto* pItem = vmAssets.Assets().GetItemAt(i);
+        Expects(pItem != nullptr);
+        pItem->CreateLocalCheckpoint();
+    }
+
 #ifndef RA_UTEST
     g_AchievementsDialog.UpdateAchievementList();
 #endif
@@ -326,6 +348,8 @@ bool GameContext::MergeLocalAchievements(ra::AchievementID nAchievementId)
     if (pData == nullptr)
         return false;
 
+    auto& vmAssets = ra::services::ServiceLocator::GetMutable<ra::ui::viewmodels::WindowManager>().AssetList;
+
     std::string sLine;
     pData->GetLine(sLine); // version used to create the file
     pData->GetLine(sLine); // game title
@@ -347,8 +371,12 @@ bool GameContext::MergeLocalAchievements(ra::AchievementID nAchievementId)
 
         bool bIsNew = false;
         Achievement* pAchievement = nullptr;
+        ra::ui::viewmodels::AchievementViewModel* vmAchievement = nullptr;
         if (nId != 0)
+        {
             pAchievement = FindAchievement(nId);
+            vmAchievement = vmAssets.FindAchievement(nId);
+        }
         if (!pAchievement)
         {
             bIsNew = true;
@@ -363,6 +391,21 @@ bool GameContext::MergeLocalAchievements(ra::AchievementID nAchievementId)
             pAchievement->SetID(nId);
         }
 
+        if (!vmAchievement)
+        {
+            auto uvmAchievement = std::make_unique<ra::ui::viewmodels::AchievementViewModel>();
+            uvmAchievement->SetID(nId);
+            uvmAchievement->SetCategory(ra::ui::viewmodels::AssetCategory::Local);
+            uvmAchievement->CreateServerCheckpoint();
+
+            auto* vmAsset = &vmAssets.Assets().Append(std::move(uvmAchievement));
+            vmAchievement = dynamic_cast<ra::ui::viewmodels::AchievementViewModel*>(vmAsset);
+        }
+
+        ra::Tokenizer pTokenizer2(sLine);
+        pTokenizer2.Advance(pTokenizer.CurrentPosition());
+        vmAchievement->Deserialize(pTokenizer2);
+
         // field 2: trigger
         std::string sTrigger;
         if (pTokenizer.PeekChar() == '"')
@@ -503,6 +546,14 @@ bool GameContext::MergeLocalAchievements(ra::AchievementID nAchievementId)
             pAchievement->SetID(m_nNextLocalId++);
     }
 
+    for (gsl::index i = 0; i < gsl::narrow_cast<gsl::index>(vmAssets.Assets().Count()); ++i)
+    {
+        auto* pItem = vmAssets.Assets().GetItemAt(i);
+        Expects(pItem != nullptr);
+        if (pItem->GetID() == 0)
+            pItem->SetID(m_nNextLocalId++);
+    }
+
     return (nAchievementId == 0);
 }
 
diff --git a/src/ui/ViewModelCollection.hh b/src/ui/ViewModelCollection.hh
index b3aa30fa..dc13e681 100644
--- a/src/ui/ViewModelCollection.hh
+++ b/src/ui/ViewModelCollection.hh
@@ -423,6 +423,17 @@ public:
         return dynamic_cast<T&>(ViewModelCollectionBase::AddItem(std::move(pItem)));
     }
 
+    /// <summary>
+    /// Adds an item to the end of the collection.
+    /// </summary>
+    /// <remarks>
+    /// This function is called Append instead of Add to prevent confusion with the templated parameters.
+    /// </summary>
+    T& Append(std::unique_ptr<T> pItem)
+    {
+        return dynamic_cast<T&>(ViewModelCollectionBase::AddItem(std::move(pItem)));
+    }
+
     /// <summary>
     /// Gets the item at the specified index.
     /// </summary>
diff --git a/src/ui/viewmodels/AchievementViewModel.cpp b/src/ui/viewmodels/AchievementViewModel.cpp
index adfcf6f6..eed1fee1 100644
--- a/src/ui/viewmodels/AchievementViewModel.cpp
+++ b/src/ui/viewmodels/AchievementViewModel.cpp
@@ -14,6 +14,8 @@ const IntModelProperty AchievementViewModel::TriggerProperty("AchievementViewMod
 
 AchievementViewModel::AchievementViewModel() noexcept
 {
+    GSL_SUPPRESS_F6 SetValue(TypeProperty, ra::etoi(AssetType::Achievement));
+
     SetTransactional(PointsProperty);
     SetTransactional(BadgeProperty);
 
@@ -25,6 +27,112 @@ void AchievementViewModel::SelectBadgeFile()
     ra::ui::viewmodels::MessageBoxViewModel::ShowWarningMessage(L"Not implemented");
 }
 
+void AchievementViewModel::Serialize(ra::services::TextWriter& pWriter) const
+{
+    WriteQuoted(pWriter, GetTrigger());
+    WritePossiblyQuoted(pWriter, GetName());
+    WritePossiblyQuoted(pWriter, GetDescription());
+    pWriter.Write("::::"); // progress/max/format/author
+    WriteNumber(pWriter, GetPoints());
+    pWriter.Write("::::"); // created/modified/upvotes/downvotes
+    WritePossiblyQuoted(pWriter, GetBadge());
+}
+
+bool AchievementViewModel::Deserialize(ra::Tokenizer& pTokenizer)
+{
+    // field 2: trigger
+    std::string sTrigger;
+    if (pTokenizer.PeekChar() == '"')
+    {
+        sTrigger = pTokenizer.ReadQuotedString();
+    }
+    else
+    {
+        // unquoted trigger requires special parsing because flags also use colons
+        const char* pTrigger = pTokenizer.GetPointer(pTokenizer.CurrentPosition());
+        const char* pScan = pTrigger;
+        while (*pScan && (*pScan != ':' || strchr("ABCMNOPRTabcmnoprt", pScan[-1]) != nullptr))
+            pScan++;
+
+        sTrigger.assign(pTrigger, pScan - pTrigger);
+        pTokenizer.Advance(sTrigger.length());
+    }
+
+    if (!pTokenizer.Consume(':'))
+        return false;
+
+    // field 3: title
+    std::string sTitle;
+    if (!ReadPossiblyQuoted(pTokenizer, sTitle))
+        return false;
+
+    // field 4: description
+    std::string sDescription;
+    if (!ReadPossiblyQuoted(pTokenizer, sDescription))
+        return false;
+
+    // field 5: progress (unused)
+    pTokenizer.AdvanceTo(':');
+    if (!pTokenizer.Consume(':'))
+        return false;
+
+    // field 6: progress max (unused)
+    pTokenizer.AdvanceTo(':');
+    if (!pTokenizer.Consume(':'))
+        return false;
+
+    // field 7: progress format (unused)
+    pTokenizer.AdvanceTo(':');
+    if (!pTokenizer.Consume(':'))
+        return false;
+
+    // field 8: author (unused)
+    pTokenizer.AdvanceTo(':');
+    if (!pTokenizer.Consume(':'))
+        return false;
+
+    // field 9: points
+    uint32_t nPoints;
+    if (!ReadNumber(pTokenizer, nPoints))
+        return false;
+
+    // field 10: created date (unused)
+    pTokenizer.AdvanceTo(':');
+    if (!pTokenizer.Consume(':'))
+        return false;
+
+    // field 11: modified date (unused)
+    pTokenizer.AdvanceTo(':');
+    if (!pTokenizer.Consume(':'))
+        return false;
+
+    // field 12: up votes (unused)
+    pTokenizer.AdvanceTo(':');
+    if (!pTokenizer.Consume(':'))
+        return false;
+
+    // field 13: down votes (unused)
+    pTokenizer.AdvanceTo(':');
+    if (!pTokenizer.Consume(':'))
+        return false;
+
+    // field 14: badge
+    std::string sBadge;
+    if (!ReadPossiblyQuoted(pTokenizer, sBadge))
+        return false;
+    if (sBadge.length() < 5)
+        sBadge.insert(0, 5 - sBadge.length(), '0');
+
+    // line is valid
+    SetName(ra::Widen(sTitle));
+    SetDescription(ra::Widen(sDescription));
+    SetPoints(nPoints);
+    SetBadge(ra::Widen(sBadge));
+    SetTrigger(sTrigger);
+
+    return true;
+}
+
 } // namespace viewmodels
 } // namespace ui
 } // namespace ra
diff --git a/src/ui/viewmodels/AchievementViewModel.hh b/src/ui/viewmodels/AchievementViewModel.hh
index e185b60a..7556a59d 100644
--- a/src/ui/viewmodels/AchievementViewModel.hh
+++ b/src/ui/viewmodels/AchievementViewModel.hh
@@ -8,7 +8,7 @@ namespace ra {
 namespace ui {
 namespace viewmodels {
 
-class AchievementViewModel : protected AssetViewModelBase
+class AchievementViewModel : public AssetViewModelBase
 {
 public:
     AchievementViewModel() noexcept;
@@ -93,6 +93,9 @@ public:
     /// </summary>
     void SetTrigger(const std::string& sTrigger) { SetAssetDefinition(m_pTrigger, sTrigger); }
 
+    void Serialize(ra::services::TextWriter& pWriter) const override;
+    bool Deserialize(ra::Tokenizer& pTokenizer) override;
+
 private:
     AssetDefinition m_pTrigger;
 };
diff --git a/src/ui/viewmodels/AssetListViewModel.cpp b/src/ui/viewmodels/AssetListViewModel.cpp
index 449b8db9..d99c62ff 100644
--- a/src/ui/viewmodels/AssetListViewModel.cpp
+++ b/src/ui/viewmodels/AssetListViewModel.cpp
@@ -27,6 +27,30 @@ const BoolModelProperty AssetListViewModel::CanRefreshProperty("AssetListViewMod
 const BoolModelProperty AssetListViewModel::CanCloneProperty("AssetListViewModel", "CanClone", true);
 
 
+AssetViewModelBase* AssetListViewModel::FindAsset(AssetType nType, ra::AchievementID nId)
+{
+    for (gsl::index nIndex = 0; nIndex < gsl::narrow_cast<gsl::index>(m_vAssets.Count()); ++nIndex)
+    {
+        auto* pItem = m_vAssets.GetItemAt(nIndex);
+        if (pItem != nullptr && pItem->GetID() == nId && pItem->GetType() == nType)
+            return pItem;
+    }
+
+    return nullptr;
+}
+
+const AssetViewModelBase* AssetListViewModel::FindAsset(AssetType nType, ra::AchievementID nId) const
+{
+    for (gsl::index nIndex = 0; nIndex < gsl::narrow_cast<gsl::index>(m_vAssets.Count()); ++nIndex)
+    {
+        auto* pItem = m_vAssets.GetItemAt(nIndex);
+        if (pItem != nullptr && pItem->GetID() == nId && pItem->GetType() == nType)
+            return pItem;
+    }
+
+    return nullptr;
+}
+
 bool AssetListViewModel::HasSelection(AssetType nAssetType) const
 {
     for (gsl::index nIndex = 0; nIndex < gsl::narrow_cast<gsl::index>(m_vAssets.Count()); ++nIndex)
diff --git a/src/ui/viewmodels/AssetListViewModel.hh b/src/ui/viewmodels/AssetListViewModel.hh
index 4a6dc84f..f3bf4511 100644
--- a/src/ui/viewmodels/AssetListViewModel.hh
+++ b/src/ui/viewmodels/AssetListViewModel.hh
@@ -7,6 +7,8 @@
 
 #include "AssetViewModelBase.hh"
 
+#include "AchievementViewModel.hh"
+
 #include "data\Types.hh"
 
 namespace ra {
@@ -98,7 +100,19 @@ public:
     ViewModelCollection<AssetViewModelBase>& Assets() noexcept { return m_vAssets; }
     const ViewModelCollection<AssetViewModelBase>& Assets() const noexcept { return m_vAssets; }
 
+    AchievementViewModel* FindAchievement(ra::AchievementID nId)
+    {
+        return dynamic_cast<AchievementViewModel*>(FindAsset(AssetType::Achievement, nId));
+    }
+    const AchievementViewModel* FindAchievement(ra::AchievementID nId) const
+    {
+        return dynamic_cast<const AchievementViewModel*>(FindAsset(AssetType::Achievement, nId));
+    }
+
 private:
+    AssetViewModelBase* FindAsset(AssetType nType, ra::AchievementID nId);
+    const AssetViewModelBase* FindAsset(AssetType nType, ra::AchievementID nId) const;
+
     bool HasSelection(AssetType nAssetType) const;
 
     ViewModelCollection<AssetViewModelBase> m_vAssets;
diff --git a/src/ui/viewmodels/AssetViewModelBase.cpp b/src/ui/viewmodels/AssetViewModelBase.cpp
index f3fcd246..d8ecd344 100644
--- a/src/ui/viewmodels/AssetViewModelBase.cpp
+++ b/src/ui/viewmodels/AssetViewModelBase.cpp
@@ -7,6 +7,7 @@ namespace viewmodels {
 const IntModelProperty AssetViewModelBase::TypeProperty("AssetViewModelBase", "Type", ra::etoi(AssetType::Achievement));
 const IntModelProperty AssetViewModelBase::IDProperty("AssetViewModelBase", "ID", 0);
 const StringModelProperty AssetViewModelBase::NameProperty("AssetViewModelBase", "Name", L"");
+const StringModelProperty AssetViewModelBase::DescriptionProperty("AssetViewModelBase", "Description", L"");
 const IntModelProperty AssetViewModelBase::CategoryProperty("AssetViewModelBase", "Category", ra::etoi(AssetCategory::Core));
 const IntModelProperty AssetViewModelBase::StateProperty("AssetViewModelBase", "State", ra::etoi(AssetState::Inactive));
 const IntModelProperty AssetViewModelBase::ChangesProperty("AssetViewModelBase", "Changes", ra::etoi(AssetChanges::None));
@@ -101,6 +102,59 @@ void AssetViewModelBase::WriteNumber(ra::services::TextWriter& pWriter, const ui
     pWriter.Write(std::to_string(nValue));
 }
 
+bool AssetViewModelBase::ReadNumber(ra::Tokenizer& pTokenizer, uint32_t& nValue)
+{
+    if (pTokenizer.EndOfString())
+        return false;
+
+    nValue = pTokenizer.ReadNumber();
+    return pTokenizer.Consume(':') || pTokenizer.EndOfString();
+}
+
+bool AssetViewModelBase::ReadQuoted(ra::Tokenizer& pTokenizer, std::string& sText)
+{
+    if (pTokenizer.EndOfString())
+        return false;
+
+    sText = pTokenizer.ReadQuotedString();
+    return pTokenizer.Consume(':') || pTokenizer.EndOfString();
+}
+
+bool AssetViewModelBase::ReadQuoted(ra::Tokenizer& pTokenizer, std::wstring& sText)
+{
+    if (pTokenizer.EndOfString())
+        return false;
+
+    sText = ra::Widen(pTokenizer.ReadQuotedString());
+    return pTokenizer.Consume(':') || pTokenizer.EndOfString();
+}
+
+bool AssetViewModelBase::ReadPossiblyQuoted(ra::Tokenizer& pTokenizer, std::string& sText)
+{
+    if (pTokenizer.EndOfString())
+        return false;
+
+    if (pTokenizer.PeekChar() == '"')
+        return ReadQuoted(pTokenizer, sText);
+
+    sText = pTokenizer.ReadTo(':');
+    pTokenizer.Consume(':');
+    return true;
+}
+
+bool AssetViewModelBase::ReadPossiblyQuoted(ra::Tokenizer& pTokenizer, std::wstring& sText)
+{
+    if (pTokenizer.EndOfString())
+        return false;
+
+    if (pTokenizer.PeekChar() == '"')
+        return ReadQuoted(pTokenizer, sText);
+
+    sText = ra::Widen(pTokenizer.ReadTo(':'));
+    pTokenizer.Consume(':');
+    return true;
+}
+
 void AssetViewModelBase::CreateServerCheckpoint()
 {
     Expects(m_pTransaction == nullptr);
@@ -253,6 +307,7 @@ void AssetViewModelBase::CommitTransaction()
         for (auto pAsset : m_vAssetDefinitions)
         {
             Expects(pAsset != nullptr);
+
             const auto nState = ra::itoe<AssetChanges>(GetValue(*pAsset->m_pProperty));
             if (nState == AssetChanges::Unpublished)
             {
@@ -273,6 +328,7 @@ void AssetViewModelBase::CommitTransaction()
         for (auto pAsset : m_vAssetDefinitions)
         {
             Expects(pAsset != nullptr);
+
             const auto nState = ra::itoe<AssetChanges>(GetValue(*pAsset->m_pProperty));
             if (nState == AssetChanges::Modified)
             {
@@ -306,6 +362,7 @@ void AssetViewModelBase::RevertTransaction()
     for (auto pAsset : m_vAssetDefinitions)
     {
         Expects(pAsset != nullptr);
+
         const auto nState = ra::itoe<AssetChanges>(GetValue(*pAsset->m_pProperty));
         switch (nState)
         {
diff --git a/src/ui/viewmodels/AssetViewModelBase.hh b/src/ui/viewmodels/AssetViewModelBase.hh
index 8bc204ef..4d8001da 100644
--- a/src/ui/viewmodels/AssetViewModelBase.hh
+++ b/src/ui/viewmodels/AssetViewModelBase.hh
@@ -6,6 +6,7 @@
 
 #include "ui\TransactionalViewModelBase.hh"
 
+#include "RA_StringUtils.h"
 #include "ra_utility.h"
 
 namespace ra {
@@ -42,7 +43,7 @@ enum class AssetChanges
     Unpublished, // local data differs from server data
 };
 
-class AssetViewModelBase : protected TransactionalViewModelBase
+class AssetViewModelBase : public TransactionalViewModelBase
 {
 public:
     AssetViewModelBase() noexcept;
@@ -67,6 +68,11 @@ public:
     /// </summary>
     uint32_t GetID() const { return ra::to_unsigned(GetValue(IDProperty)); }
 
+    /// <summary>
+    /// Sets the asset id.
+    /// </summary>
+    void SetID(uint32_t nValue) { SetValue(IDProperty, nValue); }
+
     /// <summary>
     /// The <see cref="ModelProperty" /> for the name.
     /// </summary>
@@ -90,12 +96,12 @@ public:
     /// <summary>
     /// Gets the description of the asset.
     /// </summary>
-    const std::wstring& GetDescription() const { return GetValue(NameProperty); }
+    const std::wstring& GetDescription() const { return GetValue(DescriptionProperty); }
 
     /// <summary>
     /// Sets the description of the asset.
     /// </summary>
-    void SetDescription(const std::wstring& sValue) { SetValue(NameProperty, sValue); }
+    void SetDescription(const std::wstring& sValue) { SetValue(DescriptionProperty, sValue); }
 
     /// <summary>
     /// The <see cref="ModelProperty" /> for the asset category.
@@ -148,6 +154,7 @@ public:
     void SetSelected(bool bValue) { SetValue(IsSelectedProperty, bValue); }
 
     virtual void Serialize(ra::services::TextWriter& pWriter) const = 0;
+    virtual bool Deserialize(ra::Tokenizer& pTokenizer) = 0;
 
     /// <summary>
     /// Captures the current state of the asset as a reflection of the state on the server.
@@ -210,8 +217,19 @@ protected:
     static void WritePossiblyQuoted(ra::services::TextWriter& pWriter, const std::string& sText);
     static void WritePossiblyQuoted(ra::services::TextWriter& pWriter, const std::wstring& sText);
 
+    static bool ReadNumber(ra::Tokenizer& pTokenizer, uint32_t& nValue);
+    static bool ReadQuoted(ra::Tokenizer& pTokenizer, std::string& sText);
+    static bool ReadQuoted(ra::Tokenizer& pTokenizer, std::wstring& sText);
+    static bool ReadPossiblyQuoted(ra::Tokenizer& pTokenizer, std::string& sText);
+    static bool ReadPossiblyQuoted(ra::Tokenizer& pTokenizer, std::wstring& sText);
+
     void OnValueChanged(const BoolModelProperty::ChangeArgs& args) override;
 
+    // Hide the public members of TransactionalViewModelBase that we're redefining
+    using TransactionalViewModelBase::BeginTransaction;
+    using TransactionalViewModelBase::CommitTransaction;
+    using TransactionalViewModelBase::RevertTransaction;
+
     void CommitTransaction() override;
     void RevertTransaction() override;
 };
diff --git a/src/ui/viewmodels/PopupViewModelBase.cpp b/src/ui/viewmodels/PopupViewModelBase.cpp
index 8e357fd7..56671d1d 100644
--- a/src/ui/viewmodels/PopupViewModelBase.cpp
+++ b/src/ui/viewmodels/PopupViewModelBase.cpp
@@ -19,7 +19,7 @@ int PopupViewModelBase::GetFadeOffset(double fAnimationProgress, double fTotalAn
     {
         // fading in
         const auto fPercentage = (fInOutTime - fAnimationProgress) / fInOutTime;
-        Expects(nTargetOffset > nInitialOffset);
+        Expects(nTargetOffset >= nInitialOffset);
         const auto nOffset = to_unsigned(nTargetOffset - nInitialOffset) * (fPercentage * fPercentage);
         nNewOffset = ftol(nTargetOffset - nOffset);
     }
@@ -32,7 +32,7 @@ int PopupViewModelBase::GetFadeOffset(double fAnimationProgress, double fTotalAn
     {
         // fading out
         const auto fPercentage = (fTotalAnimationTime - fInOutTime - fAnimationProgress) / fInOutTime;
-        Expects(nTargetOffset > nInitialOffset);
+        Expects(nTargetOffset >= nInitialOffset);
         const auto nOffset = to_unsigned(nTargetOffset - nInitialOffset) * (fPercentage * fPercentage);
         nNewOffset = ftoi(nTargetOffset - nOffset);
     }
diff --git a/src/ui/viewmodels/WindowManager.hh b/src/ui/viewmodels/WindowManager.hh
index 0b22b821..aface792 100644
--- a/src/ui/viewmodels/WindowManager.hh
+++ b/src/ui/viewmodels/WindowManager.hh
@@ -1,6 +1,7 @@
 #ifndef RA_UI_WINDOW_MANAGER
 #define RA_UI_WINDOW_MANAGER
 
+#include "AssetListViewModel.hh"
 #include "CodeNotesViewModel.hh"
 #include "EmulatorViewModel.hh"
 #include "MemoryBookmarksViewModel.hh"
@@ -15,6 +16,7 @@ class WindowManager {
 public:
     EmulatorViewModel Emulator;
     RichPresenceMonitorViewModel RichPresenceMonitor;
+    AssetListViewModel AssetList;
     MemoryBookmarksViewModel MemoryBookmarks;
     MemoryInspectorViewModel MemoryInspector;
     CodeNotesViewModel CodeNotes;


Test patch:
diff --git a/tests/RA_Integration.Tests.vcxproj b/tests/RA_Integration.Tests.vcxproj
index fc9a456d..864b7d92 100644
--- a/tests/RA_Integration.Tests.vcxproj
+++ b/tests/RA_Integration.Tests.vcxproj
@@ -312,6 +312,7 @@
     <ClCompile Include="..\src\ui\Theme.cpp" />
     <ClCompile Include="..\src\ui\TransactionalViewModelBase.cpp" />
     <ClCompile Include="..\src\ui\ViewModelCollection.cpp" />
+    <ClCompile Include="..\src\ui\viewmodels\AchievementViewModel.cpp" />
     <ClCompile Include="..\src\ui\viewmodels\AssetListViewModel.cpp" />
     <ClCompile Include="..\src\ui\viewmodels\AssetViewModelBase.cpp" />
     <ClCompile Include="..\src\ui\viewmodels\BrokenAchievementsViewModel.cpp" />
diff --git a/tests/RA_Integration.Tests.vcxproj.filters b/tests/RA_Integration.Tests.vcxproj.filters
index 300b902b..23bcdd31 100644
--- a/tests/RA_Integration.Tests.vcxproj.filters
+++ b/tests/RA_Integration.Tests.vcxproj.filters
@@ -360,6 +360,9 @@
     <ClCompile Include="ui\viewmodels\TriggerViewModel_Tests.cpp">
       <Filter>Tests\UI\ViewModels</Filter>
     </ClCompile>
+    <ClCompile Include="..\src\ui\viewmodels\AchievementViewModel.cpp">
+      <Filter>Code</Filter>
+    </ClCompile>
   </ItemGroup>
   <ItemGroup>
     <None Include="base.props" />
diff --git a/tests/RA_UnitTestHelpers.h b/tests/RA_UnitTestHelpers.h
index 1f81622c..b4576b64 100644
--- a/tests/RA_UnitTestHelpers.h
+++ b/tests/RA_UnitTestHelpers.h
@@ -16,6 +16,7 @@
 #include "ui\ImageReference.hh"
 #include "ui\WindowViewModelBase.hh"
 #include "ui\viewmodels\MessageBoxViewModel.hh"
+#include "ui\viewmodels\AssetViewModelBase.hh"
 
 // The rcheevos nameless struct warning is only affecting the test project, for now we have
 // to disable the warning in the project or pragmatically in rcheevos. Careful not to use nameless structs here.
@@ -203,6 +204,61 @@ std::wstring ToString<Achievement::Category>(const Achievement::Category& catego
     }
 }
 
+template<>
+std::wstring ToString<ra::ui::viewmodels::AssetCategory>(
+    const ra::ui::viewmodels::AssetCategory& nAssetCategory)
+{
+    switch (nAssetCategory)
+    {
+        case ra::ui::viewmodels::AssetCategory::Local:
+            return L"Local";
+        case ra::ui::viewmodels::AssetCategory::Core:
+            return L"Core";
+        case ra::ui::viewmodels::AssetCategory::Unofficial:
+            return L"Unofficial";
+        default:
+            return std::to_wstring(static_cast<int>(nAssetCategory));
+    }
+}
+
+template<>
+std::wstring ToString<ra::ui::viewmodels::AssetState>(
+    const ra::ui::viewmodels::AssetState& nAssetState)
+{
+    switch (nAssetState)
+    {
+        case ra::ui::viewmodels::AssetState::Inactive:
+            return L"Inactive";
+        case ra::ui::viewmodels::AssetState::Active:
+            return L"Active";
+        case ra::ui::viewmodels::AssetState::Triggered:
+            return L"Triggered";
+        case ra::ui::viewmodels::AssetState::Waiting:
+            return L"Waiting";
+        case ra::ui::viewmodels::AssetState::Paused:
+            return L"Paused";
+        default:
+            return std::to_wstring(static_cast<int>(nAssetState));
+    }
+}
+
+template<>
+std::wstring ToString<ra::ui::viewmodels::AssetChanges>(
+    const ra::ui::viewmodels::AssetChanges& nAssetChanges)
+{
+    switch (nAssetChanges)
+    {
+        case ra::ui::viewmodels::AssetChanges::None:
+            return L"None";
+        case ra::ui::viewmodels::AssetChanges::Modified:
+            return L"Modified";
+        case ra::ui::viewmodels::AssetChanges::Unpublished:
+            return L"Unpublished";
+        default:
+            return std::to_wstring(static_cast<int>(nAssetChanges));
+    }
+}
+
 #pragma warning(pop)
 
 } // namespace CppUnitTestFramework
diff --git a/tests/data/GameContext_Tests.cpp b/tests/data/GameContext_Tests.cpp
index aa5db8ce..8ca641b4 100644
--- a/tests/data/GameContext_Tests.cpp
+++ b/tests/data/GameContext_Tests.cpp
@@ -4,6 +4,8 @@
 
 #include "services\AchievementRuntime.hh"
 
+#include "ui\viewmodels\AchievementViewModel.hh"
+
 #include "tests\RA_UnitTestHelpers.h"
 
 #include "tests\mocks\MockAudioSystem.hh"
@@ -16,6 +18,7 @@
 #include "tests\mocks\MockSessionTracker.hh"
 #include "tests\mocks\MockThreadPool.hh"
 #include "tests\mocks\MockUserContext.hh"
+#include "tests\mocks\MockWindowManager.hh"
 
 using namespace Microsoft::VisualStudio::CppUnitTestFramework;
 
@@ -54,6 +57,7 @@ public:
         ra::data::mocks::MockSessionTracker mockSessionTracker;
         ra::data::mocks::MockUserContext mockUser;
         ra::services::AchievementRuntime runtime;
+        ra::ui::viewmodels::mocks::MockWindowManager mockWindowManager;
 
         static const unsigned int FirstLocalId = GameContext::FirstLocalId;
 
@@ -319,6 +323,32 @@ public:
         Assert::AreEqual(1234599999, (int)pAch2->ModifiedDate());
         Assert::AreEqual(15U, pAch2->Points());
         Assert::AreEqual(std::string("1=1"), pAch2->GetTrigger());
+
+        Assert::AreEqual({ 2U }, game.mockWindowManager.AssetList.Assets().Count());
+
+        const auto* vmAch1 = dynamic_cast<ra::ui::viewmodels::AchievementViewModel*>(game.mockWindowManager.AssetList.Assets().GetItemAt(0));
+        Assert::IsNotNull(vmAch1);
+        Ensures(vmAch1 != nullptr);
+        Assert::AreEqual(5U, vmAch1->GetID());
+        Assert::AreEqual(std::wstring(L"Ach1"), vmAch1->GetName());
+        Assert::AreEqual(std::wstring(L"Desc1"), vmAch1->GetDescription());
+        Assert::AreEqual(ra::ui::viewmodels::AssetCategory::Core, vmAch1->GetCategory());
+        Assert::AreEqual(5, vmAch1->GetPoints());
+        Assert::AreEqual(std::wstring(L"12345"), vmAch1->GetBadge());
+        Assert::AreEqual(std::string("1=1"), vmAch1->GetTrigger());
+        Assert::IsFalse(vmAch1->IsModified());
+
+        const auto* vmAch2 = dynamic_cast<ra::ui::viewmodels::AchievementViewModel*>(game.mockWindowManager.AssetList.Assets().GetItemAt(1));
+        Assert::IsNotNull(vmAch2);
+        Ensures(vmAch2 != nullptr);
+        Assert::AreEqual(7U, vmAch2->GetID());
+        Assert::AreEqual(std::wstring(L"Ach2"), vmAch2->GetName());
+        Assert::AreEqual(std::wstring(L"Desc2"), vmAch2->GetDescription());
+        Assert::AreEqual(ra::ui::viewmodels::AssetCategory::Unofficial, vmAch2->GetCategory());
+        Assert::AreEqual(15, vmAch2->GetPoints());
+        Assert::AreEqual(std::wstring(L"12345"), vmAch2->GetBadge());
+        Assert::AreEqual(std::string("1=1"), vmAch2->GetTrigger());
+        Assert::IsFalse(vmAch2->IsModified());
     }
 
     TEST_METHOD(TestLoadGameInvalidAchievementFlags)
@@ -369,6 +399,20 @@ public:
         Assert::AreEqual(1234599999, (int)pAch2->ModifiedDate());
         Assert::AreEqual(15U, pAch2->Points());
         Assert::AreEqual(std::string("1=1"), pAch2->GetTrigger());
+
+        Assert::AreEqual({ 1U }, game.mockWindowManager.AssetList.Assets().Count());
+
+        const auto* vmAch2 = dynamic_cast<ra::ui::viewmodels::AchievementViewModel*>(game.mockWindowManager.AssetList.Assets().GetItemAt(0));
+        Assert::IsNotNull(vmAch2);
+        Ensures(vmAch2 != nullptr);
+        Assert::AreEqual(7U, vmAch2->GetID());
+        Assert::AreEqual(std::wstring(L"Ach2"), vmAch2->GetName());
+        Assert::AreEqual(std::wstring(L"Desc2"), vmAch2->GetDescription());
+        Assert::AreEqual(ra::ui::viewmodels::AssetCategory::Unofficial, vmAch2->GetCategory());
+        Assert::AreEqual(15, vmAch2->GetPoints());
+        Assert::AreEqual(std::wstring(L"12345"), vmAch2->GetBadge());
+        Assert::AreEqual(std::string("1=1"), vmAch2->GetTrigger());
+        Assert::IsFalse(vmAch2->IsModified());
     }
 
     TEST_METHOD(TestLoadGameMergeLocalAchievements)
@@ -464,9 +508,70 @@ public:
         Assert::AreEqual(10U, pAch->Points());
         Assert::AreEqual(std::string("R:1=1"), pAch->GetTrigger());
 
+
+
+        Assert::AreEqual({ 4U }, game.mockWindowManager.AssetList.Assets().Count());
+
+        // server only achievement
+        const auto* vmAch = dynamic_cast<ra::ui::viewmodels::AchievementViewModel*>(game.mockWindowManager.AssetList.Assets().GetItemAt(0));
+        Assert::IsNotNull(vmAch);
+        Ensures(vmAch != nullptr);
+        Assert::AreEqual(5U, vmAch->GetID());
+        Assert::AreEqual(std::wstring(L"Ach1"), vmAch->GetName());
+        Assert::AreEqual(std::wstring(L"Desc1"), vmAch->GetDescription());
+        Assert::AreEqual(ra::ui::viewmodels::AssetCategory::Core, vmAch->GetCategory());
+        Assert::AreEqual(5, vmAch->GetPoints());
+        Assert::AreEqual(std::wstring(L"12345"), vmAch->GetBadge());
+        Assert::AreEqual(std::string("1=1"), vmAch->GetTrigger());
+        Assert::IsFalse(vmAch->IsModified());
+        Assert::AreEqual(ra::ui::viewmodels::AssetChanges::None, vmAch->GetChanges());
+
+        // local achievement data for 7 should be merged with server achievement data
+        vmAch = dynamic_cast<ra::ui::viewmodels::AchievementViewModel*>(game.mockWindowManager.AssetList.Assets().GetItemAt(1));
+        Assert::IsNotNull(vmAch);
+        Ensures(vmAch != nullptr);
+        Assert::AreEqual(7U, vmAch->GetID());
+        Assert::AreEqual(std::wstring(L"Ach2b"), vmAch->GetName());
+        Assert::AreEqual(std::wstring(L"Desc2b"), vmAch->GetDescription());
+        Assert::AreEqual(ra::ui::viewmodels::AssetCategory::Unofficial, vmAch->GetCategory());
+        Assert::AreEqual(25, vmAch->GetPoints());
+        Assert::AreEqual(std::wstring(L"54321"), vmAch->GetBadge());
+        Assert::AreEqual(std::string("1=2"), vmAch->GetTrigger());
+        Assert::IsFalse(vmAch->IsModified());
+        Assert::AreEqual(ra::ui::viewmodels::AssetChanges::Unpublished, vmAch->GetChanges());
+
+        // no server achievement, assign FirstLocalId
+        vmAch = dynamic_cast<ra::ui::viewmodels::AchievementViewModel*>(game.mockWindowManager.AssetList.Assets().GetItemAt(2));
+        Assert::IsNotNull(vmAch);
+        Ensures(vmAch != nullptr);
+        Assert::AreEqual(GameContextHarness::FirstLocalId + 2, vmAch->GetID()); // non-vms get first id and first id + 1
+        Assert::AreEqual(std::wstring(L"Ach3"), vmAch->GetName());
+        Assert::AreEqual(std::wstring(L"Desc3"), vmAch->GetDescription());
+        Assert::AreEqual(ra::ui::viewmodels::AssetCategory::Local, vmAch->GetCategory());
+        Assert::AreEqual(20, vmAch->GetPoints());
+        Assert::AreEqual(std::wstring(L"00555"), vmAch->GetBadge());
+        Assert::AreEqual(std::string("1=1"), vmAch->GetTrigger());
+        Assert::IsFalse(vmAch->IsModified());
+        Assert::AreEqual(ra::ui::viewmodels::AssetChanges::Unpublished, vmAch->GetChanges());
+
+        // no server achievement, assign next local id
+        vmAch = dynamic_cast<ra::ui::viewmodels::AchievementViewModel*>(game.mockWindowManager.AssetList.Assets().GetItemAt(3));
+        Assert::IsNotNull(vmAch);
+        Ensures(vmAch != nullptr);
+        Assert::AreEqual(GameContextHarness::FirstLocalId + 3, vmAch->GetID()); // non-vms get first id and first id + 1
+        Assert::AreEqual(std::wstring(L"Ach4"), vmAch->GetName());
+        Assert::AreEqual(std::wstring(L"Desc4"), vmAch->GetDescription());
+        Assert::AreEqual(ra::ui::viewmodels::AssetCategory::Local, vmAch->GetCategory());
+        Assert::AreEqual(10, vmAch->GetPoints());
+        Assert::AreEqual(std::wstring(L"00556"), vmAch->GetBadge());
+        Assert::AreEqual(std::string("R:1=1"), vmAch->GetTrigger());
+        Assert::IsFalse(vmAch->IsModified());
+        Assert::AreEqual(ra::ui::viewmodels::AssetChanges::Unpublished, vmAch->GetChanges());
+
+
         // new achievement should be allocated an ID higher than the largest existing local ID
         const auto& pAch2 = game.NewAchievement(Achievement::Category::Local);
-        Assert::AreEqual(GameContextHarness::FirstLocalId + 2, pAch2.ID());
+        Assert::AreEqual(GameContextHarness::FirstLocalId + 4, pAch2.ID());
     }
 
     TEST_METHOD(TestLoadGameMergeLocalAchievementsWithIds)
@@ -529,6 +634,53 @@ public:
         Assert::AreEqual(10U, pAch->Points());
         Assert::AreEqual(std::string("1=1"), pAch->GetTrigger());
 
+
+
+        Assert::AreEqual({ 3U }, game.mockWindowManager.AssetList.Assets().Count());
+
+        // 7 is not a known ID for this game, it should be loaded into a local achievement
+        const auto* vmAch = dynamic_cast<ra::ui::viewmodels::AchievementViewModel*>(game.mockWindowManager.AssetList.Assets().GetItemAt(0));
+        Assert::IsNotNull(vmAch);
+        Ensures(vmAch != nullptr);
+        Assert::AreEqual(7U, vmAch->GetID());
+        Assert::AreEqual(std::wstring(L"Ach2b"), vmAch->GetName());
+        Assert::AreEqual(std::wstring(L"Desc2b"), vmAch->GetDescription());
+        Assert::AreEqual(ra::ui::viewmodels::AssetCategory::Local, vmAch->GetCategory());
+        Assert::AreEqual(25, vmAch->GetPoints());
+        Assert::AreEqual(std::wstring(L"54321"), vmAch->GetBadge());
+        Assert::AreEqual(std::string("1=2"), vmAch->GetTrigger());
+        Assert::IsFalse(vmAch->IsModified());
+        Assert::AreEqual(ra::ui::viewmodels::AssetChanges::Unpublished, vmAch->GetChanges());
+
+        // explicit ID should be honored
+        vmAch = dynamic_cast<ra::ui::viewmodels::AchievementViewModel*>(game.mockWindowManager.AssetList.Assets().GetItemAt(1));
+        Assert::IsNotNull(vmAch);
+        Ensures(vmAch != nullptr);
+        Assert::AreEqual(999000001U, vmAch->GetID()); // non-vms get first id and first id + 1
+        Assert::AreEqual(std::wstring(L"Ach3"), vmAch->GetName());
+        Assert::AreEqual(std::wstring(L"Desc3"), vmAch->GetDescription());
+        Assert::AreEqual(ra::ui::viewmodels::AssetCategory::Local, vmAch->GetCategory());
+        Assert::AreEqual(20, vmAch->GetPoints());
+        Assert::AreEqual(std::wstring(L"00555"), vmAch->GetBadge());
+        Assert::AreEqual(std::string("1=1"), vmAch->GetTrigger());
+        Assert::IsFalse(vmAch->IsModified());
+        Assert::AreEqual(ra::ui::viewmodels::AssetChanges::Unpublished, vmAch->GetChanges());
+
+        // explicit ID should be honored
+        vmAch = dynamic_cast<ra::ui::viewmodels::AchievementViewModel*>(game.mockWindowManager.AssetList.Assets().GetItemAt(2));
+        Assert::IsNotNull(vmAch);
+        Ensures(vmAch != nullptr);
+        Assert::AreEqual(999000003U, vmAch->GetID()); // non-vms get first id and first id + 1
+        Assert::AreEqual(std::wstring(L"Ach4"), vmAch->GetName());
+        Assert::AreEqual(std::wstring(L"Desc4"), vmAch->GetDescription());
+        Assert::AreEqual(ra::ui::viewmodels::AssetCategory::Local, vmAch->GetCategory());
+        Assert::AreEqual(10, vmAch->GetPoints());
+        Assert::AreEqual(std::wstring(L"00556"), vmAch->GetBadge());
+        Assert::AreEqual(std::string("1=1"), vmAch->GetTrigger());
+        Assert::IsFalse(vmAch->IsModified());
+        Assert::AreEqual(ra::ui::viewmodels::AssetChanges::Unpublished, vmAch->GetChanges());
+
+
         // new achievement should be allocated an ID higher than the largest existing local
         // ID, even if intermediate values are available
         const auto& pAch2 = game.NewAchievement(Achievement::Category::Local);
diff --git a/tests/ui/viewmodels/AssetViewModelBase_Tests.cpp b/tests/ui/viewmodels/AssetViewModelBase_Tests.cpp
index 0ab973aa..a3140522 100644
--- a/tests/ui/viewmodels/AssetViewModelBase_Tests.cpp
+++ b/tests/ui/viewmodels/AssetViewModelBase_Tests.cpp
@@ -29,61 +29,6 @@ std::wstring ToString<ra::ui::viewmodels::AssetType>(
     }
 }
 
-template<>
-std::wstring ToString<ra::ui::viewmodels::AssetCategory>(
-    const ra::ui::viewmodels::AssetCategory& nAssetCategory)
-{
-    switch (nAssetCategory)
-    {
-        case ra::ui::viewmodels::AssetCategory::Local:
-            return L"Local";
-        case ra::ui::viewmodels::AssetCategory::Core:
-            return L"Core";
-        case ra::ui::viewmodels::AssetCategory::Unofficial:
-            return L"Unofficial";
-        default:
-            return std::to_wstring(static_cast<int>(nAssetCategory));
-    }
-}
-
-template<>
-std::wstring ToString<ra::ui::viewmodels::AssetState>(
-    const ra::ui::viewmodels::AssetState& nAssetState)
-{
-    switch (nAssetState)
-    {
-        case ra::ui::viewmodels::AssetState::Inactive:
-            return L"Inactive";
-        case ra::ui::viewmodels::AssetState::Active:
-            return L"Active";
-        case ra::ui::viewmodels::AssetState::Triggered:
-            return L"Triggered";
-        case ra::ui::viewmodels::AssetState::Waiting:
-            return L"Waiting";
-        case ra::ui::viewmodels::AssetState::Paused:
-            return L"Paused";
-        default:
-            return std::to_wstring(static_cast<int>(nAssetState));
-    }
-}
-
-template<>
-std::wstring ToString<ra::ui::viewmodels::AssetChanges>(
-    const ra::ui::viewmodels::AssetChanges& nAssetChanges)
-{
-    switch (nAssetChanges)
-    {
-        case ra::ui::viewmodels::AssetChanges::None:
-            return L"None";
-        case ra::ui::viewmodels::AssetChanges::Modified:
-            return L"Modified";
-        case ra::ui::viewmodels::AssetChanges::Unpublished:
-            return L"Unpublished";
-        default:
-            return std::to_wstring(static_cast<int>(nAssetChanges));
-    }
-}
-
 } // namespace CppUnitTestFramework
 } // namespace VisualStudio
 } // namespace Microsoft
@@ -126,6 +71,32 @@ private:
             WriteQuoted(pWriter, Utf8QuotedString);
             WritePossiblyQuoted(pWriter, Utf8String);
         }
+
+        bool Deserialize(ra::Tokenizer& pTokenizer) override
+        {
+            std::wstring sName;
+            if (!ReadQuoted(pTokenizer, sName))
+                return false;
+            SetName(sName);
+
+            std::wstring sString;
+            if (!ReadPossiblyQuoted(pTokenizer, sString))
+                return false;
+            SetString(sString);
+
+            uint32_t nNumber;
+            if (!ReadNumber(pTokenizer, nNumber))
+                return false;
+            SetInt(nNumber);
+
+            if (!ReadQuoted(pTokenizer, Utf8QuotedString))
+                return false;
+
+            if (!ReadPossiblyQuoted(pTokenizer, Utf8String))
+                return false;
+
+            return true;
+        }
     };
 
     class AssetDefinitionViewModelHarness : public AssetViewModelBase
@@ -140,6 +111,7 @@ private:
         void SetDefinition(const std::string& sValue) { SetAssetDefinition(m_pDefinition, sValue); }
 
         void Serialize(ra::services::TextWriter&) const noexcept override {}
+        bool Deserialize(ra::Tokenizer&) noexcept override { return false; }
 
     protected:
         static const IntModelProperty DefinitionProperty;
@@ -222,6 +194,134 @@ public:
         Assert::AreEqual(std::string(":\"A\\\\B\":\"A\\\\B\":0:\"A\\\\B\":\"A\\\\B\""), asset.textWriter.GetString());
     }
 
+    TEST_METHOD(TestDeserializeSimpleValues)
+    {
+        AssetViewModelHarness asset;
+
+        std::string sSerialized = ":\"Name\":String:99:\"UTF-8-Quoted\":UTF-8";
+        ra::Tokenizer pTokenizer(sSerialized);
+        pTokenizer.Advance(); // skip leading colon
+        Assert::IsTrue(asset.Deserialize(pTokenizer));
+
+        Assert::AreEqual(std::wstring(L"Name"), asset.GetName());
+        Assert::AreEqual(std::wstring(L"String"), asset.GetString());
+        Assert::AreEqual(99U, asset.GetInt());
+        Assert::AreEqual(std::string("UTF-8-Quoted"), asset.Utf8QuotedString);
+        Assert::AreEqual(std::string("UTF-8"), asset.Utf8String);
+    }
+
+    TEST_METHOD(TestDeserializeColon)
+    {
+        AssetViewModelHarness asset;
+
+        std::string sSerialized = ":\"Mission: Impossible\":\"Mission: Impossible\":0:\"Mission: Impossible\":\"Mission: Impossible\"";
+        ra::Tokenizer pTokenizer(sSerialized);
+        pTokenizer.Advance(); // skip leading colon
+        Assert::IsTrue(asset.Deserialize(pTokenizer));
+
+        Assert::AreEqual(std::wstring(L"Mission: Impossible"), asset.GetName());
+        Assert::AreEqual(std::wstring(L"Mission: Impossible"), asset.GetString());
+        Assert::AreEqual(0U, asset.GetInt());
+        Assert::AreEqual(std::string("Mission: Impossible"), asset.Utf8QuotedString);
+        Assert::AreEqual(std::string("Mission: Impossible"), asset.Utf8String);
+    }
+
+    TEST_METHOD(TestDeserializeQuotes)
+    {
+        AssetViewModelHarness asset;
+
+        std::string sSerialized = ":\"A \\\"Test\\\" B\":\"A \\\"Test\\\" B\":0:\"A \\\"Test\\\" B\":\"A \\\"Test\\\" B\"";
+        ra::Tokenizer pTokenizer(sSerialized);
+        pTokenizer.Advance(); // skip leading colon
+        Assert::IsTrue(asset.Deserialize(pTokenizer));
+
+        Assert::AreEqual(std::wstring(L"A \"Test\" B"), asset.GetName());
+        Assert::AreEqual(std::wstring(L"A \"Test\" B"), asset.GetString());
+        Assert::AreEqual(0U, asset.GetInt());
+        Assert::AreEqual(std::string("A \"Test\" B"), asset.Utf8QuotedString);
+        Assert::AreEqual(std::string("A \"Test\" B"), asset.Utf8String);
+    }
+
+    TEST_METHOD(TestDeserializeBackslash)
+    {
+        AssetViewModelHarness asset;
+
+        std::string sSerialized = ":\"A\\\\B\":\"A\\\\B\":0:\"A\\\\B\":\"A\\\\B\"";
+        ra::Tokenizer pTokenizer(sSerialized);
+        pTokenizer.Advance(); // skip leading colon
+        Assert::IsTrue(asset.Deserialize(pTokenizer));
+
+        Assert::AreEqual(std::wstring(L"A\\B"), asset.GetName());
+        Assert::AreEqual(std::wstring(L"A\\B"), asset.GetString());
+        Assert::AreEqual(0U, asset.GetInt());
+        Assert::AreEqual(std::string("A\\B"), asset.Utf8QuotedString);
+        Assert::AreEqual(std::string("A\\B"), asset.Utf8String);
+    }
+
+    TEST_METHOD(TestDeserializeInvalidNumber)
+    {
+        AssetViewModelHarness asset;
+
+        std::string sSerialized = ":\"A\":B:C:\"D\":E";
+        ra::Tokenizer pTokenizer(sSerialized);
+        pTokenizer.Advance(); // skip leading colon
+        Assert::IsFalse(asset.Deserialize(pTokenizer));
+
+        Assert::AreEqual(std::wstring(L"A"), asset.GetName());
+        Assert::AreEqual(std::wstring(L"B"), asset.GetString());
+        Assert::AreEqual(0U, asset.GetInt());
+        Assert::AreEqual(std::string(), asset.Utf8QuotedString);
+        Assert::AreEqual(std::string(), asset.Utf8String);
+    }
+
+    TEST_METHOD(TestDeserializeMissingField)
+    {
+        AssetViewModelHarness asset;
+
+        std::string sSerialized = ":\"A\":B:5:\"D\"";
+        ra::Tokenizer pTokenizer(sSerialized);
+        pTokenizer.Advance(); // skip leading colon
+        Assert::IsFalse(asset.Deserialize(pTokenizer));
+
+        Assert::AreEqual(std::wstring(L"A"), asset.GetName());
+        Assert::AreEqual(std::wstring(L"B"), asset.GetString());
+        Assert::AreEqual(5U, asset.GetInt());
+        Assert::AreEqual(std::string("D"), asset.Utf8QuotedString);
+        Assert::AreEqual(std::string(), asset.Utf8String);
+    }
+
+    TEST_METHOD(TestDeserializeNotQuoted)
+    {
+        AssetViewModelHarness asset;
+
+        std::string sSerialized = ":A:B:5:D:E";
+        ra::Tokenizer pTokenizer(sSerialized);
+        pTokenizer.Advance(); // skip leading colon
+        Assert::IsFalse(asset.Deserialize(pTokenizer));
+
+        Assert::AreEqual(std::wstring(), asset.GetName());
+        Assert::AreEqual(std::wstring(), asset.GetString());
+        Assert::AreEqual(0U, asset.GetInt());
+        Assert::AreEqual(std::string(), asset.Utf8QuotedString);
+        Assert::AreEqual(std::string(), asset.Utf8String);
+    }
+
+    TEST_METHOD(TestDeserializeBadlyQuoted)
+    {
+        AssetViewModelHarness asset;
+
+        std::string sSerialized = ":\"A\":\"B\"C:5:\"D\":E";
+        ra::Tokenizer pTokenizer(sSerialized);
+        pTokenizer.Advance(); // skip leading colon
+        Assert::IsFalse(asset.Deserialize(pTokenizer));
+
+        Assert::AreEqual(std::wstring(L"A"), asset.GetName());
+        Assert::AreEqual(std::wstring(), asset.GetString());
+        Assert::AreEqual(0U, asset.GetInt());
+        Assert::AreEqual(std::string(), asset.Utf8QuotedString);
+        Assert::AreEqual(std::string(), asset.Utf8String);
+    }
+
     TEST_METHOD(TestUpdateCheckpoint)
     {
         AssetViewModelHarness asset;