Repository: RetroAchievements/RAIntegration
Pull request #1139 (2025-01-30 14:07:06)

also unload game from rc_client
Fixes data hanging around in overlay after game is unloaded


Patch:
diff --git a/src/RA_Shared.rc b/src/RA_Shared.rc
index 23353008..2b2dde7b 100644
--- a/src/RA_Shared.rc
+++ b/src/RA_Shared.rc
@@ -263,7 +263,7 @@ BEGIN
     PUSHBUTTON      "Move Down",IDC_RA_MOVE_BOOKMARK_DOWN,290,137,50,14
 END
 
-IDD_RA_POINTERINSPECTOR DIALOGEX 0, 0, 341, 232
+IDD_RA_POINTERINSPECTOR DIALOGEX 0, 0, 341, 294
 STYLE DS_SETFONT | WS_POPUP | WS_CAPTION | WS_SYSMENU | WS_THICKFRAME
 CAPTION "Pointer Inspector"
 FONT 8, "MS Shell Dlg", 400, 0, 0x0
@@ -276,10 +276,11 @@ BEGIN
     EDITTEXT        IDC_RA_DESCRIPTION,55,18,283,12,ES_AUTOHSCROLL
     CONTROL         "",IDC_RA_LBX_GROUPS,"SysListView32",LVS_REPORT | LVS_SHOWSELALWAYS | LVS_ALIGNLEFT | WS_CLIPCHILDREN | WS_BORDER,4,32,334,60
     CONTROL         "",IDC_RA_LBX_ADDRESSES,"SysListView32",LVS_REPORT | LVS_SHOWSELALWAYS | LVS_ALIGNLEFT | WS_CLIPCHILDREN | WS_BORDER,4,95,334,118
-    PUSHBUTTON      "Bookmark Selected",IDC_RA_ADDBOOKMARK,4,215,70,14,BS_MULTILINE
-    PUSHBUTTON      "Copy Chain",IDC_RA_COPY_ALL,75,215,70,14
-    PUSHBUTTON      "Pause",IDC_RA_PAUSE,236,215,50,14,BS_MULTILINE
-    PUSHBUTTON      "Freeze",IDC_RA_FREEZE,288,215,50,14,BS_MULTILINE
+    EDITTEXT        IDC_RA_NOTE_TEXT,4,215,334,60,ES_MULTILINE | ES_WANTRETURN | WS_VSCROLL
+    PUSHBUTTON      "Bookmark Selected",IDC_RA_ADDBOOKMARK,4,277,70,14,BS_MULTILINE
+    PUSHBUTTON      "Copy Chain",IDC_RA_COPY_ALL,75,277,70,14
+    PUSHBUTTON      "Pause",IDC_RA_PAUSE,236,277,50,14,BS_MULTILINE
+    PUSHBUTTON      "Freeze",IDC_RA_FREEZE,288,277,50,14,BS_MULTILINE
 END
 
 IDD_RA_CODENOTES DIALOGEX 0, 0, 287, 230
diff --git a/src/data/context/GameContext.cpp b/src/data/context/GameContext.cpp
index 14a82c8d..41efdeec 100644
--- a/src/data/context/GameContext.cpp
+++ b/src/data/context/GameContext.cpp
@@ -119,6 +119,9 @@ bool GameContext::BeginLoadGame(unsigned int nGameId, Mode nMode, bool& bWasPaus
         if (m_nGameId != 0)
         {
             m_nGameId = 0;
+
+            rc_client_unload_game(pRuntime.GetClient());
+
             OnActiveGameChanged();
         }
         return false;
diff --git a/src/data/models/CodeNoteModel.cpp b/src/data/models/CodeNoteModel.cpp
index 76d014a2..345b0d48 100644
--- a/src/data/models/CodeNoteModel.cpp
+++ b/src/data/models/CodeNoteModel.cpp
@@ -386,74 +386,146 @@ void CodeNoteModel::SetNote(const std::wstring& sNote, bool bImpliedPointer)
     } while (true);
 }
 
-void CodeNoteModel::ExtractSize(const std::wstring& sNote)
+CodeNoteModel::Parser::TokenType CodeNoteModel::Parser::NextToken(std::wstring& sWord) const
 {
-    // provide defaults in case no matches are found
-    m_nBytes = 1;
-    m_nMemSize = MemSize::Unknown;
-
-    // "Nbit" smallest possible note - and that's just the size annotation
-    if (sNote.length() < 4)
-        return;
-
-    bool bBytesFromBits = false;
-    bool bFoundSize = false;
-    bool bLastWordIsSize = false;
-    bool bLastWordIsNumber = false;
+    wchar_t cFirstLetter = '\0';
     bool bWordIsNumber = false;
+    sWord.clear();
 
-    std::wstring sPreviousWord, sWord;
-    const size_t nLength = sNote.length();
-    for (size_t nIndex = 0; nIndex <= nLength; ++nIndex)
+    for (; m_nIndex < m_nEndIndex; ++m_nIndex)
     {
-        // support reading null terminator so we process the last word in the string
-        const wchar_t c = (nIndex == nLength) ? 0 : sNote.at(nIndex);
+        const wchar_t c = m_sNote.at(m_nIndex);
 
         // find the next word
         if (c > 255)
         {
             // ignore unicode characters - isalpha with the default locale would return false,
-            // but also likes to pop up asserts when in a debug build.
+            // but it also likes to pop up asserts in a debug build.
+
+            // if we've found any alphanumeric characters, process them.
+            if (!sWord.empty())
+                break;
         }
         else if (isalpha(c))
         {
             if (sWord.empty())
             {
-                sWord.push_back(gsl::narrow_cast<wchar_t>(tolower(c)));
+                // start of word
+                cFirstLetter = gsl::narrow_cast<wchar_t>(tolower(c));
+                sWord.push_back(cFirstLetter);
                 bWordIsNumber = false;
-                continue;
             }
-
-            if (!bWordIsNumber)
+            else if (!bWordIsNumber)
             {
+                // continue word
                 sWord.push_back(gsl::narrow_cast<wchar_t>(tolower(c)));
-                continue;
+            }
+            else
+            {
+                // transition from numeric to alpha
+                break;
             }
         }
         else if (isdigit(c))
         {
             if (sWord.empty())
             {
+                // start of number
                 sWord.push_back(c);
                 bWordIsNumber = true;
-                continue;
             }
-
-            if (bWordIsNumber)
+            else if (bWordIsNumber)
             {
+                // continue number
                 sWord.push_back(c);
-                continue;
             }
+            else
+            {
+                // transition from alpha to numeric
+                break;
+            }
+        }
+        else
+        {
+            // non alphanumeric character.
+            // if we've found any alphanumeric characters, process them.
+            if (!sWord.empty())
+                break;
         }
+    }
 
-        if (sWord.empty())
-            continue;
+    if (sWord.empty()) // end of input
+        return TokenType::None;
+
+    if (bWordIsNumber)
+        return TokenType::Number;
+
+    switch (cFirstLetter)
+    {
+        case 'b':
+            if (sWord == L"bit" || sWord == L"bits")
+                return TokenType::Bits;
+            if (sWord == L"byte" || sWord == L"bytes")
+                return TokenType::Bytes;
+            if (sWord == L"be" || sWord == L"bigendian")
+                return TokenType::BigEndian;
+            break;
+
+        case 'd':
+            if (sWord == L"double")
+                return TokenType::Double;
+            break;
+
+        case 'f':
+            if (sWord == L"float")
+                return TokenType::Float;
+            break;
+
+        case 'l':
+            if (sWord == L"le" || sWord == L"littleendian")
+                return TokenType::LittleEndian;
+            break;
+
+        case 'm':
+            if (sWord == L"mbf")
+                return TokenType::MBF;
+            break;
+
+        default:
+            break;
+    }
+
+    return TokenType::Other;
+}
+
+void CodeNoteModel::ExtractSize(const std::wstring& sNote)
+{
+    // provide defaults in case no matches are found
+    m_nBytes = 1;
+    m_nMemSize = MemSize::Unknown;
+
+    // "Nbit" smallest possible note - and that's just the size annotation
+    if (sNote.length() < 4)
+        return;
+
+    bool bBytesFromBits = false;
+    bool bFoundSize = false;
+    bool bLastWordIsSize = false;
+    Parser::TokenType nLastTokenType = Parser::TokenType::None;
+
+    std::wstring sPreviousWord, sWord;
+    const Parser parser(sNote, 0, sNote.length());
+    do
+    {
+        const auto nTokenType = parser.NextToken(sWord);
+        if (nTokenType == Parser::TokenType::None)
+            break;
 
         // process the word
         bool bWordIsSize = false;
-        if (bWordIsNumber)
+        if (nTokenType == Parser::TokenType::Number)
         {
-            if (sPreviousWord == L"mbf")
+            if (nLastTokenType == Parser::TokenType::MBF)
             {
                 const auto nBits = _wtoi(sWord.c_str());
                 if (nBits == 32)
@@ -471,7 +543,7 @@ void CodeNoteModel::ExtractSize(const std::wstring& sNote)
                     bFoundSize = true;
                 }
             }
-            else if (sPreviousWord == L"double" && sWord == L"32")
+            else if (nLastTokenType == Parser::TokenType::Double && sWord == L"32")
             {
                 m_nBytes = 4;
                 m_nMemSize = MemSize::Double32;
@@ -481,7 +553,7 @@ void CodeNoteModel::ExtractSize(const std::wstring& sNote)
         }
         else if (bLastWordIsSize)
         {
-            if (sWord == L"float")
+            if (nTokenType == Parser::TokenType::Float)
             {
                 if (m_nMemSize == MemSize::ThirtyTwoBit)
                 {
@@ -489,7 +561,7 @@ void CodeNoteModel::ExtractSize(const std::wstring& sNote)
                     bWordIsSize = true; // allow trailing be/bigendian
                 }
             }
-            else if (sWord == L"double")
+            else if (nTokenType == Parser::TokenType::Double)
             {
                 if (m_nMemSize == MemSize::ThirtyTwoBit || m_nBytes == 8)
                 {
@@ -497,7 +569,7 @@ void CodeNoteModel::ExtractSize(const std::wstring& sNote)
                     bWordIsSize = true; // allow trailing be/bigendian
                 }
             }
-            else if (sWord == L"be" || sWord == L"bigendian")
+            else if (nTokenType == Parser::TokenType::BigEndian)
             {
                 switch (m_nMemSize)
                 {
@@ -509,20 +581,20 @@ void CodeNoteModel::ExtractSize(const std::wstring& sNote)
                     default: break;
                 }
             }
-            else if (sWord == L"le")
+            else if (nTokenType == Parser::TokenType::LittleEndian)
             {
                 if (m_nMemSize == MemSize::MBF32)
                     m_nMemSize = MemSize::MBF32LE;
             }
-            else if (sWord == L"mbf")
+            else if (nTokenType == Parser::TokenType::MBF)
             {
                 if (m_nBytes == 4 || m_nBytes == 5)
                     m_nMemSize = MemSize::MBF32;
             }
         }
-        else if (bLastWordIsNumber)
+        else if (nLastTokenType == Parser::TokenType::Number)
         {
-            if (sWord == L"bit" || sWord == L"bits")
+            if (nTokenType == Parser::TokenType::Bits)
             {
                 if (!bFoundSize)
                 {
@@ -534,7 +606,7 @@ void CodeNoteModel::ExtractSize(const std::wstring& sNote)
                     bFoundSize = true;
                 }
             }
-            else if (sWord == L"byte" || sWord == L"bytes")
+            else if (nTokenType == Parser::TokenType::Bytes)
             {
                 if (!bFoundSize || bBytesFromBits)
                 {
@@ -559,7 +631,7 @@ void CodeNoteModel::ExtractSize(const std::wstring& sNote)
                 }
             }
         }
-        else if (sWord == L"float")
+        else if (nTokenType == Parser::TokenType::Float)
         {
             if (!bFoundSize)
             {
@@ -567,11 +639,11 @@ void CodeNoteModel::ExtractSize(const std::wstring& sNote)
                 m_nMemSize = MemSize::Float;
                 bWordIsSize = true; // allow trailing be/bigendian
 
-                if (sPreviousWord == L"be" || sPreviousWord == L"bigendian")
+                if (nLastTokenType == Parser::TokenType::BigEndian)
                     m_nMemSize = MemSize::FloatBigEndian;
             }
         }
-        else if (sWord == L"double")
+        else if (nTokenType == Parser::TokenType::Double)
         {
             if (!bFoundSize)
             {
@@ -579,34 +651,33 @@ void CodeNoteModel::ExtractSize(const std::wstring& sNote)
                 m_nMemSize = MemSize::Double32;
                 bWordIsSize = true; // allow trailing be/bigendian
 
-                if (sPreviousWord == L"be" || sPreviousWord == L"bigendian")
+                if (nLastTokenType == Parser::TokenType::BigEndian)
                     m_nMemSize = MemSize::Double32BigEndian;
             }
         }
 
         // store information about the word for later
         bLastWordIsSize = bWordIsSize;
-        bLastWordIsNumber = bWordIsNumber;
+        nLastTokenType = nTokenType;
 
+        const wchar_t c = parser.Peek();
         if (c < 256 && isalnum(c))
         {
+            // number next to word [32bit]
+            std::swap(sPreviousWord, sWord);
+        }
+        else if (c == L' ' || c == L'-')
+        {
+            // spaces or hyphen could be a joined word [32-bit] [32 bit].
             std::swap(sPreviousWord, sWord);
-            sWord.clear();
-
-            sWord.push_back(gsl::narrow_cast<wchar_t>(tolower(c)));
-            bWordIsNumber = isdigit(c);
         }
         else
         {
-            // only join words with spaces or hyphens.
-            if (c == L' ' || c == L'-')
-                std::swap(sPreviousWord, sWord);
-            else
-                sPreviousWord.clear();
-
-            sWord.clear();
+            // everything else starts a new phrase
+            sPreviousWord.clear();
+            nLastTokenType = Parser::TokenType::None;
         }
-    }
+    } while (true);
 }
 
 static void RemoveIndentPrefix(std::wstring& sNote)
diff --git a/src/data/models/CodeNoteModel.hh b/src/data/models/CodeNoteModel.hh
index d017eb18..6d65e7ad 100644
--- a/src/data/models/CodeNoteModel.hh
+++ b/src/data/models/CodeNoteModel.hh
@@ -51,6 +51,38 @@ public:
                                std::function<bool(ra::ByteAddress nAddress, const CodeNoteModel&)> fCallback) const;
     void EnumeratePointerNotes(std::function<bool(ra::ByteAddress nAddress, const CodeNoteModel&)> fCallback) const;
 
+    class Parser
+    {
+    public:
+        Parser(const std::wstring& sNote, size_t nStartIndex, size_t nEndIndex) noexcept :
+            m_sNote(sNote), m_nIndex(nStartIndex), m_nEndIndex(nEndIndex)
+        {
+        }
+
+        enum TokenType
+        {
+            None = 0,
+            Number,
+            Bits,
+            Bytes,
+            Float,
+            Double,
+            MBF,
+            BigEndian,
+            LittleEndian,
+            Other,
+        };
+
+        TokenType NextToken(std::wstring& sWord) const;
+        wchar_t Peek() const { return (m_nIndex < m_nEndIndex) ? m_sNote.at(m_nIndex) : 0; }
+
+    private:
+        const std::wstring& m_sNote;
+        mutable size_t m_nIndex;
+        size_t m_nEndIndex;
+    };
+
+
 private:
     std::string m_sAuthor;
     std::wstring m_sNote;
@@ -61,7 +93,6 @@ private:
     struct PointerData;
     std::unique_ptr<PointerData> m_pPointerData;
 
-private:
     bool GetPointerChainRecursive(std::vector<const CodeNoteModel*>& vChain, const CodeNoteModel& pParentNote) const;
 
     void ProcessIndirectNotes(const std::wstring& sNote, size_t nIndex);
diff --git a/src/ui/viewmodels/MemoryBookmarksViewModel.cpp b/src/ui/viewmodels/MemoryBookmarksViewModel.cpp
index 6769ec6a..190ff378 100644
--- a/src/ui/viewmodels/MemoryBookmarksViewModel.cpp
+++ b/src/ui/viewmodels/MemoryBookmarksViewModel.cpp
@@ -44,6 +44,8 @@ const IntModelProperty MemoryBookmarksViewModel::MemoryBookmarkViewModel::RowCol
 const BoolModelProperty MemoryBookmarksViewModel::MemoryBookmarkViewModel::ReadOnlyProperty("MemoryBookmarkViewModel", "IsReadOnly", false);
 const BoolModelProperty MemoryBookmarksViewModel::MemoryBookmarkViewModel::IsDirtyProperty("MemoryBookmarkViewModel", "IsDirty", false);
 const BoolModelProperty MemoryBookmarksViewModel::HasSelectionProperty("MemoryBookmarkViewModel", "HasSelection", false);
+const BoolModelProperty MemoryBookmarksViewModel::HasSingleSelectionProperty("MemoryBookmarkViewModel", "HasSingleSelection", false);
+const IntModelProperty MemoryBookmarksViewModel::SingleSelectionIndexProperty("MemoryBookmarkViewModel", "SingleSelectionIndex", -1);
 const StringModelProperty MemoryBookmarksViewModel::FreezeButtonTextProperty("MemoryBookmarksViewModel", "FreezeButtonText", L"Freeze");
 const StringModelProperty MemoryBookmarksViewModel::PauseButtonTextProperty("MemoryBookmarksViewModel", "PauseButtonText", L"Pause");
 
@@ -749,17 +751,36 @@ void MemoryBookmarksViewModel::OnEndViewModelCollectionUpdate()
 
 void MemoryBookmarksViewModel::UpdateHasSelection()
 {
+    gsl::index nSelectedItemIndex = -1;
     for (gsl::index nIndex = 0; ra::to_unsigned(nIndex) < m_vBookmarks.Count(); ++nIndex)
     {
         const auto& pBookmark = *m_vBookmarks.GetItemAt(nIndex);
         if (pBookmark.IsSelected())
         {
-            SetValue(HasSelectionProperty, true);
-            return;
+            if (nSelectedItemIndex != -1)
+            {
+                SetValue(HasSelectionProperty, true);
+                SetValue(HasSingleSelectionProperty, false);
+                SetValue(SingleSelectionIndexProperty, -1);
+                return;
+            }
+
+            nSelectedItemIndex = nIndex;
         }
     }
 
-    SetValue(HasSelectionProperty, false);
+    if (nSelectedItemIndex == -1)
+    {
+        SetValue(HasSelectionProperty, false);
+        SetValue(HasSingleSelectionProperty, false);
+        SetValue(SingleSelectionIndexProperty, -1);
+    }
+    else
+    {
+        SetValue(HasSelectionProperty, true);
+        SetValue(HasSingleSelectionProperty, true);
+        SetValue(SingleSelectionIndexProperty, gsl::narrow_cast<int>(nSelectedItemIndex));
+    }
 }
 
 bool MemoryBookmarksViewModel::ShouldFreeze() const
diff --git a/src/ui/viewmodels/MemoryBookmarksViewModel.hh b/src/ui/viewmodels/MemoryBookmarksViewModel.hh
index 472c3a04..87aada9b 100644
--- a/src/ui/viewmodels/MemoryBookmarksViewModel.hh
+++ b/src/ui/viewmodels/MemoryBookmarksViewModel.hh
@@ -346,6 +346,9 @@ public:
     static const BoolModelProperty HasSelectionProperty;
     const bool HasSelection() const { return GetValue(HasSelectionProperty); }
 
+    static const BoolModelProperty HasSingleSelectionProperty;
+    static const IntModelProperty SingleSelectionIndexProperty;
+
     static const StringModelProperty FreezeButtonTextProperty;
     const std::wstring& GetFreezeButtonText() const { return GetValue(FreezeButtonTextProperty); }
 
diff --git a/src/ui/viewmodels/PointerInspectorViewModel.cpp b/src/ui/viewmodels/PointerInspectorViewModel.cpp
index 51b749f0..c7d99d0c 100644
--- a/src/ui/viewmodels/PointerInspectorViewModel.cpp
+++ b/src/ui/viewmodels/PointerInspectorViewModel.cpp
@@ -16,7 +16,9 @@ const IntModelProperty PointerInspectorViewModel::CurrentAddressProperty("Pointe
 const StringModelProperty PointerInspectorViewModel::CurrentAddressTextProperty("PointerInspectorViewModel", "CurrentAddressText", L"0x0000");
 const StringModelProperty PointerInspectorViewModel::CurrentAddressNoteProperty("PointerInspectorViewModel", "CurrentAddressNote", L"");
 const StringModelProperty PointerInspectorViewModel::StructFieldViewModel::OffsetProperty("StructFieldViewModel", "Offset", L"+0000");
+const StringModelProperty PointerInspectorViewModel::StructFieldViewModel::BodyProperty("StructFieldViewModel", "Body", L"");
 const IntModelProperty PointerInspectorViewModel::SelectedNodeProperty("PointerInspectorViewModel", "SelectedNode", -2);
+const StringModelProperty PointerInspectorViewModel::CurrentFieldNoteProperty("PointerInspectorViewModel", "CurrentFieldNote", L"");
 
 PointerInspectorViewModel::PointerInspectorViewModel()
     : MemoryBookmarksViewModel()
@@ -40,13 +42,21 @@ void PointerInspectorViewModel::OnValueChanged(const IntModelProperty::ChangeArg
     {
         OnSelectedNodeChanged(args.tNewValue);
     }
+    else if (args.Property == SingleSelectionIndexProperty)
+    {
+        OnSelectedFieldChanged(args.tNewValue);
+    }
 
     MemoryBookmarksViewModel::OnValueChanged(args);
 }
 
 void PointerInspectorViewModel::OnValueChanged(const StringModelProperty::ChangeArgs& args)
 {
-    if (args.Property == CurrentAddressTextProperty && !m_bSyncingAddress)
+    if (args.Property == CurrentFieldNoteProperty && !m_bSyncingNote)
+    {
+        OnCurrentFieldNoteChanged(args.tNewValue);
+    }
+    else if (args.Property == CurrentAddressTextProperty && !m_bSyncingAddress)
     {
         const auto nAddress = ra::ByteAddressFromString(ra::Narrow(args.tNewValue));
 
@@ -72,7 +82,7 @@ void PointerInspectorViewModel::OnActiveGameChanged()
 
 void PointerInspectorViewModel::OnCodeNoteChanged(ra::ByteAddress nAddress, const std::wstring&)
 {
-    if (nAddress == GetCurrentAddress())
+    if (nAddress == GetCurrentAddress() && !m_bSyncingNote)
         OnCurrentAddressChanged(nAddress); // not really, but causes the note to be reloaded
 }
 
@@ -150,6 +160,22 @@ void PointerInspectorViewModel::OnSelectedNodeChanged(int nNewNode)
     LoadNote(pNote);
 }
 
+void PointerInspectorViewModel::OnSelectedFieldChanged(int nNewFieldIndex)
+{
+    m_bSyncingNote = true;
+    if (nNewFieldIndex != -1)
+    {
+        const auto* pField = Bookmarks().GetItemAt<StructFieldViewModel>(nNewFieldIndex);
+        Expects(pField != nullptr);
+        SetCurrentFieldNote(pField->GetBody());
+    }
+    else
+    {
+        SetCurrentFieldNote(L"");
+    }
+    m_bSyncingNote = false;
+}
+
 const ra::data::models::CodeNoteModel* PointerInspectorViewModel::UpdatePointerChain(int nNewNode)
 {
     const auto& pGameContext = ra::services::ServiceLocator::Get<ra::data::context::GameContext>();
@@ -233,17 +259,71 @@ const ra::data::models::CodeNoteModel* PointerInspectorViewModel::UpdatePointerC
     return pNote;
 }
 
+static std::wstring TrimSize(const std::wstring& sNote, bool bKeepPointer)
+{
+    size_t nEndIndex = 0;
+    size_t nStartIndex = sNote.find('[');
+    if (nStartIndex != std::string::npos)
+    {
+        nEndIndex = sNote.find(']', nStartIndex + 1);
+        if (nEndIndex == std::string::npos)
+            return sNote;
+    }
+    else
+    {
+        nStartIndex = sNote.find('(');
+        if (nStartIndex == std::string::npos)
+            return sNote;
+
+        nEndIndex = sNote.find(')', nStartIndex + 1);
+        if (nEndIndex == std::string::npos)
+            return sNote;
+    }
+
+    bool bPointer = false;
+    std::wstring sWord;
+    ra::data::models::CodeNoteModel::Parser::TokenType nTokenType;
+    const ra::data::models::CodeNoteModel::Parser parser(sNote, nStartIndex + 1, nEndIndex);
+    do
+    {
+        nTokenType = parser.NextToken(sWord);
+        if (nTokenType == ra::data::models::CodeNoteModel::Parser::TokenType::Other)
+        {
+            if (sWord == L"pointer")
+                bPointer = true;
+            else
+                return sNote;
+        }
+    } while (nTokenType != ra::data::models::CodeNoteModel::Parser::TokenType::None);
+
+    while (nStartIndex > 0 && isspace(sNote.at(nStartIndex - 1)))
+        nStartIndex--;
+
+    while (nEndIndex < sNote.length() - 1 && isspace(sNote.at(nEndIndex + 1)))
+        nEndIndex++;
+
+    std::wstring sNoteCopy = sNote;
+    sNoteCopy.erase(nStartIndex, nEndIndex - nStartIndex + 1);
+
+    if (bPointer && bKeepPointer)
+        sNoteCopy.insert(0, L"[pointer] ");
+
+    return sNoteCopy;
+}
+
 void PointerInspectorViewModel::SyncField(PointerInspectorViewModel::StructFieldViewModel& pFieldViewModel, const ra::data::models::CodeNoteModel& pOffsetNote)
 {
     const auto nSize = pOffsetNote.GetMemSize();
     pFieldViewModel.SetSize((nSize == MemSize::Unknown) ? MemSize::EightBit : nSize);
 
-    const auto& sDescription = pOffsetNote.GetPrimaryNote();
-    const auto nIndex = sDescription.find('\n');
+    const auto& sNote = pOffsetNote.GetPrimaryNote();
+    pFieldViewModel.SetBody(sNote);
+
+    const auto nIndex = sNote.find('\n');
     if (nIndex == std::string::npos)
-        pFieldViewModel.SetDescription(sDescription);
+        pFieldViewModel.SetDescription(TrimSize(sNote, true));
     else
-        pFieldViewModel.SetDescription(sDescription.substr(0, nIndex));
+        pFieldViewModel.SetDescription(TrimSize(sNote.substr(0, nIndex), true));
 }
 
 void PointerInspectorViewModel::LoadNote(const ra::data::models::CodeNoteModel* pNote)
@@ -301,6 +381,8 @@ void PointerInspectorViewModel::LoadNote(const ra::data::models::CodeNoteModel*
 
     UpdateValues();
     Bookmarks().EndUpdate();
+
+    OnSelectedFieldChanged(GetValue(SingleSelectionIndexProperty));
 }
 
 static void LoadSubNotes(LookupItemViewModelCollection& vNodes,
@@ -351,6 +433,99 @@ void PointerInspectorViewModel::LoadNodes(const ra::data::models::CodeNoteModel*
         SetSelectedNode(PointerNodeViewModel::RootNodeId);
 }
 
+
+void PointerInspectorViewModel::BuildNote(ra::StringBuilder& builder,
+                      std::stack<const PointerInspectorViewModel::PointerNodeViewModel*>& sChain, gsl::index nDepth,
+                      const ra::data::models::CodeNoteModel& pNote)
+{
+    const auto nBaseAddress = pNote.GetPointerAddress();
+
+    pNote.EnumeratePointerNotes([this, &builder, &sChain, nDepth, nBaseAddress](
+                                    ra::ByteAddress nAddress, const ra::data::models::CodeNoteModel& pOffsetNote) {
+        const auto nOffset = nAddress - nBaseAddress;
+
+        builder.Append(L"\n");
+        builder.Append(std::wstring(nDepth, '+'));
+        builder.Append(ra::StringPrintf(L"0x%02X: ", nOffset));
+
+        bool isCurrentNode = false;
+        if (nDepth == gsl::narrow_cast<gsl::index>(sChain.size()))
+        {
+            const auto nIndex = GetValue(SingleSelectionIndexProperty);
+            auto* pField = dynamic_cast<StructFieldViewModel*>(Bookmarks().GetItemAt(nIndex));
+            if (pField && pField->m_nOffset == ra::to_signed(nOffset))
+            {
+                ra::data::models::CodeNoteModel newNote;
+                newNote.SetNote(GetCurrentFieldNote());
+                SyncField(*pField, newNote);
+
+                isCurrentNode = true;
+            }
+        }
+
+        if (isCurrentNode)
+        {
+            builder.Append(GetCurrentFieldNote());
+        }
+        else
+        {
+            builder.Append("[");
+            builder.Append(ra::data::MemSizeString(pOffsetNote.GetMemSize()));
+
+            if (pOffsetNote.IsPointer())
+            {
+                builder.Append(L" pointer] ");
+                builder.Append(TrimSize(pOffsetNote.GetPointerDescription(), false));
+            }
+            else
+            {
+                builder.Append(L"] ");
+                builder.Append(TrimSize(pOffsetNote.GetNote(), false));
+            }
+        }
+
+        if (pOffsetNote.IsPointer())
+            BuildNote(builder, sChain, nDepth + 1, pOffsetNote);
+
+        return true;
+    });
+}
+
+void PointerInspectorViewModel::OnCurrentFieldNoteChanged(const std::wstring&)
+{
+    const auto nAddress = GetCurrentAddress();
+    auto& pGameContext = ra::services::ServiceLocator::GetMutable<ra::data::context::GameContext>();
+    auto* pCodeNotes = pGameContext.Assets().FindCodeNotes();
+    Expects(pCodeNotes != nullptr);
+    auto* pNote = pCodeNotes->FindCodeNoteModel(nAddress);
+    if (pNote != nullptr)
+    {
+        const auto nIndex = m_vNodes.FindItemIndex(LookupItemViewModel::IdProperty, GetSelectedNode());
+        if (nIndex != -1)
+        {
+            std::stack<const PointerNodeViewModel*> sChain;
+            GetPointerChain(nIndex, sChain);
+
+            ra::StringBuilder builder;
+            builder.Append(pNote->GetPointerDescription());
+
+            BuildNote(builder, sChain, 1, *pNote);
+
+            // nested note reference is invalidated in SetCodeNote. temporarily release
+            // the reference and restore it after SetCodeNote completes to prevent the UI
+            // thread from trying to use it while it's invalid (we're most likely on a
+            // background thread kicked off by a timer to support as-you-type changes).
+            m_pCurrentNote = nullptr;
+
+            m_bSyncingNote = true;
+            pCodeNotes->SetCodeNote(nAddress, builder.ToWString());
+            m_bSyncingNote = false;
+
+            m_pCurrentNote = UpdatePointerChain(GetSelectedNode());
+        }
+    }
+}
+
 void PointerInspectorViewModel::UpdatePointerChainValues()
 {
     ra::ByteAddress nAddress = 0;
diff --git a/src/ui/viewmodels/PointerInspectorViewModel.hh b/src/ui/viewmodels/PointerInspectorViewModel.hh
index c622e7ba..678ef43c 100644
--- a/src/ui/viewmodels/PointerInspectorViewModel.hh
+++ b/src/ui/viewmodels/PointerInspectorViewModel.hh
@@ -95,11 +95,41 @@ public:
         /// </summary>
         void SetOffset(const std::wstring& sValue) { SetValue(OffsetProperty, sValue); }
 
+        /// <summary>
+        /// The <see cref="ModelProperty" /> for the field note body.
+        /// </summary>
+        static const StringModelProperty BodyProperty;
+
+        /// <summary>
+        /// Gets the field note body.
+        /// </summary>
+        const std::wstring& GetBody() const { return GetValue(BodyProperty); }
+
+        /// <summary>
+        /// Sets the field note body.
+        /// </summary>
+        void SetBody(const std::wstring& sValue) { SetValue(BodyProperty, sValue); }
+
         using MemoryBookmarkViewModel::SetAddressWithoutUpdatingValue;
 
         int32_t m_nOffset = 0;
     };
 
+    /// <summary>
+    /// The <see cref="ModelProperty" /> for the current field's note.
+    /// </summary>
+    static const StringModelProperty CurrentFieldNoteProperty;
+
+    /// <summary>
+    /// Gets the current field's note.
+    /// </summary>
+    const std::wstring& GetCurrentFieldNote() const { return GetValue(CurrentFieldNoteProperty); }
+
+    /// <summary>
+    /// Sets the current field's note.
+    /// </summary>
+    void SetCurrentFieldNote(const std::wstring& sValue) { SetValue(CurrentFieldNoteProperty, sValue); }
+
     class PointerNodeViewModel : public LookupItemViewModel
     {
     public:
@@ -170,7 +200,9 @@ protected:
 
 private:
     void OnCurrentAddressChanged(ra::ByteAddress nNewAddress);
+    void OnCurrentFieldNoteChanged(const std::wstring& sValue);
     void OnSelectedNodeChanged(int nNode);
+    void OnSelectedFieldChanged(int nNode);
     void LoadNote(const ra::data::models::CodeNoteModel* pNote);
     void LoadNodes(const ra::data::models::CodeNoteModel* pNote);
     const ra::data::models::CodeNoteModel* FindNestedCodeNoteModel(const ra::data::models::CodeNoteModel& pRootNote, int nNewNode);
@@ -182,9 +214,14 @@ private:
     void UpdateValues();
     std::string GetDefinition() const;
 
+    void BuildNote(ra::StringBuilder& builder,
+                   std::stack<const PointerInspectorViewModel::PointerNodeViewModel*>& sChain, gsl::index nDepth,
+                   const ra::data::models::CodeNoteModel& pNote);
+
     LookupItemViewModelCollection m_vNodes;
     ViewModelCollection<StructFieldViewModel> m_vPointerChain;
     bool m_bSyncingAddress = false;
+    bool m_bSyncingNote = false;
 
     const ra::data::models::CodeNoteModel* m_pCurrentNote = nullptr;
 };
diff --git a/src/ui/win32/PointerInspectorDialog.cpp b/src/ui/win32/PointerInspectorDialog.cpp
index de419847..8a07401a 100644
--- a/src/ui/win32/PointerInspectorDialog.cpp
+++ b/src/ui/win32/PointerInspectorDialog.cpp
@@ -55,15 +55,17 @@ PointerInspectorDialog::PointerInspectorDialog(PointerInspectorViewModel& vmPoin
       m_bindNodes(vmPointerFinder),
       m_bindDescription(vmPointerFinder),
       m_bindPointerChain(vmPointerFinder),
-      m_bindFields(vmPointerFinder)
+      m_bindFields(vmPointerFinder),
+      m_bindFieldNote(vmPointerFinder)
 {
     m_bindWindow.SetInitialPosition(RelativePosition::After, RelativePosition::Near, "Pointer Finder");
     m_bindWindow.BindLabel(IDC_RA_PAUSE, PointerInspectorViewModel::PauseButtonTextProperty);
     m_bindWindow.BindLabel(IDC_RA_FREEZE, PointerInspectorViewModel::FreezeButtonTextProperty);
     m_bindWindow.BindEnabled(IDC_RA_ADDBOOKMARK, PointerInspectorViewModel::HasSelectionProperty);
-    m_bindWindow.BindEnabled(IDC_RA_COPY_ALL, PointerInspectorViewModel::HasSelectionProperty);
+    m_bindWindow.BindEnabled(IDC_RA_COPY_ALL, PointerInspectorViewModel::HasSingleSelectionProperty);
     m_bindWindow.BindEnabled(IDC_RA_PAUSE, PointerInspectorViewModel::HasSelectionProperty);
     m_bindWindow.BindEnabled(IDC_RA_FREEZE, PointerInspectorViewModel::HasSelectionProperty);
+    m_bindWindow.BindEnabled(IDC_RA_NOTE_TEXT, PointerInspectorViewModel::HasSingleSelectionProperty);
 
     m_bindAddress.BindText(PointerInspectorViewModel::CurrentAddressTextProperty, ra::ui::win32::bindings::TextBoxBinding::UpdateMode::Typing);
     m_bindNodes.BindItems(vmPointerFinder.Nodes());
@@ -80,7 +82,6 @@ PointerInspectorDialog::PointerInspectorDialog(PointerInspectorViewModel& vmPoin
         PointerInspectorViewModel::StructFieldViewModel::DescriptionProperty);
     pDescriptionColumn->SetHeader(L"Description");
     pDescriptionColumn->SetWidth(GridColumnBinding::WidthType::Fill, 80);
-    pDescriptionColumn->SetReadOnly(false);
     m_bindFields.BindColumn(1, std::move(pDescriptionColumn));
 
     auto pSizeColumn = std::make_unique<ra::ui::win32::bindings::GridLookupColumnBinding>(
@@ -146,12 +147,16 @@ PointerInspectorDialog::PointerInspectorDialog(PointerInspectorViewModel& vmPoin
     m_bindPointerChain.BindItems(vmPointerFinder.PointerChain());
     m_bindPointerChain.BindRowColor(PointerInspectorViewModel::StructFieldViewModel::RowColorProperty);
 
+    m_bindFieldNote.BindText(PointerInspectorViewModel::CurrentFieldNoteProperty,
+                             ra::ui::win32::bindings::TextBoxBinding::UpdateMode::Typing);
+
     using namespace ra::bitwise_ops;
     SetAnchor(IDC_RA_ADDRESS, Anchor::Top | Anchor::Left);
     SetAnchor(IDC_RA_FILTER_VALUE, Anchor::Top | Anchor::Left | Anchor::Right);
     SetAnchor(IDC_RA_DESCRIPTION, Anchor::Top | Anchor::Left | Anchor::Right);
     SetAnchor(IDC_RA_LBX_GROUPS, Anchor::Top | Anchor::Left | Anchor::Right);
     SetAnchor(IDC_RA_LBX_ADDRESSES, Anchor::Top | Anchor::Left | Anchor::Bottom | Anchor::Right);
+    SetAnchor(IDC_RA_NOTE_TEXT, Anchor::Left | Anchor::Bottom | Anchor::Right);
     SetAnchor(IDC_RA_ADDBOOKMARK, Anchor::Left | Anchor::Bottom);
     SetAnchor(IDC_RA_COPY_ALL, Anchor::Left | Anchor::Bottom);
     SetAnchor(IDC_RA_PAUSE, Anchor::Right | Anchor::Bottom);
@@ -167,6 +172,7 @@ BOOL PointerInspectorDialog::OnInitDialog()
     m_bindDescription.SetControl(*this, IDC_RA_DESCRIPTION);
     m_bindPointerChain.SetControl(*this, IDC_RA_LBX_GROUPS);
     m_bindFields.SetControl(*this, IDC_RA_LBX_ADDRESSES);
+    m_bindFieldNote.SetControl(*this, IDC_RA_NOTE_TEXT);
 
     return DialogBase::OnInitDialog();
 }
diff --git a/src/ui/win32/PointerInspectorDialog.hh b/src/ui/win32/PointerInspectorDialog.hh
index 466ba7f6..80c3f382 100644
--- a/src/ui/win32/PointerInspectorDialog.hh
+++ b/src/ui/win32/PointerInspectorDialog.hh
@@ -49,6 +49,7 @@ private:
     bindings::TextBoxBinding m_bindDescription;
     bindings::GridBinding m_bindPointerChain;
     bindings::GridBinding m_bindFields;
+    bindings::TextBoxBinding m_bindFieldNote;
 };
 
 } // namespace win32
diff --git a/src/ui/win32/bindings/GridBinding.cpp b/src/ui/win32/bindings/GridBinding.cpp
index c7ed0044..4cd5dee0 100644
--- a/src/ui/win32/bindings/GridBinding.cpp
+++ b/src/ui/win32/bindings/GridBinding.cpp
@@ -339,7 +339,11 @@ void GridBinding::OnViewModelIntValueChanged(gsl::index nIndex, const IntModelPr
     {
         const auto& pColumn = *m_vColumns.at(nColumnIndex);
         if (pColumn.DependsOn(args.Property))
-            UpdateCell(nIndex, nColumnIndex);
+        {
+            InvokeOnUIThread([this, nIndex, nColumnIndex]() {
+                UpdateCell(nIndex, nColumnIndex);
+            });
+        }
     }
 
     if (!m_vmItems->IsUpdating())
diff --git a/src/ui/win32/bindings/MultiLineGridBinding.cpp b/src/ui/win32/bindings/MultiLineGridBinding.cpp
index 9c6824ff..78090f88 100644
--- a/src/ui/win32/bindings/MultiLineGridBinding.cpp
+++ b/src/ui/win32/bindings/MultiLineGridBinding.cpp
@@ -113,9 +113,15 @@ void MultiLineGridBinding::OnViewModelStringValueChanged(gsl::index nIndex, cons
             if (!m_vmItems->IsUpdating())
             {
                 if (nLinesNeeded != pItemMetrics.nNumLines)
+                {
                     bRebuildAll = true;
+                }
                 else
-                    UpdateCell(nIndex, nColumn);
+                {
+                    InvokeOnUIThread([this, nIndex, nColumn]() {
+                        UpdateCell(nIndex, nColumn);
+                    });
+                }
             }
         }
     }


Test patch:
diff --git a/tests/data/context/GameContext_Tests.cpp b/tests/data/context/GameContext_Tests.cpp
index 68f52d7b..398d3d96 100644
--- a/tests/data/context/GameContext_Tests.cpp
+++ b/tests/data/context/GameContext_Tests.cpp
@@ -926,6 +926,7 @@ public:
 
         game.RemoveNonAchievementAssets();
         Assert::AreEqual({ 2U }, game.Assets().Count());
+        Assert::IsNotNull(rc_client_get_game_info(game.mockAchievementRuntime.GetClient()));
 
         game.LoadGame(0U, "");
 
@@ -933,6 +934,7 @@ public:
         Assert::IsNull(game.Assets().FindAchievement(7U));
 
         Assert::AreEqual({ 0U }, game.Assets().Count());
+        Assert::IsNull(rc_client_get_game_info(game.mockAchievementRuntime.GetClient()));
         Assert::IsFalse(game.Assets().IsUpdating());
     }
 
diff --git a/tests/ui/viewmodels/PointerInspectorViewModel_Tests.cpp b/tests/ui/viewmodels/PointerInspectorViewModel_Tests.cpp
index d18b54b2..0c56c425 100644
--- a/tests/ui/viewmodels/PointerInspectorViewModel_Tests.cpp
+++ b/tests/ui/viewmodels/PointerInspectorViewModel_Tests.cpp
@@ -90,6 +90,14 @@ private:
             Assert::AreEqual(sCurrentValue, pField->GetCurrentValue());
         }
 
+        void AssertFieldBody(gsl::index nIndex, const std::wstring& sBody)
+        {
+            const auto* pField = Bookmarks().GetItemAt<StructFieldViewModel>(nIndex);
+            Assert::IsNotNull(pField);
+            Ensures(pField != nullptr);
+            Assert::AreEqual(sBody, pField->GetBody());
+        }
+
         void AssertPointerChain(gsl::index nIndex, const std::wstring& sOffset,
                          ra::ByteAddress nAddress, const std::wstring& sDescription,
                          const std::wstring& sValue)
@@ -126,6 +134,16 @@ private:
             Ensures(pPointer != nullptr);
             Assert::AreEqual(ra::ui::Color(0xFFFFC0C0).ARGB, pPointer->GetRowColor().ARGB);
         }
+
+        void AssertNote(ra::ByteAddress nAddress, const std::wstring& sExpectedNote)
+        {
+            const auto* pNote = mockGameContext.Assets().FindCodeNotes()->FindCodeNoteModel(nAddress);
+            Assert::IsNotNull(pNote);
+            Ensures(pNote != nullptr);
+            Assert::AreEqual(sExpectedNote, pNote->GetNote());
+        }
+
+        bool HasSingleSelection() const { return GetValue(HasSingleSelectionProperty); }
     };
 
 
@@ -208,8 +226,40 @@ public:
 
         Assert::AreEqual({ 2U }, inspector.Bookmarks().Count());
 
-        inspector.AssertField(0, 4, 16U, L"+0004", L"[32-bit] Current HP", MemSize::ThirtyTwoBit, MemFormat::Hex, L"00000010");
-        inspector.AssertField(1, 8, 20U, L"+0008", L"[32-bit] Max HP", MemSize::ThirtyTwoBit, MemFormat::Hex, L"00000014");
+        inspector.AssertField(0, 4, 16U, L"+0004", L"Current HP", MemSize::ThirtyTwoBit, MemFormat::Hex, L"00000010");
+        inspector.AssertField(1, 8, 20U, L"+0008", L"Max HP", MemSize::ThirtyTwoBit, MemFormat::Hex, L"00000014");
+    }
+
+    TEST_METHOD(TestSetCurrentAddressWithMultiLinePointerNote)
+    {
+        PointerInspectorViewModelHarness inspector;
+        inspector.mockGameContext.SetGameId(1);
+        inspector.mockGameContext.NotifyActiveGameChanged(); // enable note support
+
+        std::array<uint8_t, 64> memory = {};
+        for (uint8_t i = 8; i < memory.size(); i += 4)
+            memory.at(i) = i;
+        inspector.mockEmulatorContext.MockMemory(memory);
+        memory.at(4) = 12;
+
+        inspector.SetCurrentAddress({4U});
+        inspector.mockGameContext.Assets().FindCodeNotes()->SetCodeNote({4U},
+            L"[32-bit pointer] Player data\n"
+            L"+4: [32-bit] Class\n"
+            "1=Wizard\n"
+            "2=Fighter\n"
+            L"+8: [32-bit] Max HP");
+
+        Assert::AreEqual({ 4U }, inspector.GetCurrentAddress());
+        Assert::AreEqual(std::wstring(L"0x0004"), inspector.GetCurrentAddressText());
+        Assert::AreEqual(std::wstring(L"[32-bit pointer] Player data"), inspector.GetCurrentAddressNote());
+
+        Assert::AreEqual({ 2U }, inspector.Bookmarks().Count());
+
+        inspector.AssertField(0, 4, 16U, L"+0004", L"Class", MemSize::ThirtyTwoBit, MemFormat::Hex, L"00000010");
+        inspector.AssertFieldBody(0, L"[32-bit] Class\n1=Wizard\n2=Fighter");
+        inspector.AssertField(1, 8, 20U, L"+0008", L"Max HP", MemSize::ThirtyTwoBit, MemFormat::Hex, L"00000014");
+        inspector.AssertFieldBody(1, L"[32-bit] Max HP");
     }
 
     TEST_METHOD(TestSelectedNode)
@@ -255,9 +305,9 @@ public:
         Assert::AreEqual(PointerInspectorViewModel::PointerNodeViewModel::RootNodeId, inspector.GetSelectedNode());
         Assert::AreEqual(std::wstring(L"[8-bit pointer] Player data"), inspector.GetCurrentAddressNote());
         Assert::AreEqual({4U}, inspector.Bookmarks().Count());
-        inspector.AssertField(0, 0, 0x0CU, L"+0000", L"[8-bit pointer] Row 1", MemSize::EightBit, MemFormat::Hex, L"14");
-        inspector.AssertField(1, 8, 0x14U, L"+0008", L"[8-bit pointer] Row 2", MemSize::EightBit, MemFormat::Hex, L"1c");
-        inspector.AssertField(2, 16, 0x1CU, L"+0010", L"[8-bit pointer] Row 3", MemSize::EightBit, MemFormat::Hex, L"24");
+        inspector.AssertField(0, 0, 0x0CU, L"+0000", L"[pointer] Row 1", MemSize::EightBit, MemFormat::Hex, L"14");
+        inspector.AssertField(1, 8, 0x14U, L"+0008", L"[pointer] Row 2", MemSize::EightBit, MemFormat::Hex, L"1c");
+        inspector.AssertField(2, 16, 0x1CU, L"+0010", L"[pointer] Row 3", MemSize::EightBit, MemFormat::Hex, L"24");
         inspector.AssertField(3, 24, 0x24U, L"+0018", L"Generic data", MemSize::EightBit, MemFormat::Hex, L"2c");
 
         Assert::AreEqual({1U}, inspector.PointerChain().Count());
@@ -276,15 +326,180 @@ public:
         Assert::AreEqual(0x00000000, inspector.GetSelectedNode());
         Assert::AreEqual(std::wstring(L"[8-bit pointer] Row 1"), inspector.GetCurrentAddressNote());
         Assert::AreEqual({3U}, inspector.Bookmarks().Count());
-        inspector.AssertField(0, 0, 0x14U, L"+0000", L"[8-bit] Column 1a", MemSize::EightBit, MemFormat::Hex, L"1c");
-        inspector.AssertField(1, 4, 0x18U, L"+0004", L"[8-bit pointer] Column 1b", MemSize::EightBit, MemFormat::Hex, L"20");
-        inspector.AssertField(2, 8, 0x1CU, L"+0008", L"[8-bit] Column 1c", MemSize::EightBit, MemFormat::Hex, L"24");
+        inspector.AssertField(0, 0, 0x14U, L"+0000", L"Column 1a", MemSize::EightBit, MemFormat::Hex, L"1c");
+        inspector.AssertField(1, 4, 0x18U, L"+0004", L"[pointer] Column 1b", MemSize::EightBit, MemFormat::Hex, L"20");
+        inspector.AssertField(2, 8, 0x1CU, L"+0008", L"Column 1c", MemSize::EightBit, MemFormat::Hex, L"24");
 
         Assert::AreEqual({2U}, inspector.PointerChain().Count());
         inspector.AssertPointerChain(0, L"", 0x04, L"[8-bit pointer] Player data", L"0c");
         inspector.AssertPointerChain(1, L"+0000", 0x0C, L"[8-bit pointer] Row 1", L"14");
     }
 
+    TEST_METHOD(TestSelectedField)
+    {
+        PointerInspectorViewModelHarness inspector;
+        inspector.mockGameContext.SetGameId(1);
+        inspector.mockGameContext.NotifyActiveGameChanged(); // enable note support
+
+        std::array<uint8_t, 64> memory = {};
+        for (uint8_t i = 8; i < memory.size(); i += 4)
+            memory.at(i) = i;
+        inspector.mockEmulatorContext.MockMemory(memory);
+        memory.at(4) = 12;
+
+        inspector.SetCurrentAddress({4U});
+        inspector.mockGameContext.Assets().FindCodeNotes()->SetCodeNote({4U},
+            L"[32-bit pointer] Player data\n"
+            L"+4: [32-bit] Class\n"
+            "1=Wizard\n"
+            "2=Fighter\n"
+            L"+8: [32-bit] Max HP\n"
+            L"+12: [32-bit pointer] Inventory\n"
+            L"++0: [16-bit] First item\n"
+            L"++2: [16-bit] Second item");
+
+        Assert::AreEqual({ 4U }, inspector.GetCurrentAddress());
+
+        Assert::AreEqual({ 3U }, inspector.Bookmarks().Count());
+        Assert::IsFalse(inspector.HasSelection());
+        Assert::IsFalse(inspector.HasSingleSelection());
+        Assert::AreEqual(std::wstring(), inspector.GetCurrentFieldNote());
+
+        inspector.Bookmarks().GetItemAt(0)->SetSelected(true);
+        Assert::IsTrue(inspector.HasSelection());
+        Assert::IsTrue(inspector.HasSingleSelection());
+        Assert::AreEqual(std::wstring(L"[32-bit] Class\n1=Wizard\n2=Fighter"), inspector.GetCurrentFieldNote());
+
+        inspector.Bookmarks().GetItemAt(1)->SetSelected(true);
+        Assert::IsTrue(inspector.HasSelection());
+        Assert::IsFalse(inspector.HasSingleSelection());
+        Assert::AreEqual(std::wstring(), inspector.GetCurrentFieldNote());
+
+        inspector.Bookmarks().GetItemAt(0)->SetSelected(false);
+        Assert::IsTrue(inspector.HasSelection());
+        Assert::IsTrue(inspector.HasSingleSelection());
+        Assert::AreEqual(std::wstring(L"[32-bit] Max HP"), inspector.GetCurrentFieldNote());
+
+        inspector.Bookmarks().GetItemAt(1)->SetSelected(false);
+        Assert::IsFalse(inspector.HasSelection());
+        Assert::IsFalse(inspector.HasSingleSelection());
+        Assert::AreEqual(std::wstring(), inspector.GetCurrentFieldNote());
+
+        inspector.Bookmarks().GetItemAt(1)->SetSelected(true);
+        Assert::IsTrue(inspector.HasSelection());
+        Assert::IsTrue(inspector.HasSingleSelection());
+        Assert::AreEqual(std::wstring(L"[32-bit] Max HP"), inspector.GetCurrentFieldNote());
+
+        inspector.SetSelectedNode(0x0000000C);
+        Assert::AreEqual({2U}, inspector.Bookmarks().Count());
+        Assert::IsFalse(inspector.HasSelection());
+        Assert::IsFalse(inspector.HasSingleSelection());
+        Assert::AreEqual(std::wstring(), inspector.GetCurrentFieldNote());
+
+        inspector.Bookmarks().GetItemAt(1)->SetSelected(true);
+        Assert::IsTrue(inspector.HasSelection());
+        Assert::IsTrue(inspector.HasSingleSelection());
+        Assert::AreEqual(std::wstring(L"[16-bit] Second item"), inspector.GetCurrentFieldNote());
+    }
+
+    TEST_METHOD(TestUpdateCurrentFieldNoteSimple)
+    {
+        PointerInspectorViewModelHarness inspector;
+        inspector.mockGameContext.SetGameId(1);
+        inspector.mockGameContext.NotifyActiveGameChanged(); // enable note support
+
+        std::array<uint8_t, 64> memory = {};
+        for (uint8_t i = 8; i < memory.size(); i += 4)
+            memory.at(i) = i;
+        inspector.mockEmulatorContext.MockMemory(memory);
+        memory.at(4) = 12;
+
+        inspector.SetCurrentAddress({4U});
+        inspector.mockGameContext.Assets().FindCodeNotes()->SetCodeNote({4U},
+            L"[32-bit pointer] Player data\n"
+            L"+8: [32-bit] Max HP");
+
+        Assert::AreEqual({ 4U }, inspector.GetCurrentAddress());
+        inspector.AssertField(0, 8, 20, L"+0008", L"Max HP", MemSize::ThirtyTwoBit, MemFormat::Hex, L"00000014");
+
+        inspector.Bookmarks().GetItemAt(0)->SetSelected(true);
+        Assert::AreEqual(std::wstring(L"[32-bit] Max HP"), inspector.GetCurrentFieldNote());
+
+        inspector.SetCurrentFieldNote(L"[32-bit] Current HP");
+        Assert::AreEqual(std::wstring(L"[32-bit] Current HP"), inspector.GetCurrentFieldNote());
+        inspector.AssertField(0, 8, 20, L"+0008", L"Current HP", MemSize::ThirtyTwoBit, MemFormat::Hex, L"00000014");
+
+        inspector.AssertNote({4U}, std::wstring(L"[32-bit pointer] Player data\n+0x08: [32-bit] Current HP"));
+    }
+    
+    TEST_METHOD(TestUpdateCurrentFieldNoteMultiline)
+    {
+        PointerInspectorViewModelHarness inspector;
+        inspector.mockGameContext.SetGameId(1);
+        inspector.mockGameContext.NotifyActiveGameChanged(); // enable note support
+
+        std::array<uint8_t, 64> memory = {};
+        for (uint8_t i = 8; i < memory.size(); i += 4)
+            memory.at(i) = i;
+        inspector.mockEmulatorContext.MockMemory(memory);
+        memory.at(4) = 12;
+
+        inspector.SetCurrentAddress({4U});
+        inspector.mockGameContext.Assets().FindCodeNotes()->SetCodeNote({4U},
+            L"[32-bit pointer] Player data\n"
+            L"+4: [32-bit] Class\n"
+            "1=Wizard\n"
+            "2=Fighter\n"
+            L"+8: [32-bit] Max HP");
+
+        Assert::AreEqual({ 4U }, inspector.GetCurrentAddress());
+        inspector.AssertField(0, 4, 16, L"+0004", L"Class", MemSize::ThirtyTwoBit, MemFormat::Hex, L"00000010");
+
+        inspector.Bookmarks().GetItemAt(0)->SetSelected(true);
+        Assert::AreEqual(std::wstring(L"[32-bit] Class\n1=Wizard\n2=Fighter"), inspector.GetCurrentFieldNote());
+
+        inspector.SetCurrentFieldNote(L"[32-bit] Class\n1=Mage\n2=Fighter");
+        Assert::AreEqual(std::wstring(L"[32-bit] Class\n1=Mage\n2=Fighter"), inspector.GetCurrentFieldNote());
+        inspector.AssertField(0, 4, 16, L"+0004", L"Class", MemSize::ThirtyTwoBit, MemFormat::Hex, L"00000010");
+
+        inspector.AssertNote({4U}, std::wstring(L"[32-bit pointer] Player data\n+0x04: [32-bit] Class\n1=Mage\n2=Fighter\n+0x08: [32-bit] Max HP"));
+    }
+
+    TEST_METHOD(TestUpdateCurrentFieldNoteNested)
+    {
+        PointerInspectorViewModelHarness inspector;
+        inspector.mockGameContext.SetGameId(1);
+        inspector.mockGameContext.NotifyActiveGameChanged(); // enable note support
+
+        std::array<uint8_t, 64> memory = {};
+        for (uint8_t i = 8; i < memory.size(); i += 4)
+            memory.at(i) = i;
+        inspector.mockEmulatorContext.MockMemory(memory);
+        memory.at(4) = 12;
+
+        inspector.mockGameContext.Assets().FindCodeNotes()->SetCodeNote({4U},
+            L"[32-bit pointer] Player data\n"
+            L"+8: [32-bit] Max HP\n"//\n"
+            L"+12: [32-bit pointer] Inventory\n"
+            L"++8: [16-bit] First item\n"
+            L"++10: [16-bit] Second item");
+        inspector.SetCurrentAddress({4U});
+        inspector.SetSelectedNode(0x0000000C);
+
+        Assert::AreEqual({ 4U }, inspector.GetCurrentAddress());
+        inspector.AssertField(0, 8, 32, L"+0008", L"First item", MemSize::SixteenBit, MemFormat::Hex, L"0020");
+
+        inspector.Bookmarks().GetItemAt(0)->SetSelected(true);
+        Assert::AreEqual(std::wstring(L"[16-bit] First item"), inspector.GetCurrentFieldNote());
+
+        inspector.SetCurrentFieldNote(L"[16-bit] 1st item");
+        Assert::AreEqual(std::wstring(L"[16-bit] 1st item"), inspector.GetCurrentFieldNote());
+        inspector.AssertField(0, 8, 32, L"+0008", L"1st item", MemSize::SixteenBit, MemFormat::Hex, L"0020");
+
+        inspector.AssertNote({4U}, std::wstring(L"[32-bit pointer] Player data\n+0x08: [32-bit] Max HP\n+0x0C: [32-bit pointer] Inventory\n"
+                                                L"++0x08: [16-bit] 1st item\n++0x0A: [16-bit] Second item"));
+    }
+
     TEST_METHOD(TestCopyDefinition)
     {
         PointerInspectorViewModelHarness inspector;
@@ -313,7 +528,7 @@ public:
 
         Assert::AreEqual(PointerInspectorViewModel::PointerNodeViewModel::RootNodeId, inspector.GetSelectedNode());
         Assert::AreEqual(std::wstring(L"[8-bit pointer] Player data"), inspector.GetCurrentAddressNote());
-        inspector.AssertField(1, 8, 0x14U, L"+0008", L"[8-bit pointer] Row 2", MemSize::EightBit, MemFormat::Hex, L"1c");
+        inspector.AssertField(1, 8, 0x14U, L"+0008", L"[pointer] Row 2", MemSize::EightBit, MemFormat::Hex, L"1c");
 
         inspector.CopyDefinition();
         Assert::AreEqual(std::wstring(), inspector.mockClipboard.GetText());
@@ -325,7 +540,7 @@ public:
         inspector.SetSelectedNode(0x01000004);
         Assert::AreEqual(0x01000004, inspector.GetSelectedNode());
         Assert::AreEqual(std::wstring(L"[8-bit pointer] Column 1b"), inspector.GetCurrentAddressNote());
-        inspector.AssertField(0, 0, 0x20U, L"+0000", L"[8-bit] Column 1a", MemSize::EightBit, MemFormat::Hex, L"28");
+        inspector.AssertField(0, 0, 0x20U, L"+0000", L"Column 1a", MemSize::EightBit, MemFormat::Hex, L"28");
         inspector.CopyDefinition();
         Assert::AreEqual(std::wstring(), inspector.mockClipboard.GetText());
 
@@ -357,8 +572,8 @@ public:
         Assert::AreEqual(std::wstring(L"[32-bit pointer] Player data"), inspector.GetCurrentAddressNote());
 
         Assert::AreEqual({2U}, inspector.Bookmarks().Count());
-        inspector.AssertField(0, 4, 16U, L"+0004", L"[32-bit] Current HP", MemSize::ThirtyTwoBit, MemFormat::Hex, L"00000010");
-        inspector.AssertField(1, 8, 20U, L"+0008", L"[32-bit] Max HP", MemSize::ThirtyTwoBit, MemFormat::Hex, L"00000014");
+        inspector.AssertField(0, 4, 16U, L"+0004", L"Current HP", MemSize::ThirtyTwoBit, MemFormat::Hex, L"00000010");
+        inspector.AssertField(1, 8, 20U, L"+0008", L"Max HP", MemSize::ThirtyTwoBit, MemFormat::Hex, L"00000014");
 
         Assert::AreEqual({1U}, inspector.PointerChain().Count());
         inspector.AssertPointerChain(0, L"", 0x04, L"[32-bit pointer] Player data", L"0000000c");
@@ -367,8 +582,8 @@ public:
         memory.at(17) = 1;
         memory.at(20) = 99;
         inspector.DoFrame();
-        inspector.AssertField(0, 4, 16U, L"+0004", L"[32-bit] Current HP", MemSize::ThirtyTwoBit, MemFormat::Hex, L"00000110");
-        inspector.AssertField(1, 8, 20U, L"+0008", L"[32-bit] Max HP", MemSize::ThirtyTwoBit, MemFormat::Hex, L"00000063");
+        inspector.AssertField(0, 4, 16U, L"+0004", L"Current HP", MemSize::ThirtyTwoBit, MemFormat::Hex, L"00000110");
+        inspector.AssertField(1, 8, 20U, L"+0008", L"Max HP", MemSize::ThirtyTwoBit, MemFormat::Hex, L"00000063");
 
         Assert::AreEqual({1U}, inspector.PointerChain().Count());
         inspector.AssertPointerChain(0, L"", 0x04, L"[32-bit pointer] Player data", L"0000000c");
@@ -376,8 +591,8 @@ public:
         // pointer changes
         memory.at(4) = 13;
         inspector.DoFrame();
-        inspector.AssertField(0, 4, 17U, L"+0004", L"[32-bit] Current HP", MemSize::ThirtyTwoBit, MemFormat::Hex, L"63000001");
-        inspector.AssertField(1, 8, 21U, L"+0008", L"[32-bit] Max HP", MemSize::ThirtyTwoBit, MemFormat::Hex, L"18000000");
+        inspector.AssertField(0, 4, 17U, L"+0004", L"Current HP", MemSize::ThirtyTwoBit, MemFormat::Hex, L"63000001");
+        inspector.AssertField(1, 8, 21U, L"+0008", L"Max HP", MemSize::ThirtyTwoBit, MemFormat::Hex, L"18000000");
 
         Assert::AreEqual({1U}, inspector.PointerChain().Count());
         inspector.AssertPointerChain(0, L"", 0x04, L"[32-bit pointer] Player data", L"0000000d");
@@ -423,9 +638,9 @@ public:
 
         // all pointers are good
         Assert::AreEqual({2U}, inspector.Bookmarks().Count());
-        inspector.AssertField(0, 0, 0x20U, L"+0000", L"[32-bit] Current HP", // 20+00=20 (56)
+        inspector.AssertField(0, 0, 0x20U, L"+0000", L"Current HP", // 20+00=20 (56)
             MemSize::ThirtyTwoBit, MemFormat::Hex, L"00000056");
-        inspector.AssertField(1, 8, 0x28U, L"+0008", L"[32-bit] Max HP",     // 20+08=28 (64)
+        inspector.AssertField(1, 8, 0x28U, L"+0008", L"Max HP",     // 20+08=28 (64)
             MemSize::ThirtyTwoBit, MemFormat::Hex, L"00000064");
 
         Assert::AreEqual({3U}, inspector.PointerChain().Count());
@@ -446,9 +661,9 @@ public:
         inspector.AssertPointerChainAddressValid(0);
         inspector.AssertPointerChainAddressNull(1);
         inspector.AssertPointerChainAddressValid(2);
-        inspector.AssertField(0, 0, 0x08U, L"+0000", L"[32-bit] Current HP", // 08+00=08 (08)
+        inspector.AssertField(0, 0, 0x08U, L"+0000", L"Current HP", // 08+00=08 (08)
                               MemSize::ThirtyTwoBit, MemFormat::Hex, L"00000008");
-        inspector.AssertField(1, 8, 0x10U, L"+0008", L"[32-bit] Max HP",     // 08+08=10 (00)
+        inspector.AssertField(1, 8, 0x10U, L"+0008", L"Max HP",     // 08+08=10 (00)
                               MemSize::ThirtyTwoBit, MemFormat::Hex, L"00000000");
         
         // third level is out of range
@@ -461,9 +676,9 @@ public:
         inspector.AssertPointerChainAddressValid(0);
         inspector.AssertPointerChainAddressInvalid(1);
         inspector.AssertPointerChainAddressNull(2);
-        inspector.AssertField(0, 0, 0x00U, L"+0000", L"[32-bit] Current HP", // 00+00=00 (00)
+        inspector.AssertField(0, 0, 0x00U, L"+0000", L"Current HP", // 00+00=00 (00)
                               MemSize::ThirtyTwoBit, MemFormat::Hex, L"00000000");
-        inspector.AssertField(1, 8, 0x08U, L"+0008", L"[32-bit] Max HP",     // 00+08=08 (08)
+        inspector.AssertField(1, 8, 0x08U, L"+0008", L"Max HP",     // 00+08=08 (08)
                               MemSize::ThirtyTwoBit, MemFormat::Hex, L"00000008");
     }
 };