Repository: RetroAchievements/RAIntegration
Pull request #913 (2022-09-15 19:44:53)

disable Highlights checkbox when no asset loaded
Prevents crash reported here: https://discord.com/channels/310192285306454017/533411674162593812/974021690554187826


Patch:
diff --git a/src/RA_Resource.h b/src/RA_Resource.h
index 77dd9984..d5126053 100644
--- a/src/RA_Resource.h
+++ b/src/RA_Resource.h
@@ -40,8 +40,8 @@
 #define IDC_RA_RESET_FILTER             1135
 #define IDC_RA_RESULT_COUNT             1136
 #define IDC_RA_NOTE_TEXT                1137
-#define IDC_RA_ADD_NOTE                 1138
-#define IDC_RA_DELETE_NOTE              1139
+#define IDC_RA_PUBLISH_NOTE             1138
+#define IDC_RA_REVERT_NOTE              1139
 #define IDC_RA_RESULTS                  1140
 #define IDC_RA_SEARCHRANGES             1141
 #define IDC_RA_SEARCHRANGE              1142
@@ -127,7 +127,6 @@
 #define IDD_RA_LOGIN                    1504
 #define IDD_RA_GAMETITLESEL             1505
 #define IDD_RA_CODENOTES                1506
-#define IDD_RA_LEADERBOARDEDITOR        1507
 #define IDD_RA_REPORTBROKENACHIEVEMENTS 1508
 #define IDD_RA_GAMELIBRARY              1509
 #define IDD_RA_ROMCHECKSUM              1510
@@ -167,7 +166,7 @@
 #define IDM_RA_FILES_LOGIN              1706
 #define IDM_RA_FILES_LOGOUT             1707
 #define IDM_RA_FILES_ACHIEVEMENTEDITOR  1708
-#define IDM_RA_FILES_LEADERBOARDEDITOR  1709
+#define IDM_RA_FILES_CODENOTES          1709
 #define IDM_RA_HARDCORE_MODE            1710
 #define IDM_RA_REPORTBROKENACHIEVEMENTS 1711
 #define IDM_RA_GETROMCHECKSUM           1712
@@ -177,6 +176,7 @@
 #define IDM_RA_PARSERICHPRESENCE        1716
 #define IDM_RA_TOGGLELEADERBOARDS       1717
 #define IDM_RA_NON_HARDCORE_WARNING     1718
+#define IDM_RA_FILES_OPENALL            1719
 #define IDM_RA_MENUEND                  1739
 
 // Next default values for new objects
diff --git a/src/RA_Shared.rc b/src/RA_Shared.rc
index 706e2fee..6240c3e5 100644
--- a/src/RA_Shared.rc
+++ b/src/RA_Shared.rc
@@ -58,8 +58,8 @@ BEGIN
     PUSHBUTTON      "...",IDC_RA_VIEW_CODENOTES,65,166,13,12
     PUSHBUTTON      "Add &Bookmark",IDC_RA_ADDBOOKMARK,8,182,70,15
     EDITTEXT        IDC_RA_NOTE_TEXT,82,152,196,44,ES_MULTILINE | WS_VSCROLL
-    PUSHBUTTON      "&Save Note",IDC_RA_ADD_NOTE,282,152,42,15
-    PUSHBUTTON      "&Delete",IDC_RA_DELETE_NOTE,282,170,42,15
+    PUSHBUTTON      "&Publish",IDC_RA_PUBLISH_NOTE,282,152,42,15
+    PUSHBUTTON      "&Revert",IDC_RA_REVERT_NOTE,282,170,42,15
     LTEXT           "Memory View:",IDC_STATIC,4,207,50,9
     CONTROL         "8-bit",IDC_RA_MEMVIEW_8BIT,"Button",BS_AUTORADIOBUTTON | WS_GROUP,58,207,31,10
     CONTROL         "16-bit",IDC_RA_MEMVIEW_16BIT,"Button",BS_AUTORADIOBUTTON,90,207,33,10
@@ -183,15 +183,6 @@ BEGIN
     PUSHBUTTON      "Copy Hash",IDC_RA_COPYCHECKSUMCLIPBOARD,179,26,66,14
 END
 
-IDD_RA_LEADERBOARDEDITOR DIALOGEX 0, 0, 332, 310
-STYLE DS_SETFONT | DS_MODALFRAME | WS_POPUP | WS_CAPTION | WS_SYSMENU
-CAPTION "Leaderboard Editor"
-FONT 8, "MS Sans Serif", 0, 0, 0x1
-BEGIN
-    GROUPBOX        "Overview:",IDC_STATIC,5,3,319,38
-    LTEXT           "Click 'New 8-bit Test' to get started. Change something in the game, adjust the filter to match this change and click 'Filter!'. Repeat this until you have a small number of candidates.",IDC_STATIC,12,13,303,17
-END
-
 IDD_RA_REPORTBROKENACHIEVEMENTS DIALOGEX 0, 0, 339, 222
 STYLE DS_SETFONT | DS_FIXEDSYS | WS_POPUP | WS_CAPTION | WS_SYSMENU | WS_THICKFRAME
 CAPTION "Report Achievement Problem"
diff --git a/src/RA_StringUtils.cpp b/src/RA_StringUtils.cpp
index f44a154a..b31278e4 100644
--- a/src/RA_StringUtils.cpp
+++ b/src/RA_StringUtils.cpp
@@ -394,6 +394,12 @@ std::string Tokenizer::ReadQuotedString()
                 case 'n':
                     sString.push_back('\n');
                     break;
+                case 'r':
+                    sString.push_back('\r');
+                    break;
+                case 't':
+                    sString.push_back('\t');
+                    break;
                 default:
                     sString.push_back(c2);
                     break;
diff --git a/src/api/impl/OfflineServer.cpp b/src/api/impl/OfflineServer.cpp
index b1635ac0..d3dd9514 100644
--- a/src/api/impl/OfflineServer.cpp
+++ b/src/api/impl/OfflineServer.cpp
@@ -90,6 +90,7 @@ FetchCodeNotes::Response OfflineServer::FetchCodeNotes(const FetchCodeNotes::Req
     {
         response.Result = ApiResult::Failed;
         response.ErrorMessage = ra::StringPrintf("Code notes for game %u not found in cache", request.GameId);
+        return response;
     }
 
     std::string sNotes;
diff --git a/src/data/context/GameAssets.cpp b/src/data/context/GameAssets.cpp
index be0bf53e..3c4a6587 100644
--- a/src/data/context/GameAssets.cpp
+++ b/src/data/context/GameAssets.cpp
@@ -175,6 +175,10 @@ void GameAssets::ReloadAssets(const std::vector<ra::data::models::AssetModelBase
                         // ignore RichPresence model (it's not actually stored in the XXX-User file)
                         case ra::data::models::AssetType::RichPresence:
                             continue;
+
+                        // ignore CodeNotes model (it's actually a collection of notes)
+                        case ra::data::models::AssetType::CodeNotes:
+                            continue;
                     }
 
                     vRemainingAssetsToReload.push_back(pAsset);
@@ -208,6 +212,11 @@ void GameAssets::ReloadAssets(const std::vector<ra::data::models::AssetModelBase
                 nType = ra::data::models::AssetType::Leaderboard;
                 pTokenizer.Consume('L');
                 break;
+
+            case 'N':
+                nType = ra::data::models::AssetType::CodeNotes;
+                pTokenizer.Consume('N');
+                break;
         }
 
         nId = pTokenizer.ReadNumber();
@@ -270,7 +279,15 @@ void GameAssets::ReloadAssets(const std::vector<ra::data::models::AssetModelBase
                     mLeaderboard->CreateServerCheckpoint();
 
                     pAsset = &Append(std::move(mLeaderboard));
+                    break;
                 }
+
+                case ra::data::models::AssetType::CodeNotes:
+                    pAsset = FindCodeNotes();
+                    if (pAsset)
+                        pAsset->Deserialize(pTokenizer);
+
+                    continue;
             }
 
             if (pAsset)
@@ -410,6 +427,10 @@ void GameAssets::SaveAssets(const std::vector<ra::data::models::AssetModelBase*>
                 pData->Write("L");
                 break;
 
+            case ra::data::models::AssetType::CodeNotes:
+                pData->Write("N");
+                break;
+
             case ra::data::models::AssetType::RichPresence:
                 continue;
         }
diff --git a/src/data/models/AssetModelBase.cpp b/src/data/models/AssetModelBase.cpp
index a9ace57f..0a7d18bd 100644
--- a/src/data/models/AssetModelBase.cpp
+++ b/src/data/models/AssetModelBase.cpp
@@ -29,8 +29,17 @@ static size_t CharactersNeedingEscaped(const std::basic_string<CharT>& sText) no
     size_t nToEscape = 0;
     for (auto c : sText)
     {
-        if (c == ':' || c == '"' || c == '\\')
-            ++nToEscape;
+        switch (c)
+        {
+            case ':':
+            case '"':
+            case '\\':
+            case '\n':
+            case '\r':
+            case '\t':
+                ++nToEscape;
+                break;
+        }
     }
 
     return nToEscape;
@@ -44,9 +53,28 @@ static void WriteEscapedString(ra::services::TextWriter& pWriter, const std::bas
     sEscaped.push_back('"');
     for (const CharT c : sText)
     {
-        if (c == '"' || c == '\\')
-            sEscaped.push_back('\\');
-        sEscaped.push_back(c);
+        switch (c)
+        {
+            case '"':
+            case '\\':
+                sEscaped.push_back('\\');
+                _FALLTHROUGH;
+            default:
+                sEscaped.push_back(c);
+                break;
+            case '\r':
+                sEscaped.push_back('\\');
+                sEscaped.push_back('r');
+                break;
+            case '\n':
+                sEscaped.push_back('\\');
+                sEscaped.push_back('n');
+                break;
+            case '\t':
+                sEscaped.push_back('\\');
+                sEscaped.push_back('t');
+                break;
+        }
     }
     sEscaped.push_back('"');
     pWriter.Write(sEscaped);
diff --git a/src/data/models/CodeNotesModel.cpp b/src/data/models/CodeNotesModel.cpp
index d0e40626..f5ed6dcd 100644
--- a/src/data/models/CodeNotesModel.cpp
+++ b/src/data/models/CodeNotesModel.cpp
@@ -50,7 +50,20 @@ void CodeNotesModel::Refresh(unsigned int nGameId, CodeNoteChangedFunction fCode
         else
         {
             for (const auto& pNote : response.Notes)
+            {
+                {
+                    std::unique_lock<std::mutex> lock(m_oMutex);
+                    const auto pIter = m_mOriginalCodeNotes.find(pNote.Address);
+                    if (pIter != m_mOriginalCodeNotes.end())
+                    {
+                        pIter->second.first = pNote.Author;
+                        pIter->second.second = pNote.Note;
+                        continue;
+                    }
+                }
+
                 AddCodeNote(pNote.Address, pNote.Author, pNote.Note);
+            }
         }
 
         callback();
@@ -346,13 +359,16 @@ void CodeNotesModel::AddCodeNote(ra::ByteAddress nAddress, const std::string& sA
 
                     // assign entire note to pointer note
                     pointerNote.Note = sNote;
-                    m_mCodeNotes.insert_or_assign(nAddress, std::move(pointerNote));
+                    {
+                        std::unique_lock<std::mutex> lock(m_oMutex);
+                        m_mCodeNotes.insert_or_assign(nAddress, std::move(pointerNote));
+                    }
                     m_bHasPointers = true;
 
+                    OnCodeNoteChanged(nAddress, sNote);
+
                     if (m_fCodeNoteChanged)
                     {
-                        m_fCodeNoteChanged(nAddress, sNote);
-
                         for (const auto& pNote : m_mCodeNotes[nAddress].PointerData->OffsetNotes)
                             m_fCodeNoteChanged(nPointerValue + pNote.Offset, pNote.Note);
                     }
@@ -369,13 +385,21 @@ void CodeNotesModel::AddCodeNote(ra::ByteAddress nAddress, const std::string& sA
     note.Author = sAuthor;
     note.Note = sNote;
     ExtractSize(note);
-    m_mCodeNotes.insert_or_assign(nAddress, std::move(note));
+    {
+        std::unique_lock<std::mutex> lock(m_oMutex);
+        m_mCodeNotes.insert_or_assign(nAddress, std::move(note));
+    }
 
     OnCodeNoteChanged(nAddress, sNote);
 }
 
 void CodeNotesModel::OnCodeNoteChanged(ra::ByteAddress nAddress, const std::wstring& sNewNote)
 {
+    SetValue(ra::data::models::AssetModelBase::ChangesProperty,
+             m_mOriginalCodeNotes.empty() ?
+                 ra::etoi(ra::data::models::AssetChanges::None) :
+                 ra::etoi(ra::data::models::AssetChanges::Unpublished));
+
     if (m_fCodeNoteChanged != nullptr)
         m_fCodeNoteChanged(nAddress, sNewNote);
 }
@@ -536,70 +560,70 @@ const std::wstring* CodeNotesModel::FindCodeNote(ra::ByteAddress nAddress, _Inou
     return nullptr;
 }
 
-bool CodeNotesModel::SetCodeNote(ra::ByteAddress nAddress, const std::wstring& sNote)
+void CodeNotesModel::SetCodeNote(ra::ByteAddress nAddress, const std::wstring& sNote)
 {
-    if (m_nGameId == 0)
-        return false;
-
-    ra::api::UpdateCodeNote::Request request;
-    request.GameId = m_nGameId;
-    request.Address = nAddress;
-    request.Note = sNote;
+    std::string sOriginalAuthor;
 
-    do
     {
-        auto response = request.Call();
-        if (response.Succeeded())
+        std::unique_lock<std::mutex> lock(m_oMutex);
+
+        const auto pIter = m_mCodeNotes.find(nAddress);
+        if (pIter != m_mCodeNotes.end())
         {
-            const auto& pUserContext = ra::services::ServiceLocator::Get<ra::data::context::UserContext>();
-            AddCodeNote(nAddress, pUserContext.GetDisplayName(), sNote);
-            return true;
+            if (pIter->second.Note == sNote)
+            {
+                // the note at this address is unchanged
+                return;
+            }
+        }
+        else if (sNote.empty())
+        {
+            // the note at this address is unchanged
+            return;
         }
 
-        ra::ui::viewmodels::MessageBoxViewModel vmMessage;
-        vmMessage.SetHeader(ra::StringPrintf(L"Error saving note for address %s", ra::ByteAddressToString(nAddress)));
-        vmMessage.SetMessage(ra::Widen(response.ErrorMessage));
-        vmMessage.SetButtons(ra::ui::viewmodels::MessageBoxViewModel::Buttons::RetryCancel);
-        if (vmMessage.ShowModal() == ra::ui::DialogResult::Cancel)
-            break;
-
-    } while (true);
-
-    return false;
-}
-
-bool CodeNotesModel::DeleteCodeNote(ra::ByteAddress nAddress)
-{
-    if (m_mCodeNotes.find(nAddress) == m_mCodeNotes.end())
-        return true;
-
-    if (m_nGameId == 0)
-        return false;
-
-    ra::api::DeleteCodeNote::Request request;
-    request.GameId = m_nGameId;
-    request.Address = nAddress;
-
-    do
-    {
-        auto response = request.Call();
-        if (response.Succeeded())
+        const auto pIter2 = m_mOriginalCodeNotes.find(nAddress);
+        if (pIter2 == m_mOriginalCodeNotes.end())
         {
-            m_mCodeNotes.erase(nAddress);
-            OnCodeNoteChanged(nAddress, L"");
-            return true;
+            // note wasn't previously modified
+            if (pIter != m_mCodeNotes.end())
+            {
+                // capture the original value
+                m_mOriginalCodeNotes.insert_or_assign(nAddress,
+                    std::make_pair(pIter->second.Author, pIter->second.Note));
+            }
+            else
+            {
+                // add a dummy original value so it appears modified
+                m_mOriginalCodeNotes.insert_or_assign(nAddress, std::make_pair("", L""));
+            }
         }
+        else if (pIter2->second.second == sNote)
+        {
+            // note restored to original value. assign the note back to the original author
+            // and discard the modification tracker
+            sOriginalAuthor = pIter2->second.first;
 
-        ra::ui::viewmodels::MessageBoxViewModel vmMessage;
-        vmMessage.SetHeader(ra::StringPrintf(L"Error deleting note for address %s", ra::ByteAddressToString(nAddress)));
-        vmMessage.SetMessage(ra::Widen(response.ErrorMessage));
-        vmMessage.SetButtons(ra::ui::viewmodels::MessageBoxViewModel::Buttons::RetryCancel);
-        if (vmMessage.ShowModal() == ra::ui::DialogResult::Cancel)
-            break;
+            m_mOriginalCodeNotes.erase(pIter2);
+        }
 
-    } while (true);
+        if (sNote.empty())
+            m_mCodeNotes.erase(pIter);
+    }
 
-    return false;
+    if (sNote.empty())
+    {
+        OnCodeNoteChanged(nAddress, sNote);
+    }
+    else if (!sOriginalAuthor.empty())
+    {
+        AddCodeNote(nAddress, sOriginalAuthor, sNote);
+    }
+    else
+    {
+        const auto& pUserContext = ra::services::ServiceLocator::Get<ra::data::context::UserContext>();
+        AddCodeNote(nAddress, pUserContext.GetDisplayName(), sNote);
+    }
 }
 
 const CodeNotesModel::CodeNote* CodeNotesModel::FindCodeNoteInternal(ra::ByteAddress nAddress) const
@@ -808,6 +832,88 @@ void CodeNotesModel::DoFrame()
     }
 }
 
+void CodeNotesModel::SetServerCodeNote(ra::ByteAddress nAddress, const std::wstring& sNote)
+{
+    // remove the original entry - we're setting a new original entry
+    const auto pIter = m_mOriginalCodeNotes.find(nAddress);
+    if (pIter != m_mOriginalCodeNotes.end())
+        m_mOriginalCodeNotes.erase(pIter);
+
+    // if we're just committing the current value, we're done
+    const auto pIter2 = m_mCodeNotes.find(nAddress);
+    if (pIter2 != m_mCodeNotes.end() && pIter2->second.Note == sNote)
+    {
+        if (m_mOriginalCodeNotes.empty())
+            SetValue(ra::data::models::AssetModelBase::ChangesProperty, ra::etoi(ra::data::models::AssetChanges::None));
+        OnCodeNoteChanged(nAddress, sNote);
+        return;
+    }
+
+    // update the current value
+    const auto& pUserContext = ra::services::ServiceLocator::Get<ra::data::context::UserContext>();
+    AddCodeNote(nAddress, pUserContext.GetDisplayName(), sNote);
+}
+
+const std::wstring* CodeNotesModel::GetServerCodeNote(ra::ByteAddress nAddress) const
+{
+    const auto pIter = m_mOriginalCodeNotes.find(nAddress);
+    if (pIter != m_mOriginalCodeNotes.end())
+        return &pIter->second.second;
+
+    return nullptr;
+}
+
+const std::string* CodeNotesModel::GetServerCodeNoteAuthor(ra::ByteAddress nAddress) const
+{
+    const auto pIter = m_mOriginalCodeNotes.find(nAddress);
+    if (pIter != m_mOriginalCodeNotes.end())
+        return &pIter->second.first;
+
+    return nullptr;
+}
+
+void CodeNotesModel::Serialize(ra::services::TextWriter& pWriter) const
+{
+    bool first = true;
+
+    for (const auto& pIter : m_mOriginalCodeNotes)
+    {
+        // "N0" of first note will be written by GameAssets
+        if (first)
+        {
+            first = false;
+            pWriter.Write(":");
+        }
+        else
+        {
+            pWriter.WriteLine();
+            pWriter.Write("N0:");
+        }
+
+        pWriter.Write(ra::ByteAddressToString(pIter.first));
+
+        const auto pNote = m_mCodeNotes.find(pIter.first);
+        if (pNote != m_mCodeNotes.end())
+            WriteQuoted(pWriter, pNote->second.Note);
+        else
+            WriteQuoted(pWriter, "");
+    }
+}
+
+bool CodeNotesModel::Deserialize(ra::Tokenizer& pTokenizer)
+{
+    const auto sAddress = pTokenizer.ReadTo(':');
+    pTokenizer.Consume(':');
+    const auto nAddress = ra::ByteAddressFromString(sAddress);
+
+    std::wstring sNote;
+    if (!ReadQuoted(pTokenizer, sNote))
+        return false;
+
+    SetCodeNote(nAddress, sNote);
+    return true;
+}
+
 } // namespace models
 } // namespace data
 } // namespace ra
diff --git a/src/data/models/CodeNotesModel.hh b/src/data/models/CodeNotesModel.hh
index 3e9ec779..caeb3055 100644
--- a/src/data/models/CodeNotesModel.hh
+++ b/src/data/models/CodeNotesModel.hh
@@ -136,14 +136,7 @@ public:
     /// <param name="nAddress">The address to set the note for.</param>
     /// <param name="sNote">The new note for the address.</param>
     /// <returns><c>true</c> if the note was updated, </c>false</c> if an error occurred.</returns>
-    virtual bool SetCodeNote(ra::ByteAddress nAddress, const std::wstring& sNote);
-
-    /// <summary>
-    /// Deletes the note associated with the specified address.
-    /// </summary>
-    /// <param name="nAddress">The address to delete the note for.</param>
-    /// <returns><c>true</c> if the note was deleted, </c>false</c> if an error occurred.</returns>
-    virtual bool DeleteCodeNote(ra::ByteAddress nAddress);
+    void SetCodeNote(ra::ByteAddress nAddress, const std::wstring& sNote);
 
     /// <summary>
     /// Returns the number of known code notes
@@ -158,8 +151,35 @@ public:
         return (m_mCodeNotes.size() == 0) ? 0U : m_mCodeNotes.begin()->first;
     }
 
-	void Serialize(ra::services::TextWriter&) const noexcept override {}
-	bool Deserialize(ra::Tokenizer&) noexcept override { return true; }
+    /// <summary>
+    /// Enumerates the modified code notes
+    /// </summary>
+    /// <remarks>
+    /// <paramref name="callback" /> is called for each known code note. If it returns <c>false</c> enumeration stops.
+    /// </remarks>
+    void EnumerateModifiedCodeNotes(std::function<bool(ra::ByteAddress nAddress)> callback) const
+    {
+        for (const auto& pair : m_mOriginalCodeNotes)
+        {
+            if (!callback(pair.first))
+                break;
+        }
+    }
+
+    /// <summary>
+    /// Gets whether or not the note for specified address has been modified.
+    /// </summary>
+    bool IsNoteModified(ra::ByteAddress nAddress) const
+    {
+        return m_mOriginalCodeNotes.find(nAddress) != m_mOriginalCodeNotes.end();
+    }
+
+    void SetServerCodeNote(ra::ByteAddress nAddress, const std::wstring& sNote);
+    const std::wstring* GetServerCodeNote(ra::ByteAddress nAddress) const;
+    const std::string* GetServerCodeNoteAuthor(ra::ByteAddress nAddress) const;
+
+	void Serialize(ra::services::TextWriter&) const override;
+    bool Deserialize(ra::Tokenizer&) override;
 
     void DoFrame() override;
 
@@ -192,6 +212,7 @@ protected:
     };
 
     std::map<ra::ByteAddress, CodeNote> m_mCodeNotes;
+    std::map<ra::ByteAddress, std::pair<std::string, std::wstring>> m_mOriginalCodeNotes;
 
     const CodeNote* FindCodeNoteInternal(ra::ByteAddress nAddress) const;
     void EnumerateCodeNotes(std::function<bool(ra::ByteAddress nAddress, const CodeNote& pCodeNote)> callback, bool bIncludeDerived) const;
@@ -204,6 +225,8 @@ protected:
 private:
     static std::wstring BuildCodeNoteSized(ra::ByteAddress nAddress, unsigned nCheckBytes, ra::ByteAddress nNoteAddress, const CodeNote& pNote);
     static void ExtractSize(CodeNote& pNote);
+
+    mutable std::mutex m_oMutex;
 };
 
 } // namespace models
diff --git a/src/ui/BindingBase.hh b/src/ui/BindingBase.hh
index 90bc952b..2fb4f880 100644
--- a/src/ui/BindingBase.hh
+++ b/src/ui/BindingBase.hh
@@ -43,9 +43,13 @@ protected:
     /// <param name="bValue">The value to set.</param>
     void SetValue(const BoolModelProperty& pProperty, bool bValue)
     {
-        m_vmViewModel.RemoveNotifyTarget(*this);
+        if (m_nSetDepth++ == 0)
+            m_vmViewModel.RemoveNotifyTarget(*this);
+
         m_vmViewModel.SetValue(pProperty, bValue);
-        m_vmViewModel.AddNotifyTarget(*this);
+
+        if (--m_nSetDepth == 0)
+            m_vmViewModel.AddNotifyTarget(*this);
     }
 
     /// <summary>
@@ -65,9 +69,13 @@ protected:
     /// <param name="sValue">The value to set.</param>
     void SetValue(const StringModelProperty& pProperty, const std::wstring& sValue)
     {
-        m_vmViewModel.RemoveNotifyTarget(*this);
+        if (m_nSetDepth++ == 0)
+            m_vmViewModel.RemoveNotifyTarget(*this);
+
         m_vmViewModel.SetValue(pProperty, sValue);
-        m_vmViewModel.AddNotifyTarget(*this);
+
+        if (--m_nSetDepth == 0)
+            m_vmViewModel.AddNotifyTarget(*this);
     }
 
     /// <summary>
@@ -87,9 +95,13 @@ protected:
     /// <param name="nValue">The value to set.</param>
     void SetValue(const IntModelProperty& pProperty, int nValue)
     {
-        m_vmViewModel.RemoveNotifyTarget(*this);
+        if (m_nSetDepth++ == 0)
+            m_vmViewModel.RemoveNotifyTarget(*this);
+
         m_vmViewModel.SetValue(pProperty, nValue);
-        m_vmViewModel.AddNotifyTarget(*this);
+
+        if (--m_nSetDepth == 0)
+            m_vmViewModel.AddNotifyTarget(*this);
     }
 
 protected:
@@ -98,6 +110,7 @@ protected:
 
 private:
     ra::ui::ViewModelBase& m_vmViewModel;
+    int m_nSetDepth = 0;
 };
 
 } // namespace ui
diff --git a/src/ui/EditorTheme.hh b/src/ui/EditorTheme.hh
index f84b39b9..1e471685 100644
--- a/src/ui/EditorTheme.hh
+++ b/src/ui/EditorTheme.hh
@@ -36,6 +36,9 @@ public:
     Color ColorHeader() const noexcept { return m_colorHeader; }
     Color ColorHeaderSelected() const noexcept { return m_colorHeaderSelected; }
 
+    // ===== code notes =====
+    Color ColorModified() const noexcept { return m_colorModified; }
+
     // ===== triggers =====
 
     Color ColorTriggerIsTrue() const noexcept { return m_colorTriggerIsTrue; }
@@ -64,6 +67,8 @@ private:
     Color m_colorHeader{ 255, 128, 128, 128 };
     Color m_colorHeaderSelected{ 255, 255, 96, 96 };
 
+    Color m_colorModified { 255, 192, 64, 64 };
+
     Color m_colorTriggerIsTrue{ 255, 192, 255, 192 };
     Color m_colorTriggerWasTrue{ 255, 192, 192, 255 };
     Color m_colorTriggerBecomingTrue{ 255, 192, 255, 255 };
diff --git a/src/ui/Theme.cpp b/src/ui/Theme.cpp
index 574bdf14..4ac72065 100644
--- a/src/ui/Theme.cpp
+++ b/src/ui/Theme.cpp
@@ -191,6 +191,18 @@ void EditorTheme::LoadFromFile()
         }
     }
 
+    if (document.HasMember("CodeNotes"))
+    {
+        const rapidjson::Value& codeNotes = document["CodeNotes"];
+
+        if (codeNotes.HasMember("Colors"))
+        {
+            const rapidjson::Value& colors = codeNotes["Colors"];
+
+            ReadColor(m_colorModified, colors, "Modified");
+        }
+    }
+
     if (document.HasMember("TriggerColors"))
     {
         const rapidjson::Value& triggerColors = document["TriggerColors"];
diff --git a/src/ui/viewmodels/AssetEditorViewModel.cpp b/src/ui/viewmodels/AssetEditorViewModel.cpp
index c70bdaef..d79e9dc6 100644
--- a/src/ui/viewmodels/AssetEditorViewModel.cpp
+++ b/src/ui/viewmodels/AssetEditorViewModel.cpp
@@ -65,6 +65,8 @@ AssetEditorViewModel::AssetEditorViewModel() noexcept
     m_vLeaderboardParts.Add(ra::etoi(LeaderboardPart::Value), L"Value");
     m_vLeaderboardParts.GetItemAt(0)->SetSelected(true);
     m_vLeaderboardParts.AddNotifyTarget(*this);
+
+    SetValue(IsTriggerProperty, false);
 }
 
 AssetEditorViewModel::~AssetEditorViewModel()
@@ -237,6 +239,7 @@ void AssetEditorViewModel::LoadAsset(ra::data::models::AssetModelBase* pAsset, b
     else
     {
         SetValue(IsAssetLoadedProperty, false);
+        SetValue(IsTriggerProperty, false);
         SetValue(HasMeasuredProperty, false);
 
         SetName(NameProperty.GetDefaultValue());
diff --git a/src/ui/viewmodels/AssetUploadViewModel.cpp b/src/ui/viewmodels/AssetUploadViewModel.cpp
index e6f855b7..89839172 100644
--- a/src/ui/viewmodels/AssetUploadViewModel.cpp
+++ b/src/ui/viewmodels/AssetUploadViewModel.cpp
@@ -1,14 +1,18 @@
 #include "AssetUploadViewModel.hh"
 
+#include "api\DeleteCodeNote.hh"
 #include "api\UpdateAchievement.hh"
+#include "api\UpdateCodeNote.hh"
 #include "api\UpdateLeaderboard.hh"
 #include "api\UploadBadge.hh"
 
 #include "data\context\GameContext.hh"
+#include "data\context\UserContext.hh"
 
 #include "ui\ImageReference.hh"
 #include "ui\viewmodels\MessageBoxViewModel.hh"
 
+#include "RA_Defs.h"
 #include "RA_Log.h"
 
 namespace ra {
@@ -32,6 +36,10 @@ void AssetUploadViewModel::QueueAsset(ra::data::models::AssetModelBase& pAsset)
             QueueLeaderboard(*(dynamic_cast<ra::data::models::LeaderboardModel*>(&pAsset)));
             break;
 
+        case ra::data::models::AssetType::CodeNotes:
+            QueueCodeNotes(*(dynamic_cast<ra::data::models::CodeNotesModel*>(&pAsset)));
+            break;
+
         default:
             Expects(!"Unsupported asset type");
             break;
@@ -83,6 +91,83 @@ void AssetUploadViewModel::QueueLeaderboard(ra::data::models::LeaderboardModel&
     });
 }
 
+void AssetUploadViewModel::QueueCodeNotes(ra::data::models::CodeNotesModel& pNotes)
+{
+    pNotes.EnumerateModifiedCodeNotes([this, pNotes = &pNotes](ra::ByteAddress nAddress)
+    {
+        QueueCodeNote(*pNotes, nAddress);
+        return true;
+    });
+}
+
+static std::wstring ShortenNote(const std::wstring& sNote)
+{
+    return sNote.length() > 256 ? (sNote.substr(0, 253) + L"...") : sNote;
+}
+
+void AssetUploadViewModel::QueueCodeNote(ra::data::models::CodeNotesModel& pNotes, ra::ByteAddress nAddress)
+{
+    const auto* pOriginalAuthor = pNotes.GetServerCodeNoteAuthor(nAddress);
+    if (pOriginalAuthor != nullptr && !pOriginalAuthor->empty())
+    {
+        const auto& pUserContext = ra::services::ServiceLocator::Get<ra::data::context::UserContext>();
+        if (pUserContext.GetDisplayName() != *pOriginalAuthor)
+        {
+            // author changed - confirm overwrite
+            std::wstring sEmpty;
+            const auto* pOriginalNote = pNotes.GetServerCodeNote(nAddress);
+            if (pOriginalNote == nullptr)
+                pOriginalNote = &sEmpty;
+
+            const auto* pNote = pNotes.FindCodeNote(nAddress);
+            if (pNote == nullptr)
+                pNote = &sEmpty;
+
+            ra::ui::viewmodels::MessageBoxViewModel vmPrompt;
+            if (!pNote->empty())
+            {
+                vmPrompt.SetHeader(
+                    ra::StringPrintf(L"Overwrite note for address %s?", ra::ByteAddressToString(nAddress)));
+
+                if (pOriginalNote->length() > 256 || pNote->length() > 256)
+                {
+                    const auto sNewNoteShort = ShortenNote(*pNote);
+                    const auto sOldNoteShort = ShortenNote(*pOriginalNote);
+                    vmPrompt.SetMessage(
+                        ra::StringPrintf(L"Are you sure you want to replace %s's note:\n\n%s\n\nWith your note:\n\n%s",
+                                         *pOriginalAuthor, sOldNoteShort, sNewNoteShort));
+                }
+                else
+                {
+                    vmPrompt.SetMessage(
+                        ra::StringPrintf(L"Are you sure you want to replace %s's note:\n\n%s\n\nWith your note:\n\n%s",
+                                         *pOriginalAuthor, *pOriginalNote, *pNote));
+                }
+            }
+            else
+            {
+                const auto pNoteShort = ShortenNote(*pOriginalNote);
+                vmPrompt.SetHeader(ra::StringPrintf(L"Delete note for address %s?", ra::ByteAddressToString(nAddress)));
+                vmPrompt.SetMessage(ra::StringPrintf(L"Are you sure you want to delete %s's note:\n\n%s", *pOriginalAuthor, pNoteShort));
+            }
+
+            vmPrompt.SetButtons(ra::ui::viewmodels::MessageBoxViewModel::Buttons::YesNo);
+            vmPrompt.SetIcon(ra::ui::viewmodels::MessageBoxViewModel::Icon::Warning);
+            if (vmPrompt.ShowModal() != ra::ui::DialogResult::Yes)
+                return;
+        }
+    }
+
+    auto& pItem = m_vUploadQueue.emplace_back();
+    pItem.pAsset = &pNotes;
+    pItem.nExtra = ra::to_signed(nAddress);
+
+    QueueTask([this, pNotes = &pNotes, nAddress]()
+    {
+        UploadCodeNote(*pNotes, nAddress);
+    });
+}
+
 void AssetUploadViewModel::OnBegin()
 {
     SetMessage(ra::StringPrintf(L"Uploading %d items...", TaskCount()));
@@ -230,6 +315,59 @@ void AssetUploadViewModel::UploadLeaderboard(ra::data::models::LeaderboardModel&
     }
 }
 
+void AssetUploadViewModel::UploadCodeNote(ra::data::models::CodeNotesModel& pNotes, ra::ByteAddress nAddress)
+{
+    std::string sErrorMessage;
+    UploadState nState = UploadState::Failed;
+
+    const auto* pNote = pNotes.FindCodeNote(nAddress);
+    if (pNote == nullptr || pNote->empty())
+    {
+        ra::api::DeleteCodeNote::Request request;
+        request.GameId = ra::services::ServiceLocator::Get<ra::data::context::GameContext>().GameId();
+        request.Address = nAddress;
+
+        const auto& response = request.Call();
+
+        if (response.Succeeded())
+        {
+            pNotes.SetServerCodeNote(nAddress, L"");
+            nState = UploadState::Success;
+        }
+
+        sErrorMessage = response.ErrorMessage;
+    }
+    else
+    {
+        ra::api::UpdateCodeNote::Request request;
+        request.GameId = ra::services::ServiceLocator::Get<ra::data::context::GameContext>().GameId();
+        request.Address = nAddress;
+        request.Note = *pNote;
+
+        const auto& response = request.Call();
+
+        if (response.Succeeded())
+        {
+            pNotes.SetServerCodeNote(nAddress, *pNote);
+            nState = UploadState::Success;
+        }
+
+        sErrorMessage = response.ErrorMessage;
+    }
+
+    // update the queue
+    std::lock_guard<std::mutex> pLock(m_pMutex);
+    for (auto& pScan : m_vUploadQueue)
+    {
+        if (pScan.pAsset == &pNotes && pScan.nExtra == gsl::narrow_cast<int>(nAddress))
+        {
+            pScan.sErrorMessage = sErrorMessage;
+            pScan.nState = nState;
+            break;
+        }
+    }
+}
+
 void AssetUploadViewModel::ShowResults() const
 {
     int nSuccessful = 0;
diff --git a/src/ui/viewmodels/AssetUploadViewModel.hh b/src/ui/viewmodels/AssetUploadViewModel.hh
index 6c8027dc..394f427b 100644
--- a/src/ui/viewmodels/AssetUploadViewModel.hh
+++ b/src/ui/viewmodels/AssetUploadViewModel.hh
@@ -5,6 +5,7 @@
 #include "ProgressViewModel.hh"
 
 #include "data\models\AchievementModel.hh"
+#include "data\models\CodeNotesModel.hh"
 #include "data\models\LeaderboardModel.hh"
 
 namespace ra {
@@ -19,6 +20,8 @@ public:
     void QueueAsset(ra::data::models::AssetModelBase& pAsset);
     void QueueAchievement(ra::data::models::AchievementModel& pAchievement);
     void QueueLeaderboard(ra::data::models::LeaderboardModel& pLeaderboard);
+    void QueueCodeNotes(ra::data::models::CodeNotesModel& pLeaderboard);
+    void QueueCodeNote(ra::data::models::CodeNotesModel& pCodeNotes, ra::ByteAddress nAddress);
 
     void ShowResults() const;
 
@@ -39,12 +42,14 @@ private:
     {
         ra::data::models::AssetModelBase* pAsset = nullptr;
         std::string sErrorMessage;
+        int nExtra = 0;
         UploadState nState = UploadState::None;
     };
 
     void UploadBadge(const std::wstring& sBadge);
     void UploadAchievement(ra::data::models::AchievementModel& pAchievement);
     void UploadLeaderboard(ra::data::models::LeaderboardModel& pLeaderboard);
+    void UploadCodeNote(ra::data::models::CodeNotesModel& pNotes, ra::ByteAddress nAddress);
 
     std::vector<UploadItem> m_vUploadQueue;
     std::mutex m_pMutex;
diff --git a/src/ui/viewmodels/CodeNotesViewModel.cpp b/src/ui/viewmodels/CodeNotesViewModel.cpp
index 35fc0124..bcef93a1 100644
--- a/src/ui/viewmodels/CodeNotesViewModel.cpp
+++ b/src/ui/viewmodels/CodeNotesViewModel.cpp
@@ -7,6 +7,7 @@
 
 #include "services\ServiceLocator.hh"
 
+#include "ui\EditorTheme.hh"
 #include "ui\viewmodels\MessageBoxViewModel.hh"
 #include "ui\viewmodels\WindowManager.hh"
 
@@ -20,6 +21,13 @@ const StringModelProperty CodeNotesViewModel::FilterValueProperty("CodeNotesView
 const StringModelProperty CodeNotesViewModel::CodeNoteViewModel::LabelProperty("CodeNoteViewModel", "Label", L"");
 const StringModelProperty CodeNotesViewModel::CodeNoteViewModel::NoteProperty("CodeNoteViewModel", "Note", L"");
 const BoolModelProperty CodeNotesViewModel::CodeNoteViewModel::IsSelectedProperty("CodeNoteViewModel", "IsSelected", false);
+const IntModelProperty CodeNotesViewModel::CodeNoteViewModel::BookmarkColorProperty("CodeNoteViewModel", "BookmarkColor", 0);
+
+void CodeNotesViewModel::CodeNoteViewModel::SetModified(bool bValue)
+{
+    const auto& pEditorTheme = ra::services::ServiceLocator::Get<ra::ui::EditorTheme>();
+    SetBookmarkColor(bValue ? pEditorTheme.ColorModified() : pEditorTheme.ColorNormal());
+}
 
 CodeNotesViewModel::CodeNotesViewModel() noexcept
 {
@@ -73,7 +81,7 @@ void CodeNotesViewModel::ResetFilter()
     auto* pCodeNotes = pGameContext.Assets().FindCodeNotes();
     if (pCodeNotes != nullptr)
     {
-        pCodeNotes->EnumerateCodeNotes([this, &nIndex](ra::ByteAddress nAddress, unsigned int nBytes, const std::wstring& sNote)
+        pCodeNotes->EnumerateCodeNotes([this, &nIndex, pCodeNotes](ra::ByteAddress nAddress, unsigned int nBytes, const std::wstring& sNote)
         {
             std::wstring sAddress;
             if (nBytes <= 4)
@@ -94,6 +102,7 @@ void CodeNotesViewModel::ResetFilter()
             }
             vmNote->nAddress = nAddress;
             vmNote->nBytes = nBytes;
+            vmNote->SetModified(pCodeNotes->IsNoteModified(nAddress));
 
             ++nIndex;
             return true;
@@ -165,6 +174,7 @@ void CodeNotesViewModel::OnCodeNoteChanged(ra::ByteAddress nAddress, const std::
             {
                 /* existing note was updated */
                 pNote->SetNote(sNewNote);
+                pNote->SetModified(pCodeNotes->IsNoteModified(nAddress));
             }
             else
             {
@@ -186,6 +196,7 @@ void CodeNotesViewModel::OnCodeNoteChanged(ra::ByteAddress nAddress, const std::
     {
         m_vNotes.BeginUpdate();
         auto& pNote = m_vNotes.Add(ra::Widen(ra::ByteAddressToString(nAddress)), sNewNote);
+        pNote.SetModified(pCodeNotes->IsNoteModified(nAddress));
         pNote.nAddress = nAddress;
         pNote.nBytes = 1; // this will be updated when the filter is reset
         m_vNotes.MoveItem(m_vNotes.Count() - 1, nIndex);
diff --git a/src/ui/viewmodels/CodeNotesViewModel.hh b/src/ui/viewmodels/CodeNotesViewModel.hh
index 9bed822c..405ebd24 100644
--- a/src/ui/viewmodels/CodeNotesViewModel.hh
+++ b/src/ui/viewmodels/CodeNotesViewModel.hh
@@ -85,6 +85,23 @@ public:
         /// </summary>
         void SetSelected(bool bValue) { SetValue(IsSelectedProperty, bValue); }
 
+        /// <summary>
+        /// The <see cref="ModelProperty" /> for the bookmark color.
+        /// </summary>
+        static const IntModelProperty BookmarkColorProperty;
+
+        /// <summary>
+        /// Gets the bookmark color.
+        /// </summary>
+        Color GetBookmarkColor() const { return Color(ra::to_unsigned(GetValue(BookmarkColorProperty))); }
+
+        /// <summary>
+        /// Sets the bookmark color.
+        /// </summary>
+        void SetBookmarkColor(Color value) { SetValue(BookmarkColorProperty, ra::to_signed(value.ARGB)); }
+
+        void SetModified(bool bModified);
+
         ra::ByteAddress nAddress = 0;
         unsigned int nBytes = 1;
     };
diff --git a/src/ui/viewmodels/IntegrationMenuViewModel.cpp b/src/ui/viewmodels/IntegrationMenuViewModel.cpp
index 3cebbab0..da94069c 100644
--- a/src/ui/viewmodels/IntegrationMenuViewModel.cpp
+++ b/src/ui/viewmodels/IntegrationMenuViewModel.cpp
@@ -74,10 +74,12 @@ void IntegrationMenuViewModel::AddCommonMenuItems(LookupItemViewModelCollection&
     vmMenu.Add(IDM_RA_TOGGLELEADERBOARDS, L"Enable &Leaderboards").SetSelected(pConfiguration.IsFeatureEnabled(ra::services::Feature::Leaderboards));
     vmMenu.Add(IDM_RA_OVERLAYSETTINGS, L"O&verlay Settings");
     vmMenu.Add(0, L"-----");
+    vmMenu.Add(IDM_RA_FILES_OPENALL, L"&Open All");
     vmMenu.Add(IDM_RA_FILES_ACHIEVEMENTS, L"Assets Li&st");
     vmMenu.Add(IDM_RA_FILES_ACHIEVEMENTEDITOR, L"Assets &Editor");
     vmMenu.Add(IDM_RA_FILES_MEMORYFINDER, L"&Memory Inspector");
     vmMenu.Add(IDM_RA_FILES_MEMORYBOOKMARKS, L"Memory &Bookmarks");
+    vmMenu.Add(IDM_RA_FILES_CODENOTES, L"Code &Notes");
     vmMenu.Add(IDM_RA_PARSERICHPRESENCE, L"Rich &Presence Monitor");
 }
 
@@ -133,10 +135,18 @@ void IntegrationMenuViewModel::ActivateMenuItem(int nMenuItemId)
             ShowMemoryBookmarks();
             break;
 
+        case IDM_RA_FILES_CODENOTES:
+            ShowCodeNotes();
+            break;
+
         case IDM_RA_PARSERICHPRESENCE:
             ShowRichPresenceMonitor();
             break;
 
+        case IDM_RA_FILES_OPENALL:
+            ShowAllEditors();
+            break;
+
         case IDM_RA_REPORTBROKENACHIEVEMENTS:
             ReportBrokenAchievements();
             break;
@@ -293,6 +303,16 @@ void IntegrationMenuViewModel::ShowMemoryBookmarks()
     }
 }
 
+void IntegrationMenuViewModel::ShowCodeNotes()
+{
+    auto& pEmulatorContext = ra::services::ServiceLocator::GetMutable<ra::data::context::EmulatorContext>();
+    if (pEmulatorContext.WarnDisableHardcoreMode("view code notes"))
+    {
+        auto& pWindowManager = ra::services::ServiceLocator::GetMutable<ra::ui::viewmodels::WindowManager>();
+        pWindowManager.CodeNotes.Show();
+    }
+}
+
 void IntegrationMenuViewModel::ShowRichPresenceMonitor()
 {
     auto& pGameContext = ra::services::ServiceLocator::GetMutable<ra::data::context::GameContext>();
@@ -304,6 +324,27 @@ void IntegrationMenuViewModel::ShowRichPresenceMonitor()
     pWindowManager.RichPresenceMonitor.Show();
 }
 
+void IntegrationMenuViewModel::ShowAllEditors()
+{
+    auto& pEmulatorContext = ra::services::ServiceLocator::GetMutable<ra::data::context::EmulatorContext>();
+    if (pEmulatorContext.WarnDisableHardcoreMode("use development tools"))
+    {
+        auto& pWindowManager = ra::services::ServiceLocator::GetMutable<ra::ui::viewmodels::WindowManager>();
+
+        pWindowManager.AssetList.Show();
+        pWindowManager.AssetEditor.Show();
+        pWindowManager.MemoryInspector.Show();
+        pWindowManager.MemoryBookmarks.Show();
+        pWindowManager.CodeNotes.Show();
+    }
+    else
+    {
+        ShowAssetList();
+    }
+
+    ShowRichPresenceMonitor();
+}
+
 void IntegrationMenuViewModel::ReportBrokenAchievements()
 {
     ra::ui::viewmodels::BrokenAchievementsViewModel vmBrokenAchievements;
diff --git a/src/ui/viewmodels/IntegrationMenuViewModel.hh b/src/ui/viewmodels/IntegrationMenuViewModel.hh
index bba41294..9d583e87 100644
--- a/src/ui/viewmodels/IntegrationMenuViewModel.hh
+++ b/src/ui/viewmodels/IntegrationMenuViewModel.hh
@@ -33,7 +33,9 @@ private:
     static void ShowAssetEditor();
     static void ShowMemoryInspector();
     static void ShowMemoryBookmarks();
+    static void ShowCodeNotes();
     static void ShowRichPresenceMonitor();
+    static void ShowAllEditors();
     static void ReportBrokenAchievements();
     static void ShowGameHash();
 };
diff --git a/src/ui/viewmodels/MemoryInspectorViewModel.cpp b/src/ui/viewmodels/MemoryInspectorViewModel.cpp
index a292d1fc..a5e60ff1 100644
--- a/src/ui/viewmodels/MemoryInspectorViewModel.cpp
+++ b/src/ui/viewmodels/MemoryInspectorViewModel.cpp
@@ -4,11 +4,12 @@
 
 #include "data\context\EmulatorContext.hh"
 #include "data\context\GameContext.hh"
+#include "data\context\UserContext.hh"
 
-#include "services\IAudioSystem.hh"
 #include "services\IConfiguration.hh"
 #include "services\ServiceLocator.hh"
 
+#include "ui\viewmodels\AssetUploadViewModel.hh"
 #include "ui\viewmodels\MessageBoxViewModel.hh"
 #include "ui\viewmodels\WindowManager.hh"
 
@@ -23,7 +24,9 @@ const StringModelProperty MemoryInspectorViewModel::CurrentAddressBitsProperty("
 const IntModelProperty MemoryInspectorViewModel::CurrentAddressValueProperty("MemoryInspectorViewModel", "CurrentAddressValue", 0);
 const BoolModelProperty MemoryInspectorViewModel::CanModifyNotesProperty("MemoryInspectorViewModel", "CanModifyNotes", true);
 const BoolModelProperty MemoryInspectorViewModel::CurrentBitsVisibleProperty("MemoryInspectorViewModel", "CurrentBitsVisibleProperty", true);
-const BoolModelProperty MemoryInspectorViewModel::IsCurrentAddressNoteEditableProperty("MemoryInspectorViewModel", "IsCurrentAddressNoteEditable", true);
+const BoolModelProperty MemoryInspectorViewModel::CanEditCurrentAddressNoteProperty("MemoryInspectorViewModel", "CanEditCurrentAddressNote", true);
+const BoolModelProperty MemoryInspectorViewModel::CanRevertCurrentAddressNoteProperty("MemoryInspectorViewModel", "CanRevertCurrentAddressNote", false);
+const BoolModelProperty MemoryInspectorViewModel::CanPublishCurrentAddressNoteProperty("MemoryInspectorViewModel", "CanPublishCurrentAddressNote", false);
 
 MemoryInspectorViewModel::MemoryInspectorViewModel()
 {
@@ -32,7 +35,7 @@ MemoryInspectorViewModel::MemoryInspectorViewModel()
     m_pViewer.AddNotifyTarget(*this);
 
     SetValue(CanModifyNotesProperty, false);
-    SetValue(IsCurrentAddressNoteEditableProperty, false);
+    SetValue(CanEditCurrentAddressNoteProperty, false);
 }
 
 void MemoryInspectorViewModel::InitializeNotifyTargets()
@@ -72,10 +75,12 @@ void MemoryInspectorViewModel::OnValueChanged(const IntModelProperty::ChangeArgs
                 (args.tNewValue & 0x01)));
         }
     }
-    else if (args.Property == CurrentAddressProperty && !m_bTyping)
+    else if (args.Property == CurrentAddressProperty && !m_bSyncingAddress)
     {
+        m_bSyncingAddress = true;
         const auto nAddress = static_cast<ra::ByteAddress>(args.tNewValue);
         SetValue(CurrentAddressTextProperty, ra::Widen(ra::ByteAddressToString(nAddress)));
+        m_bSyncingAddress = false;
 
         OnCurrentAddressChanged(nAddress);
     }
@@ -93,46 +98,135 @@ void MemoryInspectorViewModel::OnViewModelIntValueChanged(const IntModelProperty
 
 void MemoryInspectorViewModel::OnValueChanged(const StringModelProperty::ChangeArgs& args)
 {
-    if (args.Property == CurrentAddressTextProperty)
+    if (args.Property == CurrentAddressTextProperty && !m_bSyncingAddress)
     {
         const auto nAddress = ra::ByteAddressFromString(ra::Narrow(args.tNewValue));
 
         // ignore change event for current address so text field is not modified
-        m_bTyping = true;
+        m_bSyncingAddress = true;
         SetCurrentAddress(nAddress);
-        m_bTyping = false;
+        m_bSyncingAddress = false;
 
         OnCurrentAddressChanged(nAddress);
     }
+    else if (args.Property == CurrentAddressNoteProperty && !m_bSyncingCodeNote)
+    {
+        auto& pGameContext = ra::services::ServiceLocator::GetMutable<ra::data::context::GameContext>();
+        auto* pCodeNotes = pGameContext.Assets().FindCodeNotes();
+        if (pCodeNotes != nullptr)
+        {
+            const auto nAddress = GetCurrentAddress();
+            pCodeNotes->SetCodeNote(nAddress, args.tNewValue);
+
+            // don't immediately save, the user may be about to publish. instead, set a flag to
+            // do the save when the address changes.
+            UpdateNoteButtons();
+        }
+    }
 
     WindowViewModelBase::OnValueChanged(args);
 }
 
+void MemoryInspectorViewModel::SaveNotes()
+{
+    if (m_nSavedNoteAddress == 0xFFFFFFFF)
+        return;
+
+    auto& pGameContext = ra::services::ServiceLocator::GetMutable<ra::data::context::GameContext>();
+    auto* pCodeNotes = pGameContext.Assets().FindCodeNotes();
+    if (pCodeNotes != nullptr)
+    {
+        std::wstring sEmpty;
+        const auto* pNote = pCodeNotes->FindCodeNote(m_nSavedNoteAddress);
+        if (pNote == nullptr)
+            pNote = &sEmpty;
+
+        if (*pNote != m_sSavedNote)
+        {
+            m_sSavedNote = *pNote;
+
+            std::vector<ra::data::models::AssetModelBase*> vAssets;
+            vAssets.push_back(pCodeNotes);
+            pGameContext.Assets().SaveAssets(vAssets);
+
+            UpdateNoteButtons();
+        }
+    }
+}
+
+void MemoryInspectorViewModel::UpdateNoteButtons()
+{
+    auto& pGameContext = ra::services::ServiceLocator::GetMutable<ra::data::context::GameContext>();
+    const auto* pCodeNotes = pGameContext.Assets().FindCodeNotes();
+    if (pCodeNotes == nullptr || !CanModifyNotes())
+    {
+        SetValue(CanEditCurrentAddressNoteProperty, false);
+        SetValue(CanPublishCurrentAddressNoteProperty, false);
+        SetValue(CanRevertCurrentAddressNoteProperty, false);
+    }
+    else
+    {
+        const auto nAddress = GetCurrentAddress();
+        if (pCodeNotes->GetIndirectSource(nAddress) != 0xFFFFFFFF)
+        {
+            SetValue(CanEditCurrentAddressNoteProperty, false);
+            SetValue(CanPublishCurrentAddressNoteProperty, false);
+            SetValue(CanRevertCurrentAddressNoteProperty, false);
+        }
+        else
+        {
+            const bool bOffline = ra::services::ServiceLocator::Get<ra::services::IConfiguration>()
+                .IsFeatureEnabled(ra::services::Feature::Offline);
+            const auto bModified = pCodeNotes->IsNoteModified(nAddress);
+
+            SetValue(CanEditCurrentAddressNoteProperty, true);
+            SetValue(CanPublishCurrentAddressNoteProperty, !bOffline && bModified);
+            SetValue(CanRevertCurrentAddressNoteProperty, bModified);
+        }
+    }
+}
+
+void MemoryInspectorViewModel::OnCodeNoteChanged(ra::ByteAddress nAddress, const std::wstring& sNewNote)
+{
+    if (nAddress == GetCurrentAddress())
+        SetCurrentAddressNote(sNewNote);
+}
+
+void MemoryInspectorViewModel::SetCurrentAddressNote(const std::wstring& sValue)
+{
+    m_bSyncingCodeNote = true;
+    SetValue(CurrentAddressNoteProperty, sValue);
+    m_bSyncingCodeNote = false;
+}
+
 void MemoryInspectorViewModel::OnCurrentAddressChanged(ra::ByteAddress nNewAddress)
 {
     const auto& pGameContext = ra::services::ServiceLocator::Get<ra::data::context::GameContext>();
     const auto* pCodeNotes = pGameContext.Assets().FindCodeNotes();
+
+    if (m_nSavedNoteAddress != 0xFFFFFFFF)
+        SaveNotes();
+
+    m_nSavedNoteAddress = nNewAddress;
     const auto* pNote = (pCodeNotes != nullptr) ? pCodeNotes->FindCodeNote(nNewAddress) : nullptr;
     if (pNote)
     {
+        m_sSavedNote = *pNote;
+
         const auto nIndirectSource = pCodeNotes->GetIndirectSource(nNewAddress);
         if (nIndirectSource != 0xFFFFFFFF)
-        {
             SetCurrentAddressNote(ra::StringPrintf(L"[Indirect from %s]\r\n%s", ra::ByteAddressToString(nIndirectSource), *pNote));
-            SetValue(IsCurrentAddressNoteEditableProperty, false);
-        }
         else
-        {
             SetCurrentAddressNote(*pNote);
-            SetValue(IsCurrentAddressNoteEditableProperty, CanModifyNotes());
-        }
     }
     else
     {
+        m_sSavedNote.clear();
         SetCurrentAddressNote(L"");
-        SetValue(IsCurrentAddressNoteEditableProperty, CanModifyNotes());
     }
 
+    UpdateNoteButtons();
+
     const auto& pEmulatorContext = ra::services::ServiceLocator::Get<ra::data::context::EmulatorContext>();
     const auto nValue = pEmulatorContext.ReadMemoryByte(nNewAddress);
     SetValue(CurrentAddressValueProperty, nValue);
@@ -158,113 +252,65 @@ void MemoryInspectorViewModel::BookmarkCurrentAddress() const
     pBookmarks.AddBookmark(nAddress, nSize);
 }
 
-static std::wstring ShortenNote(const std::wstring& sNote)
-{
-    return sNote.length() > 256 ? (sNote.substr(0, 253) + L"...") : sNote;
-}
-
-void MemoryInspectorViewModel::SaveCurrentAddressNote()
+void MemoryInspectorViewModel::PublishCurrentAddressNote()
 {
-    // create a copy of the note. it's less efficient, but using a reference allows the value to be
-    // changed out from under us while the confirmation dialog is open.
-    const std::wstring sNewNote = GetCurrentAddressNote();
-    const auto nAddress = GetCurrentAddress();
+    if (!CanPublishCurrentAddressNote())
+        return;
 
-    std::string sAuthor;
     auto& pGameContext = ra::services::ServiceLocator::GetMutable<ra::data::context::GameContext>();
     auto* pCodeNotes = pGameContext.Assets().FindCodeNotes();
     if (pCodeNotes == nullptr)
         return;
-    const auto* pNote = pCodeNotes->FindCodeNote(nAddress, sAuthor);
-    bool bUpdated = false;
 
-    if (pNote == nullptr)
-    {
-        if (sNewNote.empty())
-        {
-            // unmodified - do nothing
-            return;
-        }
-        else
-        {
-            // new note - just add it
-            bUpdated = pCodeNotes->SetCodeNote(nAddress, sNewNote);
-        }
-    }
-    else if (*pNote == sNewNote)
-    {
-        // unmodified - do nothing
-        return;
-    }
-    else
-    {
-        // value changed - confirm overwrite
-        ra::ui::viewmodels::MessageBoxViewModel vmPrompt;
-        vmPrompt.SetHeader(ra::StringPrintf(L"Overwrite note for address %s?", ra::ByteAddressToString(nAddress)));
+    // disable buttons while publishing
+    SetValue(CanPublishCurrentAddressNoteProperty, false);
 
-        if (sNewNote.length() > 256 || pNote->length() > 256)
-        {
-            const auto sNewNoteShort = ShortenNote(sNewNote);
-            const auto sOldNoteShort = ShortenNote(*pNote);
-            vmPrompt.SetMessage(ra::StringPrintf(L"Are you sure you want to replace %s's note:\n\n%s\n\nWith your note:\n\n%s",
-                sAuthor, sOldNoteShort, sNewNoteShort));
-        }
-        else
-        {
-            vmPrompt.SetMessage(ra::StringPrintf(L"Are you sure you want to replace %s's note:\n\n%s\n\nWith your note:\n\n%s",
-                sAuthor, *pNote, sNewNote));
-        }
+    const auto nAddress = GetCurrentAddress();
 
-        vmPrompt.SetButtons(ra::ui::viewmodels::MessageBoxViewModel::Buttons::YesNo);
-        vmPrompt.SetIcon(ra::ui::viewmodels::MessageBoxViewModel::Icon::Warning);
-        if (vmPrompt.ShowModal() == ra::ui::DialogResult::Yes)
-        {
-            bUpdated = pCodeNotes->SetCodeNote(nAddress, sNewNote);
-        }
-    }
+    ra::ui::viewmodels::AssetUploadViewModel vmAssetUpload;
+    vmAssetUpload.QueueCodeNote(*pCodeNotes, nAddress);
+    vmAssetUpload.ShowModal(*this);
 
-    if (bUpdated)
+    if (pCodeNotes->IsNoteModified(nAddress))
     {
-        ra::services::ServiceLocator::Get<ra::services::IAudioSystem>().Beep();
+        // if canceled, re-enable buttons
+        SetValue(CanPublishCurrentAddressNoteProperty, true);
     }
     else
     {
-        // update failed, revert to previous text
-        SetCurrentAddressNote(pNote ? *pNote : std::wstring());
+        // if we flagged the text as modified to be written on address change, do so now
+        SaveNotes();
+
+        UpdateNoteButtons();
     }
 }
 
-void MemoryInspectorViewModel::DeleteCurrentAddressNote()
+void MemoryInspectorViewModel::RevertCurrentAddressNote()
 {
-    const auto nAddress = GetCurrentAddress();
+    if (!CanRevertCurrentAddressNote())
+        return;
 
-    std::string sAuthor;
     auto& pGameContext = ra::services::ServiceLocator::GetMutable<ra::data::context::GameContext>();
     auto* pCodeNotes = pGameContext.Assets().FindCodeNotes();
     if (pCodeNotes == nullptr)
         return;
-    const auto* pNote = pCodeNotes->FindCodeNote(nAddress, sAuthor);
 
-    if (pNote == nullptr)
+    const auto nAddress = GetCurrentAddress();
+    const auto* pOriginalNote = pCodeNotes->GetServerCodeNote(nAddress);
+    if (pOriginalNote != nullptr)
     {
-        // no note - do nothing
-        return;
-    }
+        ra::ui::viewmodels::MessageBoxViewModel vmPrompt;
+        vmPrompt.SetHeader(ra::StringPrintf(L"Revert note for address %s?", ra::ByteAddressToString(nAddress)));
+        vmPrompt.SetMessage(L"This will discard all local work and revert the note to the last state retrieved from the server.");
+        vmPrompt.SetButtons(ra::ui::viewmodels::MessageBoxViewModel::Buttons::YesNo);
+        if (vmPrompt.ShowModal() == DialogResult::No)
+            return;
 
-    const auto pNoteShort = ShortenNote(*pNote);
+        // make a copy as the original note will be destroyed when we eliminate the modification
+        const std::wstring pOriginalNoteCopy = *pOriginalNote;
+        pCodeNotes->SetCodeNote(nAddress, pOriginalNoteCopy);
 
-    ra::ui::viewmodels::MessageBoxViewModel vmPrompt;
-    vmPrompt.SetHeader(ra::StringPrintf(L"Delete note for address %s?", ra::ByteAddressToString(nAddress)));
-    vmPrompt.SetMessage(ra::StringPrintf(L"Are you sure you want to delete %s's note:\n\n%s", sAuthor, pNoteShort));
-    vmPrompt.SetButtons(ra::ui::viewmodels::MessageBoxViewModel::Buttons::YesNo);
-    vmPrompt.SetIcon(ra::ui::viewmodels::MessageBoxViewModel::Icon::Warning);
-    if (vmPrompt.ShowModal() == ra::ui::DialogResult::Yes)
-    {
-        if (pCodeNotes->DeleteCodeNote(nAddress))
-        {
-            ra::services::ServiceLocator::Get<ra::services::IAudioSystem>().Beep();
-            SetCurrentAddressNote(L"");
-        }
+        UpdateNoteButtons();
     }
 }
 
@@ -334,6 +380,9 @@ void MemoryInspectorViewModel::ToggleBit(int nBit)
 
 void MemoryInspectorViewModel::OnActiveGameChanged()
 {
+    m_nSavedNoteAddress = 0xFFFFFFFF;
+    m_sSavedNote.clear();
+
     const auto& pGameContext = ra::services::ServiceLocator::Get<ra::data::context::GameContext>();
     if (pGameContext.GameId() == 0)
     {
@@ -347,12 +396,10 @@ void MemoryInspectorViewModel::OnActiveGameChanged()
         else
             SetWindowTitle(L"Memory Inspector");
 
-        const bool bOffline = ra::services::ServiceLocator::Get<ra::services::IConfiguration>().
-            IsFeatureEnabled(ra::services::Feature::Offline);
-        SetValue(CanModifyNotesProperty, !bOffline);
+        SetValue(CanModifyNotesProperty, true);
     }
 
-    SetValue(IsCurrentAddressNoteEditableProperty, CanModifyNotes());
+    UpdateNoteButtons();
 
     if (pGameContext.GameId() != m_nGameId)
     {
diff --git a/src/ui/viewmodels/MemoryInspectorViewModel.hh b/src/ui/viewmodels/MemoryInspectorViewModel.hh
index b94e3747..9fe16053 100644
--- a/src/ui/viewmodels/MemoryInspectorViewModel.hh
+++ b/src/ui/viewmodels/MemoryInspectorViewModel.hh
@@ -51,7 +51,7 @@ public:
     /// <summary>
     /// Sets the current address.
     /// </summary>
-    void SetCurrentAddress(const ra::ByteAddress sValue) { SetValue(CurrentAddressProperty, sValue); }
+    void SetCurrentAddress(const ra::ByteAddress nValue) { SetValue(CurrentAddressProperty, nValue); }
 
     /// <summary>
     /// The <see cref="ModelProperty" /> for the current address as a string.
@@ -78,23 +78,50 @@ public:
     /// <summary>
     /// Sets the current address's note.
     /// </summary>
-    void SetCurrentAddressNote(const std::wstring& sValue) { SetValue(CurrentAddressNoteProperty, sValue); }
+    void SetCurrentAddressNote(const std::wstring& sValue);
 
     /// <summary>
     /// The <see cref="ModelProperty" /> for whether or not the current address note is editable.
     /// </summary>
-    static const BoolModelProperty IsCurrentAddressNoteEditableProperty;
+    static const BoolModelProperty CanEditCurrentAddressNoteProperty;
 
     /// <summary>
     /// Gets whether or not the current address note is editable.
     /// </summary>
-    const bool IsCurrentAddressNoteEditable() const { return GetValue(IsCurrentAddressNoteEditableProperty); }
+    const bool CanEditCurrentAddressNote() const { return GetValue(CanEditCurrentAddressNoteProperty); }
 
-    void SaveCurrentAddressNote();
-    void DeleteCurrentAddressNote();
+    /// <summary>
+    /// The <see cref="ModelProperty" /> for whether or not the current address note is modified.
+    /// </summary>
+    static const BoolModelProperty CanRevertCurrentAddressNoteProperty;
+
+    /// <summary>
+    /// Gets whether or not the current address note is modified.
+    /// </summary>
+    const bool CanRevertCurrentAddressNote() const { return GetValue(CanRevertCurrentAddressNoteProperty); }
+
+    /// <summary>
+    /// The <see cref="ModelProperty" /> for whether or not notes can be modified.
+    /// </summary>
     static const BoolModelProperty CanModifyNotesProperty;
+
+    /// <summary>
+    /// Gets whether or not notes can be modified.
+    /// </summary>
     const bool CanModifyNotes() const { return GetValue(CanModifyNotesProperty); }
 
+    /// <summary>
+    /// The <see cref="ModelProperty" /> for whether or not the current address note can be published.
+    /// </summary>
+    static const BoolModelProperty CanPublishCurrentAddressNoteProperty;
+
+    /// <summary>
+    /// Gets whether or not the current address note can be published.
+    /// </summary>
+    const bool CanPublishCurrentAddressNote() const { return GetValue(CanPublishCurrentAddressNoteProperty); }
+
+    void PublishCurrentAddressNote();
+    void RevertCurrentAddressNote();
 
     void OpenNotesList();
 
@@ -137,15 +164,22 @@ protected:
     // GameContext::NotifyTarget
     void OnActiveGameChanged() override;
     void OnEndGameLoad() override;
+    void OnCodeNoteChanged(ra::ByteAddress nAddress, const std::wstring& sNewNote) override;
 
 private:
     static const IntModelProperty CurrentAddressValueProperty;
     void OnCurrentAddressChanged(ra::ByteAddress nNewAddress);
 
+    void UpdateNoteButtons();
+    void SaveNotes();
+
     MemorySearchViewModel m_pSearch;
     MemoryViewerViewModel m_pViewer;
     unsigned m_nGameId = 0;
-    bool m_bTyping = false;
+    bool m_bSyncingAddress = false;
+    bool m_bSyncingCodeNote = false;
+    ra::ByteAddress m_nSavedNoteAddress = 0xFFFFFFFF;
+    std::wstring m_sSavedNote;
 };
 
 } // namespace viewmodels
diff --git a/src/ui/win32/AssetEditorDialog.cpp b/src/ui/win32/AssetEditorDialog.cpp
index d0934079..683d39aa 100644
--- a/src/ui/win32/AssetEditorDialog.cpp
+++ b/src/ui/win32/AssetEditorDialog.cpp
@@ -734,8 +734,9 @@ AssetEditorDialog::AssetEditorDialog(AssetEditorViewModel& vmAssetEditor)
     m_bindWindow.BindEnabled(IDC_RA_BADGENAME, AssetEditorViewModel::IsAssetLoadedProperty);
     m_bindWindow.BindEnabled(IDC_RA_BADGE_SPIN, AssetEditorViewModel::IsAssetLoadedProperty);
     m_bindWindow.BindEnabled(IDC_RA_UPLOAD_BADGE, AssetEditorViewModel::IsAssetLoadedProperty);
+    m_bindWindow.BindEnabled(IDC_RA_CHK_HIGHLIGHTS, AssetEditorViewModel::IsAssetLoadedProperty);
     m_bindWindow.BindEnabled(IDC_RA_CHK_PAUSE_ON_RESET, AssetEditorViewModel::IsAssetLoadedProperty);
-    m_bindWindow.BindEnabled(IDC_RA_CHK_PAUSE_ON_TRIGGER, AssetEditorViewModel::IsAssetLoadedProperty);
+    m_bindWindow.BindEnabled(IDC_RA_CHK_PAUSE_ON_TRIGGER, AssetEditorViewModel::IsTriggerProperty);
     m_bindWindow.BindEnabled(IDC_RA_LBX_GROUPS, AssetEditorViewModel::IsAssetLoadedProperty);
     m_bindWindow.BindEnabled(IDC_RA_ADD_GROUP, AssetEditorViewModel::IsAssetLoadedProperty);
     m_bindWindow.BindEnabled(IDC_RA_DELETE_GROUP, AssetEditorViewModel::IsAssetLoadedProperty);
@@ -751,7 +752,6 @@ AssetEditorDialog::AssetEditorDialog(AssetEditorViewModel& vmAssetEditor)
     m_bindWindow.BindLabel(IDC_RA_MEASURED, AssetEditorViewModel::MeasuredValueProperty);
     m_bindWindow.BindVisible(IDC_RA_LBL_MEASURED, AssetEditorViewModel::HasMeasuredProperty);
     m_bindWindow.BindVisible(IDC_RA_MEASURED, AssetEditorViewModel::HasMeasuredProperty);
-    m_bindWindow.BindEnabled(IDC_RA_CHK_PAUSE_ON_TRIGGER, AssetEditorViewModel::IsTriggerProperty);
 
     // achievement only fields
     m_bindBadge.BindText(AssetEditorViewModel::BadgeProperty);
diff --git a/src/ui/win32/CodeNotesDialog.cpp b/src/ui/win32/CodeNotesDialog.cpp
index 48c1344d..5512bbd6 100644
--- a/src/ui/win32/CodeNotesDialog.cpp
+++ b/src/ui/win32/CodeNotesDialog.cpp
@@ -79,12 +79,14 @@ CodeNotesDialog::CodeNotesDialog(CodeNotesViewModel& vmCodeNotes)
         CodeNotesViewModel::CodeNoteViewModel::LabelProperty);
     pAddressColumn->SetHeader(L"Address");
     pAddressColumn->SetWidth(GridColumnBinding::WidthType::Pixels, 64);
+    pAddressColumn->SetTextColorProperty(CodeNotesViewModel::CodeNoteViewModel::BookmarkColorProperty);
     m_bindNotes.BindColumn(0, std::move(pAddressColumn));
 
     auto pDescriptionColumn = std::make_unique<ra::ui::win32::bindings::GridTextColumnBinding>(
         CodeNotesViewModel::CodeNoteViewModel::NoteProperty);
     pDescriptionColumn->SetHeader(L"Note");
     pDescriptionColumn->SetWidth(GridColumnBinding::WidthType::Fill, 40);
+    pDescriptionColumn->SetTextColorProperty(CodeNotesViewModel::CodeNoteViewModel::BookmarkColorProperty);
     m_bindNotes.BindColumn(1, std::move(pDescriptionColumn));
 
     m_bindNotes.BindItems(vmCodeNotes.Notes());
diff --git a/src/ui/win32/MemoryInspectorDialog.cpp b/src/ui/win32/MemoryInspectorDialog.cpp
index 3df4c35b..4ed69709 100644
--- a/src/ui/win32/MemoryInspectorDialog.cpp
+++ b/src/ui/win32/MemoryInspectorDialog.cpp
@@ -222,10 +222,10 @@ MemoryInspectorDialog::MemoryInspectorDialog(MemoryInspectorViewModel& vmMemoryI
         vmMemoryInspector->NextNote();
         return true;
     });
-    m_bindNoteText.BindText(MemoryInspectorViewModel::CurrentAddressNoteProperty);
-    m_bindWindow.BindEnabled(IDC_RA_NOTE_TEXT, MemoryInspectorViewModel::IsCurrentAddressNoteEditableProperty);
-    m_bindWindow.BindEnabled(IDC_RA_ADD_NOTE, MemoryInspectorViewModel::IsCurrentAddressNoteEditableProperty);
-    m_bindWindow.BindEnabled(IDC_RA_DELETE_NOTE, MemoryInspectorViewModel::IsCurrentAddressNoteEditableProperty);
+    m_bindNoteText.BindText(MemoryInspectorViewModel::CurrentAddressNoteProperty, ra::ui::win32::bindings::TextBoxBinding::UpdateMode::Typing);
+    m_bindWindow.BindEnabled(IDC_RA_NOTE_TEXT, MemoryInspectorViewModel::CanEditCurrentAddressNoteProperty);
+    m_bindWindow.BindEnabled(IDC_RA_PUBLISH_NOTE, MemoryInspectorViewModel::CanPublishCurrentAddressNoteProperty);
+    m_bindWindow.BindEnabled(IDC_RA_REVERT_NOTE, MemoryInspectorViewModel::CanRevertCurrentAddressNoteProperty);
 
     // Memory Viewer
     m_bindViewer8Bit.BindCheck(MemoryViewerViewModel::SizeProperty, ra::etoi(MemSize::EightBit));
@@ -265,8 +265,8 @@ MemoryInspectorDialog::MemoryInspectorDialog(MemoryInspectorViewModel& vmMemoryI
     SetAnchor(IDC_RA_VIEW_CODENOTES, Anchor::Top | Anchor::Left);
     SetAnchor(IDC_RA_ADDBOOKMARK, Anchor::Top | Anchor::Left);
     SetAnchor(IDC_RA_NOTE_TEXT, Anchor::Top | Anchor::Left | Anchor::Right);
-    SetAnchor(IDC_RA_ADD_NOTE, Anchor::Top | Anchor::Right);
-    SetAnchor(IDC_RA_DELETE_NOTE, Anchor::Top | Anchor::Right);
+    SetAnchor(IDC_RA_PUBLISH_NOTE, Anchor::Top | Anchor::Right);
+    SetAnchor(IDC_RA_REVERT_NOTE, Anchor::Top | Anchor::Right);
 
     SetAnchor(IDC_RA_MEMVIEW_8BIT, Anchor::Top | Anchor::Left);
     SetAnchor(IDC_RA_MEMVIEW_16BIT, Anchor::Top | Anchor::Left);
@@ -404,20 +404,20 @@ BOOL MemoryInspectorDialog::OnCommand(WORD nCommand)
             return TRUE;
         }
 
-        case IDC_RA_ADD_NOTE:
+        case IDC_RA_PUBLISH_NOTE:
         {
             auto* vmMemoryInspector = dynamic_cast<MemoryInspectorViewModel*>(&m_vmWindow);
             if (vmMemoryInspector)
-                vmMemoryInspector->SaveCurrentAddressNote();
+                vmMemoryInspector->PublishCurrentAddressNote();
 
             return TRUE;
         }
 
-        case IDC_RA_DELETE_NOTE:
+        case IDC_RA_REVERT_NOTE:
         {
             auto* vmMemoryInspector = dynamic_cast<MemoryInspectorViewModel*>(&m_vmWindow);
             if (vmMemoryInspector)
-                vmMemoryInspector->DeleteCurrentAddressNote();
+                vmMemoryInspector->RevertCurrentAddressNote();
 
             return TRUE;
         }
diff --git a/src/ui/win32/bindings/TextBoxBinding.hh b/src/ui/win32/bindings/TextBoxBinding.hh
index bd84dc84..8028bff4 100644
--- a/src/ui/win32/bindings/TextBoxBinding.hh
+++ b/src/ui/win32/bindings/TextBoxBinding.hh
@@ -4,6 +4,9 @@
 
 #include "ControlBinding.hh"
 
+#include "services\IThreadPool.hh"
+#include "services\ServiceLocator.hh"
+
 namespace ra {
 namespace ui {
 namespace win32 {
@@ -29,6 +32,7 @@ public:
         None = 0,  // one way from source
         LostFocus, // update source when control loses focus
         KeyPress,  // update source after each key press
+        Typing,    // update source 500ms after a key press
     };
 
     void BindText(const StringModelProperty& pSourceProperty, UpdateMode nUpdateMode = UpdateMode::LostFocus)
@@ -49,6 +53,8 @@ public:
     {
         if (m_pTextUpdateMode == UpdateMode::KeyPress)
             UpdateSource();
+        else if (m_pTextUpdateMode == UpdateMode::Typing)
+            UpdateSourceDelayed();
     }
 
     void BindKey(unsigned int nKey, std::function<bool()> pHandler)
@@ -117,6 +123,19 @@ protected:
         SetValue(*m_pTextBoundProperty, sText);
     }
 
+    void UpdateSourceDelayed()
+    {
+        static int nVersion = 0;
+        const int nCapturedVersion = ++nVersion;
+
+        ra::services::ServiceLocator::GetMutable<ra::services::IThreadPool>().ScheduleAsync(
+            std::chrono::milliseconds(300), [this, nCapturedVersion]()
+            {
+                if (nCapturedVersion == nVersion)
+                    UpdateSource();
+            });
+    }
+
     UpdateMode m_pTextUpdateMode = UpdateMode::None;
 
 private:


Test patch:
diff --git a/tests/Exports_Tests.cpp b/tests/Exports_Tests.cpp
index eacab3f0..7a87aa06 100644
--- a/tests/Exports_Tests.cpp
+++ b/tests/Exports_Tests.cpp
@@ -971,7 +971,7 @@ private:
         ra::data::context::mocks::MockUserContext mockUserContext;
 
         RA_MenuItem menu[32];
-        Assert::AreEqual(13, _RA_GetPopupMenuItems(menu));
+        Assert::AreEqual(15, _RA_GetPopupMenuItems(menu));
         AssertMenuItem(&menu[0], IDM_RA_FILES_LOGIN, L"&Login");
         AssertMenuItem(&menu[1], 0, nullptr);
         AssertMenuItem(&menu[2], IDM_RA_HARDCORE_MODE, L"&Hardcore Mode");
@@ -980,11 +980,13 @@ private:
         AssertMenuItem(&menu[5], IDM_RA_TOGGLELEADERBOARDS, L"Enable &Leaderboards");
         AssertMenuItem(&menu[6], IDM_RA_OVERLAYSETTINGS, L"O&verlay Settings");
         AssertMenuItem(&menu[7], 0, nullptr);
-        AssertMenuItem(&menu[8], IDM_RA_FILES_ACHIEVEMENTS, L"Assets Li&st");
-        AssertMenuItem(&menu[9], IDM_RA_FILES_ACHIEVEMENTEDITOR, L"Assets &Editor");
-        AssertMenuItem(&menu[10], IDM_RA_FILES_MEMORYFINDER, L"&Memory Inspector");
-        AssertMenuItem(&menu[11], IDM_RA_FILES_MEMORYBOOKMARKS, L"Memory &Bookmarks");
-        AssertMenuItem(&menu[12], IDM_RA_PARSERICHPRESENCE, L"Rich &Presence Monitor");
+        AssertMenuItem(&menu[8], IDM_RA_FILES_OPENALL, L"&Open All");
+        AssertMenuItem(&menu[9], IDM_RA_FILES_ACHIEVEMENTS, L"Assets Li&st");
+        AssertMenuItem(&menu[10], IDM_RA_FILES_ACHIEVEMENTEDITOR, L"Assets &Editor");
+        AssertMenuItem(&menu[11], IDM_RA_FILES_MEMORYFINDER, L"&Memory Inspector");
+        AssertMenuItem(&menu[12], IDM_RA_FILES_MEMORYBOOKMARKS, L"Memory &Bookmarks");
+        AssertMenuItem(&menu[13], IDM_RA_FILES_CODENOTES, L"Code &Notes");
+        AssertMenuItem(&menu[14], IDM_RA_PARSERICHPRESENCE, L"Rich &Presence Monitor");
     }
 
     TEST_METHOD(TestGetPopupMenuItemsLoggedIn)
@@ -994,7 +996,7 @@ private:
         mockUserContext.Initialize("User", "TOKEN");
 
         RA_MenuItem menu[32];
-        Assert::AreEqual(19, _RA_GetPopupMenuItems(menu));
+        Assert::AreEqual(21, _RA_GetPopupMenuItems(menu));
         AssertMenuItem(&menu[0], IDM_RA_FILES_LOGOUT, L"Log&out");
         AssertMenuItem(&menu[1], 0, nullptr);
         AssertMenuItem(&menu[2], IDM_RA_OPENUSERPAGE, L"Open my &User Page");
@@ -1006,14 +1008,16 @@ private:
         AssertMenuItem(&menu[8], IDM_RA_TOGGLELEADERBOARDS, L"Enable &Leaderboards");
         AssertMenuItem(&menu[9], IDM_RA_OVERLAYSETTINGS, L"O&verlay Settings");
         AssertMenuItem(&menu[10], 0, nullptr);
-        AssertMenuItem(&menu[11], IDM_RA_FILES_ACHIEVEMENTS, L"Assets Li&st");
-        AssertMenuItem(&menu[12], IDM_RA_FILES_ACHIEVEMENTEDITOR, L"Assets &Editor");
-        AssertMenuItem(&menu[13], IDM_RA_FILES_MEMORYFINDER, L"&Memory Inspector");
-        AssertMenuItem(&menu[14], IDM_RA_FILES_MEMORYBOOKMARKS, L"Memory &Bookmarks");
-        AssertMenuItem(&menu[15], IDM_RA_PARSERICHPRESENCE, L"Rich &Presence Monitor");
-        AssertMenuItem(&menu[16], 0, nullptr);
-        AssertMenuItem(&menu[17], IDM_RA_REPORTBROKENACHIEVEMENTS, L"&Report Achievement Problem");
-        AssertMenuItem(&menu[18], IDM_RA_GETROMCHECKSUM, L"View Game H&ash");
+        AssertMenuItem(&menu[11], IDM_RA_FILES_OPENALL, L"&Open All");
+        AssertMenuItem(&menu[12], IDM_RA_FILES_ACHIEVEMENTS, L"Assets Li&st");
+        AssertMenuItem(&menu[13], IDM_RA_FILES_ACHIEVEMENTEDITOR, L"Assets &Editor");
+        AssertMenuItem(&menu[14], IDM_RA_FILES_MEMORYFINDER, L"&Memory Inspector");
+        AssertMenuItem(&menu[15], IDM_RA_FILES_MEMORYBOOKMARKS, L"Memory &Bookmarks");
+        AssertMenuItem(&menu[16], IDM_RA_FILES_CODENOTES, L"Code &Notes");
+        AssertMenuItem(&menu[17], IDM_RA_PARSERICHPRESENCE, L"Rich &Presence Monitor");
+        AssertMenuItem(&menu[18], 0, nullptr);
+        AssertMenuItem(&menu[19], IDM_RA_REPORTBROKENACHIEVEMENTS, L"&Report Achievement Problem");
+        AssertMenuItem(&menu[20], IDM_RA_GETROMCHECKSUM, L"View Game H&ash");
     }
 
     TEST_METHOD(TestGetPopupMenuItemsChecked)
@@ -1026,7 +1030,7 @@ private:
         mockConfiguration.SetFeatureEnabled(ra::services::Feature::Leaderboards, true);
 
         RA_MenuItem menu[32];
-        Assert::AreEqual(19, _RA_GetPopupMenuItems(menu));
+        Assert::AreEqual(21, _RA_GetPopupMenuItems(menu));
         AssertMenuItem(&menu[0], IDM_RA_FILES_LOGOUT, L"Log&out");
         AssertMenuItem(&menu[1], 0, nullptr);
         AssertMenuItem(&menu[2], IDM_RA_OPENUSERPAGE, L"Open my &User Page");
@@ -1038,14 +1042,16 @@ private:
         AssertMenuItem(&menu[8], IDM_RA_TOGGLELEADERBOARDS, L"Enable &Leaderboards", true);
         AssertMenuItem(&menu[9], IDM_RA_OVERLAYSETTINGS, L"O&verlay Settings");
         AssertMenuItem(&menu[10], 0, nullptr);
-        AssertMenuItem(&menu[11], IDM_RA_FILES_ACHIEVEMENTS, L"Assets Li&st");
-        AssertMenuItem(&menu[12], IDM_RA_FILES_ACHIEVEMENTEDITOR, L"Assets &Editor");
-        AssertMenuItem(&menu[13], IDM_RA_FILES_MEMORYFINDER, L"&Memory Inspector");
-        AssertMenuItem(&menu[14], IDM_RA_FILES_MEMORYBOOKMARKS, L"Memory &Bookmarks");
-        AssertMenuItem(&menu[15], IDM_RA_PARSERICHPRESENCE, L"Rich &Presence Monitor");
-        AssertMenuItem(&menu[16], 0, nullptr);
-        AssertMenuItem(&menu[17], IDM_RA_REPORTBROKENACHIEVEMENTS, L"&Report Achievement Problem");
-        AssertMenuItem(&menu[18], IDM_RA_GETROMCHECKSUM, L"View Game H&ash");
+        AssertMenuItem(&menu[11], IDM_RA_FILES_OPENALL, L"&Open All");
+        AssertMenuItem(&menu[12], IDM_RA_FILES_ACHIEVEMENTS, L"Assets Li&st");
+        AssertMenuItem(&menu[13], IDM_RA_FILES_ACHIEVEMENTEDITOR, L"Assets &Editor");
+        AssertMenuItem(&menu[14], IDM_RA_FILES_MEMORYFINDER, L"&Memory Inspector");
+        AssertMenuItem(&menu[15], IDM_RA_FILES_MEMORYBOOKMARKS, L"Memory &Bookmarks");
+        AssertMenuItem(&menu[16], IDM_RA_FILES_CODENOTES, L"Code &Notes");
+        AssertMenuItem(&menu[17], IDM_RA_PARSERICHPRESENCE, L"Rich &Presence Monitor");
+        AssertMenuItem(&menu[18], 0, nullptr);
+        AssertMenuItem(&menu[19], IDM_RA_REPORTBROKENACHIEVEMENTS, L"&Report Achievement Problem");
+        AssertMenuItem(&menu[20], IDM_RA_GETROMCHECKSUM, L"View Game H&ash");
     }
 };
 
diff --git a/tests/data/models/CodeNotesModel_Tests.cpp b/tests/data/models/CodeNotesModel_Tests.cpp
index 049a7b38..c99ee908 100644
--- a/tests/data/models/CodeNotesModel_Tests.cpp
+++ b/tests/data/models/CodeNotesModel_Tests.cpp
@@ -2,6 +2,8 @@
 
 #include "data\models\CodeNotesModel.hh"
 
+#include "services\impl\StringTextWriter.hh"
+
 #include "tests\RA_UnitTestHelpers.h"
 #include "tests\data\DataAsserts.hh"
 
@@ -87,6 +89,15 @@ private:
                     ra::StringPrintf(L"Bytes for address %04X", nAddress).c_str());
             }
         }
+
+        void AssertSerialize(const std::string& sExpected)
+        {
+            std::string sSerialized = "N0";
+            ra::services::impl::StringTextWriter pTextWriter(sSerialized);
+            Serialize(pTextWriter);
+
+            Assert::AreEqual(sExpected, sSerialized);
+        }
     };
 
 public:
@@ -414,52 +425,12 @@ public:
         notes.InitializeCodeNotes(1U);
         notes.mNewNotes.clear();
 
-        Assert::IsTrue(notes.SetCodeNote(1234, L"Note1"));
+        notes.SetCodeNote(1234, L"Note1");
 
         notes.AssertNote(1234U, L"Note1");
         Assert::AreEqual(std::wstring(L"Note1"), notes.mNewNotes[1234U]);
     }
 
-    TEST_METHOD(TestSetCodeNoteGameNotLoaded)
-    {
-        CodeNotesModelHarness notes;
-        notes.mockServer.ExpectUncalled<ra::api::UpdateCodeNote>();
-
-        notes.InitializeCodeNotes(0U);
-        Assert::IsFalse(notes.SetCodeNote(1234, L"Note1"));
-
-        const auto* pNote1 = notes.FindCodeNote(1234U);
-        Assert::IsNull(pNote1);
-    }
-
-    TEST_METHOD(TestUpdateCodeNote)
-    {
-        int nCalls = 0;
-        CodeNotesModelHarness notes;
-        notes.mockServer.HandleRequest<ra::api::FetchCodeNotes>([](const ra::api::FetchCodeNotes::Request& request, ra::api::FetchCodeNotes::Response&)
-        {
-            Assert::AreEqual(1U, request.GameId);
-            return true;
-        });
-        notes.mockServer.HandleRequest<ra::api::UpdateCodeNote>([&nCalls](const ra::api::UpdateCodeNote::Request&, ra::api::UpdateCodeNote::Response& response)
-        {
-            ++nCalls;
-            response.Result = ra::api::ApiResult::Success;
-            return true;
-        });
-
-        notes.InitializeCodeNotes(1U);
-        Assert::IsTrue(notes.SetCodeNote(1234, L"Note1"));
-
-        notes.AssertNote(1234U, L"Note1");
-
-        Assert::IsTrue(notes.SetCodeNote(1234, L"Note1b"));
-        notes.AssertNote(1234U, L"Note1b");
-        Assert::AreEqual(std::wstring(L"Note1b"), notes.mNewNotes[1234U]);
-
-        Assert::AreEqual(2, nCalls);
-    }
-
     TEST_METHOD(TestDeleteCodeNote)
     {
         CodeNotesModelHarness notes;
@@ -479,11 +450,11 @@ public:
         });
 
         notes.SetGameId(1U);
-        Assert::IsTrue(notes.SetCodeNote(1234, L"Note1"));
+        notes.SetCodeNote(1234, L"Note1");
 
         notes.AssertNote(1234U, L"Note1");
 
-        Assert::IsTrue(notes.DeleteCodeNote(1234));
+        notes.SetCodeNote(1234, L"");
         const auto* pNote1b = notes.FindCodeNote(1234U);
         Assert::IsNull(pNote1b);
 
@@ -497,7 +468,7 @@ public:
 
         notes.SetGameId(1U);
 
-        Assert::IsTrue(notes.DeleteCodeNote(1234));
+        notes.SetCodeNote(1234, L"");
         const auto* pNote1 = notes.FindCodeNote(1234U);
         Assert::IsNull(pNote1);
     }
@@ -823,6 +794,125 @@ public:
         // non-indirect
         Assert::AreEqual(0xFFFFFFFF, notes.GetIndirectSource(0x08));
     }
+    
+    TEST_METHOD(TestSetServerCodeNote)
+    {
+        CodeNotesModelHarness notes;
+        Assert::AreEqual(AssetChanges::None, notes.GetChanges());
+
+        notes.SetCodeNote(0x1234, L"This is a note.");
+        Assert::AreEqual(AssetChanges::Unpublished, notes.GetChanges());
+        notes.AssertNote(0x1234, std::wstring(L"This is a note."));
+
+        notes.SetServerCodeNote(0x1234, L"This is a note.");
+        Assert::AreEqual(AssetChanges::None, notes.GetChanges());
+        notes.AssertNote(0x1234, std::wstring(L"This is a note."));
+
+        notes.SetCodeNote(0x1234, L"This is a new note.");
+        Assert::AreEqual(AssetChanges::Unpublished, notes.GetChanges());
+        notes.AssertNote(0x1234, std::wstring(L"This is a new note."));
+
+        notes.SetServerCodeNote(0x1234, L"This is a newer note.");
+        Assert::AreEqual(AssetChanges::None, notes.GetChanges());
+        notes.AssertNote(0x1234, std::wstring(L"This is a newer note."));
+    }
+
+    TEST_METHOD(TestSerialize)
+    {
+        CodeNotesModelHarness notes;
+        Assert::AreEqual(AssetChanges::None, notes.GetChanges());
+
+        notes.SetCodeNote(0x1234, L"This is a note.");
+        Assert::AreEqual(AssetChanges::Unpublished, notes.GetChanges());
+        notes.AssertSerialize("N0:0x1234:\"This is a note.\"");
+
+        notes.SetCodeNote(0x1234, L"");
+        Assert::AreEqual(AssetChanges::None, notes.GetChanges());
+    }
+
+    TEST_METHOD(TestSerializeEscaped)
+    {
+        CodeNotesModelHarness notes;
+        Assert::AreEqual(AssetChanges::None, notes.GetChanges());
+
+        notes.SetCodeNote(0x1234, L"16-bit pointer\n+2:\ta\n+4:\tb\n");
+        notes.SetCodeNote(0x0099, L"This string is \"quoted\".");
+        Assert::AreEqual(AssetChanges::Unpublished, notes.GetChanges());
+        notes.AssertSerialize("N0:0x0099:\"This string is \\\"quoted\\\".\"\n"
+                              "N0:0x1234:\"16-bit pointer\\n+2:\\ta\\n+4:\\tb\\n\"");
+    }
+
+    TEST_METHOD(TestSerializeDeleted)
+    {
+        CodeNotesModelHarness notes;
+        notes.SetServerCodeNote(0x1234, L"This is a note.");
+        Assert::AreEqual(AssetChanges::None, notes.GetChanges());
+
+        notes.SetCodeNote(0x1234, L"");
+        Assert::AreEqual(AssetChanges::Unpublished, notes.GetChanges());
+        notes.AssertSerialize("N0:0x1234:\"\"");
+
+        notes.SetCodeNote(0x1234, L"This is a note.");
+        Assert::AreEqual(AssetChanges::None, notes.GetChanges());
+    }
+
+    TEST_METHOD(TestDeserialize)
+    {
+        CodeNotesModelHarness notes;
+        Assert::AreEqual(AssetChanges::None, notes.GetChanges());
+
+        const std::string sSerialized = ":0x1234:\"This is a note.\"";
+        ra::Tokenizer pTokenizer(sSerialized);
+        pTokenizer.Consume(':');
+
+        Assert::IsTrue(notes.Deserialize(pTokenizer));
+        Assert::AreEqual(AssetChanges::Unpublished, notes.GetChanges());
+        notes.AssertNote(0x1234, std::wstring(L"This is a note."));
+    }
+
+    TEST_METHOD(TestDeserializeEscaped)
+    {
+        CodeNotesModelHarness notes;
+        Assert::AreEqual(AssetChanges::None, notes.GetChanges());
+
+        const std::string sSerialized = ":0x1234:\"16-bit pointer\\n+2:\\t\\\"a\\\"\\n+4:\\tb\\n\"";
+        ra::Tokenizer pTokenizer(sSerialized);
+        pTokenizer.Consume(':');
+
+        Assert::IsTrue(notes.Deserialize(pTokenizer));
+        Assert::AreEqual(AssetChanges::Unpublished, notes.GetChanges());
+        notes.AssertNote(0x1234, std::wstring(L"16-bit pointer\n+2:\t\"a\"\n+4:\tb\n"));
+    }
+
+    TEST_METHOD(TestDeserializeUnchanged)
+    {
+        CodeNotesModelHarness notes;
+        notes.SetServerCodeNote(0x1234, L"This is a note.");
+        Assert::AreEqual(AssetChanges::None, notes.GetChanges());
+
+        const std::string sSerialized = ":0x1234:\"This is a note.\"";
+        ra::Tokenizer pTokenizer(sSerialized);
+        pTokenizer.Consume(':');
+
+        Assert::IsTrue(notes.Deserialize(pTokenizer));
+        Assert::AreEqual(AssetChanges::None, notes.GetChanges());
+        notes.AssertNote(0x1234, std::wstring(L"This is a note."));
+    }
+
+    TEST_METHOD(TestDeserializeDeleted)
+    {
+        CodeNotesModelHarness notes;
+        notes.SetServerCodeNote(0x1234, L"This is a note.");
+        Assert::AreEqual(AssetChanges::None, notes.GetChanges());
+
+        const std::string sSerialized = ":0x1234:\"\"";
+        ra::Tokenizer pTokenizer(sSerialized);
+        pTokenizer.Consume(':');
+
+        Assert::IsTrue(notes.Deserialize(pTokenizer));
+        Assert::AreEqual(AssetChanges::Unpublished, notes.GetChanges());
+        notes.AssertNoNote(0x1234);
+    }
 };
 
 } // namespace tests
diff --git a/tests/mocks/MockGameContext.hh b/tests/mocks/MockGameContext.hh
index 207901f8..bdbb3df6 100644
--- a/tests/mocks/MockGameContext.hh
+++ b/tests/mocks/MockGameContext.hh
@@ -97,7 +97,7 @@ public:
             Expects(pCodeNotes != nullptr);
         }
 
-        pCodeNotes->SetCodeNote(nAddress, sNote);
+        pCodeNotes->SetServerCodeNote(nAddress, sNote);
         return true;
     }
 
@@ -111,7 +111,7 @@ public:
             Expects(pCodeNotes != nullptr);
         }
 
-        pCodeNotes->DeleteCodeNote(nAddress);
+        pCodeNotes->SetCodeNote(nAddress, L"");
         return true;
     }
 
@@ -124,20 +124,6 @@ private:
             m_nGameId = nGameId;
             m_fCodeNoteChanged = fCodeNoteChanged;
         }
-
-        bool SetCodeNote(ra::ByteAddress nAddress, const std::wstring& sNote) override
-        {
-            // non-API part of SetCodeNote
-            AddCodeNote(nAddress, "Author", sNote);
-            return true;
-        }
-
-        bool DeleteCodeNote(ra::ByteAddress nAddress) override
-        {
-            m_mCodeNotes.erase(nAddress);
-            OnCodeNoteChanged(nAddress, L"");
-            return true;
-        }
     };
 
     ra::services::ServiceLocator::ServiceOverride<ra::data::context::GameContext> m_Override;
diff --git a/tests/mocks/MockThreadPool.hh b/tests/mocks/MockThreadPool.hh
index 614328c5..f0eff6a7 100644
--- a/tests/mocks/MockThreadPool.hh
+++ b/tests/mocks/MockThreadPool.hh
@@ -21,7 +21,14 @@ public:
 
     void RunAsync(std::function<void()>&& f) override
     {
-        m_vTasks.emplace(f);
+        if (m_bSynchronous)
+        {
+            f();
+        }
+        else
+        {
+            m_vTasks.emplace(f);
+        }
     }
 
     void ScheduleAsync(std::chrono::milliseconds nDelay, std::function<void()>&& f) override
@@ -88,10 +95,19 @@ public:
 
     bool IsShutdownRequested() const noexcept override { return false; }
 
+    /// <summary>
+    /// Specifies whether non-scheduled tasks should be immediately executed when queued.
+    /// </summary>
+    void SetSynchronous(bool bValue) noexcept
+    {
+        m_bSynchronous = bValue;
+    }
+
 private:
     ra::services::ServiceLocator::ServiceOverride<ra::services::IThreadPool> m_Override;
 
     std::queue<std::function<void()>> m_vTasks;
+    bool m_bSynchronous = false;
 
     struct DelayedTask
     {
diff --git a/tests/mocks/MockUserContext.hh b/tests/mocks/MockUserContext.hh
index e1f40613..5887a1a2 100644
--- a/tests/mocks/MockUserContext.hh
+++ b/tests/mocks/MockUserContext.hh
@@ -28,6 +28,11 @@ public:
     {
         UserContext::Initialize(sUsername, sUsername + "_", sApiToken);
     }
+
+    void SetUsername(const std::string& sUsername) 
+    { 
+        UserContext::Initialize(sUsername, sUsername, "APITOKEN");
+    }
 #pragma warning(pop)
 
     void Logout() noexcept override
diff --git a/tests/ui/viewmodels/AssetEditorViewModel_Tests.cpp b/tests/ui/viewmodels/AssetEditorViewModel_Tests.cpp
index 51f239d9..6cfa9966 100644
--- a/tests/ui/viewmodels/AssetEditorViewModel_Tests.cpp
+++ b/tests/ui/viewmodels/AssetEditorViewModel_Tests.cpp
@@ -125,7 +125,7 @@ public:
         Assert::AreEqual(std::wstring(L"[Not Active]"), editor.GetMeasuredValue());
         Assert::IsTrue(editor.IsAchievement());
         Assert::IsFalse(editor.IsLeaderboard());
-        Assert::IsTrue(editor.IsTrigger());
+        Assert::IsFalse(editor.IsTrigger());
         Assert::AreEqual((int)AssetEditorViewModel::LeaderboardPart::Start, (int)editor.GetSelectedLeaderboardPart());
         Assert::AreEqual(ra::data::ValueFormat::Value, editor.GetValueFormat());
 
@@ -204,7 +204,7 @@ public:
         Assert::IsFalse(editor.IsAssetLoaded());
         Assert::IsTrue(editor.IsAchievement());
         Assert::IsFalse(editor.IsLeaderboard());
-        Assert::IsTrue(editor.IsTrigger());
+        Assert::IsFalse(editor.IsTrigger());
         Assert::IsFalse(editor.HasMeasured());
         Assert::IsFalse(editor.HasAssetValidationError());
         Assert::IsFalse(editor.HasAssetValidationWarning());
diff --git a/tests/ui/viewmodels/AssetUploadViewModel_Tests.cpp b/tests/ui/viewmodels/AssetUploadViewModel_Tests.cpp
index d736a1eb..74558f41 100644
--- a/tests/ui/viewmodels/AssetUploadViewModel_Tests.cpp
+++ b/tests/ui/viewmodels/AssetUploadViewModel_Tests.cpp
@@ -1,6 +1,7 @@
 #include "CppUnitTest.h"
 
 #include "ui\viewmodels\AssetUploadViewModel.hh"
+#include "ui\viewmodels\MessageBoxViewModel.hh"
 
 #include "data\context\GameAssets.hh"
 #include "data\models\AchievementModel.hh"
@@ -13,6 +14,7 @@
 #include "tests\mocks\MockImageRepository.hh"
 #include "tests\mocks\MockServer.hh"
 #include "tests\mocks\MockThreadPool.hh"
+#include "tests\mocks\MockUserContext.hh"
 
 using namespace Microsoft::VisualStudio::CppUnitTestFramework;
 
@@ -36,6 +38,7 @@ private:
 
         ra::api::mocks::MockServer mockServer;
         ra::data::context::mocks::MockGameContext mockGameContext;
+        ra::data::context::mocks::MockUserContext mockUserContext;
         ra::services::mocks::MockThreadPool mockThreadPool;
         ra::ui::mocks::MockImageRepository mockImageRepository;
         ra::ui::mocks::MockDesktop mockDesktop;
@@ -141,9 +144,23 @@ private:
             return dynamic_cast<LeaderboardModel&>(m_pAssets.Append(std::move(vmLeaderboard)));
         }
 
+        ra::data::models::CodeNotesModel& CodeNotes()
+        {
+            auto* pNotes = m_pAssets.FindCodeNotes();
+            if (pNotes == nullptr)
+            {
+                auto pCodeNotes = std::make_unique<ra::data::models::CodeNotesModel>();
+                m_pAssets.Append(std::move(pCodeNotes));
+                pNotes = m_pAssets.FindCodeNotes();
+            }
+
+            return *pNotes;
+        }
+
         void AssertSuccess(int nItems)
         {
             bool bDialogSeen = false;
+            mockDesktop.ResetExpectedWindows();
             mockDesktop.ExpectWindow<ra::ui::viewmodels::MessageBoxViewModel>([&bDialogSeen, nItems](ra::ui::viewmodels::MessageBoxViewModel& vmMessageBox)
             {
                 bDialogSeen = true;
@@ -853,6 +870,276 @@ public:
 
         vmUpload.AssertSuccess(1);
     }
+
+    TEST_METHOD(TestSingleCodeNoteNew)
+    {
+        AssetUploadViewModelHarness vmUpload;
+        vmUpload.CodeNotes().SetCodeNote(0x1234, L"This is a note.");
+        Assert::AreEqual(AssetChanges::Unpublished, vmUpload.CodeNotes().GetChanges());
+
+        vmUpload.QueueAsset(vmUpload.CodeNotes());
+        Assert::AreEqual({ 1U }, vmUpload.TaskCount());
+        Assert::IsFalse(vmUpload.mockDesktop.WasDialogShown());
+
+        bool bApiCalled = false;
+        vmUpload.mockServer.HandleRequest<ra::api::UpdateCodeNote>([&bApiCalled]
+                (const ra::api::UpdateCodeNote::Request& pRequest, ra::api::UpdateCodeNote::Response& pResponse)
+        {
+            bApiCalled = true;
+            Assert::AreEqual(AssetUploadViewModelHarness::GameId, pRequest.GameId);
+            Assert::AreEqual(0x1234U, pRequest.Address);
+            Assert::AreEqual(std::wstring(L"This is a note."), pRequest.Note);
+
+            pResponse.Result = ra::api::ApiResult::Success;
+            return true;
+        });
+
+        vmUpload.DoUpload();
+
+        Assert::IsTrue(bApiCalled);
+        Assert::AreEqual(AssetChanges::None, vmUpload.CodeNotes().GetChanges());
+
+        vmUpload.AssertSuccess(1);
+    }
+
+    TEST_METHOD(TestSingleCodeNoteDeleted)
+    {
+        AssetUploadViewModelHarness vmUpload;
+        vmUpload.CodeNotes().SetServerCodeNote(0x1234, L"This is a note.");
+        vmUpload.CodeNotes().SetCodeNote(0x1234, L"");
+        Assert::AreEqual(AssetChanges::Unpublished, vmUpload.CodeNotes().GetChanges());
+
+        vmUpload.QueueAsset(vmUpload.CodeNotes());
+        Assert::AreEqual({ 1U }, vmUpload.TaskCount());
+        Assert::IsFalse(vmUpload.mockDesktop.WasDialogShown());
+
+        bool bApiCalled = false;
+        vmUpload.mockServer.HandleRequest<ra::api::DeleteCodeNote>([&bApiCalled]
+                (const ra::api::DeleteCodeNote::Request& pRequest, ra::api::DeleteCodeNote::Response& pResponse)
+        {
+            bApiCalled = true;
+            Assert::AreEqual(AssetUploadViewModelHarness::GameId, pRequest.GameId);
+            Assert::AreEqual(0x1234U, pRequest.Address);
+
+            pResponse.Result = ra::api::ApiResult::Success;
+            return true;
+        });
+
+        vmUpload.DoUpload();
+
+        Assert::IsTrue(bApiCalled);
+        Assert::AreEqual(AssetChanges::None, vmUpload.CodeNotes().GetChanges());
+
+        vmUpload.AssertSuccess(1);
+    }
+
+    TEST_METHOD(TestSingleCodeNoteDifferentAuthor)
+    {
+        AssetUploadViewModelHarness vmUpload;
+        vmUpload.mockUserContext.SetUsername("Author");
+        vmUpload.CodeNotes().SetServerCodeNote(0x1234, L"Test");
+        vmUpload.mockUserContext.SetUsername("Me");
+        vmUpload.CodeNotes().SetCodeNote(0x1234, L"Test2");
+        Assert::AreEqual(AssetChanges::Unpublished, vmUpload.CodeNotes().GetChanges());
+
+        bool bWindowSeen = false;
+        vmUpload.mockDesktop.ExpectWindow<ra::ui::viewmodels::MessageBoxViewModel>([&bWindowSeen](ra::ui::viewmodels::MessageBoxViewModel& vmMessageBox)
+        {
+            Assert::AreEqual(std::wstring(L"Overwrite note for address 0x1234?"), vmMessageBox.GetHeader());
+            Assert::AreEqual(std::wstring(L"Are you sure you want to replace Author's note:\n\nTest\n\nWith your note:\n\nTest2"), vmMessageBox.GetMessage());
+            Assert::AreEqual(ra::ui::viewmodels::MessageBoxViewModel::Buttons::YesNo, vmMessageBox.GetButtons());
+
+            bWindowSeen = true;
+            return ra::ui::DialogResult::Yes;
+        });
+
+        vmUpload.QueueAsset(vmUpload.CodeNotes());
+        Assert::IsTrue(bWindowSeen);
+        Assert::AreEqual({ 1U }, vmUpload.TaskCount());
+
+        bool bApiCalled = false;
+        vmUpload.mockServer.HandleRequest<ra::api::UpdateCodeNote>([&bApiCalled]
+                (const ra::api::UpdateCodeNote::Request& pRequest, ra::api::UpdateCodeNote::Response& pResponse)
+        {
+            bApiCalled = true;
+            Assert::AreEqual(AssetUploadViewModelHarness::GameId, pRequest.GameId);
+            Assert::AreEqual(0x1234U, pRequest.Address);
+            Assert::AreEqual(std::wstring(L"Test2"), pRequest.Note);
+
+            pResponse.Result = ra::api::ApiResult::Success;
+            return true;
+        });
+
+        vmUpload.DoUpload();
+
+        Assert::IsTrue(bApiCalled);
+        Assert::AreEqual(AssetChanges::None, vmUpload.CodeNotes().GetChanges());
+
+        vmUpload.AssertSuccess(1);
+    }
+
+    TEST_METHOD(TestSingleCodeNoteDeletedDifferentAuthor)
+    {
+        AssetUploadViewModelHarness vmUpload;
+        vmUpload.mockUserContext.SetUsername("Author");
+        vmUpload.CodeNotes().SetServerCodeNote(0x1234, L"Test");
+        vmUpload.mockUserContext.SetUsername("Me");
+        vmUpload.CodeNotes().SetCodeNote(0x1234, L"");
+        Assert::AreEqual(AssetChanges::Unpublished, vmUpload.CodeNotes().GetChanges());
+
+        bool bWindowSeen = false;
+        vmUpload.mockDesktop.ExpectWindow<ra::ui::viewmodels::MessageBoxViewModel>([&bWindowSeen](ra::ui::viewmodels::MessageBoxViewModel& vmMessageBox)
+        {
+            Assert::AreEqual(std::wstring(L"Delete note for address 0x1234?"), vmMessageBox.GetHeader());
+            Assert::AreEqual(std::wstring(L"Are you sure you want to delete Author's note:\n\nTest"), vmMessageBox.GetMessage());
+            Assert::AreEqual(ra::ui::viewmodels::MessageBoxViewModel::Buttons::YesNo, vmMessageBox.GetButtons());
+
+            bWindowSeen = true;
+            return ra::ui::DialogResult::Yes;
+        });
+
+        vmUpload.QueueAsset(vmUpload.CodeNotes());
+        Assert::IsTrue(bWindowSeen);
+        Assert::AreEqual({ 1U }, vmUpload.TaskCount());
+
+        bool bApiCalled = false;
+        vmUpload.mockServer.HandleRequest<ra::api::DeleteCodeNote>([&bApiCalled]
+                (const ra::api::DeleteCodeNote::Request& pRequest, ra::api::DeleteCodeNote::Response& pResponse)
+        {
+            bApiCalled = true;
+            Assert::AreEqual(AssetUploadViewModelHarness::GameId, pRequest.GameId);
+            Assert::AreEqual(0x1234U, pRequest.Address);
+
+            pResponse.Result = ra::api::ApiResult::Success;
+            return true;
+        });
+
+        vmUpload.DoUpload();
+
+        Assert::IsTrue(bApiCalled);
+        Assert::AreEqual(AssetChanges::None, vmUpload.CodeNotes().GetChanges());
+
+        vmUpload.AssertSuccess(1);
+    }
+
+    TEST_METHOD(TestSingleCodeNoteDifferentAuthorCancel)
+    {
+        AssetUploadViewModelHarness vmUpload;
+        vmUpload.mockUserContext.SetUsername("Author");
+        vmUpload.CodeNotes().SetServerCodeNote(0x1234, L"Test");
+        vmUpload.mockUserContext.SetUsername("Me");
+        vmUpload.CodeNotes().SetCodeNote(0x1234, L"Test2");
+        Assert::AreEqual(AssetChanges::Unpublished, vmUpload.CodeNotes().GetChanges());
+
+        bool bWindowSeen = false;
+        vmUpload.mockDesktop.ExpectWindow<ra::ui::viewmodels::MessageBoxViewModel>([&bWindowSeen](ra::ui::viewmodels::MessageBoxViewModel& vmMessageBox)
+        {
+            Assert::AreEqual(std::wstring(L"Overwrite note for address 0x1234?"), vmMessageBox.GetHeader());
+            Assert::AreEqual(std::wstring(L"Are you sure you want to replace Author's note:\n\nTest\n\nWith your note:\n\nTest2"), vmMessageBox.GetMessage());
+            Assert::AreEqual(ra::ui::viewmodels::MessageBoxViewModel::Buttons::YesNo, vmMessageBox.GetButtons());
+
+            bWindowSeen = true;
+            return ra::ui::DialogResult::No;
+        });
+
+        vmUpload.QueueAsset(vmUpload.CodeNotes());
+        Assert::IsTrue(bWindowSeen);
+        Assert::AreEqual({ 0U }, vmUpload.TaskCount());
+
+        Assert::AreEqual(AssetChanges::Unpublished, vmUpload.CodeNotes().GetChanges());
+    }
+
+    TEST_METHOD(TestSingleCodeNoteDifferentLong)
+    {
+        std::wstring sLongNote;
+        for (int i = 0; i < 48; ++i)
+            sLongNote += L"Test" + std::to_wstring(i) + L" ";
+
+        AssetUploadViewModelHarness vmUpload;
+        vmUpload.mockUserContext.SetUsername("Author");
+        vmUpload.CodeNotes().SetServerCodeNote(0x1234, sLongNote);
+        vmUpload.mockUserContext.SetUsername("Me");
+        vmUpload.CodeNotes().SetCodeNote(0x1234, L"Test");
+        Assert::AreEqual(AssetChanges::Unpublished, vmUpload.CodeNotes().GetChanges());
+
+        bool bWindowSeen = false;
+        vmUpload.mockDesktop.ExpectWindow<ra::ui::viewmodels::MessageBoxViewModel>([&bWindowSeen](ra::ui::viewmodels::MessageBoxViewModel& vmMessageBox)
+        {
+            Assert::AreEqual(std::wstring(L"Overwrite note for address 0x1234?"), vmMessageBox.GetHeader());
+            Assert::AreEqual(std::wstring(L"Are you sure you want to replace Author's note:\n\n" \
+                "Test0 Test1 Test2 Test3 Test4 Test5 Test6 Test7 Test8 Test9 Test10 Test11 Test12 Test13 " \
+                "Test14 Test15 Test16 Test17 Test18 Test19 Test20 Test21 Test22 Test23 Test24 Test25 " \
+                "Test26 Test27 Test28 Test29 Test30 Test31 Test32 Test33 Test34 Test35 Test36 Test..." \
+                "\n\nWith your note:\n\nTest"), vmMessageBox.GetMessage());
+            Assert::AreEqual(ra::ui::viewmodels::MessageBoxViewModel::Buttons::YesNo, vmMessageBox.GetButtons());
+
+            bWindowSeen = true;
+            return ra::ui::DialogResult::Yes;
+        });
+
+        vmUpload.QueueAsset(vmUpload.CodeNotes());
+        Assert::IsTrue(bWindowSeen);
+        Assert::AreEqual({ 1U }, vmUpload.TaskCount());
+
+        bool bApiCalled = false;
+        vmUpload.mockServer.HandleRequest<ra::api::UpdateCodeNote>([&bApiCalled]
+                (const ra::api::UpdateCodeNote::Request& pRequest, ra::api::UpdateCodeNote::Response& pResponse)
+        {
+            bApiCalled = true;
+            Assert::AreEqual(AssetUploadViewModelHarness::GameId, pRequest.GameId);
+            Assert::AreEqual(0x1234U, pRequest.Address);
+            Assert::AreEqual(std::wstring(L"Test"), pRequest.Note);
+
+            pResponse.Result = ra::api::ApiResult::Success;
+            return true;
+        });
+
+        vmUpload.DoUpload();
+
+        Assert::IsTrue(bApiCalled);
+        Assert::AreEqual(AssetChanges::None, vmUpload.CodeNotes().GetChanges());
+
+        vmUpload.AssertSuccess(1);
+    }
+
+    TEST_METHOD(TestMultipleCodeNotes)
+    {
+        AssetUploadViewModelHarness vmUpload;
+        vmUpload.CodeNotes().SetCodeNote(0x1234, L"This is a note.");
+        vmUpload.CodeNotes().SetCodeNote(0x1235, L"This is another note.");
+        Assert::AreEqual(AssetChanges::Unpublished, vmUpload.CodeNotes().GetChanges());
+
+        vmUpload.QueueAsset(vmUpload.CodeNotes());
+        Assert::AreEqual({ 2U }, vmUpload.TaskCount());
+
+        int nApiCount = 0;
+        vmUpload.mockServer.HandleRequest<ra::api::UpdateCodeNote>([&nApiCount]
+                (const ra::api::UpdateCodeNote::Request& pRequest, ra::api::UpdateCodeNote::Response& pResponse)
+        {
+            nApiCount++;
+            Assert::AreEqual(AssetUploadViewModelHarness::GameId, pRequest.GameId);
+            if (pRequest.Address == 0x1234U)
+            {
+                Assert::AreEqual(0x1234U, pRequest.Address);
+                Assert::AreEqual(std::wstring(L"This is a note."), pRequest.Note);
+            }
+            else
+            {
+                Assert::AreEqual(0x1235U, pRequest.Address);
+                Assert::AreEqual(std::wstring(L"This is another note."), pRequest.Note);
+            }
+
+            pResponse.Result = ra::api::ApiResult::Success;
+            return true;
+        });
+
+        vmUpload.DoUpload();
+
+        Assert::AreEqual(2, nApiCount);
+        Assert::AreEqual(AssetChanges::None, vmUpload.CodeNotes().GetChanges());
+
+        vmUpload.AssertSuccess(2);
+    }
 };
 
 } // namespace tests
diff --git a/tests/ui/viewmodels/CodeNotesViewModel_Tests.cpp b/tests/ui/viewmodels/CodeNotesViewModel_Tests.cpp
index c1ea18ed..2451c032 100644
--- a/tests/ui/viewmodels/CodeNotesViewModel_Tests.cpp
+++ b/tests/ui/viewmodels/CodeNotesViewModel_Tests.cpp
@@ -1,5 +1,6 @@
 #include "CppUnitTest.h"
 
+#include "ui\EditorTheme.hh"
 #include "ui\viewmodels\CodeNotesViewModel.hh"
 
 #include "tests\RA_UnitTestHelpers.h"
@@ -8,6 +9,7 @@
 #include "tests\mocks\MockEmulatorContext.hh"
 #include "tests\mocks\MockGameContext.hh"
 #include "tests\mocks\MockLocalStorage.hh"
+#include "tests\mocks\MockUserContext.hh"
 #include "tests\mocks\MockWindowManager.hh"
 
 using namespace Microsoft::VisualStudio::CppUnitTestFramework;
@@ -25,11 +27,16 @@ private:
     public:
         ra::data::context::mocks::MockEmulatorContext mockEmulatorContext;
         ra::data::context::mocks::MockGameContext mockGameContext;
+        ra::data::context::mocks::MockUserContext mockUserContext;
         ra::services::mocks::MockConfiguration mockConfiguration;
         ra::services::mocks::MockLocalStorage mockLocalStorage;
         ra::ui::mocks::MockDesktop mockDesktop;
         ra::ui::viewmodels::mocks::MockWindowManager mockWindowManager;
 
+        ra::ui::EditorTheme editorTheme;
+
+        CodeNotesViewModelHarness() noexcept : m_themeOverride(&editorTheme) {}
+
         void PopulateNotes()
         {
             mockGameContext.SetGameId(1U);
@@ -61,6 +68,9 @@ private:
                 memory.at(i) = i;
             mockEmulatorContext.MockMemory(memory);
         }
+
+    private:
+        ra::services::ServiceLocator::ServiceOverride<ra::ui::EditorTheme> m_themeOverride;
     };
 
     void AssertRow(CodeNotesViewModelHarness& notes, gsl::index nRow, ra::ByteAddress nAddress,
@@ -444,6 +454,33 @@ public:
         Assert::AreEqual({ 0x0040U }, pBookmarks.GetItemAt(3)->GetAddress());
         Assert::AreEqual(MemSize::EightBit, pBookmarks.GetItemAt(3)->GetSize());
     }
+
+    TEST_METHOD(TestModifiedIndicator)
+    {
+        CodeNotesViewModelHarness notes;
+        notes.PopulateNotes();
+        Assert::AreEqual({ 0U }, notes.Notes().Count());
+
+        notes.SetIsVisible(true);
+
+        Assert::AreEqual({ 14U }, notes.Notes().Count());
+        Assert::AreEqual(std::wstring(L""), notes.GetFilterValue());
+        Assert::AreEqual(std::wstring(L"14/14"), notes.GetResultCount());
+
+        AssertRow(notes, 4, 0x0016, L"0x0016", L"[32-bit] Score");
+        auto* pRow = notes.Notes().GetItemAt(4);
+        Assert::IsNotNull(pRow);
+        Ensures(pRow != nullptr);
+        Assert::AreEqual(0xFF000000, pRow->GetBookmarkColor().ARGB);
+
+        notes.mockGameContext.Assets().FindCodeNotes()->SetCodeNote(0x0016, L"Changed");
+        AssertRow(notes, 4, 0x0016, L"0x0016", L"Changed");
+        Assert::AreEqual(0xFFC04040, pRow->GetBookmarkColor().ARGB);
+
+        notes.mockGameContext.Assets().FindCodeNotes()->SetCodeNote(0x0016, L"[32-bit] Score");
+        AssertRow(notes, 4, 0x0016, L"0x0016", L"[32-bit] Score");
+        Assert::AreEqual(0xFF000000, pRow->GetBookmarkColor().ARGB);
+    }
 };
 
 } // namespace tests
diff --git a/tests/ui/viewmodels/IntegrationMenuViewModel_Tests.cpp b/tests/ui/viewmodels/IntegrationMenuViewModel_Tests.cpp
index 8067a6ad..7dca39e3 100644
--- a/tests/ui/viewmodels/IntegrationMenuViewModel_Tests.cpp
+++ b/tests/ui/viewmodels/IntegrationMenuViewModel_Tests.cpp
@@ -128,7 +128,7 @@ public:
 
         menu.BuildMenu();
 
-        menu.AssertMenuSize(13);
+        menu.AssertMenuSize(15);
         menu.AssertMenuItem(0, IDM_RA_FILES_LOGIN, L"&Login");
         menu.AssertMenuSeparator(1);
         menu.AssertMenuItem(2, IDM_RA_HARDCORE_MODE, L"&Hardcore Mode");
@@ -137,11 +137,13 @@ public:
         menu.AssertMenuItem(5, IDM_RA_TOGGLELEADERBOARDS, L"Enable &Leaderboards");
         menu.AssertMenuItem(6, IDM_RA_OVERLAYSETTINGS, L"O&verlay Settings");
         menu.AssertMenuSeparator(7);
-        menu.AssertMenuItem(8, IDM_RA_FILES_ACHIEVEMENTS, L"Assets Li&st");
-        menu.AssertMenuItem(9, IDM_RA_FILES_ACHIEVEMENTEDITOR, L"Assets &Editor");
-        menu.AssertMenuItem(10, IDM_RA_FILES_MEMORYFINDER, L"&Memory Inspector");
-        menu.AssertMenuItem(11, IDM_RA_FILES_MEMORYBOOKMARKS, L"Memory &Bookmarks");
-        menu.AssertMenuItem(12, IDM_RA_PARSERICHPRESENCE, L"Rich &Presence Monitor");
+        menu.AssertMenuItem(8, IDM_RA_FILES_OPENALL, L"&Open All");
+        menu.AssertMenuItem(9, IDM_RA_FILES_ACHIEVEMENTS, L"Assets Li&st");
+        menu.AssertMenuItem(10, IDM_RA_FILES_ACHIEVEMENTEDITOR, L"Assets &Editor");
+        menu.AssertMenuItem(11, IDM_RA_FILES_MEMORYFINDER, L"&Memory Inspector");
+        menu.AssertMenuItem(12, IDM_RA_FILES_MEMORYBOOKMARKS, L"Memory &Bookmarks");
+        menu.AssertMenuItem(13, IDM_RA_FILES_CODENOTES, L"Code &Notes");
+        menu.AssertMenuItem(14, IDM_RA_PARSERICHPRESENCE, L"Rich &Presence Monitor");
     }
 
     TEST_METHOD(TestBuildMenuLoggedIn)
@@ -151,7 +153,7 @@ public:
 
         menu.BuildMenu();
 
-        menu.AssertMenuSize(19);
+        menu.AssertMenuSize(21);
         menu.AssertMenuItem(0, IDM_RA_FILES_LOGOUT, L"Log&out");
         menu.AssertMenuSeparator(1);
         menu.AssertMenuItem(2, IDM_RA_OPENUSERPAGE, L"Open my &User Page");
@@ -163,14 +165,16 @@ public:
         menu.AssertMenuItem(8, IDM_RA_TOGGLELEADERBOARDS, L"Enable &Leaderboards");
         menu.AssertMenuItem(9, IDM_RA_OVERLAYSETTINGS, L"O&verlay Settings");
         menu.AssertMenuSeparator(10);
-        menu.AssertMenuItem(11, IDM_RA_FILES_ACHIEVEMENTS, L"Assets Li&st");
-        menu.AssertMenuItem(12, IDM_RA_FILES_ACHIEVEMENTEDITOR, L"Assets &Editor");
-        menu.AssertMenuItem(13, IDM_RA_FILES_MEMORYFINDER, L"&Memory Inspector");
-        menu.AssertMenuItem(14, IDM_RA_FILES_MEMORYBOOKMARKS, L"Memory &Bookmarks");
-        menu.AssertMenuItem(15, IDM_RA_PARSERICHPRESENCE, L"Rich &Presence Monitor");
-        menu.AssertMenuSeparator(16);
-        menu.AssertMenuItem(17, IDM_RA_REPORTBROKENACHIEVEMENTS, L"&Report Achievement Problem");
-        menu.AssertMenuItem(18, IDM_RA_GETROMCHECKSUM, L"View Game H&ash");
+        menu.AssertMenuItem(11, IDM_RA_FILES_OPENALL, L"&Open All");
+        menu.AssertMenuItem(12, IDM_RA_FILES_ACHIEVEMENTS, L"Assets Li&st");
+        menu.AssertMenuItem(13, IDM_RA_FILES_ACHIEVEMENTEDITOR, L"Assets &Editor");
+        menu.AssertMenuItem(14, IDM_RA_FILES_MEMORYFINDER, L"&Memory Inspector");
+        menu.AssertMenuItem(15, IDM_RA_FILES_MEMORYBOOKMARKS, L"Memory &Bookmarks");
+        menu.AssertMenuItem(16, IDM_RA_FILES_CODENOTES, L"Code &Notes");
+        menu.AssertMenuItem(17, IDM_RA_PARSERICHPRESENCE, L"Rich &Presence Monitor");
+        menu.AssertMenuSeparator(18);
+        menu.AssertMenuItem(19, IDM_RA_REPORTBROKENACHIEVEMENTS, L"&Report Achievement Problem");
+        menu.AssertMenuItem(20, IDM_RA_GETROMCHECKSUM, L"View Game H&ash");
     }
 
     TEST_METHOD(TestBuildMenuOffline)
@@ -180,20 +184,22 @@ public:
 
         menu.BuildMenu();
 
-        menu.AssertMenuSize(13);
+        menu.AssertMenuSize(15);
         menu.AssertMenuItem(0, IDM_RA_HARDCORE_MODE, L"&Hardcore Mode");
         menu.AssertMenuItem(1, IDM_RA_NON_HARDCORE_WARNING, L"Non-Hardcore &Warning");
         menu.AssertMenuSeparator(2);
         menu.AssertMenuItem(3, IDM_RA_TOGGLELEADERBOARDS, L"Enable &Leaderboards");
         menu.AssertMenuItem(4, IDM_RA_OVERLAYSETTINGS, L"O&verlay Settings");
         menu.AssertMenuSeparator(5);
-        menu.AssertMenuItem(6, IDM_RA_FILES_ACHIEVEMENTS, L"Assets Li&st");
-        menu.AssertMenuItem(7, IDM_RA_FILES_ACHIEVEMENTEDITOR, L"Assets &Editor");
-        menu.AssertMenuItem(8, IDM_RA_FILES_MEMORYFINDER, L"&Memory Inspector");
-        menu.AssertMenuItem(9, IDM_RA_FILES_MEMORYBOOKMARKS, L"Memory &Bookmarks");
-        menu.AssertMenuItem(10, IDM_RA_PARSERICHPRESENCE, L"Rich &Presence Monitor");
-        menu.AssertMenuSeparator(11);
-        menu.AssertMenuItem(12, IDM_RA_GETROMCHECKSUM, L"View Game H&ash");
+        menu.AssertMenuItem(6, IDM_RA_FILES_OPENALL, L"&Open All");
+        menu.AssertMenuItem(7, IDM_RA_FILES_ACHIEVEMENTS, L"Assets Li&st");
+        menu.AssertMenuItem(8, IDM_RA_FILES_ACHIEVEMENTEDITOR, L"Assets &Editor");
+        menu.AssertMenuItem(9, IDM_RA_FILES_MEMORYFINDER, L"&Memory Inspector");
+        menu.AssertMenuItem(10, IDM_RA_FILES_MEMORYBOOKMARKS, L"Memory &Bookmarks");
+        menu.AssertMenuItem(11, IDM_RA_FILES_CODENOTES, L"Code &Notes");
+        menu.AssertMenuItem(12, IDM_RA_PARSERICHPRESENCE, L"Rich &Presence Monitor");
+        menu.AssertMenuSeparator(13);
+        menu.AssertMenuItem(14, IDM_RA_GETROMCHECKSUM, L"View Game H&ash");
     }
 
     TEST_METHOD(TestLoginHardcoreValidClient)
@@ -512,6 +518,24 @@ public:
         menu.AssertShowWindow<ra::ui::viewmodels::MemoryBookmarksViewModel>(IDM_RA_FILES_MEMORYBOOKMARKS, false, "view memory bookmarks", DialogResult::None);
     }
 
+    TEST_METHOD(TestShowCodeNotesHardcore)
+    {
+        IntegrationMenuViewModelHarness menu;
+        menu.AssertShowWindow<ra::ui::viewmodels::CodeNotesViewModel>(IDM_RA_FILES_CODENOTES, true, "view code notes", DialogResult::Yes);
+    }
+
+    TEST_METHOD(TestShowCodeNotesHardcoreAbort)
+    {
+        IntegrationMenuViewModelHarness menu;
+        menu.AssertShowWindow<ra::ui::viewmodels::CodeNotesViewModel>(IDM_RA_FILES_CODENOTES, true, "view code notes", DialogResult::No);
+    }
+
+    TEST_METHOD(TestShowCodeNotesNonHardcore)
+    {
+        IntegrationMenuViewModelHarness menu;
+        menu.AssertShowWindow<ra::ui::viewmodels::CodeNotesViewModel>(IDM_RA_FILES_CODENOTES, false, "view code notes", DialogResult::None);
+    }
+
     TEST_METHOD(TestShowRichPresenceMonitorHardcore)
     {
         IntegrationMenuViewModelHarness menu;
@@ -524,6 +548,192 @@ public:
         menu.AssertShowWindow<ra::ui::viewmodels::RichPresenceMonitorViewModel>(IDM_RA_PARSERICHPRESENCE, false, "", DialogResult::None);
     }
 
+    TEST_METHOD(TestOpenAllHardcore)
+    {
+        IntegrationMenuViewModelHarness menu;
+
+        menu.mockConfiguration.SetFeatureEnabled(ra::services::Feature::Hardcore, true);
+
+        bool bAssetsShown = false;
+        menu.mockDesktop.ExpectWindow<ra::ui::viewmodels::AssetListViewModel>([&bAssetsShown](ra::ui::viewmodels::AssetListViewModel&)
+        {
+            bAssetsShown = true;
+            return DialogResult::None;
+        });
+
+
+        bool bAssetShown = false;
+        menu.mockDesktop.ExpectWindow<ra::ui::viewmodels::AssetEditorViewModel>([&bAssetShown](ra::ui::viewmodels::AssetEditorViewModel&)
+        {
+            bAssetShown = true;
+            return DialogResult::None;
+        });
+
+        bool bInspectorShown = false;
+        menu.mockDesktop.ExpectWindow<ra::ui::viewmodels::MemoryInspectorViewModel>([&bInspectorShown](ra::ui::viewmodels::MemoryInspectorViewModel&)
+        {
+            bInspectorShown = true;
+            return DialogResult::None;
+        });
+
+        bool bBookmarksShown = false;
+        menu.mockDesktop.ExpectWindow<ra::ui::viewmodels::MemoryBookmarksViewModel>([&bBookmarksShown](ra::ui::viewmodels::MemoryBookmarksViewModel&)
+        {
+            bBookmarksShown = true;
+            return DialogResult::None;
+        });
+
+        bool bNotesShown = false;
+        menu.mockDesktop.ExpectWindow<ra::ui::viewmodels::CodeNotesViewModel>([&bNotesShown](ra::ui::viewmodels::CodeNotesViewModel&)
+        {
+            bNotesShown = true;
+            return DialogResult::None;
+        });
+
+        bool bRichPresenceShown = false;
+        menu.mockDesktop.ExpectWindow<ra::ui::viewmodels::RichPresenceMonitorViewModel>([&bRichPresenceShown](ra::ui::viewmodels::RichPresenceMonitorViewModel&)
+        {
+            bRichPresenceShown = true;
+            return DialogResult::None;
+        });
+
+        menu.mockEmulatorContext.MockDisableHardcoreWarning(DialogResult::Yes);
+
+        menu.ActivateMenuItem(IDM_RA_FILES_OPENALL);
+
+        Assert::AreEqual(std::string("use development tools"), menu.mockEmulatorContext.GetDisableHardcoreWarningMessage());
+        Assert::IsTrue(bAssetsShown);
+        Assert::IsTrue(bAssetShown);
+        Assert::IsTrue(bInspectorShown);
+        Assert::IsTrue(bBookmarksShown);
+        Assert::IsTrue(bNotesShown);
+        Assert::IsTrue(bRichPresenceShown);
+    }
+
+    TEST_METHOD(TestOpenAllHardcoreAbort)
+    {
+        IntegrationMenuViewModelHarness menu;
+
+        menu.mockConfiguration.SetFeatureEnabled(ra::services::Feature::Hardcore, true);
+
+        bool bAssetsShown = false;
+        menu.mockDesktop.ExpectWindow<ra::ui::viewmodels::AssetListViewModel>([&bAssetsShown](ra::ui::viewmodels::AssetListViewModel&)
+        {
+            bAssetsShown = true;
+            return DialogResult::None;
+        });
+
+
+        bool bAssetShown = false;
+        menu.mockDesktop.ExpectWindow<ra::ui::viewmodels::AssetEditorViewModel>([&bAssetShown](ra::ui::viewmodels::AssetEditorViewModel&)
+        {
+            bAssetShown = true;
+            return DialogResult::None;
+        });
+
+        bool bInspectorShown = false;
+        menu.mockDesktop.ExpectWindow<ra::ui::viewmodels::MemoryInspectorViewModel>([&bInspectorShown](ra::ui::viewmodels::MemoryInspectorViewModel&)
+        {
+            bInspectorShown = true;
+            return DialogResult::None;
+        });
+
+        bool bBookmarksShown = false;
+        menu.mockDesktop.ExpectWindow<ra::ui::viewmodels::MemoryBookmarksViewModel>([&bBookmarksShown](ra::ui::viewmodels::MemoryBookmarksViewModel&)
+        {
+            bBookmarksShown = true;
+            return DialogResult::None;
+        });
+
+        bool bNotesShown = false;
+        menu.mockDesktop.ExpectWindow<ra::ui::viewmodels::CodeNotesViewModel>([&bNotesShown](ra::ui::viewmodels::CodeNotesViewModel&)
+        {
+            bNotesShown = true;
+            return DialogResult::None;
+        });
+
+        bool bRichPresenceShown = false;
+        menu.mockDesktop.ExpectWindow<ra::ui::viewmodels::RichPresenceMonitorViewModel>([&bRichPresenceShown](ra::ui::viewmodels::RichPresenceMonitorViewModel&)
+        {
+            bRichPresenceShown = true;
+            return DialogResult::None;
+        });
+
+        menu.mockEmulatorContext.MockDisableHardcoreWarning(DialogResult::No);
+
+        menu.ActivateMenuItem(IDM_RA_FILES_OPENALL);
+
+        Assert::AreEqual(std::string("use development tools"), menu.mockEmulatorContext.GetDisableHardcoreWarningMessage());
+        Assert::IsTrue(bAssetsShown);
+        Assert::IsFalse(bAssetShown);
+        Assert::IsFalse(bInspectorShown);
+        Assert::IsFalse(bBookmarksShown);
+        Assert::IsFalse(bNotesShown);
+        Assert::IsTrue(bRichPresenceShown);
+    }
+
+    TEST_METHOD(TestOpenAllNonHardcore)
+    {
+        IntegrationMenuViewModelHarness menu;
+
+        menu.mockConfiguration.SetFeatureEnabled(ra::services::Feature::Hardcore, false);
+
+        bool bAssetsShown = false;
+        menu.mockDesktop.ExpectWindow<ra::ui::viewmodels::AssetListViewModel>([&bAssetsShown](ra::ui::viewmodels::AssetListViewModel&)
+        {
+            bAssetsShown = true;
+            return DialogResult::None;
+        });
+
+
+        bool bAssetShown = false;
+        menu.mockDesktop.ExpectWindow<ra::ui::viewmodels::AssetEditorViewModel>([&bAssetShown](ra::ui::viewmodels::AssetEditorViewModel&)
+        {
+            bAssetShown = true;
+            return DialogResult::None;
+        });
+
+        bool bInspectorShown = false;
+        menu.mockDesktop.ExpectWindow<ra::ui::viewmodels::MemoryInspectorViewModel>([&bInspectorShown](ra::ui::viewmodels::MemoryInspectorViewModel&)
+        {
+            bInspectorShown = true;
+            return DialogResult::None;
+        });
+
+        bool bBookmarksShown = false;
+        menu.mockDesktop.ExpectWindow<ra::ui::viewmodels::MemoryBookmarksViewModel>([&bBookmarksShown](ra::ui::viewmodels::MemoryBookmarksViewModel&)
+        {
+            bBookmarksShown = true;
+            return DialogResult::None;
+        });
+
+        bool bNotesShown = false;
+        menu.mockDesktop.ExpectWindow<ra::ui::viewmodels::CodeNotesViewModel>([&bNotesShown](ra::ui::viewmodels::CodeNotesViewModel&)
+        {
+            bNotesShown = true;
+            return DialogResult::None;
+        });
+
+        bool bRichPresenceShown = false;
+        menu.mockDesktop.ExpectWindow<ra::ui::viewmodels::RichPresenceMonitorViewModel>([&bRichPresenceShown](ra::ui::viewmodels::RichPresenceMonitorViewModel&)
+        {
+            bRichPresenceShown = true;
+            return DialogResult::None;
+        });
+
+        menu.mockEmulatorContext.MockDisableHardcoreWarning(DialogResult::None);
+
+        menu.ActivateMenuItem(IDM_RA_FILES_OPENALL);
+
+        Assert::AreEqual(std::string(), menu.mockEmulatorContext.GetDisableHardcoreWarningMessage());
+        Assert::IsTrue(bAssetsShown);
+        Assert::IsTrue(bAssetShown);
+        Assert::IsTrue(bInspectorShown);
+        Assert::IsTrue(bBookmarksShown);
+        Assert::IsTrue(bNotesShown);
+        Assert::IsTrue(bRichPresenceShown);
+    }
+
     TEST_METHOD(TestReportBrokenAchievements)
     {
         IntegrationMenuViewModelHarness menu;
diff --git a/tests/ui/viewmodels/MemoryBookmarksViewModel_Tests.cpp b/tests/ui/viewmodels/MemoryBookmarksViewModel_Tests.cpp
index 56a9ec68..03da5454 100644
--- a/tests/ui/viewmodels/MemoryBookmarksViewModel_Tests.cpp
+++ b/tests/ui/viewmodels/MemoryBookmarksViewModel_Tests.cpp
@@ -15,6 +15,7 @@
 #include "tests\mocks\MockGameContext.hh"
 #include "tests\mocks\MockLocalStorage.hh"
 #include "tests\mocks\MockOverlayManager.hh"
+#include "tests\mocks\MockUserContext.hh"
 
 using namespace Microsoft::VisualStudio::CppUnitTestFramework;
 
@@ -58,6 +59,7 @@ private:
     public:
         ra::data::context::mocks::MockEmulatorContext mockEmulatorContext;
         ra::data::context::mocks::MockGameContext mockGameContext;
+        ra::data::context::mocks::MockUserContext mockUserContext;
         ra::services::mocks::MockAchievementRuntime mockAchievementRuntime;
         ra::services::mocks::MockConfiguration mockConfiguration;
         ra::services::mocks::MockFileSystem mockFileSystem;
diff --git a/tests/ui/viewmodels/MemoryInspectorViewModel_Tests.cpp b/tests/ui/viewmodels/MemoryInspectorViewModel_Tests.cpp
index d2a71e64..a571e57e 100644
--- a/tests/ui/viewmodels/MemoryInspectorViewModel_Tests.cpp
+++ b/tests/ui/viewmodels/MemoryInspectorViewModel_Tests.cpp
@@ -1,16 +1,21 @@
 #include "CppUnitTest.h"
 
+#include "api\DeleteCodeNote.hh"
+#include "api\UpdateCodeNote.hh"
+
 #include "ui\viewmodels\MemoryInspectorViewModel.hh"
 
 #include "tests\ui\UIAsserts.hh"
 #include "tests\RA_UnitTestHelpers.h"
 
-#include "tests\mocks\MockAudioSystem.hh"
 #include "tests\mocks\MockConfiguration.hh"
 #include "tests\mocks\MockConsoleContext.hh"
 #include "tests\mocks\MockDesktop.hh"
 #include "tests\mocks\MockEmulatorContext.hh"
 #include "tests\mocks\MockGameContext.hh"
+#include "tests\mocks\MockUserContext.hh"
+#include "tests\mocks\MockServer.hh"
+#include "tests\mocks\MockThreadPool.hh"
 #include "tests\mocks\MockWindowManager.hh"
 
 using namespace Microsoft::VisualStudio::CppUnitTestFramework;
@@ -26,11 +31,13 @@ private:
     class MemoryInspectorViewModelHarness : public MemoryInspectorViewModel
     {
     public:
+        ra::api::mocks::MockServer mockServer;
         ra::data::context::mocks::MockConsoleContext mockConsoleContext;
         ra::data::context::mocks::MockEmulatorContext mockEmulatorContext;
         ra::data::context::mocks::MockGameContext mockGameContext;
-        ra::services::mocks::MockAudioSystem mockAudioSystem;
+        ra::data::context::mocks::MockUserContext mockUserContext;
         ra::services::mocks::MockConfiguration mockConfiguration;
+        ra::services::mocks::MockThreadPool mockThreadPool;
         ra::ui::mocks::MockDesktop mockDesktop;
         ra::ui::viewmodels::mocks::MockWindowManager mockWindowManager;
 
@@ -47,6 +54,8 @@ private:
 
             mockEmulatorContext.MockMemory(memory);
 
+            mockUserContext.SetUsername("Author");
+
             mockGameContext.InitializeCodeNotes();
         }
 
@@ -61,7 +70,6 @@ private:
         MemoryInspectorViewModelHarness(MemoryInspectorViewModelHarness&&) noexcept = delete;
         MemoryInspectorViewModelHarness& operator=(MemoryInspectorViewModelHarness&&) noexcept = delete;
 
-        bool CanModifyCodeNotes() const { return GetValue(CanModifyNotesProperty); }
         bool CurrentBitsVisible() const { return GetValue(CurrentBitsVisibleProperty); }
 
         const std::wstring* FindCodeNote(ra::ByteAddress nAddress) const
@@ -69,6 +77,41 @@ private:
             const auto* pCodeNotes = mockGameContext.Assets().FindCodeNotes();
             return (pCodeNotes != nullptr) ? pCodeNotes->FindCodeNote(nAddress) : nullptr;
         }
+
+        void PreparePublish(ra::ByteAddress nAddress, std::wstring sNote)
+        {
+            mockServer.HandleRequest<ra::api::UpdateCodeNote>([this]
+                (const ra::api::UpdateCodeNote::Request& pRequest, ra::api::UpdateCodeNote::Response& pResponse)
+            {
+                m_nPublishedAddress = pRequest.Address;
+
+                pResponse.Result = ra::api::ApiResult::Success;
+                return true;
+            });
+
+            mockServer.HandleRequest<ra::api::DeleteCodeNote>([this]
+                (const ra::api::DeleteCodeNote::Request& pRequest, ra::api::DeleteCodeNote::Response& pResponse)
+            {
+                m_nPublishedAddress = pRequest.Address;
+
+                pResponse.Result = ra::api::ApiResult::Success;
+                return true;
+            });
+
+            mockThreadPool.SetSynchronous(true);
+
+            SetValue(CanModifyNotesProperty, true);
+
+            mockGameContext.Assets().FindCodeNotes()->SetCodeNote(nAddress, sNote);
+            SetCurrentAddress(nAddress);
+
+            Assert::AreEqual(sNote, GetCurrentAddressNote());
+        }
+
+        ra::ByteAddress GetPublishedAddress() const noexcept { return m_nPublishedAddress; }
+
+    private:
+        ra::ByteAddress m_nPublishedAddress = 0U;
     };
 
 
@@ -81,7 +124,7 @@ public:
         Assert::AreEqual(std::wstring(L"0x0000"), inspector.GetCurrentAddressText());
         Assert::AreEqual(std::wstring(), inspector.GetCurrentAddressNote());
         Assert::AreEqual(std::wstring(L"0 0 0 0 0 0 0 0"), inspector.GetCurrentAddressBits());
-        Assert::IsFalse(inspector.CanModifyCodeNotes());
+        Assert::IsFalse(inspector.CanModifyNotes());
 
         Assert::AreEqual({ 0U }, inspector.Viewer().GetAddress());
     }
@@ -253,91 +296,44 @@ public:
         Assert::AreEqual(std::wstring(L"Eight"), inspector.GetCurrentAddressNote());
     }
 
-    TEST_METHOD(TestSaveCurrentAddressNoteNew)
-    {
-        MemoryInspectorViewModelHarness inspector;
-        inspector.SetCurrentAddressNote(L"Test");
-
-        inspector.SaveCurrentAddressNote();
-        Assert::IsFalse(inspector.mockDesktop.WasDialogShown());
-
-        const auto* pNote = inspector.FindCodeNote({ 0 });
-        Assert::IsNotNull(pNote);
-        Ensures(pNote != nullptr);
-        Assert::AreEqual(std::wstring(L"Test"), *pNote);
-        Assert::IsTrue(inspector.mockAudioSystem.WasAudioFilePlayed(ra::services::mocks::MockAudioSystem::BEEP));
-    }
-
-    TEST_METHOD(TestSaveCurrentAddressNoteNewBlank)
+    TEST_METHOD(TestPublishCurrentAddressNoteNew)
     {
         MemoryInspectorViewModelHarness inspector;
-        inspector.SetCurrentAddressNote(L"");
+        inspector.PreparePublish(0x12, L"Test");
 
-        inspector.SaveCurrentAddressNote();
-        Assert::IsFalse(inspector.mockDesktop.WasDialogShown());
+        Assert::IsTrue(inspector.CanPublishCurrentAddressNote());
+        inspector.PublishCurrentAddressNote();
 
-        const auto* pNote = inspector.FindCodeNote({ 0 });
-        Assert::IsNull(pNote);
-        Assert::IsFalse(inspector.mockAudioSystem.WasAudioFilePlayed(ra::services::mocks::MockAudioSystem::BEEP));
+        Assert::AreEqual(0x12U, inspector.GetPublishedAddress());
+        Assert::IsFalse(inspector.CanPublishCurrentAddressNote());
+        Assert::IsFalse(inspector.CanRevertCurrentAddressNote());
     }
 
-    TEST_METHOD(TestSaveCurrentAddressNoteUnchanged)
+    TEST_METHOD(TestPublishCurrentAddressNoteDelete)
     {
         MemoryInspectorViewModelHarness inspector;
-        inspector.mockGameContext.SetGameId({ 3 });
-        inspector.mockGameContext.SetCodeNote({ 0 }, L"Test");
-        inspector.SetCurrentAddressNote(L"Test");
+        inspector.mockGameContext.Assets().FindCodeNotes()->SetServerCodeNote(0x12, L"Test");
+        inspector.PreparePublish(0x12, L"");
 
-        inspector.SaveCurrentAddressNote();
-        Assert::IsFalse(inspector.mockDesktop.WasDialogShown());
+        Assert::IsTrue(inspector.CanPublishCurrentAddressNote());
+        inspector.PublishCurrentAddressNote();
 
-        const auto* pNote = inspector.FindCodeNote({ 0 });
-        Assert::IsNotNull(pNote);
-        Ensures(pNote != nullptr);
-        Assert::AreEqual(std::wstring(L"Test"), *pNote);
-        Assert::IsFalse(inspector.mockAudioSystem.WasAudioFilePlayed(ra::services::mocks::MockAudioSystem::BEEP));
+        Assert::AreEqual(0x12U, inspector.GetPublishedAddress());
+        Assert::IsFalse(inspector.CanPublishCurrentAddressNote());
+        Assert::IsFalse(inspector.CanRevertCurrentAddressNote());
     }
 
-    TEST_METHOD(TestSaveCurrentAddressNoteOverwrite)
+    TEST_METHOD(TestPublishCurrentAddressNoteCancel)
     {
         MemoryInspectorViewModelHarness inspector;
-        inspector.mockGameContext.SetGameId({ 3 });
-        inspector.mockGameContext.SetCodeNote({ 0 }, L"Test");
-        inspector.SetCurrentAddressNote(L"Test2");
+        inspector.mockGameContext.Assets().FindCodeNotes()->SetServerCodeNote(0x12, L"Test");
+        inspector.mockUserContext.SetUsername("Me");
+        inspector.PreparePublish(0x12, L"Test2");
 
         bool bWindowSeen = false;
         inspector.mockDesktop.ExpectWindow<ra::ui::viewmodels::MessageBoxViewModel>([&bWindowSeen](ra::ui::viewmodels::MessageBoxViewModel& vmMessageBox)
         {
-            Assert::AreEqual(std::wstring(L"Overwrite note for address 0x0000?"), vmMessageBox.GetHeader());
-            Assert::AreEqual(std::wstring(L"Are you sure you want to replace Author's note:\n\nTest\n\nWith your note:\n\nTest2"), vmMessageBox.GetMessage());
-            Assert::AreEqual(ra::ui::viewmodels::MessageBoxViewModel::Buttons::YesNo, vmMessageBox.GetButtons());
-
-            bWindowSeen = true;
-            return ra::ui::DialogResult::Yes;
-        });
-
-        inspector.SaveCurrentAddressNote();
-        Assert::IsTrue(bWindowSeen);
-
-        const auto* pNote = inspector.FindCodeNote({ 0 });
-        Assert::IsNotNull(pNote);
-        Ensures(pNote != nullptr);
-        Assert::AreEqual(std::wstring(L"Test2"), *pNote);
-        Assert::AreEqual(std::wstring(L"Test2"), inspector.GetCurrentAddressNote()); // text should not be reset
-        Assert::IsTrue(inspector.mockAudioSystem.WasAudioFilePlayed(ra::services::mocks::MockAudioSystem::BEEP));
-    }
-
-    TEST_METHOD(TestSaveCurrentAddressNoteOverwriteCancel)
-    {
-        MemoryInspectorViewModelHarness inspector;
-        inspector.mockGameContext.SetGameId({ 3 });
-        inspector.mockGameContext.SetCodeNote({ 0 }, L"Test");
-        inspector.SetCurrentAddressNote(L"Test2");
-
-        bool bWindowSeen = false;
-        inspector.mockDesktop.ExpectWindow<ra::ui::viewmodels::MessageBoxViewModel>([&bWindowSeen](ra::ui::viewmodels::MessageBoxViewModel& vmMessageBox)
-        {
-            Assert::AreEqual(std::wstring(L"Overwrite note for address 0x0000?"), vmMessageBox.GetHeader());
+            Assert::AreEqual(std::wstring(L"Overwrite note for address 0x0012?"), vmMessageBox.GetHeader());
             Assert::AreEqual(std::wstring(L"Are you sure you want to replace Author's note:\n\nTest\n\nWith your note:\n\nTest2"), vmMessageBox.GetMessage());
             Assert::AreEqual(ra::ui::viewmodels::MessageBoxViewModel::Buttons::YesNo, vmMessageBox.GetButtons());
 
@@ -345,136 +341,92 @@ public:
             return ra::ui::DialogResult::No;
         });
 
-        inspector.SaveCurrentAddressNote();
-        Assert::IsTrue(bWindowSeen);
-
-        const auto* pNote = inspector.FindCodeNote({ 0 });
-        Assert::IsNotNull(pNote);
-        Ensures(pNote != nullptr);
-        Assert::AreEqual(std::wstring(L"Test"), *pNote);
-        Assert::AreEqual(std::wstring(L"Test"), inspector.GetCurrentAddressNote()); // text should be reset
-        Assert::IsFalse(inspector.mockAudioSystem.WasAudioFilePlayed(ra::services::mocks::MockAudioSystem::BEEP));
-    }
-
-    TEST_METHOD(TestSaveCurrentAddressNoteOverwriteLong)
-    {
-        std::wstring sLongNote;
-        for (int i = 0; i < 48; ++i)
-            sLongNote += L"Test" + std::to_wstring(i) + L" ";
-
-        MemoryInspectorViewModelHarness inspector;
-        inspector.mockGameContext.SetGameId({ 3 });
-        inspector.mockGameContext.SetCodeNote({ 0 }, sLongNote);
-        inspector.SetCurrentAddressNote(L"Test");
+        Assert::IsTrue(inspector.CanPublishCurrentAddressNote());
+        inspector.PublishCurrentAddressNote();
 
-        bool bWindowSeen = false;
-        inspector.mockDesktop.ExpectWindow<ra::ui::viewmodels::MessageBoxViewModel>([&bWindowSeen](ra::ui::viewmodels::MessageBoxViewModel& vmMessageBox)
-        {
-            Assert::AreEqual(std::wstring(L"Overwrite note for address 0x0000?"), vmMessageBox.GetHeader());
-            Assert::AreEqual(std::wstring(L"Are you sure you want to replace Author's note:\n\n" \
-                "Test0 Test1 Test2 Test3 Test4 Test5 Test6 Test7 Test8 Test9 Test10 Test11 Test12 Test13 " \
-                "Test14 Test15 Test16 Test17 Test18 Test19 Test20 Test21 Test22 Test23 Test24 Test25 " \
-                "Test26 Test27 Test28 Test29 Test30 Test31 Test32 Test33 Test34 Test35 Test36 Test..." \
-                "\n\nWith your note:\n\nTest"), vmMessageBox.GetMessage());
-            Assert::AreEqual(ra::ui::viewmodels::MessageBoxViewModel::Buttons::YesNo, vmMessageBox.GetButtons());
-
-            bWindowSeen = true;
-            return ra::ui::DialogResult::Yes;
-        });
-
-        inspector.SaveCurrentAddressNote();
+        Assert::AreEqual(0U, inspector.GetPublishedAddress());
+        Assert::IsTrue(inspector.CanPublishCurrentAddressNote());
+        Assert::IsTrue(inspector.CanRevertCurrentAddressNote());
         Assert::IsTrue(bWindowSeen);
     }
-
-    TEST_METHOD(TestDeleteCurrentAddressNoteBlank)
+    
+    TEST_METHOD(TestRevertCurrentAddressNoteApprove)
     {
         MemoryInspectorViewModelHarness inspector;
-
-        inspector.DeleteCurrentAddressNote();
-        Assert::IsFalse(inspector.mockDesktop.WasDialogShown());
-
-        const auto* pNote = inspector.FindCodeNote({ 0 });
-        Assert::IsNull(pNote);
-        Assert::IsFalse(inspector.mockAudioSystem.WasAudioFilePlayed(ra::services::mocks::MockAudioSystem::BEEP));
-    }
-
-    TEST_METHOD(TestDeleteCurrentAddressNoteConfirm)
-    {
-        MemoryInspectorViewModelHarness inspector;
-        inspector.mockGameContext.SetGameId({ 3 });
-        inspector.mockGameContext.SetCodeNote({ 0 }, L"Test");
-        inspector.SetCurrentAddressNote(L"Test");
+        inspector.mockGameContext.Assets().FindCodeNotes()->SetServerCodeNote(0x12, L"Test");
+        inspector.mockUserContext.SetUsername("Me");
+        inspector.PreparePublish(0x12, L"Test2");
 
         bool bWindowSeen = false;
         inspector.mockDesktop.ExpectWindow<ra::ui::viewmodels::MessageBoxViewModel>([&bWindowSeen](ra::ui::viewmodels::MessageBoxViewModel& vmMessageBox)
         {
-            Assert::AreEqual(std::wstring(L"Delete note for address 0x0000?"), vmMessageBox.GetHeader());
-            Assert::AreEqual(std::wstring(L"Are you sure you want to delete Author's note:\n\nTest"), vmMessageBox.GetMessage());
+            Assert::AreEqual(std::wstring(L"Revert note for address 0x0012?"), vmMessageBox.GetHeader());
+            Assert::AreEqual(std::wstring(L"This will discard all local work and revert the note to the last state retrieved from the server."), vmMessageBox.GetMessage());
             Assert::AreEqual(ra::ui::viewmodels::MessageBoxViewModel::Buttons::YesNo, vmMessageBox.GetButtons());
 
             bWindowSeen = true;
             return ra::ui::DialogResult::Yes;
         });
 
-        inspector.DeleteCurrentAddressNote();
-        Assert::IsTrue(bWindowSeen);
+        Assert::IsTrue(inspector.CanRevertCurrentAddressNote());
+        inspector.RevertCurrentAddressNote();
 
-        const auto* pNote = inspector.FindCodeNote({ 0 });
-        Assert::IsNull(pNote);
-        Assert::AreEqual(std::wstring(L""), inspector.GetCurrentAddressNote()); // text should be updated
-        Assert::IsTrue(inspector.mockAudioSystem.WasAudioFilePlayed(ra::services::mocks::MockAudioSystem::BEEP));
+        Assert::IsTrue(bWindowSeen);
+        Assert::IsFalse(inspector.CanRevertCurrentAddressNote());
+        Assert::AreEqual(std::wstring(L"Test"), inspector.GetCurrentAddressNote());
+        Assert::AreEqual(std::wstring(L"Test"), *inspector.mockGameContext.Assets().FindCodeNotes()->FindCodeNote(0x12));
+        Assert::IsFalse(inspector.mockGameContext.Assets().FindCodeNotes()->IsNoteModified(0x12));
     }
 
-    TEST_METHOD(TestDeleteCurrentAddressNoteDecline)
+    TEST_METHOD(TestRevertCurrentAddressNoteCancel)
     {
         MemoryInspectorViewModelHarness inspector;
-        inspector.mockGameContext.SetGameId({ 3 });
-        inspector.mockGameContext.SetCodeNote({ 0 }, L"Test");
-        inspector.SetCurrentAddressNote(L"Test");
+        inspector.mockGameContext.Assets().FindCodeNotes()->SetServerCodeNote(0x12, L"Test");
+        inspector.mockUserContext.SetUsername("Me");
+        inspector.PreparePublish(0x12, L"Test2");
 
         bool bWindowSeen = false;
         inspector.mockDesktop.ExpectWindow<ra::ui::viewmodels::MessageBoxViewModel>([&bWindowSeen](ra::ui::viewmodels::MessageBoxViewModel& vmMessageBox)
         {
-            Assert::AreEqual(std::wstring(L"Delete note for address 0x0000?"), vmMessageBox.GetHeader());
-            Assert::AreEqual(std::wstring(L"Are you sure you want to delete Author's note:\n\nTest"), vmMessageBox.GetMessage());
+            Assert::AreEqual(std::wstring(L"Revert note for address 0x0012?"), vmMessageBox.GetHeader());
+            Assert::AreEqual(std::wstring(L"This will discard all local work and revert the note to the last state retrieved from the server."), vmMessageBox.GetMessage());
             Assert::AreEqual(ra::ui::viewmodels::MessageBoxViewModel::Buttons::YesNo, vmMessageBox.GetButtons());
 
             bWindowSeen = true;
             return ra::ui::DialogResult::No;
         });
 
-        inspector.DeleteCurrentAddressNote();
-        Assert::IsTrue(bWindowSeen);
+        Assert::IsTrue(inspector.CanRevertCurrentAddressNote());
+        inspector.RevertCurrentAddressNote();
 
-        const auto* pNote = inspector.FindCodeNote({ 0 });
-        Assert::IsNotNull(pNote);
-        Ensures(pNote != nullptr);
-        Assert::AreEqual(std::wstring(L"Test"), *pNote);
-        Assert::AreEqual(std::wstring(L"Test"), inspector.GetCurrentAddressNote()); // text should not be updated
-        Assert::IsFalse(inspector.mockAudioSystem.WasAudioFilePlayed(ra::services::mocks::MockAudioSystem::BEEP));
+        Assert::IsTrue(bWindowSeen);
+        Assert::IsTrue(inspector.CanRevertCurrentAddressNote());
+        Assert::AreEqual(std::wstring(L"Test2"), inspector.GetCurrentAddressNote());
+        Assert::AreEqual(std::wstring(L"Test2"), *inspector.mockGameContext.Assets().FindCodeNotes()->FindCodeNote(0x12));
+        Assert::IsTrue(inspector.mockGameContext.Assets().FindCodeNotes()->IsNoteModified(0x12));
     }
 
     TEST_METHOD(TestActiveGameChanged)
     {
         MemoryInspectorViewModelHarness inspector;
         Assert::AreEqual(std::wstring(L"Memory Inspector [no game loaded]"), inspector.GetWindowTitle());
-        Assert::IsFalse(inspector.CanModifyCodeNotes());
+        Assert::IsFalse(inspector.CanModifyNotes());
 
         inspector.mockGameContext.SetGameId({ 3 });
         inspector.mockGameContext.NotifyActiveGameChanged();
         Assert::AreEqual(std::wstring(L"Memory Inspector"), inspector.GetWindowTitle());
-        Assert::IsTrue(inspector.CanModifyCodeNotes());
+        Assert::IsTrue(inspector.CanModifyNotes());
 
         inspector.mockGameContext.SetGameId({ 0 });
         inspector.mockGameContext.NotifyActiveGameChanged();
         Assert::AreEqual(std::wstring(L"Memory Inspector [no game loaded]"), inspector.GetWindowTitle());
-        Assert::IsFalse(inspector.CanModifyCodeNotes());
+        Assert::IsFalse(inspector.CanModifyNotes());
 
         inspector.mockGameContext.SetGameId({ 5 });
         inspector.mockGameContext.SetMode(ra::data::context::GameContext::Mode::CompatibilityTest);
         inspector.mockGameContext.NotifyActiveGameChanged();
         Assert::AreEqual(std::wstring(L"Memory Inspector [compatibility mode]"), inspector.GetWindowTitle());
-        Assert::IsTrue(inspector.CanModifyCodeNotes());
+        Assert::IsTrue(inspector.CanModifyNotes());
     }
 
     TEST_METHOD(TestActiveGameChangedOffline)
@@ -482,23 +434,23 @@ public:
         MemoryInspectorViewModelHarness inspector;
         inspector.mockConfiguration.SetFeatureEnabled(ra::services::Feature::Offline, true);
         Assert::AreEqual(std::wstring(L"Memory Inspector [no game loaded]"), inspector.GetWindowTitle());
-        Assert::IsFalse(inspector.CanModifyCodeNotes());
+        Assert::IsFalse(inspector.CanModifyNotes());
 
         inspector.mockGameContext.SetGameId({3});
         inspector.mockGameContext.NotifyActiveGameChanged();
         Assert::AreEqual(std::wstring(L"Memory Inspector"), inspector.GetWindowTitle());
-        Assert::IsFalse(inspector.CanModifyCodeNotes());
+        Assert::IsTrue(inspector.CanModifyNotes());
 
         inspector.mockGameContext.SetGameId({0});
         inspector.mockGameContext.NotifyActiveGameChanged();
         Assert::AreEqual(std::wstring(L"Memory Inspector [no game loaded]"), inspector.GetWindowTitle());
-        Assert::IsFalse(inspector.CanModifyCodeNotes());
+        Assert::IsFalse(inspector.CanModifyNotes());
 
         inspector.mockGameContext.SetGameId({5});
         inspector.mockGameContext.SetMode(ra::data::context::GameContext::Mode::CompatibilityTest);
         inspector.mockGameContext.NotifyActiveGameChanged();
         Assert::AreEqual(std::wstring(L"Memory Inspector [compatibility mode]"), inspector.GetWindowTitle());
-        Assert::IsFalse(inspector.CanModifyCodeNotes());
+        Assert::IsTrue(inspector.CanModifyNotes());
     }
 
     TEST_METHOD(TestActiveGameChangedClearsSearchResults)
diff --git a/tests/ui/viewmodels/MemorySearchViewModel_Tests.cpp b/tests/ui/viewmodels/MemorySearchViewModel_Tests.cpp
index fd4241fd..e65e8fcb 100644
--- a/tests/ui/viewmodels/MemorySearchViewModel_Tests.cpp
+++ b/tests/ui/viewmodels/MemorySearchViewModel_Tests.cpp
@@ -11,6 +11,7 @@
 #include "tests\mocks\MockDesktop.hh"
 #include "tests\mocks\MockEmulatorContext.hh"
 #include "tests\mocks\MockGameContext.hh"
+#include "tests\mocks\MockUserContext.hh"
 #include "tests\mocks\MockWindowManager.hh"
 
 using namespace Microsoft::VisualStudio::CppUnitTestFramework;
@@ -88,6 +89,7 @@ private:
         ra::data::context::mocks::MockConsoleContext mockConsoleContext;
         ra::data::context::mocks::MockEmulatorContext mockEmulatorContext;
         ra::data::context::mocks::MockGameContext mockGameContext;
+        ra::data::context::mocks::MockUserContext mockUserContext;
         ra::services::mocks::MockClock mockClock;
         ra::services::mocks::MockConfiguration mockConfiguration;
         ra::ui::mocks::MockDesktop mockDesktop;
diff --git a/tests/ui/viewmodels/TriggerConditionViewModel_Tests.cpp b/tests/ui/viewmodels/TriggerConditionViewModel_Tests.cpp
index a2ad3790..435fea79 100644
--- a/tests/ui/viewmodels/TriggerConditionViewModel_Tests.cpp
+++ b/tests/ui/viewmodels/TriggerConditionViewModel_Tests.cpp
@@ -8,6 +8,7 @@
 #include "tests\mocks\MockConfiguration.hh"
 #include "tests\mocks\MockEmulatorContext.hh"
 #include "tests\mocks\MockGameContext.hh"
+#include "tests\mocks\MockUserContext.hh"
 #include "tests\ui\viewmodels\TriggerConditionAsserts.hh"
 
 using namespace Microsoft::VisualStudio::CppUnitTestFramework;
@@ -24,6 +25,7 @@ private:
     {
     public:
         ra::data::context::mocks::MockGameContext mockGameContext;
+        ra::data::context::mocks::MockUserContext mockUserContext;
         ra::services::mocks::MockConfiguration mockConfiguration;
 
         void Parse(const std::string& sInput)
@@ -590,6 +592,7 @@ private:
     public:
         ra::data::context::mocks::MockEmulatorContext mockEmulatorContext;
         ra::data::context::mocks::MockGameContext mockGameContext;
+        ra::data::context::mocks::MockUserContext mockUserContext;
         ra::services::mocks::MockConfiguration mockConfiguration;
 
         void Parse(const std::string& sInput)
diff --git a/tests/ui/viewmodels/TriggerViewModel_Tests.cpp b/tests/ui/viewmodels/TriggerViewModel_Tests.cpp
index e96fda36..9db3020a 100644
--- a/tests/ui/viewmodels/TriggerViewModel_Tests.cpp
+++ b/tests/ui/viewmodels/TriggerViewModel_Tests.cpp
@@ -8,6 +8,7 @@
 #include "tests\mocks\MockDesktop.hh"
 #include "tests\mocks\MockGameContext.hh"
 #include "tests\mocks\MockEmulatorContext.hh"
+#include "tests\mocks\MockUserContext.hh"
 #include "tests\mocks\MockWindowManager.hh"
 
 using namespace Microsoft::VisualStudio::CppUnitTestFramework;
@@ -25,6 +26,7 @@ private:
     public:
         ra::data::context::mocks::MockEmulatorContext mockEmulatorContext;
         ra::data::context::mocks::MockGameContext mockGameContext;
+        ra::data::context::mocks::MockUserContext mockUserContext;
         ra::services::mocks::MockClipboard mockClipboard;
         ra::services::mocks::MockConfiguration mockConfiguration;
         ra::ui::mocks::MockDesktop mockDesktop;