Compare commits

...
3 Commits
Author SHA1 Message Date
Brues 0332e49ac2 UnitNameFromGUID: resolve via object manager + FUN_OBJECT_GET_NAME
The original implementation only consulted the SMSG_NAME_QUERY_RESPONSE-
fed player NameCache, which only populates for GUIDs the engine has
explicitly queried — so currently-synced players (just-targeted, in
view, etc.) typically miss until the engine sends a name query.

Switch to vanilla's own `Script_UnitName` path: resolve the GUID via
`FUN_CLNT_OBJ_MGR_OBJECT_PTR` with TYPEMASK_OBJECT, then call the
engine's polymorphic name getter `FUN_OBJECT_GET_NAME`. That routes
through the player NameCache AND the creature cache, so both player
and NPC GUIDs return real names for any currently-synced unit. Gate
on the "UNKNOWNOBJECT" / "Unknown Being" sentinels so non-unit
objects fall through. Persistent NameCache fallback kept for ex-units
that have left the engine's sync window.
2026-06-17 20:52:30 -05:00
Brues b1b564a533 Add UnitSubName(unit) — NPC subtitle / title text
Modern WoW returns the subtitle as UnitName's second return; vanilla
1.12's UnitName returns only one value, leaving no direct route to
the subtitle. UnitSubName reads it straight off the engine's creature
cache (`[unit + 0xB30] → [+0x10]`), no tooltip scrape required.

Returns nil for players, unsynced NPCs, and NPCs with no subtitle.
2026-06-17 20:34:53 -05:00
Brues 444875ae38 Add UnitNameFromGUID(guid) → (name, realm)
Modern backport that reuses GetPlayerInfoByGUID's lookup chain
(engine NameCache → persistent on-disk fallback) but pushes only
(name, realm) — narrower return shape for callers that only need
the name. realm is always "" in vanilla (single-realm, engine
doesn't populate per-player realm).
2026-06-17 20:16:25 -05:00
5 changed files with 251 additions and 2 deletions
+2 -2
View File
@@ -45,7 +45,7 @@ Full per-function reference: **[docs/API.md](docs/API.md)**.
| [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.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` |
| [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`, `UnitNameFromGUID` |
| [Quest](docs/API.md#quest) | `C_QuestLog.GetQuestDetails`, `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.CastAtCursor`, `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` |
| [SpellBook](docs/API.md#spellbook) | `C_SpellBook.GetCurrentLevelSpells`, `C_SpellBook.GetSpellLevelLearned`, `C_SpellBook.GetSpellSkillLine`, `C_SpellBook.IsAutoAttackSpellBookItem`, `C_SpellBook.IsRangedAutoAttackSpellBookItem`, `FindSpellBookSlotByID` |
@@ -54,7 +54,7 @@ Full per-function reference: **[docs/API.md](docs/API.md)**.
| [Talent](docs/API.md#talent) | `GetTalentIDByIndex`, `GetTalentSpellID` |
| [Time](docs/API.md#time) | `C_DateAndTime.AdjustTimeByDays`, `C_DateAndTime.AdjustTimeByMinutes`, `C_DateAndTime.CompareCalendarTime`, `C_DateAndTime.GetCalendarTimeFromEpoch`, `C_DateAndTime.GetCurrentCalendarTime`, `C_DateAndTime.GetSecondsUntilDailyReset`, `C_DateAndTime.GetServerTimeLocal`, `C_Timer.After`, `C_Timer.NewTicker`, `C_Timer.NewTimer`, `GetServerTime` |
| [UIColor](docs/API.md#uicolor) | `C_UIColor.GetColors` (backports `GlobalColor.dbc`; companion addon wraps each row with `ColorMixin` and assigns `_G[baseTag]` + `_G[baseTag.."_CODE"]`) |
| [Unit](docs/API.md#unit) | `GetUnitSpeed`, `UnitClassBase`, `UnitGUID`, `UnitInRange`, `UnitIsAFK`, `UnitIsDND`, `UnitIsFeignDeath`, `UnitIsInMyGuild`, `UnitIsPossessed`, `UnitPower`, `UnitPowerMax`, `UnitPowerType`, `UnitRaceBase`, `UnitStandState`, `UnitTokenFromGUID` |
| [Unit](docs/API.md#unit) | `GetUnitSpeed`, `UnitClassBase`, `UnitGUID`, `UnitInRange`, `UnitIsAFK`, `UnitIsDND`, `UnitIsFeignDeath`, `UnitIsInMyGuild`, `UnitIsPossessed`, `UnitPower`, `UnitPowerMax`, `UnitPowerType`, `UnitRaceBase`, `UnitStandState`, `UnitSubName`, `UnitTokenFromGUID` |
| [UnitAuras](docs/API.md#unitauras) | `C_UnitAuras.GetAuraDataByIndex`, `C_UnitAuras.GetAuraDataBySpellName`, `C_UnitAuras.GetAuraDispelTypeColor`, `C_UnitAuras.GetBuffDataByIndex`, `C_UnitAuras.GetDebuffDataByIndex`, `C_UnitAuras.GetPlayerAuraBySpellID`, `C_UnitAuras.GetUnitAuraBySpellID`, `C_UnitAuras.GetUnitAuras` |
### GlueXML calls
+58
View File
@@ -249,6 +249,7 @@ build instructions.
- [NameCache](#namecache)
- [`GetPlayerInfoByGUID(guid)`](#getplayerinfobyguidguid)
- [`UnitNameFromGUID(guid)`](#unitnamefromguidguid)
- [`C_PlayerCache.GetPlayerInfoByName(name)`](#c_playercachegetplayerinfobynamename)
- [`C_PlayerInfo.GUIDIsPlayer(guid)` / `GUIDIsCreature` / `GUIDIsPet` / `GUIDIsGameObject`](#c_playerinfoguidisplayerguid--guidiscreature--guidispet--guidisgameobject)
- [`C_CreatureInfo.GetCreatureID(guid)`](#c_creatureinfogetcreatureidguid)
@@ -346,6 +347,7 @@ build instructions.
- [Unit](#unit)
- [`UnitGUID(unit)`](#unitguidunit)
- [`UnitTokenFromGUID(guid)`](#unittokenfromguidguid)
- [`UnitSubName(unit)`](#unitsubnameunit)
- [`GetUnitSpeed(unit)`](#getunitspeedunit)
- [`UnitClassBase(unit)`](#unitclassbaseunit)
- [`UnitRaceBase(unit)`](#unitracebaseunit)
@@ -6003,6 +6005,35 @@ instance lives at `0x00C0E228`; entry layout (name, realm, race,
sex, class) was reverse-engineered from the
`SMSG_NAME_QUERY_RESPONSE` write path at `0x0055F310`.
### `UnitNameFromGUID(guid)`
Returns `name, realm` for the player identified by `guid`, or `nil`
if no player with that GUID has been encountered yet. Same lookup
chain as [`GetPlayerInfoByGUID`](#getplayerinfobyguidguid) (engine
NameCache, persistent on-disk fallback when enabled) — just narrower
return shape for callers that only need the name.
```lua
local name, realm = UnitNameFromGUID(UnitGUID("target"))
if name then
ChatFrame1:AddMessage("Target: " .. name)
end
-- Resolving a chat link
local name, realm = UnitNameFromGUID("0x0000000000000777")
```
`realm` is always `""` in vanilla — the engine doesn't populate
per-player realm names and 1.12 has no cross-realm interaction.
We push the empty string rather than `nil` to match the convention
of the other player-info accessors; addons can gate on `realm == ""`.
Doesn't trigger a network query on miss. To populate an unknown
GUID, either let the engine receive a `SMSG_NAME_QUERY_RESPONSE`
through normal chat/target interaction, or call
[`C_PlayerCache.RememberPlayer`](#c_playercacherememberplayerguid-name-classtoken)
explicitly.
### `C_PlayerCache.GetPlayerInfoByName(name)`
Returns
@@ -8251,6 +8282,33 @@ end
> ex-targets, ex-mouseover units, distant players seen in the chat
> log, etc. The engine simply doesn't address those by token.
### `UnitSubName(unit)`
Returns the NPC's subtitle / title string (the small italic text
under their name in the tooltip): `"Innkeeper"`, `"Stable Master"`,
`"<Master Engineer>"`, etc. Returns `nil` for players, unsynced
NPCs, and NPCs with no subtitle.
```lua
/dump UnitSubName("target")
-- "Innkeeper" (Stormwind innkeep)
-- "<Auction House>" (auctioneer)
-- nil (a player, or a wolf in the woods)
```
Modern WoW returns the subtitle as `UnitName`'s second return;
vanilla 1.12's `UnitName` returns only one value, leaving no
direct route to the subtitle. Addons that wanted it had to scrape
`GameTooltip` text, which is fragile and required the unit to be
hovered. `UnitSubName` reads it straight off the engine's creature
cache (`[unit + 0xB30] → [+0x10]`), no tooltip round-trip.
Coverage caveat: the creature cache row is populated when an NPC
becomes visible to the client AND the server has sent its
`CMSG_CREATURE_QUERY_RESPONSE`. The first time a fresh NPC appears
the row may be briefly NULL — usually only one or two frames.
Subsequent calls succeed once the response lands.
### `GetUnitSpeed(unit)`
Returns `currentSpeed, runSpeed, flightSpeed, swimSpeed` — all four
+17
View File
@@ -405,6 +405,23 @@ enum Offsets {
// descriptor at +0x114 — these are sibling classes under CGObject
// with class-specific descriptor offsets.
OFF_UNIT_DESCRIPTOR = 0x110,
// CGCreature client-side creature-data cache row. Populated by the
// engine when an NPC GUID becomes visible (the same row the
// `CreatureCache` SMSG fills with the templated NPC's name,
// subname, etc.). NULL for unsynced units or for player CGUnits
// (players don't have a row in this cache — their name comes from
// the SMSG_NAME_QUERY_RESPONSE-fed name cache at `0x00C0E228`).
//
// Layout of the cache row:
// +0x00 ?
// +0x0C name C-string ptr (alias of UNIT_NAME for creatures)
// +0x10 subName C-string ptr ("Innkeeper", "<Master Trainer>", etc.)
//
// Used by `Script_UnitName`'s creature path and by VanillaMinimapTracking's
// subname-based blip filter (`src/Blips.cpp::ExtractUnitSubName`).
OFF_UNIT_CREATURE_CACHE_ROW = 0xB30,
OFF_CREATURE_CACHE_SUB_NAME = 0x10,
// Pointer to the CGUnit's 8-byte GUID at `*(CGUnit + 0x08)`. Verified
// in `Script_GetInventoryItemLink` at `0x004C8CB0`-`0x004C8CB5`:
// `mov eax, [esi+8]; mov edi, [eax]; mov ecx, [eax+4]` reads the
+89
View File
@@ -41,6 +41,7 @@
#include "guid/Guid.h"
#include <cstdint>
#include <cstring>
namespace Player::Info {
@@ -182,6 +183,92 @@ int PushFromNameCacheEntry(void *L, const char *name,
return 7;
}
// `UnitNameFromGUID(guid)` → `(name, realm)`. Modern WoW backport;
// vanilla 1.12 doesn't expose any GUID → name accessor at the Lua
// surface, so addons that need to resolve a chat link or combat-log
// GUID can't get a name without round-tripping through a unit token.
//
// Resolution order (matches vanilla's own `Script_UnitName` at
// `0x00517020`):
// 1. Resolve the GUID through the object manager. Any currently-
// synced unit — player you can see, NPC in interact range,
// target / mouseover / party / raid member — gets a `CGObject *`
// back. Call the engine's canonical polymorphic name getter
// `FUN_OBJECT_GET_NAME` on it; this routes through the player
// NameCache for players AND the creature cache for NPCs, so it
// works for both with a single call.
// 2. If the unit isn't currently synced (ex-target who logged off,
// player encountered earlier in chat but no longer visible),
// fall back to the persistent NameCache (when enabled).
//
// `realm` is always `""` in vanilla — the engine doesn't populate
// per-player realm names and 1.12 has no cross-realm interaction.
// We push the empty string rather than nil so callers don't have to
// special-case the second return; modern WoW returns nil, but
// `realm == ""` is just as easy to gate on.
//
// Returns nothing on:
// - Missing or non-string arg (raises an error rather than return).
// - Unparseable GUID string.
// - Zero / NULL GUID.
// - GUID not resolvable in the object manager AND not in persistent
// NameCache (i.e., we've never seen this unit).
//
// Doesn't trigger a network query on miss — passive read, same as
// `GetPlayerInfoByGUID`. Use `C_PlayerCache.RememberPlayer` or let
// the engine populate via normal interaction.
int __fastcall Script_UnitNameFromGUID(void *L) {
if (!Game::Lua::IsString(L, 1)) {
Game::Lua::Error(L, "Usage: UnitNameFromGUID(\"0x...\")");
return 0;
}
const char *guidStr = Game::Lua::ToString(L, 1);
uint32_t hi, lo;
if (!ParseGUID(guidStr, hi, lo))
return 0;
if (hi == 0 && lo == 0)
return 0;
// Object-manager path — TYPEMASK_OBJECT (0x01) is permissive; the
// name getter at `FUN_OBJECT_GET_NAME` does its own type check and
// safely returns the "UNKNOWNOBJECT" sentinel for non-unit
// objects (gameobjects etc.), so we just gate on the sentinel
// rather than pre-filtering by typemask.
using ObjectPtr_t = void *(__fastcall *)(uint32_t typeMask,
const char *debugMsg,
uint32_t guidLo,
uint32_t guidHi,
int debugCode);
using GetName_t = const char *(__thiscall *)(void *obj, int *outFlags);
auto objectPtr = reinterpret_cast<ObjectPtr_t>(
Offsets::FUN_CLNT_OBJ_MGR_OBJECT_PTR);
void *obj = objectPtr(Offsets::TYPEMASK_OBJECT, nullptr, lo, hi, 0);
if (obj != nullptr) {
auto getName = reinterpret_cast<GetName_t>(Offsets::FUN_OBJECT_GET_NAME);
const char *name = getName(obj, nullptr);
if (name != nullptr && *name != '\0' &&
std::strcmp(name, "UNKNOWNOBJECT") != 0 &&
std::strcmp(name, "Unknown Being") != 0) {
Game::Lua::PushString(L, name);
Game::Lua::PushString(L, "");
return 2;
}
}
// Object-manager miss / sentinel — fall back to the persistent
// NameCache (when enabled). Covers ex-units no longer in the
// engine's sync window.
const std::string *cachedName = nullptr;
const NameCache::Entry *cached = NameCache::Lookup(
(static_cast<uint64_t>(hi) << 32) | lo, &cachedName);
if (cached == nullptr || cachedName == nullptr || cachedName->empty())
return 0;
Game::Lua::PushString(L, cachedName->c_str());
Game::Lua::PushString(L, "");
return 2;
}
// `C_PlayerCache.GetPlayerInfoByName(name)` — name-keyed lookup over
// the persistent NameCache. Returns `(localizedClass, englishClass,
// localizedRace, englishRace, sex, name, realm, guid)` or nothing if
@@ -212,6 +299,8 @@ int __fastcall Script_C_PlayerCache_GetPlayerInfoByName(void *L) {
static void RegisterLuaFunctions() {
Game::Lua::RegisterGlobalFunction("GetPlayerInfoByGUID",
&Script_GetPlayerInfoByGUID);
Game::Lua::RegisterGlobalFunction("UnitNameFromGUID",
&Script_UnitNameFromGUID);
Game::Lua::RegisterTableFunction("C_PlayerCache", "GetPlayerInfoByName",
&Script_C_PlayerCache_GetPlayerInfoByName);
}
+85
View File
@@ -0,0 +1,85 @@
// 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 <https://www.gnu.org/licenses/>.
// `UnitSubName(unit)` — returns the NPC's subname / title (the small
// italic text under their name in the tooltip): "Innkeeper",
// "Stable Master", "<Master Engineer>", etc. Modern WoW exposes this
// via `UnitName`'s second return; vanilla 1.12's `UnitName` returns
// only one value, so addons that want the subtitle have to roll their
// own — until now.
//
// Returns `nil` for:
// - Unresolvable unit token (`target` with nothing targeted, empty
// `partyN` slot, etc.).
// - Player units — they don't have a row in the creature cache.
// - NPCs whose creature data hasn't synced yet (rare; happens when
// the unit comes into view but the SMSG_CREATURE_QUERY_RESPONSE
// hasn't arrived).
// - NPCs with no subname (most non-vendor / non-trainer creatures).
//
// Raises a Lua error on missing / non-string `unit` argument — same
// semantics as the engine's `UnitName`.
//
// Implementation: walks the creature cache row at `[unit + 0xB30]` →
// `[+0x10]` (subname C string). Verified path; same one
// VanillaMinimapTracking's blip subname filter uses.
#include "Game.h"
#include "Offsets.h"
#include <cstdint>
namespace Unit::SubName {
namespace {
using ResolveUnitToken_t = void *(__fastcall *)(const char *token);
int __fastcall Script_UnitSubName(void *L) {
if (!Game::Lua::IsString(L, 1)) {
Game::Lua::Error(L, "Usage: UnitSubName(\"unit\")");
return 0;
}
const char *token = Game::Lua::ToString(L, 1);
if (token == nullptr)
return 0;
auto resolve = reinterpret_cast<ResolveUnitToken_t>(
Offsets::FUN_RESOLVE_UNIT_TOKEN);
auto *unit = static_cast<const uint8_t *>(resolve(token));
if (unit == nullptr)
return 0;
auto *cacheRow = *reinterpret_cast<const uint8_t *const *>(
unit + Offsets::OFF_UNIT_CREATURE_CACHE_ROW);
if (cacheRow == nullptr)
return 0;
auto *subName = *reinterpret_cast<const char *const *>(
cacheRow + Offsets::OFF_CREATURE_CACHE_SUB_NAME);
if (subName == nullptr || *subName == '\0')
return 0;
Game::Lua::PushString(L, subName);
return 1;
}
void RegisterLuaFunctions() {
Game::Lua::RegisterGlobalFunction("UnitSubName", &Script_UnitSubName);
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace
} // namespace Unit::SubName