Repository: RetroAchievements/RAIntegration
Pull request #599 (2020-08-29 17:34:52)

updates to accommodate splitting RA_Interface.h into multiple files


Patch:
diff --git a/RAInterface b/RAInterface
index 56378d56..4fafbc3f 160000
--- a/RAInterface
+++ b/RAInterface
@@ -1,1 +1,1 @@
-Subproject commit 56378d56b3996304e3c4a209fce9cf6f267629cf
+Subproject commit 4fafbc3f3e55e2cae90e3f38d441fb47556e729c
diff --git a/src/Exports.cpp b/src/Exports.cpp
index f3842c01..c4b6938f 100644
--- a/src/Exports.cpp
+++ b/src/Exports.cpp
@@ -19,6 +19,7 @@
 #include "services\IAudioSystem.hh"
 #include "services\IConfiguration.hh"
 #include "services\IFileSystem.hh"
+#include "services\Initialization.hh"
 #include "services\PerformanceCounter.hh"
 #include "services\ServiceLocator.hh"
 
@@ -27,6 +28,10 @@
 #include "ui\viewmodels\MessageBoxViewModel.hh"
 #include "ui\viewmodels\OverlayManager.hh"
 #include "ui\viewmodels\WindowManager.hh"
+#include "ui\win32\Desktop.hh"
+#include "ui\win32\OverlayWindow.hh"
+
+#include "RAInterface\RA_Emulators.h"
 
 #ifndef RA_UTEST
 #include "RA_Dlg_Achievement.h"
@@ -54,6 +59,68 @@ API int CCONV _RA_HardcoreModeIsActive()
     return pConfiguration.IsFeatureEnabled(ra::services::Feature::Hardcore);
 }
 
+#ifndef RA_UTEST
+API void CCONV _RA_UpdateHWnd(HWND hMainHWND)
+{
+    auto& pDesktop = dynamic_cast<ra::ui::win32::Desktop&>(ra::services::ServiceLocator::GetMutable<ra::ui::IDesktop>());
+    if (hMainHWND != pDesktop.GetMainHWnd())
+    {
+        pDesktop.SetMainHWnd(hMainHWND);
+
+        auto& pOverlayWindow = ra::services::ServiceLocator::GetMutable<ra::ui::win32::OverlayWindow>();
+        pOverlayWindow.CreateOverlayWindow(hMainHWND);
+    }
+}
+#endif
+
+static BOOL InitCommon([[maybe_unused]] HWND hMainHWND, [[maybe_unused]] int nEmulatorID,
+    [[maybe_unused]] const char* sClientName, const char* sClientVer, bool bOffline)
+{
+#ifndef RA_UTEST
+    ra::services::Initialization::RegisterServices(ra::itoe<EmulatorID>(nEmulatorID), sClientName);
+
+    _RA_UpdateHWnd(hMainHWND);
+#endif
+
+    if (bOffline)
+    {
+        ra::services::ServiceLocator::GetMutable<ra::data::UserContext>().DisableLogin();
+    }
+    else
+    {
+        // Set the client version and User-Agent string
+        ra::services::ServiceLocator::GetMutable<ra::data::EmulatorContext>().SetClientVersion(sClientVer);
+
+        // validate version (async call)
+        ra::services::ServiceLocator::GetMutable<ra::services::IThreadPool>().RunAsync([]
+        {
+            if (!ra::services::ServiceLocator::GetMutable<ra::data::EmulatorContext>().ValidateClientVersion())
+                ra::services::ServiceLocator::GetMutable<ra::data::UserContext>().Logout();
+        });
+    }
+
+    return TRUE;
+}
+
+API BOOL CCONV _RA_InitOffline(HWND hMainHWND, /*enum EmulatorID*/int nEmulatorID, const char* sClientVer)
+{
+    return InitCommon(hMainHWND, nEmulatorID, nullptr, sClientVer, true);
+}
+
+API BOOL CCONV _RA_InitClientOffline(HWND hMainHWND, const char* sClientName, const char* sClientVer)
+{
+    return InitCommon(hMainHWND, EmulatorID::UnknownEmulator, sClientName, sClientVer, true);
+}
+
+API BOOL CCONV _RA_InitI(HWND hMainHWND, /*enum EmulatorID*/int nEmulatorID, const char* sClientVer)
+{
+    return InitCommon(hMainHWND, nEmulatorID, nullptr, sClientVer, false);
+}
+
+API BOOL CCONV _RA_InitClient(HWND hMainHWND, const char* sClientName, const char* sClientVer)
+{
+    return InitCommon(hMainHWND, EmulatorID::UnknownEmulator, sClientName, sClientVer, false);
+}
 
 API void CCONV _RA_InstallSharedFunctions(bool(*)(void), void(*fpCauseUnpause)(void), void(*fpRebuildMenu)(void),
                                           void(*fpEstimateTitle)(char*), void(*fpResetEmulation)(void), void(*fpLoadROM)(const char*))
@@ -231,27 +298,6 @@ API void _RA_NavigateOverlay(const ControllerInput* pInput)
     ra::services::ServiceLocator::GetMutable<ra::ui::viewmodels::OverlayManager>().Update(*pInput);
 }
 
-_Use_decl_annotations_
-API int _RA_UpdateOverlay(const ControllerInput* pInput, float, bool, bool)
-{
-    _RA_NavigateOverlay(pInput);
-    return true; // was return state = closing - does anything check this?
-}
-
-#ifndef RA_UTEST
-_Use_decl_annotations_
-API void _RA_RenderOverlay(HDC hDC, const RECT* rcSize)
-{
-    switch (ra::services::ServiceLocator::Get<ra::data::EmulatorContext>().GetEmulatorId())
-    {
-        case EmulatorID::RA_Gens:
-            ra::ui::drawing::gdi::GDISurface pSurface(hDC, *rcSize);
-            ra::services::ServiceLocator::GetMutable<ra::ui::viewmodels::OverlayManager>().Render(pSurface, true);
-            break;
-    }
-}
-#endif
-
 static void ProcessAchievements()
 {
     auto& pRuntime = ra::services::ServiceLocator::GetMutable<ra::services::AchievementRuntime>();
diff --git a/src/Exports.hh b/src/Exports.hh
index 877691c4..64bc6efb 100644
--- a/src/Exports.hh
+++ b/src/Exports.hh
@@ -27,6 +27,12 @@ extern "C" {
     // Initialize all data related to RA Engine for offline mode. Call as early as possible.
     API int CCONV _RA_InitOffline(HWND hMainHWND, /*enum EmulatorID*/int nEmulatorID, const char* sClientVersion);
 
+    // Initialize all data related to RA Engine. Call as early as possible.
+    API int CCONV _RA_InitClient(HWND hMainHWND, const char* sClientName, const char* sClientVersion);
+
+    // Initialize all data related to RA Engine for offline mode. Call as early as possible.
+    API int CCONV _RA_InitClientOffline(HWND hMainHWND, const char* sClientName, const char* sClientVersion);
+
     // Specifies additional information to include in the UserAgent string
     API void CCONV _RA_SetUserAgentDetail(const char* sDetail);
 
diff --git a/src/RA_Core.cpp b/src/RA_Core.cpp
index 3303917b..307cb8e1 100644
--- a/src/RA_Core.cpp
+++ b/src/RA_Core.cpp
@@ -1,7 +1,10 @@
 #include "RA_Core.h"
 
+#include "Exports.hh"
+
 #include "RA_BuildVer.h"
 #include "RA_ImageFactory.h"
+#include "RA_Log.h"
 #include "RA_Resource.h"
 #include "RA_md5factory.h"
 
@@ -23,6 +26,7 @@
 #include "services\Initialization.hh"
 #include "services\ServiceLocator.hh"
 
+#include "ui\IDesktop.hh"
 #include "ui\ImageReference.hh"
 #include "ui\viewmodels\BrokenAchievementsViewModel.hh"
 #include "ui\viewmodels\GameChecksumViewModel.hh"
@@ -31,14 +35,12 @@
 #include "ui\viewmodels\OverlayManager.hh"
 #include "ui\viewmodels\OverlaySettingsViewModel.hh"
 #include "ui\viewmodels\WindowManager.hh"
-#include "ui\win32\Desktop.hh"
-#include "ui\win32\OverlayWindow.hh"
 
-std::wstring g_sHomeDir;
+#include "RAInterface\RA_Emulators.h"
+
 std::string g_sROMDirLocation;
 
 HMODULE g_hThisDLLInst = nullptr;
-HINSTANCE g_hRAKeysDLL = nullptr;
 HWND g_RAMainWnd = nullptr;
 
 BOOL APIENTRY DllMain(HMODULE hModule, DWORD dwReason, _UNUSED LPVOID)
@@ -115,56 +117,6 @@ void __gsl_contract_handler(const char* const file, unsigned int line, const cha
 }
 #endif
 
-API void CCONV _RA_UpdateHWnd(HWND hMainHWND)
-{
-    if (hMainHWND != g_RAMainWnd)
-    {
-        auto& pDesktop = dynamic_cast<ra::ui::win32::Desktop&>(ra::services::ServiceLocator::GetMutable<ra::ui::IDesktop>());
-        pDesktop.SetMainHWnd(hMainHWND);
-        g_RAMainWnd = hMainHWND;
-
-        auto& pOverlayWindow = ra::services::ServiceLocator::GetMutable<ra::ui::win32::OverlayWindow>();
-        pOverlayWindow.CreateOverlayWindow(hMainHWND);
-    }
-}
-
-static void InitCommon(HWND hMainHWND, /*enum EmulatorID*/int nEmulatorID)
-{
-    ra::services::Initialization::RegisterServices(ra::itoe<EmulatorID>(nEmulatorID));
-
-    // initialize global state
-    _RA_UpdateHWnd(hMainHWND);
-
-    auto& pFileSystem = ra::services::ServiceLocator::Get<ra::services::IFileSystem>();
-    g_sHomeDir = pFileSystem.BaseDirectory();
-}
-
-API BOOL CCONV _RA_InitOffline(HWND hMainHWND, /*enum EmulatorID*/int nEmulatorID, const char* /*sClientVer*/)
-{
-    InitCommon(hMainHWND, nEmulatorID);
-
-    ra::services::ServiceLocator::GetMutable<ra::data::UserContext>().DisableLogin();
-
-    return TRUE;
-}
-
-API BOOL CCONV _RA_InitI(HWND hMainHWND, /*enum EmulatorID*/int nEmulatorID, const char* sClientVer)
-{
-    InitCommon(hMainHWND, nEmulatorID);
-
-    // Set the client version and User-Agent string
-    ra::services::ServiceLocator::GetMutable<ra::data::EmulatorContext>().SetClientVersion(sClientVer);
-
-    // validate version (async call)
-    ra::services::ServiceLocator::GetMutable<ra::services::IThreadPool>().RunAsync([]
-    {
-        if (!ra::services::ServiceLocator::GetMutable<ra::data::EmulatorContext>().ValidateClientVersion())
-            ra::services::ServiceLocator::GetMutable<ra::data::UserContext>().Logout();
-    });
-
-    return TRUE;
-}
-
 API int CCONV _RA_Shutdown()
 {
     // if _RA_Init wasn't called, the services won't have been registered, so there's nothing to shut down
@@ -281,14 +233,18 @@ API bool CCONV _RA_WarnDisableHardcore(const char* sActivity)
     if (!pConfiguration.IsFeatureEnabled(ra::services::Feature::Hardcore))
         return true;
 
-    // prompt. if user doesn't consent, return failure - caller should not continue
-    ra::ui::viewmodels::MessageBoxViewModel vmMessageBox;
-    vmMessageBox.SetHeader(L"Disable Hardcore mode?");
-    vmMessageBox.SetMessage(L"You cannot " + ra::Widen(sActivity) + L" while Hardcore mode is active.");
-    vmMessageBox.SetButtons(ra::ui::viewmodels::MessageBoxViewModel::Buttons::YesNo);
-    vmMessageBox.SetIcon(ra::ui::viewmodels::MessageBoxViewModel::Icon::Warning);
-    if (vmMessageBox.ShowModal() != ra::ui::DialogResult::Yes)
-        return false;
+    // if no activity specified, just proceed to disabling without prompting
+    if (sActivity && *sActivity)
+    {
+        // prompt. if user doesn't consent, return failure - caller should not continue
+        ra::ui::viewmodels::MessageBoxViewModel vmMessageBox;
+        vmMessageBox.SetHeader(L"Disable Hardcore mode?");
+        vmMessageBox.SetMessage(L"You cannot " + ra::Widen(sActivity) + L" while Hardcore mode is active.");
+        vmMessageBox.SetButtons(ra::ui::viewmodels::MessageBoxViewModel::Buttons::YesNo);
+        vmMessageBox.SetIcon(ra::ui::viewmodels::MessageBoxViewModel::Icon::Warning);
+        if (vmMessageBox.ShowModal() != ra::ui::DialogResult::Yes)
+            return false;
+    }
 
     // user consented, switch to non-hardcore mode
     ra::services::ServiceLocator::GetMutable<ra::data::EmulatorContext>().DisableHardcoreMode();
diff --git a/src/RA_Core.h b/src/RA_Core.h
index 9b4cd182..24086f2b 100644
--- a/src/RA_Core.h
+++ b/src/RA_Core.h
@@ -2,38 +2,13 @@
 #define RA_CORE_H
 #pragma once
 
-#include "RA_Defs.h"
-#include "RA_Interface.h"
+#include "ra_fwd.h"
 
-#include "Exports.hh"
-
-// Non-exposed:
-extern std::wstring g_sHomeDir;
-
-extern HINSTANCE g_hRAKeysDLL;
 extern HMODULE g_hThisDLLInst;
 extern HWND g_RAMainWnd;
 
-// Read a file to a malloc'd buffer. Returns nullptr on error. Owner MUST free() buffer if not nullptr.
-extern char* _MallocAndBulkReadFileToBuffer(_In_z_ const wchar_t* sFilename, _Out_ long& nFileSizeOut) noexcept;
-
-// Read a file to a std::string. Returns false on error.
-_Success_(return ) _NODISCARD bool _ReadBufferFromFile(_Out_ std::string& buffer, _In_ const wchar_t* restrict sFile);
-
-// Read file until reaching the end of the file, or the specified char.
-extern BOOL _ReadTil(const char nChar, char* restrict buffer, unsigned int nSize,
-                     gsl::not_null<DWORD* restrict> pCharsReadOut, gsl::not_null<FILE* restrict> pFile);
-
-GSL_SUPPRESS_F23 char* _ReadStringTil(char nChar, char* restrict& pOffsetInOut, BOOL bTerminate);
 GSL_SUPPRESS_F23 void _ReadStringTil(std::string& sValue, char nChar, const char* restrict& pOffsetInOut);
 
-// Write out the buffer to a file
-extern void _WriteBufferToFile(const std::wstring& sFileName, const std::string& sString);
-
-// Fetch various interim txt/data files
-extern void _FetchGameHashLibraryFromWeb();
-extern void _FetchMyProgressFromWeb();
-
 void RestoreWindowPosition(HWND hDlg, const char* sDlgKey, bool bToRight, bool bToBottom);
 void RememberWindowPosition(HWND hDlg, const char* sDlgKey);
 void RememberWindowSize(HWND hDlg, const char* sDlgKey);
diff --git a/src/RA_Dlg_AchEditor.cpp b/src/RA_Dlg_AchEditor.cpp
index ea09de54..b98c30d8 100644
--- a/src/RA_Dlg_AchEditor.cpp
+++ b/src/RA_Dlg_AchEditor.cpp
@@ -2,6 +2,7 @@
 
 #include "RA_Achievement.h"
 #include "RA_Core.h"
+#include "RA_Defs.h"
 #include "RA_Dlg_Achievement.h"
 #include "RA_ImageFactory.h"
 #include "RA_Resource.h"
diff --git a/src/RA_Dlg_Achievement.cpp b/src/RA_Dlg_Achievement.cpp
index b00fa070..d31ce17e 100644
--- a/src/RA_Dlg_Achievement.cpp
+++ b/src/RA_Dlg_Achievement.cpp
@@ -2,10 +2,14 @@
 
 #include "RA_Achievement.h"
 #include "RA_Core.h"
+#include "RA_Defs.h"
 #include "RA_Dlg_AchEditor.h"
 #include "RA_Resource.h"
 #include "RA_md5factory.h"
 #include "RA_BuildVer.h"
+#include "RA_StringUtils.h"
+
+#include "Exports.hh"
 
 #include "api\UpdateAchievement.hh"
 
diff --git a/src/RA_Dlg_GameLibrary.cpp b/src/RA_Dlg_GameLibrary.cpp
index 3232e559..84c2567b 100644
--- a/src/RA_Dlg_GameLibrary.cpp
+++ b/src/RA_Dlg_GameLibrary.cpp
@@ -2,10 +2,12 @@
 
 #include "RA_Achievement.h"
 #include "RA_Core.h"
+#include "RA_Defs.h"
 #include "RA_Resource.h"
 #include "RA_md5factory.h"
 
 #include "services\IConfiguration.hh"
+#include "services\IFileSystem.hh"
 #include "services\ServiceLocator.hh"
 
 #include "ui\viewmodels\FileDialogViewModel.hh"
@@ -85,7 +87,9 @@ inline static void LogErrno() noexcept
 
 void ParseGameHashLibraryFromFile(std::map<std::string, unsigned int>& GameHashLibraryOut)
 {
-    std::wstring sGameHashFile{g_sHomeDir};
+    auto& pFileSystem = ra::services::ServiceLocator::Get<ra::services::IFileSystem>();
+
+    std::wstring sGameHashFile = pFileSystem.BaseDirectory();
     sGameHashFile += RA_GAME_HASH_FILENAME;
     std::ifstream ifile{sGameHashFile};
     if (!ifile.is_open())
@@ -113,7 +117,8 @@ void ParseGameHashLibraryFromFile(std::map<std::string, unsigned int>& GameHashL
 
 void ParseGameTitlesFromFile(std::map<unsigned int, std::string>& GameTitlesListOut)
 {
-    std::wstring sTitlesFile{g_sHomeDir};
+    auto& pFileSystem = ra::services::ServiceLocator::Get<ra::services::IFileSystem>();
+    std::wstring sTitlesFile = pFileSystem.BaseDirectory();
     sTitlesFile += RA_TITLES_FILENAME;
     std::ifstream ifile{sTitlesFile};
     if (!ifile.is_open())
@@ -142,7 +147,8 @@ void ParseGameTitlesFromFile(std::map<unsigned int, std::string>& GameTitlesList
 
 void ParseMyProgressFromFile(std::map<unsigned int, std::string>& GameProgressOut)
 {
-    std::wstring sProgressFile{g_sHomeDir};
+    auto& pFileSystem = ra::services::ServiceLocator::Get<ra::services::IFileSystem>();
+    std::wstring sProgressFile = pFileSystem.BaseDirectory();
     sProgressFile += RA_TITLES_FILENAME;
 
     std::ifstream ifile{sProgressFile, std::ios::binary};
@@ -461,7 +467,8 @@ BOOL Dlg_GameLibrary::LaunchSelected()
 
 void Dlg_GameLibrary::LoadAll()
 {
-    const auto sMyGameLibraryFile = ra::StringPrintf(L"%s%s", g_sHomeDir, RA_MY_GAME_LIBRARY_FILENAME);
+    auto& pFileSystem = ra::services::ServiceLocator::Get<ra::services::IFileSystem>();
+    std::wstring sMyGameLibraryFile = pFileSystem.BaseDirectory() + RA_MY_GAME_LIBRARY_FILENAME;
     {
         std::scoped_lock lock{mtx};
         std::ifstream ifile{sMyGameLibraryFile, std::ios::binary};
@@ -501,7 +508,8 @@ void Dlg_GameLibrary::LoadAll()
 
 void Dlg_GameLibrary::SaveAll()
 {
-    std::wstring sMyGameLibraryFile = g_sHomeDir + RA_MY_GAME_LIBRARY_FILENAME;
+    auto& pFileSystem = ra::services::ServiceLocator::Get<ra::services::IFileSystem>();
+    std::wstring sMyGameLibraryFile = pFileSystem.BaseDirectory() + RA_MY_GAME_LIBRARY_FILENAME;
 
     mtx.lock();
     FILE* pf = nullptr;
diff --git a/src/RA_Integration.vcxproj b/src/RA_Integration.vcxproj
index 1921c08d..2d2f26e4 100644
--- a/src/RA_Integration.vcxproj
+++ b/src/RA_Integration.vcxproj
@@ -182,6 +182,8 @@
     <ClCompile Include="ui\WindowViewModelBase.cpp" />
   </ItemGroup>
   <ItemGroup>
+    <ClInclude Include="..\RAInterface\RA_Consoles.h" />
+    <ClInclude Include="..\RAInterface\RA_Emulators.h" />
     <ClInclude Include="..\RAInterface\RA_Interface.h" />
     <ClInclude Include="api\ApiCall.hh" />
     <ClInclude Include="api\AwardAchievement.hh" />
diff --git a/src/RA_Integration.vcxproj.filters b/src/RA_Integration.vcxproj.filters
index ca0261e9..37953e6b 100644
--- a/src/RA_Integration.vcxproj.filters
+++ b/src/RA_Integration.vcxproj.filters
@@ -845,6 +845,12 @@
     <ClInclude Include="ui\viewmodels\AchievementViewModel.hh">
       <Filter>UI\ViewModels</Filter>
     </ClInclude>
+    <ClInclude Include="..\RAInterface\RA_Consoles.h">
+      <Filter>Interface</Filter>
+    </ClInclude>
+    <ClInclude Include="..\RAInterface\RA_Emulators.h">
+      <Filter>Interface</Filter>
+    </ClInclude>
   </ItemGroup>
   <ItemGroup>
     <ResourceCompile Include="RA_Shared.rc">
diff --git a/src/data/ConsoleContext.hh b/src/data/ConsoleContext.hh
index 1edc85a9..0e174254 100644
--- a/src/data/ConsoleContext.hh
+++ b/src/data/ConsoleContext.hh
@@ -2,7 +2,7 @@
 #define RA_DATA_CONSOLECONTEXT_HH
 #pragma once
 
-#include "RA_Interface.h"
+#include "RAInterface\RA_Consoles.h"
 #include "ra_fwd.h"
 #include "data\Types.hh"
 
diff --git a/src/data/EmulatorContext.cpp b/src/data/EmulatorContext.cpp
index a61cf983..b495ef7b 100644
--- a/src/data/EmulatorContext.cpp
+++ b/src/data/EmulatorContext.cpp
@@ -18,10 +18,13 @@
 
 #include "ui\viewmodels\MessageBoxViewModel.hh"
 
+#include "RAInterface\RA_Consoles.h"
+#include "RAInterface\RA_Emulators.h"
+
 namespace ra {
 namespace data {
 
-void EmulatorContext::Initialize(EmulatorID nEmulatorId)
+void EmulatorContext::Initialize(EmulatorID nEmulatorId, const char* sClientName)
 {
     m_nEmulatorId = nEmulatorId;
 
@@ -90,20 +93,20 @@ void EmulatorContext::Initialize(EmulatorID nEmulatorId)
             _RA_SetConsoleID(ConsoleID::Oric);
             break;
 
-        case RA_MelonDS:
-            m_sClientName = "RAMelonDS";
-            _RA_SetConsoleID(ConsoleID::DS);
-            break;
-
         default:
-        {
-            const auto& pDesktop = ra::services::ServiceLocator::Get<ra::ui::IDesktop>();
-            const auto sFileName = pDesktop.GetRunningExecutable();
-            const auto& pFileSystem = ra::services::ServiceLocator::Get<ra::services::IFileSystem>();
-            m_sClientName = pFileSystem.RemoveExtension(pFileSystem.GetFileName(sFileName));
+            if (sClientName != nullptr && *sClientName)
+            {
+                m_sClientName = sClientName;
+            }
+            else
+            {
+                const auto& pDesktop = ra::services::ServiceLocator::Get<ra::ui::IDesktop>();
+                const auto sFileName = pDesktop.GetRunningExecutable();
+                const auto& pFileSystem = ra::services::ServiceLocator::Get<ra::services::IFileSystem>();
+                m_sClientName = pFileSystem.RemoveExtension(pFileSystem.GetFileName(sFileName));
+            }
             m_nEmulatorId = EmulatorID::UnknownEmulator;
             break;
-        }
     }
 }
 
diff --git a/src/data/EmulatorContext.hh b/src/data/EmulatorContext.hh
index 1eb7b5ea..4de42ab3 100644
--- a/src/data/EmulatorContext.hh
+++ b/src/data/EmulatorContext.hh
@@ -2,7 +2,7 @@
 #define RA_DATA_EMULATORCONTEXT_HH
 #pragma once
 
-#include "RA_Interface.h"
+#include "RAInterface\RA_Emulators.h"
 
 #include "Types.hh"
 #include "ra_fwd.h"
@@ -25,7 +25,7 @@ public:
     /// <summary>
     /// Initializes the emulator context.
     /// </summary>
-    void Initialize(EmulatorID nEmulatorId);
+    void Initialize(EmulatorID nEmulatorId, const char* sClientName);
 
     /// <summary>
     /// Sets the client version.
diff --git a/src/data/GameContext.cpp b/src/data/GameContext.cpp
index d5632689..555dac38 100644
--- a/src/data/GameContext.cpp
+++ b/src/data/GameContext.cpp
@@ -30,9 +30,11 @@
 
 #include "ui\ImageReference.hh"
 
+#include "ui\viewmodels\AchievementViewModel.hh"
 #include "ui\viewmodels\MessageBoxViewModel.hh"
 #include "ui\viewmodels\OverlayManager.hh"
 #include "ui\viewmodels\ScoreboardViewModel.hh"
+#include "ui\viewmodels\WindowManager.hh"
 
 namespace ra {
 namespace data {
@@ -126,6 +128,8 @@ void GameContext::LoadGame(unsigned int nGameId, Mode nMode)
     const bool bWasPaused = pRuntime.IsPaused();
     pRuntime.SetPaused(true);
 
+    auto& vmAssets = ra::services::ServiceLocator::GetMutable<ra::ui::viewmodels::WindowManager>().AssetList;
+
     unsigned int nNumCoreAchievements = 0;
     unsigned int nTotalCoreAchievementPoints = 0;
     for (const auto& pAchievementData : response.Achievements)
@@ -139,6 +143,17 @@ void GameContext::LoadGame(unsigned int nGameId, Mode nMode)
         pAchievement.SetID(pAchievementData.Id);
         CopyAchievementData(pAchievement, pAchievementData);
 
+        auto vmAchievement = std::make_unique<ra::ui::viewmodels::AchievementViewModel>();
+        vmAchievement->SetID(pAchievementData.Id);
+        vmAchievement->SetName(ra::Widen(pAchievementData.Title));
+        vmAchievement->SetDescription(ra::Widen(pAchievementData.Description));
+        vmAchievement->SetCategory(ra::itoe<ra::ui::viewmodels::AssetCategory>(pAchievementData.CategoryId));
+        vmAchievement->SetPoints(pAchievementData.Points);
+        vmAchievement->SetBadge(ra::Widen(pAchievementData.BadgeName));
+        vmAchievement->SetTrigger(pAchievementData.Definition);
+        vmAchievement->CreateServerCheckpoint();
+        vmAssets.Assets().Append(std::move(vmAchievement));
+
 #ifndef RA_UTEST
         // prefetch the achievement image
         pImageRepository.FetchImage(ra::ui::ImageType::Badge, pAchievementData.BadgeName);
@@ -166,6 +181,13 @@ void GameContext::LoadGame(unsigned int nGameId, Mode nMode)
     m_nNextLocalId = GameContext::FirstLocalId;
     MergeLocalAchievements(0);
 
+    for (gsl::index i = 0; i < gsl::narrow_cast<gsl::index>(vmAssets.Assets().Count()); ++i)
+    {
+        auto* pItem = vmAssets.Assets().GetItemAt(i);
+        Expects(pItem != nullptr);
+        pItem->CreateLocalCheckpoint();
+    }
+
 #ifndef RA_UTEST
     g_AchievementsDialog.UpdateAchievementList();
 #endif
@@ -326,6 +348,8 @@ bool GameContext::MergeLocalAchievements(ra::AchievementID nAchievementId)
     if (pData == nullptr)
         return false;
 
+    auto& vmAssets = ra::services::ServiceLocator::GetMutable<ra::ui::viewmodels::WindowManager>().AssetList;
+
     std::string sLine;
     pData->GetLine(sLine); // version used to create the file
     pData->GetLine(sLine); // game title
@@ -347,8 +371,12 @@ bool GameContext::MergeLocalAchievements(ra::AchievementID nAchievementId)
 
         bool bIsNew = false;
         Achievement* pAchievement = nullptr;
+        ra::ui::viewmodels::AchievementViewModel* vmAchievement = nullptr;
         if (nId != 0)
+        {
             pAchievement = FindAchievement(nId);
+            vmAchievement = vmAssets.FindAchievement(nId);
+        }
         if (!pAchievement)
         {
             bIsNew = true;
@@ -363,6 +391,21 @@ bool GameContext::MergeLocalAchievements(ra::AchievementID nAchievementId)
             pAchievement->SetID(nId);
         }
 
+        if (!vmAchievement)
+        {
+            auto uvmAchievement = std::make_unique<ra::ui::viewmodels::AchievementViewModel>();
+            uvmAchievement->SetID(nId);
+            uvmAchievement->SetCategory(ra::ui::viewmodels::AssetCategory::Local);
+            uvmAchievement->CreateServerCheckpoint();
+
+            auto* vmAsset = &vmAssets.Assets().Append(std::move(uvmAchievement));
+            vmAchievement = dynamic_cast<ra::ui::viewmodels::AchievementViewModel*>(vmAsset);
+        }
+
+        ra::Tokenizer pTokenizer2(sLine);
+        pTokenizer2.Advance(pTokenizer.CurrentPosition());
+        vmAchievement->Deserialize(pTokenizer2);
+
         // field 2: trigger
         std::string sTrigger;
         if (pTokenizer.PeekChar() == '"')
@@ -503,6 +546,14 @@ bool GameContext::MergeLocalAchievements(ra::AchievementID nAchievementId)
             pAchievement->SetID(m_nNextLocalId++);
     }
 
+    for (gsl::index i = 0; i < gsl::narrow_cast<gsl::index>(vmAssets.Assets().Count()); ++i)
+    {
+        auto* pItem = vmAssets.Assets().GetItemAt(i);
+        Expects(pItem != nullptr);
+        if (pItem->GetID() == 0)
+            pItem->SetID(m_nNextLocalId++);
+    }
+
     return (nAchievementId == 0);
 }
 
diff --git a/src/data/UserContext.cpp b/src/data/UserContext.cpp
index 18891d33..fe6ddda4 100644
--- a/src/data/UserContext.cpp
+++ b/src/data/UserContext.cpp
@@ -1,6 +1,7 @@
 #include "UserContext.hh"
 
-#include "RA_Core.h"
+#include "Exports.hh"
+#include "RA_StringUtils.h"
 
 #include "api\Logout.hh"
 
diff --git a/src/services/Initialization.cpp b/src/services/Initialization.cpp
index 496bff02..b16c81bc 100644
--- a/src/services/Initialization.cpp
+++ b/src/services/Initialization.cpp
@@ -32,6 +32,9 @@
 #include "ui\win32\Desktop.hh"
 #include "ui\win32\OverlayWindow.hh"
 
+#include "RAInterface\RA_Consoles.h"
+#include "RAInterface\RA_Emulators.h"
+
 // this, in combination with the "#define ISOLATION_AWARE_ENABLED 1" in pch.h, allows our
 // UI to use visual styles introduced in ComCtl32 v6, even if the emulator does not enable them.
 // see https://docs.microsoft.com/en-us/windows/desktop/controls/cookbook-overview
@@ -83,7 +86,7 @@ void Initialization::RegisterCoreServices()
     ra::services::ServiceLocator::Provide<ra::services::IConfiguration>(std::move(pConfiguration));
 }
 
-void Initialization::RegisterServices(EmulatorID nEmulatorId)
+void Initialization::RegisterServices(EmulatorID nEmulatorId, const char* sClientName)
 {
     RegisterCoreServices();
 
@@ -95,8 +98,8 @@ void Initialization::RegisterServices(EmulatorID nEmulatorId)
     ra::services::ServiceLocator::Provide<ra::ui::IDesktop>(std::move(pDesktop));
 
     auto pEmulatorContext = std::make_unique<ra::data::EmulatorContext>();
-    pEmulatorContext->Initialize(nEmulatorId);
-    const auto& sClientName = pEmulatorContext->GetClientName();
+    pEmulatorContext->Initialize(nEmulatorId, sClientName);
+    sClientName = pEmulatorContext->GetClientName().c_str();
     ra::services::ServiceLocator::Provide<ra::data::EmulatorContext>(std::move(pEmulatorContext));
 
     // if EmulatorContext->Initialize doesn't specify the ConsoleContext, initialize a default ConsoleContext
diff --git a/src/services/Initialization.hh b/src/services/Initialization.hh
index 1672ff31..c4cbe67e 100644
--- a/src/services/Initialization.hh
+++ b/src/services/Initialization.hh
@@ -2,7 +2,7 @@
 #define RA_SERVICES_INITIALIZATION_HH
 #pragma once
 
-#include "RA_Interface.h"
+#include "RAInterface\RA_Emulators.h"
 
 namespace ra {
 namespace services {
@@ -12,7 +12,7 @@ class Initialization
 public:
     static void RegisterCoreServices();
 
-    static void RegisterServices(EmulatorID nEmulatorId);
+    static void RegisterServices(EmulatorID nEmulatorId, const char* sClientName);
 
     static void Shutdown();
 
diff --git a/src/ui/ViewModelCollection.hh b/src/ui/ViewModelCollection.hh
index b3aa30fa..dc13e681 100644
--- a/src/ui/ViewModelCollection.hh
+++ b/src/ui/ViewModelCollection.hh
@@ -423,6 +423,17 @@ public:
         return dynamic_cast<T&>(ViewModelCollectionBase::AddItem(std::move(pItem)));
     }
 
+    /// <summary>
+    /// Adds an item to the end of the collection.
+    /// </summary>
+    /// <remarks>
+    /// This function is called Append instead of Add to prevent confusion with the templated parameters.
+    /// </summary>
+    T& Append(std::unique_ptr<T> pItem)
+    {
+        return dynamic_cast<T&>(ViewModelCollectionBase::AddItem(std::move(pItem)));
+    }
+
     /// <summary>
     /// Gets the item at the specified index.
     /// </summary>
diff --git a/src/ui/drawing/gdi/ImageRepository.cpp b/src/ui/drawing/gdi/ImageRepository.cpp
index 5de06107..293750df 100644
--- a/src/ui/drawing/gdi/ImageRepository.cpp
+++ b/src/ui/drawing/gdi/ImageRepository.cpp
@@ -2,7 +2,7 @@
 
 #include "GDIBitmapSurface.hh"
 
-#include "RA_Core.h"
+#include "RA_Defs.h"
 
 #include "services\Http.hh"
 #include "services\IConfiguration.hh"
@@ -78,7 +78,8 @@ ImageRepository::~ImageRepository() noexcept
 
 std::wstring ImageRepository::GetFilename(ImageType nType, const std::string& sName)
 {
-    std::wstring sFilename = g_sHomeDir;
+    auto& pFileSystem = ra::services::ServiceLocator::Get<ra::services::IFileSystem>();
+    std::wstring sFilename = pFileSystem.BaseDirectory();
 
     switch (nType)
     {
diff --git a/src/ui/viewmodels/AchievementViewModel.cpp b/src/ui/viewmodels/AchievementViewModel.cpp
index adfcf6f6..eed1fee1 100644
--- a/src/ui/viewmodels/AchievementViewModel.cpp
+++ b/src/ui/viewmodels/AchievementViewModel.cpp
@@ -14,6 +14,8 @@ const IntModelProperty AchievementViewModel::TriggerProperty("AchievementViewMod
 
 AchievementViewModel::AchievementViewModel() noexcept
 {
+    GSL_SUPPRESS_F6 SetValue(TypeProperty, ra::etoi(AssetType::Achievement));
+
     SetTransactional(PointsProperty);
     SetTransactional(BadgeProperty);
 
@@ -25,6 +27,112 @@ void AchievementViewModel::SelectBadgeFile()
     ra::ui::viewmodels::MessageBoxViewModel::ShowWarningMessage(L"Not implemented");
 }
 
+void AchievementViewModel::Serialize(ra::services::TextWriter& pWriter) const
+{
+    WriteQuoted(pWriter, GetTrigger());
+    WritePossiblyQuoted(pWriter, GetName());
+    WritePossiblyQuoted(pWriter, GetDescription());
+    pWriter.Write("::::"); // progress/max/format/author
+    WriteNumber(pWriter, GetPoints());
+    pWriter.Write("::::"); // created/modified/upvotes/downvotes
+    WritePossiblyQuoted(pWriter, GetBadge());
+}
+
+bool AchievementViewModel::Deserialize(ra::Tokenizer& pTokenizer)
+{
+    // field 2: trigger
+    std::string sTrigger;
+    if (pTokenizer.PeekChar() == '"')
+    {
+        sTrigger = pTokenizer.ReadQuotedString();
+    }
+    else
+    {
+        // unquoted trigger requires special parsing because flags also use colons
+        const char* pTrigger = pTokenizer.GetPointer(pTokenizer.CurrentPosition());
+        const char* pScan = pTrigger;
+        while (*pScan && (*pScan != ':' || strchr("ABCMNOPRTabcmnoprt", pScan[-1]) != nullptr))
+            pScan++;
+
+        sTrigger.assign(pTrigger, pScan - pTrigger);
+        pTokenizer.Advance(sTrigger.length());
+    }
+
+    if (!pTokenizer.Consume(':'))
+        return false;
+
+    // field 3: title
+    std::string sTitle;
+    if (!ReadPossiblyQuoted(pTokenizer, sTitle))
+        return false;
+
+    // field 4: description
+    std::string sDescription;
+    if (!ReadPossiblyQuoted(pTokenizer, sDescription))
+        return false;
+
+    // field 5: progress (unused)
+    pTokenizer.AdvanceTo(':');
+    if (!pTokenizer.Consume(':'))
+        return false;
+
+    // field 6: progress max (unused)
+    pTokenizer.AdvanceTo(':');
+    if (!pTokenizer.Consume(':'))
+        return false;
+
+    // field 7: progress format (unused)
+    pTokenizer.AdvanceTo(':');
+    if (!pTokenizer.Consume(':'))
+        return false;
+
+    // field 8: author (unused)
+    pTokenizer.AdvanceTo(':');
+    if (!pTokenizer.Consume(':'))
+        return false;
+
+    // field 9: points
+    uint32_t nPoints;
+    if (!ReadNumber(pTokenizer, nPoints))
+        return false;
+
+    // field 10: created date (unused)
+    pTokenizer.AdvanceTo(':');
+    if (!pTokenizer.Consume(':'))
+        return false;
+
+    // field 11: modified date (unused)
+    pTokenizer.AdvanceTo(':');
+    if (!pTokenizer.Consume(':'))
+        return false;
+
+    // field 12: up votes (unused)
+    pTokenizer.AdvanceTo(':');
+    if (!pTokenizer.Consume(':'))
+        return false;
+
+    // field 13: down votes (unused)
+    pTokenizer.AdvanceTo(':');
+    if (!pTokenizer.Consume(':'))
+        return false;
+
+    // field 14: badge
+    std::string sBadge;
+    if (!ReadPossiblyQuoted(pTokenizer, sBadge))
+        return false;
+    if (sBadge.length() < 5)
+        sBadge.insert(0, 5 - sBadge.length(), '0');
+
+    // line is valid
+    SetName(ra::Widen(sTitle));
+    SetDescription(ra::Widen(sDescription));
+    SetPoints(nPoints);
+    SetBadge(ra::Widen(sBadge));
+    SetTrigger(sTrigger);
+
+    return true;
+}
+
 } // namespace viewmodels
 } // namespace ui
 } // namespace ra
diff --git a/src/ui/viewmodels/AchievementViewModel.hh b/src/ui/viewmodels/AchievementViewModel.hh
index e185b60a..7556a59d 100644
--- a/src/ui/viewmodels/AchievementViewModel.hh
+++ b/src/ui/viewmodels/AchievementViewModel.hh
@@ -8,7 +8,7 @@ namespace ra {
 namespace ui {
 namespace viewmodels {
 
-class AchievementViewModel : protected AssetViewModelBase
+class AchievementViewModel : public AssetViewModelBase
 {
 public:
     AchievementViewModel() noexcept;
@@ -93,6 +93,9 @@ public:
     /// </summary>
     void SetTrigger(const std::string& sTrigger) { SetAssetDefinition(m_pTrigger, sTrigger); }
 
+    void Serialize(ra::services::TextWriter& pWriter) const override;
+    bool Deserialize(ra::Tokenizer& pTokenizer) override;
+
 private:
     AssetDefinition m_pTrigger;
 };
diff --git a/src/ui/viewmodels/AssetListViewModel.cpp b/src/ui/viewmodels/AssetListViewModel.cpp
index 449b8db9..d99c62ff 100644
--- a/src/ui/viewmodels/AssetListViewModel.cpp
+++ b/src/ui/viewmodels/AssetListViewModel.cpp
@@ -27,6 +27,30 @@ const BoolModelProperty AssetListViewModel::CanRefreshProperty("AssetListViewMod
 const BoolModelProperty AssetListViewModel::CanCloneProperty("AssetListViewModel", "CanClone", true);
 
 
+AssetViewModelBase* AssetListViewModel::FindAsset(AssetType nType, ra::AchievementID nId)
+{
+    for (gsl::index nIndex = 0; nIndex < gsl::narrow_cast<gsl::index>(m_vAssets.Count()); ++nIndex)
+    {
+        auto* pItem = m_vAssets.GetItemAt(nIndex);
+        if (pItem != nullptr && pItem->GetID() == nId && pItem->GetType() == nType)
+            return pItem;
+    }
+
+    return nullptr;
+}
+
+const AssetViewModelBase* AssetListViewModel::FindAsset(AssetType nType, ra::AchievementID nId) const
+{
+    for (gsl::index nIndex = 0; nIndex < gsl::narrow_cast<gsl::index>(m_vAssets.Count()); ++nIndex)
+    {
+        auto* pItem = m_vAssets.GetItemAt(nIndex);
+        if (pItem != nullptr && pItem->GetID() == nId && pItem->GetType() == nType)
+            return pItem;
+    }
+
+    return nullptr;
+}
+
 bool AssetListViewModel::HasSelection(AssetType nAssetType) const
 {
     for (gsl::index nIndex = 0; nIndex < gsl::narrow_cast<gsl::index>(m_vAssets.Count()); ++nIndex)
diff --git a/src/ui/viewmodels/AssetListViewModel.hh b/src/ui/viewmodels/AssetListViewModel.hh
index 4a6dc84f..f3bf4511 100644
--- a/src/ui/viewmodels/AssetListViewModel.hh
+++ b/src/ui/viewmodels/AssetListViewModel.hh
@@ -7,6 +7,8 @@
 
 #include "AssetViewModelBase.hh"
 
+#include "AchievementViewModel.hh"
+
 #include "data\Types.hh"
 
 namespace ra {
@@ -98,7 +100,19 @@ public:
     ViewModelCollection<AssetViewModelBase>& Assets() noexcept { return m_vAssets; }
     const ViewModelCollection<AssetViewModelBase>& Assets() const noexcept { return m_vAssets; }
 
+    AchievementViewModel* FindAchievement(ra::AchievementID nId)
+    {
+        return dynamic_cast<AchievementViewModel*>(FindAsset(AssetType::Achievement, nId));
+    }
+    const AchievementViewModel* FindAchievement(ra::AchievementID nId) const
+    {
+        return dynamic_cast<const AchievementViewModel*>(FindAsset(AssetType::Achievement, nId));
+    }
+
 private:
+    AssetViewModelBase* FindAsset(AssetType nType, ra::AchievementID nId);
+    const AssetViewModelBase* FindAsset(AssetType nType, ra::AchievementID nId) const;
+
     bool HasSelection(AssetType nAssetType) const;
 
     ViewModelCollection<AssetViewModelBase> m_vAssets;
diff --git a/src/ui/viewmodels/AssetViewModelBase.cpp b/src/ui/viewmodels/AssetViewModelBase.cpp
index f3fcd246..d8ecd344 100644
--- a/src/ui/viewmodels/AssetViewModelBase.cpp
+++ b/src/ui/viewmodels/AssetViewModelBase.cpp
@@ -7,6 +7,7 @@ namespace viewmodels {
 const IntModelProperty AssetViewModelBase::TypeProperty("AssetViewModelBase", "Type", ra::etoi(AssetType::Achievement));
 const IntModelProperty AssetViewModelBase::IDProperty("AssetViewModelBase", "ID", 0);
 const StringModelProperty AssetViewModelBase::NameProperty("AssetViewModelBase", "Name", L"");
+const StringModelProperty AssetViewModelBase::DescriptionProperty("AssetViewModelBase", "Description", L"");
 const IntModelProperty AssetViewModelBase::CategoryProperty("AssetViewModelBase", "Category", ra::etoi(AssetCategory::Core));
 const IntModelProperty AssetViewModelBase::StateProperty("AssetViewModelBase", "State", ra::etoi(AssetState::Inactive));
 const IntModelProperty AssetViewModelBase::ChangesProperty("AssetViewModelBase", "Changes", ra::etoi(AssetChanges::None));
@@ -101,6 +102,59 @@ void AssetViewModelBase::WriteNumber(ra::services::TextWriter& pWriter, const ui
     pWriter.Write(std::to_string(nValue));
 }
 
+bool AssetViewModelBase::ReadNumber(ra::Tokenizer& pTokenizer, uint32_t& nValue)
+{
+    if (pTokenizer.EndOfString())
+        return false;
+
+    nValue = pTokenizer.ReadNumber();
+    return pTokenizer.Consume(':') || pTokenizer.EndOfString();
+}
+
+bool AssetViewModelBase::ReadQuoted(ra::Tokenizer& pTokenizer, std::string& sText)
+{
+    if (pTokenizer.EndOfString())
+        return false;
+
+    sText = pTokenizer.ReadQuotedString();
+    return pTokenizer.Consume(':') || pTokenizer.EndOfString();
+}
+
+bool AssetViewModelBase::ReadQuoted(ra::Tokenizer& pTokenizer, std::wstring& sText)
+{
+    if (pTokenizer.EndOfString())
+        return false;
+
+    sText = ra::Widen(pTokenizer.ReadQuotedString());
+    return pTokenizer.Consume(':') || pTokenizer.EndOfString();
+}
+
+bool AssetViewModelBase::ReadPossiblyQuoted(ra::Tokenizer& pTokenizer, std::string& sText)
+{
+    if (pTokenizer.EndOfString())
+        return false;
+
+    if (pTokenizer.PeekChar() == '"')
+        return ReadQuoted(pTokenizer, sText);
+
+    sText = pTokenizer.ReadTo(':');
+    pTokenizer.Consume(':');
+    return true;
+}
+
+bool AssetViewModelBase::ReadPossiblyQuoted(ra::Tokenizer& pTokenizer, std::wstring& sText)
+{
+    if (pTokenizer.EndOfString())
+        return false;
+
+    if (pTokenizer.PeekChar() == '"')
+        return ReadQuoted(pTokenizer, sText);
+
+    sText = ra::Widen(pTokenizer.ReadTo(':'));
+    pTokenizer.Consume(':');
+    return true;
+}
+
 void AssetViewModelBase::CreateServerCheckpoint()
 {
     Expects(m_pTransaction == nullptr);
@@ -253,6 +307,7 @@ void AssetViewModelBase::CommitTransaction()
         for (auto pAsset : m_vAssetDefinitions)
         {
             Expects(pAsset != nullptr);
+
             const auto nState = ra::itoe<AssetChanges>(GetValue(*pAsset->m_pProperty));
             if (nState == AssetChanges::Unpublished)
             {
@@ -273,6 +328,7 @@ void AssetViewModelBase::CommitTransaction()
         for (auto pAsset : m_vAssetDefinitions)
         {
             Expects(pAsset != nullptr);
+
             const auto nState = ra::itoe<AssetChanges>(GetValue(*pAsset->m_pProperty));
             if (nState == AssetChanges::Modified)
             {
@@ -306,6 +362,7 @@ void AssetViewModelBase::RevertTransaction()
     for (auto pAsset : m_vAssetDefinitions)
     {
         Expects(pAsset != nullptr);
+
         const auto nState = ra::itoe<AssetChanges>(GetValue(*pAsset->m_pProperty));
         switch (nState)
         {
diff --git a/src/ui/viewmodels/AssetViewModelBase.hh b/src/ui/viewmodels/AssetViewModelBase.hh
index 8bc204ef..4d8001da 100644
--- a/src/ui/viewmodels/AssetViewModelBase.hh
+++ b/src/ui/viewmodels/AssetViewModelBase.hh
@@ -6,6 +6,7 @@
 
 #include "ui\TransactionalViewModelBase.hh"
 
+#include "RA_StringUtils.h"
 #include "ra_utility.h"
 
 namespace ra {
@@ -42,7 +43,7 @@ enum class AssetChanges
     Unpublished, // local data differs from server data
 };
 
-class AssetViewModelBase : protected TransactionalViewModelBase
+class AssetViewModelBase : public TransactionalViewModelBase
 {
 public:
     AssetViewModelBase() noexcept;
@@ -67,6 +68,11 @@ public:
     /// </summary>
     uint32_t GetID() const { return ra::to_unsigned(GetValue(IDProperty)); }
 
+    /// <summary>
+    /// Sets the asset id.
+    /// </summary>
+    void SetID(uint32_t nValue) { SetValue(IDProperty, nValue); }
+
     /// <summary>
     /// The <see cref="ModelProperty" /> for the name.
     /// </summary>
@@ -90,12 +96,12 @@ public:
     /// <summary>
     /// Gets the description of the asset.
     /// </summary>
-    const std::wstring& GetDescription() const { return GetValue(NameProperty); }
+    const std::wstring& GetDescription() const { return GetValue(DescriptionProperty); }
 
     /// <summary>
     /// Sets the description of the asset.
     /// </summary>
-    void SetDescription(const std::wstring& sValue) { SetValue(NameProperty, sValue); }
+    void SetDescription(const std::wstring& sValue) { SetValue(DescriptionProperty, sValue); }
 
     /// <summary>
     /// The <see cref="ModelProperty" /> for the asset category.
@@ -148,6 +154,7 @@ public:
     void SetSelected(bool bValue) { SetValue(IsSelectedProperty, bValue); }
 
     virtual void Serialize(ra::services::TextWriter& pWriter) const = 0;
+    virtual bool Deserialize(ra::Tokenizer& pTokenizer) = 0;
 
     /// <summary>
     /// Captures the current state of the asset as a reflection of the state on the server.
@@ -210,8 +217,19 @@ protected:
     static void WritePossiblyQuoted(ra::services::TextWriter& pWriter, const std::string& sText);
     static void WritePossiblyQuoted(ra::services::TextWriter& pWriter, const std::wstring& sText);
 
+    static bool ReadNumber(ra::Tokenizer& pTokenizer, uint32_t& nValue);
+    static bool ReadQuoted(ra::Tokenizer& pTokenizer, std::string& sText);
+    static bool ReadQuoted(ra::Tokenizer& pTokenizer, std::wstring& sText);
+    static bool ReadPossiblyQuoted(ra::Tokenizer& pTokenizer, std::string& sText);
+    static bool ReadPossiblyQuoted(ra::Tokenizer& pTokenizer, std::wstring& sText);
+
     void OnValueChanged(const BoolModelProperty::ChangeArgs& args) override;
 
+    // Hide the public members of TransactionalViewModelBase that we're redefining
+    using TransactionalViewModelBase::BeginTransaction;
+    using TransactionalViewModelBase::CommitTransaction;
+    using TransactionalViewModelBase::RevertTransaction;
+
     void CommitTransaction() override;
     void RevertTransaction() override;
 };
diff --git a/src/ui/viewmodels/PopupViewModelBase.cpp b/src/ui/viewmodels/PopupViewModelBase.cpp
index 8e357fd7..56671d1d 100644
--- a/src/ui/viewmodels/PopupViewModelBase.cpp
+++ b/src/ui/viewmodels/PopupViewModelBase.cpp
@@ -19,7 +19,7 @@ int PopupViewModelBase::GetFadeOffset(double fAnimationProgress, double fTotalAn
     {
         // fading in
         const auto fPercentage = (fInOutTime - fAnimationProgress) / fInOutTime;
-        Expects(nTargetOffset > nInitialOffset);
+        Expects(nTargetOffset >= nInitialOffset);
         const auto nOffset = to_unsigned(nTargetOffset - nInitialOffset) * (fPercentage * fPercentage);
         nNewOffset = ftol(nTargetOffset - nOffset);
     }
@@ -32,7 +32,7 @@ int PopupViewModelBase::GetFadeOffset(double fAnimationProgress, double fTotalAn
     {
         // fading out
         const auto fPercentage = (fTotalAnimationTime - fInOutTime - fAnimationProgress) / fInOutTime;
-        Expects(nTargetOffset > nInitialOffset);
+        Expects(nTargetOffset >= nInitialOffset);
         const auto nOffset = to_unsigned(nTargetOffset - nInitialOffset) * (fPercentage * fPercentage);
         nNewOffset = ftoi(nTargetOffset - nOffset);
     }
diff --git a/src/ui/viewmodels/WindowManager.hh b/src/ui/viewmodels/WindowManager.hh
index 0b22b821..aface792 100644
--- a/src/ui/viewmodels/WindowManager.hh
+++ b/src/ui/viewmodels/WindowManager.hh
@@ -1,6 +1,7 @@
 #ifndef RA_UI_WINDOW_MANAGER
 #define RA_UI_WINDOW_MANAGER
 
+#include "AssetListViewModel.hh"
 #include "CodeNotesViewModel.hh"
 #include "EmulatorViewModel.hh"
 #include "MemoryBookmarksViewModel.hh"
@@ -15,6 +16,7 @@ class WindowManager {
 public:
     EmulatorViewModel Emulator;
     RichPresenceMonitorViewModel RichPresenceMonitor;
+    AssetListViewModel AssetList;
     MemoryBookmarksViewModel MemoryBookmarks;
     MemoryInspectorViewModel MemoryInspector;
     CodeNotesViewModel CodeNotes;
diff --git a/src/ui/win32/Desktop.cpp b/src/ui/win32/Desktop.cpp
index 65537cd1..df9a038a 100644
--- a/src/ui/win32/Desktop.cpp
+++ b/src/ui/win32/Desktop.cpp
@@ -136,6 +136,14 @@ IDialogPresenter* Desktop::GetDialogPresenter(const WindowViewModelBase& oViewMo
     return nullptr;
 }
 
+HWND Desktop::GetMainHWnd() const noexcept
+{
+    if (m_pWindowBinding != nullptr)
+        return m_pWindowBinding->GetHWnd();
+
+    return nullptr;
+}
+
 void Desktop::SetMainHWnd(HWND hWnd)
 {
     if (m_pWindowBinding == nullptr)
@@ -145,6 +153,8 @@ void Desktop::SetMainHWnd(HWND hWnd)
     }
 
     m_pWindowBinding->SetHWND(hWnd);
+
+    g_RAMainWnd = hWnd;
 }
 
 std::string Desktop::GetRunningExecutable() const
diff --git a/src/ui/win32/Desktop.hh b/src/ui/win32/Desktop.hh
index b32f55e2..357833ee 100644
--- a/src/ui/win32/Desktop.hh
+++ b/src/ui/win32/Desktop.hh
@@ -26,6 +26,7 @@ public:
 
     void Shutdown() noexcept override;
 
+    HWND GetMainHWnd() const noexcept;
     void SetMainHWnd(HWND hWnd);
     std::string GetRunningExecutable() const override;
     std::string GetOSVersionString() const override;
diff --git a/src/ui/win32/RichPresenceDialog.cpp b/src/ui/win32/RichPresenceDialog.cpp
index b570f633..e94c1a61 100644
--- a/src/ui/win32/RichPresenceDialog.cpp
+++ b/src/ui/win32/RichPresenceDialog.cpp
@@ -1,6 +1,6 @@
 #include "RichPresenceDialog.hh"
 
-#include "RA_Core.h"
+#include "RA_Log.h"
 #include "RA_Resource.h"
 
 namespace ra {


Test patch:
diff --git a/tests/RA_Integration.Tests.vcxproj b/tests/RA_Integration.Tests.vcxproj
index fc9a456d..864b7d92 100644
--- a/tests/RA_Integration.Tests.vcxproj
+++ b/tests/RA_Integration.Tests.vcxproj
@@ -312,6 +312,7 @@
     <ClCompile Include="..\src\ui\Theme.cpp" />
     <ClCompile Include="..\src\ui\TransactionalViewModelBase.cpp" />
     <ClCompile Include="..\src\ui\ViewModelCollection.cpp" />
+    <ClCompile Include="..\src\ui\viewmodels\AchievementViewModel.cpp" />
     <ClCompile Include="..\src\ui\viewmodels\AssetListViewModel.cpp" />
     <ClCompile Include="..\src\ui\viewmodels\AssetViewModelBase.cpp" />
     <ClCompile Include="..\src\ui\viewmodels\BrokenAchievementsViewModel.cpp" />
diff --git a/tests/RA_Integration.Tests.vcxproj.filters b/tests/RA_Integration.Tests.vcxproj.filters
index 300b902b..23bcdd31 100644
--- a/tests/RA_Integration.Tests.vcxproj.filters
+++ b/tests/RA_Integration.Tests.vcxproj.filters
@@ -360,6 +360,9 @@
     <ClCompile Include="ui\viewmodels\TriggerViewModel_Tests.cpp">
       <Filter>Tests\UI\ViewModels</Filter>
     </ClCompile>
+    <ClCompile Include="..\src\ui\viewmodels\AchievementViewModel.cpp">
+      <Filter>Code</Filter>
+    </ClCompile>
   </ItemGroup>
   <ItemGroup>
     <None Include="base.props" />
diff --git a/tests/RA_Interface_Tests.cpp b/tests/RA_Interface_Tests.cpp
index 0d44b541..74c3265a 100644
--- a/tests/RA_Interface_Tests.cpp
+++ b/tests/RA_Interface_Tests.cpp
@@ -68,72 +68,78 @@ public:
 
         Assert::IsNotNull((const void*)_RA_IntegrationVersion);
         Assert::IsNotNull((const void*)_RA_HostName);
+        Assert::IsNotNull((const void*)_RA_HostUrl);
         Assert::IsNotNull((const void*)_RA_InitI);
         Assert::IsNotNull((const void*)_RA_InitOffline);
+        Assert::IsNotNull((const void*)_RA_InitClient);
+        Assert::IsNotNull((const void*)_RA_InitClientOffline);
+        Assert::IsNotNull((const void*)_RA_InstallSharedFunctions);
+        Assert::IsNotNull((const void*)_RA_CreatePopupMenu);
+        Assert::IsNotNull((const void*)_RA_InvokeDialog);
         Assert::IsNotNull((const void*)_RA_SetUserAgentDetail);
-        Assert::IsNotNull((const void*)_RA_UpdateHWnd);
-        Assert::IsNotNull((const void*)_RA_Shutdown);
         Assert::IsNotNull((const void*)_RA_AttemptLogin);
-        Assert::IsNotNull((const void*)_RA_NavigateOverlay);
-        Assert::IsNotNull((const void*)_RA_UpdateOverlay);
-        Assert::IsNotNull((const void*)_RA_RenderOverlay);
+        Assert::IsNotNull((const void*)_RA_SetConsoleID);
+        Assert::IsNotNull((const void*)_RA_ClearMemoryBanks);
+        Assert::IsNotNull((const void*)_RA_InstallMemoryBank);
+        Assert::IsNotNull((const void*)_RA_Shutdown);
         Assert::IsNotNull((const void*)_RA_IsOverlayFullyVisible);
-        Assert::IsNotNull((const void*)_RA_OnLoadNewRom);
+        Assert::IsNotNull((const void*)_RA_SetPaused);
+        Assert::IsNotNull((const void*)_RA_NavigateOverlay);
+        Assert::IsNotNull((const void*)_RA_UpdateHWnd);
         Assert::IsNotNull((const void*)_RA_IdentifyRom);
+        Assert::IsNotNull((const void*)_RA_IdentifyHash);
         Assert::IsNotNull((const void*)_RA_ActivateGame);
-        Assert::IsNotNull((const void*)_RA_InstallMemoryBank);
-        Assert::IsNotNull((const void*)_RA_ClearMemoryBanks);
-        Assert::IsNotNull((const void*)_RA_UpdateAppTitle);
+        Assert::IsNotNull((const void*)_RA_OnLoadNewRom);
         Assert::IsNotNull((const void*)_RA_ConfirmLoadNewRom);
-        Assert::IsNotNull((const void*)_RA_CreatePopupMenu);
-        Assert::IsNotNull((const void*)_RA_InvokeDialog);
-        Assert::IsNotNull((const void*)_RA_SetPaused);
-        Assert::IsNotNull((const void*)_RA_OnLoadState);
-        Assert::IsNotNull((const void*)_RA_OnSaveState);
-        Assert::IsNotNull((const void*)_RA_CaptureState);
-        Assert::IsNotNull((const void*)_RA_RestoreState);
-        Assert::IsNotNull((const void*)_RA_OnReset);
         Assert::IsNotNull((const void*)_RA_DoAchievementsFrame);
-        Assert::IsNotNull((const void*)_RA_SetConsoleID);
+        Assert::IsNotNull((const void*)_RA_UpdateAppTitle);
+        Assert::IsNotNull((const void*)_RA_UserName);
         Assert::IsNotNull((const void*)_RA_HardcoreModeIsActive);
         Assert::IsNotNull((const void*)_RA_WarnDisableHardcore);
-        Assert::IsNotNull((const void*)_RA_InstallSharedFunctions);
+        Assert::IsNotNull((const void*)_RA_OnReset);
+        Assert::IsNotNull((const void*)_RA_OnSaveState);
+        Assert::IsNotNull((const void*)_RA_OnLoadState);
+        Assert::IsNotNull((const void*)_RA_CaptureState);
+        Assert::IsNotNull((const void*)_RA_RestoreState);
     }
 
     void AssertShutdown()
     {
         Assert::IsNull((const void*)_RA_IntegrationVersion);
         Assert::IsNull((const void*)_RA_HostName);
+        Assert::IsNull((const void*)_RA_HostUrl);
         Assert::IsNull((const void*)_RA_InitI);
         Assert::IsNull((const void*)_RA_InitOffline);
+        Assert::IsNull((const void*)_RA_InitClient);
+        Assert::IsNull((const void*)_RA_InitClientOffline);
+        Assert::IsNull((const void*)_RA_InstallSharedFunctions);
+        Assert::IsNull((const void*)_RA_CreatePopupMenu);
+        Assert::IsNull((const void*)_RA_InvokeDialog);
         Assert::IsNull((const void*)_RA_SetUserAgentDetail);
-        Assert::IsNull((const void*)_RA_UpdateHWnd);
-        Assert::IsNull((const void*)_RA_Shutdown);
         Assert::IsNull((const void*)_RA_AttemptLogin);
-        Assert::IsNull((const void*)_RA_NavigateOverlay);
-        Assert::IsNull((const void*)_RA_UpdateOverlay);
-        Assert::IsNull((const void*)_RA_RenderOverlay);
+        Assert::IsNull((const void*)_RA_SetConsoleID);
+        Assert::IsNull((const void*)_RA_ClearMemoryBanks);
+        Assert::IsNull((const void*)_RA_InstallMemoryBank);
+        Assert::IsNull((const void*)_RA_Shutdown);
         Assert::IsNull((const void*)_RA_IsOverlayFullyVisible);
-        Assert::IsNull((const void*)_RA_OnLoadNewRom);
+        Assert::IsNull((const void*)_RA_SetPaused);
+        Assert::IsNull((const void*)_RA_NavigateOverlay);
+        Assert::IsNull((const void*)_RA_UpdateHWnd);
         Assert::IsNull((const void*)_RA_IdentifyRom);
+        Assert::IsNull((const void*)_RA_IdentifyHash);
         Assert::IsNull((const void*)_RA_ActivateGame);
-        Assert::IsNull((const void*)_RA_InstallMemoryBank);
-        Assert::IsNull((const void*)_RA_ClearMemoryBanks);
-        Assert::IsNull((const void*)_RA_UpdateAppTitle);
+        Assert::IsNull((const void*)_RA_OnLoadNewRom);
         Assert::IsNull((const void*)_RA_ConfirmLoadNewRom);
-        Assert::IsNull((const void*)_RA_CreatePopupMenu);
-        Assert::IsNull((const void*)_RA_InvokeDialog);
-        Assert::IsNull((const void*)_RA_SetPaused);
-        Assert::IsNull((const void*)_RA_OnLoadState);
-        Assert::IsNull((const void*)_RA_OnSaveState);
-        Assert::IsNull((const void*)_RA_CaptureState);
-        Assert::IsNull((const void*)_RA_RestoreState);
-        Assert::IsNull((const void*)_RA_OnReset);
         Assert::IsNull((const void*)_RA_DoAchievementsFrame);
-        Assert::IsNull((const void*)_RA_SetConsoleID);
+        Assert::IsNull((const void*)_RA_UpdateAppTitle);
+        Assert::IsNull((const void*)_RA_UserName);
         Assert::IsNull((const void*)_RA_HardcoreModeIsActive);
         Assert::IsNull((const void*)_RA_WarnDisableHardcore);
-        Assert::IsNull((const void*)_RA_InstallSharedFunctions);
+        Assert::IsNull((const void*)_RA_OnReset);
+        Assert::IsNull((const void*)_RA_OnSaveState);
+        Assert::IsNull((const void*)_RA_OnLoadState);
+        Assert::IsNull((const void*)_RA_CaptureState);
+        Assert::IsNull((const void*)_RA_RestoreState);
 
         Assert::IsNull(g_hRADLL);
     }
diff --git a/tests/RA_UnitTestHelpers.h b/tests/RA_UnitTestHelpers.h
index 1f81622c..b4576b64 100644
--- a/tests/RA_UnitTestHelpers.h
+++ b/tests/RA_UnitTestHelpers.h
@@ -16,6 +16,7 @@
 #include "ui\ImageReference.hh"
 #include "ui\WindowViewModelBase.hh"
 #include "ui\viewmodels\MessageBoxViewModel.hh"
+#include "ui\viewmodels\AssetViewModelBase.hh"
 
 // The rcheevos nameless struct warning is only affecting the test project, for now we have
 // to disable the warning in the project or pragmatically in rcheevos. Careful not to use nameless structs here.
@@ -203,6 +204,61 @@ std::wstring ToString<Achievement::Category>(const Achievement::Category& catego
     }
 }
 
+template<>
+std::wstring ToString<ra::ui::viewmodels::AssetCategory>(
+    const ra::ui::viewmodels::AssetCategory& nAssetCategory)
+{
+    switch (nAssetCategory)
+    {
+        case ra::ui::viewmodels::AssetCategory::Local:
+            return L"Local";
+        case ra::ui::viewmodels::AssetCategory::Core:
+            return L"Core";
+        case ra::ui::viewmodels::AssetCategory::Unofficial:
+            return L"Unofficial";
+        default:
+            return std::to_wstring(static_cast<int>(nAssetCategory));
+    }
+}
+
+template<>
+std::wstring ToString<ra::ui::viewmodels::AssetState>(
+    const ra::ui::viewmodels::AssetState& nAssetState)
+{
+    switch (nAssetState)
+    {
+        case ra::ui::viewmodels::AssetState::Inactive:
+            return L"Inactive";
+        case ra::ui::viewmodels::AssetState::Active:
+            return L"Active";
+        case ra::ui::viewmodels::AssetState::Triggered:
+            return L"Triggered";
+        case ra::ui::viewmodels::AssetState::Waiting:
+            return L"Waiting";
+        case ra::ui::viewmodels::AssetState::Paused:
+            return L"Paused";
+        default:
+            return std::to_wstring(static_cast<int>(nAssetState));
+    }
+}
+
+template<>
+std::wstring ToString<ra::ui::viewmodels::AssetChanges>(
+    const ra::ui::viewmodels::AssetChanges& nAssetChanges)
+{
+    switch (nAssetChanges)
+    {
+        case ra::ui::viewmodels::AssetChanges::None:
+            return L"None";
+        case ra::ui::viewmodels::AssetChanges::Modified:
+            return L"Modified";
+        case ra::ui::viewmodels::AssetChanges::Unpublished:
+            return L"Unpublished";
+        default:
+            return std::to_wstring(static_cast<int>(nAssetChanges));
+    }
+}
+
 #pragma warning(pop)
 
 } // namespace CppUnitTestFramework
diff --git a/tests/data/EmulatorContext_Tests.cpp b/tests/data/EmulatorContext_Tests.cpp
index 39a3f9a9..16943361 100644
--- a/tests/data/EmulatorContext_Tests.cpp
+++ b/tests/data/EmulatorContext_Tests.cpp
@@ -64,47 +64,50 @@ public:
     {
         EmulatorContextHarness emulator;
 
-        emulator.Initialize(EmulatorID::RA_FCEUX);
+        emulator.Initialize(EmulatorID::RA_FCEUX, nullptr);
         Assert::AreEqual(std::string("RANes"), emulator.GetClientName());
 
-        emulator.Initialize(EmulatorID::RA_Gens);
+        emulator.Initialize(EmulatorID::RA_Gens, nullptr);
         Assert::AreEqual(std::string("RAGens_REWiND"), emulator.GetClientName());
 
-        emulator.Initialize(EmulatorID::RA_Libretro);
+        emulator.Initialize(EmulatorID::RA_Libretro, nullptr);
         Assert::AreEqual(std::string("RALibRetro"), emulator.GetClientName());
 
-        emulator.Initialize(EmulatorID::RA_Meka);
+        emulator.Initialize(EmulatorID::RA_Meka, nullptr);
         Assert::AreEqual(std::string("RAMeka"), emulator.GetClientName());
 
-        emulator.Initialize(EmulatorID::RA_Nester);
+        emulator.Initialize(EmulatorID::RA_Nester, nullptr);
         Assert::AreEqual(std::string("RANes"), emulator.GetClientName());
 
-        emulator.Initialize(EmulatorID::RA_PCE);
+        emulator.Initialize(EmulatorID::RA_PCE, nullptr);
         Assert::AreEqual(std::string("RAPCE"), emulator.GetClientName());
 
-        emulator.Initialize(EmulatorID::RA_Project64);
+        emulator.Initialize(EmulatorID::RA_Project64, nullptr);
         Assert::AreEqual(std::string("RAP64"), emulator.GetClientName());
 
-        emulator.Initialize(EmulatorID::RA_QUASI88);
+        emulator.Initialize(EmulatorID::RA_QUASI88, nullptr);
         Assert::AreEqual(std::string("RAQUASI88"), emulator.GetClientName());
 
-        emulator.Initialize(EmulatorID::RA_Snes9x);
+        emulator.Initialize(EmulatorID::RA_Snes9x, nullptr);
         Assert::AreEqual(std::string("RASnes9X"), emulator.GetClientName());
 
-        emulator.Initialize(EmulatorID::RA_VisualboyAdvance);
+        emulator.Initialize(EmulatorID::RA_VisualboyAdvance, nullptr);
         Assert::AreEqual(std::string("RAVisualBoyAdvance"), emulator.GetClientName());
 
-        emulator.Initialize(EmulatorID::RA_AppleWin);
+        emulator.Initialize(EmulatorID::RA_AppleWin, nullptr);
         Assert::AreEqual(std::string("RAppleWin"), emulator.GetClientName());
 
-        emulator.Initialize(EmulatorID::RA_Oricutron);
+        emulator.Initialize(EmulatorID::RA_Oricutron, nullptr);
         Assert::AreEqual(std::string("RAOricutron"), emulator.GetClientName());
+
+        emulator.Initialize(EmulatorID::UnknownEmulator, "CustomName");
+        Assert::AreEqual(std::string("CustomName"), emulator.GetClientName());
     }
 
     TEST_METHOD(TestValidateClientVersionCurrent)
     {
         EmulatorContextHarness emulator;
-        emulator.Initialize(EmulatorID::RA_Snes9x);
+        emulator.Initialize(EmulatorID::RA_Snes9x, nullptr);
         emulator.SetClientVersion("0.56");
         emulator.mockServer.HandleRequest<ra::api::LatestClient>([](const ra::api::LatestClient::Request& request, ra::api::LatestClient::Response& response)
         {
@@ -121,7 +124,7 @@ public:
     TEST_METHOD(TestValidateClientVersionNewer)
     {
         EmulatorContextHarness emulator;
-        emulator.Initialize(EmulatorID::RA_Snes9x);
+        emulator.Initialize(EmulatorID::RA_Snes9x, nullptr);
         emulator.MockVersions("0.57", "0.56");
 
         Assert::IsTrue(emulator.ValidateClientVersion());
@@ -131,7 +134,7 @@ public:
     TEST_METHOD(TestValidateClientVersionApiErrorNonHardcore)
     {
         EmulatorContextHarness emulator;
-        emulator.Initialize(EmulatorID::RA_Snes9x);
+        emulator.Initialize(EmulatorID::RA_Snes9x, nullptr);
         emulator.SetClientVersion("0.57");
         emulator.mockServer.HandleRequest<ra::api::LatestClient>([](const ra::api::LatestClient::Request&, ra::api::LatestClient::Response& response)
         {
@@ -153,7 +156,7 @@ public:
     TEST_METHOD(TestValidateClientVersionApiErrorNonHardcoreLoggedIn)
     {
         EmulatorContextHarness emulator;
-        emulator.Initialize(EmulatorID::RA_Snes9x);
+        emulator.Initialize(EmulatorID::RA_Snes9x, nullptr);
         emulator.SetClientVersion("0.57");
         emulator.mockUserContext.Initialize("User", "Token");
         emulator.mockServer.HandleRequest<ra::api::LatestClient>([](const ra::api::LatestClient::Request&, ra::api::LatestClient::Response& response)
@@ -177,7 +180,7 @@ public:
     TEST_METHOD(TestValidateClientVersionApiErrorHardcore)
     {
         EmulatorContextHarness emulator;
-        emulator.Initialize(EmulatorID::RA_Snes9x);
+        emulator.Initialize(EmulatorID::RA_Snes9x, nullptr);
         emulator.SetClientVersion("0.57");
         emulator.mockConfiguration.SetFeatureEnabled(ra::services::Feature::Hardcore, true);
         emulator.mockConfiguration.SetApiToken("Token");
@@ -201,7 +204,7 @@ public:
     TEST_METHOD(TestValidateClientVersionApiErrorHardcoreLoginDisabled)
     {
         EmulatorContextHarness emulator;
-        emulator.Initialize(EmulatorID::RA_Snes9x);
+        emulator.Initialize(EmulatorID::RA_Snes9x, nullptr);
         emulator.SetClientVersion("0.57");
         emulator.mockConfiguration.SetFeatureEnabled(ra::services::Feature::Hardcore, true);
         emulator.mockUserContext.DisableLogin();
@@ -225,7 +228,7 @@ public:
     TEST_METHOD(TestValidateClientVersionApiErrorHardcoreLoggedIn)
     {
         EmulatorContextHarness emulator;
-        emulator.Initialize(EmulatorID::RA_Snes9x);
+        emulator.Initialize(EmulatorID::RA_Snes9x, nullptr);
         emulator.SetClientVersion("0.57");
         emulator.mockConfiguration.SetFeatureEnabled(ra::services::Feature::Hardcore, true);
         emulator.mockUserContext.Initialize("User", "Token");
@@ -250,7 +253,7 @@ public:
     TEST_METHOD(TestValidateClientVersionOlderNonHardcoreIgnore)
     {
         EmulatorContextHarness emulator;
-        emulator.Initialize(EmulatorID::RA_Snes9x);
+        emulator.Initialize(EmulatorID::RA_Snes9x, nullptr);
         emulator.mockConfiguration.SetHostName("host");
         emulator.MockVersions("0.57.0.0", "0.58.0.0");
         emulator.mockDesktop.ExpectWindow<ra::ui::viewmodels::MessageBoxViewModel>([](ra::ui::viewmodels::MessageBoxViewModel& vmMessageBox)
@@ -268,7 +271,7 @@ public:
     TEST_METHOD(TestValidateClientVersionOlderNonHardcoreAcknowledge)
     {
         EmulatorContextHarness emulator;
-        emulator.Initialize(EmulatorID::RA_Snes9x);
+        emulator.Initialize(EmulatorID::RA_Snes9x, nullptr);
         emulator.mockConfiguration.SetHostName("host");
         emulator.MockVersions("0.57.0.0", "0.58.0.0");
         emulator.mockDesktop.ExpectWindow<ra::ui::viewmodels::MessageBoxViewModel>([](ra::ui::viewmodels::MessageBoxViewModel& vmMessageBox)
@@ -286,7 +289,7 @@ public:
     TEST_METHOD(TestValidateClientVersionOlderHardcoreProceed)
     {
         EmulatorContextHarness emulator;
-        emulator.Initialize(EmulatorID::RA_Snes9x);
+        emulator.Initialize(EmulatorID::RA_Snes9x, nullptr);
         emulator.mockConfiguration.SetHostName("host");
         emulator.mockConfiguration.SetFeatureEnabled(ra::services::Feature::Hardcore, true);
         emulator.MockVersions("0.57.0.0", "0.58.0.0");
@@ -306,7 +309,7 @@ public:
     TEST_METHOD(TestValidateClientVersionOlderHardcoreAcknowledge)
     {
         EmulatorContextHarness emulator;
-        emulator.Initialize(EmulatorID::RA_Snes9x);
+        emulator.Initialize(EmulatorID::RA_Snes9x, nullptr);
         emulator.mockConfiguration.SetHostName("host");
         emulator.mockConfiguration.SetFeatureEnabled(ra::services::Feature::Hardcore, true);
         emulator.MockVersions("0.57.0.0", "0.58.0.0");
@@ -326,7 +329,7 @@ public:
     TEST_METHOD(TestValidateClientVersionAboveMinimumHardcoreProceed)
     {
         EmulatorContextHarness emulator;
-        emulator.Initialize(EmulatorID::RA_Snes9x);
+        emulator.Initialize(EmulatorID::RA_Snes9x, nullptr);
         emulator.mockConfiguration.SetHostName("host");
         emulator.mockConfiguration.SetFeatureEnabled(ra::services::Feature::Hardcore, true);
         emulator.MockVersions("0.57.0.0", "0.58.0.0", "0.56.0.0");
@@ -346,7 +349,7 @@ public:
     TEST_METHOD(TestValidateClientVersionAboveMinimumHardcoreAcknowledge)
     {
         EmulatorContextHarness emulator;
-        emulator.Initialize(EmulatorID::RA_Snes9x);
+        emulator.Initialize(EmulatorID::RA_Snes9x, nullptr);
         emulator.mockConfiguration.SetHostName("host");
         emulator.mockConfiguration.SetFeatureEnabled(ra::services::Feature::Hardcore, true);
         emulator.MockVersions("0.57.0.0", "0.58.0.0", "0.56.0.0");
@@ -366,7 +369,7 @@ public:
     TEST_METHOD(TestValidateClientVersionAtMinimumHardcoreProceed)
     {
         EmulatorContextHarness emulator;
-        emulator.Initialize(EmulatorID::RA_Snes9x);
+        emulator.Initialize(EmulatorID::RA_Snes9x, nullptr);
         emulator.mockConfiguration.SetHostName("host");
         emulator.mockConfiguration.SetFeatureEnabled(ra::services::Feature::Hardcore, true);
         emulator.MockVersions("0.57.0.0", "0.58.0.0", "0.57.0.0");
@@ -387,7 +390,7 @@ public:
     TEST_METHOD(TestValidateClientVersionBelowMinimumHardcoreAcknowledge)
     {
         EmulatorContextHarness emulator;
-        emulator.Initialize(EmulatorID::RA_Snes9x);
+        emulator.Initialize(EmulatorID::RA_Snes9x, nullptr);
         emulator.mockConfiguration.SetHostName("host");
         emulator.mockConfiguration.SetFeatureEnabled(ra::services::Feature::Hardcore, true);
         emulator.MockVersions("0.56.0.0", "0.58.0.0", "0.57.0.0");
@@ -407,7 +410,7 @@ public:
     TEST_METHOD(TestValidateClientVersionCurrentAboveMinimum)
     {
         EmulatorContextHarness emulator;
-        emulator.Initialize(EmulatorID::RA_Snes9x);
+        emulator.Initialize(EmulatorID::RA_Snes9x, nullptr);
         emulator.MockVersions("0.56", "0.56", "0.55");
 
         Assert::IsTrue(emulator.ValidateClientVersion());
@@ -418,7 +421,7 @@ public:
     {
         // ensures "1.0" doesn't get trimmed to "1"
         EmulatorContextHarness emulator;
-        emulator.Initialize(EmulatorID::RA_Snes9x);
+        emulator.Initialize(EmulatorID::RA_Snes9x, nullptr);
         emulator.mockConfiguration.SetHostName("host");
         emulator.mockConfiguration.SetFeatureEnabled(ra::services::Feature::Hardcore, true);
         emulator.MockVersions("0.57.0.0", "1.0.0.0");
@@ -437,7 +440,7 @@ public:
     {
         // ensures "1.0" doesn't get trimmed to "1"
         EmulatorContextHarness emulator;
-        emulator.Initialize(EmulatorID::RA_Snes9x);
+        emulator.Initialize(EmulatorID::RA_Snes9x, nullptr);
         emulator.mockConfiguration.SetHostName("host");
         emulator.mockConfiguration.SetFeatureEnabled(ra::services::Feature::Hardcore, true);
         emulator.MockVersions("1.0.0.0", "1.0.1.0");
@@ -455,7 +458,7 @@ public:
     TEST_METHOD(TestValidateClientVersionUnknownClient)
     {
         EmulatorContextHarness emulator;
-        emulator.Initialize(EmulatorID::RA_Snes9x);
+        emulator.Initialize(EmulatorID::RA_Snes9x, nullptr);
         emulator.SetClientVersion("1.0");
         emulator.mockServer.HandleRequest<ra::api::LatestClient>([](const ra::api::LatestClient::Request&, ra::api::LatestClient::Response& response)
         {
@@ -487,25 +490,32 @@ public:
     TEST_METHOD(TestUserAgent)
     {
         EmulatorContextHarness emulator;
-        emulator.Initialize(EmulatorID::RA_Snes9x);
+        emulator.Initialize(EmulatorID::RA_Snes9x, nullptr);
         emulator.SetClientVersion("1.0");
 
         std::string sVersion = emulator.mockHttpRequester.GetUserAgent();
         ReplaceIntegrationVersion(sVersion);
         Assert::AreEqual(std::string("RASnes9X/1.0 (UnitTests) Integration/VERSION"), sVersion);
 
-        emulator.Initialize(EmulatorID::RA_AppleWin);
+        emulator.Initialize(EmulatorID::RA_AppleWin, nullptr);
         emulator.SetClientVersion("1.1.1");
 
         sVersion = emulator.mockHttpRequester.GetUserAgent();
         ReplaceIntegrationVersion(sVersion);
         Assert::AreEqual(std::string("RAppleWin/1.1.1 (UnitTests) Integration/VERSION"), sVersion);
+
+        emulator.Initialize(EmulatorID::UnknownEmulator, "CustomName");
+        emulator.SetClientVersion("3.1a");
+
+        sVersion = emulator.mockHttpRequester.GetUserAgent();
+        ReplaceIntegrationVersion(sVersion);
+        Assert::AreEqual(std::string("CustomName/3.1a (UnitTests) Integration/VERSION"), sVersion);
     }
 
     TEST_METHOD(TestUserAgentClientDetail)
     {
         EmulatorContextHarness emulator;
-        emulator.Initialize(EmulatorID::RA_Libretro);
+        emulator.Initialize(EmulatorID::RA_Libretro, nullptr);
         emulator.SetClientVersion("1.0.15");
         emulator.SetClientUserAgentDetail("fceumm_libretro/(SVN)_0a0fdb8");
 
@@ -679,7 +689,7 @@ public:
     TEST_METHOD(TestEnableHardcoreModeVersionOlderGameLoadedAccept)
     {
         EmulatorContextHarness emulator;
-        emulator.Initialize(EmulatorID::RA_Snes9x);
+        emulator.Initialize(EmulatorID::RA_Snes9x, nullptr);
         emulator.mockConfiguration.SetHostName("host");
         emulator.mockUserContext.Initialize("User", "Token");
         emulator.mockGameContext.SetGameId(1U);
@@ -709,7 +719,7 @@ public:
     TEST_METHOD(TestEnableHardcoreModeVersionOlderGameLoadedCancel)
     {
         EmulatorContextHarness emulator;
-        emulator.Initialize(EmulatorID::RA_Snes9x);
+        emulator.Initialize(EmulatorID::RA_Snes9x, nullptr);
         emulator.mockConfiguration.SetHostName("host");
         emulator.mockUserContext.Initialize("User", "Token");
         emulator.mockGameContext.SetGameId(1U);
@@ -779,7 +789,7 @@ public:
     TEST_METHOD(TestEnableHardcoreModeVersionOlderGameLoadedNoPrompt)
     {
         EmulatorContextHarness emulator;
-        emulator.Initialize(EmulatorID::RA_Snes9x);
+        emulator.Initialize(EmulatorID::RA_Snes9x, nullptr);
         emulator.mockConfiguration.SetHostName("host");
         emulator.mockUserContext.Initialize("User", "Token");
         emulator.mockGameContext.SetGameId(1U);
@@ -811,7 +821,7 @@ public:
         EmulatorContextHarness emulator;
         Assert::AreEqual(std::wstring(L" -  []"), emulator.GetAppTitle(""));
 
-        emulator.Initialize(EmulatorID::RA_Snes9x);
+        emulator.Initialize(EmulatorID::RA_Snes9x, nullptr);
         emulator.SetClientVersion("0.57.0.0");
         emulator.mockConfiguration.SetHostName("retroachievements.org");
         Assert::AreEqual(std::wstring(L"RASnes9X - 0.57"), emulator.GetAppTitle(""));
@@ -820,7 +830,7 @@ public:
     TEST_METHOD(TestGetAppTitleVersion)
     {
         EmulatorContextHarness emulator;
-        emulator.Initialize(EmulatorID::RA_Snes9x);
+        emulator.Initialize(EmulatorID::RA_Snes9x, nullptr);
         emulator.mockConfiguration.SetHostName("retroachievements.org");
 
         emulator.SetClientVersion("0.57.1.1");
@@ -836,7 +846,7 @@ public:
     TEST_METHOD(TestGetAppTitleUserName)
     {
         EmulatorContextHarness emulator;
-        emulator.Initialize(EmulatorID::RA_Snes9x);
+        emulator.Initialize(EmulatorID::RA_Snes9x, nullptr);
         emulator.mockConfiguration.SetHostName("retroachievements.org");
         emulator.SetClientVersion("0.57");
 
@@ -852,7 +862,7 @@ public:
     TEST_METHOD(TestGetAppTitleCustomMessage)
     {
         EmulatorContextHarness emulator;
-        emulator.Initialize(EmulatorID::RA_Snes9x);
+        emulator.Initialize(EmulatorID::RA_Snes9x, nullptr);
         emulator.mockConfiguration.SetHostName("retroachievements.org");
         emulator.SetClientVersion("0.57");
         Assert::AreEqual(std::wstring(L"RASnes9X - 0.57 - Test"), emulator.GetAppTitle("Test"));
@@ -861,7 +871,7 @@ public:
     TEST_METHOD(TestGetAppTitleCustomHost)
     {
         EmulatorContextHarness emulator;
-        emulator.Initialize(EmulatorID::RA_Snes9x);
+        emulator.Initialize(EmulatorID::RA_Snes9x, nullptr);
         emulator.mockConfiguration.SetHostName("localhost");
         emulator.SetClientVersion("0.57");
         Assert::AreEqual(std::wstring(L"RASnes9X - 0.57 [localhost]"), emulator.GetAppTitle(""));
@@ -870,7 +880,7 @@ public:
     TEST_METHOD(TestGetAppTitleEverything)
     {
         EmulatorContextHarness emulator;
-        emulator.Initialize(EmulatorID::RA_Snes9x);
+        emulator.Initialize(EmulatorID::RA_Snes9x, nullptr);
         emulator.mockConfiguration.SetHostName("localhost");
         emulator.mockUserContext.Initialize("User", "Token");
         emulator.SetClientVersion("0.57");
@@ -881,7 +891,7 @@ public:
     {
         EmulatorContextHarness emulator;
         emulator.mockDesktop.SetRunningExecutable("C:\\Games\\Emulator\\RATest.exe");
-        emulator.Initialize(ra::itoe<EmulatorID>(9999));
+        emulator.Initialize(ra::itoe<EmulatorID>(9999), nullptr);
         Assert::AreEqual(ra::etoi(EmulatorID::UnknownEmulator), ra::etoi(emulator.GetEmulatorId()));
         Assert::AreEqual(std::string("RATest"), emulator.GetClientName());
     }
diff --git a/tests/data/GameContext_Tests.cpp b/tests/data/GameContext_Tests.cpp
index aa5db8ce..8ca641b4 100644
--- a/tests/data/GameContext_Tests.cpp
+++ b/tests/data/GameContext_Tests.cpp
@@ -4,6 +4,8 @@
 
 #include "services\AchievementRuntime.hh"
 
+#include "ui\viewmodels\AchievementViewModel.hh"
+
 #include "tests\RA_UnitTestHelpers.h"
 
 #include "tests\mocks\MockAudioSystem.hh"
@@ -16,6 +18,7 @@
 #include "tests\mocks\MockSessionTracker.hh"
 #include "tests\mocks\MockThreadPool.hh"
 #include "tests\mocks\MockUserContext.hh"
+#include "tests\mocks\MockWindowManager.hh"
 
 using namespace Microsoft::VisualStudio::CppUnitTestFramework;
 
@@ -54,6 +57,7 @@ public:
         ra::data::mocks::MockSessionTracker mockSessionTracker;
         ra::data::mocks::MockUserContext mockUser;
         ra::services::AchievementRuntime runtime;
+        ra::ui::viewmodels::mocks::MockWindowManager mockWindowManager;
 
         static const unsigned int FirstLocalId = GameContext::FirstLocalId;
 
@@ -319,6 +323,32 @@ public:
         Assert::AreEqual(1234599999, (int)pAch2->ModifiedDate());
         Assert::AreEqual(15U, pAch2->Points());
         Assert::AreEqual(std::string("1=1"), pAch2->GetTrigger());
+
+        Assert::AreEqual({ 2U }, game.mockWindowManager.AssetList.Assets().Count());
+
+        const auto* vmAch1 = dynamic_cast<ra::ui::viewmodels::AchievementViewModel*>(game.mockWindowManager.AssetList.Assets().GetItemAt(0));
+        Assert::IsNotNull(vmAch1);
+        Ensures(vmAch1 != nullptr);
+        Assert::AreEqual(5U, vmAch1->GetID());
+        Assert::AreEqual(std::wstring(L"Ach1"), vmAch1->GetName());
+        Assert::AreEqual(std::wstring(L"Desc1"), vmAch1->GetDescription());
+        Assert::AreEqual(ra::ui::viewmodels::AssetCategory::Core, vmAch1->GetCategory());
+        Assert::AreEqual(5, vmAch1->GetPoints());
+        Assert::AreEqual(std::wstring(L"12345"), vmAch1->GetBadge());
+        Assert::AreEqual(std::string("1=1"), vmAch1->GetTrigger());
+        Assert::IsFalse(vmAch1->IsModified());
+
+        const auto* vmAch2 = dynamic_cast<ra::ui::viewmodels::AchievementViewModel*>(game.mockWindowManager.AssetList.Assets().GetItemAt(1));
+        Assert::IsNotNull(vmAch2);
+        Ensures(vmAch2 != nullptr);
+        Assert::AreEqual(7U, vmAch2->GetID());
+        Assert::AreEqual(std::wstring(L"Ach2"), vmAch2->GetName());
+        Assert::AreEqual(std::wstring(L"Desc2"), vmAch2->GetDescription());
+        Assert::AreEqual(ra::ui::viewmodels::AssetCategory::Unofficial, vmAch2->GetCategory());
+        Assert::AreEqual(15, vmAch2->GetPoints());
+        Assert::AreEqual(std::wstring(L"12345"), vmAch2->GetBadge());
+        Assert::AreEqual(std::string("1=1"), vmAch2->GetTrigger());
+        Assert::IsFalse(vmAch2->IsModified());
     }
 
     TEST_METHOD(TestLoadGameInvalidAchievementFlags)
@@ -369,6 +399,20 @@ public:
         Assert::AreEqual(1234599999, (int)pAch2->ModifiedDate());
         Assert::AreEqual(15U, pAch2->Points());
         Assert::AreEqual(std::string("1=1"), pAch2->GetTrigger());
+
+        Assert::AreEqual({ 1U }, game.mockWindowManager.AssetList.Assets().Count());
+
+        const auto* vmAch2 = dynamic_cast<ra::ui::viewmodels::AchievementViewModel*>(game.mockWindowManager.AssetList.Assets().GetItemAt(0));
+        Assert::IsNotNull(vmAch2);
+        Ensures(vmAch2 != nullptr);
+        Assert::AreEqual(7U, vmAch2->GetID());
+        Assert::AreEqual(std::wstring(L"Ach2"), vmAch2->GetName());
+        Assert::AreEqual(std::wstring(L"Desc2"), vmAch2->GetDescription());
+        Assert::AreEqual(ra::ui::viewmodels::AssetCategory::Unofficial, vmAch2->GetCategory());
+        Assert::AreEqual(15, vmAch2->GetPoints());
+        Assert::AreEqual(std::wstring(L"12345"), vmAch2->GetBadge());
+        Assert::AreEqual(std::string("1=1"), vmAch2->GetTrigger());
+        Assert::IsFalse(vmAch2->IsModified());
     }
 
     TEST_METHOD(TestLoadGameMergeLocalAchievements)
@@ -464,9 +508,70 @@ public:
         Assert::AreEqual(10U, pAch->Points());
         Assert::AreEqual(std::string("R:1=1"), pAch->GetTrigger());
 
+
+
+        Assert::AreEqual({ 4U }, game.mockWindowManager.AssetList.Assets().Count());
+
+        // server only achievement
+        const auto* vmAch = dynamic_cast<ra::ui::viewmodels::AchievementViewModel*>(game.mockWindowManager.AssetList.Assets().GetItemAt(0));
+        Assert::IsNotNull(vmAch);
+        Ensures(vmAch != nullptr);
+        Assert::AreEqual(5U, vmAch->GetID());
+        Assert::AreEqual(std::wstring(L"Ach1"), vmAch->GetName());
+        Assert::AreEqual(std::wstring(L"Desc1"), vmAch->GetDescription());
+        Assert::AreEqual(ra::ui::viewmodels::AssetCategory::Core, vmAch->GetCategory());
+        Assert::AreEqual(5, vmAch->GetPoints());
+        Assert::AreEqual(std::wstring(L"12345"), vmAch->GetBadge());
+        Assert::AreEqual(std::string("1=1"), vmAch->GetTrigger());
+        Assert::IsFalse(vmAch->IsModified());
+        Assert::AreEqual(ra::ui::viewmodels::AssetChanges::None, vmAch->GetChanges());
+
+        // local achievement data for 7 should be merged with server achievement data
+        vmAch = dynamic_cast<ra::ui::viewmodels::AchievementViewModel*>(game.mockWindowManager.AssetList.Assets().GetItemAt(1));
+        Assert::IsNotNull(vmAch);
+        Ensures(vmAch != nullptr);
+        Assert::AreEqual(7U, vmAch->GetID());
+        Assert::AreEqual(std::wstring(L"Ach2b"), vmAch->GetName());
+        Assert::AreEqual(std::wstring(L"Desc2b"), vmAch->GetDescription());
+        Assert::AreEqual(ra::ui::viewmodels::AssetCategory::Unofficial, vmAch->GetCategory());
+        Assert::AreEqual(25, vmAch->GetPoints());
+        Assert::AreEqual(std::wstring(L"54321"), vmAch->GetBadge());
+        Assert::AreEqual(std::string("1=2"), vmAch->GetTrigger());
+        Assert::IsFalse(vmAch->IsModified());
+        Assert::AreEqual(ra::ui::viewmodels::AssetChanges::Unpublished, vmAch->GetChanges());
+
+        // no server achievement, assign FirstLocalId
+        vmAch = dynamic_cast<ra::ui::viewmodels::AchievementViewModel*>(game.mockWindowManager.AssetList.Assets().GetItemAt(2));
+        Assert::IsNotNull(vmAch);
+        Ensures(vmAch != nullptr);
+        Assert::AreEqual(GameContextHarness::FirstLocalId + 2, vmAch->GetID()); // non-vms get first id and first id + 1
+        Assert::AreEqual(std::wstring(L"Ach3"), vmAch->GetName());
+        Assert::AreEqual(std::wstring(L"Desc3"), vmAch->GetDescription());
+        Assert::AreEqual(ra::ui::viewmodels::AssetCategory::Local, vmAch->GetCategory());
+        Assert::AreEqual(20, vmAch->GetPoints());
+        Assert::AreEqual(std::wstring(L"00555"), vmAch->GetBadge());
+        Assert::AreEqual(std::string("1=1"), vmAch->GetTrigger());
+        Assert::IsFalse(vmAch->IsModified());
+        Assert::AreEqual(ra::ui::viewmodels::AssetChanges::Unpublished, vmAch->GetChanges());
+
+        // no server achievement, assign next local id
+        vmAch = dynamic_cast<ra::ui::viewmodels::AchievementViewModel*>(game.mockWindowManager.AssetList.Assets().GetItemAt(3));
+        Assert::IsNotNull(vmAch);
+        Ensures(vmAch != nullptr);
+        Assert::AreEqual(GameContextHarness::FirstLocalId + 3, vmAch->GetID()); // non-vms get first id and first id + 1
+        Assert::AreEqual(std::wstring(L"Ach4"), vmAch->GetName());
+        Assert::AreEqual(std::wstring(L"Desc4"), vmAch->GetDescription());
+        Assert::AreEqual(ra::ui::viewmodels::AssetCategory::Local, vmAch->GetCategory());
+        Assert::AreEqual(10, vmAch->GetPoints());
+        Assert::AreEqual(std::wstring(L"00556"), vmAch->GetBadge());
+        Assert::AreEqual(std::string("R:1=1"), vmAch->GetTrigger());
+        Assert::IsFalse(vmAch->IsModified());
+        Assert::AreEqual(ra::ui::viewmodels::AssetChanges::Unpublished, vmAch->GetChanges());
+
+
         // new achievement should be allocated an ID higher than the largest existing local ID
         const auto& pAch2 = game.NewAchievement(Achievement::Category::Local);
-        Assert::AreEqual(GameContextHarness::FirstLocalId + 2, pAch2.ID());
+        Assert::AreEqual(GameContextHarness::FirstLocalId + 4, pAch2.ID());
     }
 
     TEST_METHOD(TestLoadGameMergeLocalAchievementsWithIds)
@@ -529,6 +634,53 @@ public:
         Assert::AreEqual(10U, pAch->Points());
         Assert::AreEqual(std::string("1=1"), pAch->GetTrigger());
 
+
+
+        Assert::AreEqual({ 3U }, game.mockWindowManager.AssetList.Assets().Count());
+
+        // 7 is not a known ID for this game, it should be loaded into a local achievement
+        const auto* vmAch = dynamic_cast<ra::ui::viewmodels::AchievementViewModel*>(game.mockWindowManager.AssetList.Assets().GetItemAt(0));
+        Assert::IsNotNull(vmAch);
+        Ensures(vmAch != nullptr);
+        Assert::AreEqual(7U, vmAch->GetID());
+        Assert::AreEqual(std::wstring(L"Ach2b"), vmAch->GetName());
+        Assert::AreEqual(std::wstring(L"Desc2b"), vmAch->GetDescription());
+        Assert::AreEqual(ra::ui::viewmodels::AssetCategory::Local, vmAch->GetCategory());
+        Assert::AreEqual(25, vmAch->GetPoints());
+        Assert::AreEqual(std::wstring(L"54321"), vmAch->GetBadge());
+        Assert::AreEqual(std::string("1=2"), vmAch->GetTrigger());
+        Assert::IsFalse(vmAch->IsModified());
+        Assert::AreEqual(ra::ui::viewmodels::AssetChanges::Unpublished, vmAch->GetChanges());
+
+        // explicit ID should be honored
+        vmAch = dynamic_cast<ra::ui::viewmodels::AchievementViewModel*>(game.mockWindowManager.AssetList.Assets().GetItemAt(1));
+        Assert::IsNotNull(vmAch);
+        Ensures(vmAch != nullptr);
+        Assert::AreEqual(999000001U, vmAch->GetID()); // non-vms get first id and first id + 1
+        Assert::AreEqual(std::wstring(L"Ach3"), vmAch->GetName());
+        Assert::AreEqual(std::wstring(L"Desc3"), vmAch->GetDescription());
+        Assert::AreEqual(ra::ui::viewmodels::AssetCategory::Local, vmAch->GetCategory());
+        Assert::AreEqual(20, vmAch->GetPoints());
+        Assert::AreEqual(std::wstring(L"00555"), vmAch->GetBadge());
+        Assert::AreEqual(std::string("1=1"), vmAch->GetTrigger());
+        Assert::IsFalse(vmAch->IsModified());
+        Assert::AreEqual(ra::ui::viewmodels::AssetChanges::Unpublished, vmAch->GetChanges());
+
+        // explicit ID should be honored
+        vmAch = dynamic_cast<ra::ui::viewmodels::AchievementViewModel*>(game.mockWindowManager.AssetList.Assets().GetItemAt(2));
+        Assert::IsNotNull(vmAch);
+        Ensures(vmAch != nullptr);
+        Assert::AreEqual(999000003U, vmAch->GetID()); // non-vms get first id and first id + 1
+        Assert::AreEqual(std::wstring(L"Ach4"), vmAch->GetName());
+        Assert::AreEqual(std::wstring(L"Desc4"), vmAch->GetDescription());
+        Assert::AreEqual(ra::ui::viewmodels::AssetCategory::Local, vmAch->GetCategory());
+        Assert::AreEqual(10, vmAch->GetPoints());
+        Assert::AreEqual(std::wstring(L"00556"), vmAch->GetBadge());
+        Assert::AreEqual(std::string("1=1"), vmAch->GetTrigger());
+        Assert::IsFalse(vmAch->IsModified());
+        Assert::AreEqual(ra::ui::viewmodels::AssetChanges::Unpublished, vmAch->GetChanges());
+
+
         // new achievement should be allocated an ID higher than the largest existing local
         // ID, even if intermediate values are available
         const auto& pAch2 = game.NewAchievement(Achievement::Category::Local);
diff --git a/tests/ui/viewmodels/AssetViewModelBase_Tests.cpp b/tests/ui/viewmodels/AssetViewModelBase_Tests.cpp
index 0ab973aa..a3140522 100644
--- a/tests/ui/viewmodels/AssetViewModelBase_Tests.cpp
+++ b/tests/ui/viewmodels/AssetViewModelBase_Tests.cpp
@@ -29,61 +29,6 @@ std::wstring ToString<ra::ui::viewmodels::AssetType>(
     }
 }
 
-template<>
-std::wstring ToString<ra::ui::viewmodels::AssetCategory>(
-    const ra::ui::viewmodels::AssetCategory& nAssetCategory)
-{
-    switch (nAssetCategory)
-    {
-        case ra::ui::viewmodels::AssetCategory::Local:
-            return L"Local";
-        case ra::ui::viewmodels::AssetCategory::Core:
-            return L"Core";
-        case ra::ui::viewmodels::AssetCategory::Unofficial:
-            return L"Unofficial";
-        default:
-            return std::to_wstring(static_cast<int>(nAssetCategory));
-    }
-}
-
-template<>
-std::wstring ToString<ra::ui::viewmodels::AssetState>(
-    const ra::ui::viewmodels::AssetState& nAssetState)
-{
-    switch (nAssetState)
-    {
-        case ra::ui::viewmodels::AssetState::Inactive:
-            return L"Inactive";
-        case ra::ui::viewmodels::AssetState::Active:
-            return L"Active";
-        case ra::ui::viewmodels::AssetState::Triggered:
-            return L"Triggered";
-        case ra::ui::viewmodels::AssetState::Waiting:
-            return L"Waiting";
-        case ra::ui::viewmodels::AssetState::Paused:
-            return L"Paused";
-        default:
-            return std::to_wstring(static_cast<int>(nAssetState));
-    }
-}
-
-template<>
-std::wstring ToString<ra::ui::viewmodels::AssetChanges>(
-    const ra::ui::viewmodels::AssetChanges& nAssetChanges)
-{
-    switch (nAssetChanges)
-    {
-        case ra::ui::viewmodels::AssetChanges::None:
-            return L"None";
-        case ra::ui::viewmodels::AssetChanges::Modified:
-            return L"Modified";
-        case ra::ui::viewmodels::AssetChanges::Unpublished:
-            return L"Unpublished";
-        default:
-            return std::to_wstring(static_cast<int>(nAssetChanges));
-    }
-}
-
 } // namespace CppUnitTestFramework
 } // namespace VisualStudio
 } // namespace Microsoft
@@ -126,6 +71,32 @@ private:
             WriteQuoted(pWriter, Utf8QuotedString);
             WritePossiblyQuoted(pWriter, Utf8String);
         }
+
+        bool Deserialize(ra::Tokenizer& pTokenizer) override
+        {
+            std::wstring sName;
+            if (!ReadQuoted(pTokenizer, sName))
+                return false;
+            SetName(sName);
+
+            std::wstring sString;
+            if (!ReadPossiblyQuoted(pTokenizer, sString))
+                return false;
+            SetString(sString);
+
+            uint32_t nNumber;
+            if (!ReadNumber(pTokenizer, nNumber))
+                return false;
+            SetInt(nNumber);
+
+            if (!ReadQuoted(pTokenizer, Utf8QuotedString))
+                return false;
+
+            if (!ReadPossiblyQuoted(pTokenizer, Utf8String))
+                return false;
+
+            return true;
+        }
     };
 
     class AssetDefinitionViewModelHarness : public AssetViewModelBase
@@ -140,6 +111,7 @@ private:
         void SetDefinition(const std::string& sValue) { SetAssetDefinition(m_pDefinition, sValue); }
 
         void Serialize(ra::services::TextWriter&) const noexcept override {}
+        bool Deserialize(ra::Tokenizer&) noexcept override { return false; }
 
     protected:
         static const IntModelProperty DefinitionProperty;
@@ -222,6 +194,134 @@ public:
         Assert::AreEqual(std::string(":\"A\\\\B\":\"A\\\\B\":0:\"A\\\\B\":\"A\\\\B\""), asset.textWriter.GetString());
     }
 
+    TEST_METHOD(TestDeserializeSimpleValues)
+    {
+        AssetViewModelHarness asset;
+
+        std::string sSerialized = ":\"Name\":String:99:\"UTF-8-Quoted\":UTF-8";
+        ra::Tokenizer pTokenizer(sSerialized);
+        pTokenizer.Advance(); // skip leading colon
+        Assert::IsTrue(asset.Deserialize(pTokenizer));
+
+        Assert::AreEqual(std::wstring(L"Name"), asset.GetName());
+        Assert::AreEqual(std::wstring(L"String"), asset.GetString());
+        Assert::AreEqual(99U, asset.GetInt());
+        Assert::AreEqual(std::string("UTF-8-Quoted"), asset.Utf8QuotedString);
+        Assert::AreEqual(std::string("UTF-8"), asset.Utf8String);
+    }
+
+    TEST_METHOD(TestDeserializeColon)
+    {
+        AssetViewModelHarness asset;
+
+        std::string sSerialized = ":\"Mission: Impossible\":\"Mission: Impossible\":0:\"Mission: Impossible\":\"Mission: Impossible\"";
+        ra::Tokenizer pTokenizer(sSerialized);
+        pTokenizer.Advance(); // skip leading colon
+        Assert::IsTrue(asset.Deserialize(pTokenizer));
+
+        Assert::AreEqual(std::wstring(L"Mission: Impossible"), asset.GetName());
+        Assert::AreEqual(std::wstring(L"Mission: Impossible"), asset.GetString());
+        Assert::AreEqual(0U, asset.GetInt());
+        Assert::AreEqual(std::string("Mission: Impossible"), asset.Utf8QuotedString);
+        Assert::AreEqual(std::string("Mission: Impossible"), asset.Utf8String);
+    }
+
+    TEST_METHOD(TestDeserializeQuotes)
+    {
+        AssetViewModelHarness asset;
+
+        std::string sSerialized = ":\"A \\\"Test\\\" B\":\"A \\\"Test\\\" B\":0:\"A \\\"Test\\\" B\":\"A \\\"Test\\\" B\"";
+        ra::Tokenizer pTokenizer(sSerialized);
+        pTokenizer.Advance(); // skip leading colon
+        Assert::IsTrue(asset.Deserialize(pTokenizer));
+
+        Assert::AreEqual(std::wstring(L"A \"Test\" B"), asset.GetName());
+        Assert::AreEqual(std::wstring(L"A \"Test\" B"), asset.GetString());
+        Assert::AreEqual(0U, asset.GetInt());
+        Assert::AreEqual(std::string("A \"Test\" B"), asset.Utf8QuotedString);
+        Assert::AreEqual(std::string("A \"Test\" B"), asset.Utf8String);
+    }
+
+    TEST_METHOD(TestDeserializeBackslash)
+    {
+        AssetViewModelHarness asset;
+
+        std::string sSerialized = ":\"A\\\\B\":\"A\\\\B\":0:\"A\\\\B\":\"A\\\\B\"";
+        ra::Tokenizer pTokenizer(sSerialized);
+        pTokenizer.Advance(); // skip leading colon
+        Assert::IsTrue(asset.Deserialize(pTokenizer));
+
+        Assert::AreEqual(std::wstring(L"A\\B"), asset.GetName());
+        Assert::AreEqual(std::wstring(L"A\\B"), asset.GetString());
+        Assert::AreEqual(0U, asset.GetInt());
+        Assert::AreEqual(std::string("A\\B"), asset.Utf8QuotedString);
+        Assert::AreEqual(std::string("A\\B"), asset.Utf8String);
+    }
+
+    TEST_METHOD(TestDeserializeInvalidNumber)
+    {
+        AssetViewModelHarness asset;
+
+        std::string sSerialized = ":\"A\":B:C:\"D\":E";
+        ra::Tokenizer pTokenizer(sSerialized);
+        pTokenizer.Advance(); // skip leading colon
+        Assert::IsFalse(asset.Deserialize(pTokenizer));
+
+        Assert::AreEqual(std::wstring(L"A"), asset.GetName());
+        Assert::AreEqual(std::wstring(L"B"), asset.GetString());
+        Assert::AreEqual(0U, asset.GetInt());
+        Assert::AreEqual(std::string(), asset.Utf8QuotedString);
+        Assert::AreEqual(std::string(), asset.Utf8String);
+    }
+
+    TEST_METHOD(TestDeserializeMissingField)
+    {
+        AssetViewModelHarness asset;
+
+        std::string sSerialized = ":\"A\":B:5:\"D\"";
+        ra::Tokenizer pTokenizer(sSerialized);
+        pTokenizer.Advance(); // skip leading colon
+        Assert::IsFalse(asset.Deserialize(pTokenizer));
+
+        Assert::AreEqual(std::wstring(L"A"), asset.GetName());
+        Assert::AreEqual(std::wstring(L"B"), asset.GetString());
+        Assert::AreEqual(5U, asset.GetInt());
+        Assert::AreEqual(std::string("D"), asset.Utf8QuotedString);
+        Assert::AreEqual(std::string(), asset.Utf8String);
+    }
+
+    TEST_METHOD(TestDeserializeNotQuoted)
+    {
+        AssetViewModelHarness asset;
+
+        std::string sSerialized = ":A:B:5:D:E";
+        ra::Tokenizer pTokenizer(sSerialized);
+        pTokenizer.Advance(); // skip leading colon
+        Assert::IsFalse(asset.Deserialize(pTokenizer));
+
+        Assert::AreEqual(std::wstring(), asset.GetName());
+        Assert::AreEqual(std::wstring(), asset.GetString());
+        Assert::AreEqual(0U, asset.GetInt());
+        Assert::AreEqual(std::string(), asset.Utf8QuotedString);
+        Assert::AreEqual(std::string(), asset.Utf8String);
+    }
+
+    TEST_METHOD(TestDeserializeBadlyQuoted)
+    {
+        AssetViewModelHarness asset;
+
+        std::string sSerialized = ":\"A\":\"B\"C:5:\"D\":E";
+        ra::Tokenizer pTokenizer(sSerialized);
+        pTokenizer.Advance(); // skip leading colon
+        Assert::IsFalse(asset.Deserialize(pTokenizer));
+
+        Assert::AreEqual(std::wstring(L"A"), asset.GetName());
+        Assert::AreEqual(std::wstring(), asset.GetString());
+        Assert::AreEqual(0U, asset.GetInt());
+        Assert::AreEqual(std::string(), asset.Utf8QuotedString);
+        Assert::AreEqual(std::string(), asset.Utf8String);
+    }
+
     TEST_METHOD(TestUpdateCheckpoint)
     {
         AssetViewModelHarness asset;