Repository: RetroAchievements/RAIntegration
Pull request #927 (2022-10-09 01:22:46)

allow Invert for non-value logic
Invert is used to toggle all the bits of a value.  It's been supported in values for a very long time.

It's been excluded from logic because it's better to invert the logic than the value read from memory. I wanted to eliminate it entirely, as it was originally added as a way ignore values when certain bits were set (`0xX1234*~0xN2345` would evaluate to 0 if bit1 of $2345 was 1). A better solution for that is to use MeasuredIf. 

Unfortunately, there are at least a couple games that are using it in a way that's not easily replaceable with alternate syntax, so I haven't been able to deprecate it.

As it's just as effectively useful in logic as BCD, I've removed the restriction limiting its use to values.



Patch:
diff --git a/rcheevos b/rcheevos
index bbefee2e..e99fa55b 160000
--- a/rcheevos
+++ b/rcheevos
@@ -1,1 +1,1 @@
-Subproject commit bbefee2ed023104c083092e82c5d14505805014e
+Subproject commit e99fa55b1cfeb665aab209cf0b3dd9970e9caa31
diff --git a/src/Exports.cpp b/src/Exports.cpp
index 1d369458..e5a9f741 100644
--- a/src/Exports.cpp
+++ b/src/Exports.cpp
@@ -298,7 +298,7 @@ static void HandleLoginResponse(const ra::api::Login::Response& response)
         ra::services::ServiceLocator::Get<ra::data::context::EmulatorContext>().RebuildMenu();
 
         // update the client title-bar to include the user name
-        _RA_UpdateAppTitle();
+        ra::services::ServiceLocator::GetMutable<ra::ui::viewmodels::WindowManager>().Emulator.UpdateWindowTitle();
     }
     else if (!response.ErrorMessage.empty())
     {
@@ -408,9 +408,8 @@ API int CCONV _RA_OnLoadNewRom(const BYTE* pROM, unsigned int nROMSize)
 
 API void CCONV _RA_UpdateAppTitle(const char* sMessage)
 {
-    auto sTitle = ra::services::ServiceLocator::Get<ra::data::context::EmulatorContext>().GetAppTitle(sMessage ? sMessage : "");
     auto& vmEmulator = ra::services::ServiceLocator::GetMutable<ra::ui::viewmodels::WindowManager>().Emulator;
-    vmEmulator.SetWindowTitle(sTitle);
+    vmEmulator.SetAppTitleMessage(sMessage);
 }
 
 API int _RA_IsOverlayFullyVisible()
diff --git a/src/RA_Defs.cpp b/src/RA_Defs.cpp
index 69930791..72aebe6a 100644
--- a/src/RA_Defs.cpp
+++ b/src/RA_Defs.cpp
@@ -76,6 +76,9 @@ std::wstring MemSizeFormat(unsigned nValue, MemSize nSize, MemFormat nFormat)
         case MemSize::MBF32:
             return U32ToFloatString(nValue, RC_MEMSIZE_MBF32);
 
+        case MemSize::MBF32LE:
+            return U32ToFloatString(nValue, RC_MEMSIZE_MBF32_LE);
+
         default:
             if (nFormat == MemFormat::Dec)
                 return std::to_wstring(nValue);
@@ -112,6 +115,9 @@ unsigned FloatToU32(float fValue, MemSize nFloatType) noexcept
 
     nValue += 0x02000000; // adjust to 129 base
 
+    if (nFloatType == MemSize::MBF32LE)
+        return nValue;
+
     return ((nValue & 0xFF000000) >> 24) | // convert to big endian
         ((nValue & 0x00FF0000) >> 8) |
         ((nValue & 0x0000FF00) << 8) |
@@ -131,10 +137,13 @@ float U32ToFloat(unsigned nValue, MemSize nFloatType) noexcept
 
     if (nFloatType != MemSize::Float)
     {
-        nValue = ((nValue & 0xFF000000) >> 24) | // convert to big endian
-            ((nValue & 0x00FF0000) >> 8) |
-            ((nValue & 0x0000FF00) << 8) |
-            ((nValue & 0x000000FF) << 24);
+        if (nFloatType == MemSize::MBF32)
+        {
+            nValue = ((nValue & 0xFF000000) >> 24) | // convert to big endian
+                ((nValue & 0x00FF0000) >> 8) |
+                ((nValue & 0x0000FF00) << 8) |
+                ((nValue & 0x000000FF) << 24);
+        }
 
         // MBF32 puts the sign after the exponent, uses a 129 base instead of 127, and stores in big endian
         nValue -= 0x02000000; // adjust to 129 base
diff --git a/src/RA_Integration.vcxproj b/src/RA_Integration.vcxproj
index 5b0417be..a5ac7409 100644
--- a/src/RA_Integration.vcxproj
+++ b/src/RA_Integration.vcxproj
@@ -111,6 +111,7 @@
     <ClCompile Include="ui\viewmodels\BrokenAchievementsViewModel.cpp" />
     <ClCompile Include="ui\viewmodels\ChallengeIndicatorViewModel.cpp" />
     <ClCompile Include="ui\viewmodels\CodeNotesViewModel.cpp" />
+    <ClCompile Include="ui\viewmodels\EmulatorViewModel.cpp" />
     <ClCompile Include="ui\viewmodels\FileDialogViewModel.cpp" />
     <ClCompile Include="ui\viewmodels\GameChecksumViewModel.cpp" />
     <ClCompile Include="ui\viewmodels\IntegrationMenuViewModel.cpp" />
diff --git a/src/RA_Integration.vcxproj.filters b/src/RA_Integration.vcxproj.filters
index b48e6f67..ab0501f2 100644
--- a/src/RA_Integration.vcxproj.filters
+++ b/src/RA_Integration.vcxproj.filters
@@ -393,6 +393,9 @@
     <ClCompile Include="data\models\CodeNotesModel.cpp">
       <Filter>Data\Models</Filter>
     </ClCompile>
+    <ClCompile Include="ui\viewmodels\EmulatorViewModel.cpp">
+      <Filter>UI\ViewModels</Filter>
+    </ClCompile>
   </ItemGroup>
   <ItemGroup>
     <ClInclude Include="RA_Resource.h">
diff --git a/src/RA_Resource.h b/src/RA_Resource.h
index d5126053..ec63b296 100644
--- a/src/RA_Resource.h
+++ b/src/RA_Resource.h
@@ -121,6 +121,7 @@
 #define IDC_RA_POINT_TOTAL              1216
 #define IDC_RA_GAMEHASH                 1217
 #define IDC_RA_ADDBOOKMARK              1218
+#define IDC_RA_RESULTS_EXPORT           1219
 #define IDD_RA_MEMORY                   1501
 #define IDD_RA_ACHIEVEMENTS             1502
 #define IDD_RA_ACHIEVEMENTEDITOR        1503
@@ -185,7 +186,7 @@
 #ifndef APSTUDIO_READONLY_SYMBOLS
 #define _APS_NEXT_RESOURCE_VALUE        122
 #define _APS_NEXT_COMMAND_VALUE         40001
-#define _APS_NEXT_CONTROL_VALUE         1219
+#define _APS_NEXT_CONTROL_VALUE         1220
 #define _APS_NEXT_SYMED_VALUE           101
 #endif
 #endif
diff --git a/src/RA_Shared.rc b/src/RA_Shared.rc
index 6240c3e5..8eec1652 100644
--- a/src/RA_Shared.rc
+++ b/src/RA_Shared.rc
@@ -34,7 +34,7 @@ BEGIN
     COMBOBOX        IDC_RA_SEARCHRANGES,8,14,70,30,CBS_DROPDOWNLIST | WS_VSCROLL | WS_TABSTOP
     EDITTEXT        IDC_RA_SEARCHRANGE,8,31,70,12,ES_AUTOHSCROLL
     COMBOBOX        IDC_RA_SEARCHTYPE,82,14,56,30,CBS_DROPDOWNLIST | WS_VSCROLL | WS_TABSTOP
-    PUSHBUTTON      "New Search",IDC_RA_RESET_FILTER,82,30,56,15
+    PUSHBUTTON      "&New Search",IDC_RA_RESET_FILTER,82,30,56,15
     GROUPBOX        "Filter",IDC_RA_GBX_FILTER,146,4,182,44
     COMBOBOX        IDC_RA_COMPARISON,150,14,26,16,CBS_DROPDOWNLIST | WS_VSCROLL | WS_TABSTOP
     COMBOBOX        IDC_RA_SPECIAL_FILTER,180,14,82,81,CBS_DROPDOWNLIST | WS_VSCROLL | WS_TABSTOP
@@ -42,18 +42,19 @@ BEGIN
     PUSHBUTTON      "&Filter Once",IDC_RA_APPLY_FILTER,266,13,58,15
     PUSHBUTTON      "&Continuous Filter",IDC_RA_CONTINUOUS_FILTER,266,30,58,15
     GROUPBOX        "Results",IDC_RA_GBX_RESULTS,4,48,324,97
-    LTEXT           "Count:",IDC_STATIC,8,60,20,8
-    LTEXT           "0",IDC_RA_RESULT_COUNT,32,60,46,8
-    LTEXT           "Filter:",IDC_STATIC,8,71,20,8
-    LTEXT           "!= Last Known",IDC_RA_RESULT_FILTER,32,71,46,8
-    PUSHBUTTON      "<<",IDC_RA_RESULTS_BACK,8,81,20,15
-    CTEXT           "0/0",IDC_RA_RESULT_PAGE,28,84,30,8
-    PUSHBUTTON      ">>",IDC_RA_RESULTS_FORWARD,58,81,20,15
-    PUSHBUTTON      "E&xclude Selected",IDC_RA_RESULTS_REMOVE,8,109,70,15,BS_MULTILINE
-    PUSHBUTTON      "Book&mark Selected",IDC_RA_RESULTS_BOOKMARK,8,126,70,15,BS_MULTILINE
+    LTEXT           "Count:",IDC_STATIC,8,58,20,8
+    LTEXT           "0",IDC_RA_RESULT_COUNT,32,58,46,8
+    LTEXT           "Filter:",IDC_STATIC,8,68,20,8
+    LTEXT           "!= Last Known",IDC_RA_RESULT_FILTER,32,68,46,8
+    PUSHBUTTON      "<<",IDC_RA_RESULTS_BACK,8,78,20,14
+    CTEXT           "0/0",IDC_RA_RESULT_PAGE,28,80,30,8
+    PUSHBUTTON      ">>",IDC_RA_RESULTS_FORWARD,58,78,20,14
+    PUSHBUTTON      "E&xclude Selected",IDC_RA_RESULTS_REMOVE,8,95,70,14
+    PUSHBUTTON      "Book&mark Selected",IDC_RA_RESULTS_BOOKMARK,8,111,70,14
+    PUSHBUTTON      "&Export",IDC_RA_RESULTS_EXPORT,8,127,70,14
     CONTROL         "",IDC_RA_RESULTS,"SysListView32",LVS_REPORT | LVS_SHOWSELALWAYS | LVS_ALIGNLEFT | LVS_OWNERDATA | LVS_NOCOLUMNHEADER | WS_BORDER | WS_VSCROLL | WS_TABSTOP,82,55,242,86
     GROUPBOX        "Code Notes",IDC_RA_GBX_NOTES,4,145,324,55
-    LTEXT           "Address:",IDC_STATIC,8,155,32,9
+    LTEXT           "&Address:",IDC_STATIC,8,155,32,9
     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
@@ -230,7 +231,7 @@ BEGIN
 END
 
 IDD_RA_RICHPRESENCE DIALOGEX 0, 0, 149, 50
-STYLE DS_SETFONT | DS_MODALFRAME | DS_FIXEDSYS | WS_POPUP | WS_CAPTION | WS_SYSMENU
+STYLE DS_SETFONT | WS_POPUP | WS_CAPTION | WS_SYSMENU | WS_THICKFRAME
 CAPTION "Rich Presence Monitor"
 FONT 8, "MS Shell Dlg", 400, 0, 0x1
 BEGIN
diff --git a/src/data/Types.hh b/src/data/Types.hh
index c66bd745..8201370b 100644
--- a/src/data/Types.hh
+++ b/src/data/Types.hh
@@ -24,6 +24,7 @@ enum class MemSize : uint8_t
     ThirtyTwoBitBigEndian,
     Float,
     MBF32,
+    MBF32LE,
 
     // extended sizes not supported by rcheevos
     Unknown,
@@ -74,6 +75,7 @@ constexpr unsigned int MemSizeBits(MemSize nSize)
         case MemSize::ThirtyTwoBitBigEndian:
         case MemSize::Float:
         case MemSize::MBF32:
+        case MemSize::MBF32LE:
             return 32;
 
         case MemSize::EightBit:
@@ -124,6 +126,7 @@ constexpr unsigned int MemSizeBytes(MemSize nSize)
         case MemSize::ThirtyTwoBitBigEndian:
         case MemSize::Float:
         case MemSize::MBF32:
+        case MemSize::MBF32LE:
             return 4;
 
         case MemSize::SixteenBit:
diff --git a/src/data/context/EmulatorContext.cpp b/src/data/context/EmulatorContext.cpp
index a98afa0b..14cbd3bb 100644
--- a/src/data/context/EmulatorContext.cpp
+++ b/src/data/context/EmulatorContext.cpp
@@ -834,6 +834,7 @@ uint32_t EmulatorContext::ReadMemory(ra::ByteAddress nAddress, MemSize nSize) co
         }
         case MemSize::Float:
         case MemSize::MBF32:
+        case MemSize::MBF32LE:
         case MemSize::ThirtyTwoBit:
         {
             uint8_t buffer[4];
@@ -917,6 +918,7 @@ void EmulatorContext::WriteMemory(ra::ByteAddress nAddress, MemSize nSize, uint3
         case MemSize::ThirtyTwoBit:
         case MemSize::Float: // assumes the value has already been encoded into a 32-bit value
         case MemSize::MBF32: // assumes the value has already been encoded into a 32-bit value
+        case MemSize::MBF32LE: // assumes the value has already been encoded into a 32-bit value
             WriteMemoryByte(nAddress, nValue & 0xFF);
             nValue >>= 8;
             WriteMemoryByte(++nAddress, nValue & 0xFF);
diff --git a/src/data/context/EmulatorContext.hh b/src/data/context/EmulatorContext.hh
index 97752d5a..d63d8ce1 100644
--- a/src/data/context/EmulatorContext.hh
+++ b/src/data/context/EmulatorContext.hh
@@ -100,7 +100,7 @@ public:
     /// </summary>
     /// <param name="sMessage">A custom message provided by the emulator to also display in the title bar.</param>
     std::wstring GetAppTitle(const std::string& sMessage) const;
-    
+
     /// <summary>
     /// Gets the game title from the emulator.
     /// </summary>
diff --git a/src/data/context/UserContext.cpp b/src/data/context/UserContext.cpp
index 3441e799..26d5e94c 100644
--- a/src/data/context/UserContext.cpp
+++ b/src/data/context/UserContext.cpp
@@ -11,6 +11,7 @@
 
 #include "ui\viewmodels\MessageBoxViewModel.hh"
 #include "ui\viewmodels\OverlayManager.hh"
+#include "ui\viewmodels\WindowManager.hh"
 
 namespace ra {
 namespace data {
@@ -35,9 +36,8 @@ void UserContext::Logout()
         pOverlayManager.HideOverlay();
 
         ra::services::ServiceLocator::Get<ra::services::IConfiguration>().Save();
-#ifndef RA_UTEST
-        _RA_UpdateAppTitle();
-#endif
+
+        ra::services::ServiceLocator::GetMutable<ra::ui::viewmodels::WindowManager>().Emulator.UpdateWindowTitle();
         ra::services::ServiceLocator::Get<ra::data::context::EmulatorContext>().RebuildMenu();
 
         ra::ui::viewmodels::MessageBoxViewModel::ShowInfoMessage(L"You are now logged out.");
diff --git a/src/data/models/CodeNotesModel.cpp b/src/data/models/CodeNotesModel.cpp
index f5ed6dcd..4bb57c14 100644
--- a/src/data/models/CodeNotesModel.cpp
+++ b/src/data/models/CodeNotesModel.cpp
@@ -146,7 +146,6 @@ void CodeNotesModel::ExtractSize(CodeNote& pNote)
             {
                 pNote.Bytes = 4;
                 pNote.MemSize = MemSize::MBF32;
-                return;
             }
             else if (sBits == L"40")
             {
@@ -154,9 +153,32 @@ void CodeNotesModel::ExtractSize(CodeNote& pNote)
                 // it can be read as an MBF32 value with only the loss of the smallest 8-bits of precision.
                 pNote.MemSize = MemSize::MBF32;
                 pNote.Bytes = 5;
-                return;
             }
-            continue;
+            else
+            {
+                continue;
+            }
+
+            // check for LE (little endian)
+            nIndex += 4;
+            while (nIndex < pNote.Note.length() && (pNote.Note.at(nIndex) == ' ' || pNote.Note.at(nIndex) == '-'))
+                ++nIndex;
+            if (nIndex + 1 < pNote.Note.length())
+            {
+                c = pNote.Note.at(nIndex);
+                if (c == 'L' || c == 'l')
+                {
+                    c = pNote.Note.at(++nIndex);
+                    if (c == 'E' || c == 'e')
+                    {
+                        nIndex++;
+                        if (nIndex == pNote.Note.length() || !std::isalpha(pNote.Note.at(nIndex)))
+                            pNote.MemSize = MemSize::MBF32LE;
+                    }
+                }
+            }
+
+            return;
         }
         else
         {
diff --git a/src/data/models/TriggerValidation.cpp b/src/data/models/TriggerValidation.cpp
index 39c8e014..917aa9ff 100644
--- a/src/data/models/TriggerValidation.cpp
+++ b/src/data/models/TriggerValidation.cpp
@@ -36,6 +36,7 @@ MemSize TriggerValidation::MapRcheevosMemSize(char nSize) noexcept
         case RC_MEMSIZE_32_BITS_BE: return MemSize::ThirtyTwoBitBigEndian;
         case RC_MEMSIZE_FLOAT: return MemSize::Float;
         case RC_MEMSIZE_MBF32: return MemSize::MBF32;
+        case RC_MEMSIZE_MBF32_LE: return MemSize::MBF32LE;
         default:
             assert(!"Unsupported operand size");
             return MemSize::EightBit;
diff --git a/src/services/SearchResults.cpp b/src/services/SearchResults.cpp
index e84f6840..ece8a8c4 100644
--- a/src/services/SearchResults.cpp
+++ b/src/services/SearchResults.cpp
@@ -1352,6 +1352,30 @@ protected:
     }
 };
 
+class MBF32LESearchImpl : public FloatSearchImpl
+{
+public:
+    MemSize GetMemSize() const noexcept override { return MemSize::MBF32LE; }
+
+protected:
+    GSL_SUPPRESS_TYPE1
+    float BuildFloatValue(const unsigned char* ptr) const noexcept override
+    {
+        GSL_SUPPRESS_F6 Expects(ptr != nullptr);
+        rc_typed_value_t value;
+        value.type = RC_VALUE_TYPE_UNSIGNED;
+        value.value.u32 = *reinterpret_cast<const unsigned int*>(ptr);
+        rc_transform_memref_value(&value, RC_MEMSIZE_MBF32_LE);
+
+        return value.value.f32;
+    }
+
+    unsigned int DeconstructFloatValue(float fValue) const noexcept override
+    {
+        return ra::data::FloatToU32(fValue, MemSize::MBF32LE);
+    }
+};
+
 
 static FourBitSearchImpl s_pFourBitSearchImpl;
 static EightBitSearchImpl s_pEightBitSearchImpl;
@@ -1364,6 +1388,7 @@ static ThirtyTwoBitBigEndianSearchImpl s_pThirtyTwoBitBigEndianSearchImpl;
 static AsciiTextSearchImpl s_pAsciiTextSearchImpl;
 static FloatSearchImpl s_pFloatSearchImpl;
 static MBF32SearchImpl s_pMBF32SearchImpl;
+static MBF32LESearchImpl s_pMBF32LESearchImpl;
 
 uint8_t* MemBlock::AllocateMatchingAddresses() noexcept
 {
@@ -1566,6 +1591,9 @@ void SearchResults::Initialize(ra::ByteAddress nAddress, size_t nBytes, SearchTy
         case SearchType::MBF32:
             m_pImpl = &ra::services::impl::s_pMBF32SearchImpl;
             break;
+        case SearchType::MBF32LE:
+            m_pImpl = &ra::services::impl::s_pMBF32LESearchImpl;
+            break;
     }
 
     const unsigned int nPadding = m_pImpl->GetPadding();
diff --git a/src/services/SearchResults.h b/src/services/SearchResults.h
index 3499ef77..171026e0 100644
--- a/src/services/SearchResults.h
+++ b/src/services/SearchResults.h
@@ -21,6 +21,7 @@ enum class SearchType
     ThirtyTwoBitBigEndian,
     Float,
     MBF32,
+    MBF32LE,
     AsciiText,
 };
 
diff --git a/src/ui/viewmodels/AssetListViewModel.cpp b/src/ui/viewmodels/AssetListViewModel.cpp
index aaf49f15..6ef9529e 100644
--- a/src/ui/viewmodels/AssetListViewModel.cpp
+++ b/src/ui/viewmodels/AssetListViewModel.cpp
@@ -1180,8 +1180,8 @@ static std::wstring ValidateCondSet(const rc_condset_t* pCondSet)
         /* rc_condition_t validation goes here */
 #if RA_INTEGRATION_VERSION_LESS_THAN(1,0,5)
 #define VALIDATE_PRERELEASE_FUNCTIONALITY
-//        if (pCondition->operand1.size == RC_MEMSIZE_MBF32_LE || pCondition->operand2.size == RC_MEMSIZE_MBF32_LE)
-//            return L"MBF32 LE size is pre-release functionality";
+        if (pCondition->operand1.size == RC_MEMSIZE_MBF32_LE || pCondition->operand2.size == RC_MEMSIZE_MBF32_LE)
+            return L"MBF32 LE size is pre-release functionality";
 #endif
     }
 
diff --git a/src/ui/viewmodels/EmulatorViewModel.cpp b/src/ui/viewmodels/EmulatorViewModel.cpp
new file mode 100644
index 00000000..ab3b6ab4
--- /dev/null
+++ b/src/ui/viewmodels/EmulatorViewModel.cpp
@@ -0,0 +1,22 @@
+#include "EmulatorViewModel.hh"
+
+#include "data/context/EmulatorContext.hh"
+
+#include "services/ServiceLocator.hh"
+
+namespace ra {
+namespace ui {
+namespace viewmodels {
+
+void EmulatorViewModel::UpdateWindowTitle()
+{
+    if (!m_bAppTitleManaged)
+        return;
+
+    auto sTitle = ra::services::ServiceLocator::Get<ra::data::context::EmulatorContext>().GetAppTitle(m_sAppTitleMessage);
+    SetWindowTitle(sTitle);
+}
+
+} // namespace viewmodels
+} // namespace ui
+} // namespace ra
diff --git a/src/ui/viewmodels/EmulatorViewModel.hh b/src/ui/viewmodels/EmulatorViewModel.hh
index 118e35e2..629d72db 100644
--- a/src/ui/viewmodels/EmulatorViewModel.hh
+++ b/src/ui/viewmodels/EmulatorViewModel.hh
@@ -10,6 +10,25 @@ namespace viewmodels {
 
 class EmulatorViewModel : public WindowViewModelBase
 {
+public:
+    /// <summary>
+    /// Sets the additional text to display in the application title bar.
+    /// </summary>
+    void SetAppTitleMessage(const std::string& sValue)
+    {
+        m_bAppTitleManaged = true;
+        m_sAppTitleMessage = sValue;
+
+        UpdateWindowTitle();
+    }
+
+    bool IsAppTitleManaged() const noexcept { return m_bAppTitleManaged; }
+
+    void UpdateWindowTitle();
+
+private:
+    bool m_bAppTitleManaged = false;
+    std::string m_sAppTitleMessage;
 };
 
 } // namespace viewmodels
diff --git a/src/ui/viewmodels/LoginViewModel.cpp b/src/ui/viewmodels/LoginViewModel.cpp
index 470e84ba..b8bd7db8 100644
--- a/src/ui/viewmodels/LoginViewModel.cpp
+++ b/src/ui/viewmodels/LoginViewModel.cpp
@@ -13,6 +13,7 @@
 #include "services\IConfiguration.hh"
 
 #include "ui\viewmodels\MessageBoxViewModel.hh"
+#include "ui\viewmodels\WindowManager.hh"
 
 namespace ra {
 namespace ui {
@@ -79,9 +80,7 @@ bool LoginViewModel::Login() const
         std::wstring(L"Successfully logged in as ") + ra::Widen(response.Username));
 
     ra::services::ServiceLocator::Get<ra::data::context::EmulatorContext>().RebuildMenu();
-#ifndef RA_UTEST
-    _RA_UpdateAppTitle();
-#endif
+    ra::services::ServiceLocator::GetMutable<ra::ui::viewmodels::WindowManager>().Emulator.UpdateWindowTitle();
 
     return true;
 }
diff --git a/src/ui/viewmodels/MemoryBookmarksViewModel.cpp b/src/ui/viewmodels/MemoryBookmarksViewModel.cpp
index 8e7dc735..db25628a 100644
--- a/src/ui/viewmodels/MemoryBookmarksViewModel.cpp
+++ b/src/ui/viewmodels/MemoryBookmarksViewModel.cpp
@@ -62,6 +62,7 @@ MemoryBookmarksViewModel::MemoryBookmarksViewModel() noexcept
     m_vSizes.Add(ra::etoi(MemSize::Nibble_Upper), L"Upper4");
     m_vSizes.Add(ra::etoi(MemSize::Float), L"Float");
     m_vSizes.Add(ra::etoi(MemSize::MBF32), L"MBF32");
+    m_vSizes.Add(ra::etoi(MemSize::MBF32LE), L"MBF32 LE");
     m_vSizes.Add(ra::etoi(MemSize::Text), L"ASCII");
 
     m_vFormats.Add(ra::etoi(MemFormat::Hex), L"Hex");
@@ -244,6 +245,7 @@ bool MemoryBookmarksViewModel::MemoryBookmarkViewModel::SetCurrentValue(const st
     {
         case MemSize::Float:
         case MemSize::MBF32:
+        case MemSize::MBF32LE:
             float fValue;
             if (!ra::ParseFloat(sValue, fValue, sError))
                 return false;
diff --git a/src/ui/viewmodels/MemorySearchViewModel.cpp b/src/ui/viewmodels/MemorySearchViewModel.cpp
index bb7c06f9..157468ec 100644
--- a/src/ui/viewmodels/MemorySearchViewModel.cpp
+++ b/src/ui/viewmodels/MemorySearchViewModel.cpp
@@ -4,9 +4,12 @@
 #include "data\context\EmulatorContext.hh"
 
 #include "services\IClock.hh"
+#include "services\IFileSystem.hh"
 #include "services\ServiceLocator.hh"
 
+#include "ui\viewmodels\FileDialogViewModel.hh"
 #include "ui\viewmodels\MessageBoxViewModel.hh"
+#include "ui\viewmodels\ProgressViewModel.hh"
 #include "ui\viewmodels\WindowManager.hh"
 
 #include "RA_Defs.h"
@@ -101,6 +104,7 @@ MemorySearchViewModel::MemorySearchViewModel()
     m_vSearchTypes.Add(ra::etoi(ra::services::SearchType::ThirtyTwoBitBigEndian), L"32-bit BE");
     m_vSearchTypes.Add(ra::etoi(ra::services::SearchType::Float), L"Float");
     m_vSearchTypes.Add(ra::etoi(ra::services::SearchType::MBF32), L"MBF32");
+    m_vSearchTypes.Add(ra::etoi(ra::services::SearchType::MBF32LE), L"MBF32 LE");
     m_vSearchTypes.Add(ra::etoi(ra::services::SearchType::AsciiText), L"ASCII Text");
 
     m_vComparisonTypes.Add(ra::etoi(ComparisonType::Equals), L"=");
@@ -709,7 +713,6 @@ void MemorySearchViewModel::UpdateResults()
     const auto& pConsoleContext = ra::services::ServiceLocator::Get<ra::data::context::ConsoleContext>();
     const auto& pEmulatorContext = ra::services::ServiceLocator::Get<ra::data::context::EmulatorContext>();
     const auto* pCodeNotes = pGameContext.Assets().FindCodeNotes();
-    Expects(pCodeNotes != nullptr);
 
     m_vResults.RemoveNotifyTarget(*this);
     m_vResults.BeginUpdate();
@@ -747,7 +750,7 @@ void MemorySearchViewModel::UpdateResults()
 
         UpdateResult(*pRow, pCurrentResults.pResults, pResult, true, pEmulatorContext);
 
-        const auto pCodeNote = pCodeNotes->FindCodeNote(pResult.nAddress, pResult.nSize);
+        const auto pCodeNote = (pCodeNotes != nullptr) ? pCodeNotes->FindCodeNote(pResult.nAddress, pResult.nSize) : std::wstring(L"");
         if (!pCodeNote.empty())
         {
             pRow->bHasCodeNote = true;
@@ -1102,6 +1105,117 @@ void MemorySearchViewModel::BookmarkSelected()
         ra::ui::viewmodels::MessageBoxViewModel::ShowInfoMessage(L"Can only create 100 new bookmarks at a time.");
 }
 
+void MemorySearchViewModel::ExportResults() const
+{
+    if (m_vResults.Count() == 0 || m_nSelectedSearchResult == 0)
+    {
+        ra::ui::viewmodels::MessageBoxViewModel::ShowErrorMessage(L"Nothing to export");
+        return;
+    }
+
+    ra::ui::viewmodels::FileDialogViewModel vmFileDialog;
+    vmFileDialog.SetWindowTitle(L"Export Search Results");
+    vmFileDialog.AddFileType(L"CSV File", L"*.csv");
+    vmFileDialog.SetDefaultExtension(L"csv");
+
+    const auto& pGameContext = ra::services::ServiceLocator::Get<ra::data::context::GameContext>();
+    vmFileDialog.SetFileName(ra::StringPrintf(L"%u-SearchResults.csv", pGameContext.GameId()));
+
+    if (vmFileDialog.ShowSaveFileDialog() == ra::ui::DialogResult::OK)
+    {
+        const auto& pFileSystem = ra::services::ServiceLocator::Get<ra::services::IFileSystem>();
+        auto pTextWriter = pFileSystem.CreateTextFile(vmFileDialog.GetFileName());
+        if (pTextWriter == nullptr)
+        {
+            ra::ui::viewmodels::MessageBoxViewModel::ShowErrorMessage(
+                ra::StringPrintf(L"Could not create %s", vmFileDialog.GetFileName()));
+        }
+        else
+        {
+            const auto& pResults = m_vSearchResults.at(m_nSelectedSearchResult).pResults;
+            const auto nResults = pResults.MatchingAddressCount();
+            if (pResults.MatchingAddressCount() > 500)
+            {
+                ra::ui::viewmodels::ProgressViewModel vmProgress;
+                vmProgress.SetMessage(ra::StringPrintf(L"Exporting %d search results", nResults));
+                vmProgress.QueueTask([this, &pTextWriter, &vmProgress]() {
+                    SaveResults(*pTextWriter, [&vmProgress](int nProgress) {
+                        vmProgress.SetProgress(nProgress);
+                        return (vmProgress.GetDialogResult() == ra::ui::DialogResult::None);
+                    });
+                });
+
+                vmProgress.ShowModal(ra::services::ServiceLocator::Get<ra::ui::viewmodels::WindowManager>().MemoryInspector);
+            }
+            else
+            {
+                SaveResults(*pTextWriter, nullptr);
+            }
+        }
+    }
+}
+
+void MemorySearchViewModel::SaveResults(ra::services::TextWriter& sFile, std::function<bool(int)> pProgressCallback) const
+{
+    const auto& pResults = m_vSearchResults.at(m_nSelectedSearchResult).pResults;
+    const auto& pCompareResults = m_vSearchResults.at(m_nSelectedSearchResult - 1).pResults;
+    const auto& pInitialResults = m_vSearchResults.front().pResults;
+    ra::services::SearchResults::Result pResult;
+
+    const auto nResults = pResults.MatchingAddressCount();
+
+    const int nPerPercentage = (nResults > 100) ? gsl::narrow_cast<int>(nResults / 100) : 1;
+
+    sFile.WriteLine("Address,Value,PreviousValue,InitialValue");
+
+    if (pCompareResults.GetSize() == MemSize::Nibble_Lower)
+    {
+        for (gsl::index nIndex = 0; ra::to_unsigned(nIndex) < pResults.MatchingAddressCount(); ++nIndex)
+        {
+            if (!pResults.GetMatchingAddress(nIndex, pResult))
+                continue;
+
+            const auto nSize = (pResult.nAddress & 1) ? MemSize::Nibble_Upper : MemSize::Nibble_Lower;
+            const auto nAddress = pResult.nAddress >> 1;
+
+            sFile.WriteLine(ra::StringPrintf(L"%s%s,%s,%s,%s",
+                ra::ByteAddressToString(nAddress), (pResult.nAddress & 1) ? "U" : "L",
+                pResults.GetFormattedValue(nAddress, nSize),
+                pCompareResults.GetFormattedValue(nAddress, nSize),
+                pInitialResults.GetFormattedValue(nAddress, nSize)));
+
+            if (pProgressCallback != nullptr && nIndex % nPerPercentage == 0)
+            {
+                if (!pProgressCallback(gsl::narrow_cast<int>(nIndex / nPerPercentage)))
+                    break;
+            }
+        }
+    }
+    else
+    {
+        const auto nSize = pCompareResults.GetSize();
+
+        for (gsl::index nIndex = 0; ra::to_unsigned(nIndex) < nResults; ++nIndex)
+        {
+            if (!pResults.GetMatchingAddress(nIndex, pResult))
+                continue;
+
+            const auto nAddress = pResult.nAddress;
+            sFile.WriteLine(ra::StringPrintf(L"%s,%s,%s,%s",
+                ra::ByteAddressToString(nAddress),
+                pResults.GetFormattedValue(nAddress, nSize),
+                pCompareResults.GetFormattedValue(nAddress, nSize),
+                pInitialResults.GetFormattedValue(nAddress, nSize)));
+
+            if (pProgressCallback != nullptr && nIndex % nPerPercentage == 0)
+            {
+                if (!pProgressCallback(gsl::narrow_cast<int>(nIndex / nPerPercentage)))
+                    break;
+            }
+        }
+    }
+}
+
 } // namespace viewmodels
 } // namespace ui
 } // namespace ra
diff --git a/src/ui/viewmodels/MemorySearchViewModel.hh b/src/ui/viewmodels/MemorySearchViewModel.hh
index 87c4a527..2d66c18b 100644
--- a/src/ui/viewmodels/MemorySearchViewModel.hh
+++ b/src/ui/viewmodels/MemorySearchViewModel.hh
@@ -7,6 +7,7 @@
 #include "data\context\GameContext.hh"
 
 #include "services\SearchResults.h"
+#include "services\TextWriter.hh"
 
 #include "ui\WindowViewModelBase.hh"
 
@@ -439,6 +440,11 @@ public:
     /// </summary>
     void BookmarkSelected();
 
+    /// <summary>
+    /// Allows the user to save the results to a CSV file.
+    /// </summary>
+    void ExportResults() const;
+
     std::wstring GetTooltip(const SearchResultViewModel& vmResult) const;
 
 protected:
@@ -455,6 +461,8 @@ protected:
     // GameContext::NotifyTarget
     void OnCodeNoteChanged(ra::ByteAddress nAddress, const std::wstring& sNote) override;
 
+    void SaveResults(ra::services::TextWriter& sFile, std::function<bool(int)> pProgressCallback) const;
+
 private:
     bool ParseFilterRange(_Out_ ra::ByteAddress& nStart, _Out_ ra::ByteAddress& nEnd);
     void ApplyContinuousFilter();
diff --git a/src/ui/viewmodels/ProgressViewModel.hh b/src/ui/viewmodels/ProgressViewModel.hh
index d600e0ae..e1e3ba09 100644
--- a/src/ui/viewmodels/ProgressViewModel.hh
+++ b/src/ui/viewmodels/ProgressViewModel.hh
@@ -52,6 +52,11 @@ public:
     /// </summary>
     const int GetProgress() const { return GetValue(ProgressProperty); }
 
+    /// <summary>
+    /// Sets the current progress (0-100).
+    /// </summary>
+    void SetProgress(int nValue) { SetValue(ProgressProperty, nValue); }
+
     void QueueTask(std::function<void()>&& fTaskHandler);
 
     const size_t TaskCount() const noexcept { return m_vTasks.size(); }
diff --git a/src/ui/viewmodels/TriggerConditionViewModel.cpp b/src/ui/viewmodels/TriggerConditionViewModel.cpp
index 3ea40f01..77d584be 100644
--- a/src/ui/viewmodels/TriggerConditionViewModel.cpp
+++ b/src/ui/viewmodels/TriggerConditionViewModel.cpp
@@ -251,6 +251,13 @@ void TriggerConditionViewModel::SerializeAppendOperand(std::string& sBuffer, Tri
             sBuffer.push_back('M');
             break;
 
+        case MemSize::MBF32LE:
+            sBuffer.pop_back();
+            sBuffer.pop_back();
+            sBuffer.push_back('f');
+            sBuffer.push_back('L');
+            break;
+
         default:
             assert(!"Unknown memory size");
             break;
@@ -757,24 +764,6 @@ bool TriggerConditionViewModel::IsAddressType(TriggerOperandType nType) noexcept
     }
 }
 
-
-bool TriggerConditionViewModel::IsOperandTypeVisible(const ViewModelBase& vmItem, int nValue) noexcept
-{
-    const auto* vmCondition = dynamic_cast<const TriggerConditionViewModel*>(&vmItem);
-    if (vmCondition == nullptr)
-        return false;
-
-    const auto nOperandType = ra::itoe<TriggerOperandType>(nValue);
-    switch (nOperandType)
-    {
-        case TriggerOperandType::Inverted:
-            return vmCondition->IsForValue();
-
-        default:
-            return true;
-    }
-}
-
 bool TriggerConditionViewModel::IsComparisonVisible(const ViewModelBase& vmItem, int nValue)
 {
     const auto* vmCondition = dynamic_cast<const TriggerConditionViewModel*>(&vmItem);
diff --git a/src/ui/viewmodels/TriggerConditionViewModel.hh b/src/ui/viewmodels/TriggerConditionViewModel.hh
index 71c2fe78..fc05d4e1 100644
--- a/src/ui/viewmodels/TriggerConditionViewModel.hh
+++ b/src/ui/viewmodels/TriggerConditionViewModel.hh
@@ -137,7 +137,6 @@ public:
     bool IsModifying() const { return IsModifying(GetType()); }
 
     static bool IsComparisonVisible(const ViewModelBase& vmItem, int nValue);
-    static bool IsOperandTypeVisible(const ViewModelBase& vmItem, int nValue) noexcept;
     static std::wstring FormatValue(unsigned nValue, TriggerOperandType nType);
     static std::wstring FormatValue(float fValue, TriggerOperandType nType);
 
diff --git a/src/ui/viewmodels/TriggerViewModel.cpp b/src/ui/viewmodels/TriggerViewModel.cpp
index 81b7e298..1ac6cf69 100644
--- a/src/ui/viewmodels/TriggerViewModel.cpp
+++ b/src/ui/viewmodels/TriggerViewModel.cpp
@@ -70,6 +70,7 @@ TriggerViewModel::TriggerViewModel() noexcept
     m_vOperandSizes.Add(ra::etoi(MemSize::BitCount), L"BitCount");
     m_vOperandSizes.Add(ra::etoi(MemSize::Float), L"Float");
     m_vOperandSizes.Add(ra::etoi(MemSize::MBF32), L"MBF32");
+    m_vOperandSizes.Add(ra::etoi(MemSize::MBF32LE), L"MBF32 LE");
 
     m_vOperatorTypes.Add(ra::etoi(TriggerOperatorType::Equals), L"=");
     m_vOperatorTypes.Add(ra::etoi(TriggerOperatorType::LessThan), L"<");
@@ -390,6 +391,7 @@ void TriggerViewModel::NewCondition()
     {
         case MemSize::Float:
         case MemSize::MBF32:
+        case MemSize::MBF32LE:
             vmCondition.SetTargetType(TriggerOperandType::Float);
             vmCondition.SetTargetValue(ra::data::U32ToFloat(nValue, nSize));
             break;
diff --git a/src/ui/win32/AssetEditorDialog.cpp b/src/ui/win32/AssetEditorDialog.cpp
index 683d39aa..233ba305 100644
--- a/src/ui/win32/AssetEditorDialog.cpp
+++ b/src/ui/win32/AssetEditorDialog.cpp
@@ -824,7 +824,6 @@ AssetEditorDialog::AssetEditorDialog(AssetEditorViewModel& vmAssetEditor)
         TriggerConditionViewModel::SourceTypeProperty, vmAssetEditor.Trigger().OperandTypes());
     pSourceTypeColumn->SetHeader(L"Type");
     pSourceTypeColumn->SetWidth(ra::ui::win32::bindings::GridColumnBinding::WidthType::Pixels, COLUMN_WIDTH_TYPE);
-    pSourceTypeColumn->SetVisibilityFilter(TriggerConditionViewModel::IsOperandTypeVisible);
     pSourceTypeColumn->SetReadOnly(false);
     m_bindConditions.BindColumn(2, std::move(pSourceTypeColumn));
 
@@ -854,7 +853,6 @@ AssetEditorDialog::AssetEditorDialog(AssetEditorViewModel& vmAssetEditor)
         TriggerConditionViewModel::HasTargetProperty, vmAssetEditor.Trigger().OperandTypes());
     pTargetTypeColumn->SetHeader(L"Type");
     pTargetTypeColumn->SetWidth(ra::ui::win32::bindings::GridColumnBinding::WidthType::Pixels, COLUMN_WIDTH_TYPE);
-    pTargetTypeColumn->SetVisibilityFilter(TriggerConditionViewModel::IsOperandTypeVisible);
     pTargetTypeColumn->SetReadOnly(false);
     m_bindConditions.BindColumn(6, std::move(pTargetTypeColumn));
 
diff --git a/src/ui/win32/MemoryBookmarksDialog.cpp b/src/ui/win32/MemoryBookmarksDialog.cpp
index eb5b6fcb..065757dd 100644
--- a/src/ui/win32/MemoryBookmarksDialog.cpp
+++ b/src/ui/win32/MemoryBookmarksDialog.cpp
@@ -159,6 +159,7 @@ private:
         {
             case MemSize::Float:
             case MemSize::MBF32:
+            case MemSize::MBF32LE:
             case MemSize::Text:
                 return true;
 
@@ -198,7 +199,7 @@ MemoryBookmarksDialog::MemoryBookmarksDialog(MemoryBookmarksViewModel& vmMemoryB
     auto pSizeColumn = std::make_unique<ra::ui::win32::bindings::GridLookupColumnBinding>(
         MemoryBookmarksViewModel::MemoryBookmarkViewModel::SizeProperty, vmMemoryBookmarks.Sizes());
     pSizeColumn->SetHeader(L"Size");
-    pSizeColumn->SetWidth(GridColumnBinding::WidthType::Pixels, 56);
+    pSizeColumn->SetWidth(GridColumnBinding::WidthType::Pixels, 62);
     pSizeColumn->SetAlignment(ra::ui::RelativePosition::Far);
     pSizeColumn->SetReadOnly(false);
     m_bindBookmarks.BindColumn(2, std::move(pSizeColumn));
diff --git a/src/ui/win32/MemoryInspectorDialog.cpp b/src/ui/win32/MemoryInspectorDialog.cpp
index 4ed69709..25f9eaca 100644
--- a/src/ui/win32/MemoryInspectorDialog.cpp
+++ b/src/ui/win32/MemoryInspectorDialog.cpp
@@ -168,6 +168,7 @@ MemoryInspectorDialog::MemoryInspectorDialog(MemoryInspectorViewModel& vmMemoryI
     m_bindWindow.BindEnabled(IDC_RA_SPECIAL_FILTER, MemorySearchViewModel::CanFilterProperty);
     m_bindWindow.BindEnabled(IDC_RA_FILTER_VALUE, MemorySearchViewModel::CanEditFilterValueProperty);
     m_bindWindow.BindEnabled(IDC_RA_APPLY_FILTER, MemorySearchViewModel::CanFilterProperty);
+    m_bindWindow.BindEnabled(IDC_RA_RESULTS_EXPORT, MemorySearchViewModel::CanFilterProperty);
     m_bindWindow.BindEnabled(IDC_RA_CONTINUOUS_FILTER, MemorySearchViewModel::CanContinuousFilterProperty);
     m_bindWindow.BindLabel(IDC_RA_CONTINUOUS_FILTER, MemorySearchViewModel::ContinuousFilterLabelProperty);
 
@@ -386,6 +387,15 @@ BOOL MemoryInspectorDialog::OnCommand(WORD nCommand)
             return TRUE;
         }
 
+        case IDC_RA_RESULTS_EXPORT:
+        {
+            auto* vmMemoryInspector = dynamic_cast<MemoryInspectorViewModel*>(&m_vmWindow);
+            if (vmMemoryInspector)
+                vmMemoryInspector->Search().ExportResults();
+
+            return TRUE;
+        }
+
         case IDC_RA_VIEW_CODENOTES:
         {
             auto* vmMemoryInspector = dynamic_cast<MemoryInspectorViewModel*>(&m_vmWindow);
diff --git a/src/ui/win32/RichPresenceDialog.cpp b/src/ui/win32/RichPresenceDialog.cpp
index e94c1a61..e966c20a 100644
--- a/src/ui/win32/RichPresenceDialog.cpp
+++ b/src/ui/win32/RichPresenceDialog.cpp
@@ -44,6 +44,10 @@ RichPresenceDialog::RichPresenceDialog(ra::ui::viewmodels::RichPresenceMonitorVi
     m_bindWindow.SetInitialPosition(RelativePosition::After, RelativePosition::Far, "Rich Presence Monitor");
     m_bindWindow.BindLabel(IDC_RA_RICHPRESENCERESULTTEXT,
                            ra::ui::viewmodels::RichPresenceMonitorViewModel::DisplayStringProperty);
+
+    using namespace ra::bitwise_ops;
+    SetAnchor(IDC_RA_RICHPRESENCERESULTTEXT, Anchor::Top | Anchor::Left | Anchor::Bottom | Anchor::Right);
+    SetMinimumSize(240, 90);
 }
 
 RichPresenceDialog::~RichPresenceDialog() noexcept
diff --git a/src/ui/win32/bindings/WindowBinding.cpp b/src/ui/win32/bindings/WindowBinding.cpp
index 3985dc9f..3f68361a 100644
--- a/src/ui/win32/bindings/WindowBinding.cpp
+++ b/src/ui/win32/bindings/WindowBinding.cpp
@@ -26,7 +26,7 @@ void WindowBinding::SetHWND(DialogBase* pDialog, HWND hWnd)
     if (m_hWnd)
     {
         // immediately push values from the viewmodel to the UI
-        SetWindowTextW(m_hWnd, GetValue(WindowViewModelBase::WindowTitleProperty).c_str());
+        UpdateAppTitle();
 
         for (auto& pIter : m_mLabelBindings)
         {
@@ -95,6 +95,13 @@ void WindowBinding::SetHWND(DialogBase* pDialog, HWND hWnd)
     }
 }
 
+void WindowBinding::UpdateAppTitle()
+{
+    const auto& pEmulatorViewModel = ra::services::ServiceLocator::Get<ra::ui::viewmodels::WindowManager>().Emulator;
+    if (pEmulatorViewModel.IsAppTitleManaged())
+        SetWindowTextW(m_hWnd, GetValue(WindowViewModelBase::WindowTitleProperty).c_str());
+}
+
 void WindowBinding::SetInitialPosition(RelativePosition nDefaultHorizontalLocation, RelativePosition nDefaultVerticalLocation, const char* sSizeAndPositionKey)
 {
     if (sSizeAndPositionKey)
@@ -278,7 +285,7 @@ void WindowBinding::OnViewModelStringValueChanged(const StringModelProperty::Cha
 {
     if (args.Property == WindowViewModelBase::WindowTitleProperty)
     {
-        SetWindowTextW(m_hWnd, args.tNewValue.c_str());
+        UpdateAppTitle();
         return;
     }
 
diff --git a/src/ui/win32/bindings/WindowBinding.hh b/src/ui/win32/bindings/WindowBinding.hh
index 5fe09544..e0afff74 100644
--- a/src/ui/win32/bindings/WindowBinding.hh
+++ b/src/ui/win32/bindings/WindowBinding.hh
@@ -132,6 +132,7 @@ protected:
     GSL_SUPPRESS_F6 void OnViewModelBoolValueChanged(const BoolModelProperty::ChangeArgs& args) noexcept override;
 
     void RestoreSizeAndPosition();
+    void UpdateAppTitle();
 
 private:
     std::unordered_map<int, int> m_mLabelBindings;


Test patch:
diff --git a/tests/Exports_Tests.cpp b/tests/Exports_Tests.cpp
index 7a87aa06..18566746 100644
--- a/tests/Exports_Tests.cpp
+++ b/tests/Exports_Tests.cpp
@@ -3,6 +3,7 @@
 #include "Exports.hh"
 
 #include "RA_BuildVer.h"
+#include "RA_Emulators.h"
 #include "RA_Resource.h"
 
 #include "tests\mocks\MockAudioSystem.hh"
@@ -190,6 +191,65 @@ public:
         // menu
         Assert::IsTrue(bWasMenuRebuilt);
 
+        // titlebar (should not be updated unless _RA_UpdateAppTitle was called first)
+        Assert::AreEqual(std::wstring(L"Window"), harness.mockWindowManager.Emulator.GetWindowTitle());
+    }
+
+    TEST_METHOD(TestAttemptLoginSuccessTitlebar)
+    {
+        AttemptLoginHarness harness;
+
+        bool bWasMenuRebuilt = false;
+        harness.mockEmulatorContext.SetRebuildMenuFunction([&bWasMenuRebuilt]() { bWasMenuRebuilt = true; });
+        harness.mockEmulatorContext.MockClient("RATests", "0.1.2.0");
+
+        bool bLoggedIn = false;
+        harness.mockServer.HandleRequest<api::Login>([&bLoggedIn](const ra::api::Login::Request& request, ra::api::Login::Response& response)
+        {
+            Assert::AreEqual(std::string("User"), request.Username);
+            Assert::AreEqual(std::string("ApiToken"), request.ApiToken);
+            bLoggedIn = true;
+
+            response.Username = "User";
+            response.DisplayName = "UserDisplay";
+            response.ApiToken = "ApiToken";
+            response.Score = 12345U;
+            response.Result = ra::api::ApiResult::Success;
+            return true;
+        });
+
+        harness.mockConfiguration.SetUsername("User");
+        harness.mockConfiguration.SetApiToken("ApiToken");
+
+        Assert::IsFalse(harness.mockUserContext.IsLoggedIn());
+
+        _RA_UpdateAppTitle(""); // enable automatic update of titlebar
+        _RA_AttemptLogin(true);
+
+        // user context
+        Assert::IsTrue(harness.mockUserContext.IsLoggedIn());
+        Assert::AreEqual(std::string("User"), harness.mockUserContext.GetUsername());
+        Assert::AreEqual(std::string("UserDisplay"), harness.mockUserContext.GetDisplayName());
+        Assert::AreEqual(std::string("ApiToken"), harness.mockUserContext.GetApiToken());
+        Assert::AreEqual(12345U, harness.mockUserContext.GetScore());
+
+        // session context
+        Assert::AreEqual(std::wstring(L"User"), harness.mockSessionTracker.GetUsername());
+
+        // popup notification and sound
+        Assert::IsTrue(harness.mockAudioSystem.WasAudioFilePlayed(L"Overlay\\login.wav"));
+        const auto* pPopup = harness.mockOverlayManager.GetMessage(1);
+        Assert::IsNotNull(pPopup);
+        Ensures(pPopup != nullptr);
+        Assert::AreEqual(std::wstring(L"Welcome UserDisplay"), pPopup->GetTitle());
+        Assert::AreEqual(std::wstring(L"You have 0 new messages"), pPopup->GetDescription());
+        Assert::AreEqual(std::wstring(L"12345 points"), pPopup->GetDetail());
+        Assert::AreEqual(ra::ui::ImageType::UserPic, pPopup->GetImage().Type());
+        Assert::AreEqual(std::string("User"), pPopup->GetImage().Name());
+
+        // menu
+        Assert::IsTrue(bWasMenuRebuilt);
+
         // titlebar
         Assert::AreEqual(std::wstring(L"RATests - 0.1 - UserDisplay []"), harness.mockWindowManager.Emulator.GetWindowTitle());
     }
@@ -1053,6 +1113,29 @@ private:
         AssertMenuItem(&menu[19], IDM_RA_REPORTBROKENACHIEVEMENTS, L"&Report Achievement Problem");
         AssertMenuItem(&menu[20], IDM_RA_GETROMCHECKSUM, L"View Game H&ash");
     }
+
+    TEST_METHOD(TestUpdateAppTitle)
+    {
+        ra::ui::viewmodels::mocks::MockWindowManager mockWindowManager;
+        ra::data::context::mocks::MockEmulatorContext mockEmulatorContext;
+        ra::data::context::mocks::MockUserContext mockUserContext;
+        ra::services::mocks::MockConfiguration mockConfiguration;
+
+        mockUserContext.Initialize("User", "TOKEN");
+        mockEmulatorContext.MockClient("MyClient", "1.2.3");
+        mockConfiguration.SetHostName("retroachievements.org");
+
+        Assert::AreEqual(std::wstring(L"Window"), mockWindowManager.Emulator.GetWindowTitle());
+
+        _RA_UpdateAppTitle("");
+        Assert::AreEqual(std::wstring(L"MyClient - 1.2 - User_"), mockWindowManager.Emulator.GetWindowTitle());
+
+        _RA_UpdateAppTitle("MyGame");
+        Assert::AreEqual(std::wstring(L"MyClient - 1.2 - MyGame - User_"), mockWindowManager.Emulator.GetWindowTitle());
+
+        _RA_UpdateAppTitle("");
+        Assert::AreEqual(std::wstring(L"MyClient - 1.2 - User_"), mockWindowManager.Emulator.GetWindowTitle());
+    }
 };
 
 } // namespace tests
diff --git a/tests/RA_Integration.Tests.vcxproj b/tests/RA_Integration.Tests.vcxproj
index e3380f7b..78c10887 100644
--- a/tests/RA_Integration.Tests.vcxproj
+++ b/tests/RA_Integration.Tests.vcxproj
@@ -418,6 +418,7 @@
     <ClCompile Include="..\src\ui\viewmodels\BrokenAchievementsViewModel.cpp" />
     <ClCompile Include="..\src\ui\viewmodels\ChallengeIndicatorViewModel.cpp" />
     <ClCompile Include="..\src\ui\viewmodels\CodeNotesViewModel.cpp" />
+    <ClCompile Include="..\src\ui\viewmodels\EmulatorViewModel.cpp" />
     <ClCompile Include="..\src\ui\viewmodels\FileDialogViewModel.cpp" />
     <ClCompile Include="..\src\ui\viewmodels\IntegrationMenuViewModel.cpp" />
     <ClCompile Include="..\src\ui\viewmodels\LookupItemViewModel.cpp" />
diff --git a/tests/RA_Integration.Tests.vcxproj.filters b/tests/RA_Integration.Tests.vcxproj.filters
index 2a4ca9f3..54420dda 100644
--- a/tests/RA_Integration.Tests.vcxproj.filters
+++ b/tests/RA_Integration.Tests.vcxproj.filters
@@ -444,6 +444,9 @@
     <ClCompile Include="..\src\data\models\CodeNotesModel.cpp">
       <Filter>Code</Filter>
     </ClCompile>
+    <ClCompile Include="..\src\ui\viewmodels\EmulatorViewModel.cpp">
+      <Filter>Code</Filter>
+    </ClCompile>
   </ItemGroup>
   <ItemGroup>
     <None Include="base.props" />
diff --git a/tests/RA_UnitTestHelpers.h b/tests/RA_UnitTestHelpers.h
index ca5538da..89e54764 100644
--- a/tests/RA_UnitTestHelpers.h
+++ b/tests/RA_UnitTestHelpers.h
@@ -81,6 +81,8 @@ std::wstring ToString<MemSize>(const MemSize& t)
             return L"Float";
         case MemSize::MBF32:
             return L"MBF32";
+        case MemSize::MBF32LE:
+            return L"MBF32LE";
         case MemSize::Text:
             return L"Text";
         case MemSize::Unknown:
diff --git a/tests/data/models/CodeNotesModel_Tests.cpp b/tests/data/models/CodeNotesModel_Tests.cpp
index c99ee908..3e156aa5 100644
--- a/tests/data/models/CodeNotesModel_Tests.cpp
+++ b/tests/data/models/CodeNotesModel_Tests.cpp
@@ -237,6 +237,8 @@ public:
         TestCodeNoteSize(L"[40-bit MBF] Test", 5U, MemSize::MBF32);
         TestCodeNoteSize(L"[MBF] Test", 1U, MemSize::Unknown);
         TestCodeNoteSize(L"Test MBF32", 4U, MemSize::MBF32);
+        TestCodeNoteSize(L"[MBF32 LE] Test", 4U, MemSize::MBF32LE);
+        TestCodeNoteSize(L"[MBF40-LE] Test", 5U, MemSize::MBF32LE);
 
         TestCodeNoteSize(L"42=bitten", 1U, MemSize::Unknown);
         TestCodeNoteSize(L"42-bitten", 1U, MemSize::Unknown);
diff --git a/tests/services/SearchResults_Tests.cpp b/tests/services/SearchResults_Tests.cpp
index 763e7629..9371f583 100644
--- a/tests/services/SearchResults_Tests.cpp
+++ b/tests/services/SearchResults_Tests.cpp
@@ -1605,6 +1605,38 @@ public:
         Assert::AreEqual(0x00008080U, result.nValue);
     }
 
+    TEST_METHOD(TestInitializeFromMemoryMBF32LE)
+    {
+        std::array<unsigned char, 8> memory{ 0x00, 0x00, 0x46, 0x87, 0x00, 0x00, 0x80, 0x80 }; // 99.0, -0.5
+        ra::data::context::mocks::MockEmulatorContext mockEmulatorContext;
+        mockEmulatorContext.MockMemory(memory);
+
+        SearchResults results;
+        results.Initialize(0U, memory.size(), ra::services::SearchType::MBF32LE);
+        Assert::AreEqual({ 5U }, results.MatchingAddressCount());
+
+        Assert::IsTrue(results.ContainsAddress(0U));
+        Assert::IsTrue(results.ContainsAddress(1U));
+        Assert::IsTrue(results.ContainsAddress(2U));
+        Assert::IsTrue(results.ContainsAddress(3U));
+        Assert::IsTrue(results.ContainsAddress(4U));
+        Assert::IsFalse(results.ContainsAddress(5U));
+        Assert::IsFalse(results.ContainsAddress(6U));
+        Assert::IsFalse(results.ContainsAddress(7U));
+
+        // nValue is an MBF32 encoded float
+        SearchResults::Result result;
+        Assert::IsTrue(results.GetMatchingAddress(0U, result));
+        Assert::AreEqual(0U, result.nAddress);
+        Assert::AreEqual(MemSize::MBF32LE, result.nSize);
+        Assert::AreEqual(0x87460000U, result.nValue);
+
+        Assert::IsTrue(results.GetMatchingAddress(4U, result));
+        Assert::AreEqual(4U, result.nAddress);
+        Assert::AreEqual(MemSize::MBF32LE, result.nSize);
+        Assert::AreEqual(0x80800000U, result.nValue);
+    }
+
     TEST_METHOD(TestInitializeFromResultsAsciiText)
     {
         std::array<unsigned char, 36> memory{
@@ -1845,6 +1877,52 @@ public:
         Assert::AreEqual(0x00008000U, result.nValue);
     }
 
+    TEST_METHOD(TestInitializeFromResultsMBF32LEEqualsConstant)
+    {
+        std::array<unsigned char, 8> memory{ 0x00, 0x00, 0x46, 0x87, 0x00, 0x00, 0x80, 0x80 }; // 99.0, -0.5
+        ra::data::context::mocks::MockEmulatorContext mockEmulatorContext;
+        mockEmulatorContext.MockMemory(memory);
+
+        SearchResults results;
+        results.Initialize(0U, memory.size(), ra::services::SearchType::MBF32LE);
+        Assert::AreEqual({ 5U }, results.MatchingAddressCount());
+
+        SearchResults results2;
+        results2.Initialize(results, ComparisonType::Equals, SearchFilterType::Constant, L"-0.5");
+        Assert::AreEqual({ 1U }, results2.MatchingAddressCount());
+
+        // nValue is an MBF32 encoded float
+        SearchResults::Result result;
+        Assert::IsTrue(results2.GetMatchingAddress(0U, result));
+        Assert::AreEqual(4U, result.nAddress);
+        Assert::AreEqual(MemSize::MBF32LE, result.nSize);
+        Assert::AreEqual(0x80800000U, result.nValue);
+    }
+
+    TEST_METHOD(TestInitializeFromResultsMBF32LEGreaterThanLast)
+    {
+        std::array<unsigned char, 8> memory{ 0x00, 0x00, 0x46, 0x87, 0x00, 0x00, 0x80, 0x80 }; // 99.0, -0.5
+        ra::data::context::mocks::MockEmulatorContext mockEmulatorContext;
+        mockEmulatorContext.MockMemory(memory);
+
+        SearchResults results;
+        results.Initialize(0U, memory.size(), ra::services::SearchType::MBF32LE);
+        Assert::AreEqual({ 5U }, results.MatchingAddressCount());
+
+        memory.at(7) = 0x00; // -0.5 -> -0.0
+
+        SearchResults results2;
+        results2.Initialize(results, ComparisonType::GreaterThan, SearchFilterType::LastKnownValue, L"");
+        Assert::AreEqual({ 1U }, results2.MatchingAddressCount());
+
+        // nValue is an MBF32 encoded float
+        SearchResults::Result result;
+        Assert::IsTrue(results2.GetMatchingAddress(0U, result));
+        Assert::AreEqual(4U, result.nAddress);
+        Assert::AreEqual(MemSize::MBF32LE, result.nSize);
+        Assert::AreEqual(0x00800000U, result.nValue);
+    }
+
     TEST_METHOD(TestCopyConstructor)
     {
         std::array<unsigned char, 5> memory{0x00, 0x12, 0x34, 0xAB, 0x56};
@@ -1953,7 +2031,7 @@ public:
 
         Assert::AreEqual(std::wstring(L"-2.0"), results1.GetFormattedValue(0U, MemSize::Float));
         Assert::AreEqual(std::wstring(L"12.375"), results1.GetFormattedValue(4U, MemSize::Float));
-        Assert::AreEqual(std::wstring(L"5.91191e-39"), results1.GetFormattedValue(2U, MemSize::Float));
+        Assert::AreEqual(std::wstring(L"6.88766e-41"), results1.GetFormattedValue(2U, MemSize::Float));
         Assert::AreEqual(std::wstring(L"8192.1875"), results1.GetFormattedValue(3U, MemSize::Float));
     }
 
diff --git a/tests/ui/viewmodels/LoginViewModel_Tests.cpp b/tests/ui/viewmodels/LoginViewModel_Tests.cpp
index 2dec1968..2816ea2b 100644
--- a/tests/ui/viewmodels/LoginViewModel_Tests.cpp
+++ b/tests/ui/viewmodels/LoginViewModel_Tests.cpp
@@ -9,6 +9,7 @@
 #include "tests\mocks\MockServer.hh"
 #include "tests\mocks\MockSessionTracker.hh"
 #include "tests\mocks\MockUserContext.hh"
+#include "tests\mocks\MockWindowManager.hh"
 
 using namespace Microsoft::VisualStudio::CppUnitTestFramework;
 using ra::api::mocks::MockServer;
@@ -37,6 +38,7 @@ private:
         MockUserContext mockUserContext;
         MockEmulatorContext mockEmulatorContext;
         MockSessionTracker mockSessionTracker;
+        ra::ui::viewmodels::mocks::MockWindowManager mockWindowManager;
     };
 
 public:
@@ -98,6 +100,7 @@ public:
         vmLogin.mockServer.HandleRequest<ra::api::Login>([](_UNUSED const ra::api::Login::Request&, ra::api::Login::Response& response)
         {
             response.Username = "User";
+            response.DisplayName = "UserDisplay";
             response.ApiToken = "ApiToken";
             response.Score = 12345;
             response.Result = ra::api::ApiResult::Success;
@@ -133,6 +136,58 @@ public:
 
         // emulator should have been notified to rebuild the RetroAchievements menu
         Assert::IsTrue(bWasMenuRebuilt);
+
+        // app title should not be updated as _RA_UpdateAppTitle was not previously called
+        Assert::AreEqual(std::wstring(L"Window"), vmLogin.mockWindowManager.Emulator.GetWindowTitle());
+    }
+
+    TEST_METHOD(TestLoginSuccessfulAppTitle)
+    {
+        LoginViewModelHarness vmLogin;
+        vmLogin.mockServer.HandleRequest<ra::api::Login>([](_UNUSED const ra::api::Login::Request&, ra::api::Login::Response& response)
+        {
+            response.Username = "User";
+            response.DisplayName = "UserDisplay";
+            response.ApiToken = "ApiToken";
+            response.Score = 12345;
+            response.Result = ra::api::ApiResult::Success;
+            return true;
+        });
+        vmLogin.mockDesktop.ExpectWindow<MessageBoxViewModel>([](MessageBoxViewModel& vmMessageBox)
+        {
+            Assert::AreEqual(std::wstring(L"Successfully logged in as User"), vmMessageBox.GetMessage());
+            Assert::AreEqual(MessageBoxViewModel::Icon::Info, vmMessageBox.GetIcon());
+            return DialogResult::OK;
+        });
+        bool bWasMenuRebuilt = false;
+        vmLogin.mockEmulatorContext.SetRebuildMenuFunction([&bWasMenuRebuilt] { bWasMenuRebuilt = true; });
+        vmLogin.mockEmulatorContext.MockClient("RATests", "0.1.2.0");
+        vmLogin.mockWindowManager.Emulator.SetAppTitleMessage("Test");
+
+        vmLogin.SetUsername(L"user");
+        vmLogin.SetPassword(L"Pa$$w0rd");
+        Assert::IsTrue(vmLogin.Login());
+        Assert::IsTrue(vmLogin.mockDesktop.WasDialogShown());
+
+        // expect case of username to be corrected by API response
+        Assert::AreEqual(std::string("User"), vmLogin.mockConfiguration.GetUsername());
+
+        // API token should not be set unless "remember me" is checked
+        Assert::AreEqual(std::string(""), vmLogin.mockConfiguration.GetApiToken());
+
+        // values should also be updated in UserContext, including API token and score
+        Assert::AreEqual(std::string("User"), vmLogin.mockUserContext.GetUsername());
+        Assert::AreEqual(std::string("ApiToken"), vmLogin.mockUserContext.GetApiToken());
+        Assert::AreEqual(12345U, vmLogin.mockUserContext.GetScore());
+
+        // session tracker should know user name
+        Assert::AreEqual(std::wstring(L"User"), vmLogin.mockSessionTracker.GetUsername());
+
+        // emulator should have been notified to rebuild the RetroAchievements menu
+        Assert::IsTrue(bWasMenuRebuilt);
+
+        // app title should be updated as _RA_UpdateAppTitle was previously called
+        Assert::AreEqual(std::wstring(L"RATests - 0.1 - Test - UserDisplay []"), vmLogin.mockWindowManager.Emulator.GetWindowTitle());
     }
 
     TEST_METHOD(TestLoginInvalidPassword)
diff --git a/tests/ui/viewmodels/MemoryBookmarksViewModel_Tests.cpp b/tests/ui/viewmodels/MemoryBookmarksViewModel_Tests.cpp
index d3cabc6b..deee691b 100644
--- a/tests/ui/viewmodels/MemoryBookmarksViewModel_Tests.cpp
+++ b/tests/ui/viewmodels/MemoryBookmarksViewModel_Tests.cpp
@@ -84,7 +84,7 @@ public:
 
         Assert::AreEqual({ 0U }, bookmarks.Bookmarks().Count());
 
-        Assert::AreEqual({ 13U }, bookmarks.Sizes().Count());
+        Assert::AreEqual({ 14U }, bookmarks.Sizes().Count());
         Assert::AreEqual((int)MemSize::EightBit, bookmarks.Sizes().GetItemAt(0)->GetId());
         Assert::AreEqual(std::wstring(L" 8-bit"), bookmarks.Sizes().GetItemAt(0)->GetLabel());
         Assert::AreEqual((int)MemSize::SixteenBit, bookmarks.Sizes().GetItemAt(1)->GetId());
@@ -109,8 +109,10 @@ public:
         Assert::AreEqual(std::wstring(L"Float"), bookmarks.Sizes().GetItemAt(10)->GetLabel());
         Assert::AreEqual((int)MemSize::MBF32, bookmarks.Sizes().GetItemAt(11)->GetId());
         Assert::AreEqual(std::wstring(L"MBF32"), bookmarks.Sizes().GetItemAt(11)->GetLabel());
-        Assert::AreEqual((int)MemSize::Text, bookmarks.Sizes().GetItemAt(12)->GetId());
-        Assert::AreEqual(std::wstring(L"ASCII"), bookmarks.Sizes().GetItemAt(12)->GetLabel());
+        Assert::AreEqual((int)MemSize::MBF32LE, bookmarks.Sizes().GetItemAt(12)->GetId());
+        Assert::AreEqual(std::wstring(L"MBF32 LE"), bookmarks.Sizes().GetItemAt(12)->GetLabel());
+        Assert::AreEqual((int)MemSize::Text, bookmarks.Sizes().GetItemAt(13)->GetId());
+        Assert::AreEqual(std::wstring(L"ASCII"), bookmarks.Sizes().GetItemAt(13)->GetLabel());
 
         Assert::AreEqual({ 2U }, bookmarks.Formats().Count());
         Assert::AreEqual((int)MemFormat::Hex, bookmarks.Formats().GetItemAt(0)->GetId());
diff --git a/tests/ui/viewmodels/MemorySearchViewModel_Tests.cpp b/tests/ui/viewmodels/MemorySearchViewModel_Tests.cpp
index 9f0044b7..0db84252 100644
--- a/tests/ui/viewmodels/MemorySearchViewModel_Tests.cpp
+++ b/tests/ui/viewmodels/MemorySearchViewModel_Tests.cpp
@@ -1,5 +1,6 @@
 #include "CppUnitTest.h"
 
+#include "ui\viewmodels\FileDialogViewModel.hh"
 #include "ui\viewmodels\MemorySearchViewModel.hh"
 
 #include "tests\RA_UnitTestHelpers.h"
@@ -10,6 +11,7 @@
 #include "tests\mocks\MockConsoleContext.hh"
 #include "tests\mocks\MockDesktop.hh"
 #include "tests\mocks\MockEmulatorContext.hh"
+#include "tests\mocks\MockFileSystem.hh"
 #include "tests\mocks\MockGameContext.hh"
 #include "tests\mocks\MockUserContext.hh"
 #include "tests\mocks\MockWindowManager.hh"
@@ -92,6 +94,7 @@ private:
         ra::data::context::mocks::MockUserContext mockUserContext;
         ra::services::mocks::MockClock mockClock;
         ra::services::mocks::MockConfiguration mockConfiguration;
+        ra::services::mocks::MockFileSystem mockFileSystem;
         ra::ui::mocks::MockDesktop mockDesktop;
         ra::ui::viewmodels::mocks::MockWindowManager mockWindowManager;
 
@@ -155,7 +158,7 @@ public:
         Assert::AreEqual(std::wstring(L""), search.GetFilterRange());
         Assert::AreEqual(std::wstring(L""), search.GetFilterValue());
 
-        Assert::AreEqual({ 11U }, search.SearchTypes().Count());
+        Assert::AreEqual({ 12U }, search.SearchTypes().Count());
         Assert::AreEqual((int)ra::services::SearchType::FourBit, search.SearchTypes().GetItemAt(0)->GetId());
         Assert::AreEqual(std::wstring(L"4-bit"), search.SearchTypes().GetItemAt(0)->GetLabel());
         Assert::AreEqual((int)ra::services::SearchType::EightBit, search.SearchTypes().GetItemAt(1)->GetId());
@@ -176,8 +179,10 @@ public:
         Assert::AreEqual(std::wstring(L"Float"), search.SearchTypes().GetItemAt(8)->GetLabel());
         Assert::AreEqual((int)ra::services::SearchType::MBF32, search.SearchTypes().GetItemAt(9)->GetId());
         Assert::AreEqual(std::wstring(L"MBF32"), search.SearchTypes().GetItemAt(9)->GetLabel());
-        Assert::AreEqual((int)ra::services::SearchType::AsciiText, search.SearchTypes().GetItemAt(10)->GetId());
-        Assert::AreEqual(std::wstring(L"ASCII Text"), search.SearchTypes().GetItemAt(10)->GetLabel());
+        Assert::AreEqual((int)ra::services::SearchType::MBF32LE, search.SearchTypes().GetItemAt(10)->GetId());
+        Assert::AreEqual(std::wstring(L"MBF32 LE"), search.SearchTypes().GetItemAt(10)->GetLabel());
+        Assert::AreEqual((int)ra::services::SearchType::AsciiText, search.SearchTypes().GetItemAt(11)->GetId());
+        Assert::AreEqual(std::wstring(L"ASCII Text"), search.SearchTypes().GetItemAt(11)->GetLabel());
         Assert::AreEqual(ra::services::SearchType::EightBit, search.GetSearchType());
 
         Assert::AreEqual({ 6U }, search.ComparisonTypes().Count());
@@ -1802,6 +1807,152 @@ public:
         search.DoFrame();
         Assert::AreEqual(std::wstring(L"0x0002\nNEAT | Current\nNEXT | Last Filter\nTEST | Initial"), search.GetTooltip(vmResult2));
     }
+
+    TEST_METHOD(TestExportResults)
+    {
+        MemorySearchViewModelHarness search;
+        search.mockGameContext.SetGameId(3);
+        search.InitializeMemory();
+        search.BeginNewSearch();
+
+        search.SetComparisonType(ComparisonType::NotEqualTo);
+        search.SetValueType(ra::services::SearchFilterType::LastKnownValue);
+        search.memory.at(5) = 8;
+        search.memory.at(12) = 9;
+
+        search.ApplyFilter();
+        Assert::AreEqual({2U}, search.Results().Count());
+        AssertRow(search, 0, 5U, L"0x0005", L"0x08");
+        AssertRow(search, 1, 12U, L"0x000c", L"0x09");
+
+        search.memory.at(5) = 5;
+        search.memory.at(12) = 7;
+        search.ApplyFilter();
+        Assert::AreEqual({2U}, search.Results().Count());
+        AssertRow(search, 0, 5U, L"0x0005", L"0x05");
+        AssertRow(search, 1, 12U, L"0x000c", L"0x07");
+
+        bool bDialogSeen = false;
+        search.mockDesktop.ExpectWindow<ra::ui::viewmodels::FileDialogViewModel>(
+            [&bDialogSeen](ra::ui::viewmodels::FileDialogViewModel& vmFileDialog) {
+                bDialogSeen = true;
+
+                Assert::AreEqual(std::wstring(L"Export Search Results"), vmFileDialog.GetWindowTitle());
+                Assert::AreEqual({1U}, vmFileDialog.GetFileTypes().size());
+                Assert::AreEqual(std::wstring(L"csv"), vmFileDialog.GetDefaultExtension());
+                Assert::AreEqual(std::wstring(L"3-SearchResults.csv"), vmFileDialog.GetFileName());
+
+                vmFileDialog.SetFileName(L"E:\\Data\\3-SearchResults.csv");
+
+                return DialogResult::OK;
+            });
+
+        search.ExportResults();
+
+        Assert::IsTrue(bDialogSeen);
+        const std::string& sContents = search.mockFileSystem.GetFileContents(L"E:\\Data\\3-SearchResults.csv");
+        Assert::AreEqual(std::string("Address,Value,PreviousValue,InitialValue\n0x0005,0x05,0x08,0x05\n0x000c,0x07,0x09,0x0c\n"),
+                         sContents);
+    }
+
+    TEST_METHOD(TestExportResultsCancel)
+    {
+        MemorySearchViewModelHarness search;
+        search.mockGameContext.SetGameId(3);
+        search.InitializeMemory();
+        search.BeginNewSearch();
+
+        search.SetComparisonType(ComparisonType::NotEqualTo);
+        search.SetValueType(ra::services::SearchFilterType::LastKnownValue);
+        search.memory.at(12) = 9;
+
+        search.ApplyFilter();
+        Assert::AreEqual({1U}, search.Results().Count());
+        AssertRow(search, 0, 12U, L"0x000c", L"0x09");
+
+        search.memory.at(12) = 7;
+        search.ApplyFilter();
+        Assert::AreEqual({1U}, search.Results().Count());
+        AssertRow(search, 0, 12U, L"0x000c", L"0x07");
+
+        bool bDialogSeen = false;
+        search.mockDesktop.ExpectWindow<ra::ui::viewmodels::FileDialogViewModel>(
+            [&bDialogSeen](ra::ui::viewmodels::FileDialogViewModel& vmFileDialog) {
+                bDialogSeen = true;
+
+                Assert::AreEqual(std::wstring(L"Export Search Results"), vmFileDialog.GetWindowTitle());
+                Assert::AreEqual({1U}, vmFileDialog.GetFileTypes().size());
+                Assert::AreEqual(std::wstring(L"csv"), vmFileDialog.GetDefaultExtension());
+                Assert::AreEqual(std::wstring(L"3-SearchResults.csv"), vmFileDialog.GetFileName());
+
+                vmFileDialog.SetFileName(L"E:\\Data\\3-SearchResults.csv");
+
+                return DialogResult::Cancel;
+            });
+
+        search.ExportResults();
+
+        Assert::IsTrue(bDialogSeen);
+        const std::string& sContents = search.mockFileSystem.GetFileContents(L"E:\\Data\\3-SearchResults.csv");
+        Assert::AreEqual(std::string(), sContents);
+    }
+
+    TEST_METHOD(TestExportResultsNone)
+    {
+        MemorySearchViewModelHarness search;
+        search.mockGameContext.SetGameId(3);
+        search.InitializeMemory();
+
+        bool bMessageSeen = false;
+        search.mockDesktop.ExpectWindow<ra::ui::viewmodels::MessageBoxViewModel>(
+            [&bMessageSeen](ra::ui::viewmodels::MessageBoxViewModel& vmMessageBox) {
+                bMessageSeen = true;
+                Assert::AreEqual(std::wstring(L"Nothing to export"), vmMessageBox.GetMessage());
+                return ra::ui::DialogResult::OK;
+            });
+
+        bool bDialogSeen = false;
+        search.mockDesktop.ExpectWindow<ra::ui::viewmodels::FileDialogViewModel>(
+            [&bDialogSeen](ra::ui::viewmodels::FileDialogViewModel&) {
+                bDialogSeen = true;
+                return DialogResult::Cancel;
+            });
+
+        // search not started
+        search.ExportResults();
+
+        Assert::IsTrue(bMessageSeen);
+        Assert::IsFalse(bDialogSeen);
+        bMessageSeen = false;
+
+        // start search. no filter applied. no results
+        search.BeginNewSearch();
+
+        search.ExportResults();
+        Assert::IsTrue(bMessageSeen);
+        Assert::IsFalse(bDialogSeen);
+        bMessageSeen = false;
+
+        // filter applied. one result
+        search.memory.at(12) = 9;
+        search.SetComparisonType(ComparisonType::NotEqualTo);
+        search.SetValueType(ra::services::SearchFilterType::LastKnownValue);
+        search.ApplyFilter();
+        Assert::AreEqual({1U}, search.Results().Count());
+
+        search.ExportResults();
+        Assert::IsFalse(bMessageSeen);
+        Assert::IsTrue(bDialogSeen);
+        bDialogSeen = false;
+
+        // filter applied again. no results remaining
+        search.ApplyFilter();
+        Assert::AreEqual({0U}, search.Results().Count());
+
+        search.ExportResults();
+        Assert::IsTrue(bMessageSeen);
+        Assert::IsFalse(bDialogSeen);
+    }
 };
 
 } // namespace tests
diff --git a/tests/ui/viewmodels/TriggerConditionViewModel_Tests.cpp b/tests/ui/viewmodels/TriggerConditionViewModel_Tests.cpp
index 435fea79..2f513e61 100644
--- a/tests/ui/viewmodels/TriggerConditionViewModel_Tests.cpp
+++ b/tests/ui/viewmodels/TriggerConditionViewModel_Tests.cpp
@@ -454,6 +454,7 @@ public:
         ParseAndRegenerate("0xK1234=0xK2345"); // bitcount
         ParseAndRegenerate("fF1234=fF2345"); // float
         ParseAndRegenerate("fM1234=fM2345"); // mbf32
+        ParseAndRegenerate("fL1234=fL2345"); // mbf32le
     }
 
     TEST_METHOD(TestOperandTypes)
diff --git a/tests/ui/viewmodels/TriggerViewModel_Tests.cpp b/tests/ui/viewmodels/TriggerViewModel_Tests.cpp
index 9db3020a..fd947adf 100644
--- a/tests/ui/viewmodels/TriggerViewModel_Tests.cpp
+++ b/tests/ui/viewmodels/TriggerViewModel_Tests.cpp
@@ -164,7 +164,7 @@ public:
         Assert::AreEqual((int)TriggerOperandType::Inverted, vmTrigger.OperandTypes().GetItemAt(6)->GetId());
         Assert::AreEqual(std::wstring(L"Invert"), vmTrigger.OperandTypes().GetItemAt(6)->GetLabel());
 
-        Assert::AreEqual({ 20U }, vmTrigger.OperandSizes().Count());
+        Assert::AreEqual({ 21U }, vmTrigger.OperandSizes().Count());
         Assert::AreEqual((int)MemSize::Bit_0, vmTrigger.OperandSizes().GetItemAt(0)->GetId());
         Assert::AreEqual(std::wstring(L"Bit0"), vmTrigger.OperandSizes().GetItemAt(0)->GetLabel());
         Assert::AreEqual((int)MemSize::Bit_1, vmTrigger.OperandSizes().GetItemAt(1)->GetId());
@@ -205,6 +205,8 @@ public:
         Assert::AreEqual(std::wstring(L"Float"), vmTrigger.OperandSizes().GetItemAt(18)->GetLabel());
         Assert::AreEqual((int)MemSize::MBF32, vmTrigger.OperandSizes().GetItemAt(19)->GetId());
         Assert::AreEqual(std::wstring(L"MBF32"), vmTrigger.OperandSizes().GetItemAt(19)->GetLabel());
+        Assert::AreEqual((int)MemSize::MBF32LE, vmTrigger.OperandSizes().GetItemAt(20)->GetId());
+        Assert::AreEqual(std::wstring(L"MBF32 LE"), vmTrigger.OperandSizes().GetItemAt(20)->GetLabel());
 
         Assert::AreEqual({ 10U }, vmTrigger.OperatorTypes().Count());
         Assert::AreEqual((int)TriggerOperatorType::Equals, vmTrigger.OperatorTypes().GetItemAt(0)->GetId());