Repository: RetroAchievements/RAIntegration
Pull request #885 (2022-04-27 23:31:11)

add rich presence to asset list
Provides minimal functionality. Attempting to open the asset launches the file in the default text editor on the machine. The only available action is "Revert" which will replace the file with the data downloaded from the server. 

The main feature provided by this functionality is that any modifications made to the file will be kept between sessions.

![image](https://user-images.githubusercontent.com/32680403/165642863-c18371f4-7adb-4866-a393-0a314024189e.png)

![image](https://user-images.githubusercontent.com/32680403/165642983-e6caa1bf-c39f-4f99-8e62-f03d0e5e28c1.png)

If there is no rich presence data available on the server, the asset is automatically marked as local, and the "Revert" functionality is also disabled.

![image](https://user-images.githubusercontent.com/32680403/165642924-eecef8eb-bda4-4a30-a3ca-edcddb18c9b4.png)

This does not provide any support for publishing/activating/deactivating the rich presence. Changes to the text file are still only detected while the Rich Presence Monitor is open.


Patch:
diff --git a/src/RA_Integration.vcxproj b/src/RA_Integration.vcxproj
index 76c3cd4e..1288e97e 100644
--- a/src/RA_Integration.vcxproj
+++ b/src/RA_Integration.vcxproj
@@ -64,6 +64,7 @@
     <ClCompile Include="data\models\CapturedTriggerHits.cpp" />
     <ClCompile Include="data\models\LeaderboardModel.cpp" />
     <ClCompile Include="data\models\LocalBadgesModel.cpp" />
+    <ClCompile Include="data\models\RichPresenceModel.cpp" />
     <ClCompile Include="data\models\TriggerValidation.cpp" />
     <ClCompile Include="pch.cpp">
       <PrecompiledHeader Condition="'$(Configuration)|$(Platform)'=='Debug|Win32'">Create</PrecompiledHeader>
@@ -217,6 +218,7 @@
     <ClInclude Include="data\models\CapturedTriggerHits.hh" />
     <ClInclude Include="data\models\LeaderboardModel.hh" />
     <ClInclude Include="data\models\LocalBadgesModel.hh" />
+    <ClInclude Include="data\models\RichPresenceModel.hh" />
     <ClInclude Include="data\models\TriggerValidation.hh" />
     <ClInclude Include="data\Types.hh" />
     <ClInclude Include="Exports.hh" />
diff --git a/src/RA_Integration.vcxproj.filters b/src/RA_Integration.vcxproj.filters
index 06a02ad6..6d271a39 100644
--- a/src/RA_Integration.vcxproj.filters
+++ b/src/RA_Integration.vcxproj.filters
@@ -387,6 +387,9 @@
     <ClCompile Include="ui\viewmodels\NewAssetViewModel.cpp">
       <Filter>UI\ViewModels</Filter>
     </ClCompile>
+    <ClCompile Include="data\models\RichPresenceModel.cpp">
+      <Filter>Data\Models</Filter>
+    </ClCompile>
   </ItemGroup>
   <ItemGroup>
     <ClInclude Include="RA_Resource.h">
@@ -962,6 +965,9 @@
     <ClInclude Include="api\UpdateLeaderboard.hh">
       <Filter>API</Filter>
     </ClInclude>
+    <ClInclude Include="data\models\RichPresenceModel.hh">
+      <Filter>Data\Models</Filter>
+    </ClInclude>
   </ItemGroup>
   <ItemGroup>
     <ResourceCompile Include="RA_Shared.rc">
diff --git a/src/data/context/GameAssets.cpp b/src/data/context/GameAssets.cpp
index 3268cf30..9452c7d2 100644
--- a/src/data/context/GameAssets.cpp
+++ b/src/data/context/GameAssets.cpp
@@ -6,6 +6,7 @@
 #include "GameContext.hh"
 
 #include "data\models\LocalBadgesModel.hh"
+#include "data\models\RichPresenceModel.hh"
 
 #include "services\ILocalStorage.hh"
 #include "services\ServiceLocator.hh"
@@ -76,6 +77,9 @@ bool GameAssets::HasCoreAssets() const
 
                 default:
                     // Core, Bonus, or something else that's been published
+                    if (pAsset->GetType() == ra::data::models::AssetType::RichPresence)
+                        break;
+
                     return true;
             }
         }
@@ -118,6 +122,10 @@ void GameAssets::ReloadAssets(const std::vector<ra::data::models::AssetModelBase
 {
     const auto& pGameContext = ra::services::ServiceLocator::Get<ra::data::context::GameContext>();
 
+    auto* pRichPresence = dynamic_cast<ra::data::models::RichPresenceModel*>(FindAsset(ra::data::models::AssetType::RichPresence, 0));
+    if (pRichPresence != nullptr)
+        pRichPresence->ReloadRichPresenceScript();
+
     auto& pLocalStorage = ra::services::ServiceLocator::GetMutable<ra::services::ILocalStorage>();
     auto pData = pLocalStorage.ReadText(ra::services::StorageItemType::UserAchievements, std::to_wstring(pGameContext.GameId()));
     if (pData == nullptr)
@@ -148,9 +156,16 @@ void GameAssets::ReloadAssets(const std::vector<ra::data::models::AssetModelBase
                 if (pAsset->GetChanges() != ra::data::models::AssetChanges::None ||
                     pAsset->GetCategory() == ra::data::models::AssetCategory::Local)
                 {
-                    // ignore LocalBadges container
-                    if (pAsset->GetType() == ra::data::models::AssetType::LocalBadges)
-                        continue;
+                    switch (pAsset->GetType())
+                    {
+                        // ignore LocalBadges container
+                        case ra::data::models::AssetType::LocalBadges:
+                            continue;
+
+                        // ignore RichPresence model (it's not actually stored in the XXX-User file)
+                        case ra::data::models::AssetType::RichPresence:
+                            continue;
+                    }
 
                     vRemainingAssetsToReload.push_back(pAsset);
                 }
diff --git a/src/data/context/GameAssets.hh b/src/data/context/GameAssets.hh
index 27cdb216..191ee088 100644
--- a/src/data/context/GameAssets.hh
+++ b/src/data/context/GameAssets.hh
@@ -7,6 +7,7 @@
 
 #include "data\models\AchievementModel.hh"
 #include "data\models\LeaderboardModel.hh"
+#include "data\models\RichPresenceModel.hh"
 
 namespace ra {
 namespace data {
@@ -74,6 +75,22 @@ public:
     /// </summary>
     ra::data::models::LeaderboardModel& NewLeaderboard();
 
+    /// <summary>
+    /// Finds the rich presence asset.
+    /// </summary>
+    ra::data::models::RichPresenceModel* FindRichPresence()
+    {
+        return dynamic_cast<ra::data::models::RichPresenceModel*>(FindAsset(ra::data::models::AssetType::RichPresence, 0));
+    }
+
+    /// <summary>
+    /// Finds the rich presence asset.
+    /// </summary>
+    const ra::data::models::RichPresenceModel* FindRichPresence() const
+    {
+        return dynamic_cast<const ra::data::models::RichPresenceModel*>(FindAsset(ra::data::models::AssetType::RichPresence, 0));
+    }
+
     /// <summary>
     /// Determines if any Core assets exist.
     /// </summary>
diff --git a/src/data/context/GameContext.cpp b/src/data/context/GameContext.cpp
index 4ca41f25..be878f47 100644
--- a/src/data/context/GameContext.cpp
+++ b/src/data/context/GameContext.cpp
@@ -22,6 +22,7 @@
 
 #include "data\models\AchievementModel.hh"
 #include "data\models\LocalBadgesModel.hh"
+#include "data\models\RichPresenceModel.hh"
 
 #include "services\AchievementRuntime.hh"
 #include "services\IAudioSystem.hh"
@@ -55,7 +56,6 @@ void GameContext::LoadGame(unsigned int nGameId, Mode nMode)
     // reset the GameContext
     m_nMode = nMode;
     m_sGameTitle.clear();
-    m_bRichPresenceFromFile = false;
     m_mCodeNotes.clear();
     m_vAssets.ResetLocalId();
 
@@ -143,23 +143,11 @@ void GameContext::LoadGame(unsigned int nGameId, Mode nMode)
 
     // rich presence
     {
-        // normalize for MD5 calulation (unix line endings, must end with a line ending)
-        std::string sRichPresence = response.RichPresence;
-        sRichPresence.erase(std::remove(sRichPresence.begin(), sRichPresence.end(), '\r'), sRichPresence.end());
-        if (!sRichPresence.empty() && sRichPresence.back() != '\n')
-            sRichPresence.push_back('\n');
-
-        LoadRichPresenceScript(sRichPresence);
-        m_sServerRichPresenceMD5 = RAGenerateMD5(sRichPresence);
-    }
-
-    if (!response.RichPresence.empty())
-    {
-        // TODO: this file is written so devs can modify the rich presence without having to restart
-        // the game. if the user doesn't have dev permission, there's no reason to write it.
-        auto& pLocalStorage = ra::services::ServiceLocator::GetMutable<ra::services::ILocalStorage>();
-        auto pRich = pLocalStorage.WriteText(ra::services::StorageItemType::RichPresence, std::to_wstring(nGameId));
-        pRich->Write(response.RichPresence);
+        auto pRichPresence = std::make_unique<ra::data::models::RichPresenceModel>();
+        pRichPresence->SetScript(response.RichPresence);
+        pRichPresence->CreateServerCheckpoint();
+        pRichPresence->CreateLocalCheckpoint();
+        m_vAssets.Append(std::move(pRichPresence));
     }
 
     // achievements
@@ -233,6 +221,22 @@ void GameContext::LoadGame(unsigned int nGameId, Mode nMode)
     DoFrame();
 #endif
 
+    // activate rich presence (or remove if not defined)
+    auto* pRichPresence = m_vAssets.FindRichPresence();
+    if (pRichPresence)
+    {
+        if (pRichPresence->GetScript().empty() && !pRichPresence->IsModified())
+        {
+            const auto nIndex = m_vAssets.FindItemIndex(ra::data::models::AssetModelBase::TypeProperty,
+                                                        ra::etoi(ra::data::models::AssetType::RichPresence));
+            m_vAssets.RemoveAt(nIndex);
+        }
+        else
+        {
+            pRichPresence->Activate();
+        }
+    }
+
     // show "game loaded" popup
     ra::services::ServiceLocator::Get<ra::services::IAudioSystem>().PlayAudioFile(L"Overlay\\info.wav");
     const auto nPopup = ra::services::ServiceLocator::GetMutable<ra::ui::viewmodels::OverlayManager>().QueueMessage(
@@ -810,11 +814,6 @@ void GameContext::SubmitLeaderboardEntry(ra::LeaderboardID nLeaderboardId, int n
     });
 }
 
-void GameContext::LoadRichPresenceScript(const std::string& sRichPresenceScript)
-{
-    ra::services::ServiceLocator::GetMutable<ra::services::AchievementRuntime>().ActivateRichPresence(sRichPresenceScript);
-}
-
 bool GameContext::HasRichPresence() const
 {
     return ra::services::ServiceLocator::Get<ra::services::AchievementRuntime>().HasRichPresence();
@@ -825,32 +824,6 @@ std::wstring GameContext::GetRichPresenceDisplayString() const
     return ra::services::ServiceLocator::Get<ra::services::AchievementRuntime>().GetRichPresenceDisplayString();
 }
 
-void GameContext::ReloadRichPresenceScript()
-{
-    auto& pLocalStorage = ra::services::ServiceLocator::GetMutable<ra::services::ILocalStorage>();
-    auto pRich = pLocalStorage.ReadText(ra::services::StorageItemType::RichPresence, std::to_wstring(GameId()));
-
-    std::string sRichPresence;
-    if (pRich != nullptr)
-    {
-        std::string sLine;
-        while (pRich->GetLine(sLine))
-        {
-            sRichPresence.append(sLine);
-            sRichPresence.append("\n");
-        }
-
-        // remove UTF-8 BOM if present
-        if (ra::StringStartsWith(sRichPresence, "\xef\xbb\xbf"))
-            sRichPresence.erase(0, 3);
-    }
-
-    const auto sFileRichPresenceMD5 = RAGenerateMD5(sRichPresence);
-    m_bRichPresenceFromFile = (sFileRichPresenceMD5 != m_sServerRichPresenceMD5);
-
-    LoadRichPresenceScript(sRichPresence);
-}
-
 void GameContext::RefreshCodeNotes()
 {
     m_mCodeNotes.clear();
diff --git a/src/data/context/GameContext.hh b/src/data/context/GameContext.hh
index e459b742..f7f87c20 100644
--- a/src/data/context/GameContext.hh
+++ b/src/data/context/GameContext.hh
@@ -106,21 +106,11 @@ public:
     /// Gets whether or not the loaded game has a rich presence script.
     /// </summary>
     virtual bool HasRichPresence() const;
-    
-    /// <summary>
-    /// Gets whether or not the loaded game has a rich presence script.
-    /// </summary>
-    bool IsRichPresenceFromFile() const noexcept { return m_bRichPresenceFromFile; }
 
     /// <summary>
     /// Gets the current rich presence display string.
     /// </summary>
     virtual std::wstring GetRichPresenceDisplayString() const;
-    
-    /// <summary>
-    /// Reloads the rich presence script.
-    /// </summary>
-    void ReloadRichPresenceScript();
 
     /// <summary>
     /// Returns the note associated with the specified address.
@@ -266,8 +256,6 @@ private:
 protected:
     void RefreshUnlocks(bool bUnpause, int nPopup);
     void UpdateUnlocks(const std::set<unsigned int>& vUnlockedAchievements, bool bUnpause, int nPopup);
-    void AwardMastery() const;
-    void LoadRichPresenceScript(const std::string& sRichPresenceScript);
     void RefreshCodeNotes();
     void AddCodeNote(ra::ByteAddress nAddress, const std::string& sAuthor, const std::wstring& sNote);
     void ShowSimplifiedScoreboard(ra::LeaderboardID nLeaderboardId, int nScore) const;
@@ -282,8 +270,6 @@ protected:
     std::string m_sGameHash;
     std::string m_sGameImage;
     Mode m_nMode{};
-    std::string m_sServerRichPresenceMD5;
-    bool m_bRichPresenceFromFile = false;
 
     struct CodeNote
     {
diff --git a/src/data/context/SessionTracker.cpp b/src/data/context/SessionTracker.cpp
index 0aa177fe..d7444141 100644
--- a/src/data/context/SessionTracker.cpp
+++ b/src/data/context/SessionTracker.cpp
@@ -273,7 +273,8 @@ std::wstring SessionTracker::GetCurrentActivity() const
         return sMessage;
     }
 
-    if (pGameContext.HasRichPresence() && !pGameContext.IsRichPresenceFromFile())
+    if (pGameContext.HasRichPresence() &&
+        pGameContext.Assets().FindRichPresence()->GetChanges() == ra::data::models::AssetChanges::None)
     {
         const auto sRPResponse = pGameContext.GetRichPresenceDisplayString();
         if (!sRPResponse.empty())
diff --git a/src/data/models/AssetModelBase.hh b/src/data/models/AssetModelBase.hh
index 44416ddd..72d1c093 100644
--- a/src/data/models/AssetModelBase.hh
+++ b/src/data/models/AssetModelBase.hh
@@ -18,6 +18,7 @@ enum class AssetType
     None = 0,
     Achievement,
     Leaderboard,
+    RichPresence,
     LocalBadges,
 };
 
diff --git a/src/data/models/RichPresenceModel.cpp b/src/data/models/RichPresenceModel.cpp
new file mode 100644
index 00000000..9ec838cc
--- /dev/null
+++ b/src/data/models/RichPresenceModel.cpp
@@ -0,0 +1,175 @@
+#include "RichPresenceModel.hh"
+
+#include "data\context\GameContext.hh"
+
+#include "services\AchievementRuntime.hh"
+#include "services\ILocalStorage.hh"
+#include "services\ServiceLocator.hh"
+
+namespace ra {
+namespace data {
+namespace models {
+
+const IntModelProperty RichPresenceModel::ScriptProperty("RichPresenceModel", "Script", 0);
+
+RichPresenceModel::RichPresenceModel() noexcept
+{
+    GSL_SUPPRESS_F6 SetValue(TypeProperty, ra::etoi(AssetType::RichPresence));
+    GSL_SUPPRESS_F6 SetValue(NameProperty, L"Rich Presence");
+
+    GSL_SUPPRESS_F6 AddAssetDefinition(m_pScript, ScriptProperty);
+}
+
+void RichPresenceModel::Activate()
+{
+    SetState(AssetState::Active);
+}
+
+void RichPresenceModel::Deactivate()
+{
+    SetState(AssetState::Inactive);
+}
+
+void RichPresenceModel::OnValueChanged(const IntModelProperty::ChangeArgs& args)
+{
+    // if we're still loading the asset, ignore the event. we'll get recalled once loading finishes
+    if (!IsUpdating())
+    {
+        if (args.Property == StateProperty || args.Property == ScriptProperty)
+        {
+            auto& pRuntime = ra::services::ServiceLocator::GetMutable<ra::services::AchievementRuntime>();
+
+            if (IsActive())
+            {
+                const std::string sScript = GetScript();
+                if (!pRuntime.ActivateRichPresence(sScript))
+                {
+                    SetState(AssetState::Disabled);
+
+                    // if this was triggered as a result of the State changing to Active, and we changed the State
+                    // to Disabled, swallow the event for the State changing to Active as it's no longer accurate.
+                    if (args.Property == StateProperty)
+                        return;
+                }
+            }
+            else if (GetState() != AssetState::Disabled)
+            {
+                pRuntime.ActivateRichPresence("");
+            }
+            else if (args.Property == ScriptProperty)
+            {
+                // State is Disabled, Script changed. Try to reactivate
+                SetState(AssetState::Active);
+            }
+        }
+        else if (args.Property == ChangesProperty && args.tNewValue == 0)
+        {
+            WriteRichPresenceScript();
+        }
+    }
+
+    AssetModelBase::OnValueChanged(args);
+}
+
+void RichPresenceModel::SetScript(const std::string& sScript)
+{
+    bool bIsOnlyWhitespace = true;
+    for (const char c : sScript)
+    {
+        if (!isspace(static_cast<int>(c)))
+        {
+            bIsOnlyWhitespace = false;
+            break;
+        }
+    }
+
+    if (bIsOnlyWhitespace)
+    {
+        SetAssetDefinition(m_pScript, "");
+        return;
+    }
+
+    if (sScript.back() == '\n' && sScript.find('\r') == std::string::npos)
+    {
+        SetAssetDefinition(m_pScript, sScript);
+        return;
+    }
+
+    // normalize (unix line endings, must end with a line ending)
+    std::string sNormalizedScript = sScript;
+    sNormalizedScript.erase(std::remove(sNormalizedScript.begin(), sNormalizedScript.end(), '\r'), sNormalizedScript.end());
+    if (!sNormalizedScript.empty() && sNormalizedScript.back() != '\n')
+        sNormalizedScript.push_back('\n');
+
+    SetAssetDefinition(m_pScript, sNormalizedScript);
+}
+
+void RichPresenceModel::ReloadRichPresenceScript()
+{
+    const auto& pGameContext = ra::services::ServiceLocator::Get<ra::data::context::GameContext>();
+    auto& pLocalStorage = ra::services::ServiceLocator::GetMutable<ra::services::ILocalStorage>();
+    auto pRich = pLocalStorage.ReadText(ra::services::StorageItemType::RichPresence, std::to_wstring(pGameContext.GameId()));
+
+    if (pRich == nullptr)
+    {
+        // file doesn't exist. reset back to the server state
+        RestoreServerCheckpoint();
+
+        // if server state is not empty, flush it to disk
+        if (!GetScript().empty())
+            WriteRichPresenceScript();
+
+        return;
+    }
+
+    std::string sRichPresence;
+    std::string sLine;
+    while (pRich->GetLine(sLine))
+    {
+        sRichPresence.append(sLine);
+        sRichPresence.append("\n");
+    }
+
+    // remove UTF-8 BOM if present
+    if (ra::StringStartsWith(sRichPresence, "\xef\xbb\xbf"))
+        sRichPresence.erase(0, 3);
+
+    BeginUpdate();
+
+    SetScript(sRichPresence);
+
+    UpdateLocalCheckpoint();
+
+    // if there's a local script, but no core script, change the category to Local
+    if (GetChanges() == ra::data::models::AssetChanges::Unpublished && m_pScript.m_sCoreDefinition.empty())
+    {
+        SetCategory(ra::data::models::AssetCategory::Local);
+        UpdateLocalCheckpoint();
+    }
+
+    EndUpdate();
+}
+
+void RichPresenceModel::WriteRichPresenceScript()
+{
+    const auto& pGameContext = ra::services::ServiceLocator::Get<ra::data::context::GameContext>();
+    auto& pLocalStorage = ra::services::ServiceLocator::GetMutable<ra::services::ILocalStorage>();
+
+    auto pWriter = pLocalStorage.WriteText(ra::services::StorageItemType::RichPresence,
+                                           std::to_wstring(pGameContext.GameId()));
+    const auto& pScript = GetScript();
+    pWriter->Write(pScript);
+}
+
+void RichPresenceModel::Serialize(ra::services::TextWriter&) const noexcept
+{
+}
+
+bool RichPresenceModel::Deserialize(ra::Tokenizer&) noexcept
+{
+    return false;
+}
+
+} // namespace models
+} // namespace data
+} // namespace ra
diff --git a/src/data/models/RichPresenceModel.hh b/src/data/models/RichPresenceModel.hh
new file mode 100644
index 00000000..45308289
--- /dev/null
+++ b/src/data/models/RichPresenceModel.hh
@@ -0,0 +1,56 @@
+#ifndef RA_DATA_RICHPRESENCE_MODEL_H
+#define RA_DATA_RICHPRESENCE_MODEL_H
+#pragma once
+
+#include "AssetModelBase.hh"
+
+#include "CapturedTriggerHits.hh"
+
+#include "data\Types.hh"
+
+namespace ra {
+namespace data {
+namespace models {
+
+class RichPresenceModel : public AssetModelBase
+{
+public:
+    RichPresenceModel() noexcept;
+
+    /// <summary>
+    /// The <see cref="ModelProperty" /> for the start trigger version.
+    /// </summary>
+    static const IntModelProperty ScriptProperty;
+
+    /// <summary>
+    /// Gets the start trigger definition.
+    /// </summary>
+    const std::string& GetScript() const { return GetAssetDefinition(m_pScript); }
+
+    /// <summary>
+    /// Sets the start trigger definition.
+    /// </summary>
+    void SetScript(const std::string& sScript);
+
+    void ReloadRichPresenceScript();
+
+    void Activate() override;
+    void Deactivate() override;
+
+    void Serialize(ra::services::TextWriter& pWriter) const noexcept override;
+    bool Deserialize(ra::Tokenizer& pTokenizer) noexcept override;
+
+protected:
+    void OnValueChanged(const IntModelProperty::ChangeArgs& args) override;
+
+private:
+    void WriteRichPresenceScript();
+
+    AssetDefinition m_pScript;
+};
+
+} // namespace models
+} // namespace data
+} // namespace ra
+
+#endif RA_DATA_RICHPRESENCE_MODEL_H
diff --git a/src/services/AchievementRuntime.cpp b/src/services/AchievementRuntime.cpp
index 1e6a7bd8..bc87c2c8 100644
--- a/src/services/AchievementRuntime.cpp
+++ b/src/services/AchievementRuntime.cpp
@@ -157,7 +157,7 @@ int AchievementRuntime::ActivateLeaderboard(unsigned int nId, const std::string&
     return rc_runtime_activate_lboard(&m_pRuntime, nId, sDefinition.c_str(), nullptr, 0);
 }
 
-void AchievementRuntime::ActivateRichPresence(const std::string& sScript)
+bool AchievementRuntime::ActivateRichPresence(const std::string& sScript)
 {
     std::lock_guard<std::mutex> pLock(m_pMutex);
     EnsureInitialized();
@@ -174,6 +174,8 @@ void AchievementRuntime::ActivateRichPresence(const std::string& sScript)
         if (m_nRichPresenceParseResult != RC_OK)
             m_nRichPresenceParseResult = rc_richpresence_size_lines(sScript.c_str(), &m_nRichPresenceErrorLine);
     }
+
+    return (m_nRichPresenceParseResult == RC_OK);
 }
 
 std::wstring AchievementRuntime::GetRichPresenceDisplayString() const
diff --git a/src/services/AchievementRuntime.hh b/src/services/AchievementRuntime.hh
index d90d6648..367dd83d 100644
--- a/src/services/AchievementRuntime.hh
+++ b/src/services/AchievementRuntime.hh
@@ -127,7 +127,8 @@ public:
     /// Specifies the rich presence to process each frame.
     /// </summary>
     /// <remarks>Only updates the memrefs each frame (for deltas), the script is not processed here.</remarks>
-    void ActivateRichPresence(const std::string& sScript);
+    /// <returns><c>true</c> if the rich presence was activated, <c>false</c> if there was an error.</returns>
+    bool ActivateRichPresence(const std::string& sScript);
 
     /// <summary>
     /// Gets whether or not the loaded game has a rich presence script.
diff --git a/src/ui/viewmodels/AssetListViewModel.cpp b/src/ui/viewmodels/AssetListViewModel.cpp
index 81bd06dd..803e5319 100644
--- a/src/ui/viewmodels/AssetListViewModel.cpp
+++ b/src/ui/viewmodels/AssetListViewModel.cpp
@@ -8,7 +8,9 @@
 #include "services\ILocalStorage.hh"
 #include "services\IThreadPool.hh"
 #include "services\ServiceLocator.hh"
+#include "services\impl\FileLocalStorage.hh"
 
+#include "ui\IDesktop.hh"
 #include "ui\viewmodels\AssetUploadViewModel.hh"
 #include "ui\viewmodels\MessageBoxViewModel.hh"
 #include "ui\viewmodels\NewAssetViewModel.hh"
@@ -70,6 +72,7 @@ AssetListViewModel::AssetListViewModel() noexcept
     m_vAssetTypeFilters.Add(ra::etoi(ra::data::models::AssetType::None), L"All");
     m_vAssetTypeFilters.Add(ra::etoi(ra::data::models::AssetType::Achievement), L"Achievements");
     m_vAssetTypeFilters.Add(ra::etoi(ra::data::models::AssetType::Leaderboard), L"Leaderboards");
+    m_vAssetTypeFilters.Add(ra::etoi(ra::data::models::AssetType::RichPresence), L"Rich Presence");
 
     m_vChanges.Add(ra::etoi(ra::data::models::AssetChanges::None), L"");
     m_vChanges.Add(ra::etoi(ra::data::models::AssetChanges::Modified), L"Modified");
@@ -541,6 +544,17 @@ void AssetListViewModel::OpenEditor(const AssetSummaryViewModel* pAsset)
         vmAsset = pGameContext.Assets().FindAsset(pAsset->GetType(), pAsset->GetId());
     }
 
+    if (vmAsset && vmAsset->GetType() == ra::data::models::AssetType::RichPresence)
+    {
+        const auto& pLocalStorage = dynamic_cast<const ra::services::impl::FileLocalStorage&>(ra::services::ServiceLocator::Get<ra::services::ILocalStorage>());
+        auto& pGameContext = ra::services::ServiceLocator::GetMutable<ra::data::context::GameContext>();
+        const auto sFilePath = pLocalStorage.GetPath(ra::services::StorageItemType::RichPresence, std::to_wstring(pGameContext.GameId()));
+        auto sUrl = ra::StringPrintf("file://localhost/%s", sFilePath);
+        std::replace(sUrl.begin(), sUrl.end(), '\\', '/');
+        ra::services::ServiceLocator::Get<ra::ui::IDesktop>().OpenUrl(sUrl);
+        return;
+    }
+
     auto& pWindowManager = ra::services::ServiceLocator::GetMutable<ra::ui::viewmodels::WindowManager>();
     pWindowManager.AssetEditor.LoadAsset(vmAsset);
 
@@ -618,6 +632,8 @@ void AssetListViewModel::DoUpdateButtons()
     bool bHasUnofficial = false;
     bool bHasCore = false;
     bool bHasLocal = false;
+    bool bHasRichPresenceSelection = false;
+    bool bHasNonRichPresenceSelection = false;
 
     const bool bGameLoaded = (GetGameId() != 0);
     if (!bGameLoaded)
@@ -691,6 +707,16 @@ void AssetListViewModel::DoUpdateButtons()
                             bHasNonNewSelection = true;
                             break;
                     }
+
+                    switch (pItem->GetType())
+                    {
+                        case ra::data::models::AssetType::RichPresence:
+                            bHasRichPresenceSelection = true;
+                            break;
+                        default:
+                            bHasNonRichPresenceSelection = true;
+                            break;
+                    }
                 }
                 else
                 {
@@ -713,6 +739,30 @@ void AssetListViewModel::DoUpdateButtons()
         }
     }
 
+    if (bHasRichPresenceSelection)
+    {
+        SetValue(CanCreateProperty, true);
+        SetValue(CanCloneProperty, false);
+
+        SetValue(ActivateButtonTextProperty, L"&Activate");
+        SetValue(CanActivateProperty, false);
+
+        SetValue(SaveButtonTextProperty, L"&Save");
+        SetValue(CanSaveProperty, false);
+
+        SetValue(ResetButtonTextProperty, L"&Reset");
+        SetValue(CanResetProperty, false);
+
+        SetValue(RevertButtonTextProperty, L"Re&vert");
+
+        std::vector<AssetSummaryViewModel> vSelectedAssets;
+        SetValue(CanRevertProperty,
+                 (!bHasNonRichPresenceSelection &&
+                  m_vFilteredAssets.GetItemAt(0)->GetCategory() == ra::data::models::AssetCategory::Core));
+
+        return;
+    }
+
     if (bHasInactiveSelection)
     {
         SetValue(ActivateButtonTextProperty, L"&Activate");
@@ -1458,7 +1508,7 @@ void AssetListViewModel::RevertSelected()
     if (bCoreAssetSelected)
     {
         auto& pEmulatorContext = ra::services::ServiceLocator::GetMutable<ra::data::context::EmulatorContext>();
-        if (!pEmulatorContext.WarnDisableHardcoreMode("revert core achievements"))
+        if (!pEmulatorContext.WarnDisableHardcoreMode("revert core assets"))
             return;
     }
 
diff --git a/src/ui/viewmodels/IntegrationMenuViewModel.cpp b/src/ui/viewmodels/IntegrationMenuViewModel.cpp
index 7dbbf651..d3a9f0b0 100644
--- a/src/ui/viewmodels/IntegrationMenuViewModel.cpp
+++ b/src/ui/viewmodels/IntegrationMenuViewModel.cpp
@@ -277,7 +277,9 @@ void IntegrationMenuViewModel::ShowMemoryBookmarks()
 void IntegrationMenuViewModel::ShowRichPresenceMonitor()
 {
     auto& pGameContext = ra::services::ServiceLocator::GetMutable<ra::data::context::GameContext>();
-    pGameContext.ReloadRichPresenceScript();
+    auto* pRichPresence = pGameContext.Assets().FindRichPresence();
+    if (pRichPresence != nullptr)
+        pRichPresence->ReloadRichPresenceScript();
 
     auto& pWindowManager = ra::services::ServiceLocator::GetMutable<ra::ui::viewmodels::WindowManager>();
     pWindowManager.RichPresenceMonitor.Show();
diff --git a/src/ui/viewmodels/RichPresenceMonitorViewModel.cpp b/src/ui/viewmodels/RichPresenceMonitorViewModel.cpp
index a3c62123..d836f49d 100644
--- a/src/ui/viewmodels/RichPresenceMonitorViewModel.cpp
+++ b/src/ui/viewmodels/RichPresenceMonitorViewModel.cpp
@@ -36,6 +36,8 @@ static time_t GetRichPresenceModified()
 
 void RichPresenceMonitorViewModel::StartMonitoring()
 {
+    UpdateWindowTitle();
+
     switch (m_nState)
     {
         default:
@@ -106,8 +108,12 @@ void RichPresenceMonitorViewModel::ScheduleUpdateDisplayString()
             const time_t tRichPresenceFileTime = GetRichPresenceModified();
             if (tRichPresenceFileTime != m_tRichPresenceFileTime)
             {
-                ra::services::ServiceLocator::GetMutable<ra::data::context::GameContext>().ReloadRichPresenceScript();
+                auto* pRichPresence = ra::services::ServiceLocator::GetMutable<ra::data::context::GameContext>().Assets().FindRichPresence();
+                if (pRichPresence)
+                    pRichPresence->ReloadRichPresenceScript();
                 m_tRichPresenceFileTime = tRichPresenceFileTime;
+
+                UpdateWindowTitle();
             }
 
             UpdateDisplayString();
@@ -146,10 +152,23 @@ void RichPresenceMonitorViewModel::UpdateDisplayString()
     }
 }
 
+void RichPresenceMonitorViewModel::UpdateWindowTitle()
+{
+    const auto& pGameContext = ra::services::ServiceLocator::Get<ra::data::context::GameContext>();
+    const auto* pRichPresence = pGameContext.Assets().FindRichPresence();
+    if (pRichPresence && pRichPresence->GetChanges() != ra::data::models::AssetChanges::None)
+        SetWindowTitle(L"Rich Presence Monitor (local)");
+    else
+        SetWindowTitle(L"Rich Presence Monitor");
+}
+
 void RichPresenceMonitorViewModel::OnActiveGameChanged()
 {
     if (IsVisible())
+    {
+        UpdateWindowTitle();
         UpdateDisplayString();
+    }
 }
 
 void RichPresenceMonitorViewModel::OnValueChanged(const BoolModelProperty::ChangeArgs& args)
diff --git a/src/ui/viewmodels/RichPresenceMonitorViewModel.hh b/src/ui/viewmodels/RichPresenceMonitorViewModel.hh
index ff1a5818..da3ab554 100644
--- a/src/ui/viewmodels/RichPresenceMonitorViewModel.hh
+++ b/src/ui/viewmodels/RichPresenceMonitorViewModel.hh
@@ -56,6 +56,7 @@ protected:
 
 private:
     void ScheduleUpdateDisplayString();
+    void UpdateWindowTitle();
 
     enum class MonitorState
     {
diff --git a/src/ui/win32/AssetListDialog.cpp b/src/ui/win32/AssetListDialog.cpp
index de0a4ea2..85925d80 100644
--- a/src/ui/win32/AssetListDialog.cpp
+++ b/src/ui/win32/AssetListDialog.cpp
@@ -63,6 +63,10 @@ public:
         if (nId >= ra::data::context::GameAssets::FirstLocalId)
             return L"0";
 
+        const auto nType = vmItems.GetItemValue(nIndex, ra::data::models::AssetModelBase::TypeProperty);
+        if (nType == ra::etoi(ra::data::models::AssetType::RichPresence))
+            return L"";
+
         return std::to_wstring(nId);
     }
 };
@@ -111,11 +115,10 @@ public:
             case ra::data::models::AssetType::Leaderboard:
                 return L"\U0001F4CA"; // 1F4CA - Bar Chart
 
-            //case ra::data::models::AssetType::RichPresence:
+            case ra::data::models::AssetType::RichPresence:
             //    return L"\U0001F4C4"; // 1F4C4 - Document
             //    return L"\U0001F4CD"; // 1F4CD - Round Pushpin
-            //    return L"\U0001F4DC"; // 1F4DC - Scroll
-            //    return L"\U0001F522"; // 1F5FA - World Map
+                return L"\U0001F4DC"; // 1F4DC - Scroll
 
             default:
                 return std::to_wstring(ra::etoi(nType));
@@ -133,6 +136,9 @@ public:
             case ra::data::models::AssetType::Leaderboard:
                 return L"Leaderboard";
 
+            case ra::data::models::AssetType::RichPresence:
+                return L"Rich Presence";
+
             default:
                 return std::to_wstring(ra::etoi(nType));
         }


Test patch:
diff --git a/tests/RA_Integration.Tests.vcxproj b/tests/RA_Integration.Tests.vcxproj
index 8406125e..fe79731c 100644
--- a/tests/RA_Integration.Tests.vcxproj
+++ b/tests/RA_Integration.Tests.vcxproj
@@ -385,6 +385,7 @@
     <ClCompile Include="..\src\data\models\CapturedTriggerHits.cpp" />
     <ClCompile Include="..\src\data\models\LeaderboardModel.cpp" />
     <ClCompile Include="..\src\data\models\LocalBadgesModel.cpp" />
+    <ClCompile Include="..\src\data\models\RichPresenceModel.cpp" />
     <ClCompile Include="..\src\data\models\TriggerValidation.cpp" />
     <ClCompile Include="..\src\pch.cpp">
       <PrecompiledHeader Condition="'$(Configuration)|$(Platform)'=='Debug|Win32'">Create</PrecompiledHeader>
@@ -461,6 +462,7 @@
     <ClCompile Include="data\models\AssetModelBase_Tests.cpp" />
     <ClCompile Include="data\models\LeaderboardModel_Tests.cpp" />
     <ClCompile Include="data\models\LocalBadgesModel_Tests.cpp" />
+    <ClCompile Include="data\models\RichPresenceModel_Tests.cpp" />
     <ClCompile Include="data\models\TriggerValidation_Tests.cpp" />
     <ClCompile Include="Exports_Tests.cpp" />
     <ClCompile Include="services\AchievementRuntime_Tests.cpp" />
diff --git a/tests/RA_Integration.Tests.vcxproj.filters b/tests/RA_Integration.Tests.vcxproj.filters
index 75c7b347..6ca84551 100644
--- a/tests/RA_Integration.Tests.vcxproj.filters
+++ b/tests/RA_Integration.Tests.vcxproj.filters
@@ -432,6 +432,12 @@
     <ClCompile Include="..\src\ui\viewmodels\NewAssetViewModel.cpp">
       <Filter>Code</Filter>
     </ClCompile>
+    <ClCompile Include="..\src\data\models\RichPresenceModel.cpp">
+      <Filter>Code</Filter>
+    </ClCompile>
+    <ClCompile Include="data\models\RichPresenceModel_Tests.cpp">
+      <Filter>Tests\Data\Models</Filter>
+    </ClCompile>
   </ItemGroup>
   <ItemGroup>
     <None Include="base.props" />
diff --git a/tests/data/context/GameContext_Tests.cpp b/tests/data/context/GameContext_Tests.cpp
index 9a1c1764..d9eafc51 100644
--- a/tests/data/context/GameContext_Tests.cpp
+++ b/tests/data/context/GameContext_Tests.cpp
@@ -73,7 +73,29 @@ public:
 
         void SetGameId(unsigned int nGameId) noexcept { m_nGameId = nGameId; }
 
-        void SetRichPresenceFromFile(bool bValue) noexcept { m_bRichPresenceFromFile = bValue; }
+        void SetRichPresenceFromFile(bool bValue)
+        {
+            auto* pRichPresence = GetRichPresence();
+            Expects(pRichPresence != nullptr);
+
+            if (bValue)
+                pRichPresence->SetScript("Display:\nThis differs\n");
+            else
+                pRichPresence->SetScript("Display:\nTest\n");
+        }
+
+        void ReloadRichPresenceScript()
+        {
+            auto* pRichPresence = GetRichPresence();
+            Expects(pRichPresence != nullptr);
+            pRichPresence->ReloadRichPresenceScript();
+        }
+
+        bool IsRichPresenceFromFile() const
+        {
+            const auto* pRichPresence = Assets().FindRichPresence();
+            return pRichPresence != nullptr && pRichPresence->GetChanges() != ra::data::models::AssetChanges::None;
+        }
 
         ra::data::models::AchievementModel& MockAchievement()
         {
@@ -110,6 +132,23 @@ public:
         }
 
     private:
+        ra::data::models::RichPresenceModel* GetRichPresence()
+        {
+            auto* pRichPresence = Assets().FindRichPresence();
+            if (!pRichPresence)
+            {
+                auto pNewRichPresence = std::make_unique<ra::data::models::RichPresenceModel>();
+                pNewRichPresence->SetScript("Display:\nTest\n");
+                pNewRichPresence->CreateServerCheckpoint();
+                pNewRichPresence->CreateLocalCheckpoint();
+                Assets().Append(std::move(pNewRichPresence));
+
+                pRichPresence = Assets().FindRichPresence();
+            }
+
+            return pRichPresence;
+        }
+
         ra::services::ServiceLocator::ServiceOverride<ra::data::context::GameContext> m_OverrideGameContext;
         ra::services::ServiceLocator::ServiceOverride<ra::services::AchievementRuntime> m_OverrideRuntime;
     };
@@ -364,12 +403,18 @@ public:
         game.LoadGame(1U);
 
         Assert::IsTrue(game.HasRichPresence());
-        Assert::AreEqual(std::string("Display:\nHello, World"),
+        Assert::AreEqual(std::string("Display:\nHello, World\n"),
             game.mockStorage.GetStoredData(ra::services::StorageItemType::RichPresence, L"1"));
         Assert::IsFalse(game.IsRichPresenceFromFile());
+
+        const auto* pRichPresence = game.Assets().FindRichPresence();
+        Assert::IsNotNull(pRichPresence);
+        Ensures(pRichPresence != nullptr);
+        Assert::AreEqual(ra::data::models::AssetCategory::Core, pRichPresence->GetCategory());
+        Assert::AreEqual(ra::data::models::AssetChanges::None, pRichPresence->GetChanges());
     }
 
-    TEST_METHOD(TestLoadGameResetsRichPresenceFromFile)
+    TEST_METHOD(TestLoadGameDoesNotOverwriteRichPresenceFromFile)
     {
         GameContextHarness game;
         game.mockServer.HandleRequest<ra::api::FetchGameData>([](const ra::api::FetchGameData::Request&, ra::api::FetchGameData::Response& response)
@@ -378,13 +423,63 @@ public:
             return true;
         });
 
-        game.SetRichPresenceFromFile(true);
+        game.mockStorage.MockStoredData(ra::services::StorageItemType::RichPresence, L"1", "Display:\nFrom File\n");
         game.LoadGame(1U);
 
         Assert::IsTrue(game.HasRichPresence());
-        Assert::AreEqual(std::string("Display:\nHello, World"),
+        Assert::AreEqual(std::string("Display:\nFrom File\n"),
             game.mockStorage.GetStoredData(ra::services::StorageItemType::RichPresence, L"1"));
+        Assert::IsTrue(game.IsRichPresenceFromFile());
+
+        const auto* pRichPresence = game.Assets().FindRichPresence();
+        Assert::IsNotNull(pRichPresence);
+        Ensures(pRichPresence != nullptr);
+        Assert::AreEqual(ra::data::models::AssetCategory::Core, pRichPresence->GetCategory());
+        Assert::AreEqual(ra::data::models::AssetChanges::Unpublished, pRichPresence->GetChanges());
+    }
+
+    TEST_METHOD(TestLoadGameNoRichPresence)
+    {
+        GameContextHarness game;
+        game.mockServer.HandleRequest<ra::api::FetchGameData>(
+            [](const ra::api::FetchGameData::Request&, ra::api::FetchGameData::Response& response) {
+                response.RichPresence = "";
+                return true;
+            });
+
+        game.LoadGame(1U);
+
+        Assert::IsFalse(game.HasRichPresence());
+        Assert::AreEqual(std::string(),
+                         game.mockStorage.GetStoredData(ra::services::StorageItemType::RichPresence, L"1"));
         Assert::IsFalse(game.IsRichPresenceFromFile());
+
+        const auto* pRichPresence = game.Assets().FindRichPresence();
+        Assert::IsNull(pRichPresence);
+    }
+
+    TEST_METHOD(TestLoadGameRichPresenceOnlyFromFile)
+    {
+        GameContextHarness game;
+        game.mockServer.HandleRequest<ra::api::FetchGameData>(
+            [](const ra::api::FetchGameData::Request&, ra::api::FetchGameData::Response& response) {
+                response.RichPresence = "";
+                return true;
+            });
+
+        game.mockStorage.MockStoredData(ra::services::StorageItemType::RichPresence, L"1", "Display:\nFrom File\n");
+        game.LoadGame(1U);
+
+        Assert::IsTrue(game.HasRichPresence());
+        Assert::AreEqual(std::string("Display:\nFrom File\n"),
+                         game.mockStorage.GetStoredData(ra::services::StorageItemType::RichPresence, L"1"));
+        Assert::IsTrue(game.IsRichPresenceFromFile());
+
+        const auto* pRichPresence = game.Assets().FindRichPresence();
+        Assert::IsNotNull(pRichPresence);
+        Ensures(pRichPresence != nullptr);
+        Assert::AreEqual(ra::data::models::AssetCategory::Local, pRichPresence->GetCategory());
+        Assert::AreEqual(ra::data::models::AssetChanges::Unpublished, pRichPresence->GetChanges());
     }
 
     TEST_METHOD(TestLoadGameAchievements)
@@ -1083,7 +1178,7 @@ public:
         Assert::IsFalse(game.IsRichPresenceFromFile());
 
         /* replace written server RP with empty file */
-        game.mockStorage.DeleteStoredData(ra::services::StorageItemType::RichPresence, L"1");
+        game.mockStorage.MockStoredData(ra::services::StorageItemType::RichPresence, L"1", "");
         game.ReloadRichPresenceScript();
 
         Assert::IsFalse(game.HasRichPresence());
diff --git a/tests/data/context/SessionTracker_Tests.cpp b/tests/data/context/SessionTracker_Tests.cpp
index 8e7f3b52..596f4350 100644
--- a/tests/data/context/SessionTracker_Tests.cpp
+++ b/tests/data/context/SessionTracker_Tests.cpp
@@ -5,6 +5,7 @@
 #include "tests\RA_UnitTestHelpers.h"
 #include "tests\data\DataAsserts.hh"
 
+#include "tests\mocks\MockAchievementRuntime.hh"
 #include "tests\mocks\MockClock.hh"
 #include "tests\mocks\MockConfiguration.hh"
 #include "tests\mocks\MockGameContext.hh"
@@ -36,6 +37,7 @@ public:
 
         ra::api::mocks::MockServer mockServer;
         ra::data::context::mocks::MockGameContext mockGameContext;
+        ra::services::mocks::MockAchievementRuntime mockRuntime;
         ra::services::mocks::MockClock mockClock;
         ra::services::mocks::MockConfiguration mockConfiguration;
         ra::services::mocks::MockLocalStorage mockStorage;
diff --git a/tests/data/models/RichPresenceModel_Tests.cpp b/tests/data/models/RichPresenceModel_Tests.cpp
new file mode 100644
index 00000000..ddfb6bf1
--- /dev/null
+++ b/tests/data/models/RichPresenceModel_Tests.cpp
@@ -0,0 +1,284 @@
+#include "CppUnitTest.h"
+
+#include "data\models\RichPresenceModel.hh"
+
+#include "services\impl\StringTextWriter.hh"
+
+#include "tests\RA_UnitTestHelpers.h"
+#include "tests\data\DataAsserts.hh"
+
+#include "tests\mocks\MockAchievementRuntime.hh"
+#include "tests\mocks\MockGameContext.hh"
+#include "tests\mocks\MockLocalStorage.hh"
+
+using namespace Microsoft::VisualStudio::CppUnitTestFramework;
+
+namespace ra {
+namespace data {
+namespace models {
+namespace tests {
+
+TEST_CLASS(RichPresenceModel_Tests)
+{
+private:
+    class RichPresenceModelHarness : public RichPresenceModel
+    {
+    public:
+        ra::data::context::mocks::MockGameContext mockGameContext;
+        ra::services::mocks::MockLocalStorage mockLocalStorage;
+        ra::services::impl::StringTextWriter textWriter;
+        ra::services::mocks::MockAchievementRuntime mockRuntime;
+    };
+
+public:
+    TEST_METHOD(TestInitialValues)
+    {
+        RichPresenceModelHarness richPresence;
+
+        Assert::AreEqual(AssetType::RichPresence, richPresence.GetType());
+        Assert::AreEqual(0U, richPresence.GetID());
+        Assert::AreEqual(std::wstring(L"Rich Presence"), richPresence.GetName());
+        Assert::AreEqual(std::wstring(L""), richPresence.GetDescription());
+        Assert::AreEqual(AssetCategory::Core, richPresence.GetCategory());
+        Assert::AreEqual(AssetState::Inactive, richPresence.GetState());
+        Assert::AreEqual(AssetChanges::None, richPresence.GetChanges());
+        Assert::AreEqual(std::string(""), richPresence.GetScript());
+    }
+
+    TEST_METHOD(TestActivateDeactivate)
+    {
+        RichPresenceModelHarness richPresence;
+        richPresence.SetScript("Display:\nTest\n");
+        richPresence.CreateServerCheckpoint();
+        richPresence.CreateLocalCheckpoint();
+
+        Assert::AreEqual(std::string("Display:\nTest\n"), richPresence.GetScript());
+
+        Assert::AreEqual(AssetState::Inactive, richPresence.GetState());
+        Assert::AreEqual(std::wstring(L"No Rich Presence defined."), richPresence.mockRuntime.GetRichPresenceDisplayString());
+
+        richPresence.Activate();
+
+        Assert::AreEqual(AssetState::Active, richPresence.GetState());
+        Assert::AreEqual(std::wstring(L"Test"), richPresence.mockRuntime.GetRichPresenceDisplayString());
+
+        richPresence.Deactivate();
+
+        Assert::AreEqual(AssetState::Inactive, richPresence.GetState());
+        Assert::AreEqual(std::wstring(L"No Rich Presence defined."), richPresence.mockRuntime.GetRichPresenceDisplayString());
+    }
+
+    TEST_METHOD(TestChangeWhileActive)
+    {
+        RichPresenceModelHarness richPresence;
+        richPresence.SetScript("Display:\nTest\n");
+        richPresence.CreateServerCheckpoint();
+        richPresence.CreateLocalCheckpoint();
+        richPresence.Activate();
+
+        Assert::AreEqual(AssetState::Active, richPresence.GetState());
+        Assert::AreEqual(std::wstring(L"Test"), richPresence.mockRuntime.GetRichPresenceDisplayString());
+
+        richPresence.SetScript("Display:\nNew test\n");
+
+        Assert::AreEqual(AssetState::Active, richPresence.GetState());
+        Assert::AreEqual(std::wstring(L"New test"), richPresence.mockRuntime.GetRichPresenceDisplayString());
+    }
+
+    TEST_METHOD(TestActivateInvalid)
+    {
+        RichPresenceModelHarness richPresence;
+        richPresence.SetScript("Test\n");
+        richPresence.CreateServerCheckpoint();
+        richPresence.CreateLocalCheckpoint();
+
+        Assert::AreEqual(std::string("Test\n"), richPresence.GetScript());
+
+        Assert::AreEqual(AssetState::Inactive, richPresence.GetState());
+        Assert::AreEqual(std::wstring(L"No Rich Presence defined."), richPresence.mockRuntime.GetRichPresenceDisplayString());
+
+        richPresence.Activate();
+
+        Assert::AreEqual(AssetState::Disabled, richPresence.GetState());
+        Assert::AreEqual(std::wstring(L"Parse error -18 (line 1): Missing display string"), richPresence.mockRuntime.GetRichPresenceDisplayString());
+
+        richPresence.SetScript("Display:\nTest\n");
+
+        Assert::AreEqual(AssetState::Active, richPresence.GetState());
+        Assert::AreEqual(std::wstring(L"Test"), richPresence.mockRuntime.GetRichPresenceDisplayString());
+    }
+
+    TEST_METHOD(TestNormalizeScript)
+    {
+        RichPresenceModelHarness richPresence;
+        richPresence.SetScript("Display:\nTest\n");
+        richPresence.CreateServerCheckpoint();
+        richPresence.CreateLocalCheckpoint();
+
+        Assert::AreEqual(AssetChanges::None, richPresence.GetChanges());
+
+        richPresence.SetScript("Display:\r\nTest\r\n");
+        Assert::AreEqual(AssetChanges::None, richPresence.GetChanges());
+
+        richPresence.SetScript("Display:\nTest");
+        Assert::AreEqual(AssetChanges::None, richPresence.GetChanges());
+
+        richPresence.SetScript("Display:\nTest2\n");
+        Assert::AreEqual(AssetChanges::Modified, richPresence.GetChanges());
+    }
+
+    TEST_METHOD(TestNormalizeScriptOnlyWhitespace)
+    {
+        RichPresenceModelHarness richPresence;
+        richPresence.CreateServerCheckpoint();
+        richPresence.CreateLocalCheckpoint();
+
+        Assert::AreEqual(AssetChanges::None, richPresence.GetChanges());
+
+        richPresence.SetScript("\n");
+        Assert::AreEqual(AssetChanges::None, richPresence.GetChanges());
+
+        richPresence.SetScript("     ");
+        Assert::AreEqual(AssetChanges::None, richPresence.GetChanges());
+
+        richPresence.SetScript("\n\nDisplay:\nTest\n");
+        Assert::AreEqual(AssetChanges::Modified, richPresence.GetChanges());
+    }
+
+    TEST_METHOD(TestReloadWritesFile)
+    {
+        RichPresenceModelHarness richPresence;
+        richPresence.mockGameContext.SetGameId(1);
+        richPresence.SetScript("Display:\nTest\n");
+        richPresence.CreateServerCheckpoint();
+        richPresence.CreateLocalCheckpoint();
+
+        Assert::AreEqual(AssetChanges::None, richPresence.GetChanges());
+        Assert::AreEqual(std::string(), richPresence.mockLocalStorage.GetStoredData(ra::services::StorageItemType::RichPresence, L"1"));
+
+        richPresence.ReloadRichPresenceScript();
+        Assert::AreEqual(AssetChanges::None, richPresence.GetChanges());
+        Assert::AreEqual(std::string("Display:\nTest\n"), richPresence.mockLocalStorage.GetStoredData(ra::services::StorageItemType::RichPresence, L"1"));
+    }
+
+    TEST_METHOD(TestReloadReadsFile)
+    {
+        RichPresenceModelHarness richPresence;
+        richPresence.mockGameContext.SetGameId(1);
+        richPresence.mockLocalStorage.MockStoredData(ra::services::StorageItemType::RichPresence, L"1", "Display:\nFrom file\n");
+        richPresence.SetScript("Display:\nTest\n");
+        richPresence.CreateServerCheckpoint();
+        richPresence.CreateLocalCheckpoint();
+
+        Assert::AreEqual(AssetChanges::None, richPresence.GetChanges());
+
+        richPresence.ReloadRichPresenceScript();
+        Assert::AreEqual(std::string("Display:\nFrom file\n"), richPresence.GetScript());
+        Assert::AreEqual(AssetChanges::Unpublished, richPresence.GetChanges());
+        Assert::AreEqual(AssetCategory::Core, richPresence.GetCategory());
+
+        Assert::AreEqual(std::string("Display:\nFrom file\n"), richPresence.mockLocalStorage.GetStoredData(ra::services::StorageItemType::RichPresence, L"1"));
+    }
+
+    TEST_METHOD(TestReloadNormalizesFile)
+    {
+        RichPresenceModelHarness richPresence;
+        richPresence.mockGameContext.SetGameId(1);
+        richPresence.mockLocalStorage.MockStoredData(ra::services::StorageItemType::RichPresence, L"1", "\xef\xbb\xbf\nDisplay:\r\nFrom file\r\n");
+        richPresence.SetScript("Display:\nTest\n");
+        richPresence.CreateServerCheckpoint();
+        richPresence.CreateLocalCheckpoint();
+
+        Assert::AreEqual(AssetChanges::None, richPresence.GetChanges());
+
+        richPresence.ReloadRichPresenceScript();
+        Assert::AreEqual(std::string("\nDisplay:\nFrom file\n"), richPresence.GetScript());
+        Assert::AreEqual(AssetChanges::Unpublished, richPresence.GetChanges());
+        Assert::AreEqual(AssetCategory::Core, richPresence.GetCategory());
+
+        Assert::AreEqual(std::string("\xef\xbb\xbf\nDisplay:\r\nFrom file\r\n"), richPresence.mockLocalStorage.GetStoredData(ra::services::StorageItemType::RichPresence, L"1"));
+    }
+
+    TEST_METHOD(TestReloadReadsFileNoServerData)
+    {
+        RichPresenceModelHarness richPresence;
+        richPresence.mockGameContext.SetGameId(1);
+        richPresence.mockLocalStorage.MockStoredData(ra::services::StorageItemType::RichPresence, L"1", "Display:\nFrom file\n");
+        richPresence.CreateServerCheckpoint();
+        richPresence.CreateLocalCheckpoint();
+
+        Assert::AreEqual(AssetChanges::None, richPresence.GetChanges());
+
+        richPresence.ReloadRichPresenceScript();
+        Assert::AreEqual(std::string("Display:\nFrom file\n"), richPresence.GetScript());
+        Assert::AreEqual(AssetChanges::Unpublished, richPresence.GetChanges());
+        Assert::AreEqual(AssetCategory::Local, richPresence.GetCategory());
+
+        Assert::AreEqual(std::string("Display:\nFrom file\n"), richPresence.mockLocalStorage.GetStoredData(ra::services::StorageItemType::RichPresence, L"1"));
+    }
+
+    TEST_METHOD(TestReloadDiscardsChanges)
+    {
+        RichPresenceModelHarness richPresence;
+        richPresence.mockGameContext.SetGameId(1);
+        richPresence.mockLocalStorage.MockStoredData(ra::services::StorageItemType::RichPresence, L"1", "Display:\nFrom file\n");
+        richPresence.SetScript("Display:\nTest\n");
+        richPresence.CreateServerCheckpoint();
+        richPresence.SetScript("Display:\nLocal\n");
+        richPresence.CreateLocalCheckpoint();
+
+        Assert::AreEqual(AssetChanges::Unpublished, richPresence.GetChanges());
+
+        richPresence.ReloadRichPresenceScript();
+        Assert::AreEqual(std::string("Display:\nFrom file\n"), richPresence.GetScript());
+        Assert::AreEqual(AssetChanges::Unpublished, richPresence.GetChanges());
+        Assert::AreEqual(AssetCategory::Core, richPresence.GetCategory());
+
+        Assert::AreEqual(std::string("Display:\nFrom file\n"), richPresence.mockLocalStorage.GetStoredData(ra::services::StorageItemType::RichPresence, L"1"));
+    }
+
+    TEST_METHOD(TestReloadDiscardsChangesMatchesServer)
+    {
+        RichPresenceModelHarness richPresence;
+        richPresence.mockGameContext.SetGameId(1);
+        richPresence.mockLocalStorage.MockStoredData(ra::services::StorageItemType::RichPresence, L"1", "Display:\nTest\n");
+        richPresence.SetScript("Display:\nTest\n");
+        richPresence.CreateServerCheckpoint();
+        richPresence.SetScript("Display:\nLocal\n");
+        richPresence.CreateLocalCheckpoint();
+
+        Assert::AreEqual(AssetChanges::Unpublished, richPresence.GetChanges());
+
+        richPresence.ReloadRichPresenceScript();
+        Assert::AreEqual(std::string("Display:\nTest\n"), richPresence.GetScript());
+        Assert::AreEqual(AssetChanges::None, richPresence.GetChanges());
+        Assert::AreEqual(AssetCategory::Core, richPresence.GetCategory());
+
+        Assert::AreEqual(std::string("Display:\nTest\n"), richPresence.mockLocalStorage.GetStoredData(ra::services::StorageItemType::RichPresence, L"1"));
+    }
+
+    TEST_METHOD(TestRevertUpdatesFile)
+    {
+        RichPresenceModelHarness richPresence;
+        richPresence.mockGameContext.SetGameId(1);
+        richPresence.mockLocalStorage.MockStoredData(ra::services::StorageItemType::RichPresence, L"1", "Display:\nFrom file\n");
+        richPresence.SetScript("Display:\nTest\n");
+        richPresence.CreateServerCheckpoint();
+        richPresence.SetScript("Display:\nLocal\n");
+        richPresence.CreateLocalCheckpoint();
+
+        Assert::AreEqual(AssetChanges::Unpublished, richPresence.GetChanges());
+
+        richPresence.RestoreServerCheckpoint();
+        Assert::AreEqual(std::string("Display:\nTest\n"), richPresence.GetScript());
+        Assert::AreEqual(AssetChanges::None, richPresence.GetChanges());
+        Assert::AreEqual(AssetCategory::Core, richPresence.GetCategory());
+
+        Assert::AreEqual(std::string("Display:\nTest\n"), richPresence.mockLocalStorage.GetStoredData(ra::services::StorageItemType::RichPresence, L"1"));
+    }
+};
+
+} // namespace tests
+} // namespace models
+} // namespace data
+} // namespace ra
diff --git a/tests/mocks/MockGameContext.hh b/tests/mocks/MockGameContext.hh
index 843316e7..ff1f7df6 100644
--- a/tests/mocks/MockGameContext.hh
+++ b/tests/mocks/MockGameContext.hh
@@ -45,9 +45,31 @@ public:
     /// <summary>
     /// Sets the rich presence display string.
     /// </summary>
-    void SetRichPresenceDisplayString(std::wstring sValue) { m_sRichPresenceDisplayString = sValue; }
+    void SetRichPresenceDisplayString(std::wstring sValue)
+    {
+        if (Assets().FindRichPresence() == nullptr)
+        {
+            auto pRichPresence = std::make_unique<ra::data::models::RichPresenceModel>();
+            pRichPresence->SetScript(ra::StringPrintf("Display:\n%s\n", sValue));
+            pRichPresence->CreateServerCheckpoint();
+            pRichPresence->CreateLocalCheckpoint();
+            Assets().Append(std::move(pRichPresence));
+        }
+
+        m_sRichPresenceDisplayString = sValue;
+    }
 
-    void SetRichPresenceFromFile(bool bValue) noexcept { m_bRichPresenceFromFile = bValue; }
+    void SetRichPresenceFromFile(bool bValue)
+    {
+        auto* pRichPresence = Assets().FindRichPresence();
+        if (pRichPresence)
+        {
+            if (bValue)
+                pRichPresence->SetScript("Display:\nThis differs\n");
+            else
+                pRichPresence->SetScript(ra::StringPrintf("Display:\n%s\n", m_sRichPresenceDisplayString));
+        }
+    }
 
     bool SetCodeNote(ra::ByteAddress nAddress, const std::wstring& sNote) override
     {
diff --git a/tests/ui/viewmodels/AssetListViewModel_Tests.cpp b/tests/ui/viewmodels/AssetListViewModel_Tests.cpp
index e380a6a7..8df44448 100644
--- a/tests/ui/viewmodels/AssetListViewModel_Tests.cpp
+++ b/tests/ui/viewmodels/AssetListViewModel_Tests.cpp
@@ -2,10 +2,12 @@
 
 #include "data\models\LeaderboardModel.hh"
 #include "data\models\LocalBadgesModel.hh"
+#include "data\models\RichPresenceModel.hh"
 
 #include "ui\viewmodels\AssetListViewModel.hh"
 #include "ui\viewmodels\NewAssetViewModel.hh"
 
+#include "services\impl\FileLocalStorage.hh"
 #include "services\impl\StringTextWriter.hh"
 
 #include "tests\RA_UnitTestHelpers.h"
@@ -17,6 +19,7 @@
 #include "tests\mocks\MockConfiguration.hh"
 #include "tests\mocks\MockDesktop.hh"
 #include "tests\mocks\MockEmulatorContext.hh"
+#include "tests\mocks\MockFileSystem.hh"
 #include "tests\mocks\MockGameContext.hh"
 #include "tests\mocks\MockLocalStorage.hh"
 #include "tests\mocks\MockOverlayManager.hh"
@@ -98,7 +101,8 @@ private:
         Activate,
         Deactivate,
         ActivateAll,
-        Disabled
+        ActivateDisabled,
+        ActivateAllDisabled
     };
 
     enum class SaveButtonState
@@ -128,7 +132,8 @@ private:
         RevertAll,
         Delete,
         DeleteAll,
-        Disabled
+        RevertDisabled,
+        RevertAllDisabled
     };
 
     enum class CreateButtonState
@@ -184,10 +189,15 @@ private:
                     Assert::IsTrue(CanActivate());
                     break;
 
-                case ActivateButtonState::Disabled:
+                case ActivateButtonState::ActivateAllDisabled:
                     Assert::AreEqual(std::wstring(L"&Activate All"), GetValue(ActivateButtonTextProperty));
                     Assert::IsFalse(CanActivate());
                     break;
+
+                case ActivateButtonState::ActivateDisabled:
+                    Assert::AreEqual(std::wstring(L"&Activate"), GetValue(ActivateButtonTextProperty));
+                    Assert::IsFalse(CanActivate());
+                    break;
             }
         }
 
@@ -292,7 +302,12 @@ private:
                     Assert::IsTrue(CanRevert());
                     break;
 
-                case RevertButtonState::Disabled:
+                case RevertButtonState::RevertDisabled:
+                    Assert::AreEqual(std::wstring(L"Re&vert"), GetValue(RevertButtonTextProperty));
+                    Assert::IsFalse(CanRevert());
+                    break;
+
+                case RevertButtonState::RevertAllDisabled:
                     Assert::AreEqual(std::wstring(L"Re&vert All"), GetValue(RevertButtonTextProperty));
                     Assert::IsFalse(CanRevert());
                     break;
@@ -408,6 +423,15 @@ private:
             AddLeaderboard(AssetCategory::Core, L"Lboard1");
         }
 
+        void AddRichPresence(const std::string& sScript)
+        {
+            auto vmRichPresence = std::make_unique<ra::data::models::RichPresenceModel>();
+            vmRichPresence->SetScript(sScript);
+            vmRichPresence->CreateServerCheckpoint();
+            vmRichPresence->CreateLocalCheckpoint();
+            mockGameContext.Assets().Append(std::move(vmRichPresence));
+        }
+
         void ForceUpdateButtons()
         {
             mockThreadPool.AdvanceTime(std::chrono::milliseconds(500));
@@ -553,13 +577,15 @@ public:
         Assert::AreEqual(std::wstring(L"Authored"), vmAssetList.SpecialFilters().GetItemAt(5)->GetLabel());
         Assert::AreEqual(AssetListViewModel::SpecialFilter::All, vmAssetList.GetSpecialFilter());
 
-        Assert::AreEqual({ 3U }, vmAssetList.AssetTypeFilters().Count());
+        Assert::AreEqual({ 4U }, vmAssetList.AssetTypeFilters().Count());
         Assert::AreEqual((int)AssetType::None, vmAssetList.AssetTypeFilters().GetItemAt(0)->GetId());
         Assert::AreEqual(std::wstring(L"All"), vmAssetList.AssetTypeFilters().GetItemAt(0)->GetLabel());
         Assert::AreEqual((int)AssetType::Achievement, vmAssetList.AssetTypeFilters().GetItemAt(1)->GetId());
         Assert::AreEqual(std::wstring(L"Achievements"), vmAssetList.AssetTypeFilters().GetItemAt(1)->GetLabel());
         Assert::AreEqual((int)AssetType::Leaderboard, vmAssetList.AssetTypeFilters().GetItemAt(2)->GetId());
         Assert::AreEqual(std::wstring(L"Leaderboards"), vmAssetList.AssetTypeFilters().GetItemAt(2)->GetLabel());
+        Assert::AreEqual((int)AssetType::RichPresence, vmAssetList.AssetTypeFilters().GetItemAt(3)->GetId());
+        Assert::AreEqual(std::wstring(L"Rich Presence"), vmAssetList.AssetTypeFilters().GetItemAt(3)->GetLabel());
         Assert::AreEqual(AssetType::Achievement, vmAssetList.GetAssetTypeFilter());
 
         Assert::AreEqual({ 5U }, vmAssetList.Changes().Count());
@@ -1165,8 +1191,8 @@ public:
         vmAssetList.ForceUpdateButtons();
 
         vmAssetList.AssertButtonState(
-            ActivateButtonState::Disabled, SaveButtonState::SaveAllDisabled,
-            ResetButtonState::ResetAllDisabled, RevertButtonState::Disabled,
+            ActivateButtonState::ActivateAllDisabled, SaveButtonState::SaveAllDisabled,
+            ResetButtonState::ResetAllDisabled, RevertButtonState::RevertAllDisabled,
             CreateButtonState::Disabled, CloneButtonState::Disabled
         );
 
@@ -1176,8 +1202,8 @@ public:
         vmAssetList.ForceUpdateButtons();
 
         vmAssetList.AssertButtonState(
-            ActivateButtonState::Disabled, SaveButtonState::SaveAllDisabled,
-            ResetButtonState::ResetAllDisabled, RevertButtonState::Disabled,
+            ActivateButtonState::ActivateAllDisabled, SaveButtonState::SaveAllDisabled,
+            ResetButtonState::ResetAllDisabled, RevertButtonState::RevertAllDisabled,
             CreateButtonState::Disabled, CloneButtonState::Disabled
         );
     }
@@ -1192,7 +1218,7 @@ public:
         vmAssetList.ForceUpdateButtons();
 
         vmAssetList.AssertButtonState(
-            ActivateButtonState::Disabled, SaveButtonState::SaveAllDisabled,
+            ActivateButtonState::ActivateAllDisabled, SaveButtonState::SaveAllDisabled,
             ResetButtonState::ResetAll, RevertButtonState::RevertAll,
             CreateButtonState::Enabled, CloneButtonState::Disabled
         );
@@ -1379,7 +1405,7 @@ public:
         vmAssetList.ForceUpdateButtons();
 
         vmAssetList.AssertButtonState(
-            ActivateButtonState::Disabled, SaveButtonState::SaveAllDisabled,
+            ActivateButtonState::ActivateAllDisabled, SaveButtonState::SaveAllDisabled,
             ResetButtonState::ResetAll, RevertButtonState::RevertAll,
             CreateButtonState::Enabled, CloneButtonState::Disabled
         );
@@ -4845,7 +4871,7 @@ public:
         vmAssetList.mockEmulatorContext.MockDisableHardcoreWarning(ra::ui::DialogResult::Yes);
         vmAssetList.RevertSelected();
 
-        Assert::AreEqual(std::string("revert core achievements"), vmAssetList.mockEmulatorContext.GetDisableHardcoreWarningMessage());
+        Assert::AreEqual(std::string("revert core assets"), vmAssetList.mockEmulatorContext.GetDisableHardcoreWarningMessage());
         Assert::AreEqual(AssetChanges::None, pAsset->GetChanges());
     }
 
@@ -4868,7 +4894,7 @@ public:
         vmAssetList.mockEmulatorContext.MockDisableHardcoreWarning(ra::ui::DialogResult::No);
         vmAssetList.RevertSelected();
 
-        Assert::AreEqual(std::string("revert core achievements"), vmAssetList.mockEmulatorContext.GetDisableHardcoreWarningMessage());
+        Assert::AreEqual(std::string("revert core assets"), vmAssetList.mockEmulatorContext.GetDisableHardcoreWarningMessage());
         Assert::AreEqual(AssetChanges::Modified, pAsset->GetChanges());
     }
 
@@ -4993,6 +5019,94 @@ public:
         Assert::AreEqual(std::string("edit assets"), vmAssetList.mockEmulatorContext.GetDisableHardcoreWarningMessage());
         Assert::IsTrue(vmAssetList.mockConfiguration.IsFeatureEnabled(ra::services::Feature::Hardcore));
     }
+
+    TEST_METHOD(TestOpenEditorRichPresence)
+    {
+        AssetListViewModelHarness vmAssetList;
+        ra::services::mocks::MockFileSystem mockFileSystem;
+        ra::services::impl::FileLocalStorage storage(mockFileSystem);
+        ra::services::ServiceLocator::ServiceOverride<ra::services::ILocalStorage> storageOverride(&storage);
+
+        vmAssetList.mockConfiguration.SetFeatureEnabled(ra::services::Feature::Hardcore, true);
+        vmAssetList.mockEmulatorContext.MockDisableHardcoreWarning(ra::ui::DialogResult::Yes);
+        vmAssetList.SetGameId(22U);
+        vmAssetList.AddRichPresence("Display\nTest\n");
+        vmAssetList.SetAssetTypeFilter(AssetType::RichPresence);
+
+        Assert::AreEqual({ 1U }, vmAssetList.mockGameContext.Assets().Count());
+        Assert::AreEqual({ 1U }, vmAssetList.FilteredAssets().Count());
+
+        auto* pItem = vmAssetList.FilteredAssets().GetItemAt(0);
+        Expects(pItem != nullptr);
+        pItem->SetSelected(true);
+        vmAssetList.ForceUpdateButtons();
+
+        Assert::IsFalse(vmAssetList.mockWindowManager.AssetEditor.IsVisible());
+        vmAssetList.OpenEditor(pItem);
+        Assert::IsFalse(vmAssetList.mockWindowManager.AssetEditor.IsVisible());
+
+        Assert::AreEqual(std::string("file://localhost/./RACache/Data/0-Rich.txt"), vmAssetList.mockDesktop.LastOpenedUrl());
+    }
+
+    TEST_METHOD(TestUpdateButtonsRichPresenceSelection)
+    {
+        AssetListViewModelHarness vmAssetList;
+        vmAssetList.SetGameId(1U);
+        vmAssetList.SetFilterCategory(AssetListViewModel::FilterCategory::Core);
+        vmAssetList.SetAssetTypeFilter(AssetType::RichPresence);
+        vmAssetList.AddRichPresence("Display:\nTest\n");
+
+        Assert::AreEqual({ 1U }, vmAssetList.FilteredAssets().Count());
+        vmAssetList.FilteredAssets().GetItemAt(0)->SetSelected(true);
+        vmAssetList.ForceUpdateButtons();
+
+        vmAssetList.AssertButtonState(
+            ActivateButtonState::ActivateDisabled, SaveButtonState::SaveDisabled,
+            ResetButtonState::ResetDisabled, RevertButtonState::Revert,
+            CreateButtonState::Enabled, CloneButtonState::Disabled
+        );
+    }
+
+    TEST_METHOD(TestUpdateButtonsRichPresenceAndOtherSelection)
+    {
+        AssetListViewModelHarness vmAssetList;
+        vmAssetList.SetGameId(1U);
+        vmAssetList.SetFilterCategory(AssetListViewModel::FilterCategory::Core);
+        vmAssetList.SetAssetTypeFilter(AssetType::None);
+        vmAssetList.AddRichPresence("Display:\nTest\n");
+        vmAssetList.AddThreeAchievements();
+
+        Assert::AreEqual({ 3U }, vmAssetList.FilteredAssets().Count());
+        vmAssetList.FilteredAssets().GetItemAt(0)->SetSelected(true);
+        vmAssetList.FilteredAssets().GetItemAt(1)->SetSelected(true);
+        vmAssetList.ForceUpdateButtons();
+
+        vmAssetList.AssertButtonState(
+            ActivateButtonState::ActivateDisabled, SaveButtonState::SaveDisabled,
+            ResetButtonState::ResetDisabled, RevertButtonState::RevertDisabled,
+            CreateButtonState::Enabled, CloneButtonState::Disabled
+        );
+    }
+    
+    TEST_METHOD(TestUpdateButtonsRichPresenceLocalSelection)
+    {
+        AssetListViewModelHarness vmAssetList;
+        vmAssetList.SetGameId(1U);
+        vmAssetList.SetFilterCategory(AssetListViewModel::FilterCategory::Local);
+        vmAssetList.SetAssetTypeFilter(AssetType::RichPresence);
+        vmAssetList.AddRichPresence("Display:\nTest\n");
+        vmAssetList.mockGameContext.Assets().FindRichPresence()->SetCategory(AssetCategory::Local);
+
+        Assert::AreEqual({ 1U }, vmAssetList.FilteredAssets().Count());
+        vmAssetList.FilteredAssets().GetItemAt(0)->SetSelected(true);
+        vmAssetList.ForceUpdateButtons();
+
+        vmAssetList.AssertButtonState(
+            ActivateButtonState::ActivateDisabled, SaveButtonState::SaveDisabled,
+            ResetButtonState::ResetDisabled, RevertButtonState::RevertDisabled,
+            CreateButtonState::Enabled, CloneButtonState::Disabled
+        );
+    }
 };
 
 } // namespace tests