Repository: RetroAchievements/RAIntegration
Pull request #597 (2020-08-22 22:40:39)

add AchievementViewModel


Patch:
diff --git a/src/Exports.cpp b/src/Exports.cpp
index 724c7c24..f3842c01 100644
--- a/src/Exports.cpp
+++ b/src/Exports.cpp
@@ -166,7 +166,7 @@ API const char* CCONV _RA_UserName()
 API void CCONV _RA_SetConsoleID(unsigned int nConsoleId)
 {
     auto pContext = std::make_unique<ra::data::ConsoleContext>(ra::itoe<ConsoleID>(nConsoleId));
-    RA_LOG("Console set to %u (%s)", pContext->Id(), pContext->Name());
+    RA_LOG_INFO("Console set to %u (%s)", pContext->Id(), pContext->Name());
     ra::services::ServiceLocator::Provide<ra::data::ConsoleContext>(std::move(pContext));
 }
 
diff --git a/src/RA_Achievement.cpp b/src/RA_Achievement.cpp
index 8768c5a1..bd14b218 100644
--- a/src/RA_Achievement.cpp
+++ b/src/RA_Achievement.cpp
@@ -174,7 +174,7 @@ void Achievement::RebuildTrigger()
         if (nResult != RC_OK)
         {
             // parse error occurred
-            RA_LOG("rc_parse_trigger returned %d for achievement %u", nResult, ID());
+            RA_LOG_WARN("rc_parse_trigger returned %d for achievement %u", nResult, ID());
 
             ra::ui::viewmodels::MessageBoxViewModel::ShowWarningMessage(
                 ra::StringPrintf(L"Unable to rebuild achievement: %s", Title()),
@@ -409,7 +409,7 @@ void Achievement::GenerateConditions()
             if (nSize < 0)
             {
                 // parse error occurred
-                RA_LOG("rc_parse_trigger returned %d for achievement %u", nSize, ID());
+                RA_LOG_WARN("rc_parse_trigger returned %d for achievement %u", nSize, ID());
 
                 ra::ui::viewmodels::MessageBoxViewModel::ShowWarningMessage(
                     ra::StringPrintf(L"Unable to activate achievement: %s", Title()),
@@ -516,7 +516,7 @@ void Achievement::SetActive(BOOL bActive) noexcept
                 if (nResult != RC_OK)
                 {
                     // parse error occurred
-                    RA_LOG("rc_parse_trigger returned %d for achievement %u", nResult, ID());
+                    RA_LOG_WARN("rc_parse_trigger returned %d for achievement %u", nResult, ID());
 
                     ra::ui::viewmodels::MessageBoxViewModel::ShowWarningMessage(
                         ra::StringPrintf(L"Unable to activate achievement: %s", Title()),
diff --git a/src/RA_Dlg_GameLibrary.cpp b/src/RA_Dlg_GameLibrary.cpp
index 2de9136c..3232e559 100644
--- a/src/RA_Dlg_GameLibrary.cpp
+++ b/src/RA_Dlg_GameLibrary.cpp
@@ -78,7 +78,7 @@ inline static void LogErrno() noexcept
     std::array<char, 2048> buf{};
     strerror_s(buf.data(), sizeof(buf), errno);
     // TODO: Make StringPrintf support std::array
-    GSL_SUPPRESS_F6 RA_LOG("Error: %s", buf.data());
+    GSL_SUPPRESS_F6 RA_LOG_ERR("Error: %s", buf.data());
 }
 
 } /* namespace ra */
@@ -325,7 +325,7 @@ void Dlg_GameLibrary::ScanAndAddRomsRecursive(const std::string& sBaseDir)
             }
             else if (ffd.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY)
             {
-                RA_LOG("Directory found: %s\n", ffd.cFileName);
+                RA_LOG_INFO("Directory found: %s", ffd.cFileName);
                 std::string sRecurseDir = sBaseDir + "\\" + sFilename.c_str();
                 ScanAndAddRomsRecursive(sRecurseDir);
             }
@@ -337,12 +337,12 @@ void Dlg_GameLibrary::ScanAndAddRomsRecursive(const std::string& sBaseDir)
                 if (filesize.QuadPart < 2048 || filesize.QuadPart > ROM_MAX_SIZE)
                 {
                     //	Ignore: wrong size
-                    RA_LOG("Ignoring %s, wrong size\n", sFilename.c_str());
+                    RA_LOG_INFO("Ignoring %s, wrong size", sFilename.c_str());
                 }
                 else
                 {
                     //	Parse as ROM!
-                    RA_LOG("%s looks good: parsing!\n", sFilename.c_str());
+                    RA_LOG_INFO("%s looks good: parsing!", sFilename.c_str());
 
                     char sAbsFileDir[2048];
                     sprintf_s(sAbsFileDir, 2048, "%s\\%s", sBaseDir.c_str(), sFilename.c_str());
@@ -365,7 +365,7 @@ void Dlg_GameLibrary::ScanAndAddRomsRecursive(const std::string& sBaseDir)
                         if (m_GameHashLibrary.find(sHashOut) != m_GameHashLibrary.end())
                         {
                             const unsigned int nGameID = m_GameHashLibrary[std::string(sHashOut)];
-                            RA_LOG("Found one! Game ID %u (%s)", nGameID, m_GameTitlesLibrary[nGameID].c_str());
+                            RA_LOG_INFO("Found one! Game ID %u (%s)", nGameID, m_GameTitlesLibrary[nGameID].c_str());
 
                             const std::string& sGameTitle = m_GameTitlesLibrary[nGameID];
                             AddTitle(sGameTitle, sAbsFileDir, nGameID);
@@ -425,7 +425,7 @@ void Dlg_GameLibrary::RefreshList()
             {
                 //	Found in our hash library!
                 const auto nGameID = m_GameHashLibrary[md5];
-                RA_LOG("Found one! Game ID %u (%s)", nGameID, m_GameTitlesLibrary[nGameID].c_str());
+                RA_LOG_INFO("Found one! Game ID %u (%s)", nGameID, m_GameTitlesLibrary[nGameID].c_str());
 
                 const std::string& sGameTitle = m_GameTitlesLibrary[nGameID];
                 AddTitle(sGameTitle, filepath, nGameID);
@@ -612,7 +612,7 @@ INT_PTR CALLBACK Dlg_GameLibrary::GameLibraryProc(HWND hDlg, UINT uMsg, WPARAM w
                     return FALSE;
 
                 default:
-                    RA_LOG("%08x, %08x\n", wParam, lParam);
+                    RA_LOG_WARN("%08x, %08x", wParam, lParam);
                     return FALSE;
             }
 
@@ -646,7 +646,7 @@ INT_PTR CALLBACK Dlg_GameLibrary::GameLibraryProc(HWND hDlg, UINT uMsg, WPARAM w
                     {
                         auto& pConfiguration = ra::services::ServiceLocator::GetMutable<ra::services::IConfiguration>();
                         pConfiguration.SetRomDirectory(vmFileDialog.GetFileName());
-                        RA_LOG("Selected Folder: %s\n", vmFileDialog.GetFileName().c_str());
+                        RA_LOG_INFO("Selected Folder: %s", vmFileDialog.GetFileName().c_str());
                         SetDlgItemText(hDlg, IDC_RA_ROMDIR, NativeStr(vmFileDialog.GetFileName()).c_str());
                     }
                     return FALSE;
@@ -695,7 +695,7 @@ void Dlg_GameLibrary::KillThread()
     Dlg_GameLibrary::ThreadProcessingAllowed = false;
     while (Dlg_GameLibrary::ThreadProcessingActive)
     {
-        RA_LOG("Waiting for background scanner...");
+        RA_LOG_INFO("Waiting for background scanner...");
         Sleep(200);
     }
 }
diff --git a/src/RA_Integration.vcxproj b/src/RA_Integration.vcxproj
index 45713c9b..1c6be4f5 100644
--- a/src/RA_Integration.vcxproj
+++ b/src/RA_Integration.vcxproj
@@ -126,6 +126,7 @@
     <ClCompile Include="ui\TransactionalViewModelBase.cpp" />
     <ClCompile Include="ui\ViewModelBase.cpp" />
     <ClCompile Include="ui\ViewModelCollection.cpp" />
+    <ClCompile Include="ui\viewmodels\AchievementViewModel.cpp" />
     <ClCompile Include="ui\viewmodels\AssetListViewModel.cpp" />
     <ClCompile Include="ui\viewmodels\AssetViewModelBase.cpp" />
     <ClCompile Include="ui\viewmodels\BrokenAchievementsViewModel.cpp" />
@@ -284,6 +285,7 @@
     <ClInclude Include="ui\Types.hh" />
     <ClInclude Include="ui\ViewModelBase.hh" />
     <ClInclude Include="ui\ViewModelCollection.hh" />
+    <ClInclude Include="ui\viewmodels\AchievementViewModel.hh" />
     <ClInclude Include="ui\viewmodels\AssetListViewModel.hh" />
     <ClInclude Include="ui\viewmodels\AssetViewModelBase.hh" />
     <ClInclude Include="ui\viewmodels\BrokenAchievementsViewModel.hh" />
diff --git a/src/RA_Integration.vcxproj.filters b/src/RA_Integration.vcxproj.filters
index 1c27877c..0c81d617 100644
--- a/src/RA_Integration.vcxproj.filters
+++ b/src/RA_Integration.vcxproj.filters
@@ -330,6 +330,9 @@
     <ClCompile Include="ui\viewmodels\AssetListViewModel.cpp">
       <Filter>UI\ViewModels</Filter>
     </ClCompile>
+    <ClCompile Include="ui\viewmodels\AchievementViewModel.cpp">
+      <Filter>UI\ViewModels</Filter>
+    </ClCompile>
   </ItemGroup>
   <ItemGroup>
     <ClInclude Include="RA_Achievement.h">
@@ -826,13 +829,16 @@
     </ClInclude>
     <ClInclude Include="ui\viewmodels\TriggerConditionViewModel.hh">
       <Filter>UI\ViewModels</Filter>
-    </ClCompile>
+    </ClInclude>
     <ClInclude Include="ui\viewmodels\AssetViewModelBase.hh">
       <Filter>UI\ViewModels</Filter>
     </ClInclude>
     <ClInclude Include="ui\viewmodels\AssetListViewModel.hh">
       <Filter>UI\ViewModels</Filter>
     </ClInclude>
+    <ClInclude Include="ui\viewmodels\AchievementViewModel.hh">
+      <Filter>UI\ViewModels</Filter>
+    </ClInclude>
   </ItemGroup>
   <ItemGroup>
     <ResourceCompile Include="RA_Shared.rc">
diff --git a/src/RA_Leaderboard.cpp b/src/RA_Leaderboard.cpp
index dbeac8d2..77cacee3 100644
--- a/src/RA_Leaderboard.cpp
+++ b/src/RA_Leaderboard.cpp
@@ -49,7 +49,7 @@ void RA_Leaderboard::SetActive(bool bActive) noexcept
                 if (nResult < 0)
                 {
                     // parse error occurred
-                    RA_LOG("rc_parse_trigger returned %d for achievement %u", nResult, ID());
+                    RA_LOG_WARN("rc_parse_trigger returned %d for achievement %u", nResult, ID());
 
                     ra::ui::viewmodels::MessageBoxViewModel::ShowWarningMessage(
                         ra::StringPrintf(L"Unable to activate leaderboard: %s", Title()),
diff --git a/src/RA_Log.h b/src/RA_Log.h
index b6e07165..1344d530 100644
--- a/src/RA_Log.h
+++ b/src/RA_Log.h
@@ -22,17 +22,6 @@
         __pLogger.LogMessage(lvl, ra::StringPrintf(__VA_ARGS__)); \
 }
 
-// legacy log message writes at Info level and may need trailing newline removed
-#define RA_LOG(...) \
-{ \
-    const auto& __pLogger = ra::services::ServiceLocator::Get<ra::services::ILogger>(); \
-    if (__pLogger.IsEnabled(ra::services::LogLevel::Info)) \
-    { \
-        std::string __sFormatted = ra::StringPrintf(__VA_ARGS__); \
-        __pLogger.LogMessage(ra::services::LogLevel::Info, ra::TrimLineEnding(__sFormatted)); \
-    } \
-}
-
 #endif
 
 #define RA_LOG_INFO(...) RA_LOG_LEVEL(ra::services::LogLevel::Info, __VA_ARGS__)
diff --git a/src/data/EmulatorContext.cpp b/src/data/EmulatorContext.cpp
index df7b2369..a61cf983 100644
--- a/src/data/EmulatorContext.cpp
+++ b/src/data/EmulatorContext.cpp
@@ -149,7 +149,7 @@ void EmulatorContext::UpdateUserAgent()
         sUserAgent.append(m_sClientUserAgentDetail);
     }
 
-    RA_LOG("User-Agent: %s", sUserAgent.c_str());
+    RA_LOG_INFO("User-Agent: %s", sUserAgent.c_str());
 
     ra::services::ServiceLocator::GetMutable<ra::services::IHttpRequester>().SetUserAgent(sUserAgent);
 }
@@ -231,7 +231,7 @@ bool EmulatorContext::ValidateClientVersion(bool& bHardcore)
 
             const unsigned long long nServerVersion = ParseVersion(m_sLatestVersion.c_str());
             const unsigned long long nLocalVersion = ParseVersion(m_sVersion.c_str());
-            RA_LOG("Client %s date: server %s, current %s",
+            RA_LOG_INFO("Client %s date: server %s, current %s",
                    (nLocalVersion >= nServerVersion) ? "up to" : "out of",
                    m_sLatestVersion,
                    m_sVersion);
diff --git a/src/services/GameIdentifier.cpp b/src/services/GameIdentifier.cpp
index 263e08c8..93ac0554 100644
--- a/src/services/GameIdentifier.cpp
+++ b/src/services/GameIdentifier.cpp
@@ -71,7 +71,7 @@ unsigned int GameIdentifier::IdentifyHash(const std::string& sMD5)
         nGameId = response.GameId;
         if (nGameId == 0) // Unknown
         {
-            RA_LOG("Could not identify game with MD5 %s", sMD5);
+            RA_LOG_INFO("Could not identify game with MD5 %s", sMD5);
 
             auto sEstimatedGameTitle = ra::services::ServiceLocator::Get<ra::data::EmulatorContext>().GetGameTitle();
 
@@ -92,7 +92,7 @@ unsigned int GameIdentifier::IdentifyHash(const std::string& sMD5)
         }
         else
         {
-            RA_LOG("Successfully looked up game with ID %u", nGameId);
+            RA_LOG_INFO("Successfully looked up game with ID %u", nGameId);
         }
     }
     else
@@ -134,7 +134,7 @@ void GameIdentifier::ActivateGame(unsigned int nGameId)
             return;
         }
 
-        RA_LOG("Loading game %u", nGameId);
+        RA_LOG_INFO("Loading game %u", nGameId);
 
         auto& pOverlayManager = ra::services::ServiceLocator::GetMutable<ra::ui::viewmodels::OverlayManager>();
         pOverlayManager.ClearPopups();
@@ -187,7 +187,7 @@ void GameIdentifier::ActivateGame(unsigned int nGameId)
     }
     else
     {
-        RA_LOG("Unloading current game");
+        RA_LOG_INFO("Unloading current game");
 
         ra::services::ServiceLocator::GetMutable<ra::data::SessionTracker>().EndSession();
 
diff --git a/src/services/impl/JsonFileConfiguration.cpp b/src/services/impl/JsonFileConfiguration.cpp
index b02dc337..3fcb8b96 100644
--- a/src/services/impl/JsonFileConfiguration.cpp
+++ b/src/services/impl/JsonFileConfiguration.cpp
@@ -32,7 +32,7 @@ bool JsonFileConfiguration::Load(const std::wstring& sFilename)
         (1 << static_cast<int>(Feature::LeaderboardCounters)) |
         (1 << static_cast<int>(Feature::LeaderboardScoreboards));
 
-    RA_LOG("Loading preferences...");
+    RA_LOG_INFO("Loading preferences...");
 
     auto& pFileSystem = ra::services::ServiceLocator::Get<ra::services::IFileSystem>();
     auto pReader = pFileSystem.OpenTextFile(m_sFilename);
@@ -109,11 +109,11 @@ bool JsonFileConfiguration::Load(const std::wstring& sFilename)
 
 void JsonFileConfiguration::Save() const
 {
-    RA_LOG("Saving preferences...");
+    RA_LOG_INFO("Saving preferences...");
 
     if (m_sFilename.empty())
     {
-        RA_LOG(" - Aborting save, we don't know where to write...");
+        RA_LOG_WARN(" - Aborting save, we don't know where to write...");
         return;
     }
 
diff --git a/src/services/impl/WindowsFileSystem.cpp b/src/services/impl/WindowsFileSystem.cpp
index cac17fe9..65046bab 100644
--- a/src/services/impl/WindowsFileSystem.cpp
+++ b/src/services/impl/WindowsFileSystem.cpp
@@ -167,7 +167,7 @@ std::unique_ptr<TextReader> WindowsFileSystem::OpenTextFile(const std::wstring&
     auto pReader = std::make_unique<FileTextReader>(sAbsolutePath);
     if (!pReader->GetFStream().is_open())
     {
-        RA_LOG("Failed to open \"%s\": %d", ra::Narrow(sPath).c_str(), errno);
+        RA_LOG_INFO("Failed to open \"%s\": %d", ra::Narrow(sPath).c_str(), errno);
         return std::unique_ptr<TextReader>();
     }
 
@@ -182,7 +182,7 @@ std::unique_ptr<TextWriter> WindowsFileSystem::CreateTextFile(const std::wstring
     auto pWriter = std::make_unique<FileTextWriter>(sAbsolutePath);
     if (!pWriter->GetFStream().is_open())
     {
-        RA_LOG("Failed to create \"%s\": %d", ra::Narrow(sPath).c_str(), errno);
+        RA_LOG_WARN("Failed to create \"%s\": %d", ra::Narrow(sPath).c_str(), errno);
         return std::unique_ptr<TextWriter>();
     }
 
@@ -214,7 +214,7 @@ std::unique_ptr<TextWriter> WindowsFileSystem::AppendTextFile(const std::wstring
             // create failed
             if (ra::services::ServiceLocator::Exists<ra::services::ILogger>())
             {
-                RA_LOG("Failed to open \"%s\" for append: %d", ra::Narrow(sPath).c_str(), errno);
+                RA_LOG_WARN("Failed to open \"%s\" for append: %d", ra::Narrow(sPath).c_str(), errno);
             }
 
             return std::unique_ptr<TextWriter>();
diff --git a/src/ui/viewmodels/AchievementViewModel.cpp b/src/ui/viewmodels/AchievementViewModel.cpp
new file mode 100644
index 00000000..adfcf6f6
--- /dev/null
+++ b/src/ui/viewmodels/AchievementViewModel.cpp
@@ -0,0 +1,30 @@
+#include "AchievementViewModel.hh"
+
+#include "ui\viewmodels\MessageBoxViewModel.hh"
+
+namespace ra {
+namespace ui {
+namespace viewmodels {
+
+const IntModelProperty AchievementViewModel::PointsProperty("AchievementViewModel", "Points", 5);
+const StringModelProperty AchievementViewModel::BadgeProperty("AchievementViewModel", "Badge", L"00000");
+const BoolModelProperty AchievementViewModel::PauseOnResetProperty("AchievementViewModel", "PauseOnReset", false);
+const BoolModelProperty AchievementViewModel::PauseOnTriggerProperty("AchievementViewModel", "PauseOnTrigger", false);
+const IntModelProperty AchievementViewModel::TriggerProperty("AchievementViewModel", "Trigger", ra::etoi(AssetChanges::None));
+
+AchievementViewModel::AchievementViewModel() noexcept
+{
+    SetTransactional(PointsProperty);
+    SetTransactional(BadgeProperty);
+
+    GSL_SUPPRESS_F6 AddAssetDefinition(m_pTrigger, TriggerProperty);
+}
+
+void AchievementViewModel::SelectBadgeFile()
+{
+    ra::ui::viewmodels::MessageBoxViewModel::ShowWarningMessage(L"Not implemented");
+}
+
+} // namespace viewmodels
+} // namespace ui
+} // namespace ra
diff --git a/src/ui/viewmodels/AchievementViewModel.hh b/src/ui/viewmodels/AchievementViewModel.hh
new file mode 100644
index 00000000..e185b60a
--- /dev/null
+++ b/src/ui/viewmodels/AchievementViewModel.hh
@@ -0,0 +1,104 @@
+#ifndef RA_UI_ACHIEVEMENT_VIEWMODEL_H
+#define RA_UI_ACHIEVEMENT_VIEWMODEL_H
+#pragma once
+
+#include "AssetViewModelBase.hh"
+
+namespace ra {
+namespace ui {
+namespace viewmodels {
+
+class AchievementViewModel : protected AssetViewModelBase
+{
+public:
+    AchievementViewModel() noexcept;
+
+    /// <summary>
+    /// The <see cref="ModelProperty" /> for the achievement points.
+    /// </summary>
+    static const IntModelProperty PointsProperty;
+
+    /// <summary>
+    /// Gets the points for the achievement.
+    /// </summary>
+    int GetPoints() const { return GetValue(PointsProperty); }
+
+    /// <summary>
+    /// Sets the points for the achievement.
+    /// </summary>
+    void SetPoints(int nValue) { SetValue(PointsProperty, nValue); }
+
+    /// <summary>
+    /// The <see cref="ModelProperty" /> for the badge.
+    /// </summary>
+    static const StringModelProperty BadgeProperty;
+
+    /// <summary>
+    /// Gets the name of the badge associated to the achievement.
+    /// </summary>
+    const std::wstring& GetBadge() const { return GetValue(BadgeProperty); }
+
+    /// <summary>
+    /// Sets the name of the badge associated to the achievement.
+    /// </summary>
+    void SetBadge(const std::wstring& sValue) { SetValue(BadgeProperty, sValue); }
+
+    /// <summary>
+    /// Opens the file chooser to select a new badge image.
+    /// </summary>
+    void SelectBadgeFile();
+
+    /// <summary>
+    /// The <see cref="ModelProperty" /> for whether or not the emulator should be paused when the trigger is reset.
+    /// </summary>
+    static const BoolModelProperty PauseOnResetProperty;
+
+    /// <summary>
+    /// Gets whether or not the emulator should be paused when the trigger is reset.
+    /// </summary>
+    bool IsPauseOnReset() const { return GetValue(PauseOnResetProperty); }
+
+    /// <summary>
+    /// Sets whether or not the emulator should be paused when the trigger is reset.
+    /// </summary>
+    void SetPauseOnReset(bool nValue) { SetValue(PauseOnResetProperty, nValue); }
+
+    /// <summary>
+    /// The <see cref="ModelProperty" /> for whether or not the emulator should be paused when the achievement triggers.
+    /// </summary>
+    static const BoolModelProperty PauseOnTriggerProperty;
+
+    /// <summary>
+    /// Gets whether or not the emulator should be paused when the achievement triggers.
+    /// </summary>
+    bool IsPauseOnTrigger() const { return GetValue(PauseOnTriggerProperty); }
+
+    /// <summary>
+    /// Sets whether or not the emulator should be paused when the achievement triggers.
+    /// </summary>
+    void SetPauseOnTrigger(bool nValue) { SetValue(PauseOnTriggerProperty, nValue); }
+
+    /// <summary>
+    /// The <see cref="ModelProperty" /> for the trigger modification state.
+    /// </summary>
+    static const IntModelProperty TriggerProperty;
+
+    /// <summary>
+    /// Gets the trigger definition.
+    /// </summary>
+    const std::string& GetTrigger() const { return GetAssetDefinition(m_pTrigger); }
+    
+    /// <summary>
+    /// Sets the trigger definition.
+    /// </summary>
+    void SetTrigger(const std::string& sTrigger) { SetAssetDefinition(m_pTrigger, sTrigger); }
+
+private:
+    AssetDefinition m_pTrigger;
+};
+
+} // namespace viewmodels
+} // namespace ui
+} // namespace ra
+
+#endif RA_UI_ACHIEVEMENT_VIEWMODEL_H
diff --git a/src/ui/viewmodels/AssetListViewModel.cpp b/src/ui/viewmodels/AssetListViewModel.cpp
index a0a98ebc..449b8db9 100644
--- a/src/ui/viewmodels/AssetListViewModel.cpp
+++ b/src/ui/viewmodels/AssetListViewModel.cpp
@@ -58,7 +58,7 @@ void AssetListViewModel::SaveSelected()
     auto pData = pLocalStorage.WriteText(ra::services::StorageItemType::UserAchievements, std::to_wstring(GetGameId()));
     if (pData == nullptr)
     {
-        RA_LOG("Failed to create user assets file");
+        RA_LOG_ERR("Failed to create user assets file");
         return;
     }
 
@@ -87,7 +87,7 @@ void AssetListViewModel::SaveSelected()
         pItem->UpdateLocalCheckpoint();
     }
 
-    RA_LOG("Wrote user assets file");
+    RA_LOG_INFO("Wrote user assets file");
 }
 
 void AssetListViewModel::PublishSelected()
diff --git a/src/ui/viewmodels/AssetViewModelBase.cpp b/src/ui/viewmodels/AssetViewModelBase.cpp
index 37e1442f..f3fcd246 100644
--- a/src/ui/viewmodels/AssetViewModelBase.cpp
+++ b/src/ui/viewmodels/AssetViewModelBase.cpp
@@ -183,6 +183,149 @@ void AssetViewModelBase::OnValueChanged(const BoolModelProperty::ChangeArgs& arg
     }
 }
 
+const std::string& AssetViewModelBase::GetAssetDefinition(const AssetDefinition& pAsset) const
+{
+    const auto nState = ra::itoe<AssetChanges>(GetValue(*pAsset.m_pProperty));
+    switch (nState)
+    {
+        case AssetChanges::None:
+            return pAsset.m_sCoreDefinition;
+
+        case AssetChanges::Unpublished:
+            return pAsset.m_sLocalDefinition;
+
+        default:
+            return pAsset.m_sCurrentDefinition;
+    }
+}
+
+void AssetViewModelBase::SetAssetDefinition(AssetDefinition& pAsset, const std::string& sValue)
+{
+    if (m_pTransaction == nullptr)
+    {
+        // before core checkpoint
+        pAsset.m_sCoreDefinition = sValue;
+    }
+    else if (m_pTransaction->m_pNext == nullptr)
+    {
+        // before local checkpoint
+        if (sValue == pAsset.m_sCoreDefinition)
+        {
+            pAsset.m_sLocalDefinition.clear();
+            SetValue(*pAsset.m_pProperty, ra::etoi(AssetChanges::None));
+            pAsset.m_bLocalModified = false;
+        }
+        else
+        {
+            pAsset.m_sLocalDefinition = sValue;
+            SetValue(*pAsset.m_pProperty, ra::etoi(AssetChanges::Unpublished));
+            pAsset.m_bLocalModified = true;
+        }
+    }
+    else
+    {
+        // after local checkpoint
+        if (pAsset.m_bLocalModified && sValue == pAsset.m_sLocalDefinition)
+        {
+            pAsset.m_sCurrentDefinition.clear();
+            SetValue(*pAsset.m_pProperty, ra::etoi(AssetChanges::Unpublished));
+        }
+        else if (!pAsset.m_bLocalModified && sValue == pAsset.m_sCoreDefinition)
+        {
+            pAsset.m_sCurrentDefinition.clear();
+            SetValue(*pAsset.m_pProperty, ra::etoi(AssetChanges::None));
+        }
+        else
+        {
+            pAsset.m_sCurrentDefinition = sValue;
+            SetValue(*pAsset.m_pProperty, ra::etoi(AssetChanges::Modified));
+        }
+    }
+}
+
+void AssetViewModelBase::CommitTransaction()
+{
+    Expects(m_pTransaction != nullptr);
+
+    if (m_pTransaction->m_pNext == nullptr)
+    {
+        // commit local to core
+        for (auto pAsset : m_vAssetDefinitions)
+        {
+            Expects(pAsset != nullptr);
+            const auto nState = ra::itoe<AssetChanges>(GetValue(*pAsset->m_pProperty));
+            if (nState == AssetChanges::Unpublished)
+            {
+                if (pAsset->m_bLocalModified)
+                {
+                    pAsset->m_sCoreDefinition.swap(pAsset->m_sLocalDefinition);
+                    pAsset->m_sLocalDefinition.clear();
+                    pAsset->m_bLocalModified = false;
+                }
+
+                SetValue(*pAsset->m_pProperty, ra::etoi(AssetChanges::None));
+            }
+        }
+    }
+    else
+    {
+        // commit modifications to local
+        for (auto pAsset : m_vAssetDefinitions)
+        {
+            Expects(pAsset != nullptr);
+            const auto nState = ra::itoe<AssetChanges>(GetValue(*pAsset->m_pProperty));
+            if (nState == AssetChanges::Modified)
+            {
+                if (pAsset->m_sCurrentDefinition == pAsset->m_sCoreDefinition)
+                {
+                    pAsset->m_sCurrentDefinition.clear();
+                    pAsset->m_sLocalDefinition.clear();
+                    pAsset->m_bLocalModified = false;
+                    SetValue(*pAsset->m_pProperty, ra::etoi(AssetChanges::None));
+                }
+                else
+                {
+                    pAsset->m_sLocalDefinition.swap(pAsset->m_sCurrentDefinition);
+                    pAsset->m_sCurrentDefinition.clear();
+                    pAsset->m_bLocalModified = true;
+                    SetValue(*pAsset->m_pProperty, ra::etoi(AssetChanges::Unpublished));
+                }
+            }
+        }
+    }
+
+    // call after updating so TrackingProperties are also committed
+    TransactionalViewModelBase::CommitTransaction();
+}
+
+void AssetViewModelBase::RevertTransaction()
+{
+    // call before updating so TrackingProperties are reverted first
+    TransactionalViewModelBase::RevertTransaction();
+
+    for (auto pAsset : m_vAssetDefinitions)
+    {
+        Expects(pAsset != nullptr);
+        const auto nState = ra::itoe<AssetChanges>(GetValue(*pAsset->m_pProperty));
+        switch (nState)
+        {
+            case AssetChanges::None:
+                pAsset->m_sCurrentDefinition.clear();
+                pAsset->m_sLocalDefinition.clear();
+                pAsset->m_bLocalModified = false;
+                break;
+
+            case AssetChanges::Unpublished:
+                pAsset->m_sCurrentDefinition.clear();
+                pAsset->m_bLocalModified = (pAsset->m_sLocalDefinition != pAsset->m_sCoreDefinition);
+                break;
+
+            default:
+                Expects(!"Unexpected state after revert");
+                break;
+        }
+    }
+}
 
 } // namespace viewmodels
 } // namespace ui
diff --git a/src/ui/viewmodels/AssetViewModelBase.hh b/src/ui/viewmodels/AssetViewModelBase.hh
index cd33e06c..8bc204ef 100644
--- a/src/ui/viewmodels/AssetViewModelBase.hh
+++ b/src/ui/viewmodels/AssetViewModelBase.hh
@@ -82,6 +82,21 @@ public:
     /// </summary>
     void SetName(const std::wstring& sValue) { SetValue(NameProperty, sValue); }
 
+    /// <summary>
+    /// The <see cref="ModelProperty" /> for the description of the asset.
+    /// </summary>
+    static const StringModelProperty DescriptionProperty;
+
+    /// <summary>
+    /// Gets the description of the asset.
+    /// </summary>
+    const std::wstring& GetDescription() const { return GetValue(NameProperty); }
+
+    /// <summary>
+    /// Sets the description of the asset.
+    /// </summary>
+    void SetDescription(const std::wstring& sValue) { SetValue(NameProperty, sValue); }
+
     /// <summary>
     /// The <see cref="ModelProperty" /> for the asset category.
     /// </summary>
@@ -165,6 +180,30 @@ public:
     void RestoreServerCheckpoint();
 
 protected:
+    /// <summary>
+    /// Helper class for versioned ASCII strings to avoid overhead of UNICODE characters
+    /// </summary>
+    struct AssetDefinition
+    {
+        const IntModelProperty* m_pProperty = nullptr;
+        std::string m_sCoreDefinition;
+        std::string m_sLocalDefinition;
+        std::string m_sCurrentDefinition;
+        bool m_bLocalModified = false;
+    };
+
+    std::vector<AssetDefinition*> m_vAssetDefinitions;
+
+    void AddAssetDefinition(AssetDefinition& pAsset, const IntModelProperty& pProperty)
+    {
+        pAsset.m_pProperty = &pProperty;
+        m_vAssetDefinitions.push_back(&pAsset);
+        SetTransactional(pProperty);
+    }
+
+    const std::string& GetAssetDefinition(const AssetDefinition& pAsset) const;
+    void SetAssetDefinition(AssetDefinition& pAsset, const std::string& sValue);
+
     static void WriteNumber(ra::services::TextWriter& pWriter, const uint32_t nValue);
     static void WriteQuoted(ra::services::TextWriter& pWriter, const std::string& sText);
     static void WriteQuoted(ra::services::TextWriter& pWriter, const std::wstring& sText);
@@ -172,6 +211,9 @@ protected:
     static void WritePossiblyQuoted(ra::services::TextWriter& pWriter, const std::wstring& sText);
 
     void OnValueChanged(const BoolModelProperty::ChangeArgs& args) override;
+
+    void CommitTransaction() override;
+    void RevertTransaction() override;
 };
 
 } // namespace viewmodels


Test patch:
diff --git a/tests/ui/viewmodels/AssetViewModelBase_Tests.cpp b/tests/ui/viewmodels/AssetViewModelBase_Tests.cpp
index c4f01c55..0ab973aa 100644
--- a/tests/ui/viewmodels/AssetViewModelBase_Tests.cpp
+++ b/tests/ui/viewmodels/AssetViewModelBase_Tests.cpp
@@ -128,6 +128,24 @@ private:
         }
     };
 
+    class AssetDefinitionViewModelHarness : public AssetViewModelBase
+    {
+    public:
+        AssetDefinitionViewModelHarness() noexcept
+        {
+            GSL_SUPPRESS_F6 AddAssetDefinition(m_pDefinition, DefinitionProperty);
+        }
+
+        const std::string& GetDefinition() const { return GetAssetDefinition(m_pDefinition); }
+        void SetDefinition(const std::string& sValue) { SetAssetDefinition(m_pDefinition, sValue); }
+
+        void Serialize(ra::services::TextWriter&) const noexcept override {}
+
+    protected:
+        static const IntModelProperty DefinitionProperty;
+        AssetDefinition m_pDefinition;
+    };
+
 public:
     TEST_METHOD(TestInitialValues)
     {
@@ -136,6 +154,7 @@ public:
         Assert::AreEqual(AssetType::Achievement, asset.GetType());
         Assert::AreEqual(0U, asset.GetID());
         Assert::AreEqual(std::wstring(L""), asset.GetName());
+        Assert::AreEqual(std::wstring(L""), asset.GetDescription());
         Assert::AreEqual(AssetCategory::Core, asset.GetCategory());
         Assert::AreEqual(AssetState::Inactive, asset.GetState());
         Assert::AreEqual(AssetChanges::None, asset.GetChanges());
@@ -250,10 +269,62 @@ public:
         Assert::AreEqual(ra::ui::viewmodels::AssetChanges::None, asset.GetChanges());
         Assert::AreEqual(std::wstring(L"ServerName"), asset.GetName());
     }
+
+    TEST_METHOD(TestUpdateDefinitionCheckpoint)
+    {
+        AssetDefinitionViewModelHarness asset;
+        asset.SetDefinition("ServerDefinition");
+        asset.CreateServerCheckpoint();
+        asset.CreateLocalCheckpoint();
+
+        Assert::AreEqual(ra::ui::viewmodels::AssetChanges::None, asset.GetChanges());
+        Assert::AreEqual(std::string("ServerDefinition"), asset.GetDefinition());
+
+        asset.SetDefinition("LocalDefinition");
+        Assert::AreEqual(ra::ui::viewmodels::AssetChanges::Modified, asset.GetChanges());
+        Assert::AreEqual(std::string("LocalDefinition"), asset.GetDefinition());
+
+        asset.UpdateLocalCheckpoint();
+        Assert::AreEqual(ra::ui::viewmodels::AssetChanges::Unpublished, asset.GetChanges());
+        Assert::AreEqual(std::string("LocalDefinition"), asset.GetDefinition());
+
+        asset.UpdateServerCheckpoint();
+        Assert::AreEqual(ra::ui::viewmodels::AssetChanges::None, asset.GetChanges());
+        Assert::AreEqual(std::string("LocalDefinition"), asset.GetDefinition());
+    }
+
+    TEST_METHOD(TestRestoreDefinitionCheckpoint)
+    {
+        AssetDefinitionViewModelHarness asset;
+        asset.SetDefinition("ServerDefinition");
+        asset.CreateServerCheckpoint();
+        asset.SetDefinition("LocalDefinition");
+        asset.CreateLocalCheckpoint();
+
+        Assert::AreEqual(ra::ui::viewmodels::AssetChanges::Unpublished, asset.GetChanges());
+        Assert::AreEqual(std::string("LocalDefinition"), asset.GetDefinition());
+
+        asset.SetDefinition("ModifiedDefinition");
+        Assert::AreEqual(ra::ui::viewmodels::AssetChanges::Modified, asset.GetChanges());
+        Assert::AreEqual(std::string("ModifiedDefinition"), asset.GetDefinition());
+
+        asset.RestoreLocalCheckpoint();
+        Assert::AreEqual(ra::ui::viewmodels::AssetChanges::Unpublished, asset.GetChanges());
+        Assert::AreEqual(std::string("LocalDefinition"), asset.GetDefinition());
+
+        asset.SetDefinition("ModifiedDefinition");
+        Assert::AreEqual(ra::ui::viewmodels::AssetChanges::Modified, asset.GetChanges());
+        Assert::AreEqual(std::string("ModifiedDefinition"), asset.GetDefinition());
+
+        asset.RestoreServerCheckpoint();
+        Assert::AreEqual(ra::ui::viewmodels::AssetChanges::None, asset.GetChanges());
+        Assert::AreEqual(std::string("ServerDefinition"), asset.GetDefinition());
+    }
 };
 
 const StringModelProperty AssetViewModelBase_Tests::AssetViewModelHarness::StringProperty("AssetViewModelHarness", "String", L"");
 const IntModelProperty AssetViewModelBase_Tests::AssetViewModelHarness::IntProperty("AssetViewModelHarness", "Int", 0);
+const IntModelProperty AssetViewModelBase_Tests::AssetDefinitionViewModelHarness::DefinitionProperty("AssetDefinitionViewModelHarness", "Int", ra::etoi(AssetChanges::None));
 
 } // namespace tests
 } // namespace viewmodels