Repository: RetroAchievements/RAIntegration
Pull request #959 (2023-01-21 22:38:00)

Add Progress Indicator widget
Adds a new widget that pops up whenever progress is made towards a Measured achievement. The widget shows the icon for the achievement and the current progress. The widget will automatically disappear after two seconds.

If more than one achievement are updated in a single frame, only the closest to completion will be shown (this prevents spamming multiple widgets for tiered achievements).

![image](https://user-images.githubusercontent.com/32680403/213889658-17b93a6b-3ff4-46e1-951d-20e1a34f241b.png)

![image](https://user-images.githubusercontent.com/32680403/213889688-dba4e778-0cc6-4d64-af94-250295198dc2.png)



Patch:
diff --git a/rcheevos b/rcheevos
index 165d1a3b..a5dc2b2e 160000
--- a/rcheevos
+++ b/rcheevos
@@ -1,1 +1,1 @@
-Subproject commit 165d1a3b189c472c4547ac385e59be2f527e688e
+Subproject commit a5dc2b2e6fbaad0f586161e8cf9d05de0546fa80
diff --git a/src/Exports.cpp b/src/Exports.cpp
index 419be250..026f0e85 100644
--- a/src/Exports.cpp
+++ b/src/Exports.cpp
@@ -36,7 +36,9 @@
 #include "ui\win32\OverlayWindow.hh"
 #include "ui\win32\bindings\ControlBinding.hh"
 
-#include "RAInterface\RA_Emulators.h"
+#include <RAInterface\RA_Emulators.h>
+
+#include <rcheevos\include\rc_api_runtime.h>
 
 API const char* CCONV _RA_IntegrationVersion() { return RA_INTEGRATION_VERSION; }
 
@@ -439,6 +441,10 @@ static void ProcessAchievements()
     std::vector<ra::services::AchievementRuntime::Change> vChanges;
     pRuntime.Process(vChanges);
 
+    const ra::data::models::AchievementModel* vmMeasuredAchievement = nullptr;
+    const rc_trigger_t* pMeasuredAchievementTrigger = nullptr;
+    float fMeasuredAchievementPercent = 0.0;
+
     TALLY_PERFORMANCE(PerformanceCheckpoint::RuntimeEvents);
     for (const auto& pChange : vChanges)
     {
@@ -674,8 +680,42 @@ static void ProcessAchievements()
 
                 break;
             }
+
+            case ra::services::AchievementRuntime::ChangeType::AchievementProgressChanged:
+            {
+                const auto* pTrigger = pRuntime.GetAchievementTrigger(pChange.nId);
+                if (pTrigger != nullptr && pTrigger->measured_target > 0)
+                {
+                    const auto& pConfiguration = ra::services::ServiceLocator::Get<ra::services::IConfiguration>();
+                    if (pConfiguration.GetPopupLocation(ra::ui::viewmodels::Popup::Progress) != ra::ui::viewmodels::PopupLocation::None)
+                    {
+                        const auto* vmAchievement = pGameContext.Assets().FindAchievement(pChange.nId);
+                        if (vmAchievement != nullptr)
+                        {
+                            const float fProgress = gsl::narrow_cast<float>(pTrigger->measured_value) / gsl::narrow_cast<float>(pTrigger->measured_target);
+                            if (fProgress > fMeasuredAchievementPercent)
+                            {
+                                fMeasuredAchievementPercent = fProgress;
+                                vmMeasuredAchievement = vmAchievement;
+                                pMeasuredAchievementTrigger = pTrigger;
+                            }
+                        }
+                     }
+                 }
+
+                 break;
+            }
         }
     }
+
+    if (vmMeasuredAchievement && pMeasuredAchievementTrigger)
+    {
+        auto& pOverlayManager = ra::services::ServiceLocator::GetMutable<ra::ui::viewmodels::OverlayManager>();
+        pOverlayManager.UpdateProgressTracker(ra::ui::ImageType::Badge,
+            ra::StringPrintf("%s_lock", vmMeasuredAchievement->GetBadge()),
+            pMeasuredAchievementTrigger->measured_value, pMeasuredAchievementTrigger->measured_target,
+            pMeasuredAchievementTrigger->measured_as_percent);
+    }
 }
 
 API void CCONV _RA_SetPaused(int bIsPaused)
diff --git a/src/RA_Integration.vcxproj b/src/RA_Integration.vcxproj
index aa6d1045..0df6602f 100644
--- a/src/RA_Integration.vcxproj
+++ b/src/RA_Integration.vcxproj
@@ -134,6 +134,7 @@
     <ClCompile Include="ui\viewmodels\PointerFinderViewModel.cpp" />
     <ClCompile Include="ui\viewmodels\PopupMessageViewModel.cpp" />
     <ClCompile Include="ui\viewmodels\PopupViewModelBase.cpp" />
+    <ClCompile Include="ui\viewmodels\ProgressTrackerViewModel.cpp" />
     <ClCompile Include="ui\viewmodels\ProgressViewModel.cpp" />
     <ClCompile Include="ui\viewmodels\RichPresenceMonitorViewModel.cpp" />
     <ClCompile Include="ui\viewmodels\ScoreboardViewModel.cpp" />
@@ -317,6 +318,7 @@
     <ClInclude Include="ui\viewmodels\PointerFinderViewModel.hh" />
     <ClInclude Include="ui\viewmodels\PopupMessageViewModel.hh" />
     <ClInclude Include="ui\viewmodels\PopupViewModelBase.hh" />
+    <ClInclude Include="ui\viewmodels\ProgressTrackerViewModel.hh" />
     <ClInclude Include="ui\viewmodels\ProgressViewModel.hh" />
     <ClInclude Include="ui\viewmodels\RichPresenceMonitorViewModel.hh" />
     <ClInclude Include="ui\viewmodels\ScoreboardViewModel.hh" />
diff --git a/src/RA_Integration.vcxproj.filters b/src/RA_Integration.vcxproj.filters
index 6288e5ab..8873b01a 100644
--- a/src/RA_Integration.vcxproj.filters
+++ b/src/RA_Integration.vcxproj.filters
@@ -402,6 +402,9 @@
     <ClCompile Include="ui\win32\PointerFinderDialog.cpp">
       <Filter>UI\Win32</Filter>
     </ClCompile>
+    <ClCompile Include="ui\viewmodels\ProgressTrackerViewModel.cpp">
+      <Filter>UI\ViewModels</Filter>
+    </ClCompile>
   </ItemGroup>
   <ItemGroup>
     <ClInclude Include="RA_Resource.h">
@@ -989,6 +992,9 @@
     <ClInclude Include="ui\win32\PointerFinderDialog.hh">
       <Filter>UI\Win32</Filter>
     </ClInclude>
+    <ClInclude Include="ui\viewmodels\ProgressTrackerViewModel.hh">
+      <Filter>UI\ViewModels</Filter>
+    </ClInclude>
   </ItemGroup>
   <ItemGroup>
     <ResourceCompile Include="RA_Shared.rc">
diff --git a/src/RA_Resource.h b/src/RA_Resource.h
index d9ba0ede..ae5df029 100644
--- a/src/RA_Resource.h
+++ b/src/RA_Resource.h
@@ -143,6 +143,7 @@
 #define IDC_RA_ADDRESS_4                1238
 #define IDC_RA_CAPTURE_4                1239
 #define IDC_RA_MEMVIEWER_4              1240
+#define IDC_RA_DISPLAY_PROGRESS_INDICATOR 1241
 
 #define IDD_RA_MEMORY                   1501
 #define IDD_RA_ACHIEVEMENTS             1502
@@ -210,7 +211,7 @@
 #ifndef APSTUDIO_READONLY_SYMBOLS
 #define _APS_NEXT_RESOURCE_VALUE        122
 #define _APS_NEXT_COMMAND_VALUE         40001
-#define _APS_NEXT_CONTROL_VALUE         1241
+#define _APS_NEXT_CONTROL_VALUE         1242
 #define _APS_NEXT_SYMED_VALUE           101
 #endif
 #endif
diff --git a/src/RA_Shared.rc b/src/RA_Shared.rc
index 1aefc104..89669243 100644
--- a/src/RA_Shared.rc
+++ b/src/RA_Shared.rc
@@ -58,7 +58,7 @@ BEGIN
     EDITTEXT        IDC_RA_ADDRESS,8,166,56,12,ES_AUTOHSCROLL
     PUSHBUTTON      "...",IDC_RA_VIEW_CODENOTES,65,166,13,12
     PUSHBUTTON      "Add &Bookmark",IDC_RA_ADDBOOKMARK,8,182,70,15
-    EDITTEXT        IDC_RA_NOTE_TEXT,82,152,196,44,ES_MULTILINE | WS_VSCROLL
+    EDITTEXT        IDC_RA_NOTE_TEXT,82,152,196,44,ES_MULTILINE | ES_WANTRETURN | WS_VSCROLL
     PUSHBUTTON      "&Publish",IDC_RA_PUBLISH_NOTE,282,152,42,15
     PUSHBUTTON      "&Revert",IDC_RA_REVERT_NOTE,282,170,42,15
     LTEXT           "Memory View:",IDC_STATIC,4,207,50,9
@@ -281,7 +281,7 @@ BEGIN
     PUSHBUTTON      "Revert",IDC_RA_REVERT_NOTE,233,213,50,14
 END
 
-IDD_RA_OVERLAYSETTINGS DIALOGEX 0, 0, 240, 188
+IDD_RA_OVERLAYSETTINGS DIALOGEX 0, 0, 240, 204
 STYLE DS_SYSMODAL | DS_SETFONT | DS_MODALFRAME | DS_FIXEDSYS | WS_POPUP | WS_CAPTION | WS_SYSMENU
 EXSTYLE WS_EX_TOPMOST
 CAPTION "Overlay Settings"
@@ -303,15 +303,17 @@ BEGIN
     COMBOBOX        IDC_RA_DISPLAY_LBOARD_VALUE,176,82,60,30,CBS_DROPDOWNLIST | WS_VSCROLL | WS_TABSTOP
     LTEXT           "&Scoreboard after submitting leaderboard entry",IDC_STATIC,4,98,168,10
     COMBOBOX        IDC_RA_DISPLAY_LBOARD_SCOREBOARD,176,96,60,30,CBS_DROPDOWNLIST | WS_VSCROLL | WS_TABSTOP
-    LTEXT           "Active c&hallenge indicator",IDC_STATIC,4,115,168,10
-    COMBOBOX        IDC_RA_DISPLAY_CHALLENGE_INDICATOR,176,113,60,30,CBS_DROPDOWNLIST | WS_VSCROLL | WS_TABSTOP
-    LTEXT           "&Informational notifications",IDC_STATIC,4,132,168,10
-    COMBOBOX        IDC_RA_DISPLAY_INFORMATION,176,131,60,30,CBS_DROPDOWNLIST | WS_VSCROLL | WS_TABSTOP
-    LTEXT           "Sc&reenshot Location",IDC_RA_LOCATION,4,145,94,8
-    EDITTEXT        IDC_RA_SCREENSHOT_LOCATION,4,155,217,14,ES_AUTOHSCROLL | ES_READONLY
-    PUSHBUTTON      " ...",IDC_RA_BROWSE,221,155,15,14
-    DEFPUSHBUTTON   "OK",IDOK,146,171,44,15
-    PUSHBUTTON      "Cancel",IDCANCEL,193,171,44,15
+    LTEXT           "Active c&hallenge indicator",IDC_STATIC,4,116,168,10
+    COMBOBOX        IDC_RA_DISPLAY_CHALLENGE_INDICATOR,176,114,60,30,CBS_DROPDOWNLIST | WS_VSCROLL | WS_TABSTOP
+    LTEXT           "&Progress indicator",IDC_STATIC,4,131,168,10
+    COMBOBOX        IDC_RA_DISPLAY_PROGRESS_INDICATOR,176,129,60,30,CBS_DROPDOWNLIST | WS_VSCROLL | WS_TABSTOP
+    LTEXT           "&Informational notifications",IDC_STATIC,4,148,168,10
+    COMBOBOX        IDC_RA_DISPLAY_INFORMATION,176,147,60,30,CBS_DROPDOWNLIST | WS_VSCROLL | WS_TABSTOP
+    LTEXT           "Sc&reenshot Location",IDC_RA_LOCATION,4,161,94,8
+    EDITTEXT        IDC_RA_SCREENSHOT_LOCATION,4,171,217,14,ES_AUTOHSCROLL | ES_READONLY
+    PUSHBUTTON      " ...",IDC_RA_BROWSE,221,171,15,14
+    DEFPUSHBUTTON   "OK",IDOK,146,187,44,15
+    PUSHBUTTON      "Cancel",IDCANCEL,193,187,44,15
 END
 
 IDD_RA_NEWASSET DIALOGEX 0, 0, 200, 34
diff --git a/src/services/AchievementRuntime.cpp b/src/services/AchievementRuntime.cpp
index 96cf8bed..0954a833 100644
--- a/src/services/AchievementRuntime.cpp
+++ b/src/services/AchievementRuntime.cpp
@@ -14,6 +14,7 @@
 #include "services\IThreadPool.hh"
 #include "services\ServiceLocator.hh"
 
+#include <rcheevos\src\rcheevos\rc_internal.h>
 #include <rcheevos\src\rhash\md5.h>
 
 namespace ra {
@@ -252,6 +253,9 @@ static void map_event_to_change(const rc_runtime_event_t* pRuntimeEvent)
         case RC_RUNTIME_EVENT_LBOARD_DISABLED:
             nChangeType = AchievementRuntime::ChangeType::LeaderboardDisabled;
             break;
+        case RC_RUNTIME_EVENT_ACHIEVEMENT_PROGRESS_UPDATED:
+            nChangeType = AchievementRuntime::ChangeType::AchievementProgressChanged;
+            break;
         default:
             // unsupported
             return;
@@ -402,49 +406,16 @@ _Use_decl_annotations_ void AchievementRuntime::Process(std::vector<Change>& cha
         CheckForLeaderboardPauseChanges(vLeaderboardPauseFlags, &m_pRuntime, changes);
 }
 
-_NODISCARD static _CONSTANT_FN ComparisonSizeToPrefix(_In_ char nSize) noexcept
-{
-    switch (nSize)
-    {
-        case RC_MEMSIZE_BIT_0:        return "M";
-        case RC_MEMSIZE_BIT_1:        return "N";
-        case RC_MEMSIZE_BIT_2:        return "O";
-        case RC_MEMSIZE_BIT_3:        return "P";
-        case RC_MEMSIZE_BIT_4:        return "Q";
-        case RC_MEMSIZE_BIT_5:        return "R";
-        case RC_MEMSIZE_BIT_6:        return "S";
-        case RC_MEMSIZE_BIT_7:        return "T";
-        case RC_MEMSIZE_LOW:          return "L";
-        case RC_MEMSIZE_HIGH:         return "U";
-        case RC_MEMSIZE_8_BITS:       return "H";
-        case RC_MEMSIZE_24_BITS:      return "W";
-        case RC_MEMSIZE_32_BITS:      return "X";
-        default:
-        case RC_MEMSIZE_16_BITS:      return " ";
-    }
-}
-
-_NODISCARD static constexpr char ComparisonSizeFromPrefix(_In_ char cPrefix) noexcept
+_NODISCARD static char ComparisonSizeFromPrefix(_In_ char cPrefix) noexcept
 {
-    switch (cPrefix)
-    {
-        case 'm': case 'M': return RC_MEMSIZE_BIT_0;
-        case 'n': case 'N': return RC_MEMSIZE_BIT_1;
-        case 'o': case 'O': return RC_MEMSIZE_BIT_2;
-        case 'p': case 'P': return RC_MEMSIZE_BIT_3;
-        case 'q': case 'Q': return RC_MEMSIZE_BIT_4;
-        case 'r': case 'R': return RC_MEMSIZE_BIT_5;
-        case 's': case 'S': return RC_MEMSIZE_BIT_6;
-        case 't': case 'T': return RC_MEMSIZE_BIT_7;
-        case 'l': case 'L': return RC_MEMSIZE_LOW;
-        case 'u': case 'U': return RC_MEMSIZE_HIGH;
-        case 'h': case 'H': return RC_MEMSIZE_8_BITS;
-        case 'w': case 'W': return RC_MEMSIZE_24_BITS;
-        case 'x': case 'X': return RC_MEMSIZE_32_BITS;
-        default:
-        case ' ':
-            return RC_MEMSIZE_16_BITS;
-    }
+    char buffer[] = "0xH0";
+    const char* ptr = buffer;
+    buffer[2] = cPrefix;
+
+    char size = RC_MEMSIZE_16_BITS;
+    unsigned address = 0;
+    rc_parse_memref(&ptr, &size, &address);
+    return size;
 }
 
 static bool IsMemoryOperand(int nOperandType) noexcept
@@ -454,6 +425,8 @@ static bool IsMemoryOperand(int nOperandType) noexcept
         case RC_OPERAND_ADDRESS:
         case RC_OPERAND_DELTA:
         case RC_OPERAND_PRIOR:
+        case RC_OPERAND_BCD:
+        case RC_OPERAND_INVERTED:
             return true;
 
         default:
@@ -809,7 +782,7 @@ bool AchievementRuntime::LoadProgressFromFile(const char* sLoadStateFilename)
     }
 
     // reset the runtime state, then apply state from file
-    rc_runtime_reset(&m_pRuntime);
+    ResetActiveAchievements();
 
     if (sLoadStateFilename == nullptr)
         return false;
@@ -883,7 +856,7 @@ bool AchievementRuntime::LoadProgressFromBuffer(const char* pBuffer)
     std::lock_guard<std::mutex> pLock(m_pMutex);
 
     // reset the runtime state, then apply state from file
-    rc_runtime_reset(&m_pRuntime);
+    ResetActiveAchievements();
 
     const unsigned char* pBytes;
     GSL_SUPPRESS_TYPE1 pBytes = reinterpret_cast<const unsigned char*>(pBuffer);
diff --git a/src/services/AchievementRuntime.hh b/src/services/AchievementRuntime.hh
index 6490c8b2..bb8301bc 100644
--- a/src/services/AchievementRuntime.hh
+++ b/src/services/AchievementRuntime.hh
@@ -171,6 +171,7 @@ public:
         AchievementPrimed,
         AchievementActivated,
         AchievementUnprimed,
+        AchievementProgressChanged,
 
         // runtime only changes
         LeaderboardStartReset,
diff --git a/src/services/impl/JsonFileConfiguration.cpp b/src/services/impl/JsonFileConfiguration.cpp
index 63c011fb..797eb869 100644
--- a/src/services/impl/JsonFileConfiguration.cpp
+++ b/src/services/impl/JsonFileConfiguration.cpp
@@ -84,6 +84,7 @@ bool JsonFileConfiguration::Load(const std::wstring& sFilename)
     SetPopupLocation(ra::ui::viewmodels::Popup::LeaderboardTracker, ra::ui::viewmodels::PopupLocation::BottomRight);
     SetPopupLocation(ra::ui::viewmodels::Popup::LeaderboardScoreboard, ra::ui::viewmodels::PopupLocation::BottomRight);
     SetPopupLocation(ra::ui::viewmodels::Popup::Challenge, ra::ui::viewmodels::PopupLocation::BottomRight);
+    SetPopupLocation(ra::ui::viewmodels::Popup::Progress, ra::ui::viewmodels::PopupLocation::BottomRight);
 
     RA_LOG_INFO("Loading preferences...");
 
@@ -121,7 +122,8 @@ bool JsonFileConfiguration::Load(const std::wstring& sFilename)
     ReadPopupLocation(*this, ra::ui::viewmodels::Popup::LeaderboardTracker, doc, "Leaderboard Counter Display", ra::ui::viewmodels::PopupLocation::BottomRight, true);
     ReadPopupLocation(*this, ra::ui::viewmodels::Popup::LeaderboardScoreboard, doc, "Leaderboard Scoreboard Display", ra::ui::viewmodels::PopupLocation::BottomRight, false);
 
-    ReadPopupLocation(*this, ra::ui::viewmodels::Popup::Challenge, doc, "Challenge Notification Display", ra::ui::viewmodels::PopupLocation::BottomRight, true);
+    ReadPopupLocation(*this, ra::ui::viewmodels::Popup::Challenge, doc, "Challenge Notification Display", ra::ui::viewmodels::PopupLocation::BottomRight, false);
+    ReadPopupLocation(*this, ra::ui::viewmodels::Popup::Progress, doc, "Progress Display", ra::ui::viewmodels::PopupLocation::BottomRight, false);
 
     ReadPopupLocation(*this, ra::ui::viewmodels::Popup::Message, doc, "Informational Notification Display", ra::ui::viewmodels::PopupLocation::BottomLeft, true);
 
diff --git a/src/ui/drawing/gdi/GDISurface.cpp b/src/ui/drawing/gdi/GDISurface.cpp
index e240774a..3c45cab1 100644
--- a/src/ui/drawing/gdi/GDISurface.cpp
+++ b/src/ui/drawing/gdi/GDISurface.cpp
@@ -100,7 +100,10 @@ void GDISurface::DrawImageStretched(int nX, int nY, int nWidth, int nHeight, con
 
     BITMAP bm;
     if (GetObject(hBitmap, sizeof(bm), &bm) == sizeof(bm))
+    {
+        SetStretchBltMode(m_hDC, HALFTONE);
         StretchBlt(m_hDC, nX, nY, nWidth, nHeight, hdcMem, 0, 0, bm.bmWidth, bm.bmHeight, SRCCOPY);
+    }
 
     SelectBitmap(hdcMem, hOldBitmap);
 
diff --git a/src/ui/viewmodels/AssetEditorViewModel.cpp b/src/ui/viewmodels/AssetEditorViewModel.cpp
index d79e9dc6..7384baf7 100644
--- a/src/ui/viewmodels/AssetEditorViewModel.cpp
+++ b/src/ui/viewmodels/AssetEditorViewModel.cpp
@@ -13,6 +13,8 @@
 #include "ui\viewmodels\FileDialogViewModel.hh"
 #include "ui\viewmodels\MessageBoxViewModel.hh"
 
+#include <rcheevos\src\rcheevos\rc_internal.h>
+
 namespace ra {
 namespace ui {
 namespace viewmodels {
@@ -975,7 +977,8 @@ void AssetEditorViewModel::UpdateMeasuredValue()
                         return;
                 }
 
-                SetValue(MeasuredValueProperty, ra::StringPrintf(L"%d/%d", pTrigger->measured_value, pTrigger->measured_target));
+                const auto nValue = (pTrigger->measured_value == RC_MEASURED_UNKNOWN) ? 0 : pTrigger->measured_value;
+                SetValue(MeasuredValueProperty, ra::StringPrintf(L"%d/%d", nValue, pTrigger->measured_target));
             }
             else
             {
diff --git a/src/ui/viewmodels/OverlayManager.cpp b/src/ui/viewmodels/OverlayManager.cpp
index 00b67a35..a4d89c39 100644
--- a/src/ui/viewmodels/OverlayManager.cpp
+++ b/src/ui/viewmodels/OverlayManager.cpp
@@ -60,7 +60,8 @@ bool OverlayManager::NeedsRender() const noexcept
         return true;
     }
 
-    if (!m_vPopupMessages.empty() || !m_vScoreboards.empty() || !m_vScoreTrackers.empty() || !m_vChallengeIndicators.empty())
+    if (!m_vPopupMessages.empty() || !m_vScoreboards.empty() || !m_vScoreTrackers.empty() ||
+        !m_vChallengeIndicators.empty() || m_vmProgressTracker != nullptr)
     {
         // a popup is visible
         return true;
@@ -171,6 +172,15 @@ void OverlayManager::Render(ra::ui::drawing::ISurface& pSurface, bool bRedrawAll
         if (!m_vChallengeIndicators.empty())
             UpdateChallengeIndicators(pSurface, pPopupLocations, fElapsed);
 
+        if (m_vmProgressTracker != nullptr)
+        {
+            UpdateProgressTracker(pSurface, pPopupLocations, fElapsed);
+
+            // a visible progress tracker may not set m_bRedrawAll if it's in the pause portion of its animation loop
+            // make sure we keep the render cycle going so it'll eventually handle when the pause ends
+            bRequestRender = true;
+        }
+
         // if anything changed, or caller requested a repaint, do so now
         if (m_bRedrawAll)
         {
@@ -181,6 +191,8 @@ void OverlayManager::Render(ra::ui::drawing::ISurface& pSurface, bool bRedrawAll
                 RenderPopup(pSurface, *pScoreTracker);
             for (const auto& pChallengeIndicator : m_vChallengeIndicators)
                 RenderPopup(pSurface, *pChallengeIndicator);
+            if (m_vmProgressTracker != nullptr)
+                RenderPopup(pSurface, *m_vmProgressTracker);
             if (!m_vPopupMessages.empty())
                 RenderPopup(pSurface, *m_vPopupMessages.front());
 
@@ -313,6 +325,18 @@ void OverlayManager::RemoveChallengeIndicator(ra::AchievementID nAchievementId)
         RequestRender();
 }
 
+void OverlayManager::UpdateProgressTracker(ra::ui::ImageType imageType, const std::string& sImageName, unsigned nValue, unsigned nTarget, bool bAsPercent)
+{
+    if (m_vmProgressTracker == nullptr)
+        m_vmProgressTracker.reset(new ProgressTrackerViewModel());
+
+    m_vmProgressTracker->SetImage(imageType, sImageName);
+    m_vmProgressTracker->SetProgress(nValue, nTarget, bAsPercent);
+    m_vmProgressTracker->BeginAnimation();
+
+    RequestRender();
+}
+
 int OverlayManager::QueueMessage(std::unique_ptr<PopupMessageViewModel>& pMessage)
 {
     if (pMessage->GetImage().Type() != ra::ui::ImageType::None)
@@ -351,6 +375,9 @@ void OverlayManager::ClearPopups()
         for (auto& pChallengeIndicator : m_vChallengeIndicators)
             pChallengeIndicator->SetDestroyPending();
 
+        if (m_vmProgressTracker != nullptr)
+            m_vmProgressTracker->SetDestroyPending();
+
         for (auto& pTracker : m_vScoreTrackers)
             pTracker->SetDestroyPending();
     }
@@ -597,13 +624,14 @@ void OverlayManager::UpdateChallengeIndicators(ra::ui::drawing::ISurface& pSurfa
 {
     assert(!m_vChallengeIndicators.empty());
 
+    constexpr int nSpacing = 10;
+    auto nRenderX = nSpacing;
+
     const auto& pConfiguration = ra::services::ServiceLocator::Get<ra::services::IConfiguration>();
     const auto bEnabled = (pConfiguration.GetPopupLocation(ra::ui::viewmodels::Popup::Challenge) != ra::ui::viewmodels::PopupLocation::None);
     if (bEnabled)
     {
         // adjust offsets so the items appear horizontally stacked
-        constexpr int nSpacing = 10;
-        auto nRenderX = nSpacing;
         for (auto pIter = m_vChallengeIndicators.begin(); pIter != m_vChallengeIndicators.end(); ++pIter)
         {
             auto& vmNotification = **pIter;
@@ -634,10 +662,40 @@ void OverlayManager::UpdateChallengeIndicators(ra::ui::drawing::ISurface& pSurfa
             ++pIter;
     }
 
-    if (bEnabled && !m_vChallengeIndicators.empty())
+    if (!m_vChallengeIndicators.empty())
         AdjustLocationForPopup(pPopupLocations, *m_vChallengeIndicators.front());
 }
 
+void OverlayManager::UpdateProgressTracker(ra::ui::drawing::ISurface& pSurface, PopupLocations& pPopupLocations, double fElapsed)
+{
+    assert(m_vmProgressTracker != nullptr);
+
+    if (m_vmProgressTracker->IsDestroyPending())
+    {
+        // call UpdatePopup again to force it to "unpaint"
+        UpdatePopup(pSurface, pPopupLocations, fElapsed, *m_vmProgressTracker);
+
+        m_vmProgressTracker.reset();
+        return;
+    }
+
+    const auto& pConfiguration = ra::services::ServiceLocator::Get<ra::services::IConfiguration>();
+    const auto bEnabled = (pConfiguration.GetPopupLocation(ra::ui::viewmodels::Popup::Progress) != ra::ui::viewmodels::PopupLocation::None);
+
+    if (bEnabled)
+    {
+        UpdatePopup(pSurface, pPopupLocations, fElapsed, *m_vmProgressTracker);
+
+        if (m_vmProgressTracker->IsAnimationComplete())
+        {
+            m_vmProgressTracker->SetDestroyPending();
+            return;
+        }
+
+        AdjustLocationForPopup(pPopupLocations, *m_vmProgressTracker);
+    }
+}
+
 void OverlayManager::UpdateOverlay(ra::ui::drawing::ISurface& pSurface, double fElapsed)
 {
     PopupLocations pPopupLocations;
@@ -657,6 +715,8 @@ void OverlayManager::UpdateOverlay(ra::ui::drawing::ISurface& pSurface, double f
 
         for (const auto& pChallengeIndicator : m_vChallengeIndicators)
             RenderPopupClippedX(pSurface, *pChallengeIndicator, nOverlayRight);
+        if (m_vmProgressTracker != nullptr)
+            RenderPopupClippedX(pSurface, *m_vmProgressTracker, nOverlayRight);
 
         for (const auto& pTracker : m_vScoreTrackers)
             RenderPopupClippedX(pSurface, *pTracker, nOverlayRight);
diff --git a/src/ui/viewmodels/OverlayManager.hh b/src/ui/viewmodels/OverlayManager.hh
index 081d6fd3..4c8fdd6b 100644
--- a/src/ui/viewmodels/OverlayManager.hh
+++ b/src/ui/viewmodels/OverlayManager.hh
@@ -6,6 +6,7 @@
 #include "OverlayViewModel.hh"
 #include "ChallengeIndicatorViewModel.hh"
 #include "PopupMessageViewModel.hh"
+#include "ProgressTrackerViewModel.hh"
 #include "ScoreboardViewModel.hh"
 #include "ScoreTrackerViewModel.hh"
 
@@ -268,6 +269,12 @@ public:
         return nullptr;
     }
 
+    /// <summary>
+    /// Updates the progress tracker indicator
+    /// </summary>
+    void UpdateProgressTracker(ra::ui::ImageType imageType, const std::string& sImageName,
+        unsigned nValue, unsigned nTarget, bool bAsPercent);
+
     /// <summary>
     /// Clears all popups.
     /// </summary>
@@ -311,6 +318,7 @@ protected:
     std::vector<std::unique_ptr<ScoreTrackerViewModel>> m_vScoreTrackers;
     std::vector<std::unique_ptr<ChallengeIndicatorViewModel>> m_vChallengeIndicators;
     std::deque<ScoreboardViewModel> m_vScoreboards;
+    std::unique_ptr<ra::ui::viewmodels::ProgressTrackerViewModel> m_vmProgressTracker;
 
     bool m_bIsRendering = false;
     bool m_bRenderRequestPending = false;
@@ -344,6 +352,7 @@ private:
     void UpdateActiveScoreboard(ra::ui::drawing::ISurface& pSurface, PopupLocations& pPopupLocations, double fElapsed);
     void UpdateScoreTrackers(ra::ui::drawing::ISurface& pSurface, PopupLocations& pPopupLocations, double fElapsed);
     void UpdateChallengeIndicators(ra::ui::drawing::ISurface& pSurface, PopupLocations& pPopupLocations, double fElapsed);
+    void UpdateProgressTracker(ra::ui::drawing::ISurface& pSurface, PopupLocations& pPopupLocations, double fElapsed);
     void UpdatePopup(ra::ui::drawing::ISurface& pSurface, const PopupLocations& pPopupLocations, double fElapsed, ra::ui::viewmodels::PopupViewModelBase& vmPopup);
 
     void UpdateOverlay(ra::ui::drawing::ISurface& pSurface, double fElapsed);
diff --git a/src/ui/viewmodels/OverlaySettingsViewModel.cpp b/src/ui/viewmodels/OverlaySettingsViewModel.cpp
index 6f5b2b09..fcd8bd75 100644
--- a/src/ui/viewmodels/OverlaySettingsViewModel.cpp
+++ b/src/ui/viewmodels/OverlaySettingsViewModel.cpp
@@ -19,6 +19,7 @@ const IntModelProperty OverlaySettingsViewModel::LeaderboardCanceledLocationProp
 const IntModelProperty OverlaySettingsViewModel::LeaderboardTrackerLocationProperty("OverlaySettingsViewModel", "LeaderboardTrackerLocation", ra::etoi(PopupLocation::BottomRight));
 const IntModelProperty OverlaySettingsViewModel::LeaderboardScoreboardLocationProperty("OverlaySettingsViewModel", "LeaderboardScoreboardLocation", ra::etoi(PopupLocation::BottomRight));
 const IntModelProperty OverlaySettingsViewModel::ActiveChallengeLocationProperty("OverlaySettingsViewModel", "ActiveChallengeLocation", ra::etoi(PopupLocation::BottomRight));
+const IntModelProperty OverlaySettingsViewModel::ProgressTrackerLocationProperty("OverlaySettingsViewModel", "ProgressLocation", ra::etoi(PopupLocation::BottomRight));
 const StringModelProperty OverlaySettingsViewModel::ScreenshotLocationProperty("OverlaySettingsViewModel", "ScreenshotLocation", L"");
 
 OverlaySettingsViewModel::OverlaySettingsViewModel() noexcept
@@ -58,6 +59,7 @@ void OverlaySettingsViewModel::Initialize()
     SetLeaderboardScoreboardLocation(pConfiguration.GetPopupLocation(Popup::LeaderboardScoreboard));
 
     SetActiveChallengeLocation(pConfiguration.GetPopupLocation(Popup::Challenge));
+    SetProgressTrackerLocation(pConfiguration.GetPopupLocation(Popup::Progress));
 
     SetScreenshotLocation(pConfiguration.GetScreenshotDirectory());
 }
@@ -76,6 +78,7 @@ void OverlaySettingsViewModel::Commit()
     pConfiguration.SetPopupLocation(Popup::LeaderboardTracker, GetLeaderboardTrackerLocation());
     pConfiguration.SetPopupLocation(Popup::LeaderboardScoreboard, GetLeaderboardScoreboardLocation());
     pConfiguration.SetPopupLocation(Popup::Challenge, GetActiveChallengeLocation());
+    pConfiguration.SetPopupLocation(Popup::Progress, GetProgressTrackerLocation());
 
     std::wstring sLocation = ScreenshotLocation();
     if (!sLocation.empty() && sLocation.back() != '\\')
diff --git a/src/ui/viewmodels/OverlaySettingsViewModel.hh b/src/ui/viewmodels/OverlaySettingsViewModel.hh
index 0c8744ac..4139dae0 100644
--- a/src/ui/viewmodels/OverlaySettingsViewModel.hh
+++ b/src/ui/viewmodels/OverlaySettingsViewModel.hh
@@ -175,6 +175,21 @@ public:
     /// </summary>
     void SetActiveChallengeLocation(PopupLocation nValue) { SetValue(ActiveChallengeLocationProperty, ra::etoi(nValue)); }
 
+    /// <summary>
+    /// The <see cref="ModelProperty" /> for where the progress trackers should be displayed.
+    /// </summary>
+    static const IntModelProperty ProgressTrackerLocationProperty;
+
+    /// <summary>
+    /// Gets the location where progress trackers should be displayed.
+    /// </summary>
+    PopupLocation GetProgressTrackerLocation() const { return ra::itoe<PopupLocation>(GetValue(ProgressTrackerLocationProperty)); }
+
+    /// <summary>
+    /// Sets the location where progress trackers should be displayed.
+    /// </summary>
+    void SetProgressTrackerLocation(PopupLocation nValue) { SetValue(ProgressTrackerLocationProperty, ra::etoi(nValue)); }
+
     /// <summary>
     /// The <see cref="ModelProperty" /> for the location to write screenshots.
     /// </summary>
diff --git a/src/ui/viewmodels/PopupViewModelBase.hh b/src/ui/viewmodels/PopupViewModelBase.hh
index ef16ef50..c69a515a 100644
--- a/src/ui/viewmodels/PopupViewModelBase.hh
+++ b/src/ui/viewmodels/PopupViewModelBase.hh
@@ -20,6 +20,7 @@ enum class Popup
     LeaderboardTracker,
     LeaderboardScoreboard,
     Challenge,
+    Progress,
     NumPopups
 };
 
diff --git a/src/ui/viewmodels/ProgressTrackerViewModel.cpp b/src/ui/viewmodels/ProgressTrackerViewModel.cpp
new file mode 100644
index 00000000..af617ebe
--- /dev/null
+++ b/src/ui/viewmodels/ProgressTrackerViewModel.cpp
@@ -0,0 +1,92 @@
+#include "ProgressTrackerViewModel.hh"
+
+#include "ra_math.h"
+
+#include "ui\OverlayTheme.hh"
+#include "ui\viewmodels\OverlayManager.hh"
+
+namespace ra {
+namespace ui {
+namespace viewmodels {
+
+ProgressTrackerViewModel::ProgressTrackerViewModel() noexcept
+{
+    GSL_SUPPRESS_F6 SetPopupType(ra::ui::viewmodels::Popup::Progress);
+
+    GSL_SUPPRESS_F6 SetHorizontalOffset(10);
+    GSL_SUPPRESS_F6 SetVerticalOffset(10);
+}
+
+void ProgressTrackerViewModel::SetProgress(unsigned nValue, unsigned nTarget, bool bAsPercent)
+{
+    if (bAsPercent)
+    {
+        const auto nProgressPercent = gsl::narrow_cast<int>(static_cast<long long>(nValue) * 100 / nTarget);
+        m_sProgress = ra::StringPrintf(L"%d%%", nProgressPercent);
+    }
+    else
+    {
+        m_sProgress = ra::StringPrintf(L"%u/%u", nValue, nTarget);
+    }
+}
+
+bool ProgressTrackerViewModel::UpdateRenderImage(double fElapsed)
+{
+    if (m_fAnimationProgress >= TOTAL_ANIMATION_TIME)
+        return false;
+
+    m_fAnimationProgress += fElapsed;
+
+    bool bUpdated = false;
+
+    if (m_pSurface == nullptr)
+    {
+        const auto& pTheme = ra::services::ServiceLocator::Get<ra::ui::OverlayTheme>();
+        const auto nShadowOffset = pTheme.ShadowOffset();
+        constexpr int nImageSize = 32;
+
+        // create a temporary surface so we can determine the size required for the actual surface
+        const auto& pSurfaceFactory = ra::services::ServiceLocator::Get<ra::ui::drawing::ISurfaceFactory>();
+        auto pSurface = pSurfaceFactory.CreateSurface(1, 1);
+
+        const auto& pOverlayTheme = ra::services::ServiceLocator::Get<ra::ui::OverlayTheme>();
+
+        const auto nFontSubtitle = pSurface->LoadFont(pOverlayTheme.FontPopup(), pOverlayTheme.FontSizePopupDetail(), ra::ui::FontStyles::Normal);
+        const auto szText = pSurface->MeasureText(nFontSubtitle, m_sProgress);
+
+        const auto nWidth = 4 + nImageSize + 6 + szText.Width + nShadowOffset + 4 + nShadowOffset;
+        const auto nHeight = 4 + nImageSize + 4 + nShadowOffset;
+
+        pSurface = pSurfaceFactory.CreateSurface(nWidth, nHeight);
+
+        // background
+        pSurface->FillRectangle(0, 0, nWidth, nHeight, Color::Transparent);
+        pSurface->FillRectangle(nShadowOffset, nShadowOffset, nWidth - nShadowOffset, nHeight - nShadowOffset, pTheme.ColorShadow());
+
+        // frame
+        const auto nColorBackground = GetBackgroundColor();
+        pSurface->FillRectangle(0, 0, nWidth - nShadowOffset, nHeight - nShadowOffset, nColorBackground);
+        pSurface->FillRectangle(1, 1, nWidth - nShadowOffset - 2, nHeight - nShadowOffset - 2, pOverlayTheme.ColorBorder());
+        pSurface->FillRectangle(2, 2, nWidth - nShadowOffset - 4, nHeight - nShadowOffset - 4, nColorBackground);
+
+        // image
+        if (m_hImage.Type() != ra::ui::ImageType::None)
+            pSurface->DrawImageStretched(4, 4, nImageSize, nImageSize, m_hImage);
+
+        // text
+        const auto constexpr nX = 4 + nImageSize + 6;
+        const auto nY = (nHeight - nShadowOffset - szText.Height - nShadowOffset) / 2;
+        pSurface->WriteText(nX + 2, nY + 2, nFontSubtitle, pOverlayTheme.ColorTextShadow(), m_sProgress);
+        pSurface->WriteText(nX, nY, nFontSubtitle, pOverlayTheme.ColorDetail(), m_sProgress);
+
+        m_pSurface = std::move(pSurface);
+
+        bUpdated = true;
+    }
+
+    return bUpdated;
+}
+
+} // namespace viewmodels
+} // namespace ui
+} // namespace ra
diff --git a/src/ui/viewmodels/ProgressTrackerViewModel.hh b/src/ui/viewmodels/ProgressTrackerViewModel.hh
new file mode 100644
index 00000000..f7fc28b3
--- /dev/null
+++ b/src/ui/viewmodels/ProgressTrackerViewModel.hh
@@ -0,0 +1,67 @@
+#ifndef RA_UI_PROGRESSTRACKER_VIEWMODEL_H
+#define RA_UI_PROGRESSTRACKER_VIEWMODEL_H
+#pragma once
+
+#include "PopupViewModelBase.hh"
+
+namespace ra {
+namespace ui {
+namespace viewmodels {
+
+class ProgressTrackerViewModel : public PopupViewModelBase
+{
+public:
+    ProgressTrackerViewModel() noexcept;
+
+    /// <summary>
+    /// Sets the image to display.
+    /// </summary>
+    void SetImage(ra::ui::ImageType nImageType, const std::string& sImageName)
+    {
+        m_hImage.ChangeReference(nImageType, sImageName);
+        m_pSurface.reset();
+        SetRenderLocationY(-1); // force redraw when position calculated
+    }
+
+    /// <summary>
+    /// Sets the percentage of the image to highlight
+    /// </summary>
+    void SetProgress(unsigned nValue, unsigned nTarget, bool bAsPercent);
+
+    /// <summary>
+    /// Gets the image to display.
+    /// </summary>
+    const ra::ui::ImageReference& GetImage() const noexcept { return m_hImage; }
+
+    /// <summary>
+    /// Gets the text to display.
+    /// </summary>
+    const std::wstring& GetText() const noexcept { return m_sProgress; }
+
+    /// <summary>
+    /// Updates the image to render.
+    /// </summary>
+    bool UpdateRenderImage(double fElapsed) override;
+
+    void BeginAnimation() noexcept override { m_fAnimationProgress = 0.0; }
+    bool IsAnimationStarted() const noexcept override { return true; }
+
+    bool IsAnimationComplete() const noexcept override
+    {
+        return m_fAnimationProgress >= TOTAL_ANIMATION_TIME;
+    }
+
+private:
+    ra::ui::ImageReference m_hImage;
+    std::wstring m_sProgress;
+
+    double m_fAnimationProgress = TOTAL_ANIMATION_TIME;
+
+    static _CONSTANT_VAR TOTAL_ANIMATION_TIME = 2.0;
+};
+
+} // namespace viewmodels
+} // namespace ui
+} // namespace ra
+
+#endif !RA_UI_PROGRESSTRACKER_VIEWMODEL_H
diff --git a/src/ui/win32/OverlaySettingsDialog.cpp b/src/ui/win32/OverlaySettingsDialog.cpp
index 68919851..0105c14d 100644
--- a/src/ui/win32/OverlaySettingsDialog.cpp
+++ b/src/ui/win32/OverlaySettingsDialog.cpp
@@ -38,6 +38,7 @@ OverlaySettingsDialog::OverlaySettingsDialog(ra::ui::viewmodels::OverlaySettings
       m_bindDisplayLeaderboardValue(vmSettings),
       m_bindDisplayLeaderboardScoreboards(vmSettings),
       m_bindChallengeNotifications(vmSettings),
+      m_bindProgressTrackers(vmSettings),
       m_bindScreenshotLocation(vmSettings),
       m_bindInformationLocation(vmSettings)
 {
@@ -63,6 +64,8 @@ OverlaySettingsDialog::OverlaySettingsDialog(ra::ui::viewmodels::OverlaySettings
 
     m_bindChallengeNotifications.BindItems(vmSettings.PopupLocationsNoMiddle());
     m_bindChallengeNotifications.BindSelectedItem(ra::ui::viewmodels::OverlaySettingsViewModel::ActiveChallengeLocationProperty);
+    m_bindProgressTrackers.BindItems(vmSettings.PopupLocationsNoMiddle());
+    m_bindProgressTrackers.BindSelectedItem(ra::ui::viewmodels::OverlaySettingsViewModel::ProgressTrackerLocationProperty);
 
     m_bindInformationLocation.BindItems(vmSettings.PopupLocations());
     m_bindInformationLocation.BindSelectedItem(ra::ui::viewmodels::OverlaySettingsViewModel::MessageLocationProperty);
@@ -81,6 +84,7 @@ BOOL OverlaySettingsDialog::OnInitDialog()
     m_bindDisplayLeaderboardValue.SetControl(*this, IDC_RA_DISPLAY_LBOARD_VALUE);
     m_bindDisplayLeaderboardScoreboards.SetControl(*this, IDC_RA_DISPLAY_LBOARD_SCOREBOARD);
     m_bindChallengeNotifications.SetControl(*this, IDC_RA_DISPLAY_CHALLENGE_INDICATOR);
+    m_bindProgressTrackers.SetControl(*this, IDC_RA_DISPLAY_PROGRESS_INDICATOR);
     m_bindInformationLocation.SetControl(*this, IDC_RA_DISPLAY_INFORMATION);
 
     m_bindScreenshotLocation.SetControl(*this, IDC_RA_SCREENSHOT_LOCATION);
diff --git a/src/ui/win32/OverlaySettingsDialog.hh b/src/ui/win32/OverlaySettingsDialog.hh
index a789efd1..0eb5a6a7 100644
--- a/src/ui/win32/OverlaySettingsDialog.hh
+++ b/src/ui/win32/OverlaySettingsDialog.hh
@@ -45,6 +45,7 @@ private:
     ra::ui::win32::bindings::ComboBoxBinding m_bindDisplayLeaderboardValue;
     ra::ui::win32::bindings::ComboBoxBinding m_bindDisplayLeaderboardScoreboards;
     ra::ui::win32::bindings::ComboBoxBinding m_bindChallengeNotifications;
+    ra::ui::win32::bindings::ComboBoxBinding m_bindProgressTrackers;
     ra::ui::win32::bindings::ComboBoxBinding m_bindInformationLocation;
     ra::ui::win32::bindings::TextBoxBinding m_bindScreenshotLocation;
 };
diff --git a/src/ui/win32/bindings/GridBinding.cpp b/src/ui/win32/bindings/GridBinding.cpp
index 7cd3ed6d..c41d6095 100644
--- a/src/ui/win32/bindings/GridBinding.cpp
+++ b/src/ui/win32/bindings/GridBinding.cpp
@@ -1231,6 +1231,7 @@ void GridBinding::OnNmClick(const NMITEMACTIVATE* pnmItemActivate)
         {
             auto pInfo = std::make_unique<GridColumnBinding::InPlaceEditorInfo>();
             pInfo->bIgnoreLostFocus = false;
+            pInfo->bForceWmChar = false;
 
             if (pnmItemActivate->iItem >= 0)
             {
diff --git a/src/ui/win32/bindings/GridColumnBinding.hh b/src/ui/win32/bindings/GridColumnBinding.hh
index 149ab31a..9ac59747 100644
--- a/src/ui/win32/bindings/GridColumnBinding.hh
+++ b/src/ui/win32/bindings/GridColumnBinding.hh
@@ -76,6 +76,7 @@ public:
         void* pGridBinding;
         GridColumnBinding* pColumnBinding;
         bool bIgnoreLostFocus;
+        bool bForceWmChar;
     };
 
     virtual HWND CreateInPlaceEditor(HWND, InPlaceEditorInfo&) noexcept(false) { return nullptr; }
diff --git a/src/ui/win32/bindings/GridTextColumnBinding.cpp b/src/ui/win32/bindings/GridTextColumnBinding.cpp
index f96a90d6..4b3ea144 100644
--- a/src/ui/win32/bindings/GridTextColumnBinding.cpp
+++ b/src/ui/win32/bindings/GridTextColumnBinding.cpp
@@ -70,7 +70,24 @@ static LRESULT CALLBACK EditBoxProc(HWND hwnd, UINT nMsg, WPARAM wParam, LPARAM
                 return GridBinding::CloseIPE(hwnd, pInfo);
             }
 
+            // BizHawk specific hack (possibly affects other emus?), seems BizHawk does not have WM_CHAR messages sent,
+            // so text can't be typed. It's unknown why this occurs, it's likely something to do with it using
+            // IsDialogMessage more properly in its message loop and something is likely wrong with the way edit windows
+            // are handled in RAIntegration. Until that is figured out, this hack seems to suffice
+            if (pInfo->bForceWmChar)
+            {
+                MSG msg = {hwnd, nMsg, wParam, lParam};
+                TranslateMessage(&msg);
+                PeekMessage(&msg, hwnd, 0, 0, PM_REMOVE);
+                return CallWindowProc(pInfo->pOriginalWndProc, msg.hwnd, msg.message, msg.wParam, msg.lParam);
+            }
+
             break;
+
+        case WM_GETDLGCODE:
+            // only reached if IsDialogMessage is used (also used as a signal to activate the above hack)
+            pInfo->bForceWmChar = true;
+            return DLGC_WANTALLKEYS;
     }
 
     return CallWindowProc(pInfo->pOriginalWndProc, hwnd, nMsg, wParam, lParam);


Test patch:
diff --git a/tests/Exports_Tests.cpp b/tests/Exports_Tests.cpp
index fa606047..616a7cf3 100644
--- a/tests/Exports_Tests.cpp
+++ b/tests/Exports_Tests.cpp
@@ -588,6 +588,92 @@ public:
         Assert::IsTrue(pIndicator->IsDestroyPending());
     }
 
+    TEST_METHOD(TestDoAchievementsFrameAchievementProgress)
+    {
+        DoAchievementsFrameHarness harness;
+        harness.MockAchievement(1U).SetBadge(L"55223");
+        harness.mockConfiguration.SetPopupLocation(ra::ui::viewmodels::Popup::Progress, ra::ui::viewmodels::PopupLocation::BottomRight);
+        harness.mockRuntime.ActivateAchievement(1U, "M:0xH0001=10");
+        auto* pTrigger = harness.mockRuntime.GetAchievementTrigger(1U);
+        pTrigger->measured_value = 5;
+
+        harness.mockRuntime.QueueChange(ra::services::AchievementRuntime::ChangeType::AchievementProgressChanged, 1U, pTrigger->measured_value);
+        _RA_DoAchievementsFrame();
+
+        const auto* pIndicator = harness.mockOverlayManager.GetProgressTracker();
+        Assert::IsNotNull(pIndicator);
+        Ensures(pIndicator != nullptr);
+
+        Assert::AreEqual(ra::ui::ImageType::Badge, pIndicator->GetImage().Type());
+        Assert::AreEqual(std::string("55223_lock"), pIndicator->GetImage().Name());
+        Assert::AreEqual(std::wstring(L"5/10"), pIndicator->GetText());
+    }
+
+    TEST_METHOD(TestDoAchievementsFrameAchievementProgressNoTracker)
+    {
+        DoAchievementsFrameHarness harness;
+        harness.MockAchievement(1U).SetBadge(L"55223");
+        harness.mockConfiguration.SetPopupLocation(ra::ui::viewmodels::Popup::Progress, ra::ui::viewmodels::PopupLocation::None);
+        harness.mockRuntime.ActivateAchievement(1U, "M:0xH0001=10");
+        auto* pTrigger = harness.mockRuntime.GetAchievementTrigger(1U);
+        pTrigger->measured_value = 5;
+
+        harness.mockRuntime.QueueChange(ra::services::AchievementRuntime::ChangeType::AchievementProgressChanged, 1U, pTrigger->measured_value);
+        _RA_DoAchievementsFrame();
+
+        const auto* pIndicator = harness.mockOverlayManager.GetProgressTracker();
+        Assert::IsNull(pIndicator);
+    }
+
+    TEST_METHOD(TestDoAchievementsFrameAchievementProgressHighestPercent)
+    {
+        DoAchievementsFrameHarness harness;
+        harness.mockConfiguration.SetPopupLocation(ra::ui::viewmodels::Popup::Progress, ra::ui::viewmodels::PopupLocation::BottomRight);
+        harness.MockAchievement(1U).SetBadge(L"55221");
+        harness.mockRuntime.ActivateAchievement(1U, "M:0xH0001=10");
+        harness.MockAchievement(2U).SetBadge(L"55222");
+        harness.mockRuntime.ActivateAchievement(2U, "M:0xH0001=10");
+        harness.MockAchievement(3U).SetBadge(L"55223");
+        harness.mockRuntime.ActivateAchievement(3U, "M:0xH0001=10");
+        harness.mockRuntime.GetAchievementTrigger(1U)->measured_value = 5;
+        harness.mockRuntime.GetAchievementTrigger(2U)->measured_value = 7;
+        harness.mockRuntime.GetAchievementTrigger(3U)->measured_value = 3;
+
+        harness.mockRuntime.QueueChange(ra::services::AchievementRuntime::ChangeType::AchievementProgressChanged, 1U, 5);
+        harness.mockRuntime.QueueChange(ra::services::AchievementRuntime::ChangeType::AchievementProgressChanged, 2U, 7);
+        harness.mockRuntime.QueueChange(ra::services::AchievementRuntime::ChangeType::AchievementProgressChanged, 3U, 3);
+        _RA_DoAchievementsFrame();
+
+        const auto* pIndicator = harness.mockOverlayManager.GetProgressTracker();
+        Assert::IsNotNull(pIndicator);
+        Ensures(pIndicator != nullptr);
+
+        Assert::AreEqual(ra::ui::ImageType::Badge, pIndicator->GetImage().Type());
+        Assert::AreEqual(std::string("55222_lock"), pIndicator->GetImage().Name());
+        Assert::AreEqual(std::wstring(L"7/10"), pIndicator->GetText());
+    }
+
+    TEST_METHOD(TestDoAchievementsFrameAchievementProgressAsPercent)
+    {
+        DoAchievementsFrameHarness harness;
+        harness.MockAchievement(1U).SetBadge(L"55223");
+        harness.mockConfiguration.SetPopupLocation(ra::ui::viewmodels::Popup::Progress, ra::ui::viewmodels::PopupLocation::BottomRight);
+        harness.mockRuntime.ActivateAchievement(1U, "G:0xH0001=10");
+        auto* pTrigger = harness.mockRuntime.GetAchievementTrigger(1U);
+        pTrigger->measured_value = 5;
+
+        harness.mockRuntime.QueueChange(ra::services::AchievementRuntime::ChangeType::AchievementProgressChanged, 1U, pTrigger->measured_value);
+        _RA_DoAchievementsFrame();
+
+        const auto* pIndicator = harness.mockOverlayManager.GetProgressTracker();
+        Assert::IsNotNull(pIndicator);
+        Ensures(pIndicator != nullptr);
+
+        Assert::AreEqual(ra::ui::ImageType::Badge, pIndicator->GetImage().Type());
+        Assert::AreEqual(std::string("55223_lock"), pIndicator->GetImage().Name());
+        Assert::AreEqual(std::wstring(L"50%"), pIndicator->GetText());
+    }
+
     TEST_METHOD(TestDoAchievementsFramePaused)
     {
         DoAchievementsFrameHarness harness;
diff --git a/tests/RA_Integration.Tests.vcxproj b/tests/RA_Integration.Tests.vcxproj
index ee849767..97bb3f8a 100644
--- a/tests/RA_Integration.Tests.vcxproj
+++ b/tests/RA_Integration.Tests.vcxproj
@@ -439,6 +439,7 @@
     <ClCompile Include="..\src\ui\viewmodels\PopupMessageViewModel.cpp" />
     <ClCompile Include="..\src\ui\viewmodels\LoginViewModel.cpp" />
     <ClCompile Include="..\src\ui\viewmodels\PopupViewModelBase.cpp" />
+    <ClCompile Include="..\src\ui\viewmodels\ProgressTrackerViewModel.cpp" />
     <ClCompile Include="..\src\ui\viewmodels\ProgressViewModel.cpp" />
     <ClCompile Include="..\src\ui\viewmodels\ScoreboardViewModel.cpp" />
     <ClCompile Include="..\src\ui\viewmodels\ScoreTrackerViewModel.cpp" />
diff --git a/tests/RA_Integration.Tests.vcxproj.filters b/tests/RA_Integration.Tests.vcxproj.filters
index bfcad221..c2990fd4 100644
--- a/tests/RA_Integration.Tests.vcxproj.filters
+++ b/tests/RA_Integration.Tests.vcxproj.filters
@@ -456,6 +456,9 @@
     <ClCompile Include="ui\viewmodels\PointerFinderViewModel_Tests.cpp">
       <Filter>Tests\UI\ViewModels</Filter>
     </ClCompile>
+    <ClCompile Include="..\src\ui\viewmodels\ProgressTrackerViewModel.cpp">
+      <Filter>Code</Filter>
+    </ClCompile>
   </ItemGroup>
   <ItemGroup>
     <None Include="base.props" />
diff --git a/tests/mocks/MockOverlayManager.hh b/tests/mocks/MockOverlayManager.hh
index a38d5cac..0745773a 100644
--- a/tests/mocks/MockOverlayManager.hh
+++ b/tests/mocks/MockOverlayManager.hh
@@ -31,6 +31,16 @@ public:
         m_vScoreTrackers.clear();
     }
 
+    ra::ui::viewmodels::ProgressTrackerViewModel* GetProgressTracker() noexcept
+    {
+        return m_vmProgressTracker.get();
+    }
+
+    const ra::ui::viewmodels::ProgressTrackerViewModel* GetProgressTracker() const noexcept
+    {
+        return m_vmProgressTracker.get();
+    }
+
 private:
     ra::services::ServiceLocator::ServiceOverride<ra::ui::viewmodels::OverlayManager> m_Override;
 };
diff --git a/tests/services/AchievementRuntime_Tests.cpp b/tests/services/AchievementRuntime_Tests.cpp
index 03a13e2c..266fbd4d 100644
--- a/tests/services/AchievementRuntime_Tests.cpp
+++ b/tests/services/AchievementRuntime_Tests.cpp
@@ -145,6 +145,22 @@ private:
         Assert::Fail(pString.c_str());
     }
 
+    static void AssertChange(const std::vector<AchievementRuntime::Change>& vChanges, AchievementRuntime::ChangeType nType, unsigned nId, int nValue)
+    {
+        for (const auto& pChange : vChanges)
+        {
+            if (pChange.nId == nId && pChange.nType == nType)
+            {
+                Assert::AreEqual(nValue, pChange.nValue);
+                return;
+            }
+        }
+
+        const auto pString = ra::StringPrintf(L"Could not find change notification for %s:%d",
+            Microsoft::VisualStudio::CppUnitTestFramework::ToString<AchievementRuntime::ChangeType>(nType), nId);
+        Assert::Fail(pString.c_str());
+    }
+
 public:
     TEST_METHOD(TestActivateAchievement)
     {
@@ -328,6 +344,61 @@ public:
         vChanges.clear();
     }
 
+    TEST_METHOD(TestMonitorAchievementMeasured)
+    {
+        std::array<unsigned char, 5> memory{0x00, 0x12, 0x34, 0xAB, 0x56};
+
+        AchievementRuntimeHarness runtime;
+        runtime.mockEmulatorContext.MockMemory(memory);
+        auto* pTrigger = "M:0xH0002=100";
+        runtime.ActivateAchievement(4U, pTrigger);
+        runtime.GetAchievementTrigger(4U)->state = RC_TRIGGER_STATE_ACTIVE;
+
+        // value did not change, expect no event from initialization
+        std::vector<AchievementRuntime::Change> vChanges;
+        runtime.Process(vChanges);
+        Assert::AreEqual({ 0U }, vChanges.size());
+
+        // change value, expect notification
+        memory.at(2) = 75;
+        runtime.Process(vChanges);
+        Assert::AreEqual({ 1U }, vChanges.size());
+        AssertChange(vChanges, AchievementRuntime::ChangeType::AchievementProgressChanged, 4U, 75);
+        vChanges.clear();
+
+        // watch for notification, no change, no notification
+        runtime.Process(vChanges);
+        Assert::AreEqual({ 0U }, vChanges.size());
+
+        // disable achievement, no notification
+        runtime.DeactivateAchievement(4U);
+        memory.at(2) = 76;
+        runtime.Process(vChanges);
+        Assert::AreEqual({ 0U }, vChanges.size());
+
+        // enable achievement, expect activation event, but not progress notification
+        runtime.ActivateAchievement(4U, pTrigger);
+        memory.at(2) = 77;
+        runtime.Process(vChanges);
+        Assert::AreEqual({ 1U }, vChanges.size());
+        AssertChange(vChanges, AchievementRuntime::ChangeType::AchievementActivated, 4U);
+        vChanges.clear();
+
+        // change value, expect notification
+        memory.at(2) = 78;
+        runtime.Process(vChanges);
+        Assert::AreEqual({ 1U }, vChanges.size());
+        AssertChange(vChanges, AchievementRuntime::ChangeType::AchievementProgressChanged, 4U, 78);
+        vChanges.clear();
+
+        // trigger achievement, expect trigger notification, but not progress notification
+        memory.at(2) = 100;
+        runtime.Process(vChanges);
+        Assert::AreEqual({ 1U }, vChanges.size());
+        AssertChange(vChanges, AchievementRuntime::ChangeType::AchievementTriggered, 4U);
+        vChanges.clear();
+    }
+
     TEST_METHOD(TestUpdateAchievementId)
     {
         std::array<unsigned char, 1> memory{ 0x00 };
diff --git a/tests/ui/viewmodels/OverlayManager_Tests.cpp b/tests/ui/viewmodels/OverlayManager_Tests.cpp
index 03b1a4f9..75d6f12e 100644
--- a/tests/ui/viewmodels/OverlayManager_Tests.cpp
+++ b/tests/ui/viewmodels/OverlayManager_Tests.cpp
@@ -77,6 +77,11 @@ private:
 
         int GetOverlayRenderX() const { return m_vmOverlay.GetHorizontalOffset(); }
 
+        ra::ui::viewmodels::ProgressTrackerViewModel* GetProgressTracker() noexcept
+        {
+            return m_vmProgressTracker.get();
+        }
+
     private:
         bool m_bRenderRequested = false;
         bool m_bShowRequested = false;
@@ -520,6 +525,37 @@ public:
         Assert::IsTrue(&vmIndicator == &vmIndicator2);
     }
 
+    TEST_METHOD(TestAddRemoveProgressIndicator)
+    {
+        OverlayManagerHarness overlay;
+        overlay.mockConfiguration.SetPopupLocation(ra::ui::viewmodels::Popup::Progress, ra::ui::viewmodels::PopupLocation::BottomRight);
+
+        overlay.UpdateProgressTracker(ra::ui::ImageType::Badge, "12345_lock", 3, 7, false);
+        auto* pTracker = overlay.GetProgressTracker();
+        Assert::IsNotNull(pTracker);
+        Ensures(pTracker != nullptr);
+
+        Assert::AreEqual(std::wstring(L"3/7"), pTracker->GetText());
+        Assert::AreEqual(ra::ui::ImageType::Badge, pTracker->GetImage().Type());
+        Assert::AreEqual(std::string("12345_lock"), pTracker->GetImage().Name());
+        Assert::IsTrue(overlay.WasRenderRequested());
+        Assert::IsFalse(pTracker->IsDestroyPending());
+        Assert::AreEqual(10, pTracker->GetVerticalOffset());
+
+        ra::ui::drawing::mocks::MockSurface mockSurface(800, 600);
+        overlay.Render(mockSurface, false);
+        Assert::AreEqual(10, pTracker->GetVerticalOffset());
+
+        overlay.mockClock.AdvanceTime(std::chrono::seconds(3));
+        overlay.Render(mockSurface, false);
+        Assert::IsTrue(overlay.WasRenderRequested()); // destroy pending
+        Assert::IsTrue(pTracker->IsDestroyPending());
+
+        overlay.Render(mockSurface, false);
+        pTracker = overlay.GetProgressTracker(); // destroy occurred
+        Assert::IsNull(pTracker);
+    }
+
     TEST_METHOD(TestShowHideOverlay)
     {
         OverlayManagerHarness overlay;
diff --git a/tests/ui/viewmodels/OverlaySettingsViewModel_Tests.cpp b/tests/ui/viewmodels/OverlaySettingsViewModel_Tests.cpp
index f3dd6d4e..b373f741 100644
--- a/tests/ui/viewmodels/OverlaySettingsViewModel_Tests.cpp
+++ b/tests/ui/viewmodels/OverlaySettingsViewModel_Tests.cpp
@@ -163,6 +163,7 @@ public:
         ValidatePopupLocationInitialize(ra::ui::viewmodels::Popup::LeaderboardTracker, [](OverlaySettingsViewModel& vm) { return vm.GetLeaderboardTrackerLocation(); });
         ValidatePopupLocationInitialize(ra::ui::viewmodels::Popup::LeaderboardScoreboard, [](OverlaySettingsViewModel& vm) { return vm.GetLeaderboardScoreboardLocation(); });
         ValidatePopupLocationInitialize(ra::ui::viewmodels::Popup::Challenge, [](OverlaySettingsViewModel& vm) { return vm.GetActiveChallengeLocation(); });
+        ValidatePopupLocationInitialize(ra::ui::viewmodels::Popup::Progress, [](OverlaySettingsViewModel& vm) { return vm.GetProgressTrackerLocation(); });
 
         OverlaySettingsViewModelHarness vmSettings;
         vmSettings.mockConfiguration.SetScreenshotDirectory(L"C:\\Screenshots\\");
@@ -181,6 +182,7 @@ public:
         ValidatePopupLocationCommit(ra::ui::viewmodels::Popup::LeaderboardTracker, [](OverlaySettingsViewModel& vm, ra::ui::viewmodels::PopupLocation nValue) { return vm.SetLeaderboardTrackerLocation(nValue); });
         ValidatePopupLocationCommit(ra::ui::viewmodels::Popup::LeaderboardScoreboard, [](OverlaySettingsViewModel& vm, ra::ui::viewmodels::PopupLocation nValue) { return vm.SetLeaderboardScoreboardLocation(nValue); });
         ValidatePopupLocationCommit(ra::ui::viewmodels::Popup::Challenge, [](OverlaySettingsViewModel& vm, ra::ui::viewmodels::PopupLocation nValue) { return vm.SetActiveChallengeLocation(nValue); });
+        ValidatePopupLocationCommit(ra::ui::viewmodels::Popup::Progress, [](OverlaySettingsViewModel& vm, ra::ui::viewmodels::PopupLocation nValue) { return vm.SetProgressTrackerLocation(nValue); });
 
         OverlaySettingsViewModelHarness vmSettings;
         vmSettings.SetScreenshotLocation(L"C:\\Screenshots\\");