Repository: RetroAchievements/RAIntegration
Pull request #621 (2020-10-26 15:50:18)

additional logging around toggling hardcore mode


Patch:
diff --git a/src/Exports.cpp b/src/Exports.cpp
index 3a0d170d..320b6734 100644
--- a/src/Exports.cpp
+++ b/src/Exports.cpp
@@ -111,6 +111,7 @@ static BOOL InitCommon([[maybe_unused]] HWND hMainHWND, [[maybe_unused]] int nEm
                 return FALSE;
             }
 
+            RA_LOG_INFO("Hardcore disabled by external tool");
             ra::services::ServiceLocator::GetMutable<ra::data::EmulatorContext>().DisableHardcoreMode();
         }
     }
@@ -477,6 +478,12 @@ static void UpdateUIForFrameChange()
 
     TALLY_PERFORMANCE(PerformanceCheckpoint::MemoryInspectorDoFrame);
     pWindowManager.MemoryInspector.DoFrame();
+
+    TALLY_PERFORMANCE(PerformanceCheckpoint::AssetListDoFrame);
+    pWindowManager.AssetList.DoFrame();
+
+    TALLY_PERFORMANCE(PerformanceCheckpoint::AssetEditorDoFrame);
+    pWindowManager.AssetEditor.DoFrame();
 }
 
 #endif
@@ -513,6 +520,8 @@ static bool CanRestoreState()
     {
         // save state is being allowed by app (user should have been warned!)
         ra::ui::viewmodels::MessageBoxViewModel::ShowWarningMessage(L"Disabling Hardcore mode.", L"Loading save states is not allowed in Hardcore mode.");
+
+        RA_LOG_INFO("Hardcore disabled by loading state");
         ra::services::ServiceLocator::GetMutable<ra::data::EmulatorContext>().DisableHardcoreMode();
     }
 
diff --git a/src/RA_Core.cpp b/src/RA_Core.cpp
index 63c25f85..b0ae685d 100644
--- a/src/RA_Core.cpp
+++ b/src/RA_Core.cpp
@@ -254,6 +254,8 @@ API bool CCONV _RA_WarnDisableHardcore(const char* sActivity)
             return false;
     }
 
+    RA_LOG_INFO("User chose to disable hardcore to %s", sActivity);
+
     // user consented, switch to non-hardcore mode
     ra::services::ServiceLocator::GetMutable<ra::data::EmulatorContext>().DisableHardcoreMode();
 
@@ -466,6 +468,7 @@ API void CCONV _RA_InvokeDialog(LPARAM nID)
             const auto& pConfiguration = ra::services::ServiceLocator::Get<ra::services::IConfiguration>();
             if (pConfiguration.IsFeatureEnabled(ra::services::Feature::Hardcore))
             {
+                RA_LOG_INFO("Hardcore disabled by menu option");
                 pEmulatorContext.DisableHardcoreMode();
             }
             else
diff --git a/src/data/EmulatorContext.cpp b/src/data/EmulatorContext.cpp
index 392ad16f..e61c02fc 100644
--- a/src/data/EmulatorContext.cpp
+++ b/src/data/EmulatorContext.cpp
@@ -203,6 +203,8 @@ bool EmulatorContext::ValidateClientVersion()
 /// </summary>
 bool EmulatorContext::ValidateClientVersion(bool& bHardcore)
 {
+    std::string sMinimumVersion;
+
     // client not specified - assume pre-release of new client
     if (m_nEmulatorId == EmulatorID::UnknownEmulator)
         return true;
@@ -232,6 +234,7 @@ bool EmulatorContext::ValidateClientVersion(bool& bHardcore)
         else
         {
             m_sLatestVersion = response.LatestVersion;
+            sMinimumVersion = response.MinimumVersion;
             nMinimumVersion = ParseVersion(response.MinimumVersion.c_str());
 
             const unsigned long long nServerVersion = ParseVersion(m_sLatestVersion.c_str());
@@ -323,6 +326,7 @@ bool EmulatorContext::ValidateClientVersion(bool& bHardcore)
 
         if (vmMessageBox.ShowModal() == ra::ui::DialogResult::Cancel)
         {
+            RA_LOG_INFO("Hardcore disabled by version check %s < %s", m_sVersion, sMinimumVersion);
             bHardcore = false;
         }
         else
@@ -409,6 +413,8 @@ bool EmulatorContext::EnableHardcoreMode(bool bShowWarning)
             return false;
     }
 
+    RA_LOG_INFO("Hardcore enabled");
+
     // User has agreed to reset the emulator, or no game is loaded. Enabled hardcore!
     pConfiguration.SetFeatureEnabled(ra::services::Feature::Hardcore, true);
 
diff --git a/src/data/GameContext.cpp b/src/data/GameContext.cpp
index 239581a5..4847e3b7 100644
--- a/src/data/GameContext.cpp
+++ b/src/data/GameContext.cpp
@@ -201,6 +201,7 @@ void GameContext::LoadGame(unsigned int nGameId, Mode nMode)
 
 #ifndef RA_UTEST
     g_AchievementsDialog.UpdateAchievementList();
+    vmAssets.DoFrame();
 #endif
 
     // show "game loaded" popup
diff --git a/src/services/PerformanceCounter.hh b/src/services/PerformanceCounter.hh
index a819aa12..cb5668c1 100644
--- a/src/services/PerformanceCounter.hh
+++ b/src/services/PerformanceCounter.hh
@@ -13,6 +13,8 @@ enum class PerformanceCheckpoint
     OverlayManagerAdvanceFrame,
     MemoryBookmarksDoFrame,
     MemoryInspectorDoFrame,
+    AssetListDoFrame,
+    AssetEditorDoFrame,
 
     NUM_CHECKPOINTS
 };
diff --git a/src/services/impl/ThreadPool.cpp b/src/services/impl/ThreadPool.cpp
index c83a20e8..7923b931 100644
--- a/src/services/impl/ThreadPool.cpp
+++ b/src/services/impl/ThreadPool.cpp
@@ -45,7 +45,8 @@ void ThreadPool::RunThread()
             // do work
             try
             {
-                pNext();
+                if (!m_bShutdownInitiated)
+                    pNext();
             }
             catch (const std::exception& ex)
             {
diff --git a/src/ui/viewmodels/AchievementViewModel.cpp b/src/ui/viewmodels/AchievementViewModel.cpp
index a7173c1d..95d84f5e 100644
--- a/src/ui/viewmodels/AchievementViewModel.cpp
+++ b/src/ui/viewmodels/AchievementViewModel.cpp
@@ -1,5 +1,9 @@
 #include "AchievementViewModel.hh"
 
+#include "services\AchievementRuntime.hh"
+#include "services\IClock.hh"
+#include "services\ServiceLocator.hh"
+
 namespace ra {
 namespace ui {
 namespace viewmodels {
@@ -22,22 +26,73 @@ AchievementViewModel::AchievementViewModel() noexcept
 
 void AchievementViewModel::Activate()
 {
-    if (IsActive())
-        return;
-
-    SetState(AssetState::Waiting);
-    // TODO: activate in runtime
+    if (!IsActive())
+        SetState(AssetState::Waiting);
 }
 
 void AchievementViewModel::Deactivate()
 {
-    const bool bWasActive = IsActive();
-
     SetState(AssetState::Inactive);
+}
+
+void AchievementViewModel::OnValueChanged(const IntModelProperty::ChangeArgs& args)
+{
+    if (args.Property == StateProperty)
+    {
+        const bool bWasActive = IsActive(ra::itoe<AssetState>(args.tOldValue));
+        const bool bIsActive = IsActive(ra::itoe<AssetState>(args.tNewValue));
+        if (bWasActive != bIsActive)
+        {
+            auto& pRuntime = ra::services::ServiceLocator::GetMutable<ra::services::AchievementRuntime>();
+            const bool bRuntimeActive = (pRuntime.GetAchievementTrigger(GetID()) != nullptr);
+            if (bRuntimeActive != bIsActive)
+            {
+                if (bIsActive)
+                    pRuntime.ActivateAchievement(GetID(), GetTrigger());
+                else
+                    pRuntime.DeactivateAchievement(GetID());
+            }
+        }
+
+        if (ra::itoe<AssetState>(args.tNewValue) == AssetState::Triggered)
+        {
+            const auto& pClock = ra::services::ServiceLocator::Get<ra::services::IClock>();
+            m_tUnlock = pClock.Now();
+        }
+    }
+
+    AssetViewModelBase::OnValueChanged(args);
+}
 
-    if (bWasActive)
+void AchievementViewModel::DoFrame()
+{
+    const auto& pRuntime = ra::services::ServiceLocator::Get<ra::services::AchievementRuntime>();
+    const auto* pTrigger = pRuntime.GetAchievementTrigger(GetID());
+    if (pTrigger == nullptr)
+    {
+        if (GetState() != AssetState::Triggered)
+            SetState(AssetState::Inactive);
+    }
+    else
     {
-        // TODO: deactivate in runtime
+        switch (pTrigger->state)
+        {
+            case RC_TRIGGER_STATE_ACTIVE:
+                SetState(AssetState::Active);
+                break;
+            case RC_TRIGGER_STATE_INACTIVE:
+                SetState(AssetState::Inactive);
+                break;
+            case RC_TRIGGER_STATE_PAUSED:
+                SetState(AssetState::Paused);
+                break;
+            case RC_TRIGGER_STATE_TRIGGERED:
+                SetState(AssetState::Triggered);
+                break;
+            case RC_TRIGGER_STATE_WAITING:
+                SetState(AssetState::Waiting);
+                break;
+        }
     }
 }
 
diff --git a/src/ui/viewmodels/AchievementViewModel.hh b/src/ui/viewmodels/AchievementViewModel.hh
index 3ffdfb1d..0a2c1d5a 100644
--- a/src/ui/viewmodels/AchievementViewModel.hh
+++ b/src/ui/viewmodels/AchievementViewModel.hh
@@ -88,14 +88,25 @@ public:
     /// </summary>
     void SetTrigger(const std::string& sTrigger) { SetAssetDefinition(m_pTrigger, sTrigger); }
 
+    /// <summary>
+    /// Gets when the achievement was unlocked.
+    /// </summary>
+    std::chrono::system_clock::time_point GetUnlockTime() const noexcept { return m_tUnlock; }
+
     void Activate() override;
     void Deactivate() override;
 
+    void DoFrame() override;
+
     void Serialize(ra::services::TextWriter& pWriter) const override;
     bool Deserialize(ra::Tokenizer& pTokenizer) override;
 
+protected:
+    void OnValueChanged(const IntModelProperty::ChangeArgs& args) override;
+
 private:
     AssetDefinition m_pTrigger;
+    std::chrono::system_clock::time_point m_tUnlock;
 };
 
 } // namespace viewmodels
diff --git a/src/ui/viewmodels/AssetEditorViewModel.cpp b/src/ui/viewmodels/AssetEditorViewModel.cpp
index 882d94e5..f40e4c2e 100644
--- a/src/ui/viewmodels/AssetEditorViewModel.cpp
+++ b/src/ui/viewmodels/AssetEditorViewModel.cpp
@@ -49,7 +49,9 @@ void AssetEditorViewModel::LoadAsset(AssetViewModelBase* pAsset)
     if (m_pAsset)
         m_pAsset->RemoveNotifyTarget(*this);
 
-    m_pAsset = pAsset;
+    // null out internal pointer while binding to prevent change triggers
+    m_pAsset = nullptr;
+
     if (pAsset)
     {
         pAsset->AddNotifyTarget(*this);
@@ -73,9 +75,18 @@ void AssetEditorViewModel::LoadAsset(AssetViewModelBase* pAsset)
             SetPauseOnTrigger(pAchievement->IsPauseOnTrigger());
             SetWindowTitle(L"Achievement Editor");
 
-            Trigger().InitializeFrom(pAchievement->GetTrigger());
+            const auto* pTrigger = ra::services::ServiceLocator::Get<ra::services::AchievementRuntime>().GetAchievementTrigger(pAsset->GetID());
+            if (pTrigger != nullptr)
+                Trigger().InitializeFrom(*pTrigger);
+            else
+                Trigger().InitializeFrom(pAchievement->GetTrigger());
+        }
+        else
+        {
+            Trigger().InitializeFrom("");
         }
 
+        m_pAsset = pAsset;
         SetValue(AssetLoadedProperty, true);
     }
     else
@@ -92,6 +103,8 @@ void AssetEditorViewModel::LoadAsset(AssetViewModelBase* pAsset)
         SetPauseOnReset(PauseOnResetProperty.GetDefaultValue());
         SetPauseOnTrigger(PauseOnTriggerProperty.GetDefaultValue());
         SetWindowTitle(L"Asset Editor");
+
+        Trigger().InitializeFrom("");
     }
 }
 
@@ -182,6 +195,7 @@ void AssetEditorViewModel::OnValueChanged(const IntModelProperty::ChangeArgs& ar
         if (args.Property == StateProperty)
         {
             m_pAsset->SetState(ra::itoe<AssetState>(args.tNewValue));
+            UpdateTriggerBinding();
         }
         else if (args.Property == CategoryProperty)
         {
@@ -201,9 +215,42 @@ void AssetEditorViewModel::OnValueChanged(const IntModelProperty::ChangeArgs& ar
     WindowViewModelBase::OnValueChanged(args);
 }
 
-void AssetEditorViewModel::OnTriggerChanged() noexcept
+void AssetEditorViewModel::OnTriggerChanged()
+{
+    auto* pAchievement = dynamic_cast<AchievementViewModel*>(m_pAsset);
+    if (pAchievement != nullptr)
+    {
+        const std::string& sTrigger = Trigger().Serialize();
+        pAchievement->SetTrigger(sTrigger);
+
+        if (m_pAsset->IsActive())
+        {
+            auto& pRuntime = ra::services::ServiceLocator::GetMutable<ra::services::AchievementRuntime>();
+            pRuntime.ActivateAchievement(GetID(), sTrigger);
+            UpdateTriggerBinding();
+        }
+    }
+}
+
+void AssetEditorViewModel::UpdateTriggerBinding()
+{
+    const auto* pAchievement = dynamic_cast<AchievementViewModel*>(m_pAsset);
+    if (pAchievement != nullptr)
+    {
+        const auto* pTrigger = ra::services::ServiceLocator::Get<ra::services::AchievementRuntime>().GetAchievementTrigger(pAchievement->GetID());
+        if (pTrigger != nullptr)
+            Trigger().UpdateFrom(*pTrigger);
+        else
+            Trigger().UpdateFrom(pAchievement->GetTrigger());
+    }
+}
+
+void AssetEditorViewModel::DoFrame()
 {
-    // TODO: update asset / runtime
+    if (m_pAsset == nullptr || !m_pAsset->IsActive())
+        return;
+
+    m_vmTrigger.DoFrame();
 }
 
 } // namespace viewmodels
diff --git a/src/ui/viewmodels/AssetEditorViewModel.hh b/src/ui/viewmodels/AssetEditorViewModel.hh
index 2863e1d0..94e00eaf 100644
--- a/src/ui/viewmodels/AssetEditorViewModel.hh
+++ b/src/ui/viewmodels/AssetEditorViewModel.hh
@@ -180,6 +180,8 @@ public:
 
     void LoadAsset(AssetViewModelBase* pAsset);
 
+    void DoFrame();
+
     TriggerViewModel& Trigger() noexcept { return m_vmTrigger; }
     const TriggerViewModel& Trigger() const noexcept { return m_vmTrigger; }
 
@@ -193,7 +195,8 @@ protected:
     void OnValueChanged(const StringModelProperty::ChangeArgs& args) override;
     void OnValueChanged(const IntModelProperty::ChangeArgs& args) override;
 
-    void OnTriggerChanged() noexcept;
+    void UpdateTriggerBinding();
+    void OnTriggerChanged();
 
     TriggerViewModel m_vmTrigger;
 
diff --git a/src/ui/viewmodels/AssetListViewModel.cpp b/src/ui/viewmodels/AssetListViewModel.cpp
index 95a5f9b7..854d03b5 100644
--- a/src/ui/viewmodels/AssetListViewModel.cpp
+++ b/src/ui/viewmodels/AssetListViewModel.cpp
@@ -665,6 +665,16 @@ void AssetListViewModel::CloneSelected()
         return;
 }
 
+void AssetListViewModel::DoFrame()
+{
+    for (gsl::index nIndex = 0; nIndex < gsl::narrow_cast<gsl::index>(m_vAssets.Count()); ++nIndex)
+    {
+        auto* pItem = m_vAssets.GetItemAt(nIndex);
+        if (pItem != nullptr)
+            pItem->DoFrame();
+    }
+}
+
 } // namespace viewmodels
 } // namespace ui
 } // namespace ra
diff --git a/src/ui/viewmodels/AssetListViewModel.hh b/src/ui/viewmodels/AssetListViewModel.hh
index f3049ba6..b7ae8419 100644
--- a/src/ui/viewmodels/AssetListViewModel.hh
+++ b/src/ui/viewmodels/AssetListViewModel.hh
@@ -136,6 +136,7 @@ public:
     bool CanClone() const { return GetValue(CanCloneProperty); }
     void CloneSelected();
 
+    void DoFrame();
 
     ViewModelCollection<AssetViewModelBase>& Assets() noexcept { return m_vAssets; }
     const ViewModelCollection<AssetViewModelBase>& Assets() const noexcept { return m_vAssets; }
diff --git a/src/ui/viewmodels/AssetViewModelBase.cpp b/src/ui/viewmodels/AssetViewModelBase.cpp
index d4ebeaeb..c59e8e97 100644
--- a/src/ui/viewmodels/AssetViewModelBase.cpp
+++ b/src/ui/viewmodels/AssetViewModelBase.cpp
@@ -224,6 +224,11 @@ bool AssetViewModelBase::HasUnpublishedChanges() const noexcept
     return (m_pTransaction && m_pTransaction->m_pNext && m_pTransaction->m_pNext->IsModified());
 }
 
+void AssetViewModelBase::OnValueChanged(const IntModelProperty::ChangeArgs& args)
+{
+    TransactionalViewModelBase::OnValueChanged(args);
+}
+
 void AssetViewModelBase::OnValueChanged(const BoolModelProperty::ChangeArgs& args)
 {
     if (args.Property == IsModifiedProperty)
@@ -240,6 +245,8 @@ void AssetViewModelBase::OnValueChanged(const BoolModelProperty::ChangeArgs& arg
                 SetValue(ChangesProperty, ra::etoi(AssetChanges::None));
         }
     }
+
+    TransactionalViewModelBase::OnValueChanged(args);
 }
 
 bool AssetViewModelBase::GetLocalValue(const BoolModelProperty& pProperty) const
@@ -442,9 +449,9 @@ void AssetViewModelBase::RevertTransaction()
     }
 }
 
-bool AssetViewModelBase::IsActive() const
+bool AssetViewModelBase::IsActive(AssetState nState) noexcept
 {
-    switch (GetState())
+    switch (nState)
     {
         case AssetState::Inactive:
         case AssetState::Triggered:
diff --git a/src/ui/viewmodels/AssetViewModelBase.hh b/src/ui/viewmodels/AssetViewModelBase.hh
index 7cde16e6..ff9e9e88 100644
--- a/src/ui/viewmodels/AssetViewModelBase.hh
+++ b/src/ui/viewmodels/AssetViewModelBase.hh
@@ -136,7 +136,7 @@ public:
     /// <summary>
     /// Gets whether or not the asset is in an active state.
     /// </summary>
-    bool IsActive() const;
+    bool IsActive() const { return IsActive(GetState()); }
 
     /// <summary>
     /// Activates the asset.
@@ -148,6 +148,11 @@ public:
     /// </summary>
     virtual void Deactivate() noexcept(false) { }
 
+    /// <summary>
+    /// Updates the asset for the current frame.
+    /// </summary>
+    virtual void DoFrame() noexcept(false) { }
+
     /// <summary>
     /// The <see cref="ModelProperty" /> for the asset state.
     /// </summary>
@@ -239,6 +244,7 @@ protected:
     static bool ReadPossiblyQuoted(ra::Tokenizer& pTokenizer, std::string& sText);
     static bool ReadPossiblyQuoted(ra::Tokenizer& pTokenizer, std::wstring& sText);
 
+    void OnValueChanged(const IntModelProperty::ChangeArgs& args) override;
     void OnValueChanged(const BoolModelProperty::ChangeArgs& args) override;
 
     // Hide the public members of TransactionalViewModelBase that we're redefining
@@ -248,6 +254,8 @@ protected:
 
     void CommitTransaction() override;
     void RevertTransaction() override;
+
+    static bool IsActive(AssetState nState) noexcept;
 };
 
 } // namespace viewmodels
diff --git a/src/ui/viewmodels/MemoryInspectorViewModel.cpp b/src/ui/viewmodels/MemoryInspectorViewModel.cpp
index 8878932d..5e8472d3 100644
--- a/src/ui/viewmodels/MemoryInspectorViewModel.cpp
+++ b/src/ui/viewmodels/MemoryInspectorViewModel.cpp
@@ -21,6 +21,7 @@ const StringModelProperty MemoryInspectorViewModel::CurrentAddressNoteProperty("
 const StringModelProperty MemoryInspectorViewModel::CurrentAddressBitsProperty("MemoryInspectorViewModel", "CurrentAddressBits", L"0 0 0 0 0 0 0 0");
 const IntModelProperty MemoryInspectorViewModel::CurrentAddressValueProperty("MemoryInspectorViewModel", "CurrentAddressValue", 0);
 const BoolModelProperty MemoryInspectorViewModel::CanModifyNotesProperty("MemoryInspectorViewModel", "CanModifyNotes", true);
+const BoolModelProperty MemoryInspectorViewModel::CurrentBitsVisibleProperty("MemoryInspectorViewModel", "CurrentBitsVisibleProperty", true);
 
 MemoryInspectorViewModel::MemoryInspectorViewModel()
 {
@@ -83,6 +84,8 @@ void MemoryInspectorViewModel::OnViewModelIntValueChanged(const IntModelProperty
 {
     if (args.Property == MemoryViewerViewModel::AddressProperty)
         SetValue(CurrentAddressProperty, args.tNewValue);
+    else if (args.Property == MemoryViewerViewModel::SizeProperty)
+        SetValue(CurrentBitsVisibleProperty, ra::itoe<MemSize>(args.tNewValue) == MemSize::EightBit);
 }
 
 void MemoryInspectorViewModel::OnValueChanged(const StringModelProperty::ChangeArgs& args)
diff --git a/src/ui/viewmodels/MemoryInspectorViewModel.hh b/src/ui/viewmodels/MemoryInspectorViewModel.hh
index 186b332a..e7f832ab 100644
--- a/src/ui/viewmodels/MemoryInspectorViewModel.hh
+++ b/src/ui/viewmodels/MemoryInspectorViewModel.hh
@@ -108,6 +108,11 @@ public:
     /// </summary>
     const std::wstring& GetCurrentAddressBits() const { return GetValue(CurrentAddressBitsProperty); }
 
+    /// <summary>
+    /// The <see cref="ModelProperty" /> for whether or not the CurrentBits should be visible.
+    /// </summary>
+    static const BoolModelProperty CurrentBitsVisibleProperty;
+
     void ToggleBit(int nBit);
 
 protected:
diff --git a/src/ui/viewmodels/OverlayManager.cpp b/src/ui/viewmodels/OverlayManager.cpp
index c2d7991e..312289d7 100644
--- a/src/ui/viewmodels/OverlayManager.cpp
+++ b/src/ui/viewmodels/OverlayManager.cpp
@@ -92,13 +92,19 @@ void OverlayManager::RequestRender()
     if (!m_fHandleRenderRequest)
         return;
 
+    auto& pClock = ra::services::ServiceLocator::Get<ra::services::IClock>();
+    const auto tNow = pClock.UpTime();
+    const auto tTimeSinceLastRequest = tNow - m_tLastRequestRender;
+    m_tLastRequestRender = tNow;
+
     const bool bNeedsRender = NeedsRender();
     if (m_bIsRendering != bNeedsRender)
     {
         if (bNeedsRender)
         {
-            auto& pClock = ra::services::ServiceLocator::Get<ra::services::IClock>();
-            m_tLastRender = pClock.UpTime();
+            // when the overlay first becomes visible, capture the current time for
+            // elapsed calculations in Render
+            m_tLastRender = tNow;
 
             if (m_fHandleShowRequest)
                 m_fHandleShowRequest();
@@ -118,12 +124,12 @@ void OverlayManager::RequestRender()
     {
         m_bRenderRequestPending = true;
 
-        auto& pClock = ra::services::ServiceLocator::Get<ra::services::IClock>();
-        const auto tNow = pClock.UpTime();
+#ifndef RA_UTEST
+        // make sure not to call RenderRequest more than once every 10ms
         const auto tPacing = std::chrono::milliseconds(10);
-        if (tNow - m_tLastRender < tPacing)
+        if (tTimeSinceLastRequest < tPacing)
         {
-            const auto nDelay = tPacing - (tNow - m_tLastRender);
+            const auto nDelay = tPacing - tTimeSinceLastRequest;
             ra::services::ServiceLocator::GetMutable<ra::services::IThreadPool>().ScheduleAsync(
                 std::chrono::duration_cast<std::chrono::milliseconds>(nDelay), [this]()
             {
@@ -131,6 +137,7 @@ void OverlayManager::RequestRender()
             });
         }
         else
+#endif
         {
             m_fHandleRenderRequest();
         }
@@ -194,6 +201,10 @@ void OverlayManager::Render(ra::ui::drawing::ISurface& pSurface, bool bRedrawAll
             bRequestRender = true;
     }
 
+    // update state now, in case handlers cause recursion
+    m_tLastRender = tNow;
+    m_bRedrawAll = false;
+
     if (bRequestRender)
     {
         // something changed, render is explicit
@@ -210,9 +221,6 @@ void OverlayManager::Render(ra::ui::drawing::ISurface& pSurface, bool bRedrawAll
                 m_fHandleHideRequest();
         }
     }
-
-    m_tLastRender = tNow;
-    m_bRedrawAll = false;
 }
 
 ScoreTrackerViewModel& OverlayManager::AddScoreTracker(ra::LeaderboardID nLeaderboardId)
diff --git a/src/ui/viewmodels/OverlayManager.hh b/src/ui/viewmodels/OverlayManager.hh
index d86b64d6..5ca5938d 100644
--- a/src/ui/viewmodels/OverlayManager.hh
+++ b/src/ui/viewmodels/OverlayManager.hh
@@ -281,6 +281,7 @@ private:
 
     bool m_bRedrawAll = false;
     std::chrono::steady_clock::time_point m_tLastRender{};
+    std::chrono::steady_clock::time_point m_tLastRequestRender{};
     std::function<void()> m_fHandleRenderRequest;
     std::function<void()> m_fHandleShowRequest;
     std::function<void()> m_fHandleHideRequest;
diff --git a/src/ui/viewmodels/TriggerViewModel.cpp b/src/ui/viewmodels/TriggerViewModel.cpp
index fd8bf3f0..1bf1e43c 100644
--- a/src/ui/viewmodels/TriggerViewModel.cpp
+++ b/src/ui/viewmodels/TriggerViewModel.cpp
@@ -132,6 +132,15 @@ void TriggerViewModel::SerializeAppend(std::string& sBuffer) const
     }
 }
 
+static void AddAltGroup(ViewModelCollection<TriggerViewModel::GroupViewModel>& vGroups,
+    int nId, rc_condset_t* pConditionSet)
+{
+    auto& vmAltGroup = vGroups.Add();
+    vmAltGroup.SetId(++nId);
+    vmAltGroup.SetLabel(ra::StringPrintf(L"Alt %d", nId));
+    vmAltGroup.m_pConditionSet = pConditionSet;
+}
+
 void TriggerViewModel::InitializeFrom(const rc_trigger_t& pTrigger)
 {
     m_vGroups.RemoveNotifyTarget(*this);
@@ -152,17 +161,12 @@ void TriggerViewModel::InitializeFrom(const rc_trigger_t& pTrigger)
     int nId = 0;
     rc_condset_t* pConditionSet = pTrigger.alternative;
     for (; pConditionSet != nullptr; pConditionSet = pConditionSet->next)
-    {
-        auto& vmAltGroup = m_vGroups.Add();
-        vmAltGroup.SetId(++nId);
-        vmAltGroup.SetLabel(ra::StringPrintf(L"Alt %d", nId));
-        vmAltGroup.m_pConditionSet = pConditionSet;
-    }
+        AddAltGroup(m_vGroups, ++nId, pConditionSet);
 
     m_vGroups.EndUpdate();
     m_vGroups.AddNotifyTarget(*this);
 
-    // forcibly update the conditions from the core group that was selected earlier
+    // forcibly update the conditions from the group that was selected earlier
     UpdateConditions(&vmCoreGroup);
 }
 
@@ -183,6 +187,59 @@ void TriggerViewModel::InitializeFrom(const std::string& sTrigger)
     }
 }
 
+void TriggerViewModel::UpdateFrom(const rc_trigger_t& pTrigger)
+{
+    const int nSelectedIndex = GetSelectedGroupIndex();
+
+    m_vGroups.RemoveNotifyTarget(*this);
+    m_vGroups.BeginUpdate();
+
+    // update core group
+    auto* vmCoreGroup = m_vGroups.GetItemAt(0);
+    Expects(vmCoreGroup != nullptr);
+    vmCoreGroup->m_pConditionSet = pTrigger.requirement;
+
+    // update alt groups (add any new alt groups)
+    gsl::index nIndex = 0;
+    rc_condset_t* pConditionSet = pTrigger.alternative;
+    for (; pConditionSet != nullptr; pConditionSet = pConditionSet->next)
+    {
+        auto* vmAltGroup = m_vGroups.GetItemAt(++nIndex);
+        if (vmAltGroup != nullptr)
+            vmAltGroup->m_pConditionSet = pConditionSet;
+        else
+            AddAltGroup(m_vGroups, gsl::narrow_cast<int>(nIndex), pConditionSet);
+    }
+
+    // remove any extra alt groups
+    ++nIndex;
+    while (m_vGroups.Count() > ra::to_unsigned(nIndex))
+        m_vGroups.RemoveAt(m_vGroups.Count() - 1);
+
+    m_vGroups.EndUpdate();
+    m_vGroups.AddNotifyTarget(*this);
+
+    // forcibly update the conditions from the selected group
+    UpdateConditions(m_vGroups.GetItemAt(nSelectedIndex));
+}
+
+void TriggerViewModel::UpdateFrom(const std::string& sTrigger)
+{
+    const auto nSize = rc_trigger_size(sTrigger.c_str());
+    if (nSize > 0)
+    {
+        m_sTriggerBuffer.resize(nSize);
+        m_pTrigger = rc_parse_trigger(m_sTriggerBuffer.data(), sTrigger.c_str(), nullptr, 0);
+        UpdateFrom(*m_pTrigger);
+    }
+    else
+    {
+        rc_trigger_t pTrigger;
+        memset(&pTrigger, 0, sizeof(pTrigger));
+        UpdateFrom(pTrigger);
+    }
+}
+
 void TriggerViewModel::OnViewModelBoolValueChanged(gsl::index, const BoolModelProperty::ChangeArgs& args)
 {
     if (args.Property == GroupViewModel::IsSelectedProperty)
@@ -200,9 +257,10 @@ void TriggerViewModel::OnViewModelBoolValueChanged(gsl::index, const BoolModelPr
     }
 }
 
-void TriggerViewModel::ConditionsMonitor::OnViewModelIntValueChanged(gsl::index, const IntModelProperty::ChangeArgs&)
+void TriggerViewModel::ConditionsMonitor::OnViewModelIntValueChanged(gsl::index, const IntModelProperty::ChangeArgs& args)
 {
-    UpdateCurrentGroup();
+    if (args.Property != TriggerConditionViewModel::CurrentHitsProperty)
+        UpdateCurrentGroup();
 }
 
 void TriggerViewModel::ConditionsMonitor::OnViewModelAdded(gsl::index)
@@ -282,6 +340,27 @@ void TriggerViewModel::UpdateConditions(const GroupViewModel* pGroup)
     m_vConditions.AddNotifyTarget(m_pConditionsMonitor);
 }
 
+void TriggerViewModel::DoFrame()
+{
+    auto* pGroup = m_vGroups.GetItemAt(GetSelectedGroupIndex());
+    if (pGroup != nullptr)
+    {
+        m_vConditions.BeginUpdate();
+
+        gsl::index nConditionIndex = 0;
+        rc_condition_t* pCondition = pGroup->m_pConditionSet->conditions;
+        for (; pCondition != nullptr; pCondition = pCondition->next)
+        {
+            auto* vmCondition = m_vConditions.GetItemAt(nConditionIndex++);
+            Expects(vmCondition != nullptr);
+
+            vmCondition->SetCurrentHits(pCondition->current_hits);
+        }
+
+        m_vConditions.EndUpdate();
+    }
+}
+
 } // namespace viewmodels
 } // namespace ui
 } // namespace ra
diff --git a/src/ui/viewmodels/TriggerViewModel.hh b/src/ui/viewmodels/TriggerViewModel.hh
index 1c2f4501..e83a33e3 100644
--- a/src/ui/viewmodels/TriggerViewModel.hh
+++ b/src/ui/viewmodels/TriggerViewModel.hh
@@ -66,6 +66,8 @@ public:
 
     void InitializeFrom(const rc_trigger_t& pTrigger);
     void InitializeFrom(const std::string& sTrigger);
+    void UpdateFrom(const rc_trigger_t& pTrigger);
+    void UpdateFrom(const std::string& sTrigger);
 
     /// <summary>
     /// Gets the list of condition types.
@@ -99,6 +101,7 @@ public:
         return m_vOperatorTypes;
     }
 
+    void DoFrame();
 
 protected:
     // ViewModelCollectionBase::NotifyTarget
diff --git a/src/ui/win32/AssetEditorDialog.cpp b/src/ui/win32/AssetEditorDialog.cpp
index 8a4515bd..c3e1c58e 100644
--- a/src/ui/win32/AssetEditorDialog.cpp
+++ b/src/ui/win32/AssetEditorDialog.cpp
@@ -179,6 +179,12 @@ public:
         const auto nCurrentHits = vmItems.GetItemValue(nIndex, TriggerConditionViewModel::CurrentHitsProperty);
         const auto nRequiredHits = vmItems.GetItemValue(nIndex, *m_pBoundProperty);
 
+        if (m_nEditingItemIndex == nIndex)
+        {
+            m_nEditingItemIndex = -1;
+            return std::to_wstring(nRequiredHits);
+        }
+
         return ra::StringPrintf(L"%d (%d)", nRequiredHits, nCurrentHits);
     }
 
@@ -189,6 +195,15 @@ public:
 
         return GridNumberColumnBinding::DependsOn(pProperty);
     }
+
+    HWND CreateInPlaceEditor(HWND hParent, InPlaceEditorInfo& pInfo) override
+    {
+        m_nEditingItemIndex = pInfo.nItemIndex;
+        return GridNumberColumnBinding::CreateInPlaceEditor(hParent, pInfo);
+    }
+
+private:
+    mutable gsl::index m_nEditingItemIndex = -1;
 };
 
 AssetEditorDialog::AssetEditorDialog(AssetEditorViewModel& vmAssetList)
@@ -212,6 +227,11 @@ AssetEditorDialog::AssetEditorDialog(AssetEditorViewModel& vmAssetList)
     m_bindBadge.BindText(AssetEditorViewModel::BadgeProperty);
     m_bindPoints.BindValue(AssetEditorViewModel::PointsProperty);
 
+    m_bindWindow.BindEnabled(IDC_RA_ACH_TITLE, AssetEditorViewModel::AssetLoadedProperty);
+    m_bindWindow.BindEnabled(IDC_RA_ACH_DESC, AssetEditorViewModel::AssetLoadedProperty);
+    m_bindWindow.BindEnabled(IDC_RA_CHK_ACH_PAUSE_ON_RESET, AssetEditorViewModel::AssetLoadedProperty);
+    m_bindWindow.BindEnabled(IDC_RA_CHK_ACH_PAUSE_ON_TRIGGER, AssetEditorViewModel::AssetLoadedProperty);
+
     m_bindPauseOnReset.BindCheck(AssetEditorViewModel::PauseOnResetProperty);
     m_bindPauseOnTrigger.BindCheck(AssetEditorViewModel::PauseOnTriggerProperty);
 
@@ -312,6 +332,9 @@ BOOL AssetEditorDialog::OnInitDialog()
     m_bindGroups.SetControl(*this, IDC_RA_ACH_GROUP);
     m_bindTrigger.SetControl(*this, IDC_RA_LBX_CONDITIONS);
 
+    m_bindPauseOnReset.SetControl(*this, IDC_RA_CHK_ACH_PAUSE_ON_RESET);
+    m_bindPauseOnTrigger.SetControl(*this, IDC_RA_CHK_ACH_PAUSE_ON_TRIGGER);
+
     return DialogBase::OnInitDialog();
 }
 
diff --git a/src/ui/win32/MemoryInspectorDialog.cpp b/src/ui/win32/MemoryInspectorDialog.cpp
index 7eb606f9..763f0874 100644
--- a/src/ui/win32/MemoryInspectorDialog.cpp
+++ b/src/ui/win32/MemoryInspectorDialog.cpp
@@ -225,6 +225,8 @@ MemoryInspectorDialog::MemoryInspectorDialog(MemoryInspectorViewModel& vmMemoryI
     m_bindViewer16Bit.BindCheck(MemoryViewerViewModel::SizeProperty, ra::etoi(MemSize::SixteenBit));
     m_bindViewer32Bit.BindCheck(MemoryViewerViewModel::SizeProperty, ra::etoi(MemSize::ThirtyTwoBit));
     m_bindWindow.BindLabel(IDC_RA_MEMBITS, MemoryInspectorViewModel::CurrentAddressBitsProperty);
+    m_bindWindow.BindVisible(IDC_RA_MEMBITS_TITLE, MemoryInspectorViewModel::CurrentBitsVisibleProperty);
+    m_bindWindow.BindVisible(IDC_RA_MEMBITS, MemoryInspectorViewModel::CurrentBitsVisibleProperty);
 
     // Resize behavior
     using namespace ra::bitwise_ops;
diff --git a/src/ui/win32/OverlayWindow.cpp b/src/ui/win32/OverlayWindow.cpp
index 8d07302d..41139ba5 100644
--- a/src/ui/win32/OverlayWindow.cpp
+++ b/src/ui/win32/OverlayWindow.cpp
@@ -7,6 +7,7 @@
 #include "ui\OverlayTheme.hh"
 #include "ui\drawing\gdi\GDISurface.hh"
 #include "ui\viewmodels\OverlayManager.hh"
+#include "ui\win32\bindings\ControlBinding.hh"
 
 namespace ra {
 namespace ui {
@@ -182,8 +183,8 @@ void OverlayWindow::CreateOverlayWindow()
     UpdateOverlayPosition();
 
     auto& pOverlayManager = ra::services::ServiceLocator::GetMutable<ra::ui::viewmodels::OverlayManager>();
-    pOverlayManager.SetRenderRequestHandler([this]() noexcept {
-        InvalidateRect(m_hOverlayWnd, nullptr, FALSE);
+    pOverlayManager.SetRenderRequestHandler([this]() {
+        ra::ui::win32::bindings::ControlBinding::ForceRepaint(m_hOverlayWnd);
     });
 
     pOverlayManager.SetShowRequestHandler([this]() noexcept {
diff --git a/src/ui/win32/bindings/ControlBinding.cpp b/src/ui/win32/bindings/ControlBinding.cpp
index 112e5b86..30bc6a9e 100644
--- a/src/ui/win32/bindings/ControlBinding.cpp
+++ b/src/ui/win32/bindings/ControlBinding.cpp
@@ -3,6 +3,9 @@
 #include "data\EmulatorContext.hh"
 
 #include "services\ServiceLocator.hh"
+#include "services\IClock.hh"
+
+#include "ui\win32\Desktop.hh"
 
 namespace ra {
 namespace ui {
diff --git a/src/ui/win32/bindings/GridBinding.cpp b/src/ui/win32/bindings/GridBinding.cpp
index 1cad6a39..368c5354 100644
--- a/src/ui/win32/bindings/GridBinding.cpp
+++ b/src/ui/win32/bindings/GridBinding.cpp
@@ -522,9 +522,15 @@ void GridBinding::OnEndViewModelCollectionUpdate()
         }
 
         if (m_bForceRepaint && m_nAdjustingScrollOffset == 0)
+        {
+            m_bForceRepaint = false;
+
             ControlBinding::ForceRepaint(m_hWnd);
+        }
         else
+        {
             Invalidate();
+        }
     }
 }
 
diff --git a/src/ui/win32/bindings/WindowBinding.cpp b/src/ui/win32/bindings/WindowBinding.cpp
index 28a2cca8..29568f23 100644
--- a/src/ui/win32/bindings/WindowBinding.cpp
+++ b/src/ui/win32/bindings/WindowBinding.cpp
@@ -61,6 +61,20 @@ void WindowBinding::SetHWND(HWND hWnd)
             }
         }
 
+        for (auto& pIter : m_mVisibilityBindings)
+        {
+            auto* pProperty = dynamic_cast<const BoolModelProperty*>(ModelPropertyBase::GetPropertyForKey(pIter.first));
+            if (pProperty != nullptr)
+            {
+                for (const auto nDlgItemId : pIter.second)
+                {
+                    auto hControl = GetDlgItem(m_hWnd, nDlgItemId);
+                    if (hControl)
+                        ShowWindow(hControl, GetValueFromAny(*pProperty) ? SW_SHOW : SW_HIDE);
+                }
+            }
+        }
+
         RestoreSizeAndPosition();
     }
 }
@@ -290,11 +304,11 @@ void WindowBinding::BindLabel(int nDlgItemId, const IntModelProperty& pSourcePro
 
 void WindowBinding::OnViewModelBoolValueChanged(const BoolModelProperty::ChangeArgs& args) noexcept
 {
+    bool bRepaint = false;
+
     const auto pEnabledIter = m_mEnabledBindings.find(args.Property.GetKey());
     if (pEnabledIter != m_mEnabledBindings.end())
     {
-        bool bRepaint = false;
-
         for (const auto nDlgItemId : pEnabledIter->second)
         {
             auto hControl = GetDlgItem(m_hWnd, nDlgItemId);
@@ -304,10 +318,24 @@ void WindowBinding::OnViewModelBoolValueChanged(const BoolModelProperty::ChangeA
                 bRepaint = true;
             }
         }
+    }
 
-        if (bRepaint)
-            ControlBinding::ForceRepaint(m_hWnd);
+    const auto pVisibilityIter = m_mVisibilityBindings.find(args.Property.GetKey());
+    if (pVisibilityIter != m_mVisibilityBindings.end())
+    {
+        for (const auto nDlgItemId : pVisibilityIter->second)
+        {
+            auto hControl = GetDlgItem(m_hWnd, nDlgItemId);
+            if (hControl)
+            {
+                ShowWindow(hControl, args.tNewValue ? SW_SHOW : SW_HIDE);
+                bRepaint = true;
+            }
+        }
     }
+
+    if (bRepaint)
+        ControlBinding::ForceRepaint(m_hWnd);
 }
 
 void WindowBinding::BindEnabled(int nDlgItemId, const BoolModelProperty& pSourceProperty)
@@ -323,6 +351,19 @@ void WindowBinding::BindEnabled(int nDlgItemId, const BoolModelProperty& pSource
     }
 }
 
+void WindowBinding::BindVisible(int nDlgItemId, const BoolModelProperty& pSourceProperty)
+{
+    m_mVisibilityBindings[pSourceProperty.GetKey()].insert(nDlgItemId);
+
+    if (m_hWnd)
+    {
+        // immediately push values from the viewmodel to the UI
+        auto hControl = GetDlgItem(m_hWnd, nDlgItemId);
+        if (hControl)
+            ShowWindow(hControl, GetValueFromAny(pSourceProperty) ? SW_SHOW : SW_HIDE);
+    }
+}
+
 const std::wstring& WindowBinding::GetValueFromAny(const StringModelProperty& pProperty) const
 {
     const std::wstring& sValue = GetValue(pProperty);
diff --git a/src/ui/win32/bindings/WindowBinding.hh b/src/ui/win32/bindings/WindowBinding.hh
index 751c1d57..825b8ad5 100644
--- a/src/ui/win32/bindings/WindowBinding.hh
+++ b/src/ui/win32/bindings/WindowBinding.hh
@@ -102,6 +102,13 @@ public:
     /// <param name="pSourceProperty">The property to bind to.</param>
     void BindEnabled(int nDlgItemId, const BoolModelProperty& pSourceProperty);
 
+    /// <summary>
+    /// Binds the a visibility of a control to a property of the viewmodel.
+    /// </summary>
+    /// <param name="nDlgItemId">The unique identifier of the control in the dialog.</param>
+    /// <param name="pSourceProperty">The property to bind to.</param>
+    void BindVisible(int nDlgItemId, const BoolModelProperty& pSourceProperty);
+
     /// <summary>
     /// Called when the window's size changes.
     /// </summary>
@@ -125,6 +132,7 @@ protected:
 private:
     std::unordered_map<int, int> m_mLabelBindings;
     std::unordered_map<int, std::set<int>> m_mEnabledBindings;
+    std::unordered_map<int, std::set<int>> m_mVisibilityBindings;
 
     class ChildBinding : public BindingBase
     {


Test patch:
diff --git a/tests/RA_Integration.Tests.vcxproj b/tests/RA_Integration.Tests.vcxproj
index 69718e51..888ab564 100644
--- a/tests/RA_Integration.Tests.vcxproj
+++ b/tests/RA_Integration.Tests.vcxproj
@@ -372,6 +372,7 @@
     <ClCompile Include="..\src\RA_Defs.cpp" />
     <ClCompile Include="..\src\RA_Leaderboard.cpp" />
     <ClCompile Include="ui\ViewModelCollection_Tests.cpp" />
+    <ClCompile Include="ui\viewmodels\AchievementViewModel_Tests.cpp" />
     <ClCompile Include="ui\viewmodels\AssetEditorViewModel_Tests.cpp" />
     <ClCompile Include="ui\viewmodels\AssetListViewModel_Tests.cpp" />
     <ClCompile Include="ui\viewmodels\AssetViewModelBase_Tests.cpp" />
diff --git a/tests/RA_Integration.Tests.vcxproj.filters b/tests/RA_Integration.Tests.vcxproj.filters
index 44d1121b..d816612a 100644
--- a/tests/RA_Integration.Tests.vcxproj.filters
+++ b/tests/RA_Integration.Tests.vcxproj.filters
@@ -372,6 +372,9 @@
     <ClCompile Include="..\src\ui\viewmodels\AssetEditorViewModel.cpp">
       <Filter>Code</Filter>
     </ClCompile>
+    <ClCompile Include="ui\viewmodels\AchievementViewModel_Tests.cpp">
+      <Filter>Tests\UI\ViewModels</Filter>
+    </ClCompile>
   </ItemGroup>
   <ItemGroup>
     <None Include="base.props" />
diff --git a/tests/ui/viewmodels/AchievementViewModel_Tests.cpp b/tests/ui/viewmodels/AchievementViewModel_Tests.cpp
new file mode 100644
index 00000000..6eab5a5e
--- /dev/null
+++ b/tests/ui/viewmodels/AchievementViewModel_Tests.cpp
@@ -0,0 +1,163 @@
+#include "CppUnitTest.h"
+
+#include "ui\viewmodels\AchievementViewModel.hh"
+
+#include "services\impl\StringTextWriter.hh"
+
+#include "tests\RA_UnitTestHelpers.h"
+#include "tests\mocks\MockAchievementRuntime.hh"
+#include "tests\mocks\MockClock.hh"
+
+using namespace Microsoft::VisualStudio::CppUnitTestFramework;
+
+namespace ra {
+namespace ui {
+namespace viewmodels {
+namespace tests {
+
+TEST_CLASS(AchievementViewModel_Tests)
+{
+private:
+    class AchievementViewModelHarness : public AchievementViewModel
+    {
+    public:
+        ra::services::impl::StringTextWriter textWriter;
+        ra::services::mocks::MockAchievementRuntime mockRuntime;
+        ra::services::mocks::MockClock mockClock;
+    };
+
+public:
+    TEST_METHOD(TestInitialValues)
+    {
+        AchievementViewModelHarness achievement;
+
+        Assert::AreEqual((int)AssetType::Achievement, (int)achievement.GetType());
+        Assert::AreEqual(0U, achievement.GetID());
+        Assert::AreEqual(std::wstring(L""), achievement.GetName());
+        Assert::AreEqual(std::wstring(L""), achievement.GetDescription());
+        Assert::AreEqual(AssetCategory::Core, achievement.GetCategory());
+        Assert::AreEqual(AssetState::Inactive, achievement.GetState());
+        Assert::AreEqual(AssetChanges::None, achievement.GetChanges());
+        Assert::AreEqual(5, achievement.GetPoints());
+        Assert::AreEqual(std::wstring(L"00000"), achievement.GetBadge());
+        Assert::IsFalse(achievement.IsPauseOnReset());
+        Assert::IsFalse(achievement.IsPauseOnTrigger());
+        Assert::AreEqual(std::string(""), achievement.GetTrigger());
+    }
+
+    TEST_METHOD(TestActivateDeactivate)
+    {
+        AchievementViewModelHarness achievement;
+        achievement.SetID(1U);
+        achievement.SetTrigger("0xH1234=1");
+
+        Assert::AreEqual(AssetState::Inactive, achievement.GetState());
+        Assert::IsNull(achievement.mockRuntime.GetAchievementTrigger(1U));
+
+        achievement.Activate();
+
+        Assert::AreEqual(AssetState::Waiting, achievement.GetState());
+        const auto* pTrigger = achievement.mockRuntime.GetAchievementTrigger(1U);
+        Assert::IsNotNull(pTrigger);
+        Assert::AreEqual((int)RC_TRIGGER_STATE_WAITING, (int)pTrigger->state);
+
+        achievement.Deactivate();
+
+        Assert::AreEqual(AssetState::Inactive, achievement.GetState());
+        pTrigger = achievement.mockRuntime.GetAchievementTrigger(1U);
+        Assert::IsNull(pTrigger);
+    }
+
+    TEST_METHOD(TestActivateDeactivateActive)
+    {
+        AchievementViewModelHarness achievement;
+        achievement.SetID(1U);
+        achievement.SetTrigger("0xH1234=1");
+        achievement.Activate();
+
+        auto* pTrigger = achievement.mockRuntime.GetAchievementTrigger(1U);
+        Assert::IsNotNull(pTrigger);
+        Expects(pTrigger != nullptr);
+        pTrigger->state = RC_TRIGGER_STATE_ACTIVE;
+        achievement.DoFrame();
+        Assert::AreEqual(AssetState::Active, achievement.GetState());
+
+        achievement.Activate();
+        Assert::AreEqual(AssetState::Active, achievement.GetState());
+        pTrigger = achievement.mockRuntime.GetAchievementTrigger(1U);
+        Assert::IsNotNull(pTrigger);
+        Assert::AreEqual((int)RC_TRIGGER_STATE_ACTIVE, (int)pTrigger->state);
+
+        achievement.Deactivate();
+        Assert::AreEqual(AssetState::Inactive, achievement.GetState());
+        pTrigger = achievement.mockRuntime.GetAchievementTrigger(1U);
+        Assert::IsNull(pTrigger);
+    }
+
+    TEST_METHOD(TestActivateDeactivatePaused)
+    {
+        AchievementViewModelHarness achievement;
+        achievement.SetID(1U);
+        achievement.SetTrigger("0xH1234=1");
+        achievement.Activate();
+
+        auto* pTrigger = achievement.mockRuntime.GetAchievementTrigger(1U);
+        Assert::IsNotNull(pTrigger);
+        Expects(pTrigger != nullptr);
+        pTrigger->state = RC_TRIGGER_STATE_PAUSED;
+        achievement.DoFrame();
+        Assert::AreEqual(AssetState::Paused, achievement.GetState());
+
+        achievement.Activate();
+        Assert::AreEqual(AssetState::Paused, achievement.GetState());
+        pTrigger = achievement.mockRuntime.GetAchievementTrigger(1U);
+        Assert::IsNotNull(pTrigger);
+        Assert::AreEqual((int)RC_TRIGGER_STATE_PAUSED, (int)pTrigger->state);
+
+        achievement.Deactivate();
+        Assert::AreEqual(AssetState::Inactive, achievement.GetState());
+        pTrigger = achievement.mockRuntime.GetAchievementTrigger(1U);
+        Assert::IsNull(pTrigger);
+        Assert::AreEqual(std::chrono::system_clock::duration::zero().count(), achievement.GetUnlockTime().time_since_epoch().count());
+    }
+
+    TEST_METHOD(TestActivateDeactivateTriggered)
+    {
+        AchievementViewModelHarness achievement;
+        achievement.SetID(1U);
+        achievement.SetTrigger("0xH1234=1");
+        achievement.Activate();
+
+        auto* pTrigger = achievement.mockRuntime.GetAchievementTrigger(1U);
+        Assert::IsNotNull(pTrigger);
+        Expects(pTrigger != nullptr);
+        pTrigger->state = RC_TRIGGER_STATE_TRIGGERED;
+
+        // first DoFrame will set State to Triggered, which deactivates the achievement
+        achievement.DoFrame();
+        Assert::AreEqual(AssetState::Triggered, achievement.GetState());
+        pTrigger = achievement.mockRuntime.GetAchievementTrigger(1U);
+        Assert::IsNull(pTrigger);
+        Assert::AreNotEqual(std::chrono::system_clock::duration::zero().count(), achievement.GetUnlockTime().time_since_epoch().count());
+
+        // second DoFrame won't be able to find the trigger, but should not modify the State
+        achievement.DoFrame();
+        Assert::AreEqual(AssetState::Triggered, achievement.GetState());
+        pTrigger = achievement.mockRuntime.GetAchievementTrigger(1U);
+        Assert::IsNull(pTrigger);
+
+        // reactivating the achievement will set it back to waiting, but not reset the unlock time
+        achievement.Activate();
+        Assert::AreEqual(AssetState::Waiting, achievement.GetState());
+        pTrigger = achievement.mockRuntime.GetAchievementTrigger(1U);
+        Assert::IsNotNull(pTrigger);
+        Assert::AreEqual((int)RC_TRIGGER_STATE_WAITING, (int)pTrigger->state);
+        Assert::AreNotEqual(std::chrono::system_clock::duration::zero().count(), achievement.GetUnlockTime().time_since_epoch().count());
+    }
+};
+
+
+} // namespace tests
+} // namespace viewmodels
+} // namespace ui
+} // namespace ra
diff --git a/tests/ui/viewmodels/AssetEditorViewModel_Tests.cpp b/tests/ui/viewmodels/AssetEditorViewModel_Tests.cpp
index a510ba7c..c9ca32bb 100644
--- a/tests/ui/viewmodels/AssetEditorViewModel_Tests.cpp
+++ b/tests/ui/viewmodels/AssetEditorViewModel_Tests.cpp
@@ -3,6 +3,8 @@
 #include "ui\viewmodels\AssetEditorViewModel.hh"
 
 #include "tests\RA_UnitTestHelpers.h"
+#include "tests\mocks\MockClock.hh"
+#include "tests\mocks\MockAchievementRuntime.hh"
 
 using namespace Microsoft::VisualStudio::CppUnitTestFramework;
 
@@ -29,6 +31,9 @@ private:
         AssetEditorViewModelHarness& operator=(const AssetEditorViewModelHarness&) noexcept = delete;
         AssetEditorViewModelHarness(AssetEditorViewModelHarness&&) noexcept = delete;
         AssetEditorViewModelHarness& operator=(AssetEditorViewModelHarness&&) noexcept = delete;
+
+        ra::services::mocks::MockAchievementRuntime mockRuntime;
+        ra::services::mocks::MockClock mockClock;
     };
 
 
@@ -338,7 +343,128 @@ public:
         Assert::AreEqual(std::wstring(L"New Name 2"), editor.GetName());
         Assert::AreEqual(std::wstring(L"New Name 3"), achievement1.GetName());
         Assert::AreEqual(std::wstring(L"New Name 2"), achievement2.GetName());
-    }};
+    }
+
+    TEST_METHOD(TestUpdateActiveTrigger)
+    {
+        AssetEditorViewModelHarness editor;
+        AchievementViewModel achievement;
+        achievement.SetID(1234U);
+        achievement.SetTrigger("0xH1234=1");
+        achievement.Activate();
+
+        editor.LoadAsset(&achievement);
+
+        // make sure the record got loaded into the runtime
+        const auto* pTrigger = editor.mockRuntime.GetAchievementTrigger(1234U);
+        Expects(pTrigger != nullptr);
+        Assert::AreEqual(1U, pTrigger->requirement->conditions->operand2.value.num);
+
+        Assert::AreEqual({ 1U }, editor.Trigger().Conditions().Count());
+        auto* pCondition = editor.Trigger().Conditions().GetItemAt(0);
+        Expects(pCondition != nullptr);
+        Assert::AreEqual(MemSize::EightBit, pCondition->GetSourceSize());
+        Assert::AreEqual((int)TriggerOperandType::Address, (int)pCondition->GetSourceType());
+        Assert::AreEqual(0x1234U, pCondition->GetSourceValue());
+        Assert::AreEqual((int)TriggerOperatorType::Equals, (int)pCondition->GetOperator());
+        Assert::AreEqual(MemSize::ThirtyTwoBit, pCondition->GetTargetSize());
+        Assert::AreEqual((int)TriggerOperandType::Value, (int)pCondition->GetTargetType());
+        Assert::AreEqual(1U, pCondition->GetTargetValue());
+
+        pCondition->SetTargetValue(2U);
+
+        // make sure the trigger definition got updated
+        Assert::AreEqual(std::string("0xH1234=2"), achievement.GetTrigger());
+
+        // make sure the runtime record got updated
+        pTrigger = editor.mockRuntime.GetAchievementTrigger(1234U);
+        Expects(pTrigger != nullptr);
+        Assert::AreEqual(2U, pTrigger->requirement->conditions->operand2.value.num);
+
+        // make sure the UI kept the updated value
+        Assert::AreEqual({ 1U }, editor.Trigger().Conditions().Count());
+        pCondition = editor.Trigger().Conditions().GetItemAt(0);
+        Expects(pCondition != nullptr);
+        Assert::AreEqual(MemSize::EightBit, pCondition->GetSourceSize());
+        Assert::AreEqual((int)TriggerOperandType::Address, (int)pCondition->GetSourceType());
+        Assert::AreEqual(0x1234U, pCondition->GetSourceValue());
+        Assert::AreEqual((int)TriggerOperatorType::Equals, (int)pCondition->GetOperator());
+        Assert::AreEqual(MemSize::ThirtyTwoBit, pCondition->GetTargetSize());
+        Assert::AreEqual((int)TriggerOperandType::Value, (int)pCondition->GetTargetType());
+        Assert::AreEqual(2U, pCondition->GetTargetValue());
+    }
+
+    TEST_METHOD(TestUpdateInactiveTrigger)
+    {
+        AssetEditorViewModelHarness editor;
+        AchievementViewModel achievement;
+        achievement.SetID(1234U);
+        achievement.SetTrigger("0xH1234=1");
+        achievement.Deactivate();
+
+        editor.LoadAsset(&achievement);
+
+        // make sure the record did not get loaded into the runtime
+        const auto* pTrigger = editor.mockRuntime.GetAchievementTrigger(1234U);
+        Assert::IsNull(pTrigger);
+
+        Assert::AreEqual({ 1U }, editor.Trigger().Conditions().Count());
+        auto* pCondition = editor.Trigger().Conditions().GetItemAt(0);
+        Expects(pCondition != nullptr);
+        Assert::AreEqual(MemSize::EightBit, pCondition->GetSourceSize());
+        Assert::AreEqual((int)TriggerOperandType::Address, (int)pCondition->GetSourceType());
+        Assert::AreEqual(0x1234U, pCondition->GetSourceValue());
+        Assert::AreEqual((int)TriggerOperatorType::Equals, (int)pCondition->GetOperator());
+        Assert::AreEqual(MemSize::ThirtyTwoBit, pCondition->GetTargetSize());
+        Assert::AreEqual((int)TriggerOperandType::Value, (int)pCondition->GetTargetType());
+        Assert::AreEqual(1U, pCondition->GetTargetValue());
+
+        pCondition->SetTargetValue(2U);
+
+        // make sure the trigger definition got updated
+        Assert::AreEqual(std::string("0xH1234=2"), achievement.GetTrigger());
+
+        // make sure the record did not get loaded into the runtime
+        pTrigger = editor.mockRuntime.GetAchievementTrigger(1234U);
+        Assert::IsNull(pTrigger);
+
+        // make sure the UI kept the updated value
+        Assert::AreEqual({ 1U }, editor.Trigger().Conditions().Count());
+        pCondition = editor.Trigger().Conditions().GetItemAt(0);
+        Expects(pCondition != nullptr);
+        Assert::AreEqual(MemSize::EightBit, pCondition->GetSourceSize());
+        Assert::AreEqual((int)TriggerOperandType::Address, (int)pCondition->GetSourceType());
+        Assert::AreEqual(0x1234U, pCondition->GetSourceValue());
+        Assert::AreEqual((int)TriggerOperatorType::Equals, (int)pCondition->GetOperator());
+        Assert::AreEqual(MemSize::ThirtyTwoBit, pCondition->GetTargetSize());
+        Assert::AreEqual((int)TriggerOperandType::Value, (int)pCondition->GetTargetType());
+        Assert::AreEqual(2U, pCondition->GetTargetValue());
+    }
+
+    TEST_METHOD(TestDoFrameUpdatesHitsFromActiveAchievement)
+    {
+        AssetEditorViewModelHarness editor;
+        AchievementViewModel achievement;
+        achievement.SetID(1234U);
+        achievement.SetTrigger("0xH1234=1");
+        achievement.Activate();
+
+        editor.LoadAsset(&achievement);
+
+        Assert::AreEqual({ 1U }, editor.Trigger().Conditions().Count());
+        auto* pCondition = editor.Trigger().Conditions().GetItemAt(0);
+        Expects(pCondition != nullptr);
+        Assert::AreEqual(0U, pCondition->GetCurrentHits());
+
+        const auto* pTrigger = editor.mockRuntime.GetAchievementTrigger(1234U);
+        Expects(pTrigger != nullptr);
+        pTrigger->requirement->conditions->current_hits = 6U;
+        Assert::AreEqual(0U, pCondition->GetCurrentHits());
+
+        editor.DoFrame();
+        Assert::AreEqual(6U, pCondition->GetCurrentHits());
+    }
+};
 
 } // namespace tests
 } // namespace viewmodels
diff --git a/tests/ui/viewmodels/AssetListViewModel_Tests.cpp b/tests/ui/viewmodels/AssetListViewModel_Tests.cpp
index 08a9d0dc..0d93c017 100644
--- a/tests/ui/viewmodels/AssetListViewModel_Tests.cpp
+++ b/tests/ui/viewmodels/AssetListViewModel_Tests.cpp
@@ -6,6 +6,7 @@
 
 #include "tests\RA_UnitTestHelpers.h"
 
+#include "tests\mocks\MockAchievementRuntime.hh"
 #include "tests\mocks\MockGameContext.hh"
 #include "tests\mocks\MockLocalStorage.hh"
 #include "tests\mocks\MockThreadPool.hh"
@@ -23,6 +24,7 @@ private:
     class AssetListViewModelHarness : public AssetListViewModel
     {
     public:
+        ra::services::mocks::MockAchievementRuntime mockRuntime;
         ra::services::mocks::MockThreadPool mockThreadPool;
         ra::services::mocks::MockLocalStorage mockLocalStorage;
         ra::data::mocks::MockGameContext mockGameContext;
@@ -1211,6 +1213,27 @@ public:
 
         Assert::AreEqual(AssetChanges::None, pItem->GetChanges());
     }
+
+    TEST_METHOD(TestDoFrameUpdatesAssets)
+    {
+        AssetListViewModelHarness vmAssetList;
+        vmAssetList.mockGameContext.SetGameId(22U);
+        vmAssetList.AddAchievement(AssetCategory::Core, 5, L"Test1", L"Desc1", L"12345", "0xH1234=1");
+        vmAssetList.AddAchievement(AssetCategory::Core, 7, L"Test2", L"Desc2", L"11111", "0xH1111=1");
+
+        auto* pAchievement = vmAssetList.FindAchievement(1U);
+        Expects(pAchievement != nullptr);
+        pAchievement->Activate();
+        Assert::AreEqual(AssetState::Waiting, pAchievement->GetState());
+
+        auto* pTrigger = vmAssetList.mockRuntime.GetAchievementTrigger(1U);
+        Expects(pTrigger != nullptr);
+        pTrigger->state = RC_TRIGGER_STATE_ACTIVE;
+        Assert::AreEqual(AssetState::Waiting, pAchievement->GetState());
+
+        vmAssetList.DoFrame();
+        Assert::AreEqual(AssetState::Active, pAchievement->GetState());
+    }
 };
 
 } // namespace tests
diff --git a/tests/ui/viewmodels/MemoryInspectorViewModel_Tests.cpp b/tests/ui/viewmodels/MemoryInspectorViewModel_Tests.cpp
index cb41b0a1..4820bb82 100644
--- a/tests/ui/viewmodels/MemoryInspectorViewModel_Tests.cpp
+++ b/tests/ui/viewmodels/MemoryInspectorViewModel_Tests.cpp
@@ -58,6 +58,7 @@ private:
         MemoryInspectorViewModelHarness& operator=(MemoryInspectorViewModelHarness&&) noexcept = delete;
 
         bool CanModifyCodeNotes() const { return GetValue(CanModifyNotesProperty); }
+        bool CurrentBitsVisible() const { return GetValue(CurrentBitsVisibleProperty); }
     };
 
 
@@ -159,6 +160,23 @@ public:
         Assert::AreEqual(0x41U, pNote->GetCurrentValue());
     }
 
+    TEST_METHOD(TestCurrentBitsVisible)
+    {
+        MemoryInspectorViewModelHarness inspector;
+        inspector.Viewer().SetAddress({ 3U });
+        Assert::AreEqual(MemSize::EightBit, inspector.Viewer().GetSize());
+        Assert::IsTrue(inspector.CurrentBitsVisible());
+
+        inspector.Viewer().SetSize(MemSize::SixteenBit);
+        Assert::IsFalse(inspector.CurrentBitsVisible());
+
+        inspector.Viewer().SetSize(MemSize::ThirtyTwoBit);
+        Assert::IsFalse(inspector.CurrentBitsVisible());
+
+        inspector.Viewer().SetSize(MemSize::EightBit);
+        Assert::IsTrue(inspector.CurrentBitsVisible());
+    }
+
     TEST_METHOD(TestOpenNotesList)
     {
         MemoryInspectorViewModelHarness inspector;
diff --git a/tests/ui/viewmodels/OverlayManager_Tests.cpp b/tests/ui/viewmodels/OverlayManager_Tests.cpp
index f72821f3..5e17a2a9 100644
--- a/tests/ui/viewmodels/OverlayManager_Tests.cpp
+++ b/tests/ui/viewmodels/OverlayManager_Tests.cpp
@@ -161,8 +161,9 @@ public:
         ra::ui::drawing::mocks::MockSurface mockSurface(800, 600);
         overlay.Render(mockSurface, false);
         Assert::AreEqual(pPopup->GetRenderLocationY(), 0);
-        // no time has elapsed since the popup was queued, we're still waiting for the first render
-        Assert::IsFalse(overlay.WasRenderRequested());
+        // a queued popup still requests a render even if it's not visible. this ensures the Render
+        // callback will be called to animate the popup
+        Assert::IsTrue(overlay.WasRenderRequested());
         Assert::IsFalse(overlay.WasShowRequested());
         Assert::IsFalse(overlay.WasHideRequested());