fix(unit): consult the name cache only when no unit object is held

UnitClassBase and UnitRaceBase now follow stock Script_UnitClass
(0x00518350) and Script_UnitRace (0x00518200) branch for branch.
"player" reads the selected-character record; any other token goes
through GetGUIDFromName + ClntObjMgrObjectPtr(TYPEMASK_UNIT) and reads
the held object's descriptor byte (+0x79 class, +0x78 race). The
NameCache record (+0x140 / +0x138, null callback) is peeked only when
no object is held.

Before, the NameCache was also peeked when a held object's byte was 0.
That never changed a result (creatures are not in the NameCache) but
resolved the token twice and did not match the engine's branch.

The comments and API.md also claimed creatures return nil, that class
IDs run 1-9, and that UnitClass has a third return. None of that is
true: creatures go through the same ChrClasses/ChrRaces lookup as in
UnitClass/UnitRace, Druid is 11, and stock returns exactly two values.
This commit is contained in:
Brues
2026-11-06 11:52:58 -06:00
parent fac3be3a0b
commit a106d86969
3 changed files with 88 additions and 134 deletions
+31 -31
View File
@@ -25599,10 +25599,9 @@ Notes and limits:
Returns `(classFile, classID)` — the locale-independent class
token plus the numeric classID. The token is one of
`"WARRIOR"`, `"PALADIN"`, `"HUNTER"`, `"ROGUE"`, `"PRIEST"`,
`"SHAMAN"`, `"MAGE"`, `"WARLOCK"`, `"DRUID"`; the classID matches
the integer `UnitClass`'s third return surfaces (1=Warrior,
2=Paladin, 3=Hunter, 4=Rogue, 5=Priest, 7=Shaman, 8=Mage,
9=Warlock, 11=Druid — 6 and 10 are unused here).
`"SHAMAN"`, `"MAGE"`, `"WARLOCK"`, `"DRUID"`. The classID is
1=Warrior, 2=Paladin, 3=Hunter, 4=Rogue, 5=Priest, 7=Shaman,
8=Mage, 9=Warlock, 11=Druid. IDs 6 and 10 are not used.
```lua
local token, id = UnitClassBase("player") -- "WARRIOR", 1
@@ -25610,29 +25609,27 @@ local token = UnitClassBase("target") -- works for any synced unit
local color = RAID_CLASS_COLORS[UnitClassBase("party1")]
```
Addons use the token for class detection because the
`UnitClass(unit)` returns a localized first return (e.g.
`"Krieger"` on a German client), which is fine for display but
breaks any addon code that keys on `if class == "WARRIOR"`. The
classID second return is a ClassicAPI extension — the standard
`UnitClassBase` returns the token only — but it saves callers
from chaining `UnitClass(unit)` just to get the integer.
Use the token for class checks. The first return of
`UnitClass(unit)` is localized (for example, `"Krieger"` on a
German client). It is good for display, but a check such as
`if class == "WARRIOR"` fails on other locales. The classID is a
ClassicAPI extension: the standard `UnitClassBase` returns only
the token.
Reads the class byte from `UNIT_FIELD_BYTES_0` (descriptor `+0x79`,
the same byte `UnitClass`'s general-token path reads) and looks up
the english filename at `ChrClasses.dbc::Filename` (record `+0x38`).
Both fields are broadcast / static, so remote players' class is
current regardless of distance.
`UnitClassBase` finds the unit and its class the same way as
`UnitClass`, so the two functions always agree:
- `"player"` gives a result before the player is in the world
(for example, during addon load).
- Party and raid members that are out of range give a result if
the client has their name data.
- Creatures give the same class as `UnitClass` gives for them.
Returns `(nil, nil)` for:
- `target` with no current target, empty `partyN` / `raidN` slots,
or units whose descriptor isn't loaded yet
- creatures and other non-player units (their class byte indexes a
different table; the class-byte lookup naturally returns nil for
out-of-range / zero values)
Returns `(nil, nil)` when `UnitClass` returns `(nil, nil)`. For
example, `"target"` with no target, or an empty `partyN` / `raidN`
slot.
Throws a Lua error on missing / non-string `unit` argument — same
shape as `UnitClass` itself.
Throws a Lua error if `unit` is missing or is not a string, the
same as `UnitClass`.
### `UnitRaceBase(unit)`
@@ -25646,14 +25643,17 @@ local token, id = UnitRaceBase("player") -- "Human", 1
local token = UnitRaceBase("target") -- works for any synced unit
```
Sibling to [`UnitClassBase`](#unitclassbaseunit). Same problem
(the `UnitRace(unit)` returns a localized name — `"Mensch"`,
`"Orc"`, etc.); same solution (locale-independent token straight
from the DBC). Reads byte 0 of `UNIT_FIELD_BYTES_0` (descriptor
`+0x78`) and looks up `ChrRaces.dbc::Filename` (`+0x3C`).
Use the token for race checks. The first return of
`UnitRace(unit)` is localized (for example, `"Mensch"` on a German
client).
Returns `(nil, nil)` for unresolvable units and non-player units
(creature race bytes don't index `ChrRaces.dbc`).
`UnitRaceBase` finds the unit and its race the same way as
`UnitRace`, so the two functions always agree. The notes for
[`UnitClassBase`](#unitclassbaseunit) about `"player"`,
out-of-range group members and creatures also apply here.
Returns `(nil, nil)` when `UnitRace` returns `(nil, nil)`. Throws a
Lua error if `unit` is missing or is not a string.
### `ClosestUnitPosition(creatureID)`
+33 -61
View File
@@ -19,45 +19,29 @@ namespace Unit::ClassBase {
namespace {
// `UnitClassBase(unit) → (classFile, classID)` — returns the locale-
// independent class token ("WARRIOR", "PALADIN", "HUNTER", "ROGUE",
// "PRIEST", "SHAMAN", "MAGE", "WARLOCK", "DRUID") plus the numeric
// classID (1-9 in vanilla), or `(nil, nil)` if the unit can't be
// resolved.
// `UnitClassBase(unit) → (classFile, classID)` — the locale-independent
// class token ("WARRIOR", "PALADIN", "HUNTER", "ROGUE", "PRIEST",
// "SHAMAN", "MAGE", "WARLOCK", "DRUID") plus the numeric classID
// (1=Warrior, 2=Paladin, 3=Hunter, 4=Rogue, 5=Priest, 7=Shaman, 8=Mage,
// 9=Warlock, 11=Druid; 6 and 10 have no ChrClasses row), or `(nil, nil)`.
//
// Modern addons use this for class detection because `UnitClass`
// returns a localized name — usable for display but unsafe as a
// dictionary key when an addon runs across locales. The English
// token comes straight from `ChrClasses.dbc::Filename` at record
// `+0x38`, so it matches the GLOBAL_STRINGS / addon-side conventions
// (`LOCALIZED_CLASS_NAMES_MALE[token]`).
// Unit detection is a step-for-step mirror of stock `Script_UnitClass`
// (0x00518350), so the two always agree on which unit and which class
// byte; only the pushed fields differ (token + id here, localized name
// + token there — stock returns exactly two values):
// - `"player"` (whole-string SStrCmpI) reads the selected-character
// record's class byte (`VAR_PLAYER_CLASS_BYTE`), filled at EnterWorld,
// so it answers before the player object exists.
// - Any other token: GetGUIDFromName + ClntObjMgrObjectPtr(TYPEMASK_UNIT)
// (= `FUN_RESOLVE_UNIT_TOKEN`). A held object gives its descriptor
// byte `+0x79`; ONLY when no object is held does it peek the NameCache
// record's `+0x140` (null callback, no query) — out-of-range party /
// raid members.
// - The byte indexes ChrClasses by id; an out-of-range id or empty row
// is `(nil, nil)`. Creatures carry a class byte too and go through
// the same lookup, exactly as they do for `UnitClass`.
//
// The classID second return is a vanilla extension — modern's
// `UnitClassBase` returns the token only — but it's free here
// (we already read the byte) and saves callers from also calling
// `UnitClass(unit)` just to get the integer. Matches the values
// `UnitClass`'s third return surfaces: 1=Warrior, 2=Paladin,
// 3=Hunter, 4=Rogue, 5=Priest, 7=Shaman, 8=Mage, 9=Warlock,
// 11=Druid (6 and 10 are post-vanilla).
//
// Resolves the unit via the engine's token resolver (so `"player"`,
// `"target"`, `"partyN"`, `"raidN"`, `"mouseover"` all work) and
// reads the class byte from `UNIT_FIELD_BYTES_0` at descriptor
// `+0x79`. Looks the byte up in `ChrClasses.dbc`. Works for any
// synced unit; remote players' class is broadcast as an UpdateField.
//
// Returns `(nil, nil)` for:
// - missing / unresolvable units (`target` with nothing targeted,
// empty `partyN` slot, unloaded descriptor)
// - non-player units (creatures have a class byte too but it
// indexes a different table — `ChrClasses.dbc[0]` is unused
// and `ChrClasses.dbc[N>9]` is out of range, so out-of-range
// class bytes naturally return nil here)
// - `ChrClasses.dbc` rows with no Filename (shouldn't happen on
// real client data but we handle defensively)
//
// Throws a Lua error on missing / non-string `unit` argument — same
// semantics as `UnitClass` itself.
// Throws `Usage:` on a missing / non-string argument, like `UnitClass`.
int __fastcall Script_UnitClassBase(void *L) {
if (!Game::Lua::IsString(L, 1)) {
Game::Lua::Error(L, "Usage: UnitClassBase(\"unit\")");
@@ -70,34 +54,22 @@ int __fastcall Script_UnitClassBase(void *L) {
return 2;
}
// Mirror Script_UnitClass: the literal "player" token reads the
// login-time class byte global (populated at character-enter, before
// the in-world descriptor exists), so this resolves at addon-load
// instead of returning nil until the player object spawns. All other
// tokens go through the unit descriptor.
uint8_t classByte = 0;
if (Unit::Identity::IsPlayerToken(token)) {
classByte = Game::Read<uint8_t>(Offsets::VAR_PLAYER_CLASS_BYTE);
} else if (auto *unit =
static_cast<const uint8_t *>(Game::ResolveUnitToken(token))) {
auto *desc = Game::Read<const uint8_t *>(unit, Offsets::OFF_UNIT_DESCRIPTOR);
if (desc != nullptr)
classByte = *(desc + Offsets::OFF_UNIT_DESCRIPTOR_CLASS_BYTE);
} else {
auto *unit = static_cast<const uint8_t *>(Game::ResolveUnitToken(token));
if (unit != nullptr) {
auto *desc =
Game::Read<const uint8_t *>(unit, Offsets::OFF_UNIT_DESCRIPTOR);
if (desc != nullptr)
classByte = *(desc + Offsets::OFF_UNIT_DESCRIPTOR_CLASS_BYTE);
}
// No live descriptor (out of range / different map) → fall back to the
// engine's player-info cache, matching what UnitClass itself does out
// of range (class at OFF_PLAYER_INFO_CLASS). `token` is already valid
// here — resolve() raises on a bad token before this — so GuidForToken
// won't raise.
if (classByte == 0) {
const uint8_t *rec = Unit::Identity::PlayerInfoRecord(
Unit::Identity::GuidForToken(token));
if (rec != nullptr)
classByte = static_cast<uint8_t>(
Game::Read<uint32_t>(rec, Offsets::OFF_PLAYER_INFO_CLASS));
}
// No object held: the NameCache, as UnitClass does. `token` already
// passed the resolver, so GuidForToken won't raise.
const uint8_t *rec =
Unit::Identity::PlayerInfoRecord(Unit::Identity::GuidForToken(token));
if (rec != nullptr)
classByte = static_cast<uint8_t>(
Game::Read<uint32_t>(rec, Offsets::OFF_PLAYER_INFO_CLASS));
}
if (classByte == 0) {
Game::Lua::PushNil(L);
+24 -42
View File
@@ -19,25 +19,19 @@ namespace Unit::RaceBase {
namespace {
// `UnitRaceBase(unit) → (raceFile, raceID)` — returns the locale-
// independent race token (`"Human"`, `"Orc"`, `"Dwarf"`, `"NightElf"`,
// `"Scourge"`, `"Tauren"`, `"Gnome"`, `"Troll"`) plus the numeric
// raceID (1..8 in vanilla), or `(nil, nil)` if the unit can't be
// resolved.
// `UnitRaceBase(unit) → (raceFile, raceID)` — the locale-independent
// race token (`"Human"`, `"Orc"`, `"Dwarf"`, `"NightElf"`, `"Scourge"`,
// `"Tauren"`, `"Gnome"`, `"Troll"`) plus the numeric raceID (1..8), or
// `(nil, nil)`.
//
// Sibling to `UnitClassBase` — same descriptor-byte + DBC.Filename
// chain, just reading `UNIT_FIELD_BYTES_0` byte 0 (race) instead of
// byte 1 (class) and looking up `ChrRaces.dbc::Filename` at `+0x3C`
// (locale-independent string, distinct from the localized `Name[9]`
// at `+0x44` that `UnitRace` already returns).
//
// Race byte is broadcast in UpdateField data, so this works for any
// synced unit token (player, target, party / raid, mouseover,
// inspect targets — anything FUN_RESOLVE_UNIT_TOKEN handles).
//
// Returns `(nil, nil)` for unresolvable units (empty target /
// party slot, unloaded descriptor), unit-byte 0 (uninitialized /
// NPC), or out-of-range race bytes (no matching ChrRaces.dbc row).
// Unit detection is a step-for-step mirror of stock `Script_UnitRace`
// (0x00518200), the same shape as `UnitClassBase` / `UnitClass`:
// `"player"` reads the selected-character record (`VAR_PLAYER_RACE_BYTE`);
// any other token takes a held object's descriptor byte `+0x78`, and ONLY
// when no object is held peeks the NameCache record's `+0x138`. The byte
// indexes ChrRaces by id; the token is `ChrRaces.dbc::Filename` at `+0x3C`
// (stock pushes the localized `Name[locale]` at `+0x44` plus this same
// field). Race 0 (most creatures) or a missing row is `(nil, nil)`.
int __fastcall Script_UnitRaceBase(void *L) {
if (!Game::Lua::IsString(L, 1)) {
Game::Lua::Error(L, "Usage: UnitRaceBase(\"unit\")");
@@ -50,34 +44,22 @@ int __fastcall Script_UnitRaceBase(void *L) {
return 2;
}
// Mirror Script_UnitRace: the literal "player" token reads the
// login-time race byte global (populated at character-enter, before
// the in-world descriptor exists), so this resolves at addon-load
// instead of returning nil until the player object spawns. All other
// tokens go through the unit descriptor.
uint8_t raceByte = 0;
if (Unit::Identity::IsPlayerToken(token)) {
raceByte = Game::Read<uint8_t>(Offsets::VAR_PLAYER_RACE_BYTE);
} else if (auto *unit =
static_cast<const uint8_t *>(Game::ResolveUnitToken(token))) {
auto *desc = Game::Read<const uint8_t *>(unit, Offsets::OFF_UNIT_DESCRIPTOR);
if (desc != nullptr)
raceByte = *(desc + Offsets::OFF_UNIT_DESCRIPTOR_RACE_BYTE);
} else {
auto *unit = static_cast<const uint8_t *>(Game::ResolveUnitToken(token));
if (unit != nullptr) {
auto *desc =
Game::Read<const uint8_t *>(unit, Offsets::OFF_UNIT_DESCRIPTOR);
if (desc != nullptr)
raceByte = *(desc + Offsets::OFF_UNIT_DESCRIPTOR_RACE_BYTE);
}
// No live descriptor (out of range / different map) → fall back to the
// engine's player-info cache, matching what UnitRace itself does out of
// range (race at OFF_PLAYER_INFO_RACE). `token` is already valid here —
// resolve() raises on a bad token before this — so GuidForToken won't
// raise.
if (raceByte == 0) {
const uint8_t *rec = Unit::Identity::PlayerInfoRecord(
Unit::Identity::GuidForToken(token));
if (rec != nullptr)
raceByte = static_cast<uint8_t>(
Game::Read<uint32_t>(rec, Offsets::OFF_PLAYER_INFO_RACE));
}
// No object held: the NameCache, as UnitRace does. `token` already
// passed the resolver, so GuidForToken won't raise.
const uint8_t *rec =
Unit::Identity::PlayerInfoRecord(Unit::Identity::GuidForToken(token));
if (rec != nullptr)
raceByte = static_cast<uint8_t>(
Game::Read<uint32_t>(rec, Offsets::OFF_PLAYER_INFO_RACE));
}
if (raceByte == 0) {
Game::Lua::PushNil(L);