From 8e0f99640a899216ab83b15351e45b73fd754d1b Mon Sep 17 00:00:00 2001
From: Brues <5278969+brues-code@users.noreply.github.com>
Date: Tue, 26 May 2026 00:59:07 -0500
Subject: [PATCH 01/15] C_NamePlate.GetNamePlateGUIDs
---
README.md | 1 +
docs/API.md | 30 ++++++++++-
src/nameplate/Info.cpp | 118 +++++++++++++++++++++++++++++++++++++++++
3 files changed, 148 insertions(+), 1 deletion(-)
create mode 100644 src/nameplate/Info.cpp
diff --git a/README.md b/README.md
index 2c6bb86..ea3cacf 100644
--- a/README.md
+++ b/README.md
@@ -39,6 +39,7 @@ Full per-function reference: **[docs/API.md](docs/API.md)**.
| [Macros](docs/API.md#macros) | `GetMacroSpell` |
| [Map](docs/API.md#map) | `C_Map.GetBestMapForUnit` |
| [MerchantFrame](docs/API.md#merchantframe) | `C_MerchantFrame.GetBuybackItemID`, `C_MerchantFrame.GetItemInfo`, `C_MerchantFrame.GetNumJunkItems`, `C_MerchantFrame.IsMerchantItemRefundable`, `C_MerchantFrame.IsSellAllJunkEnabled`, `C_MerchantFrame.SellAllJunkItems` |
+| [NamePlate](docs/API.md#nameplate) | `C_NamePlate.GetNamePlateGUIDs` |
| [NameCache](docs/API.md#namecache) | `C_CreatureInfo.GetCreatureID`, `C_PlayerCache.GetPlayerInfoByName`, `C_PlayerCache.IsEnabled`, `C_PlayerCache.IsScanEnabled`, `C_PlayerCache.RememberPlayer`, `C_PlayerCache.SetEnabled`, `C_PlayerCache.SetScanEnabled`, `C_PlayerInfo.GUIDIsCreature`, `C_PlayerInfo.GUIDIsGameObject`, `C_PlayerInfo.GUIDIsPet`, `C_PlayerInfo.GUIDIsPlayer`, `GetPlayerInfoByGUID` |
| [Quest](docs/API.md#quest) | `C_QuestLog.GetQuestIDForLogIndex`, `C_QuestLog.GetTitleForQuestID`, `C_QuestLog.IsOnQuest`, `C_QuestLog.IsQuestDataCachedByID`, `C_QuestLog.IsUnitOnQuest`, `C_QuestLog.RequestLoadQuestByID`, `GetQuestLogLeaderBoardID` |
| [Spell](docs/API.md#spell) | `C_Spell.CancelSpellByID`, `C_Spell.DoesSpellExist`, `C_Spell.GetSchoolString`, `C_Spell.GetSpellCooldown`, `C_Spell.GetSpellDescription`, `C_Spell.GetSpellInfo`, `C_Spell.GetSpellLink`, `C_Spell.GetSpellName`, `C_Spell.GetSpellReagents`, `C_Spell.GetSpellSubtext`, `C_Spell.GetSpellTexture`, `C_Spell.IsAutoAttackSpell`, `C_Spell.IsCurrentSpell`, `C_Spell.IsRangedAutoAttackSpell`, `C_Spell.IsSelfBuff`, `C_Spell.IsSpellHarmful`, `C_Spell.IsSpellHelpful`, `C_Spell.IsSpellPassive`, `C_Spell.IsSpellUsable`, `C_Spell.SpellHasRange`, `CancelSpellByName`, `CastSpellNoToggle`, `GetCraftSpellID`, `GetSpellInfo`, `GetSpellLink`, `GetSpellSchool`, `IsHarmfulSpell`, `IsHelpfulSpell`, `IsPassiveSpell`, `IsPlayerSpell`, `IsSpellKnown`, `IsUsableSpell`, `SpellHasRange` |
diff --git a/docs/API.md b/docs/API.md
index 5defd74..0b9857a 100644
--- a/docs/API.md
+++ b/docs/API.md
@@ -195,6 +195,9 @@ build instructions.
- [`C_MerchantFrame.IsMerchantItemRefundable(slot)`](#c_merchantframeismerchantitemrefundableslot)
- [`C_MerchantFrame.IsSellAllJunkEnabled()`](#c_merchantframeissellalljunkenabled)
+- [NamePlate](#nameplate)
+ - [`C_NamePlate.GetNamePlateGUIDs()`](#c_nameplategetnameplateguids)
+
- [NameCache](#namecache)
- [`GetPlayerInfoByGUID(guid)`](#getplayerinfobyguidguid)
- [`C_PlayerCache.GetPlayerInfoByName(name)`](#c_playercachegetplayerinfobynamename)
@@ -4325,7 +4328,32 @@ disable the sell-all-junk button; vanilla has no such setting, so
the feature is always on. Function exists so retail addons that
gate `SellAllJunkItems` on this don't no-op silently.
-## NameCache
+## NamePlate
+
+### `C_NamePlate.GetNamePlateGUIDs()`
+
+Returns a 1-based table of GUID strings (modern `"0x..."` format) —
+one per CGUnit that currently has an allocated nameplate frame.
+Empty table when nameplates are toggled off (`V` key) or no units
+in nameplate range are visible.
+
+```lua
+/dump C_NamePlate.GetNamePlateGUIDs()
+-- { "0xF13000C36C26FD02", "0xF130000009276912", ... }
+```
+
+Walks the local-player-anchored object hash table for `TYPEMASK_UNIT`
+entries, filters by `*(unit + 0xE60) != nullptr` (the per-unit
+nameplate-frame pointer the engine assigns in `FUN_006086E0`'s "show
+nameplate" path). Returns the GUIDs of matching units in hash-bucket
+order — order isn't stable across calls.
+
+**Named differently from modern WoW's `C_NamePlate.GetNamePlates`** —
+the modern call returns nameplate `Frame` objects, not GUIDs. We
+ship the GUID primitive; surfacing the frames would need additional
+engine hooks. Modern also provides `"nameplateN"` unit tokens and
+`NAME_PLATE_UNIT_ADDED` / `REMOVED` events — both unimplemented
+here (multi-session scope).
GUID-keyed cache of player names and classes. The engine itself
maintains an in-memory `NameCache` at `0x00C0E228`, populated by
diff --git a/src/nameplate/Info.cpp b/src/nameplate/Info.cpp
new file mode 100644
index 0000000..0561852
--- /dev/null
+++ b/src/nameplate/Info.cpp
@@ -0,0 +1,118 @@
+// This file is part of ClassicAPI.
+//
+// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
+// of the GNU Lesser General Public License as published by the Free Software Foundation, either
+// version 3 of the License, or (at your option) any later version.
+//
+// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
+// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
+// PURPOSE. See the GNU Lesser General Public License for more details.
+//
+// You should have received a copy of the GNU Lesser General Public License along with
+// ClassicAPI. If not, see .
+
+// `C_NamePlate.GetNamePlateGUIDs()` — enumerates GUIDs of currently-
+// visible units that have an allocated nameplate frame.
+//
+// Named differently from modern `C_NamePlate.GetNamePlates` because
+// the modern call returns nameplate `Frame` objects, not GUIDs. This
+// backport ships only the GUID primitive; surfacing the frames would
+// require additional engine hooks.
+//
+// Vanilla 1.12 stores each unit's nameplate pointer at `CGUnit + 0xE60`
+// (verified via `FUN_006086E0`'s "ensure nameplate exists" path). The
+// nameplate also caches the unit's GUID at `+0x4E8` for back-lookup.
+// There's no central "active nameplates" list — the engine maintains
+// per-unit pointers updated by per-unit state-change handlers.
+//
+// To enumerate, we walk the local-player-anchored object hash table
+// (`player + 0x1C` = bucket array, `player + 0x24` = mask). Each
+// bucket header stores the link-field offset at byte 0 and the
+// chain-head pointer at byte 8 — Storm's intrusive-hash pattern.
+// Filter by `TYPEMASK_UNIT` (`flags & 0x08` at `*(entry+8) + 8`) and
+// check `+0xE60` for a non-null nameplate pointer.
+
+#include "Game.h"
+#include "Offsets.h"
+#include "guid/Guid.h"
+
+#include
+
+namespace NamePlate::Info {
+
+namespace {
+
+constexpr uintptr_t kLocalPlayerGlobal = 0x00B41414;
+constexpr int kOffPlayerBucketArray = 0x1C;
+constexpr int kOffPlayerBucketMask = 0x24;
+constexpr int kBucketStride = 12;
+constexpr int kBucketLinkOffsetField = 0; // byte 0: link-field offset within entry
+constexpr int kBucketChainHeadField = 8; // byte 8: chain head pointer
+constexpr int kOffEntryInstanceBlock = 0x08;
+constexpr int kOffInstanceTypeMask = 0x08;
+constexpr uint32_t kTypeMaskUnit = 0x08;
+constexpr int kOffUnitNamePlate = 0xE60;
+
+} // namespace
+
+static int __fastcall Script_GetNamePlateGUIDs(void *L) {
+ Game::Lua::NewTable(L);
+
+ auto *player = *reinterpret_cast(kLocalPlayerGlobal);
+ if (player == nullptr)
+ return 1;
+
+ auto *buckets = *reinterpret_cast(
+ player + kOffPlayerBucketArray);
+ const uint32_t mask = *reinterpret_cast(
+ player + kOffPlayerBucketMask);
+ if (buckets == nullptr || mask == 0xFFFFFFFFu)
+ return 1;
+
+ int nextIndex = 1;
+ for (uint32_t b = 0; b <= mask; ++b) {
+ const uint8_t *bucket = buckets + b * kBucketStride;
+ const uint32_t linkOffset = *reinterpret_cast(
+ bucket + kBucketLinkOffsetField);
+ uintptr_t entry = *reinterpret_cast(
+ bucket + kBucketChainHeadField);
+
+ while (entry != 0 && (entry & 1) == 0) {
+ auto *obj = reinterpret_cast(entry);
+ auto *instance = *reinterpret_cast(
+ obj + kOffEntryInstanceBlock);
+ if (instance != nullptr) {
+ const uint32_t typeMask = *reinterpret_cast(
+ instance + kOffInstanceTypeMask);
+ if ((typeMask & kTypeMaskUnit) != 0) {
+ const auto *nameplate = *reinterpret_cast(
+ obj + kOffUnitNamePlate);
+ if (nameplate != nullptr) {
+ const uint64_t guid = *reinterpret_cast(
+ instance);
+ if (guid != 0) {
+ char buf[Guid::STRING_SIZE];
+ Game::Lua::PushNumber(L, static_cast(nextIndex++));
+ Game::Lua::PushString(L,
+ Guid::FormatAsString(guid, buf, sizeof buf));
+ Game::Lua::SetTable(L, -3);
+ }
+ }
+ }
+ }
+
+ entry = *reinterpret_cast(
+ obj + linkOffset + 4);
+ }
+ }
+ return 1;
+}
+
+static void RegisterLuaFunctions() {
+ Game::Lua::RegisterTableFunction("C_NamePlate", "GetNamePlateGUIDs",
+ &Script_GetNamePlateGUIDs);
+}
+
+static const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
+
+} // namespace NamePlate::Info
From 2ec0767b3564abfea21f9d45d209dee243542d5a Mon Sep 17 00:00:00 2001
From: Brues <5278969+brues-code@users.noreply.github.com>
Date: Tue, 26 May 2026 01:02:00 -0500
Subject: [PATCH 02/15] fixed NameCache header
---
docs/API.md | 2 ++
1 file changed, 2 insertions(+)
diff --git a/docs/API.md b/docs/API.md
index 0b9857a..e4efe86 100644
--- a/docs/API.md
+++ b/docs/API.md
@@ -4355,6 +4355,8 @@ engine hooks. Modern also provides `"nameplateN"` unit tokens and
`NAME_PLATE_UNIT_ADDED` / `REMOVED` events — both unimplemented
here (multi-session scope).
+## NameCache
+
GUID-keyed cache of player names and classes. The engine itself
maintains an in-memory `NameCache` at `0x00C0E228`, populated by
`SMSG_NAME_QUERY_RESPONSE` — but vanilla doesn't expose it to Lua,
From e0adc32f2f8230e23df5db39cf83d60869c0930a Mon Sep 17 00:00:00 2001
From: Brues <5278969+brues-code@users.noreply.github.com>
Date: Tue, 26 May 2026 01:06:07 -0500
Subject: [PATCH 03/15] C_NamePlate.GetNamePlates
---
README.md | 2 +-
docs/API.md | 57 ++++++++++++++++++++--------
src/nameplate/Info.cpp | 86 ++++++++++++++++++++++++++++++------------
3 files changed, 104 insertions(+), 41 deletions(-)
diff --git a/README.md b/README.md
index ea3cacf..062868e 100644
--- a/README.md
+++ b/README.md
@@ -39,7 +39,7 @@ Full per-function reference: **[docs/API.md](docs/API.md)**.
| [Macros](docs/API.md#macros) | `GetMacroSpell` |
| [Map](docs/API.md#map) | `C_Map.GetBestMapForUnit` |
| [MerchantFrame](docs/API.md#merchantframe) | `C_MerchantFrame.GetBuybackItemID`, `C_MerchantFrame.GetItemInfo`, `C_MerchantFrame.GetNumJunkItems`, `C_MerchantFrame.IsMerchantItemRefundable`, `C_MerchantFrame.IsSellAllJunkEnabled`, `C_MerchantFrame.SellAllJunkItems` |
-| [NamePlate](docs/API.md#nameplate) | `C_NamePlate.GetNamePlateGUIDs` |
+| [NamePlate](docs/API.md#nameplate) | `C_NamePlate.GetNamePlates`, `C_NamePlate.GetNamePlateGUIDs` |
| [NameCache](docs/API.md#namecache) | `C_CreatureInfo.GetCreatureID`, `C_PlayerCache.GetPlayerInfoByName`, `C_PlayerCache.IsEnabled`, `C_PlayerCache.IsScanEnabled`, `C_PlayerCache.RememberPlayer`, `C_PlayerCache.SetEnabled`, `C_PlayerCache.SetScanEnabled`, `C_PlayerInfo.GUIDIsCreature`, `C_PlayerInfo.GUIDIsGameObject`, `C_PlayerInfo.GUIDIsPet`, `C_PlayerInfo.GUIDIsPlayer`, `GetPlayerInfoByGUID` |
| [Quest](docs/API.md#quest) | `C_QuestLog.GetQuestIDForLogIndex`, `C_QuestLog.GetTitleForQuestID`, `C_QuestLog.IsOnQuest`, `C_QuestLog.IsQuestDataCachedByID`, `C_QuestLog.IsUnitOnQuest`, `C_QuestLog.RequestLoadQuestByID`, `GetQuestLogLeaderBoardID` |
| [Spell](docs/API.md#spell) | `C_Spell.CancelSpellByID`, `C_Spell.DoesSpellExist`, `C_Spell.GetSchoolString`, `C_Spell.GetSpellCooldown`, `C_Spell.GetSpellDescription`, `C_Spell.GetSpellInfo`, `C_Spell.GetSpellLink`, `C_Spell.GetSpellName`, `C_Spell.GetSpellReagents`, `C_Spell.GetSpellSubtext`, `C_Spell.GetSpellTexture`, `C_Spell.IsAutoAttackSpell`, `C_Spell.IsCurrentSpell`, `C_Spell.IsRangedAutoAttackSpell`, `C_Spell.IsSelfBuff`, `C_Spell.IsSpellHarmful`, `C_Spell.IsSpellHelpful`, `C_Spell.IsSpellPassive`, `C_Spell.IsSpellUsable`, `C_Spell.SpellHasRange`, `CancelSpellByName`, `CastSpellNoToggle`, `GetCraftSpellID`, `GetSpellInfo`, `GetSpellLink`, `GetSpellSchool`, `IsHarmfulSpell`, `IsHelpfulSpell`, `IsPassiveSpell`, `IsPlayerSpell`, `IsSpellKnown`, `IsUsableSpell`, `SpellHasRange` |
diff --git a/docs/API.md b/docs/API.md
index e4efe86..5cf6c54 100644
--- a/docs/API.md
+++ b/docs/API.md
@@ -196,6 +196,7 @@ build instructions.
- [`C_MerchantFrame.IsSellAllJunkEnabled()`](#c_merchantframeissellalljunkenabled)
- [NamePlate](#nameplate)
+ - [`C_NamePlate.GetNamePlates()`](#c_nameplategetnameplates)
- [`C_NamePlate.GetNamePlateGUIDs()`](#c_nameplategetnameplateguids)
- [NameCache](#namecache)
@@ -4330,30 +4331,54 @@ gate `SellAllJunkItems` on this don't no-op silently.
## NamePlate
+Modern `C_NamePlate.*` returns nameplate `Frame` objects keyed off
+unit data. Vanilla 1.12 doesn't ship the API at all — but the
+underlying data (per-unit nameplate pointer at `CGUnit + 0xE60`)
+exists. We enumerate visible units via the local-player-anchored
+object hash table, filter by `TYPEMASK_UNIT`, and return matches.
+
+> **Scope vs. modern.** Modern API also provides `"nameplateN"` unit
+> tokens and `NAME_PLATE_UNIT_ADDED` / `REMOVED` events. Both
+> deferred — would require hooking the unit-token resolver and a
+> per-frame polling loop. The functions documented below give
+> addons enough to walk plates per call.
+
+### `C_NamePlate.GetNamePlates()`
+
+Returns a 1-based table of nameplate `Frame` objects — one per
+CGUnit that currently has an allocated nameplate. The frames are
+real Lua tables with methods (`:GetName()`, `:GetWidth()`,
+`:SetAlpha()`, etc.); decorations added by other addons (pfUI's
+nameplate skin, healthbar overlays, etc.) are visible on the
+returned tables as expected.
+
+```lua
+local plates = C_NamePlate.GetNamePlates()
+for i, plate in ipairs(plates) do
+ print(i, plate:GetName(), plate:GetWidth())
+end
+```
+
+Pushes `registry[plate + 0x08]` for each frame — the per-CFrame Lua
+registry ref-key the engine populates on frame creation. Empty
+table when nameplates are toggled off (`V` key) or no units are in
+nameplate range.
+
### `C_NamePlate.GetNamePlateGUIDs()`
-Returns a 1-based table of GUID strings (modern `"0x..."` format) —
-one per CGUnit that currently has an allocated nameplate frame.
-Empty table when nameplates are toggled off (`V` key) or no units
-in nameplate range are visible.
+Companion to `GetNamePlates()` — returns the GUID strings of the
+same set of units in modern `"0xHHHHHHHHHHHHHHHH"` format. Useful
+when an addon only needs GUIDs (raid-target tracking, threat
+coloring) and doesn't want to walk the frame list to read each
+plate's stored guid.
```lua
/dump C_NamePlate.GetNamePlateGUIDs()
-- { "0xF13000C36C26FD02", "0xF130000009276912", ... }
```
-Walks the local-player-anchored object hash table for `TYPEMASK_UNIT`
-entries, filters by `*(unit + 0xE60) != nullptr` (the per-unit
-nameplate-frame pointer the engine assigns in `FUN_006086E0`'s "show
-nameplate" path). Returns the GUIDs of matching units in hash-bucket
-order — order isn't stable across calls.
-
-**Named differently from modern WoW's `C_NamePlate.GetNamePlates`** —
-the modern call returns nameplate `Frame` objects, not GUIDs. We
-ship the GUID primitive; surfacing the frames would need additional
-engine hooks. Modern also provides `"nameplateN"` unit tokens and
-`NAME_PLATE_UNIT_ADDED` / `REMOVED` events — both unimplemented
-here (multi-session scope).
+Same enumeration as `GetNamePlates`; the two return parallel lists
+in the same hash-bucket order (which isn't stable across calls).
## NameCache
diff --git a/src/nameplate/Info.cpp b/src/nameplate/Info.cpp
index 0561852..1111381 100644
--- a/src/nameplate/Info.cpp
+++ b/src/nameplate/Info.cpp
@@ -11,13 +11,17 @@
// You should have received a copy of the GNU Lesser General Public License along with
// ClassicAPI. If not, see .
-// `C_NamePlate.GetNamePlateGUIDs()` — enumerates GUIDs of currently-
-// visible units that have an allocated nameplate frame.
+// `C_NamePlate.GetNamePlates()` — returns nameplate Frame objects,
+// matching modern WoW's signature. Each CGNamePlateFrame is a CFrame
+// subclass that stores its Lua-registry ref-key at the standard
+// `OFF_COBJECT_LUA_REGISTRY_REF = +0x08`; pushing
+// `registry[refKey]` yields the Lua table that addons can call
+// `:Show()`, `:GetWidth()`, etc. on.
//
-// Named differently from modern `C_NamePlate.GetNamePlates` because
-// the modern call returns nameplate `Frame` objects, not GUIDs. This
-// backport ships only the GUID primitive; surfacing the frames would
-// require additional engine hooks.
+// `C_NamePlate.GetNamePlateGUIDs()` — companion returning the GUID
+// strings of the same set of units. Faster than walking
+// `GetNamePlates()` + reading guid back, useful when an addon only
+// needs the GUIDs (raid-target tracking, threat coloring, etc.).
//
// Vanilla 1.12 stores each unit's nameplate pointer at `CGUnit + 0xE60`
// (verified via `FUN_006086E0`'s "ensure nameplate exists" path). The
@@ -53,23 +57,24 @@ constexpr int kOffInstanceTypeMask = 0x08;
constexpr uint32_t kTypeMaskUnit = 0x08;
constexpr int kOffUnitNamePlate = 0xE60;
-} // namespace
-
-static int __fastcall Script_GetNamePlateGUIDs(void *L) {
- Game::Lua::NewTable(L);
+using LuaRawGetI_t = void(__fastcall *)(void *L, int idx, int n);
+// Walk visible units with allocated nameplates, invoking `emit(unit,
+// nameplate, instance)` for each. Returns the number of emissions.
+template
+int ForEachNamePlatedUnit(F &&emit) {
auto *player = *reinterpret_cast(kLocalPlayerGlobal);
if (player == nullptr)
- return 1;
+ return 0;
auto *buckets = *reinterpret_cast(
player + kOffPlayerBucketArray);
const uint32_t mask = *reinterpret_cast(
player + kOffPlayerBucketMask);
if (buckets == nullptr || mask == 0xFFFFFFFFu)
- return 1;
+ return 0;
- int nextIndex = 1;
+ int count = 0;
for (uint32_t b = 0; b <= mask; ++b) {
const uint8_t *bucket = buckets + b * kBucketStride;
const uint32_t linkOffset = *reinterpret_cast(
@@ -85,30 +90,63 @@ static int __fastcall Script_GetNamePlateGUIDs(void *L) {
const uint32_t typeMask = *reinterpret_cast(
instance + kOffInstanceTypeMask);
if ((typeMask & kTypeMaskUnit) != 0) {
- const auto *nameplate = *reinterpret_cast(
+ const auto *nameplate = *reinterpret_cast(
obj + kOffUnitNamePlate);
if (nameplate != nullptr) {
- const uint64_t guid = *reinterpret_cast(
- instance);
- if (guid != 0) {
- char buf[Guid::STRING_SIZE];
- Game::Lua::PushNumber(L, static_cast(nextIndex++));
- Game::Lua::PushString(L,
- Guid::FormatAsString(guid, buf, sizeof buf));
- Game::Lua::SetTable(L, -3);
- }
+ emit(obj, nameplate, instance);
+ ++count;
}
}
}
-
entry = *reinterpret_cast(
obj + linkOffset + 4);
}
}
+ return count;
+}
+
+} // namespace
+
+static int __fastcall Script_GetNamePlates(void *L) {
+ Game::Lua::NewTable(L);
+ auto rawgeti = reinterpret_cast(
+ Offsets::FUN_FRAMESCRIPT_PUSH_OBJECT);
+ int nextIndex = 1;
+ ForEachNamePlatedUnit(
+ [L, rawgeti, &nextIndex](const uint8_t *, const uint8_t *nameplate,
+ const uint8_t *) {
+ const int refKey = *reinterpret_cast(
+ nameplate + Offsets::OFF_COBJECT_LUA_REGISTRY_REF);
+ if (refKey == 0)
+ return;
+ Game::Lua::PushNumber(L, static_cast(nextIndex++));
+ rawgeti(L, Game::Lua::REGISTRY_INDEX, refKey);
+ Game::Lua::SetTable(L, -3);
+ });
+ return 1;
+}
+
+static int __fastcall Script_GetNamePlateGUIDs(void *L) {
+ Game::Lua::NewTable(L);
+ int nextIndex = 1;
+ ForEachNamePlatedUnit(
+ [L, &nextIndex](const uint8_t *, const uint8_t *,
+ const uint8_t *instance) {
+ const uint64_t guid = *reinterpret_cast(instance);
+ if (guid == 0)
+ return;
+ char buf[Guid::STRING_SIZE];
+ Game::Lua::PushNumber(L, static_cast(nextIndex++));
+ Game::Lua::PushString(L,
+ Guid::FormatAsString(guid, buf, sizeof buf));
+ Game::Lua::SetTable(L, -3);
+ });
return 1;
}
static void RegisterLuaFunctions() {
+ Game::Lua::RegisterTableFunction("C_NamePlate", "GetNamePlates",
+ &Script_GetNamePlates);
Game::Lua::RegisterTableFunction("C_NamePlate", "GetNamePlateGUIDs",
&Script_GetNamePlateGUIDs);
}
From 7861b50012519a484f20756135f57dd217d47d0c Mon Sep 17 00:00:00 2001
From: Brues <5278969+brues-code@users.noreply.github.com>
Date: Tue, 26 May 2026 01:39:09 -0500
Subject: [PATCH 04/15] GetNamePlates will show blizzard nameplates as well
---
docs/API.md | 69 ++++++++++++++++++++++++++++++---------
src/nameplate/Info.cpp | 73 +++++++++++++++++++++++++++++++++++-------
2 files changed, 114 insertions(+), 28 deletions(-)
diff --git a/docs/API.md b/docs/API.md
index 5cf6c54..fec8ee0 100644
--- a/docs/API.md
+++ b/docs/API.md
@@ -4346,11 +4346,10 @@ object hash table, filter by `TYPEMASK_UNIT`, and return matches.
### `C_NamePlate.GetNamePlates()`
Returns a 1-based table of nameplate `Frame` objects — one per
-CGUnit that currently has an allocated nameplate. The frames are
-real Lua tables with methods (`:GetName()`, `:GetWidth()`,
-`:SetAlpha()`, etc.); decorations added by other addons (pfUI's
-nameplate skin, healthbar overlays, etc.) are visible on the
-returned tables as expected.
+CGUnit that currently has an allocated **Lua-registered** nameplate.
+The frames are real Lua tables with methods (`:GetName()`,
+`:GetWidth()`, `:SetAlpha()`, etc.) and any addon-added decorations
+on them.
```lua
local plates = C_NamePlate.GetNamePlates()
@@ -4359,26 +4358,64 @@ for i, plate in ipairs(plates) do
end
```
-Pushes `registry[plate + 0x08]` for each frame — the per-CFrame Lua
-registry ref-key the engine populates on frame creation. Empty
-table when nameplates are toggled off (`V` key) or no units are in
-nameplate range.
+Two kinds of plates can show up:
+
+- **Addon-created plates** (pfUI, TidyPlates, NamePlateMod, etc.):
+ registered with Lua via `CreateFrame`, so each has a real
+ registry ref. We push `registry[plate + 0x08]`. Identity is
+ stable across calls — caching is safe while the frame is alive.
+
+- **Default vanilla plates**: created internally by the engine
+ without ever calling `CreateFrame`. Their `+0x08` field holds the
+ sentinel `LUA_NOREF` (`-2`), not a real registry key. We build a
+ fresh wrapper table per call (`{[0] = lightuserdata(plate)}` with
+ the global `__framescript_meta` metatable) so addons get the
+ same method surface. The wrapper isn't cached engine-side, so
+ identity isn't stable across calls — don't compare wrappers, and
+ don't store them across the unit going out of range (the
+ underlying frame may be freed). Call `GetNamePlates()` fresh
+ each time you need plates.
+
+### Reading region content from a default nameplate
+
+Vanilla plates have six child regions in stable positions. Walk them
+with `:GetRegions()`:
+
+```lua
+local plates = C_NamePlate.GetNamePlates()
+for _, plate in ipairs(plates) do
+ local regions = {plate:GetRegions()}
+ local name = regions[3]:GetText() -- e.g. "Joseph Dalton"
+ local level = tonumber(regions[4]:GetText()) -- e.g. 60
+ -- regions[1], [2], [5], [6] are textures (border, healthbar,
+ -- glow, raid-icon — order depends on the engine's draw order)
+end
+```
+
+Lua 5.0 has no `select()`, so collect into a table via
+`{plate:GetRegions()}` and index. Addon-created plates have
+different region layouts — those frames inherit whatever shape the
+addon built, not this one.
### `C_NamePlate.GetNamePlateGUIDs()`
-Companion to `GetNamePlates()` — returns the GUID strings of the
-same set of units in modern `"0xHHHHHHHHHHHHHHHH"` format. Useful
-when an addon only needs GUIDs (raid-target tracking, threat
-coloring) and doesn't want to walk the frame list to read each
-plate's stored guid.
+Returns a 1-based table of GUID strings (modern
+`"0xHHHHHHHHHHHHHHHH"` format) — one per CGUnit with an allocated
+nameplate, **regardless** of whether the frame has been registered
+with Lua. Catches default vanilla nameplates that
+[`GetNamePlates`](#c_nameplategetnameplates) can't surface as
+frames.
```lua
/dump C_NamePlate.GetNamePlateGUIDs()
-- { "0xF13000C36C26FD02", "0xF130000009276912", ... }
```
-Same enumeration as `GetNamePlates`; the two return parallel lists
-in the same hash-bucket order (which isn't stable across calls).
+Walks the local-player-anchored object hash table for `TYPEMASK_UNIT`
+entries, filters by `*(unit + 0xE60) != nullptr`. The per-unit
+nameplate pointer is set by `FUN_006086E0`'s "show nameplate" path
+regardless of which nameplate system rendered it. Order follows
+hash-bucket iteration and isn't stable across calls.
## NameCache
diff --git a/src/nameplate/Info.cpp b/src/nameplate/Info.cpp
index 1111381..e799068 100644
--- a/src/nameplate/Info.cpp
+++ b/src/nameplate/Info.cpp
@@ -12,16 +12,32 @@
// ClassicAPI. If not, see .
// `C_NamePlate.GetNamePlates()` — returns nameplate Frame objects,
-// matching modern WoW's signature. Each CGNamePlateFrame is a CFrame
-// subclass that stores its Lua-registry ref-key at the standard
-// `OFF_COBJECT_LUA_REGISTRY_REF = +0x08`; pushing
-// `registry[refKey]` yields the Lua table that addons can call
-// `:Show()`, `:GetWidth()`, etc. on.
+// matching modern WoW's signature. Two paths because vanilla has two
+// kinds of nameplates:
+//
+// 1. **Addon-created** (pfUI, TidyPlates, etc.) — already registered
+// with Lua via `CreateFrame`. Their Lua-registry ref-key sits at
+// `+0x08`; we push `registry[refKey]` to return the cached
+// wrapper. Identity stable across calls.
+//
+// 2. **Default vanilla nameplates** — created internally without ever
+// calling `CreateFrame`, so `+0x08` is 0. We build a fresh wrapper
+// table per call: `{[0] = lightuserdata(frame)}` with the global
+// `__framescript_meta` metatable. Methods work
+// (`:GetWidth()` / `:GetAlpha()` / etc.) but the wrapper isn't
+// cached engine-side — calling `GetNamePlates()` again returns a
+// different table for the same frame. Don't compare wrappers by
+// identity, and don't cache them across the unit going out of
+// range (the underlying frame may be freed).
+//
+// We deliberately don't call the engine's frame-registration helper
+// (`FUN_00701BD0`) for the unregistered case — it increments a Lua
+// refcount on the frame that's never decremented, pinning the frame
+// in memory.
//
// `C_NamePlate.GetNamePlateGUIDs()` — companion returning the GUID
-// strings of the same set of units. Faster than walking
-// `GetNamePlates()` + reading guid back, useful when an addon only
-// needs the GUIDs (raid-target tracking, threat coloring, etc.).
+// strings of the same set of units, regardless of registration
+// state. Cheapest enumeration when an addon only needs the GUIDs.
//
// Vanilla 1.12 stores each unit's nameplate pointer at `CGUnit + 0xE60`
// (verified via `FUN_006086E0`'s "ensure nameplate exists" path). The
@@ -58,6 +74,33 @@ constexpr uint32_t kTypeMaskUnit = 0x08;
constexpr int kOffUnitNamePlate = 0xE60;
using LuaRawGetI_t = void(__fastcall *)(void *L, int idx, int n);
+using LuaPushLightUserdata_t = void(__fastcall *)(void *L, void *p);
+using LuaSetMetatable_t = int(__fastcall *)(void *L, int idx);
+
+constexpr uintptr_t kFunLuaPushLightUserdata = 0x006F3A20;
+constexpr uintptr_t kFunLuaSetMetatable = 0x006F4020;
+constexpr int kLuaGlobalsIndex = -10001;
+constexpr const char *kFrameMetatableGlobal = "__framescript_meta";
+
+// Build a fresh frame wrapper on the Lua stack: `{[0] = frame}` with
+// `_G["__framescript_meta"]` as metatable. Same shape the engine's
+// frame-registration helper builds, minus the registry-cache step
+// (which we skip to avoid pinning the frame's refcount).
+void PushFreshFrameWrapper(void *L, void *frame) {
+ auto pushLight = reinterpret_cast(
+ kFunLuaPushLightUserdata);
+ auto setMetatable = reinterpret_cast(
+ kFunLuaSetMetatable);
+
+ Game::Lua::NewTable(L);
+ Game::Lua::PushNumber(L, 0);
+ pushLight(L, frame);
+ Game::Lua::RawSet(L, -3);
+
+ Game::Lua::PushString(L, kFrameMetatableGlobal);
+ Game::Lua::GetTable(L, kLuaGlobalsIndex);
+ setMetatable(L, -2);
+}
// Walk visible units with allocated nameplates, invoking `emit(unit,
// nameplate, instance)` for each. Returns the number of emissions.
@@ -115,12 +158,18 @@ static int __fastcall Script_GetNamePlates(void *L) {
ForEachNamePlatedUnit(
[L, rawgeti, &nextIndex](const uint8_t *, const uint8_t *nameplate,
const uint8_t *) {
+ Game::Lua::PushNumber(L, static_cast(nextIndex++));
+ // The engine initializes `OFF_COBJECT_LUA_REGISTRY_REF` to
+ // `LUA_NOREF` (`-2`) for internally-created frames; a real
+ // refkey is always a positive integer from `luaL_ref`.
+ // Treat anything `<= 0` as unregistered.
const int refKey = *reinterpret_cast(
nameplate + Offsets::OFF_COBJECT_LUA_REGISTRY_REF);
- if (refKey == 0)
- return;
- Game::Lua::PushNumber(L, static_cast(nextIndex++));
- rawgeti(L, Game::Lua::REGISTRY_INDEX, refKey);
+ if (refKey > 0) {
+ rawgeti(L, Game::Lua::REGISTRY_INDEX, refKey);
+ } else {
+ PushFreshFrameWrapper(L, const_cast(nameplate));
+ }
Game::Lua::SetTable(L, -3);
});
return 1;
From 26bae33b814b15c3f73f30c7c6194cc2e65ed7d1 Mon Sep 17 00:00:00 2001
From: Brues <5278969+brues-code@users.noreply.github.com>
Date: Tue, 26 May 2026 01:43:07 -0500
Subject: [PATCH 05/15] C_NamePlate.GetNamePlateForUnit
---
README.md | 2 +-
docs/API.md | 20 +++++++++++++
src/nameplate/Info.cpp | 68 ++++++++++++++++++++++++++++++++++++++++++
3 files changed, 89 insertions(+), 1 deletion(-)
diff --git a/README.md b/README.md
index 062868e..31931e5 100644
--- a/README.md
+++ b/README.md
@@ -39,7 +39,7 @@ Full per-function reference: **[docs/API.md](docs/API.md)**.
| [Macros](docs/API.md#macros) | `GetMacroSpell` |
| [Map](docs/API.md#map) | `C_Map.GetBestMapForUnit` |
| [MerchantFrame](docs/API.md#merchantframe) | `C_MerchantFrame.GetBuybackItemID`, `C_MerchantFrame.GetItemInfo`, `C_MerchantFrame.GetNumJunkItems`, `C_MerchantFrame.IsMerchantItemRefundable`, `C_MerchantFrame.IsSellAllJunkEnabled`, `C_MerchantFrame.SellAllJunkItems` |
-| [NamePlate](docs/API.md#nameplate) | `C_NamePlate.GetNamePlates`, `C_NamePlate.GetNamePlateGUIDs` |
+| [NamePlate](docs/API.md#nameplate) | `C_NamePlate.GetNamePlateForUnit`, `C_NamePlate.GetNamePlateGUIDs`, `C_NamePlate.GetNamePlates` |
| [NameCache](docs/API.md#namecache) | `C_CreatureInfo.GetCreatureID`, `C_PlayerCache.GetPlayerInfoByName`, `C_PlayerCache.IsEnabled`, `C_PlayerCache.IsScanEnabled`, `C_PlayerCache.RememberPlayer`, `C_PlayerCache.SetEnabled`, `C_PlayerCache.SetScanEnabled`, `C_PlayerInfo.GUIDIsCreature`, `C_PlayerInfo.GUIDIsGameObject`, `C_PlayerInfo.GUIDIsPet`, `C_PlayerInfo.GUIDIsPlayer`, `GetPlayerInfoByGUID` |
| [Quest](docs/API.md#quest) | `C_QuestLog.GetQuestIDForLogIndex`, `C_QuestLog.GetTitleForQuestID`, `C_QuestLog.IsOnQuest`, `C_QuestLog.IsQuestDataCachedByID`, `C_QuestLog.IsUnitOnQuest`, `C_QuestLog.RequestLoadQuestByID`, `GetQuestLogLeaderBoardID` |
| [Spell](docs/API.md#spell) | `C_Spell.CancelSpellByID`, `C_Spell.DoesSpellExist`, `C_Spell.GetSchoolString`, `C_Spell.GetSpellCooldown`, `C_Spell.GetSpellDescription`, `C_Spell.GetSpellInfo`, `C_Spell.GetSpellLink`, `C_Spell.GetSpellName`, `C_Spell.GetSpellReagents`, `C_Spell.GetSpellSubtext`, `C_Spell.GetSpellTexture`, `C_Spell.IsAutoAttackSpell`, `C_Spell.IsCurrentSpell`, `C_Spell.IsRangedAutoAttackSpell`, `C_Spell.IsSelfBuff`, `C_Spell.IsSpellHarmful`, `C_Spell.IsSpellHelpful`, `C_Spell.IsSpellPassive`, `C_Spell.IsSpellUsable`, `C_Spell.SpellHasRange`, `CancelSpellByName`, `CastSpellNoToggle`, `GetCraftSpellID`, `GetSpellInfo`, `GetSpellLink`, `GetSpellSchool`, `IsHarmfulSpell`, `IsHelpfulSpell`, `IsPassiveSpell`, `IsPlayerSpell`, `IsSpellKnown`, `IsUsableSpell`, `SpellHasRange` |
diff --git a/docs/API.md b/docs/API.md
index fec8ee0..59e60fc 100644
--- a/docs/API.md
+++ b/docs/API.md
@@ -198,6 +198,7 @@ build instructions.
- [NamePlate](#nameplate)
- [`C_NamePlate.GetNamePlates()`](#c_nameplategetnameplates)
- [`C_NamePlate.GetNamePlateGUIDs()`](#c_nameplategetnameplateguids)
+ - [`C_NamePlate.GetNamePlateForUnit(unitToken)`](#c_nameplategetnameplateforunitunittoken)
- [NameCache](#namecache)
- [`GetPlayerInfoByGUID(guid)`](#getplayerinfobyguidguid)
@@ -4397,6 +4398,25 @@ Lua 5.0 has no `select()`, so collect into a table via
different region layouts — those frames inherit whatever shape the
addon built, not this one.
+### `C_NamePlate.GetNamePlateForUnit(unitToken)`
+
+Returns the nameplate Frame for a single unit (resolved via the
+engine's token-to-GUID path, so out-of-range party/raid members
+work too), or `nil` if the unit has no allocated nameplate.
+
+```lua
+local plate = C_NamePlate.GetNamePlateForUnit("target")
+if plate then
+ local regions = {plate:GetRegions()}
+ print("targeting:", regions[3]:GetText()) -- e.g. "Santora"
+end
+```
+
+Same registered-vs-fresh-wrapper behavior as `GetNamePlates()` —
+addon-created plates return their cached wrapper, default vanilla
+plates get a fresh per-call wrapper. Don't cache the result across
+the unit going out of range.
+
### `C_NamePlate.GetNamePlateGUIDs()`
Returns a 1-based table of GUID strings (modern
diff --git a/src/nameplate/Info.cpp b/src/nameplate/Info.cpp
index e799068..1d146f3 100644
--- a/src/nameplate/Info.cpp
+++ b/src/nameplate/Info.cpp
@@ -76,6 +76,10 @@ constexpr int kOffUnitNamePlate = 0xE60;
using LuaRawGetI_t = void(__fastcall *)(void *L, int idx, int n);
using LuaPushLightUserdata_t = void(__fastcall *)(void *L, void *p);
using LuaSetMetatable_t = int(__fastcall *)(void *L, int idx);
+using TokenToGUID_t = uint64_t(__fastcall *)(const char *token);
+using ResolveByGUID_t = void *(__fastcall *)(int type, const char *debugName,
+ uint32_t guidLo, uint32_t guidHi,
+ int priority);
constexpr uintptr_t kFunLuaPushLightUserdata = 0x006F3A20;
constexpr uintptr_t kFunLuaSetMetatable = 0x006F4020;
@@ -193,11 +197,75 @@ static int __fastcall Script_GetNamePlateGUIDs(void *L) {
return 1;
}
+// Pushes a nameplate Frame onto the stack — registered or fresh
+// wrapper depending on whether the engine assigned a real refKey.
+static void PushNamePlateFrame(void *L, void *nameplate) {
+ const int refKey = *reinterpret_cast(
+ static_cast(nameplate) + Offsets::OFF_COBJECT_LUA_REGISTRY_REF);
+ if (refKey > 0) {
+ auto rawgeti = reinterpret_cast(
+ Offsets::FUN_FRAMESCRIPT_PUSH_OBJECT);
+ rawgeti(L, Game::Lua::REGISTRY_INDEX, refKey);
+ } else {
+ PushFreshFrameWrapper(L, nameplate);
+ }
+}
+
+// `C_NamePlate.GetNamePlateForUnit(unitToken)` — returns the
+// nameplate Frame for the given unit, or `nil` if the unit has no
+// nameplate (out of range, hidden, etc.). Resolves the token to a
+// GUID via the engine's `FUN_TOKEN_TO_GUID` so distant party/raid
+// members can be queried, then looks up the CGUnit via the object
+// hash and reads its `+0xE60` nameplate pointer.
+static int __fastcall Script_GetNamePlateForUnit(void *L) {
+ if (!Game::Lua::IsString(L, 1)) {
+ Game::Lua::PushNil(L);
+ return 1;
+ }
+ const char *token = Game::Lua::ToString(L, 1);
+ if (token == nullptr) {
+ Game::Lua::PushNil(L);
+ return 1;
+ }
+
+ auto tokenToGuid = reinterpret_cast(
+ static_cast(Offsets::FUN_TOKEN_TO_GUID));
+ const uint64_t guid = tokenToGuid(token);
+ if (guid == 0) {
+ Game::Lua::PushNil(L);
+ return 1;
+ }
+
+ auto resolve = reinterpret_cast(
+ static_cast(Offsets::FUN_OBJECT_RESOLVE_BY_GUID));
+ auto *unit = static_cast(
+ resolve(Offsets::OBJ_TYPE_UNIT, "NamePlate",
+ static_cast(guid),
+ static_cast(guid >> 32),
+ 0x172));
+ if (unit == nullptr) {
+ Game::Lua::PushNil(L);
+ return 1;
+ }
+
+ auto *nameplate = *reinterpret_cast(
+ unit + kOffUnitNamePlate);
+ if (nameplate == nullptr) {
+ Game::Lua::PushNil(L);
+ return 1;
+ }
+
+ PushNamePlateFrame(L, nameplate);
+ return 1;
+}
+
static void RegisterLuaFunctions() {
Game::Lua::RegisterTableFunction("C_NamePlate", "GetNamePlates",
&Script_GetNamePlates);
Game::Lua::RegisterTableFunction("C_NamePlate", "GetNamePlateGUIDs",
&Script_GetNamePlateGUIDs);
+ Game::Lua::RegisterTableFunction("C_NamePlate", "GetNamePlateForUnit",
+ &Script_GetNamePlateForUnit);
}
static const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
From c17d4d2de5e0c441a312704f2beacea221ef0447 Mon Sep 17 00:00:00 2001
From: Brues <5278969+brues-code@users.noreply.github.com>
Date: Tue, 26 May 2026 02:17:34 -0500
Subject: [PATCH 06/15] nameplate events
---
README.md | 3 ++
docs/API.md | 51 ++++++++++++++++++
src/nameplate/Events.cpp | 114 +++++++++++++++++++++++++++++++++++++++
src/nameplate/Info.cpp | 59 ++------------------
src/nameplate/Walk.h | 83 ++++++++++++++++++++++++++++
5 files changed, 254 insertions(+), 56 deletions(-)
create mode 100644 src/nameplate/Events.cpp
create mode 100644 src/nameplate/Walk.h
diff --git a/README.md b/README.md
index 31931e5..e32a5bd 100644
--- a/README.md
+++ b/README.md
@@ -95,6 +95,9 @@ functions, just behavior the stock 1.12 engine didn't have. See the
| `HEARTHSTONE_BOUND` | *(none)* |
| `ITEM_DATA_LOAD_RESULT` | `itemID, success` |
| `MODIFIER_STATE_CHANGED` | `keyName, down` |
+| `NAME_PLATE_CREATED` | `unitGUID` |
+| `NAME_PLATE_UNIT_ADDED` | `unitGUID` |
+| `NAME_PLATE_UNIT_REMOVED` | `unitGUID` |
| `PLAYER_STARTED_LOOKING` | *(none)* |
| `PLAYER_STOPPED_LOOKING` | *(none)* |
| `PLAYER_STARTED_MOVING` | *(none)* |
diff --git a/docs/API.md b/docs/API.md
index 59e60fc..66859b0 100644
--- a/docs/API.md
+++ b/docs/API.md
@@ -73,6 +73,7 @@ build instructions.
- [`EQUIPMENT_SWAP_FINISHED` event](#equipment_swap_finished-event)
- [`FACTION_STANDING_CHANGED` event](#faction_standing_changed-event)
- [`MODIFIER_STATE_CHANGED` event](#modifier_state_changed-event)
+ - [`NAME_PLATE_CREATED` / `NAME_PLATE_UNIT_ADDED` / `NAME_PLATE_UNIT_REMOVED` events](#name_plate_created--name_plate_unit_added--name_plate_unit_removed-events)
- [`QUEST_ACCEPTED` event](#quest_accepted-event)
- [`QUEST_TURNED_IN` event](#quest_turned_in-event)
- [`UPDATE_SHAPESHIFT_FORM` event](#update_shapeshift_form-event)
@@ -1572,6 +1573,56 @@ renderer-state changes that recreate WoW's main window (e.g. toggling
vertical sync), where an `SetWindowLongPtr`-style `WNDPROC` subclass
would be left dangling.
+### `NAME_PLATE_CREATED` / `NAME_PLATE_UNIT_ADDED` / `NAME_PLATE_UNIT_REMOVED` events
+
+Fire when nameplate state actually changes. All three carry a single
+payload — the **unit GUID string** (modern `"0xHHHHHHHHHHHHHHHH"`
+format).
+
+| Event | When it fires |
+|-------|---------------|
+| `NAME_PLATE_CREATED` | First time we surface a particular `CGNamePlateFrame` pointer. Same frame re-used for a later unit (pool recycle) does NOT refire. |
+| `NAME_PLATE_UNIT_ADDED` | Unit gets a visible nameplate (entered nameplate range, became hostile, etc.). |
+| `NAME_PLATE_UNIT_REMOVED` | Unit's nameplate is gone (left range, despawned, etc.). |
+
+```lua
+local f = CreateFrame("Frame")
+f:RegisterEvent("NAME_PLATE_UNIT_ADDED")
+f:RegisterEvent("NAME_PLATE_UNIT_REMOVED")
+f:SetScript("OnEvent", function()
+ -- arg1 = unit GUID string ("0xF13000C36C26FD02", etc.)
+ if event == "NAME_PLATE_UNIT_ADDED" then
+ -- skin the nameplate, register tracking, etc.
+ end
+end)
+```
+
+> **Modern divergence.** Retail passes `"nameplateN"` unit tokens to
+> ADDED/REMOVED and the nameplate `Frame` to CREATED. We don't expose
+> nameplate tokens (the engine's resolver isn't extensible without
+> hooking it), and the event dispatcher can't push a Frame as
+> payload — so all three events use the GUID string. Addons that
+> need the Frame call
+> [`C_NamePlate.GetNamePlateForUnit`](#c_nameplategetnameplateforunitunittoken)
+> reactively, using the GUID via a custom token-resolution path or
+> matching against `GetNamePlateGUIDs()`.
+
+**Implementation notes**
+
+Detected by per-frame polling, not engine hooks. Each world tick we
+walk the object hash for nameplated units and diff against the
+previous tick's snapshot. Modern WoW also synthesizes these via
+diffing (the underlying engine has no event for "plate state
+changed"). The cost is ~20-50µs/frame even in busy raids — well
+below noise.
+
+The diff approach absorbs the engine's transient hide/reshow cycle:
+vanilla has ~7 code paths that briefly zero `unit + 0xE60` (z-order
+rebuilds, anchor changes, flag-change re-eval) and the next frame's
+show path re-allocates from the pool. Those transient zeroes never
+become events because the unit appears in both the previous and
+current tick's snapshot.
+
### `QUEST_ACCEPTED` event
Fires once per quest the player just accepted, with two payload args:
diff --git a/src/nameplate/Events.cpp b/src/nameplate/Events.cpp
new file mode 100644
index 0000000..ddec6e7
--- /dev/null
+++ b/src/nameplate/Events.cpp
@@ -0,0 +1,114 @@
+// This file is part of ClassicAPI.
+//
+// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
+// of the GNU Lesser General Public License as published by the Free Software Foundation, either
+// version 3 of the License, or (at your option) any later version.
+//
+// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
+// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
+// PURPOSE. See the GNU Lesser General Public License for more details.
+//
+// You should have received a copy of the GNU Lesser General Public License along with
+// ClassicAPI. If not, see .
+
+// Nameplate lifecycle events: NAME_PLATE_CREATED, NAME_PLATE_UNIT_ADDED,
+// NAME_PLATE_UNIT_REMOVED.
+//
+// Per-frame poll: walk visible CGUnits with allocated nameplates, diff
+// against last frame's snapshot, fire events on real transitions only.
+// The engine's internal hide/show cycle (z-order rebuilds, anchor
+// changes, ~7 callers of `FUN_00608A10` that transiently zero `+0xE60`)
+// is absorbed because we only compare with the previous *frame's*
+// state, not every transient `unit + 0xE60` write.
+//
+// Events fire with the unit's GUID string as payload — vanilla has no
+// `"nameplateN"` tokens (engine resolver isn't extensible) and the
+// event dispatcher's format codes don't include a "push frame" type.
+// Addons that need the frame call
+// `C_NamePlate.GetNamePlateForUnit()`
+// reactively.
+
+#include "Game.h"
+#include "Offsets.h"
+#include "event/Custom.h"
+#include "guid/Guid.h"
+#include "nameplate/Walk.h"
+#include "tick/WorldTick.h"
+
+#include
+#include
+#include
+
+namespace NamePlate::Events {
+
+namespace {
+
+constexpr const char *kEventCreated = "NAME_PLATE_CREATED";
+constexpr const char *kEventUnitAdded = "NAME_PLATE_UNIT_ADDED";
+constexpr const char *kEventUnitRemoved = "NAME_PLATE_UNIT_REMOVED";
+
+const Event::Custom::AutoReserve _r1{kEventCreated};
+const Event::Custom::AutoReserve _r2{kEventUnitAdded};
+const Event::Custom::AutoReserve _r3{kEventUnitRemoved};
+
+// Previous tick's snapshot — GUID → nameplate-frame pointer for each
+// nameplated unit. Compared against the next tick's walk to compute
+// ADDED/REMOVED diffs.
+std::unordered_map g_lastTickPlates;
+
+// Scratch map reused each tick, swapped into `g_lastTickPlates` at
+// the end. File-static so we don't pay the constructor/destructor
+// cycle every frame — `clear()` keeps the existing bucket capacity.
+std::unordered_map g_currentTickPlates;
+
+// Frame pointers we've ever surfaced as nameplate plates. First
+// sighting fires NAME_PLATE_CREATED; same pointer reappearing (pool
+// reuse) doesn't refire.
+std::unordered_set g_seenPlates;
+
+void FireWithGUID(const char *eventName, uint64_t guid) {
+ if (guid == 0)
+ return;
+ const int slot = Event::Custom::Lookup(eventName);
+ if (slot < 0)
+ return;
+ char buf[Guid::STRING_SIZE];
+ Guid::FormatAsString(guid, buf, sizeof buf);
+ Event::Custom::Fire(slot, "%s", buf);
+}
+
+void OnWorldTick() {
+ g_currentTickPlates.clear();
+ g_currentTickPlates.reserve(64); // typical visible-nameplate ceiling
+ NamePlate::Walk::ForEachNamePlatedUnit(
+ [](const uint8_t *, const uint8_t *nameplate,
+ const uint8_t *instance) {
+ const uint64_t guid = *reinterpret_cast(instance);
+ if (guid == 0)
+ return;
+ g_currentTickPlates.emplace(guid, nameplate);
+ });
+
+ // Fire CREATED for never-before-seen frame pointers, ADDED for
+ // GUIDs not in last tick's snapshot.
+ for (const auto &kv : g_currentTickPlates) {
+ if (g_seenPlates.insert(kv.second).second)
+ FireWithGUID(kEventCreated, kv.first);
+ if (g_lastTickPlates.find(kv.first) == g_lastTickPlates.end())
+ FireWithGUID(kEventUnitAdded, kv.first);
+ }
+
+ // Fire REMOVED for GUIDs in last tick's snapshot but not current.
+ for (const auto &kv : g_lastTickPlates) {
+ if (g_currentTickPlates.find(kv.first) == g_currentTickPlates.end())
+ FireWithGUID(kEventUnitRemoved, kv.first);
+ }
+
+ g_lastTickPlates.swap(g_currentTickPlates);
+}
+
+} // namespace
+
+static const Tick::WorldTick::AutoSubscribe _tickSub{&OnWorldTick};
+
+} // namespace NamePlate::Events
diff --git a/src/nameplate/Info.cpp b/src/nameplate/Info.cpp
index 1d146f3..2362cd4 100644
--- a/src/nameplate/Info.cpp
+++ b/src/nameplate/Info.cpp
@@ -55,6 +55,7 @@
#include "Game.h"
#include "Offsets.h"
#include "guid/Guid.h"
+#include "nameplate/Walk.h"
#include
@@ -62,16 +63,8 @@ namespace NamePlate::Info {
namespace {
-constexpr uintptr_t kLocalPlayerGlobal = 0x00B41414;
-constexpr int kOffPlayerBucketArray = 0x1C;
-constexpr int kOffPlayerBucketMask = 0x24;
-constexpr int kBucketStride = 12;
-constexpr int kBucketLinkOffsetField = 0; // byte 0: link-field offset within entry
-constexpr int kBucketChainHeadField = 8; // byte 8: chain head pointer
-constexpr int kOffEntryInstanceBlock = 0x08;
-constexpr int kOffInstanceTypeMask = 0x08;
-constexpr uint32_t kTypeMaskUnit = 0x08;
-constexpr int kOffUnitNamePlate = 0xE60;
+using NamePlate::Walk::ForEachNamePlatedUnit;
+using NamePlate::Walk::kOffUnitNamePlate;
using LuaRawGetI_t = void(__fastcall *)(void *L, int idx, int n);
using LuaPushLightUserdata_t = void(__fastcall *)(void *L, void *p);
@@ -106,52 +99,6 @@ void PushFreshFrameWrapper(void *L, void *frame) {
setMetatable(L, -2);
}
-// Walk visible units with allocated nameplates, invoking `emit(unit,
-// nameplate, instance)` for each. Returns the number of emissions.
-template
-int ForEachNamePlatedUnit(F &&emit) {
- auto *player = *reinterpret_cast(kLocalPlayerGlobal);
- if (player == nullptr)
- return 0;
-
- auto *buckets = *reinterpret_cast(
- player + kOffPlayerBucketArray);
- const uint32_t mask = *reinterpret_cast(
- player + kOffPlayerBucketMask);
- if (buckets == nullptr || mask == 0xFFFFFFFFu)
- return 0;
-
- int count = 0;
- for (uint32_t b = 0; b <= mask; ++b) {
- const uint8_t *bucket = buckets + b * kBucketStride;
- const uint32_t linkOffset = *reinterpret_cast(
- bucket + kBucketLinkOffsetField);
- uintptr_t entry = *reinterpret_cast(
- bucket + kBucketChainHeadField);
-
- while (entry != 0 && (entry & 1) == 0) {
- auto *obj = reinterpret_cast(entry);
- auto *instance = *reinterpret_cast(
- obj + kOffEntryInstanceBlock);
- if (instance != nullptr) {
- const uint32_t typeMask = *reinterpret_cast(
- instance + kOffInstanceTypeMask);
- if ((typeMask & kTypeMaskUnit) != 0) {
- const auto *nameplate = *reinterpret_cast(
- obj + kOffUnitNamePlate);
- if (nameplate != nullptr) {
- emit(obj, nameplate, instance);
- ++count;
- }
- }
- }
- entry = *reinterpret_cast(
- obj + linkOffset + 4);
- }
- }
- return count;
-}
-
} // namespace
static int __fastcall Script_GetNamePlates(void *L) {
diff --git a/src/nameplate/Walk.h b/src/nameplate/Walk.h
new file mode 100644
index 0000000..6a92d66
--- /dev/null
+++ b/src/nameplate/Walk.h
@@ -0,0 +1,83 @@
+// This file is part of ClassicAPI.
+//
+// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
+// of the GNU Lesser General Public License as published by the Free Software Foundation, either
+// version 3 of the License, or (at your option) any later version.
+//
+// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
+// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
+// PURPOSE. See the GNU Lesser General Public License for more details.
+//
+// You should have received a copy of the GNU Lesser General Public License along with
+// ClassicAPI. If not, see .
+
+#pragma once
+
+#include
+
+// Shared nameplate enumeration — walks the local-player-anchored
+// object hash table for visible `TYPEMASK_UNIT` entries and invokes
+// `emit(unit, nameplate, instance)` for each unit that currently has
+// an allocated nameplate (`unit + 0xE60` non-null).
+//
+// Header-only template — used by both the Lua-facing accessors in
+// `nameplate/Info.cpp` and the per-tick differ in `nameplate/Events.cpp`.
+
+namespace NamePlate::Walk {
+
+constexpr uintptr_t kLocalPlayerGlobal = 0x00B41414;
+constexpr int kOffPlayerBucketArray = 0x1C;
+constexpr int kOffPlayerBucketMask = 0x24;
+constexpr int kBucketStride = 12;
+constexpr int kBucketLinkOffsetField = 0;
+constexpr int kBucketChainHeadField = 8;
+constexpr int kOffEntryInstanceBlock = 0x08;
+constexpr int kOffInstanceTypeMask = 0x08;
+constexpr uint32_t kTypeMaskUnit = 0x08;
+constexpr int kOffUnitNamePlate = 0xE60;
+
+template
+int ForEachNamePlatedUnit(F &&emit) {
+ auto *player = *reinterpret_cast(kLocalPlayerGlobal);
+ if (player == nullptr)
+ return 0;
+
+ auto *buckets = *reinterpret_cast(
+ player + kOffPlayerBucketArray);
+ const uint32_t mask = *reinterpret_cast(
+ player + kOffPlayerBucketMask);
+ if (buckets == nullptr || mask == 0xFFFFFFFFu)
+ return 0;
+
+ int count = 0;
+ for (uint32_t b = 0; b <= mask; ++b) {
+ const uint8_t *bucket = buckets + b * kBucketStride;
+ const uint32_t linkOffset = *reinterpret_cast(
+ bucket + kBucketLinkOffsetField);
+ uintptr_t entry = *reinterpret_cast(
+ bucket + kBucketChainHeadField);
+
+ while (entry != 0 && (entry & 1) == 0) {
+ auto *obj = reinterpret_cast(entry);
+ auto *instance = *reinterpret_cast(
+ obj + kOffEntryInstanceBlock);
+ if (instance != nullptr) {
+ const uint32_t typeMask = *reinterpret_cast(
+ instance + kOffInstanceTypeMask);
+ if ((typeMask & kTypeMaskUnit) != 0) {
+ const auto *nameplate = *reinterpret_cast(
+ obj + kOffUnitNamePlate);
+ if (nameplate != nullptr) {
+ emit(obj, nameplate, instance);
+ ++count;
+ }
+ }
+ }
+ entry = *reinterpret_cast(
+ obj + linkOffset + 4);
+ }
+ }
+ return count;
+}
+
+} // namespace NamePlate::Walk
From 4140d1b6cbba47eab8d2079ba10f11804a307606 Mon Sep 17 00:00:00 2001
From: Brues <5278969+brues-code@users.noreply.github.com>
Date: Tue, 26 May 2026 02:24:02 -0500
Subject: [PATCH 07/15] C_NamePlate.GetNamePlateForGUID
---
README.md | 2 +-
docs/API.md | 23 +++++++++++++++
src/nameplate/Info.cpp | 66 ++++++++++++++++++++++++++++--------------
3 files changed, 68 insertions(+), 23 deletions(-)
diff --git a/README.md b/README.md
index e32a5bd..fdc9060 100644
--- a/README.md
+++ b/README.md
@@ -39,7 +39,7 @@ Full per-function reference: **[docs/API.md](docs/API.md)**.
| [Macros](docs/API.md#macros) | `GetMacroSpell` |
| [Map](docs/API.md#map) | `C_Map.GetBestMapForUnit` |
| [MerchantFrame](docs/API.md#merchantframe) | `C_MerchantFrame.GetBuybackItemID`, `C_MerchantFrame.GetItemInfo`, `C_MerchantFrame.GetNumJunkItems`, `C_MerchantFrame.IsMerchantItemRefundable`, `C_MerchantFrame.IsSellAllJunkEnabled`, `C_MerchantFrame.SellAllJunkItems` |
-| [NamePlate](docs/API.md#nameplate) | `C_NamePlate.GetNamePlateForUnit`, `C_NamePlate.GetNamePlateGUIDs`, `C_NamePlate.GetNamePlates` |
+| [NamePlate](docs/API.md#nameplate) | `C_NamePlate.GetNamePlateForGUID`, `C_NamePlate.GetNamePlateForUnit`, `C_NamePlate.GetNamePlateGUIDs`, `C_NamePlate.GetNamePlates` |
| [NameCache](docs/API.md#namecache) | `C_CreatureInfo.GetCreatureID`, `C_PlayerCache.GetPlayerInfoByName`, `C_PlayerCache.IsEnabled`, `C_PlayerCache.IsScanEnabled`, `C_PlayerCache.RememberPlayer`, `C_PlayerCache.SetEnabled`, `C_PlayerCache.SetScanEnabled`, `C_PlayerInfo.GUIDIsCreature`, `C_PlayerInfo.GUIDIsGameObject`, `C_PlayerInfo.GUIDIsPet`, `C_PlayerInfo.GUIDIsPlayer`, `GetPlayerInfoByGUID` |
| [Quest](docs/API.md#quest) | `C_QuestLog.GetQuestIDForLogIndex`, `C_QuestLog.GetTitleForQuestID`, `C_QuestLog.IsOnQuest`, `C_QuestLog.IsQuestDataCachedByID`, `C_QuestLog.IsUnitOnQuest`, `C_QuestLog.RequestLoadQuestByID`, `GetQuestLogLeaderBoardID` |
| [Spell](docs/API.md#spell) | `C_Spell.CancelSpellByID`, `C_Spell.DoesSpellExist`, `C_Spell.GetSchoolString`, `C_Spell.GetSpellCooldown`, `C_Spell.GetSpellDescription`, `C_Spell.GetSpellInfo`, `C_Spell.GetSpellLink`, `C_Spell.GetSpellName`, `C_Spell.GetSpellReagents`, `C_Spell.GetSpellSubtext`, `C_Spell.GetSpellTexture`, `C_Spell.IsAutoAttackSpell`, `C_Spell.IsCurrentSpell`, `C_Spell.IsRangedAutoAttackSpell`, `C_Spell.IsSelfBuff`, `C_Spell.IsSpellHarmful`, `C_Spell.IsSpellHelpful`, `C_Spell.IsSpellPassive`, `C_Spell.IsSpellUsable`, `C_Spell.SpellHasRange`, `CancelSpellByName`, `CastSpellNoToggle`, `GetCraftSpellID`, `GetSpellInfo`, `GetSpellLink`, `GetSpellSchool`, `IsHarmfulSpell`, `IsHelpfulSpell`, `IsPassiveSpell`, `IsPlayerSpell`, `IsSpellKnown`, `IsUsableSpell`, `SpellHasRange` |
diff --git a/docs/API.md b/docs/API.md
index 66859b0..bfc8535 100644
--- a/docs/API.md
+++ b/docs/API.md
@@ -200,6 +200,7 @@ build instructions.
- [`C_NamePlate.GetNamePlates()`](#c_nameplategetnameplates)
- [`C_NamePlate.GetNamePlateGUIDs()`](#c_nameplategetnameplateguids)
- [`C_NamePlate.GetNamePlateForUnit(unitToken)`](#c_nameplategetnameplateforunitunittoken)
+ - [`C_NamePlate.GetNamePlateForGUID(guidString)`](#c_nameplategetnameplateforguidguidstring)
- [NameCache](#namecache)
- [`GetPlayerInfoByGUID(guid)`](#getplayerinfobyguidguid)
@@ -4468,6 +4469,28 @@ addon-created plates return their cached wrapper, default vanilla
plates get a fresh per-call wrapper. Don't cache the result across
the unit going out of range.
+### `C_NamePlate.GetNamePlateForGUID(guidString)`
+
+Same as `GetNamePlateForUnit` but takes the `"0xHHHHHHHHHHHHHHHH"`
+GUID-string form. Designed to pair with the
+[NAME_PLATE_UNIT_ADDED / REMOVED](#name_plate_created--name_plate_unit_added--name_plate_unit_removed-events)
+events, whose payload is the unit GUID rather than a token.
+
+```lua
+local f = CreateFrame("Frame")
+f:RegisterEvent("NAME_PLATE_UNIT_ADDED")
+f:SetScript("OnEvent", function()
+ -- arg1 = GUID string
+ local plate = C_NamePlate.GetNamePlateForGUID(arg1)
+ if plate then
+ -- ... skin / style plate ...
+ end
+end)
+```
+
+Returns `nil` if the GUID doesn't parse, doesn't resolve to a
+visible CGUnit, or the unit has no allocated nameplate.
+
### `C_NamePlate.GetNamePlateGUIDs()`
Returns a 1-based table of GUID strings (modern
diff --git a/src/nameplate/Info.cpp b/src/nameplate/Info.cpp
index 2362cd4..7a8b1e5 100644
--- a/src/nameplate/Info.cpp
+++ b/src/nameplate/Info.cpp
@@ -158,12 +158,39 @@ static void PushNamePlateFrame(void *L, void *nameplate) {
}
}
+// GUID → nameplate Frame pushed on stack. Pushes nil if the GUID
+// doesn't resolve to a visible CGUnit, or the unit has no allocated
+// nameplate.
+static void PushNamePlateForGUID(void *L, uint64_t guid) {
+ if (guid == 0) {
+ Game::Lua::PushNil(L);
+ return;
+ }
+ auto resolve = reinterpret_cast(
+ static_cast(Offsets::FUN_OBJECT_RESOLVE_BY_GUID));
+ auto *unit = static_cast(
+ resolve(Offsets::OBJ_TYPE_UNIT, "NamePlate",
+ static_cast(guid),
+ static_cast(guid >> 32),
+ 0x172));
+ if (unit == nullptr) {
+ Game::Lua::PushNil(L);
+ return;
+ }
+ auto *nameplate = *reinterpret_cast(
+ unit + kOffUnitNamePlate);
+ if (nameplate == nullptr) {
+ Game::Lua::PushNil(L);
+ return;
+ }
+ PushNamePlateFrame(L, nameplate);
+}
+
// `C_NamePlate.GetNamePlateForUnit(unitToken)` — returns the
// nameplate Frame for the given unit, or `nil` if the unit has no
// nameplate (out of range, hidden, etc.). Resolves the token to a
// GUID via the engine's `FUN_TOKEN_TO_GUID` so distant party/raid
-// members can be queried, then looks up the CGUnit via the object
-// hash and reads its `+0xE60` nameplate pointer.
+// members can be queried.
static int __fastcall Script_GetNamePlateForUnit(void *L) {
if (!Game::Lua::IsString(L, 1)) {
Game::Lua::PushNil(L);
@@ -174,35 +201,28 @@ static int __fastcall Script_GetNamePlateForUnit(void *L) {
Game::Lua::PushNil(L);
return 1;
}
-
auto tokenToGuid = reinterpret_cast(
static_cast(Offsets::FUN_TOKEN_TO_GUID));
- const uint64_t guid = tokenToGuid(token);
- if (guid == 0) {
- Game::Lua::PushNil(L);
- return 1;
- }
+ PushNamePlateForGUID(L, tokenToGuid(token));
+ return 1;
+}
- auto resolve = reinterpret_cast(
- static_cast(Offsets::FUN_OBJECT_RESOLVE_BY_GUID));
- auto *unit = static_cast(
- resolve(Offsets::OBJ_TYPE_UNIT, "NamePlate",
- static_cast(guid),
- static_cast(guid >> 32),
- 0x172));
- if (unit == nullptr) {
+// `C_NamePlate.GetNamePlateForGUID(guidString)` — same as
+// `GetNamePlateForUnit` but takes a `"0xHHHHHHHHHHHHHHHH"` GUID
+// string. Useful for handling the `NAME_PLATE_UNIT_ADDED` /
+// `_REMOVED` events whose payload is a GUID, not a unit token.
+static int __fastcall Script_GetNamePlateForGUID(void *L) {
+ if (!Game::Lua::IsString(L, 1)) {
Game::Lua::PushNil(L);
return 1;
}
-
- auto *nameplate = *reinterpret_cast(
- unit + kOffUnitNamePlate);
- if (nameplate == nullptr) {
+ const char *guidStr = Game::Lua::ToString(L, 1);
+ uint64_t guid = 0;
+ if (guidStr == nullptr || !Guid::Parse(guidStr, &guid)) {
Game::Lua::PushNil(L);
return 1;
}
-
- PushNamePlateFrame(L, nameplate);
+ PushNamePlateForGUID(L, guid);
return 1;
}
@@ -213,6 +233,8 @@ static void RegisterLuaFunctions() {
&Script_GetNamePlateGUIDs);
Game::Lua::RegisterTableFunction("C_NamePlate", "GetNamePlateForUnit",
&Script_GetNamePlateForUnit);
+ Game::Lua::RegisterTableFunction("C_NamePlate", "GetNamePlateForGUID",
+ &Script_GetNamePlateForGUID);
}
static const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
From 3b01601cb79188c500f91ba2d44157a59766b64e Mon Sep 17 00:00:00 2001
From: Brues <5278969+brues-code@users.noreply.github.com>
Date: Tue, 26 May 2026 12:22:48 -0500
Subject: [PATCH 08/15] NAME_PLATE_CREATED now sends frame as argument
---
README.md | 2 +-
docs/API.md | 45 ++++++++++++++----------
src/Offsets.h | 8 +++++
src/nameplate/Events.cpp | 75 +++++++++++++++++++++++++++++++++++-----
src/nameplate/Info.cpp | 7 ++--
src/nameplate/Walk.h | 10 ++++++
6 files changed, 115 insertions(+), 32 deletions(-)
diff --git a/README.md b/README.md
index fdc9060..7de4d4f 100644
--- a/README.md
+++ b/README.md
@@ -95,7 +95,7 @@ functions, just behavior the stock 1.12 engine didn't have. See the
| `HEARTHSTONE_BOUND` | *(none)* |
| `ITEM_DATA_LOAD_RESULT` | `itemID, success` |
| `MODIFIER_STATE_CHANGED` | `keyName, down` |
-| `NAME_PLATE_CREATED` | `unitGUID` |
+| `NAME_PLATE_CREATED` | `nameplateFrame` |
| `NAME_PLATE_UNIT_ADDED` | `unitGUID` |
| `NAME_PLATE_UNIT_REMOVED` | `unitGUID` |
| `PLAYER_STARTED_LOOKING` | *(none)* |
diff --git a/docs/API.md b/docs/API.md
index bfc8535..2de6dee 100644
--- a/docs/API.md
+++ b/docs/API.md
@@ -1576,37 +1576,44 @@ would be left dangling.
### `NAME_PLATE_CREATED` / `NAME_PLATE_UNIT_ADDED` / `NAME_PLATE_UNIT_REMOVED` events
-Fire when nameplate state actually changes. All three carry a single
-payload — the **unit GUID string** (modern `"0xHHHHHHHHHHHHHHHH"`
-format).
+Fire when nameplate state actually changes. Payloads:
-| Event | When it fires |
-|-------|---------------|
-| `NAME_PLATE_CREATED` | First time we surface a particular `CGNamePlateFrame` pointer. Same frame re-used for a later unit (pool recycle) does NOT refire. |
-| `NAME_PLATE_UNIT_ADDED` | Unit gets a visible nameplate (entered nameplate range, became hostile, etc.). |
-| `NAME_PLATE_UNIT_REMOVED` | Unit's nameplate is gone (left range, despawned, etc.). |
+| Event | `arg1` | Notes |
+|-------|--------|-------|
+| `NAME_PLATE_CREATED` | nameplate **Frame** | Matches modern WoW. Fires once per unique `CGNamePlateFrame` pointer — same frame re-used via pool recycle does NOT refire. |
+| `NAME_PLATE_UNIT_ADDED` | unit **GUID string** | Modern uses a `"nameplateN"` token; vanilla's token resolver isn't extensible. Pass `arg1` to [`GetNamePlateForGUID`](#c_nameplategetnameplateforguidguidstring) for the frame. |
+| `NAME_PLATE_UNIT_REMOVED` | unit **GUID string** | Same as above. |
```lua
local f = CreateFrame("Frame")
+f:RegisterEvent("NAME_PLATE_CREATED")
f:RegisterEvent("NAME_PLATE_UNIT_ADDED")
f:RegisterEvent("NAME_PLATE_UNIT_REMOVED")
f:SetScript("OnEvent", function()
- -- arg1 = unit GUID string ("0xF13000C36C26FD02", etc.)
- if event == "NAME_PLATE_UNIT_ADDED" then
- -- skin the nameplate, register tracking, etc.
+ if event == "NAME_PLATE_CREATED" then
+ -- arg1 = the nameplate Frame itself
+ arg1:SetAlpha(0.8)
+ elseif event == "NAME_PLATE_UNIT_ADDED" then
+ -- arg1 = unit GUID string ("0xF13000C36C26FD02", etc.)
+ local plate = C_NamePlate.GetNamePlateForGUID(arg1)
+ -- ... style based on the unit ...
end
end)
```
+> **CREATED timing with nameplate addons (pfUI / TidyPlates / etc).**
+> The event fires when the *engine* allocates the underlying
+> `CGNamePlateFrame`. Nameplate-mod addons typically decorate the
+> frame on their own per-frame update — so `arg1` at `CREATED` time
+> is a bare frame with no addon-side decorations yet. For
+> unit-specific work after the addon has decorated, use
+> `NAME_PLATE_UNIT_ADDED` (fires next tick at the latest) or fetch
+> the current frame on-demand via `GetNamePlateForGUID`.
+
> **Modern divergence.** Retail passes `"nameplateN"` unit tokens to
-> ADDED/REMOVED and the nameplate `Frame` to CREATED. We don't expose
-> nameplate tokens (the engine's resolver isn't extensible without
-> hooking it), and the event dispatcher can't push a Frame as
-> payload — so all three events use the GUID string. Addons that
-> need the Frame call
-> [`C_NamePlate.GetNamePlateForUnit`](#c_nameplategetnameplateforunitunittoken)
-> reactively, using the GUID via a custom token-resolution path or
-> matching against `GetNamePlateGUIDs()`.
+> `ADDED`/`REMOVED`. Vanilla's token resolver isn't extensible, so
+> we ship the GUID string instead and let addons round-trip through
+> `GetNamePlateForGUID` to reach the frame.
**Implementation notes**
diff --git a/src/Offsets.h b/src/Offsets.h
index 104c382..0f33f08 100644
--- a/src/Offsets.h
+++ b/src/Offsets.h
@@ -2089,6 +2089,14 @@ enum Offsets {
// to L->top → `add [ecx+8], 0x10` incr_top). `0x6F32B0` is
// `lua_replace` (single TValue copy + decr_top).
LUA_PUSH_VALUE = 0x6F3350,
+ // `luaL_ref(L, t)` — pops the top, stores it in the table at `t`
+ // at a freshly-allocated integer key, returns the key. Use with
+ // `LUA_REGISTRY_INDEX` to stash Lua values across C-side scopes;
+ // pair with `LUA_REF_UNREF` to release.
+ LUA_REF_REF = 0x6F5310,
+ // `luaL_unref(L, t, ref)` — releases a ref previously returned
+ // by `luaL_ref`, freeing the slot for future allocations.
+ LUA_REF_UNREF = 0x6F5400,
LUA_PUSH_CCLOSURE = 0x6F3920,
LUA_NEW_TABLE = 0x6F3C90,
LUA_GET_TABLE = 0x6F3A40, // (was 0x6F3EA0, which is lua_rawset)
diff --git a/src/nameplate/Events.cpp b/src/nameplate/Events.cpp
index ddec6e7..4bde3e1 100644
--- a/src/nameplate/Events.cpp
+++ b/src/nameplate/Events.cpp
@@ -21,12 +21,18 @@
// is absorbed because we only compare with the previous *frame's*
// state, not every transient `unit + 0xE60` write.
//
-// Events fire with the unit's GUID string as payload — vanilla has no
-// `"nameplateN"` tokens (engine resolver isn't extensible) and the
-// event dispatcher's format codes don't include a "push frame" type.
-// Addons that need the frame call
-// `C_NamePlate.GetNamePlateForUnit()`
-// reactively.
+// Payload shape:
+// - `NAME_PLATE_CREATED` — `arg1` is the nameplate **Frame** (matches
+// modern WoW). The engine's printf-style dispatcher
+// (`FUN_FIRE_EVENT`) only knows `%s`/`%d`/`%u`/`%f` format codes
+// with no "push Lua value" option, so we route this event through
+// a pre-set path: save current `_G.arg1`, set it to the frame, fire
+// with empty format (dispatcher leaves `_G.arg` alone when no
+// codes are parsed), restore.
+// - `NAME_PLATE_UNIT_ADDED` / `_REMOVED` — `arg1` is the unit GUID
+// string. Modern uses a `"nameplateN"` token; vanilla's token
+// resolver isn't extensible. Addons call
+// `C_NamePlate.GetNamePlateForGUID(arg1)` to get the frame.
#include "Game.h"
#include "Offsets.h"
@@ -77,6 +83,56 @@ void FireWithGUID(const char *eventName, uint64_t guid) {
Event::Custom::Fire(slot, "%s", buf);
}
+// Fire `eventName` with the nameplate `Frame` set as `_G.arg1`.
+// `FUN_FIRE_EVENT`'s format-string parser only handles primitive
+// types, but it only mutates `_G.arg` for codes it actually
+// parses. With an empty format we pre-set `_G.arg1` and the
+// dispatcher leaves it alone. Restore the previous value after
+// fire so we don't leak our frame into unrelated global state.
+//
+// Lua-stack-clean: stack depth on entry == stack depth on exit.
+using LuaRefRef_t = int(__fastcall *)(void *L, int t);
+using LuaRefUnref_t = void(__fastcall *)(void *L, int t, int ref);
+using LuaRawGetI_t = void(__fastcall *)(void *L, int t, int n);
+
+void FireWithFrame(const char *eventName, void *frame) {
+ if (frame == nullptr)
+ return;
+ const int slot = Event::Custom::Lookup(eventName);
+ if (slot < 0)
+ return;
+
+ void *L = Game::Lua::State();
+ if (L == nullptr)
+ return;
+
+ auto refRef = reinterpret_cast(
+ static_cast(Offsets::LUA_REF_REF));
+ auto refUnref = reinterpret_cast(
+ static_cast(Offsets::LUA_REF_UNREF));
+ auto rawgeti = reinterpret_cast(
+ Offsets::FUN_FRAMESCRIPT_PUSH_OBJECT);
+
+ // Save current `_G.arg1` to the registry.
+ Game::Lua::PushString(L, "arg1");
+ Game::Lua::GetTable(L, Game::Lua::GLOBALS_INDEX);
+ const int savedRef = refRef(L, Game::Lua::REGISTRY_INDEX);
+
+ // Set `_G.arg1 = frame`.
+ Game::Lua::PushString(L, "arg1");
+ NamePlate::Info::PushNamePlateFrame(L, frame);
+ Game::Lua::SetTable(L, Game::Lua::GLOBALS_INDEX);
+
+ // Fire — empty format, so the dispatcher doesn't touch `arg1`.
+ Event::Custom::Fire(slot, "");
+
+ // Restore previous `_G.arg1` from the saved registry ref.
+ Game::Lua::PushString(L, "arg1");
+ rawgeti(L, Game::Lua::REGISTRY_INDEX, savedRef);
+ Game::Lua::SetTable(L, Game::Lua::GLOBALS_INDEX);
+ refUnref(L, Game::Lua::REGISTRY_INDEX, savedRef);
+}
+
void OnWorldTick() {
g_currentTickPlates.clear();
g_currentTickPlates.reserve(64); // typical visible-nameplate ceiling
@@ -89,11 +145,12 @@ void OnWorldTick() {
g_currentTickPlates.emplace(guid, nameplate);
});
- // Fire CREATED for never-before-seen frame pointers, ADDED for
- // GUIDs not in last tick's snapshot.
+ // Fire CREATED (with the Frame as arg1) for never-before-seen
+ // frame pointers; ADDED (with GUID string) for GUIDs not in last
+ // tick's snapshot.
for (const auto &kv : g_currentTickPlates) {
if (g_seenPlates.insert(kv.second).second)
- FireWithGUID(kEventCreated, kv.first);
+ FireWithFrame(kEventCreated, const_cast(kv.second));
if (g_lastTickPlates.find(kv.first) == g_lastTickPlates.end())
FireWithGUID(kEventUnitAdded, kv.first);
}
diff --git a/src/nameplate/Info.cpp b/src/nameplate/Info.cpp
index 7a8b1e5..8c61738 100644
--- a/src/nameplate/Info.cpp
+++ b/src/nameplate/Info.cpp
@@ -144,9 +144,10 @@ static int __fastcall Script_GetNamePlateGUIDs(void *L) {
return 1;
}
-// Pushes a nameplate Frame onto the stack — registered or fresh
-// wrapper depending on whether the engine assigned a real refKey.
-static void PushNamePlateFrame(void *L, void *nameplate) {
+// Exported via `nameplate/Walk.h` so `Events.cpp` can reuse the same
+// frame-push path. Internal callers (Script_GetNamePlates etc.) call
+// it through the unqualified name (they live in the same namespace).
+void PushNamePlateFrame(void *L, void *nameplate) {
const int refKey = *reinterpret_cast(
static_cast(nameplate) + Offsets::OFF_COBJECT_LUA_REGISTRY_REF);
if (refKey > 0) {
diff --git a/src/nameplate/Walk.h b/src/nameplate/Walk.h
index 6a92d66..2d78612 100644
--- a/src/nameplate/Walk.h
+++ b/src/nameplate/Walk.h
@@ -23,6 +23,16 @@
// Header-only template — used by both the Lua-facing accessors in
// `nameplate/Info.cpp` and the per-tick differ in `nameplate/Events.cpp`.
+namespace NamePlate::Info {
+
+// Pushes the nameplate Frame at `nameplate` onto the Lua stack —
+// either the cached registry-wrapper (addon-registered plates) or a
+// fresh per-call wrapper (default vanilla plates). Defined in
+// `Info.cpp`.
+void PushNamePlateFrame(void *L, void *nameplate);
+
+} // namespace NamePlate::Info
+
namespace NamePlate::Walk {
constexpr uintptr_t kLocalPlayerGlobal = 0x00B41414;
From 6145962f3e28280e095a664cfa22fede92b08c9c Mon Sep 17 00:00:00 2001
From: Brues <5278969+brues-code@users.noreply.github.com>
Date: Tue, 26 May 2026 12:53:48 -0500
Subject: [PATCH 09/15] document verification provenance for nameplate-adjacent
offsets
- Offsets.h: annotate LUA_REF_REF / LUA_REF_UNREF with their Ghidra
decompile shapes (FREELIST_REF chain + LUA_REFNIL/NOREF sentinels)
so the next maintainer doesn't have to re-derive whether these are
really luaL_ref/unref.
- Walk.h: warn that the 0x00B41414 player global overlays multiple
sub-objects; reading +0x1C/+0x24 here is safe, feeding it to the
inventory path crashes.
---
src/Offsets.h | 12 ++++++++++--
src/nameplate/Walk.h | 9 +++++++++
2 files changed, 19 insertions(+), 2 deletions(-)
diff --git a/src/Offsets.h b/src/Offsets.h
index 0f33f08..e4042e2 100644
--- a/src/Offsets.h
+++ b/src/Offsets.h
@@ -2092,10 +2092,18 @@ enum Offsets {
// `luaL_ref(L, t)` — pops the top, stores it in the table at `t`
// at a freshly-allocated integer key, returns the key. Use with
// `LUA_REGISTRY_INDEX` to stash Lua values across C-side scopes;
- // pair with `LUA_REF_UNREF` to release.
+ // pair with `LUA_REF_UNREF` to release. Verified by decompiling
+ // `FUN_006f5310`: classic luaL_ref shape — nil-top early-out
+ // returning `LUA_REFNIL = -1`, FREELIST_REF chain pop (pushvalue +
+ // tonumber on table[0]), else objlen-based new slot, finally
+ // table[ref] = popped value.
LUA_REF_REF = 0x6F5310,
// `luaL_unref(L, t, ref)` — releases a ref previously returned
- // by `luaL_ref`, freeing the slot for future allocations.
+ // by `luaL_ref`, freeing the slot for future allocations. Verified
+ // by decompiling `FUN_006f5400`: `ref >= 0` guard (skips
+ // `LUA_NOREF = -2` / `LUA_REFNIL = -1`), reads current FREELIST_REF
+ // head, writes it to `table[ref]`, then updates FREELIST_REF = ref
+ // — the canonical freelist-link operation.
LUA_REF_UNREF = 0x6F5400,
LUA_PUSH_CCLOSURE = 0x6F3920,
LUA_NEW_TABLE = 0x6F3C90,
diff --git a/src/nameplate/Walk.h b/src/nameplate/Walk.h
index 2d78612..704217a 100644
--- a/src/nameplate/Walk.h
+++ b/src/nameplate/Walk.h
@@ -35,6 +35,15 @@ void PushNamePlateFrame(void *L, void *nameplate);
namespace NamePlate::Walk {
+// `0x00B41414` is a player-related global that overlays multiple
+// sub-objects at different offsets. We read the object-hash-table
+// fields at `+0x1C` (bucket array) and `+0x24` (bucket mask) — that
+// sub-struct is safe to walk for any unit the engine knows about.
+// **Do not feed this pointer to inventory routines** (`GetItemBySlot`
+// etc.); the CGPlayer_C inventory manager lives at a different
+// offset (`+0x1D38`) and is sourced via `ResolveUnitToken("player")`,
+// not via this global. See CLAUDE.md "Resolving an item-location"
+// for the inventory-crash story.
constexpr uintptr_t kLocalPlayerGlobal = 0x00B41414;
constexpr int kOffPlayerBucketArray = 0x1C;
constexpr int kOffPlayerBucketMask = 0x24;
From f96ef0f3698df08a911f864964ec599edf8e6ebb Mon Sep 17 00:00:00 2001
From: Brues <5278969+brues-code@users.noreply.github.com>
Date: Tue, 26 May 2026 13:10:56 -0500
Subject: [PATCH 10/15] harden nameplate frame push against stale registry refs
PushNamePlateFrame now type-checks the rawgeti'd value; on a stale
slot (post-/reload windows, freed slot, etc.) it pops and falls back
to a fresh wrapper instead of returning whatever the registry happens
to hold at that index in the new Lua state. Script_GetNamePlates now
routes through the same helper so it inherits the guard.
Also document the bound on g_seenPlates: pool reuse via DAT_00c4d920
caps it at the session's peak simultaneous-plate count.
---
src/nameplate/Events.cpp | 8 ++++++++
src/nameplate/Info.cpp | 38 +++++++++++++++++++++-----------------
2 files changed, 29 insertions(+), 17 deletions(-)
diff --git a/src/nameplate/Events.cpp b/src/nameplate/Events.cpp
index 4bde3e1..f98136e 100644
--- a/src/nameplate/Events.cpp
+++ b/src/nameplate/Events.cpp
@@ -70,6 +70,14 @@ std::unordered_map g_currentTickPlates;
// Frame pointers we've ever surfaced as nameplate plates. First
// sighting fires NAME_PLATE_CREATED; same pointer reappearing (pool
// reuse) doesn't refire.
+//
+// Bounded by the engine's CGNamePlateFrame freelist high-water mark:
+// `FUN_006087F0` first checks the global freelist head at
+// `DAT_00c4d920` and recycles any waiting frame before falling back
+// to `SMemAlloc(0x518)`. So the set grows only up to the peak
+// simultaneous-visible-plate count for the session — typically
+// <80 even in AV-scale scenes (matching the `reserve(64)` below) —
+// then tops out as pool reuse covers all subsequent shows.
std::unordered_set g_seenPlates;
void FireWithGUID(const char *eventName, uint64_t guid) {
diff --git a/src/nameplate/Info.cpp b/src/nameplate/Info.cpp
index 8c61738..127bfcd 100644
--- a/src/nameplate/Info.cpp
+++ b/src/nameplate/Info.cpp
@@ -103,24 +103,16 @@ void PushFreshFrameWrapper(void *L, void *frame) {
static int __fastcall Script_GetNamePlates(void *L) {
Game::Lua::NewTable(L);
- auto rawgeti = reinterpret_cast(
- Offsets::FUN_FRAMESCRIPT_PUSH_OBJECT);
int nextIndex = 1;
ForEachNamePlatedUnit(
- [L, rawgeti, &nextIndex](const uint8_t *, const uint8_t *nameplate,
- const uint8_t *) {
+ [L, &nextIndex](const uint8_t *, const uint8_t *nameplate,
+ const uint8_t *) {
Game::Lua::PushNumber(L, static_cast(nextIndex++));
- // The engine initializes `OFF_COBJECT_LUA_REGISTRY_REF` to
- // `LUA_NOREF` (`-2`) for internally-created frames; a real
- // refkey is always a positive integer from `luaL_ref`.
- // Treat anything `<= 0` as unregistered.
- const int refKey = *reinterpret_cast(
- nameplate + Offsets::OFF_COBJECT_LUA_REGISTRY_REF);
- if (refKey > 0) {
- rawgeti(L, Game::Lua::REGISTRY_INDEX, refKey);
- } else {
- PushFreshFrameWrapper(L, const_cast(nameplate));
- }
+ // Shared helper validates refkey freshness (defensive vs.
+ // stale-across-reload) and falls back to a fresh wrapper
+ // if the registry slot doesn't actually hold the frame
+ // table any more.
+ PushNamePlateFrame(L, const_cast(nameplate));
Game::Lua::SetTable(L, -3);
});
return 1;
@@ -147,6 +139,16 @@ static int __fastcall Script_GetNamePlateGUIDs(void *L) {
// Exported via `nameplate/Walk.h` so `Events.cpp` can reuse the same
// frame-push path. Internal callers (Script_GetNamePlates etc.) call
// it through the unqualified name (they live in the same namespace).
+//
+// Defensive against stale refkeys: on `/reload` the C++ frame
+// persists but the Lua registry is rebuilt at a fresh state. The
+// engine's per-frame teardown should release each refkey via
+// `luaL_unref`, but we'd rather not rely on it for every frame in
+// every reload path — if the rawgeti'd value isn't actually a
+// frame-wrapper table (nil, freed registry slot, anything), pop it
+// and synthesise a fresh wrapper. Same fallback the
+// "unregistered frame" path uses, so the caller can't observe a
+// non-table being pushed.
void PushNamePlateFrame(void *L, void *nameplate) {
const int refKey = *reinterpret_cast(
static_cast(nameplate) + Offsets::OFF_COBJECT_LUA_REGISTRY_REF);
@@ -154,9 +156,11 @@ void PushNamePlateFrame(void *L, void *nameplate) {
auto rawgeti = reinterpret_cast(
Offsets::FUN_FRAMESCRIPT_PUSH_OBJECT);
rawgeti(L, Game::Lua::REGISTRY_INDEX, refKey);
- } else {
- PushFreshFrameWrapper(L, nameplate);
+ if (Game::Lua::Type(L, -1) == Game::Lua::TYPE_TABLE)
+ return;
+ Game::Lua::SetTop(L, -2); // pop the stale value
}
+ PushFreshFrameWrapper(L, nameplate);
}
// GUID → nameplate Frame pushed on stack. Pushes nil if the GUID
From 4b37a53ea91cafb44d2b7d93759e698d87d0f56e Mon Sep 17 00:00:00 2001
From: Brues <5278969+brues-code@users.noreply.github.com>
Date: Tue, 26 May 2026 13:45:35 -0500
Subject: [PATCH 11/15] support nameplateN unit tokens
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Hook FUN_TOKEN_TO_GUID so every Script_Unit* (UnitName, UnitGUID,
UnitHealth, UnitClass, UnitExists, ...) accepts `nameplateN` tokens
and their `target`-suffix chains (`nameplate1target`,
`nameplate1targettarget`) — modern WoW semantics on the vanilla
engine. Hook is gated by an SStrCmpI prefix check; non-nameplate
tokens fall straight through to the original resolver.
- Ordered GUID list is maintained alongside the existing
NAME_PLATE_UNIT_ADDED / _REMOVED diff. Append on ADDED, erase on
REMOVED — creation-order, stable for a plate's lifetime, modern
semantics.
- Suffix walker mirrors the engine's own LAB_005159d3 walker
instruction-for-instruction: SStrCmpI "target" + ObjectByGUID +
read UNIT_FIELD_TARGET (m_objectFields + 0x28).
- SStrCmpI typedef is __stdcall, not __cdecl. The function ends with
ret 0xc; declaring it cdecl makes MSVC emit a redundant add esp, 12
on top of the callee cleanup, drifting ESP +12 per call and
crashing somewhere deep in Lua once L gets read from a poisoned
stack slot.
- m_objectFields is a *pointer* at obj+0x110, not an inline byte
offset. Two dereferences (mov eax, [obj+0x110]; mov edi, [eax+0x28]),
not one — earlier version mistook the inline-vs-pointer shape and
read instruction bytes as GUIDs.
---
src/Offsets.h | 22 +++++
src/nameplate/Events.cpp | 38 ++++++++-
src/nameplate/TokenResolver.cpp | 146 ++++++++++++++++++++++++++++++++
src/nameplate/Walk.h | 11 +++
4 files changed, 214 insertions(+), 3 deletions(-)
create mode 100644 src/nameplate/TokenResolver.cpp
diff --git a/src/Offsets.h b/src/Offsets.h
index e4042e2..28121a1 100644
--- a/src/Offsets.h
+++ b/src/Offsets.h
@@ -281,6 +281,28 @@ enum Offsets {
// input it's the right primitive.
FUN_TOKEN_TO_GUID = 0x00515970,
+ // `SStrCmpI(a, b, n)` — Storm's case-insensitive memcmp-style
+ // comparator. **`int __stdcall(const char *a, const char *b, int n)`**
+ // — the function ends with `ret 0xc`, so the callee pops the
+ // 3-arg stack frame. Declaring it as `__cdecl` and calling makes
+ // MSVC emit a redundant `add esp, 12` post-call, drifting ESP
+ // upward by 12 per call and corrupting the caller's stack frame
+ // — manifested as a deep-Lua crash whose `L` pointer landed in
+ // `.text`. Returns 0 when the first `n` characters match
+ // (ignoring case) or both strings end before `n`.
+ FUN_SSTR_CMP_I = 0x0064A4C0,
+
+ // UNIT_FIELD_TARGET within `m_objectFields` — 64-bit GUID at byte
+ // offsets +0x28 (lo) / +0x2C (hi). Verified by disassembling
+ // `FUN_TOKEN_TO_GUID`'s suffix walker at `0x00515A1C-A2C`:
+ // `mov eax, [obj + 0x110]; mov edi, [eax + 0x28]; mov ebx, [eax + 0x2c]`
+ // — reads the target GUID to chain `targettarget`-style tokens.
+ // Distinct from the higher field offsets in the
+ // `OFF_UNIT_FIELD_*` block; UNIT_FIELD_TARGET is one of the few
+ // 1.12 offsets that matches the CMaNGOS-documented vanilla
+ // layout.
+ OFF_UNIT_FIELD_TARGET = 0x28,
+
// Party / raid roster counts and the party GUID array referenced
// in the `FUN_TOKEN_TO_GUID` dispatch comment above. Used by
// `UnitTokenFromGUID` to cap its candidate iteration — solo
diff --git a/src/nameplate/Events.cpp b/src/nameplate/Events.cpp
index f98136e..3428625 100644
--- a/src/nameplate/Events.cpp
+++ b/src/nameplate/Events.cpp
@@ -41,9 +41,11 @@
#include "nameplate/Walk.h"
#include "tick/WorldTick.h"
+#include
#include
#include
#include
+#include
namespace NamePlate::Events {
@@ -80,6 +82,14 @@ std::unordered_map g_currentTickPlates;
// then tops out as pool reuse covers all subsequent shows.
std::unordered_set g_seenPlates;
+// Ordered list of currently-visible nameplate GUIDs, in
+// creation-order. Append on UNIT_ADDED, erase on UNIT_REMOVED. Backs
+// the `nameplateN` unit-token resolver in `TokenResolver.cpp`. Order
+// matches modern WoW semantics: stable for the lifetime of each
+// plate, gaps when middle plates vanish (until the next REMOVED
+// shifts later entries down).
+std::vector g_orderedGUIDs;
+
void FireWithGUID(const char *eventName, uint64_t guid) {
if (guid == 0)
return;
@@ -155,18 +165,28 @@ void OnWorldTick() {
// Fire CREATED (with the Frame as arg1) for never-before-seen
// frame pointers; ADDED (with GUID string) for GUIDs not in last
- // tick's snapshot.
+ // tick's snapshot. New ADDED entries also get appended to the
+ // ordered GUID list that backs `nameplateN` token resolution.
for (const auto &kv : g_currentTickPlates) {
if (g_seenPlates.insert(kv.second).second)
FireWithFrame(kEventCreated, const_cast(kv.second));
- if (g_lastTickPlates.find(kv.first) == g_lastTickPlates.end())
+ if (g_lastTickPlates.find(kv.first) == g_lastTickPlates.end()) {
FireWithGUID(kEventUnitAdded, kv.first);
+ g_orderedGUIDs.push_back(kv.first);
+ }
}
// Fire REMOVED for GUIDs in last tick's snapshot but not current.
+ // Same GUIDs are removed from the ordered list so later plates
+ // shift down — matches modern semantics.
for (const auto &kv : g_lastTickPlates) {
- if (g_currentTickPlates.find(kv.first) == g_currentTickPlates.end())
+ if (g_currentTickPlates.find(kv.first) == g_currentTickPlates.end()) {
FireWithGUID(kEventUnitRemoved, kv.first);
+ auto it = std::find(g_orderedGUIDs.begin(), g_orderedGUIDs.end(),
+ kv.first);
+ if (it != g_orderedGUIDs.end())
+ g_orderedGUIDs.erase(it);
+ }
}
g_lastTickPlates.swap(g_currentTickPlates);
@@ -176,4 +196,16 @@ void OnWorldTick() {
static const Tick::WorldTick::AutoSubscribe _tickSub{&OnWorldTick};
+// Exposed via `nameplate/Walk.h` so the `nameplateN` token resolver
+// in `TokenResolver.cpp` can map an index to a GUID without seeing
+// the internal vector.
+uint64_t GetGUIDByIndex(int oneBased) {
+ if (oneBased <= 0)
+ return 0;
+ const size_t idx = static_cast(oneBased - 1);
+ if (idx >= g_orderedGUIDs.size())
+ return 0;
+ return g_orderedGUIDs[idx];
+}
+
} // namespace NamePlate::Events
diff --git a/src/nameplate/TokenResolver.cpp b/src/nameplate/TokenResolver.cpp
new file mode 100644
index 0000000..ac886df
--- /dev/null
+++ b/src/nameplate/TokenResolver.cpp
@@ -0,0 +1,146 @@
+// This file is part of ClassicAPI.
+//
+// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
+// of the GNU Lesser General Public License as published by the Free Software Foundation, either
+// version 3 of the License, or (at your option) any later version.
+//
+// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
+// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
+// PURPOSE. See the GNU Lesser General Public License for more details.
+//
+// You should have received a copy of the GNU Lesser General Public License along with
+// ClassicAPI. If not, see .
+
+// Extend `FUN_TOKEN_TO_GUID` (`0x00515970`) with modern WoW's
+// `nameplateN` unit-token family. Hooking the central token→GUID
+// resolver means every `Script_Unit*` (UnitName, UnitGUID, UnitHealth,
+// UnitClass, UnitExists, …) gains nameplate-token support for free,
+// since they all funnel here.
+//
+// Resolution:
+//
+// 1. SStrCmpI the input against `"nameplate"` (9 chars). Non-match →
+// fall through to the original resolver and behave exactly as
+// before (engine handles `player` / `target` / `partyN` / etc.,
+// or raises "Unknown unit name").
+// 2. Parse trailing digits — `nameplate1`, `nameplate12`, ... — into
+// a 1-based index `N`.
+// 3. Look up `N` in `NamePlate::Events`' ordered GUID list. Out of
+// range returns 0 quietly, which the engine treats as "no unit"
+// and Lua callers see as `nil` — matches modern behavior, no
+// "Unknown unit name" error.
+// 4. If the token continues past the digits (`nameplate1target`,
+// `nameplate1targettarget`, …) mirror the engine's own suffix
+// walker at `LAB_005159d3`: each `"target"` chunk advances the
+// GUID by reading `m_objectFields + OFF_UNIT_FIELD_TARGET`
+// (UNIT_FIELD_TARGET) via `ObjectByGUID`.
+//
+// The engine's walker is **inside** the function we hook, so a hook
+// that returns early at the prefix-parse step would skip it
+// entirely. Replicating the walker here keeps `nameplate1target`
+// working without rewriting the resolver. The walker's logic is
+// short (one `SStrCmpI` + one `ObjectByGUID` + one field-read per
+// iteration) and matches the engine instruction-for-instruction —
+// verified against the disassembly of `0x005159D3..0x00515A2C`.
+
+#include "Game.h"
+#include "Offsets.h"
+#include "nameplate/Walk.h"
+
+#include
+
+namespace NamePlate::TokenResolver {
+
+namespace {
+
+using TokenToGUID_t = uint64_t(__fastcall *)(const char *token);
+// `__stdcall`, not `__cdecl` — `FUN_00064A4C0` ends with `ret 0xc`,
+// callee cleans 3-arg stack frame. Calling it through a cdecl typedef
+// makes MSVC emit `add esp, 12` after the call on top of the callee's
+// own cleanup, drifting ESP +12 per call and corrupting the caller's
+// frame.
+using SStrCmpI_t = int(__stdcall *)(const char *a, const char *b, int n);
+using ResolveByGUID_t = void *(__fastcall *)(int type, const char *debugName,
+ uint32_t guidLo, uint32_t guidHi,
+ int priority);
+
+constexpr const char kPrefix[] = "nameplate";
+constexpr int kPrefixLen = static_cast(sizeof(kPrefix) - 1);
+constexpr const char kSuffixTarget[] = "target";
+constexpr int kSuffixTargetLen = static_cast(sizeof(kSuffixTarget) - 1);
+
+// Engine's own priority value for the suffix walker's ObjectByGUID
+// calls (verified at `0x005159FE: PUSH 0x6E`). Different from the
+// `0x172` we use elsewhere — matching the engine here so the
+// resolver's lookup path is identical to the native `targettarget`
+// walker.
+constexpr int kResolvePriority = 0x6e;
+
+TokenToGUID_t Original_o = nullptr;
+
+uint64_t WalkSuffix(uint64_t guid, const char *suffix) {
+ auto resolve = reinterpret_cast(
+ Offsets::FUN_OBJECT_RESOLVE_BY_GUID);
+ auto sstrcmpi = reinterpret_cast(Offsets::FUN_SSTR_CMP_I);
+ while (guid != 0 && *suffix != '\0') {
+ if (sstrcmpi(suffix, kSuffixTarget, kSuffixTargetLen) != 0)
+ return 0; // unknown suffix component — modern returns nil
+ suffix += kSuffixTargetLen;
+ auto *obj = static_cast(
+ resolve(Offsets::OBJ_TYPE_UNIT, "nameplate",
+ static_cast(guid),
+ static_cast(guid >> 32),
+ kResolvePriority));
+ if (obj == nullptr)
+ return 0;
+ // `m_objectFields` is a *pointer* stored at `obj + 0x110`,
+ // not an inline array. Engine's walker does
+ // `mov eax, [obj+0x110]; mov edi, [eax+0x28]` — two
+ // dereferences. Earlier version of this code missed the
+ // first one and read instruction bytes interpreted as GUIDs.
+ auto *fields = *reinterpret_cast(
+ obj + Offsets::OFF_CGUNIT_OBJECT_FIELDS);
+ if (fields == nullptr)
+ return 0;
+ guid = *reinterpret_cast(
+ fields + Offsets::OFF_UNIT_FIELD_TARGET);
+ }
+ return guid;
+}
+
+uint64_t __fastcall Hook_h(const char *token) {
+ if (token == nullptr || *token == '\0')
+ return Original_o(token);
+
+ auto sstrcmpi = reinterpret_cast(Offsets::FUN_SSTR_CMP_I);
+ if (sstrcmpi(token, kPrefix, kPrefixLen) != 0)
+ return Original_o(token);
+
+ // Must have at least one digit after `"nameplate"`; otherwise
+ // it's `"nameplate"` (no number) or `"nameplateX"` — fall
+ // through to the original so the engine raises its
+ // "Unknown unit name" error consistently.
+ const char *p = token + kPrefixLen;
+ if (*p < '0' || *p > '9')
+ return Original_o(token);
+
+ int index = 0;
+ while (*p >= '0' && *p <= '9') {
+ index = index * 10 + (*p - '0');
+ ++p;
+ }
+
+ const uint64_t guid = NamePlate::Events::GetGUIDByIndex(index);
+ if (guid == 0)
+ return 0; // OOB / no plate at index — modern returns nil
+ return WalkSuffix(guid, p);
+}
+
+} // namespace
+
+static const Game::HookAutoRegister _hook{
+ Offsets::FUN_TOKEN_TO_GUID,
+ reinterpret_cast(&Hook_h),
+ reinterpret_cast(&Original_o)};
+
+} // namespace NamePlate::TokenResolver
diff --git a/src/nameplate/Walk.h b/src/nameplate/Walk.h
index 704217a..06dc082 100644
--- a/src/nameplate/Walk.h
+++ b/src/nameplate/Walk.h
@@ -33,6 +33,17 @@ void PushNamePlateFrame(void *L, void *nameplate);
} // namespace NamePlate::Info
+namespace NamePlate::Events {
+
+// Returns the GUID currently bound to `nameplateN` (1-based, matching
+// the modern Lua token form). `0` means no nameplate at that index.
+// Backed by the per-tick UNIT_ADDED / UNIT_REMOVED diff in
+// `Events.cpp` — order is creation-order (append on ADDED, erase on
+// REMOVED), stable within a frame.
+uint64_t GetGUIDByIndex(int oneBased);
+
+} // namespace NamePlate::Events
+
namespace NamePlate::Walk {
// `0x00B41414` is a player-related global that overlays multiple
From 7d7ecafc6a137028c7075cc23d22c20ba3f01505 Mon Sep 17 00:00:00 2001
From: Brues <5278969+brues-code@users.noreply.github.com>
Date: Tue, 26 May 2026 13:48:43 -0500
Subject: [PATCH 12/15] document nameplateN unit-token support
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- API.md NamePlate section: replace the "scope vs. modern (deferred)"
callout with a pointer to the new Unit tokens subsection; update
the events table and "modern divergence" callout to explain why
the GUID payload remains (engine dispatcher can't carry Lua values
through %s/%d/%u/%f format codes) even though the token form now
works.
- New "Unit tokens (`nameplateN`)" subsection covering the resolved
~30 UnitX functions, creation-order semantics, suffix chains
(`nameplate1target`, `targettarget`), and the SStrCmpI prefix-gate
implementation detail.
- UnitTokenFromGUID's "post-1.12 omitted" list: nameplateN is no
longer engine-unsupported (we hook the resolver) but it's still
not used as a reverse-lookup key — indices can be reassigned
during a session, so it'd be unstable.
- README: new Unit tokens table row pointing to the API.md anchor.
---
README.md | 6 +++++
docs/API.md | 73 +++++++++++++++++++++++++++++++++++++++++++----------
2 files changed, 66 insertions(+), 13 deletions(-)
diff --git a/README.md b/README.md
index 7de4d4f..492fdbd 100644
--- a/README.md
+++ b/README.md
@@ -120,6 +120,12 @@ functions, just behavior the stock 1.12 engine didn't have. See the
| Addon security | `Enum.AddOnSecurityStatus.{Secure,Insecure,Banned,NotAvailable}` |
| Power type | `Enum.PowerType.{HealthCost,None,Mana,Rage,Focus,Energy,Happiness}` |
+### Unit tokens
+
+| Token | Resolves to |
+|-------|-------------|
+| `nameplate1`..`nameplateN` | Unit behind the Nth visible nameplate, in creation-order. Works with every `UnitX` function — `UnitName`, `UnitGUID`, `UnitClass`, `UnitHealth`, etc. Suffix chains (`nameplate1target`, `nameplate1targettarget`) compose. See [NamePlate / Unit tokens](docs/API.md#unit-tokens-nameplaten). |
+
## Installation
Use [VanillaFixes](https://github.com/hannesmann/vanillafixes) to load the
diff --git a/docs/API.md b/docs/API.md
index 2de6dee..ced665c 100644
--- a/docs/API.md
+++ b/docs/API.md
@@ -201,6 +201,7 @@ build instructions.
- [`C_NamePlate.GetNamePlateGUIDs()`](#c_nameplategetnameplateguids)
- [`C_NamePlate.GetNamePlateForUnit(unitToken)`](#c_nameplategetnameplateforunitunittoken)
- [`C_NamePlate.GetNamePlateForGUID(guidString)`](#c_nameplategetnameplateforguidguidstring)
+ - [Unit tokens (`nameplateN`)](#unit-tokens-nameplaten)
- [NameCache](#namecache)
- [`GetPlayerInfoByGUID(guid)`](#getplayerinfobyguidguid)
@@ -1581,7 +1582,7 @@ Fire when nameplate state actually changes. Payloads:
| Event | `arg1` | Notes |
|-------|--------|-------|
| `NAME_PLATE_CREATED` | nameplate **Frame** | Matches modern WoW. Fires once per unique `CGNamePlateFrame` pointer — same frame re-used via pool recycle does NOT refire. |
-| `NAME_PLATE_UNIT_ADDED` | unit **GUID string** | Modern uses a `"nameplateN"` token; vanilla's token resolver isn't extensible. Pass `arg1` to [`GetNamePlateForGUID`](#c_nameplategetnameplateforguidguidstring) for the frame. |
+| `NAME_PLATE_UNIT_ADDED` | unit **GUID string** | Modern passes a `"nameplateN"` unit token; we ship the GUID string instead because event payloads can't carry Lua values through the engine's printf-style dispatcher. The `nameplateN` token itself **does** work for `UnitName` / `UnitGUID` / etc. — see [Unit tokens](#unit-tokens-nameplaten). |
| `NAME_PLATE_UNIT_REMOVED` | unit **GUID string** | Same as above. |
```lua
@@ -1610,10 +1611,14 @@ end)
> `NAME_PLATE_UNIT_ADDED` (fires next tick at the latest) or fetch
> the current frame on-demand via `GetNamePlateForGUID`.
-> **Modern divergence.** Retail passes `"nameplateN"` unit tokens to
-> `ADDED`/`REMOVED`. Vanilla's token resolver isn't extensible, so
-> we ship the GUID string instead and let addons round-trip through
-> `GetNamePlateForGUID` to reach the frame.
+> **Modern divergence.** Retail passes `"nameplateN"` unit tokens as
+> the event payload. The vanilla engine's event dispatcher only
+> accepts `%s`/`%d`/`%u`/`%f` format codes — no path for "push the
+> token string and let `UnitX` resolve it lazily" — so we ship the
+> GUID string and addons can either round-trip via
+> `GetNamePlateForGUID` for the frame, or pass the matching
+> `"nameplate1"`-style token to any `UnitX` function. See
+> [Unit tokens](#unit-tokens-nameplaten).
**Implementation notes**
@@ -4397,11 +4402,10 @@ underlying data (per-unit nameplate pointer at `CGUnit + 0xE60`)
exists. We enumerate visible units via the local-player-anchored
object hash table, filter by `TYPEMASK_UNIT`, and return matches.
-> **Scope vs. modern.** Modern API also provides `"nameplateN"` unit
-> tokens and `NAME_PLATE_UNIT_ADDED` / `REMOVED` events. Both
-> deferred — would require hooking the unit-token resolver and a
-> per-frame polling loop. The functions documented below give
-> addons enough to walk plates per call.
+Modern's `"nameplateN"` unit-token family is also supported — see
+[Unit tokens](#unit-tokens-nameplaten) below. `NAME_PLATE_UNIT_ADDED`
+/ `_REMOVED` / `_CREATED` events fire via a per-tick visible-plate
+diff (see [the events section](#name_plate_created--name_plate_unit_added--name_plate_unit_removed-events)).
### `C_NamePlate.GetNamePlates()`
@@ -4518,6 +4522,45 @@ nameplate pointer is set by `FUN_006086E0`'s "show nameplate" path
regardless of which nameplate system rendered it. Order follows
hash-bucket iteration and isn't stable across calls.
+### Unit tokens (`nameplateN`)
+
+`"nameplate1"`, `"nameplate2"`, … work as unit tokens against every
+`UnitX` function: `UnitName`, `UnitGUID`, `UnitClass`, `UnitHealth`,
+`UnitHealthMax`, `UnitLevel`, `UnitFaction`, `UnitReaction`,
+`UnitExists`, `UnitIsPlayer`, `UnitIsEnemy`, `UnitIsDead`, etc. —
+~30 functions for free.
+
+```lua
+for i = 1, 40 do
+ if not UnitExists("nameplate" .. i) then break end
+ print(i, UnitName("nameplate" .. i), UnitClass("nameplate" .. i))
+end
+```
+
+Ordering is **creation-order** — each new plate appends to the end of
+the list and stays at its index until the unit goes out of range or
+the plate is removed, at which point later indices shift down.
+Stable for the lifetime of a single plate; no mid-frame reordering.
+Same semantics as modern WoW.
+
+Token chains work too — `"nameplate1target"`, `"nameplate1targettarget"`,
+etc. — by mirroring the engine's own `targettarget`-style suffix
+walker (read `UNIT_FIELD_TARGET` off `m_objectFields`, loop). Other
+suffixes (`pet`, `master`) aren't supported by the vanilla engine's
+own walker either, so they don't compose.
+
+Out-of-range indices return `nil` cleanly without raising "Unknown
+unit name" — `UnitExists("nameplate99")` just returns `false`.
+
+**Implementation note.** We hook `FUN_TOKEN_TO_GUID` (the central
+token→GUID resolver) so the entire `Script_Unit*` surface gains the
+new token form transparently. The hook is gated by an `SStrCmpI`
+prefix check against `"nameplate"`; non-nameplate tokens
+(`"player"`, `"target"`, `"partyN"`, etc.) fall straight through to
+the unmodified resolver. The ordered list is maintained alongside
+the existing `NAME_PLATE_UNIT_ADDED` / `_REMOVED` diff in the
+per-tick nameplate walker.
+
## NameCache
GUID-keyed cache of player names and classes. The engine itself
@@ -6712,9 +6755,13 @@ known unit tokens and return the first one currently mapped to that
GUID, or `nil` if none of them point at it.
The search order matches modern retail with post-1.12 tokens
-omitted (`vehicle`, `nameplateN`, `arenaN`, `arenapetN`, `bossN`,
-`focus`, `softenemy`, `softfriend`, `softinteract` all post-date
-vanilla and the engine's resolver doesn't recognize them):
+omitted (`vehicle`, `arenaN`, `arenapetN`, `bossN`, `focus`,
+`softenemy`, `softfriend`, `softinteract` all post-date vanilla and
+the engine's resolver doesn't recognize them). `nameplateN` is
+recognized by the resolver (we hook it — see
+[Unit tokens](#unit-tokens-nameplaten)) but **not** searched here —
+two GUIDs can share a nameplate index over the lifetime of a
+session, so it's not a stable reverse-lookup key:
```
player → pet → party1..4 → partypet1..4 → raid1..40
From b75437f5fb45dc18570249ecda1fc3c2b46e2746 Mon Sep 17 00:00:00 2001
From: Brues <5278969+brues-code@users.noreply.github.com>
Date: Tue, 26 May 2026 13:58:22 -0500
Subject: [PATCH 13/15] return stable nameplate wrapper across calls
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
PushNamePlateFrame was building a fresh Lua table on every call for
unregistered (default vanilla) nameplates — so fields addons set on
the wrapper they received in NAME_PLATE_CREATED were lost on the
next GetNamePlateForUnit lookup. pfUI specifically writes
`plate.nameplate = styledButton` on the CREATED-delivered table to
hand its overlay back through our API, and that field was
disappearing.
Fix: pin the first wrapper for each nameplate pointer in the Lua
registry via luaL_ref, cache the refkey in g_wrapperRefkeys, and
rawgeti on every subsequent push. Same table identity for the life
of the engine plate. Bound by the CGNamePlateFrame pool size (~80).
Cleared on /reload via a new NamePlate::Info::PrepareForReload()
wired into FrameScript_Initialize_h next to the existing
Event::Custom and NameCache reload hooks — stale refkeys would
otherwise point into the freed registry.
---
src/DllMain.cpp | 5 +++
src/nameplate/Info.cpp | 83 ++++++++++++++++++++++++++++++++++--------
src/nameplate/Walk.h | 16 ++++++--
3 files changed, 85 insertions(+), 19 deletions(-)
diff --git a/src/DllMain.cpp b/src/DllMain.cpp
index 2546312..0d5ac41 100644
--- a/src/DllMain.cpp
+++ b/src/DllMain.cpp
@@ -16,6 +16,7 @@
#include "MinHook.h"
#include "Offsets.h"
#include "event/Custom.h"
+#include "nameplate/Walk.h"
#include "player/NameCache.h"
static Game::FrameScript_Initialize_t FrameScript_Initialize_o = nullptr;
@@ -37,6 +38,10 @@ static bool __fastcall FrameScript_Initialize_h() {
// table is rebuilt at a fresh allocation; the old slots are stale.
Event::Custom::PrepareForReload();
+ // Drop the nameplate wrapper-cache refkeys for the same reason:
+ // the Lua registry that holds them is about to be freed.
+ NamePlate::Info::PrepareForReload();
+
// Persist the name cache before the engine starts tearing down.
// This hook fires on both `/reload` and `/logout` (the engine
// re-initializes Lua state in both cases), giving us a clean
diff --git a/src/nameplate/Info.cpp b/src/nameplate/Info.cpp
index 127bfcd..6f10c6d 100644
--- a/src/nameplate/Info.cpp
+++ b/src/nameplate/Info.cpp
@@ -58,6 +58,7 @@
#include "nameplate/Walk.h"
#include
+#include
namespace NamePlate::Info {
@@ -69,6 +70,7 @@ using NamePlate::Walk::kOffUnitNamePlate;
using LuaRawGetI_t = void(__fastcall *)(void *L, int idx, int n);
using LuaPushLightUserdata_t = void(__fastcall *)(void *L, void *p);
using LuaSetMetatable_t = int(__fastcall *)(void *L, int idx);
+using LuaRefRef_t = int(__fastcall *)(void *L, int t);
using TokenToGUID_t = uint64_t(__fastcall *)(const char *token);
using ResolveByGUID_t = void *(__fastcall *)(int type, const char *debugName,
uint32_t guidLo, uint32_t guidHi,
@@ -79,6 +81,18 @@ constexpr uintptr_t kFunLuaSetMetatable = 0x006F4020;
constexpr int kLuaGlobalsIndex = -10001;
constexpr const char *kFrameMetatableGlobal = "__framescript_meta";
+// Wrapper-table identity cache for default vanilla nameplates.
+// Without this, every `PushNamePlateFrame` call for an unregistered
+// plate produces a *different* Lua table from `PushFreshFrameWrapper`
+// — so addon-set fields on the wrapper (pfUI sets `plate.nameplate =
+// styledButton` on the table it gets in `NAME_PLATE_CREATED`) don't
+// survive a later `GetNamePlateForUnit('nameplateN')` call. We pin
+// the first wrapper in the Lua registry via `luaL_ref` and reuse
+// that refkey for every subsequent push for the same nameplate
+// pointer. Bound by the engine's CGNamePlateFrame freelist
+// high-water mark (<80 typical, see `Events.cpp` g_seenPlates).
+std::unordered_map g_wrapperRefkeys;
+
// Build a fresh frame wrapper on the Lua stack: `{[0] = frame}` with
// `_G["__framescript_meta"]` as metatable. Same shape the engine's
// frame-registration helper builds, minus the registry-cache step
@@ -140,27 +154,66 @@ static int __fastcall Script_GetNamePlateGUIDs(void *L) {
// frame-push path. Internal callers (Script_GetNamePlates etc.) call
// it through the unqualified name (they live in the same namespace).
//
-// Defensive against stale refkeys: on `/reload` the C++ frame
-// persists but the Lua registry is rebuilt at a fresh state. The
-// engine's per-frame teardown should release each refkey via
-// `luaL_unref`, but we'd rather not rely on it for every frame in
-// every reload path — if the rawgeti'd value isn't actually a
-// frame-wrapper table (nil, freed registry slot, anything), pop it
-// and synthesise a fresh wrapper. Same fallback the
-// "unregistered frame" path uses, so the caller can't observe a
-// non-table being pushed.
+// Path 1 — engine-registered (`refKey > 0`): use the engine's
+// per-frame registry slot. Real refkeys come from addons that
+// built their own plate via `CreateFrame`; default vanilla plates
+// never hit this path (their `+0x08` is `LUA_NOREF`).
+//
+// Path 2 — our cached wrapper: for unregistered plates we keep one
+// wrapper table per nameplate pointer pinned in the Lua registry,
+// so every call returns the *same* table. Without this, addons
+// like pfUI that stash `plate.nameplate = decorated` on the
+// wrapper received via `NAME_PLATE_CREATED` see their field
+// missing on the next `GetNamePlateForUnit` call (different
+// wrapper table, fresh metatable, no carried fields).
+//
+// Path 3 — build a wrapper, pin it via `luaL_ref`, leave it on the
+// stack. First time we see this nameplate.
+//
+// Both lookup paths validate the rawgeti'd value is actually a
+// table before returning — guards against stale registry slots
+// across `/reload`, where the Lua state rebuilds but our caches
+// persist. `PrepareForReload()` (below) clears the cache up front,
+// but if it's somehow missed, the type check still keeps callers
+// from observing a non-table.
void PushNamePlateFrame(void *L, void *nameplate) {
- const int refKey = *reinterpret_cast(
+ auto rawgeti = reinterpret_cast(
+ Offsets::FUN_FRAMESCRIPT_PUSH_OBJECT);
+
+ const int engineRefKey = *reinterpret_cast(
static_cast(nameplate) + Offsets::OFF_COBJECT_LUA_REGISTRY_REF);
- if (refKey > 0) {
- auto rawgeti = reinterpret_cast(
- Offsets::FUN_FRAMESCRIPT_PUSH_OBJECT);
- rawgeti(L, Game::Lua::REGISTRY_INDEX, refKey);
+ if (engineRefKey > 0) {
+ rawgeti(L, Game::Lua::REGISTRY_INDEX, engineRefKey);
+ if (Game::Lua::Type(L, -1) == Game::Lua::TYPE_TABLE)
+ return;
+ Game::Lua::SetTop(L, -2); // stale; fall through
+ }
+
+ auto cached = g_wrapperRefkeys.find(nameplate);
+ if (cached != g_wrapperRefkeys.end()) {
+ rawgeti(L, Game::Lua::REGISTRY_INDEX, cached->second);
if (Game::Lua::Type(L, -1) == Game::Lua::TYPE_TABLE)
return;
- Game::Lua::SetTop(L, -2); // pop the stale value
+ Game::Lua::SetTop(L, -2);
+ g_wrapperRefkeys.erase(cached); // registry slot is dead, rebuild
}
+
PushFreshFrameWrapper(L, nameplate);
+ // Duplicate so `luaL_ref` can consume one copy while the other
+ // stays on the stack as our return value.
+ Game::Lua::PushValue(L, -1);
+ auto refRef = reinterpret_cast(
+ static_cast(Offsets::LUA_REF_REF));
+ const int newRef = refRef(L, Game::Lua::REGISTRY_INDEX);
+ g_wrapperRefkeys[nameplate] = newRef;
+}
+
+// Called from `FrameScript_Initialize_h` ahead of the engine's Lua
+// teardown so we don't leak refkeys into a destroyed registry. The
+// nameplate frames themselves persist across `/reload`, but the
+// registry slots they pointed to are about to be freed.
+void PrepareForReload() {
+ g_wrapperRefkeys.clear();
}
// GUID → nameplate Frame pushed on stack. Pushes nil if the GUID
diff --git a/src/nameplate/Walk.h b/src/nameplate/Walk.h
index 06dc082..ec9bff7 100644
--- a/src/nameplate/Walk.h
+++ b/src/nameplate/Walk.h
@@ -25,12 +25,20 @@
namespace NamePlate::Info {
-// Pushes the nameplate Frame at `nameplate` onto the Lua stack —
-// either the cached registry-wrapper (addon-registered plates) or a
-// fresh per-call wrapper (default vanilla plates). Defined in
-// `Info.cpp`.
+// Pushes the nameplate Frame at `nameplate` onto the Lua stack.
+// Returns a stable wrapper table — the same table for the same
+// nameplate pointer across every call — so addon-set fields on the
+// wrapper (pfUI: `plate.nameplate`) survive between
+// `NAME_PLATE_CREATED` and a later `GetNamePlateFor*` lookup.
+// Defined in `Info.cpp`.
void PushNamePlateFrame(void *L, void *nameplate);
+// Clears the wrapper-cache map. Called from `FrameScript_Initialize`
+// before the engine tears down the Lua registry on `/reload`; the
+// next push for each plate then builds a fresh wrapper pinned in
+// the freshly-rebuilt registry.
+void PrepareForReload();
+
} // namespace NamePlate::Info
namespace NamePlate::Events {
From 74f640af7ae47f5addc3706b6d3de9ecc8f65528 Mon Sep 17 00:00:00 2001
From: Brues <5278969+brues-code@users.noreply.github.com>
Date: Tue, 26 May 2026 14:22:55 -0500
Subject: [PATCH 14/15] NAME_PLATE_UNIT events now fire token
---
README.md | 4 +--
docs/API.md | 42 +++++++++++-----------
src/nameplate/Events.cpp | 78 ++++++++++++++++++++++++++--------------
3 files changed, 74 insertions(+), 50 deletions(-)
diff --git a/README.md b/README.md
index 492fdbd..9ff713e 100644
--- a/README.md
+++ b/README.md
@@ -96,8 +96,8 @@ functions, just behavior the stock 1.12 engine didn't have. See the
| `ITEM_DATA_LOAD_RESULT` | `itemID, success` |
| `MODIFIER_STATE_CHANGED` | `keyName, down` |
| `NAME_PLATE_CREATED` | `nameplateFrame` |
-| `NAME_PLATE_UNIT_ADDED` | `unitGUID` |
-| `NAME_PLATE_UNIT_REMOVED` | `unitGUID` |
+| `NAME_PLATE_UNIT_ADDED` | `unitToken` ("nameplateN") |
+| `NAME_PLATE_UNIT_REMOVED` | `unitToken` ("nameplateN") |
| `PLAYER_STARTED_LOOKING` | *(none)* |
| `PLAYER_STOPPED_LOOKING` | *(none)* |
| `PLAYER_STARTED_MOVING` | *(none)* |
diff --git a/docs/API.md b/docs/API.md
index ced665c..69edc24 100644
--- a/docs/API.md
+++ b/docs/API.md
@@ -1582,8 +1582,8 @@ Fire when nameplate state actually changes. Payloads:
| Event | `arg1` | Notes |
|-------|--------|-------|
| `NAME_PLATE_CREATED` | nameplate **Frame** | Matches modern WoW. Fires once per unique `CGNamePlateFrame` pointer — same frame re-used via pool recycle does NOT refire. |
-| `NAME_PLATE_UNIT_ADDED` | unit **GUID string** | Modern passes a `"nameplateN"` unit token; we ship the GUID string instead because event payloads can't carry Lua values through the engine's printf-style dispatcher. The `nameplateN` token itself **does** work for `UnitName` / `UnitGUID` / etc. — see [Unit tokens](#unit-tokens-nameplaten). |
-| `NAME_PLATE_UNIT_REMOVED` | unit **GUID string** | Same as above. |
+| `NAME_PLATE_UNIT_ADDED` | `"nameplateN"` **unit token** | Matches modern WoW. Pass straight to `UnitName` / `UnitGUID` / `UnitClass` / etc., or to [`GetNamePlateForUnit`](#c_nameplategetnameplateforunitunittoken) for the frame. The token is positional — see [Unit tokens](#unit-tokens-nameplaten) for ordering semantics. |
+| `NAME_PLATE_UNIT_REMOVED` | `"nameplateN"` **unit token** | Same as above. Computed from the plate's slot *before* it shifts out of the ordered list, so the token still resolves to the leaving unit during the event handler. |
```lua
local f = CreateFrame("Frame")
@@ -1595,8 +1595,9 @@ f:SetScript("OnEvent", function()
-- arg1 = the nameplate Frame itself
arg1:SetAlpha(0.8)
elseif event == "NAME_PLATE_UNIT_ADDED" then
- -- arg1 = unit GUID string ("0xF13000C36C26FD02", etc.)
- local plate = C_NamePlate.GetNamePlateForGUID(arg1)
+ -- arg1 = "nameplate1" / "nameplate2" / ...
+ local name = UnitName(arg1)
+ local plate = C_NamePlate.GetNamePlateForUnit(arg1)
-- ... style based on the unit ...
end
end)
@@ -1609,16 +1610,14 @@ end)
> is a bare frame with no addon-side decorations yet. For
> unit-specific work after the addon has decorated, use
> `NAME_PLATE_UNIT_ADDED` (fires next tick at the latest) or fetch
-> the current frame on-demand via `GetNamePlateForGUID`.
+> the current frame on-demand via `GetNamePlateForUnit(arg1)`.
-> **Modern divergence.** Retail passes `"nameplateN"` unit tokens as
-> the event payload. The vanilla engine's event dispatcher only
-> accepts `%s`/`%d`/`%u`/`%f` format codes — no path for "push the
-> token string and let `UnitX` resolve it lazily" — so we ship the
-> GUID string and addons can either round-trip via
-> `GetNamePlateForGUID` for the frame, or pass the matching
-> `"nameplate1"`-style token to any `UnitX` function. See
-> [Unit tokens](#unit-tokens-nameplaten).
+> **Token stability gotcha.** Like modern WoW, the `arg1` token is
+> positional — `"nameplate3"` today may resolve to a different unit
+> after the slot vacates and shifts. If you need a per-unit hash key
+> for cross-event bookkeeping, call `UnitGUID(arg1)` and store the
+> GUID instead. See [Unit tokens](#unit-tokens-nameplaten) for the
+> ordering rules.
**Implementation notes**
@@ -4483,19 +4482,20 @@ the unit going out of range.
### `C_NamePlate.GetNamePlateForGUID(guidString)`
Same as `GetNamePlateForUnit` but takes the `"0xHHHHHHHHHHHHHHHH"`
-GUID-string form. Designed to pair with the
-[NAME_PLATE_UNIT_ADDED / REMOVED](#name_plate_created--name_plate_unit_added--name_plate_unit_removed-events)
-events, whose payload is the unit GUID rather than a token.
+GUID-string form. Useful when you've stored a unit GUID across
+events (e.g., converted the positional `"nameplateN"` token to a
+GUID via `UnitGUID(arg1)` at `NAME_PLATE_UNIT_ADDED` time) and need
+the frame later.
```lua
+local platesByGuid = {}
+
local f = CreateFrame("Frame")
f:RegisterEvent("NAME_PLATE_UNIT_ADDED")
f:SetScript("OnEvent", function()
- -- arg1 = GUID string
- local plate = C_NamePlate.GetNamePlateForGUID(arg1)
- if plate then
- -- ... skin / style plate ...
- end
+ -- arg1 = "nameplateN" token; convert to stable GUID for storage
+ local guid = UnitGUID(arg1)
+ platesByGuid[guid] = C_NamePlate.GetNamePlateForGUID(guid)
end)
```
diff --git a/src/nameplate/Events.cpp b/src/nameplate/Events.cpp
index 3428625..531528c 100644
--- a/src/nameplate/Events.cpp
+++ b/src/nameplate/Events.cpp
@@ -21,28 +21,30 @@
// is absorbed because we only compare with the previous *frame's*
// state, not every transient `unit + 0xE60` write.
//
-// Payload shape:
-// - `NAME_PLATE_CREATED` — `arg1` is the nameplate **Frame** (matches
-// modern WoW). The engine's printf-style dispatcher
-// (`FUN_FIRE_EVENT`) only knows `%s`/`%d`/`%u`/`%f` format codes
-// with no "push Lua value" option, so we route this event through
-// a pre-set path: save current `_G.arg1`, set it to the frame, fire
-// with empty format (dispatcher leaves `_G.arg` alone when no
-// codes are parsed), restore.
-// - `NAME_PLATE_UNIT_ADDED` / `_REMOVED` — `arg1` is the unit GUID
-// string. Modern uses a `"nameplateN"` token; vanilla's token
-// resolver isn't extensible. Addons call
-// `C_NamePlate.GetNamePlateForGUID(arg1)` to get the frame.
+// Payload shape (matches modern WoW exactly):
+// - `NAME_PLATE_CREATED` — `arg1` is the nameplate **Frame**. The
+// engine's printf-style dispatcher (`FUN_FIRE_EVENT`) only knows
+// `%s`/`%d`/`%u`/`%f` format codes with no "push Lua value" option,
+// so we route this event through a pre-set path: save current
+// `_G.arg1`, set it to the frame, fire with empty format
+// (dispatcher leaves `_G.arg` alone when no codes are parsed),
+// restore.
+// - `NAME_PLATE_UNIT_ADDED` / `_REMOVED` — `arg1` is the
+// `"nameplateN"` unit token (formatted from the plate's index in
+// `g_orderedGUIDs` at fire time). The token resolves to the unit
+// via the `nameplateN`-aware token resolver in `TokenResolver.cpp`
+// — addons can pass it straight to `UnitName`, `UnitGUID`, etc.,
+// or to `GetNamePlateForUnit` for the frame.
#include "Game.h"
#include "Offsets.h"
#include "event/Custom.h"
-#include "guid/Guid.h"
#include "nameplate/Walk.h"
#include "tick/WorldTick.h"
#include
#include
+#include
#include
#include
#include
@@ -90,15 +92,26 @@ std::unordered_set g_seenPlates;
// shifts later entries down).
std::vector g_orderedGUIDs;
-void FireWithGUID(const char *eventName, uint64_t guid) {
- if (guid == 0)
+// Fire `eventName` with a pre-formatted string as `arg1`. The engine
+// dispatcher's `%s` format code pushes the C string into `_G.arg1`
+// as a Lua string — no escaping concerns for our own input
+// (`"nameplateN"`).
+void FireWithString(const char *eventName, const char *value) {
+ if (value == nullptr)
return;
const int slot = Event::Custom::Lookup(eventName);
if (slot < 0)
return;
- char buf[Guid::STRING_SIZE];
- Guid::FormatAsString(guid, buf, sizeof buf);
- Event::Custom::Fire(slot, "%s", buf);
+ Event::Custom::Fire(slot, "%s", value);
+}
+
+// Format a 1-based nameplate index as `"nameplateN"` for ADDED /
+// REMOVED event payloads. Buffer should be at least 24 bytes — 9
+// for the prefix, up to 10 digits for the index, room for null.
+// Returns `buf` for convenient inline use.
+const char *FormatNamePlateToken(char *buf, size_t bufSize, int oneBasedIndex) {
+ std::snprintf(buf, bufSize, "nameplate%d", oneBasedIndex);
+ return buf;
}
// Fire `eventName` with the nameplate `Frame` set as `_G.arg1`.
@@ -164,28 +177,39 @@ void OnWorldTick() {
});
// Fire CREATED (with the Frame as arg1) for never-before-seen
- // frame pointers; ADDED (with GUID string) for GUIDs not in last
- // tick's snapshot. New ADDED entries also get appended to the
- // ordered GUID list that backs `nameplateN` token resolution.
+ // frame pointers; ADDED (with `"nameplateN"` token as arg1) for
+ // GUIDs not in last tick's snapshot. New entries are appended to
+ // the ordered list *before* firing so the token resolves to the
+ // newly-added plate during the event handler.
for (const auto &kv : g_currentTickPlates) {
if (g_seenPlates.insert(kv.second).second)
FireWithFrame(kEventCreated, const_cast(kv.second));
if (g_lastTickPlates.find(kv.first) == g_lastTickPlates.end()) {
- FireWithGUID(kEventUnitAdded, kv.first);
g_orderedGUIDs.push_back(kv.first);
+ char tokenBuf[24];
+ FireWithString(kEventUnitAdded,
+ FormatNamePlateToken(tokenBuf, sizeof tokenBuf,
+ static_cast(g_orderedGUIDs.size())));
}
}
// Fire REMOVED for GUIDs in last tick's snapshot but not current.
- // Same GUIDs are removed from the ordered list so later plates
- // shift down — matches modern semantics.
+ // We compute the token from the position *before* erasing so the
+ // event payload reflects the slot the unit just vacated; the
+ // handler can still resolve the token to the unit via
+ // `g_orderedGUIDs[slot]` during dispatch. Later plates shift down
+ // when we erase — matches modern semantics.
for (const auto &kv : g_lastTickPlates) {
if (g_currentTickPlates.find(kv.first) == g_currentTickPlates.end()) {
- FireWithGUID(kEventUnitRemoved, kv.first);
auto it = std::find(g_orderedGUIDs.begin(), g_orderedGUIDs.end(),
kv.first);
- if (it != g_orderedGUIDs.end())
- g_orderedGUIDs.erase(it);
+ if (it == g_orderedGUIDs.end())
+ continue;
+ const int oneBased = static_cast(it - g_orderedGUIDs.begin()) + 1;
+ char tokenBuf[24];
+ FireWithString(kEventUnitRemoved,
+ FormatNamePlateToken(tokenBuf, sizeof tokenBuf, oneBased));
+ g_orderedGUIDs.erase(it);
}
}
From 0490ea8a63288071cabe5a2c8d2eaf9886563855 Mon Sep 17 00:00:00 2001
From: Brues <5278969+brues-code@users.noreply.github.com>
Date: Tue, 26 May 2026 14:53:16 -0500
Subject: [PATCH 15/15] nameplate events: ship "nameplateN" tokens, delegate
wrappers to engine
Two intertwined changes that together make nameplate-mod addons
(pfUI) survive pool recycle.
1. NAME_PLATE_UNIT_ADDED / _REMOVED arg1 is now the "nameplateN"
unit token, matching modern WoW exactly. For REMOVED we compute
the token before erasing from g_orderedGUIDs so the handler can
still resolve the leaving unit. Addons that want a stable hash
key call UnitGUID(arg1) to convert.
2. PushNamePlateFrame now delegates to the engine's own
FrameScript_Object::ScriptRegister (0x00701BD0) instead of
maintaining our own luaL_ref'd wrapper cache. Per-pointer the
engine builds a wrapper table once, refs it into the registry,
and stores the refkey at nameplate+0x08 - every code path
(ours, addons' CreateFrame(parent) extraction, the engine's own
internal pushers) then funnels through
rawgeti(REGISTRY, nameplate+0x08) and gets the same Lua table.
The prior approach kept our own wrapper alongside the engine's
once pfUI's CreateFrame inside NAME_PLATE_CREATED caused the
engine to register the underlying frame itself - fields set on
our wrapper (plate.nameplate = decoratedButton) disappeared from
the API surface even though they still existed on the orphaned
table. Single-source-of-truth fixes it. The refcount-pinning
caveat CLAUDE.md warned about is benign for pool-managed
nameplates (never destroyed during a session).
Also clear seenPlates / lastTickPlates / orderedGUIDs on
FrameScript_Initialize so currently-visible plates refire CREATED
+ UNIT_ADDED after /reload - re-presents them to the freshly
reloaded UI, matching modern WoW.
---
src/DllMain.cpp | 11 ++-
src/Offsets.h | 24 +++++++
src/nameplate/Events.cpp | 18 +++++
src/nameplate/Info.cpp | 145 +++++++++++++++------------------------
src/nameplate/Walk.h | 22 +++---
5 files changed, 116 insertions(+), 104 deletions(-)
diff --git a/src/DllMain.cpp b/src/DllMain.cpp
index 0d5ac41..afeb8f8 100644
--- a/src/DllMain.cpp
+++ b/src/DllMain.cpp
@@ -38,9 +38,14 @@ static bool __fastcall FrameScript_Initialize_h() {
// table is rebuilt at a fresh allocation; the old slots are stale.
Event::Custom::PrepareForReload();
- // Drop the nameplate wrapper-cache refkeys for the same reason:
- // the Lua registry that holds them is about to be freed.
- NamePlate::Info::PrepareForReload();
+ // Clear the nameplate diff state so currently-visible plates
+ // refire CREATED and UNIT_ADDED on the next tick — re-presents
+ // them to the freshly reloaded UI, matching modern WoW. The
+ // wrappers themselves now live in the engine's own registry
+ // (via `FrameScript_Object::ScriptRegister`), which the engine
+ // tears down and rebuilds across the Lua reset — no per-module
+ // cache to clear here.
+ NamePlate::Events::PrepareForReload();
// Persist the name cache before the engine starts tearing down.
// This hook fires on both `/reload` and `/logout` (the engine
diff --git a/src/Offsets.h b/src/Offsets.h
index 28121a1..70db8bd 100644
--- a/src/Offsets.h
+++ b/src/Offsets.h
@@ -200,6 +200,30 @@ enum Offsets {
// Registers a single global Lua function. __fastcall(name, func).
FUN_FRAMESCRIPT_REGISTER_FUNCTION = 0x00704120,
+ // `FrameScript_Object::ScriptRegister(this, name)` — `__thiscall`,
+ // `this` = a `CFrameScriptObject *`. On first call (when `this+0x04`
+ // is zero) builds a Lua wrapper table `{[0] = lightuserdata(this)}`
+ // with `_G["__framescript_meta"]` as metatable, `luaL_ref`s it into
+ // the registry, stores the refkey at `this+0x08`. Always increments
+ // `this+0x04` (the Lua-side refcount). Optional `name` argument
+ // installs `_G[name] = wrapper` for engine-named frames.
+ //
+ // We call this in `PushNamePlateFrame` so the engine and our own
+ // C_NamePlate getters operate on the **same** wrapper table —
+ // every push through `lua_rawgeti(REGISTRY, this+0x08)` (the
+ // canonical engine path) lands on the same Lua object pfUI
+ // received in `NAME_PLATE_CREATED`, so addon-set fields
+ // (`plate.nameplate = decoratedButton`) survive engine-side
+ // re-fetches. Earlier note in `Info.cpp` warned about pinning the
+ // refcount; for pool-managed nameplates the engine never
+ // un-registers them anyway, so the pin is benign.
+ FUN_FRAMESCRIPT_OBJECT_SCRIPT_REGISTER = 0x00701BD0,
+
+ // `this+0x04` Lua refcount, incremented by `ScriptRegister`. We
+ // read it as a "has the engine ever exposed this CObject to Lua"
+ // probe — equivalent to checking `this+0x08 > 0` but more direct.
+ OFF_COBJECT_LUA_REFCOUNT = 0x04,
+
// Direct cvar lookup — `__fastcall(const char *name) → CVar* | NULL`.
// Hash-table by-name lookup over the CVar registry; same call
// `Script_GetCVar` makes internally before the engine wraps the
diff --git a/src/nameplate/Events.cpp b/src/nameplate/Events.cpp
index 531528c..9ab123f 100644
--- a/src/nameplate/Events.cpp
+++ b/src/nameplate/Events.cpp
@@ -220,6 +220,24 @@ void OnWorldTick() {
static const Tick::WorldTick::AutoSubscribe _tickSub{&OnWorldTick};
+// Called from `FrameScript_Initialize_h` ahead of the engine's Lua
+// teardown. Clears the diff state alongside `NamePlate::Info`'s
+// wrapper-cache reset so that on the first post-reload tick every
+// currently-visible plate refires `NAME_PLATE_CREATED` and
+// `NAME_PLATE_UNIT_ADDED`. Without this, addons that decorate via
+// CREATED (pfUI: builds its overlay button per-pointer) never see
+// the existing plates after a `/reload` — `g_seenPlates` would
+// suppress every refire, and the freshly-built wrapper would lack
+// the addon's `.nameplate` field.
+//
+// `g_orderedGUIDs` is also cleared so the post-reload token indices
+// start at `nameplate1` again, matching the order plates re-fire in.
+void PrepareForReload() {
+ g_seenPlates.clear();
+ g_lastTickPlates.clear();
+ g_orderedGUIDs.clear();
+}
+
// Exposed via `nameplate/Walk.h` so the `nameplateN` token resolver
// in `TokenResolver.cpp` can map an index to a GUID without seeing
// the internal vector.
diff --git a/src/nameplate/Info.cpp b/src/nameplate/Info.cpp
index 6f10c6d..20a3e55 100644
--- a/src/nameplate/Info.cpp
+++ b/src/nameplate/Info.cpp
@@ -58,7 +58,6 @@
#include "nameplate/Walk.h"
#include
-#include
namespace NamePlate::Info {
@@ -68,51 +67,13 @@ using NamePlate::Walk::ForEachNamePlatedUnit;
using NamePlate::Walk::kOffUnitNamePlate;
using LuaRawGetI_t = void(__fastcall *)(void *L, int idx, int n);
-using LuaPushLightUserdata_t = void(__fastcall *)(void *L, void *p);
-using LuaSetMetatable_t = int(__fastcall *)(void *L, int idx);
-using LuaRefRef_t = int(__fastcall *)(void *L, int t);
+using ScriptRegister_t = void(__fastcall *)(void *this_, void *edx_unused,
+ const char *name);
using TokenToGUID_t = uint64_t(__fastcall *)(const char *token);
using ResolveByGUID_t = void *(__fastcall *)(int type, const char *debugName,
uint32_t guidLo, uint32_t guidHi,
int priority);
-constexpr uintptr_t kFunLuaPushLightUserdata = 0x006F3A20;
-constexpr uintptr_t kFunLuaSetMetatable = 0x006F4020;
-constexpr int kLuaGlobalsIndex = -10001;
-constexpr const char *kFrameMetatableGlobal = "__framescript_meta";
-
-// Wrapper-table identity cache for default vanilla nameplates.
-// Without this, every `PushNamePlateFrame` call for an unregistered
-// plate produces a *different* Lua table from `PushFreshFrameWrapper`
-// — so addon-set fields on the wrapper (pfUI sets `plate.nameplate =
-// styledButton` on the table it gets in `NAME_PLATE_CREATED`) don't
-// survive a later `GetNamePlateForUnit('nameplateN')` call. We pin
-// the first wrapper in the Lua registry via `luaL_ref` and reuse
-// that refkey for every subsequent push for the same nameplate
-// pointer. Bound by the engine's CGNamePlateFrame freelist
-// high-water mark (<80 typical, see `Events.cpp` g_seenPlates).
-std::unordered_map g_wrapperRefkeys;
-
-// Build a fresh frame wrapper on the Lua stack: `{[0] = frame}` with
-// `_G["__framescript_meta"]` as metatable. Same shape the engine's
-// frame-registration helper builds, minus the registry-cache step
-// (which we skip to avoid pinning the frame's refcount).
-void PushFreshFrameWrapper(void *L, void *frame) {
- auto pushLight = reinterpret_cast(
- kFunLuaPushLightUserdata);
- auto setMetatable = reinterpret_cast(
- kFunLuaSetMetatable);
-
- Game::Lua::NewTable(L);
- Game::Lua::PushNumber(L, 0);
- pushLight(L, frame);
- Game::Lua::RawSet(L, -3);
-
- Game::Lua::PushString(L, kFrameMetatableGlobal);
- Game::Lua::GetTable(L, kLuaGlobalsIndex);
- setMetatable(L, -2);
-}
-
} // namespace
static int __fastcall Script_GetNamePlates(void *L) {
@@ -154,66 +115,70 @@ static int __fastcall Script_GetNamePlateGUIDs(void *L) {
// frame-push path. Internal callers (Script_GetNamePlates etc.) call
// it through the unqualified name (they live in the same namespace).
//
-// Path 1 — engine-registered (`refKey > 0`): use the engine's
-// per-frame registry slot. Real refkeys come from addons that
-// built their own plate via `CreateFrame`; default vanilla plates
-// never hit this path (their `+0x08` is `LUA_NOREF`).
+// Delegate wrapper construction to the engine's own
+// `FrameScript_Object::ScriptRegister`. First call for a never-seen
+// nameplate builds a `{[0] = lightuserdata}` table with the
+// framescript metatable, `luaL_ref`s it into the registry, and
+// writes the refkey to `nameplate + 0x08` (`OFF_COBJECT_LUA_REGISTRY_REF`).
+// Subsequent calls find a populated refkey and just rawgeti.
//
-// Path 2 — our cached wrapper: for unregistered plates we keep one
-// wrapper table per nameplate pointer pinned in the Lua registry,
-// so every call returns the *same* table. Without this, addons
-// like pfUI that stash `plate.nameplate = decorated` on the
-// wrapper received via `NAME_PLATE_CREATED` see their field
-// missing on the next `GetNamePlateForUnit` call (different
-// wrapper table, fresh metatable, no carried fields).
+// Why delegate instead of building our own wrapper:
//
-// Path 3 — build a wrapper, pin it via `luaL_ref`, leave it on the
-// stack. First time we see this nameplate.
+// - **Single source of truth.** Every code path that pushes this
+// frame — ours via this function, addons' `CreateFrame("Type",
+// "name", plate)` extracting the parent, the engine's own internal
+// pushers — funnels through `rawgeti(REGISTRY, plate+0x08)` and
+// gets the same Lua table. Earlier "build our own wrapper +
+// `luaL_ref` it" approach kept a side cache, but pfUI's
+// `CreateFrame("Button", "name", parent)` inside `NAME_PLATE_CREATED`
+// ends up causing the engine to register the parent itself —
+// the engine's wrapper diverges from ours, and later
+// `GetNamePlateForUnit` calls return the engine's bare wrapper
+// instead of our decorated one. Addon fields on the wrapper
+// (`plate.nameplate = styledButton`) disappear from the API's
+// perspective even though they still exist on the orphaned
+// table.
//
-// Both lookup paths validate the rawgeti'd value is actually a
-// table before returning — guards against stale registry slots
-// across `/reload`, where the Lua state rebuilds but our caches
-// persist. `PrepareForReload()` (below) clears the cache up front,
-// but if it's somehow missed, the type check still keeps callers
-// from observing a non-table.
+// - **Refcount-pinning is benign here.** `ScriptRegister`
+// unconditionally increments `nameplate + 0x04`, which normally
+// keeps the engine from GC-ing the CObject. Nameplates are
+// pool-managed and never destroyed during a session, so pinning
+// doesn't actually leak anything. We call `ScriptRegister` at
+// most once per nameplate pointer (gated by `refKey <= 0`), so
+// the refcount tops out at one increment per pool slot.
+//
+// Defensive fallback: if the registry slot exists but isn't a table
+// any more (some other code freed it), we re-register. This shouldn't
+// happen in practice — engines only unref on frame destruction —
+// but a stale type check keeps callers from ever seeing a non-table.
void PushNamePlateFrame(void *L, void *nameplate) {
auto rawgeti = reinterpret_cast(
Offsets::FUN_FRAMESCRIPT_PUSH_OBJECT);
+ auto scriptRegister = reinterpret_cast(
+ static_cast(Offsets::FUN_FRAMESCRIPT_OBJECT_SCRIPT_REGISTER));
- const int engineRefKey = *reinterpret_cast(
+ int refKey = *reinterpret_cast(
static_cast(nameplate) + Offsets::OFF_COBJECT_LUA_REGISTRY_REF);
- if (engineRefKey > 0) {
- rawgeti(L, Game::Lua::REGISTRY_INDEX, engineRefKey);
- if (Game::Lua::Type(L, -1) == Game::Lua::TYPE_TABLE)
- return;
- Game::Lua::SetTop(L, -2); // stale; fall through
- }
-
- auto cached = g_wrapperRefkeys.find(nameplate);
- if (cached != g_wrapperRefkeys.end()) {
- rawgeti(L, Game::Lua::REGISTRY_INDEX, cached->second);
- if (Game::Lua::Type(L, -1) == Game::Lua::TYPE_TABLE)
- return;
- Game::Lua::SetTop(L, -2);
- g_wrapperRefkeys.erase(cached); // registry slot is dead, rebuild
+ if (refKey <= 0) {
+ scriptRegister(nameplate, nullptr, nullptr);
+ refKey = *reinterpret_cast(
+ static_cast(nameplate) + Offsets::OFF_COBJECT_LUA_REGISTRY_REF);
}
- PushFreshFrameWrapper(L, nameplate);
- // Duplicate so `luaL_ref` can consume one copy while the other
- // stays on the stack as our return value.
- Game::Lua::PushValue(L, -1);
- auto refRef = reinterpret_cast(
- static_cast(Offsets::LUA_REF_REF));
- const int newRef = refRef(L, Game::Lua::REGISTRY_INDEX);
- g_wrapperRefkeys[nameplate] = newRef;
-}
+ rawgeti(L, Game::Lua::REGISTRY_INDEX, refKey);
+ if (Game::Lua::Type(L, -1) == Game::Lua::TYPE_TABLE)
+ return;
+ Game::Lua::SetTop(L, -2);
-// Called from `FrameScript_Initialize_h` ahead of the engine's Lua
-// teardown so we don't leak refkeys into a destroyed registry. The
-// nameplate frames themselves persist across `/reload`, but the
-// registry slots they pointed to are about to be freed.
-void PrepareForReload() {
- g_wrapperRefkeys.clear();
+ // Slot got freed somewhere — re-register to rebuild a fresh
+ // wrapper and slot. Zero the refcount so `ScriptRegister`
+ // re-enters its build branch.
+ *reinterpret_cast(
+ static_cast(nameplate) + Offsets::OFF_COBJECT_LUA_REFCOUNT) = 0;
+ scriptRegister(nameplate, nullptr, nullptr);
+ refKey = *reinterpret_cast(
+ static_cast(nameplate) + Offsets::OFF_COBJECT_LUA_REGISTRY_REF);
+ rawgeti(L, Game::Lua::REGISTRY_INDEX, refKey);
}
// GUID → nameplate Frame pushed on stack. Pushes nil if the GUID
diff --git a/src/nameplate/Walk.h b/src/nameplate/Walk.h
index ec9bff7..bddb1d3 100644
--- a/src/nameplate/Walk.h
+++ b/src/nameplate/Walk.h
@@ -26,19 +26,12 @@
namespace NamePlate::Info {
// Pushes the nameplate Frame at `nameplate` onto the Lua stack.
-// Returns a stable wrapper table — the same table for the same
-// nameplate pointer across every call — so addon-set fields on the
-// wrapper (pfUI: `plate.nameplate`) survive between
-// `NAME_PLATE_CREATED` and a later `GetNamePlateFor*` lookup.
-// Defined in `Info.cpp`.
+// Returns the engine's canonical wrapper table — the same one
+// `rawgeti(REGISTRY, nameplate + 0x08)` resolves to anywhere else
+// in the engine — so addon-set fields (pfUI: `plate.nameplate`)
+// survive every roundtrip through our API. Defined in `Info.cpp`.
void PushNamePlateFrame(void *L, void *nameplate);
-// Clears the wrapper-cache map. Called from `FrameScript_Initialize`
-// before the engine tears down the Lua registry on `/reload`; the
-// next push for each plate then builds a fresh wrapper pinned in
-// the freshly-rebuilt registry.
-void PrepareForReload();
-
} // namespace NamePlate::Info
namespace NamePlate::Events {
@@ -50,6 +43,13 @@ namespace NamePlate::Events {
// REMOVED), stable within a frame.
uint64_t GetGUIDByIndex(int oneBased);
+// Clears the per-tick diff state (seen pointers, last tick's
+// snapshot, ordered GUID list) so the post-`/reload` tick refires
+// CREATED + UNIT_ADDED for every currently-visible plate. Called
+// from `FrameScript_Initialize_h` alongside the wrapper-cache reset
+// in `NamePlate::Info::PrepareForReload`.
+void PrepareForReload();
+
} // namespace NamePlate::Events
namespace NamePlate::Walk {