Compare commits

...
15 Commits
Author SHA1 Message Date
Brues 4ba82e59e4 table: reset getn length counter in table.wipe
table.wipe nil'd every key via lua_next + rawset(nil) but left the
Lua 5.0 length counter untouched. In 5.0 a table's table.insert/getn
length lives out of band from its keys (a t.n field, else a weak
sizes[t] entry in the registry's LUA_SIZES table), so it survived the
clear. After a wipe, getn stayed at its old value and subsequent
table.insert appended past the wiped slots -- t[1] stayed nil. The
3.3.5 (Lua 5.1) twipe this was ported from has no getn legacy, which
is why the ported logic looked complete.

Fix: call luaL_setn(L, 1, 0) after the clear loop. Bound the engine's
luaL_setn (FUN_006f4ea0, verified against 5.0.2; located via
table.insert FUN_007fb6b0) as Game::Lua::SetN.

Repro before: table.insert(t,1); table.insert(t,2); table.wipe(t);
table.insert(t,3) -> getn 3, t[1] nil, t[3]=3.
After: getn 2, t[1]=3, t[2]=4. Verified in-game.
2026-07-14 00:37:19 -05:00
Brues b05c15ffa7 input: evict GetMouseButtonClicked so it reads nil outside a handler
Previously g_lastButton persisted as the last click forever. Modern
WoW returns the button only while a mouse handler is running and nil
otherwise (verified in 3.3.5: GetMouseButtonClicked pushes a
frame-manager field [mgr+0x1234] that is set before a mouse handler
dispatches and cleared after, so it is nil when null).

We cannot bracket the handler the same way: WoW dispatches the
frame-script mouse handlers deferred from the WH_GETMESSAGE pump, so
eviction is time-based via the existing per-frame WorldTick. The
button is still captured on each WM button message; EvictTick clears
it after a couple of quiet ticks with nothing held. A held button
keeps it (OnDragStart still reads it) and it survives the whole frame
its click lands in, so multiple addons hooking one OnClick all see it,
avoiding the multi-reader hole a consume-on-read latch would have.

Verified in-game: value matches the GLOBAL_MOUSE_DOWN button arg inside
a handler, and reads nil once clicks settle.
2026-07-13 23:41:33 -05:00
Brues 4daf6b9364 input: add GetMouseButtonClicked()
Returns the button name of the most recent mouse-button message
("LeftButton"/.../"Button5"), or nil before any click. Rides the
existing WH_GETMESSAGE stream in GlobalMouse that already backs
GLOBAL_MOUSE_DOWN/UP and IsMouseButtonDown, so no engine hook is
needed: g_lastButton is captured on every press/release, and the
Script fn pushes it (null tail-jumps to pushnil for the "no click
yet" case).

The hook runs at message-dequeue, synchronously before the engine
dispatches the click to the Lua handler, so the value reflects the
button of the handler currently running (and is readable from nested
helpers where arg1 isn't in scope). Not cleared between events -- no
"handler ended" signal in the 1.12 stream -- so outside a mouse
handler it returns the last-clicked button rather than nil; documented.
2026-07-13 19:02:51 -05:00
Brues 4c6c9deae6 namecache: share the on-disk cache across accounts per server
Move the persistent name cache from the per-account
WTF\Account\<acct>\<realm>\ClassicAPI_NameCache.txt to a shared
WTF\ClassicAPI\<realmList>\<realm>\ClassicAPI_NameCache.txt, so alts on
different accounts of the same server pool into one file instead of each
rebuilding their own.

Keyed by the realmlist address (unambiguous server identity — can't collide
across unrelated private servers, the same reason CredStore scopes on it)
plus the realm name. Since this path isn't under Account\, WoW doesn't
create it: EnsureParentDirs makes the tree before saving. On first load with
no shared file yet, a pre-existing per-account cache is migrated in (load
old file, mark dirty so the next save lands in the shared location); the old
file is left untouched.

The PersistentNameCacheEnabled / NameCacheScanEnabled toggles stay
per-account in WTF\Account\<acct>\ClassicAPI.txt — each account opts in
independently; only the cache contents are shared.
2026-07-13 12:52:52 -05:00
Brues 983cc3bdff unit: add UnitDistanceSquared
Backport retail's UnitDistanceSquared(unit) -> (distanceSquared,
checkedPosition): the squared world distance from the player to the unit,
plus a flag for whether both positions were readable. Squared because
almost all distance logic is a threshold compare or nearest-unit sort,
neither of which needs the sqrt — which is why retail never shipped a plain
UnitDistance.

Added to the existing Unit::Range module alongside UnitInRange (both now
share one PlayerToUnitDistSq compute off the CGObject::GetPosition vtable
virtual). Center-to-center, self-contained (reads the position in C — no
SuperWoW dependency, and leaves SuperWoW's own UnitPosition untouched).
Verified in-game to match SuperWoW UnitPosition math to float precision.

On a position miss returns (0, false); no self-quirk, so
UnitDistanceSquared("player") is a legitimate (0, true).
2026-07-13 00:23:58 -05:00
Brues 18ac033381 spell: add GetSpellLevelInfo + GetSpellRequiredTargetLevel
Expose the target-level gate for ranked beneficial auras — e.g. Divine
Spirit Rank 1 (14752) can only be applied to a target level >= 20.

- C_Spell.GetSpellLevelInfo(spellID) -> spellLevel, baseLevel, maxLevel
  (raw Spell.dbc level fields; nil for an unknown spell).
- GetSpellRequiredTargetLevel(spellID) (global, plus C_Spell mirror) ->
  the minimum castable target level (0 = no restriction; nil if unknown).

The computed helper mirrors the server's SpellMgr::SelectAuraRankForLevel:
returns spellLevel - 10 only when the spell is a ranked positive aura
(spellLevel > 10, not passive, has a Rank string, and has an APPLY_AURA
effect on a friendly/party/raid target or an APPLY_AREA_AURA_PARTY effect),
else 0. All fields read directly from the client's Spell.dbc.

The explicit MinTargetLevel / MaxTargetLevel fields the server also checks
are server-only columns (absent from the 1.12 client DBC, whose tail is
SpellFamilyName / SpellFamilyFlags / MaxAffectedTargets), so the spellLevel
rule is the only client-readable target-level mechanism.
2026-07-12 23:28:41 -05:00
Brues 81434f7014 aura: evict phantom cache entries on removal and descriptor reconcile
C_UnitAuras' descriptor-drop fallback could surface a "phantom" aura that
was actually gone — enumerated with a spellId but SetUnitAura rendering an
empty tooltip. Two cases, both fixed:

- Owner cancels a buff (e.g. a flask) while dead: the server keeps
  death-persistent auras through death without changing any field, so death
  itself fires no OnAuraRemoved. Any OnAuraRemoved we do receive for one is
  genuine (cancel/dispel/expiry), so always evict the cache entry — including
  death-persistent auras on dead units. Fixes the phantom for the caster and
  nearby observers (both receive the removal packet).

- A buff cancelled while its owner is out of an observer's range sends no
  packet, so the stale entry lingers. Reconcile on return: when a unit's
  descriptor holds >=1 raw aura it's in view and fully synced, so it's
  authoritative — evict any cached entry whose spell isn't in the array
  (Aura::Data::ReconcileCache -> Aura::Source::EvictAbsent, run at the top of
  the fallback path). Reads raw slots so a flag-hidden aura still counts as
  present; skips an empty array (can't tell out-of-range from buffless).

The only residual is a buff cancelled while the owner is still out of range,
which vanilla can't report until they return.
2026-07-12 17:10:27 -05:00
Brues 22605dad64 tooltip: format compare-item percent stats via PERCENTAGE_STRING
Block/dodge/parry chance and crit/hit are native vanilla percentages, so
their compare-tooltip delta read as a bare number ("-5 Block"). Attach the
percent via the locale-aware PERCENTAGE_STRING global so it renders
"-5% Block" without touching any _SHORT label (correct in every locale).

IsPercentStat marks the percent keys; FormatPercent substitutes the value
into PERCENTAGE_STRING's first conversion slot by hand (never feeds the
locale format to snprintf) with a plain "%" fallback. Flat stats (Defense,
Attack Power, Block Value) stay bare; dropped the now-redundant trailing
" %" from the English fallback labels.
2026-07-12 14:23:54 -05:00
Brues 7378e0b128 unit: add UnitIsMinion / UnitIsPet / UnitIsOtherPlayersPet
Player-minion classification for a unit, all owner-based:

- UnitIsMinion(unit): owned by a player (pet/guardian/totem). Primary
  check is the owner GUID, since summoned guardians ("X's Minion") carry
  an owner but not UNIT_FLAG_PLAYER_CONTROLLED; the flag is a fallback.
- UnitIsPet(unit): a player's controllable pet specifically -- owned by a
  player AND the engine's creature-family pet/minion classifier
  FUN_00605570 == 1, the same signal driving the UNITNAME_TITLE_PET vs
  _MINION tooltip label. The GUID can't be used (pets and guardians share
  the 0xF14 prefix) nor can PLAYER_CONTROLLED (a Minion can have it).
  ClassicAPI extension.
- UnitIsOtherPlayersPet(unit): owner is a player other than you; matches
  5.4.8's owner-based Script_UnitIsOtherPlayersPet.

Owner GUID read from CHARMEDBY (+0x10, verified via Script_UnitIsCharmed)
and CREATEDBY (+0x20, verified via the unit title builder FUN_0052FD30).
Verified in-game: Salvatory's Pet is a pet; Jimmyf's/Methl's Minion is a
minion but not a pet.
2026-07-12 03:06:11 -05:00
Brues 94b0545631 player: add C_PlayerInfo.CanUseItem (equip/use requirement gate)
The proficiency/level/class/skill/spell/rep gate that answers "can this
player equip or use this item" -- the tooltip's red-requirement logic,
distinct from IsEquippableItem (fits a slot) and IsUsableItem (on-use
castable). Mirrors the requirement checks FUN_0052B650 inlines:

  1. proficiency  - VAR_PROFICIENCY_TABLE[m_class] & (1<<m_subClass)
  2. RequiredLevel
  3. AllowableClass / AllowableRace
  4. RequiredSkill  - effective rank via FUN_SKILL_LINE_TO_SLOT
  5. RequiredSpell  - spell-knowledge bitmap (crafting specializations
                      like Armorsmith, proficiency spells)
  6. RequiredHonorRank / RequiredCityRank - PvP honor rank + medal mask
  7. RequiredReputation - current standing vs the reaction-min band

Verified in-game: mail boots false (proficiency), Riding mount false
without skill 150, Plans: Lionheart Helm false until Armorsmith learned.
Residual gap: a ranked RequiredSpell known only at a different rank.

Adds OFF_CGPLAYER_HONOR_RANK / OFF_CGPLAYER_PVP_MEDALS.
2026-07-12 01:54:05 -05:00
Brues 243ab37836 tooltip: localize item-compare stat labels via GlobalStrings keys
Compare-tooltip stat deltas now resolve their display label through
_G[key] (the WoW GlobalString), falling back to the built-in English
only when undefined. LabelFor tries _G[key], then _G[key.."_SHORT"],
then the English fallback: primary stats / resistances / CURRENTLY_EQUIPPED
resolve directly, while the rating/regen tokens GetItemStats keys on
(ITEM_MOD_CRIT_MELEE_RATING, ...) — whose own global is a "%s"
description — map to their compact, translatable _SHORT sibling for
display. StatAccum keys are left untouched (the GetItemStats contract,
confirmed against a TBC dump: ratings key on the non-_SHORT token).

The format guard now rejects only real printf conversions (%d/%s/%1$s),
letting a literal "%" unit through.

enUS.lua gains the ITEM_MOD_*_SHORT, ITEM_DELTA_DESCRIPTION and format
keys (native RESISTANCE*/CURRENTLY_EQUIPPED left to the client). The
non-US locales (deDE/esES/frFR/ruRU/zhCN/zhTW) are auto-filled from
Blizzard Classic (1.15) per-locale GlobalStrings; only the custom
BINDING_NAME_AOELOOT stays commented as an enUS fallback.

Fixes #8
2026-07-11 16:40:10 -05:00
Brues a12fff6704 Small refactor for clarity 2026-07-11 16:28:46 -05:00
Brues d747c699f5 Tighten up pfUI HookScript compat
Defer CreateFrame hook application until the updatenotify module loads, rather than applying it globally so it only affects the actionar module
2026-07-11 16:21:43 -05:00
Brues 88d76c04ad item: add IsUsableItem / C_Item.IsUsableItem (on-use usability)
Item analog of IsUsableSpell, returning (usable, noMana) for an item's
on-use ability -- not equippability. Gates on the item having an ON_USE
spell, its use requirements (RequiredLevel, AllowableClass/Race), then
the on-use spell's castability via the engine helper FUN_SPELL_IS_USABLE
(0x006E3D60), which sets noMana on power shortfall.

Shares the on-use spell-slot walk with C_Item.GetItemSpell: promoted
Item::Spell::FindOnUseSpellIDInRecord to the header so both use it
instead of re-deriving the trigger scan.
2026-07-11 14:55:20 -05:00
Brues 47d4b9c660 loot: stable rollID, winnerIdx, FULL_UPDATE dispatch, timestamp, Clear
Bring C_LootHistory closer to the MoP contract and make a UI keying on it
robust across the ring saturating.

- GetItem now returns rollID, itemLink, numPlayers, isDone, winnerIdx,
  timestamp (was itemLink-first, with the winner as a name string). rollID
  is a stable monotonic ID assigned at creation, so addons can key expanded
  / highlighted rows on it instead of the volatile ring index. winnerIdx is
  the winner 1-based player index for GetPlayerInfo, matching MoP; the winner
  is inserted into the roster if their own roll packet never arrived, so it
  always resolves. timestamp is a ClassicAPI extension (GetTime()-comparable
  seconds).
- LOOT_HISTORY_FULL_UPDATE is now actually dispatched (was reserved-only):
  on a new item appearing (roll open) and on ring eviction, fired before the
  granular ROLL_CHANGED / ROLL_COMPLETE.
- Add C_LootHistory.Clear() (ClassicAPI extension) to wipe history and fire
  FULL_UPDATE.
- Factor roll/winner roster insertion into a shared FindOrAddPlayer helper.

Docs updated for the new signature, Clear, and the FULL_UPDATE semantics.
2026-07-10 21:03:51 -05:00
28 changed files with 2161 additions and 179 deletions
+8 -7
View File
@@ -20,18 +20,19 @@ if C_AddOns.DoesAddOnExist('pfUI') then
-- X-Website tag and left alone. The wrapper is removed as soon as the
-- actionbar module finishes loading so it affects nothing else.
local website = GetAddOnMetadata("pfUI", "X-Website")
if not website or not string.find(GetAddOnMetadata("pfUI", "X-Website") or '', 'brues') then
if not website or not strfind(website, 'brues') then
local _createFrame = CreateFrame
CreateFrame = function(frameType, name, parent, template)
if frameType == "Button" and string.find(name or "", "pfActionBar") then
local frame = _createFrame(frameType, name, parent, template)
local HookedCreateFrame = function(frameType, name, parent, template)
local frame = _createFrame(frameType, name, parent, template)
if frameType == "Button" and strfind(name or "", "pfActionBar") then
frame.HookScript = false
return frame
end
return _createFrame(frameType, name, parent, template)
return frame
end
hooksecurefunc(pfUI, 'LoadModule', function(frame, moduleName)
if moduleName == "actionbar" then
if moduleName == "updatenotify" then
CreateFrame = HookedCreateFrame
elseif moduleName == "actionbar" then
CreateFrame = _createFrame
end
end)
+42 -1
View File
@@ -9,4 +9,45 @@ ITEMS_IN_INVENTORY = "%d |4Gegenstand:Gegenstände; im Inventar";
ITEM_SLOTS_IGNORED = "%d |4Platz:Plätze; ignoriert";
ITEM_MISSING = "%s fehlt";
ITEMS_NOT_IN_INVENTORY = "%d |4Gegenstand fehlt:Gegenstände fehlen;";
TARGETFOCUS = "Fokussierte Einheit anvisieren";
TARGETFOCUS = "Fokussierte Einheit anvisieren";
-- BINDING_NAME_AOELOOT = "AoE Loot";
COPPER_AMOUNT_SYMBOL = "K";
GENERIC_FRACTION_STRING = "%d/%d";
GOLD_AMOUNT_SYMBOL = "G";
HOURS_MINUTES_SECONDS = "%.2d:%.2d:%.2d";
ITEM_DELTA_DESCRIPTION = "Wenn Ihr diesen Gegenstand ersetzt, werden sich folgende Werte ändern:";
ITEM_MOD_MANA_SHORT = "Mana";
ITEM_MOD_HEALTH_SHORT = "Gesundheit";
ITEM_MOD_AGILITY_SHORT = "Beweglichkeit";
ITEM_MOD_STRENGTH_SHORT = "Stärke";
ITEM_MOD_INTELLECT_SHORT = "Intelligenz";
ITEM_MOD_SPIRIT_SHORT = "Willenskraft";
ITEM_MOD_STAMINA_SHORT = "Ausdauer";
ITEM_MOD_ATTACK_POWER_SHORT = "Angriffskraft";
ITEM_MOD_RANGED_ATTACK_POWER_SHORT = "Distanzangriffskraft";
ITEM_MOD_CRIT_MELEE_RATING_SHORT = "Kritischer Trefferwert (Nah)";
ITEM_MOD_CRIT_RANGED_RATING_SHORT = "Kritischer Trefferwert (Distanz)";
ITEM_MOD_HIT_MELEE_RATING_SHORT = "Trefferwert (Nah)";
ITEM_MOD_HIT_RANGED_RATING_SHORT = "Trefferwert (Distanz)";
ITEM_MOD_HIT_SPELL_RATING_SHORT = "Trefferwert (Zauber)";
ITEM_MOD_CRIT_SPELL_RATING_SHORT = "Kritischer Trefferwert (Zauber)";
ITEM_MOD_SPELL_DAMAGE_DONE_SHORT = "Bonusschaden";
ITEM_MOD_SPELL_HEALING_DONE_SHORT = "Bonusheilung";
ITEM_MOD_MANA_REGENERATION_SHORT = "Manaregeneration";
ITEM_MOD_DEFENSE_SKILL_RATING_SHORT = "Verteidigung";
ITEM_MOD_DODGE_RATING_SHORT = "Ausweichen";
ITEM_MOD_PARRY_RATING_SHORT = "Parieren";
ITEM_MOD_BLOCK_RATING_SHORT = "Blocken";
ITEM_MOD_BLOCK_VALUE_SHORT = "Blocken";
ITEM_MOD_DAMAGE_PER_SECOND_SHORT = "Schaden pro Sekunde";
LARGE_NUMBER_SEPERATOR = ".";
MINUTES_SECONDS = "%.2d:%.2d";
PERCENTAGE_STRING = "%d%%";
SILVER_AMOUNT_SYMBOL = "S";
SLASH_CLEARFOCUS1 = "/fokusaufheben";
SLASH_CLEARFOCUS2 = "/clearfocus";
SLASH_EQUIP_SET1 = "/Ausrüstungsset";
SLASH_EQUIP_SET2 = "/equipset";
SLASH_FOCUS1 = "/fokus";
SLASH_FOCUS2 = "/focus";
LINK_TRADESKILL_TOOLTIP = "Hier klicken, um einen Link zu Eurem Beruf zu erstellen.";
+24
View File
@@ -7,6 +7,30 @@ GENERIC_FRACTION_STRING = "%d/%d";
GOLD_AMOUNT_SYMBOL = "g";
HOURS_MINUTES_SECONDS = "%.2d:%.2d:%.2d";
ITEM_DELTA_DESCRIPTION = "If you replace this item, the following stat changes will occur:";
ITEM_MOD_MANA_SHORT = "Mana";
ITEM_MOD_HEALTH_SHORT = "Health";
ITEM_MOD_AGILITY_SHORT = "Agility";
ITEM_MOD_STRENGTH_SHORT = "Strength";
ITEM_MOD_INTELLECT_SHORT = "Intellect";
ITEM_MOD_SPIRIT_SHORT = "Spirit";
ITEM_MOD_STAMINA_SHORT = "Stamina";
ITEM_MOD_ATTACK_POWER_SHORT = "Attack Power";
ITEM_MOD_RANGED_ATTACK_POWER_SHORT = "Ranged Attack Power";
ITEM_MOD_CRIT_MELEE_RATING_SHORT = "Critical Strike (Melee)";
ITEM_MOD_CRIT_RANGED_RATING_SHORT = "Critical Strike (Ranged)";
ITEM_MOD_HIT_MELEE_RATING_SHORT = "Hit (Melee)";
ITEM_MOD_HIT_RANGED_RATING_SHORT = "Hit (Ranged)";
ITEM_MOD_HIT_SPELL_RATING_SHORT = "Hit (Spell)";
ITEM_MOD_CRIT_SPELL_RATING_SHORT = "Critical Strike (Spell)";
ITEM_MOD_SPELL_DAMAGE_DONE_SHORT = "Spell Damage";
ITEM_MOD_SPELL_HEALING_DONE_SHORT = "Spell Healing";
ITEM_MOD_MANA_REGENERATION_SHORT = "Mana Regeneration";
ITEM_MOD_DEFENSE_SKILL_RATING_SHORT = "Defense";
ITEM_MOD_DODGE_RATING_SHORT = "Dodge";
ITEM_MOD_PARRY_RATING_SHORT = "Parry";
ITEM_MOD_BLOCK_RATING_SHORT = "Block";
ITEM_MOD_BLOCK_VALUE_SHORT = "Block Value";
ITEM_MOD_DAMAGE_PER_SECOND_SHORT = "Damage Per Second";
ITEMS_EQUIPPED = "%d |4item:items; equipped";
ITEMS_IN_INVENTORY = "%d |4item:items; in inventory";
ITEMS_NOT_IN_INVENTORY = "%d |4item:items; missing";
+42 -1
View File
@@ -9,4 +9,45 @@ ITEMS_IN_INVENTORY = "En inventario: %d |4objeto:objetos;";
ITEM_SLOTS_IGNORED = "Ignorado: %d |4casilla:casillas;";
ITEM_MISSING = "Falta: %s";
ITEMS_NOT_IN_INVENTORY = "Faltan: %d |4objeto:objetos;";
TARGETFOCUS = "Fijar unidad objetivo";
TARGETFOCUS = "Fijar unidad objetivo";
-- BINDING_NAME_AOELOOT = "AoE Loot";
COPPER_AMOUNT_SYMBOL = "c";
GENERIC_FRACTION_STRING = "%d/%d";
GOLD_AMOUNT_SYMBOL = "o";
HOURS_MINUTES_SECONDS = "%.2d:%.2d:%.2d";
ITEM_DELTA_DESCRIPTION = "Si reemplazas este objeto, se producirán los siguientes cambios en las estadísticas:";
ITEM_MOD_MANA_SHORT = "Maná";
ITEM_MOD_HEALTH_SHORT = "Salud";
ITEM_MOD_AGILITY_SHORT = "agilidad";
ITEM_MOD_STRENGTH_SHORT = "fuerza";
ITEM_MOD_INTELLECT_SHORT = "intelecto";
ITEM_MOD_SPIRIT_SHORT = "espíritu";
ITEM_MOD_STAMINA_SHORT = "aguante";
ITEM_MOD_ATTACK_POWER_SHORT = "Poder de ataque";
ITEM_MOD_RANGED_ATTACK_POWER_SHORT = "Poder de ataque a distancia";
ITEM_MOD_CRIT_MELEE_RATING_SHORT = "Golpe crítico (cuerpo a cuerpo)";
ITEM_MOD_CRIT_RANGED_RATING_SHORT = "Golpe crítico (a distancia)";
ITEM_MOD_HIT_MELEE_RATING_SHORT = "Golpe (cuerpo a cuerpo)";
ITEM_MOD_HIT_RANGED_RATING_SHORT = "Golpe (a distancia)";
ITEM_MOD_HIT_SPELL_RATING_SHORT = "Golpe (hechizo)";
ITEM_MOD_CRIT_SPELL_RATING_SHORT = "Golpe crítico (hechizo)";
ITEM_MOD_SPELL_DAMAGE_DONE_SHORT = "Bon. daño";
ITEM_MOD_SPELL_HEALING_DONE_SHORT = "Bon. sanac.";
ITEM_MOD_MANA_REGENERATION_SHORT = "Regeneración de maná";
ITEM_MOD_DEFENSE_SKILL_RATING_SHORT = "Defensa";
ITEM_MOD_DODGE_RATING_SHORT = "Esquiva";
ITEM_MOD_PARRY_RATING_SHORT = "Parada";
ITEM_MOD_BLOCK_RATING_SHORT = "Bloqueo";
ITEM_MOD_BLOCK_VALUE_SHORT = "Valor de bloqueo";
ITEM_MOD_DAMAGE_PER_SECOND_SHORT = "daño por segundo";
LARGE_NUMBER_SEPERATOR = "";
MINUTES_SECONDS = "%.2d:%.2d";
PERCENTAGE_STRING = "%d%%";
SILVER_AMOUNT_SYMBOL = "p";
SLASH_CLEARFOCUS1 = "/quitarenfoque";
SLASH_CLEARFOCUS2 = "/clearfocus";
SLASH_EQUIP_SET1 = "/conjequip";
SLASH_EQUIP_SET2 = "/equipset";
SLASH_FOCUS1 = "/enfoque";
SLASH_FOCUS2 = "/focus";
LINK_TRADESKILL_TOOLTIP = "Haz clic aquí para crear un vínculo a tu profesión.";
+42 -1
View File
@@ -9,4 +9,45 @@ ITEMS_IN_INVENTORY = "%d |4objet:objets; dans l'inventaire";
ITEM_SLOTS_IGNORED = "%d |4emplacement ignoré:emplacements ignorés;";
ITEM_MISSING = "Objet manquant : %s";
ITEMS_NOT_IN_INVENTORY = "%d |4objet:objets; manquants";
TARGETFOCUS = "Cibler l'unité focalisée";
TARGETFOCUS = "Cibler l'unité focalisée";
-- BINDING_NAME_AOELOOT = "AoE Loot";
COPPER_AMOUNT_SYMBOL = "c";
GENERIC_FRACTION_STRING = "%d/%d";
GOLD_AMOUNT_SYMBOL = "o";
HOURS_MINUTES_SECONDS = "%.2d:%.2d:%.2d";
ITEM_DELTA_DESCRIPTION = "Si vous remplacez cet objet, les caractéristiques suivantes seront modifiées :";
ITEM_MOD_MANA_SHORT = "Mana";
ITEM_MOD_HEALTH_SHORT = "Vie";
ITEM_MOD_AGILITY_SHORT = "Agilité";
ITEM_MOD_STRENGTH_SHORT = "Force";
ITEM_MOD_INTELLECT_SHORT = "Intelligence";
ITEM_MOD_SPIRIT_SHORT = "Esprit";
ITEM_MOD_STAMINA_SHORT = "Endurance";
ITEM_MOD_ATTACK_POWER_SHORT = "Puissance d'attaque";
ITEM_MOD_RANGED_ATTACK_POWER_SHORT = "Puissance d'attaque à distance";
ITEM_MOD_CRIT_MELEE_RATING_SHORT = "Score de critique (mêlée)";
ITEM_MOD_CRIT_RANGED_RATING_SHORT = "Score de critique (distance)";
ITEM_MOD_HIT_MELEE_RATING_SHORT = "Toucher (mêlée)";
ITEM_MOD_HIT_RANGED_RATING_SHORT = "Toucher (distance)";
ITEM_MOD_HIT_SPELL_RATING_SHORT = "Toucher (sorts)";
ITEM_MOD_CRIT_SPELL_RATING_SHORT = "Score de critique (sorts)";
ITEM_MOD_SPELL_DAMAGE_DONE_SHORT = "Bonus aux dégâts";
ITEM_MOD_SPELL_HEALING_DONE_SHORT = "Bonus aux soins";
ITEM_MOD_MANA_REGENERATION_SHORT = "Régénération du mana";
ITEM_MOD_DEFENSE_SKILL_RATING_SHORT = "Défense";
ITEM_MOD_DODGE_RATING_SHORT = "Esquive";
ITEM_MOD_PARRY_RATING_SHORT = "Parade";
ITEM_MOD_BLOCK_RATING_SHORT = "Blocage";
ITEM_MOD_BLOCK_VALUE_SHORT = "Valeur de blocage";
ITEM_MOD_DAMAGE_PER_SECOND_SHORT = "Dégâts par seconde";
LARGE_NUMBER_SEPERATOR = "";
MINUTES_SECONDS = "%.2d:%.2d";
PERCENTAGE_STRING = "%d%%";
SILVER_AMOUNT_SYMBOL = "a";
SLASH_CLEARFOCUS1 = "/clearfocus";
SLASH_CLEARFOCUS2 = "/annulerfocal";
SLASH_EQUIP_SET1 = "/equipset";
SLASH_EQUIP_SET2 = "/équipens";
SLASH_FOCUS1 = "/focus";
SLASH_FOCUS2 = "/focal";
LINK_TRADESKILL_TOOLTIP = "Cliquez ici pour créer un lien vers votre profession.";
+42 -1
View File
@@ -9,4 +9,45 @@ ITEMS_IN_INVENTORY = "%d |4предмет:предмета:предметов;
ITEM_SLOTS_IGNORED = "%d |4ячейка пропущена:ячейки пропущены:ячейки пропущены;";
ITEM_MISSING = "Предмет для ячейки %s отсутствует";
ITEMS_NOT_IN_INVENTORY = "%d |4предмет отсутствует:предмета отсутствует:предметов отсутствуют;";
TARGETFOCUS = "Восстановить цель";
TARGETFOCUS = "Восстановить цель";
-- BINDING_NAME_AOELOOT = "AoE Loot";
COPPER_AMOUNT_SYMBOL = "м";
GENERIC_FRACTION_STRING = "%d/%d";
GOLD_AMOUNT_SYMBOL = "з";
HOURS_MINUTES_SECONDS = "%.2d:%.2d:%.2d";
ITEM_DELTA_DESCRIPTION = "Если вы замените этот предмет, произойдут следующие изменения:";
ITEM_MOD_MANA_SHORT = "к мане";
ITEM_MOD_HEALTH_SHORT = "к здоровью";
ITEM_MOD_AGILITY_SHORT = "к ловкости";
ITEM_MOD_STRENGTH_SHORT = "к силе";
ITEM_MOD_INTELLECT_SHORT = "к интеллекту";
ITEM_MOD_SPIRIT_SHORT = "к духу";
ITEM_MOD_STAMINA_SHORT = "к выносливости";
ITEM_MOD_ATTACK_POWER_SHORT = "к силе атаки";
ITEM_MOD_RANGED_ATTACK_POWER_SHORT = "к силе атаки дальнего боя";
ITEM_MOD_CRIT_MELEE_RATING_SHORT = "к показателю критического удара в ближнем бою";
ITEM_MOD_CRIT_RANGED_RATING_SHORT = "к показателю критического удара в дальнем бою";
ITEM_MOD_HIT_MELEE_RATING_SHORT = "к меткости в ближнем бою";
ITEM_MOD_HIT_RANGED_RATING_SHORT = "к меткости в дальнем бою";
ITEM_MOD_HIT_SPELL_RATING_SHORT = "к меткости заклинаний";
ITEM_MOD_CRIT_SPELL_RATING_SHORT = "к критическому эффекту заклинаний";
ITEM_MOD_SPELL_DAMAGE_DONE_SHORT = "к дополнительному урону";
ITEM_MOD_SPELL_HEALING_DONE_SHORT = "к дополнительному исцелению";
ITEM_MOD_MANA_REGENERATION_SHORT = "к восполнению маны";
ITEM_MOD_DEFENSE_SKILL_RATING_SHORT = "Защита";
ITEM_MOD_DODGE_RATING_SHORT = "Уклонение";
ITEM_MOD_PARRY_RATING_SHORT = "Парирование";
ITEM_MOD_BLOCK_RATING_SHORT = "Блок";
ITEM_MOD_BLOCK_VALUE_SHORT = "к показателю блокирования";
ITEM_MOD_DAMAGE_PER_SECOND_SHORT = "к урону в секунду";
LARGE_NUMBER_SEPERATOR = "";
MINUTES_SECONDS = "%.2d:%.2d";
PERCENTAGE_STRING = "%d%%";
SILVER_AMOUNT_SYMBOL = "с";
SLASH_CLEARFOCUS1 = "/clearfocus";
SLASH_CLEARFOCUS2 = "/сбросфокуса";
SLASH_EQUIP_SET1 = "/комплект";
SLASH_EQUIP_SET2 = "/equipset";
SLASH_FOCUS1 = "/focus";
SLASH_FOCUS2 = "/фокус";
LINK_TRADESKILL_TOOLTIP = "Щелкните здесь, чтобы создать ссылку на вашу профессию.";
+42 -1
View File
@@ -9,4 +9,45 @@ ITEMS_IN_INVENTORY = "背包中有%d件物品";
ITEM_SLOTS_IGNORED = "忽略%d个栏位";
ITEM_MISSING = "%s缺失";
ITEMS_NOT_IN_INVENTORY = "缺少%d件物品";
TARGETFOCUS = "选中焦点目标";
TARGETFOCUS = "选中焦点目标";
-- BINDING_NAME_AOELOOT = "AoE Loot";
COPPER_AMOUNT_SYMBOL = "铜";
GENERIC_FRACTION_STRING = "%d/%d";
GOLD_AMOUNT_SYMBOL = "金";
HOURS_MINUTES_SECONDS = "%.2d:%.2d:%.2d";
ITEM_DELTA_DESCRIPTION = "如果你替换该物品,将会产生以下的属性变更:";
ITEM_MOD_MANA_SHORT = "法力值";
ITEM_MOD_HEALTH_SHORT = "生命值";
ITEM_MOD_AGILITY_SHORT = "敏捷";
ITEM_MOD_STRENGTH_SHORT = "力量";
ITEM_MOD_INTELLECT_SHORT = "智力";
ITEM_MOD_SPIRIT_SHORT = "精神";
ITEM_MOD_STAMINA_SHORT = "耐力";
ITEM_MOD_ATTACK_POWER_SHORT = "攻击强度";
ITEM_MOD_RANGED_ATTACK_POWER_SHORT = "远程攻击强度";
ITEM_MOD_CRIT_MELEE_RATING_SHORT = "爆击(近战)";
ITEM_MOD_CRIT_RANGED_RATING_SHORT = "爆击(远程)";
ITEM_MOD_HIT_MELEE_RATING_SHORT = "命中(近战)";
ITEM_MOD_HIT_RANGED_RATING_SHORT = "命中(远程)";
ITEM_MOD_HIT_SPELL_RATING_SHORT = "命中(法术)";
ITEM_MOD_CRIT_SPELL_RATING_SHORT = "爆击(法术)";
ITEM_MOD_SPELL_DAMAGE_DONE_SHORT = "伤害加成";
ITEM_MOD_SPELL_HEALING_DONE_SHORT = "治疗加成";
ITEM_MOD_MANA_REGENERATION_SHORT = "法力回复";
ITEM_MOD_DEFENSE_SKILL_RATING_SHORT = "防御";
ITEM_MOD_DODGE_RATING_SHORT = "躲闪";
ITEM_MOD_PARRY_RATING_SHORT = "招架";
ITEM_MOD_BLOCK_RATING_SHORT = "格挡";
ITEM_MOD_BLOCK_VALUE_SHORT = "格挡值";
ITEM_MOD_DAMAGE_PER_SECOND_SHORT = "每秒伤害";
LARGE_NUMBER_SEPERATOR = ",";
MINUTES_SECONDS = "%.2d:%.2d";
PERCENTAGE_STRING = "%d%%";
SILVER_AMOUNT_SYMBOL = "银";
SLASH_CLEARFOCUS1 = "/clearfocus";
SLASH_CLEARFOCUS2 = "/clearfocus";
SLASH_EQUIP_SET1 = "/equipset";
SLASH_EQUIP_SET2 = "/equipset";
SLASH_FOCUS1 = "/focus";
SLASH_FOCUS2 = "/focus";
LINK_TRADESKILL_TOOLTIP = "点击这里以创建一个链接到你的专业的快捷方式。";
+42 -1
View File
@@ -9,4 +9,45 @@ ITEMS_IN_INVENTORY = "%d件物品在物品欄中";
ITEM_SLOTS_IGNORED = "忽略%d個欄位";
ITEM_MISSING = "缺少%s物品";
ITEMS_NOT_IN_INVENTORY = "缺少%d件物品";
TARGETFOCUS = "選定鎖定單位";
TARGETFOCUS = "選定鎖定單位";
-- BINDING_NAME_AOELOOT = "AoE Loot";
COPPER_AMOUNT_SYMBOL = "銅";
GENERIC_FRACTION_STRING = "%d/%d";
GOLD_AMOUNT_SYMBOL = "金";
HOURS_MINUTES_SECONDS = "%.2d:%.2d:%.2d";
ITEM_DELTA_DESCRIPTION = "若你更換此裝備,下列屬性將會改變:";
ITEM_MOD_MANA_SHORT = "法力";
ITEM_MOD_HEALTH_SHORT = "生命力";
ITEM_MOD_AGILITY_SHORT = "敏捷";
ITEM_MOD_STRENGTH_SHORT = "力量";
ITEM_MOD_INTELLECT_SHORT = "智力";
ITEM_MOD_SPIRIT_SHORT = "精神";
ITEM_MOD_STAMINA_SHORT = "耐力";
ITEM_MOD_ATTACK_POWER_SHORT = "攻擊強度";
ITEM_MOD_RANGED_ATTACK_POWER_SHORT = "遠程攻擊強度";
ITEM_MOD_CRIT_MELEE_RATING_SHORT = "致命一擊(近戰)";
ITEM_MOD_CRIT_RANGED_RATING_SHORT = "致命一擊(遠程)";
ITEM_MOD_HIT_MELEE_RATING_SHORT = "命中(近戰)";
ITEM_MOD_HIT_RANGED_RATING_SHORT = "命中(遠程)";
ITEM_MOD_HIT_SPELL_RATING_SHORT = "命中(法術)";
ITEM_MOD_CRIT_SPELL_RATING_SHORT = "致命一擊(法術)";
ITEM_MOD_SPELL_DAMAGE_DONE_SHORT = "傷害加成";
ITEM_MOD_SPELL_HEALING_DONE_SHORT = "治療加成";
ITEM_MOD_MANA_REGENERATION_SHORT = "法力恢復";
ITEM_MOD_DEFENSE_SKILL_RATING_SHORT = "防禦";
ITEM_MOD_DODGE_RATING_SHORT = "閃躲";
ITEM_MOD_PARRY_RATING_SHORT = "招架";
ITEM_MOD_BLOCK_RATING_SHORT = "格擋";
ITEM_MOD_BLOCK_VALUE_SHORT = "格擋值";
ITEM_MOD_DAMAGE_PER_SECOND_SHORT = "每秒傷害";
LARGE_NUMBER_SEPERATOR = ",";
MINUTES_SECONDS = "%.2d:%.2d";
PERCENTAGE_STRING = "%d%%";
SILVER_AMOUNT_SYMBOL = "銀";
SLASH_CLEARFOCUS1 = "/clearfocus";
SLASH_CLEARFOCUS2 = "/清除專注目標";
SLASH_EQUIP_SET1 = "/equipset";
SLASH_EQUIP_SET2 = "/裝備套裝";
SLASH_FOCUS1 = "/focus";
SLASH_FOCUS2 = "/專注目標";
LINK_TRADESKILL_TOOLTIP = "點擊這裡以製造一個你專業技能的連結。";
+6 -5
View File
@@ -39,21 +39,22 @@ Full per-function reference: **[docs/API.md](docs/API.md)**.
| [Gossip](docs/API.md#gossip) | `C_GossipInfo.CloseGossip`, `C_GossipInfo.GetActiveQuests`, `C_GossipInfo.GetAvailableQuests`, `C_GossipInfo.GetNumActiveQuests`, `C_GossipInfo.GetNumAvailableQuests`, `C_GossipInfo.GetNumOptions`, `C_GossipInfo.GetOptions`, `C_GossipInfo.GetText`, `C_GossipInfo.SelectActiveQuest`, `C_GossipInfo.SelectAvailableQuest`, `C_GossipInfo.SelectOption`, `C_GossipInfo.SelectOptionByIndex` |
| [GameTooltip](docs/API.md#gametooltip) | `GameTooltip:GetGameObject`, `GameTooltip:GetItem`, `GameTooltip:GetOwner`, `GameTooltip:GetSpell`, `GameTooltip:GetUnitGUID`, `GameTooltip:HasGameObject`, `GameTooltip:HasItem`, `GameTooltip:HasSpell`, `GameTooltip:HasUnit`, `GameTooltip:IsEquippedItem`, `GameTooltip:SetEquipmentSet`, `GameTooltip:SetHyperlinkCompareItem`, `GameTooltip:SetInventoryItemByID`, `GameTooltip:SetItemByGUID`, `GameTooltip:SetItemByID`, `GameTooltip:SetSpellByID`, `GameTooltip:SetTalentByID`, `GameTooltip:SetUnitAura`, `OnTooltipSetItem` (script) |
| [Hooks](docs/API.md#hooks) | `hooksecurefunc` |
| [Input](docs/API.md#input) | `IsLeftAltKeyDown`, `IsLeftControlKeyDown`, `IsLeftShiftKeyDown`, `IsModifierKeyDown`, `IsMouseButtonDown`, `IsRightAltKeyDown`, `IsRightControlKeyDown`, `IsRightShiftKeyDown` |
| [Input](docs/API.md#input) | `GetMouseButtonClicked`, `IsLeftAltKeyDown`, `IsLeftControlKeyDown`, `IsLeftShiftKeyDown`, `IsModifierKeyDown`, `IsMouseButtonDown`, `IsRightAltKeyDown`, `IsRightControlKeyDown`, `IsRightShiftKeyDown` |
| [Instance](docs/API.md#instance) | `GetInstanceInfo` |
| [Item](docs/API.md#item) | `C_Item.DoesItemExist`, `C_Item.DoesItemExistByID`, `C_Item.EquipItemByName`, `C_Item.GetCurrentItemLevel`, `C_Item.GetDetailedItemLevelInfo`, `C_Item.GetEnchantInfo`, `C_Item.GetItemCount`, `C_Item.GetItemFamily`, `C_Item.GetItemGUID`, `C_Item.GetItemIcon`, `C_Item.GetItemIconByID`, `C_Item.GetItemID`, `C_Item.GetItemInfoInstant`, `C_Item.GetItemInventorySlotInfo`, `C_Item.GetItemInventorySlotKey`, `C_Item.GetItemInventoryType`, `C_Item.GetItemInventoryTypeByID`, `C_Item.GetItemLink`, `C_Item.GetItemLocation`, `C_Item.GetItemMaxStackSize`, `C_Item.GetItemMaxStackSizeByID`, `C_Item.GetItemName`, `C_Item.GetItemNameByID`, `C_Item.GetItemQuality`, `C_Item.GetItemQualityByID`, `C_Item.GetItemSellPrice`, `C_Item.GetItemSellPriceByID`, `C_Item.GetItemSetID`, `C_Item.GetItemSetIDByID`, `C_Item.GetItemSetInfo`, `C_Item.GetItemSpell`, `C_Item.GetItemStatDelta`, `C_Item.GetItemStats`, `C_Item.GetItemSubClassInfo`, `C_Item.GetItemUniqueness`, `C_Item.GetItemUniquenessByID`, `C_Item.GetStackCount`, `C_Item.GetWeaponEnchantInfo`, `C_Item.IsBound`, `C_Item.IsEquippableItem`, `C_Item.IsEquippedItem`, `C_Item.IsItemDataCached`, `C_Item.IsItemDataCachedByID`, `C_Item.IsItemGUIDInInventory`, `C_Item.IsItemOpenable`, `C_Item.IsLocked`, `C_Item.LockItem`, `C_Item.LockItemByGUID`, `C_Item.UnlockAllItems`, `C_Item.UnlockItem`, `C_Item.RequestLoadItemData`, `C_Item.RequestLoadItemDataByID`, `C_Item.UseAtCursor`, `C_Item.UseAtUnit`, `C_Item.UseItemByName`, `GetAuctionItemID`, `GetAuctionSellItemID`, `GetAverageItemLevel`, `GetCraftReagentItemID`, `GetInboxItemID`, `GetInventoryItemDurability`, `GetInventoryItemID`, `GetInventoryItemsForSlot`, `GetInventoryItemRepairCost`, `GetItemIcon`, `GetLootRollItemID`, `GetLootSlotItemID`, `GetMerchantItemID`, `GetQuestItemID`, `GetQuestLogItemID`, `GetTradePlayerItemID`, `GetTradeSkillItemID`, `GetTradeSkillReagentItemID`, `GetTradeTargetItemID`, `OffhandHasWeapon` |
| [Loot](docs/API.md#loot) | `C_Loot.GetNearbyLootableUnits`, `C_Loot.GetLastScanResults`, `C_Loot.IsScanInProgress`, `C_Loot.LootUnit`, `C_Loot.LootUnitItem`, `C_Loot.ScanNearbyLoot` |
| [LootHistory](docs/API.md#loothistory) | `C_LootHistory.GetNumItems`, `C_LootHistory.GetItem`, `C_LootHistory.GetPlayerInfo` |
| [LootHistory](docs/API.md#loothistory) | `C_LootHistory.GetNumItems`, `C_LootHistory.GetItem`, `C_LootHistory.GetPlayerInfo`, `C_LootHistory.Clear` |
| [LossOfControl](docs/API.md#lossofcontrol) | `C_LossOfControl.GetActiveLossOfControlData`, `C_LossOfControl.GetActiveLossOfControlDataCount` |
| [Macros](docs/API.md#macros) | `GetLooseMacroIcons`, `GetLooseMacroItemIcons`, `GetMacroIcons`, `GetMacroItemIcons`, `GetMacroSpell` |
| [Mail](docs/API.md#mail) | `GetInboxItemLink`, `GetSendMailItemLink` |
| [Map](docs/API.md#map) | `C_Map.GetBestMapForUnit`, `C_Map.GetMapOverlays` |
| [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_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` |
| [NameCache](docs/API.md#namecache) | `C_PlayerCache.GetPlayerInfoByName`, `C_PlayerCache.IsEnabled`, `C_PlayerCache.IsScanEnabled`, `C_PlayerCache.RememberPlayer`, `C_PlayerCache.SetEnabled`, `C_PlayerCache.SetScanEnabled`, `GetPlayerInfoByGUID`, `UnitNameFromGUID` |
| [PlayerInfo](docs/API.md#playerinfo) | `C_PlayerInfo.CanUseItem`, `C_PlayerInfo.GUIDIsCreature`, `C_PlayerInfo.GUIDIsGameObject`, `C_PlayerInfo.GUIDIsPet`, `C_PlayerInfo.GUIDIsPlayer` |
| [Quest](docs/API.md#quest) | `C_QuestLog.GetNumQuestObjectives`, `C_QuestLog.GetQuestDetails`, `C_QuestLog.GetQuestIDForLogIndex`, `C_QuestLog.GetTitleForQuestID`, `C_QuestLog.IsOnQuest`, `C_QuestLog.IsQuestDataCachedByID`, `C_QuestLog.IsUnitOnQuest`, `C_QuestLog.RequestLoadQuestByID`, `GetQuestLogLeaderBoardID` |
| [Screen](docs/API.md#screen) | `GetPhysicalScreenSize` |
| [Spell](docs/API.md#spell) | `C_Spell.CancelSpellByID`, `C_Spell.CastAtCursor`, `C_Spell.CastAtUnit`, `C_Spell.CastingInfo`, `C_Spell.ChannelInfo`, `C_Spell.DoesSpellExist`, `C_Spell.GetSchoolString`, `C_Spell.GetSpellCooldown`, `C_Spell.GetSpellDescription`, `C_Spell.GetSpellInfo`, `C_Spell.GetSpellLink`, `C_Spell.GetSpellMechanicByID`, `C_Spell.GetSpellName`, `C_Spell.GetSpellPowerCost`, `C_Spell.GetSpellRadius`, `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`, `C_Spell.UnitCastingInfo`, `C_Spell.UnitChannelInfo`, `CancelSpellByName`, `CastSpellNoToggle`, `GetCraftSpellID`, `GetSpellInfo`, `GetSpellLink`, `GetSpellRadius`, `GetSpellSchool`, `IsHarmfulSpell`, `IsHelpfulSpell`, `IsPassiveSpell`, `IsPlayerSpell`, `IsSpellKnown`, `IsUsableSpell`, `SpellHasRange` |
| [Spell](docs/API.md#spell) | `C_Spell.CancelSpellByID`, `C_Spell.CastAtCursor`, `C_Spell.CastAtUnit`, `C_Spell.CastingInfo`, `C_Spell.ChannelInfo`, `C_Spell.DoesSpellExist`, `C_Spell.GetSchoolString`, `C_Spell.GetSpellCooldown`, `C_Spell.GetSpellDescription`, `C_Spell.GetSpellInfo`, `C_Spell.GetSpellLevelInfo`, `C_Spell.GetSpellLink`, `C_Spell.GetSpellMechanicByID`, `C_Spell.GetSpellName`, `C_Spell.GetSpellPowerCost`, `C_Spell.GetSpellRadius`, `C_Spell.GetSpellReagents`, `C_Spell.GetSpellRequiredTargetLevel`, `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`, `C_Spell.UnitCastingInfo`, `C_Spell.UnitChannelInfo`, `CancelSpellByName`, `CastSpellNoToggle`, `GetCraftSpellID`, `GetSpellInfo`, `GetSpellLink`, `GetSpellRadius`, `GetSpellRequiredTargetLevel`, `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` |
| [State](docs/API.md#state) | `CancelShapeshiftForm`, `Dismount`, `GetMirrorTimerInfo`, `GetMirrorTimerProgress`, `GetShapeshiftFormID`, `IsAssistingRitual`, `IsFalling`, `IsInGroup`, `IsInRaid`, `IsLoggedIn`, `IsMounted`, `IsStealthed`, `IsSwimming` |
| [Table](docs/API.md#table) | `table.wipe` |
@@ -61,7 +62,7 @@ Full per-function reference: **[docs/API.md](docs/API.md)**.
| [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` |
| [TradeSkillUI](docs/API.md#tradeskillui) | `C_TradeSkillUI.GetTradeSkillListLink`, `C_TradeSkillUI.GetCraftListLink`, `C_TradeSkillUI.GetTradeSkillListRecipes` |
| [UIColor](docs/API.md#uicolor) | `C_UIColor.GetColors` |
| [Unit](docs/API.md#unit) | `GetUnitSpeed`, `UnitClassBase`, `UnitGUID`, `UnitInRange`, `UnitIsAFK`, `UnitIsDND`, `UnitIsFeignDeath`, `UnitIsInMyGuild`, `UnitIsPossessed`, `UnitPower`, `UnitPowerMax`, `UnitPowerType`, `UnitRaceBase`, `UnitStandState`, `UnitSubName`, `UnitTokenFromGUID` |
| [Unit](docs/API.md#unit) | `GetUnitSpeed`, `UnitClassBase`, `UnitDistanceSquared`, `UnitGUID`, `UnitInRange`, `UnitIsAFK`, `UnitIsDND`, `UnitIsFeignDeath`, `UnitIsInMyGuild`, `UnitIsMinion`, `UnitIsOtherPlayersPet`, `UnitIsPet`, `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`, `C_UnitAuras.RegisterComboDuration` |
| [VoiceChat](docs/API.md#voicechat) | `C_VoiceChat.GetTtsVoices`, `C_VoiceChat.GetRemoteTtsVoices`, `C_VoiceChat.SpeakText`, `C_VoiceChat.StopSpeakingText`, `C_TTSSettings.GetSpeechRate`, `C_TTSSettings.GetSpeechVolume`, `C_TTSSettings.GetSpeechVoiceID`, `C_TTSSettings.GetVoiceOptionName`, `C_TTSSettings.SetSpeechRate`, `C_TTSSettings.SetSpeechVolume`, `C_TTSSettings.SetVoiceOption`, `C_TTSSettings.SetVoiceOptionByName`, `C_TTSSettings.SetDefaultSettings`, `C_TTSSettings.RefreshVoices` |
| [XMLUtil](docs/API.md#xmlutil) | `C_XMLUtil.DoesTemplateExist`, `C_XMLUtil.GetTemplateInfo`, `C_XMLUtil.GetTemplates` |
+347 -44
View File
@@ -194,6 +194,7 @@ build instructions.
- [`IsLeftAltKeyDown()` / `IsRightAltKeyDown()`](#isleftaltkeydown--isrightaltkeydown)
- [`IsModifierKeyDown()`](#ismodifierkeydown)
- [`IsMouseButtonDown([button])`](#ismousebuttondownbutton)
- [`GetMouseButtonClicked()`](#getmousebuttonclicked)
- [Instance](#instance)
- [`GetInstanceInfo()`](#getinstanceinfo)
@@ -227,6 +228,7 @@ build instructions.
- [`C_Item.GetStackCount(itemLocation)`](#c_itemgetstackcountitemlocation)
- [`C_Item.IsBound(itemLocation)`](#c_itemisbounditemlocation)
- [`C_Item.IsEquippableItem(item)`](#c_itemisequippableitemitem)
- [`IsUsableItem(item)` / `C_Item.IsUsableItem(item)`](#isusableitemitem--c_itemisusableitemitem)
- [`C_Item.IsEquippedItem(item)`](#c_itemisequippeditemitem)
- [`C_Item.IsItemDataCachedByID(item)` / `C_Item.IsItemDataCached(itemLocation)`](#c_itemisitemdatacachedbyiditem--c_itemisitemdatacacheditemlocation)
- [`C_Item.IsItemGUIDInInventory(itemGUID)`](#c_itemisitemguidininventoryitemguid)
@@ -299,13 +301,15 @@ build instructions.
- [`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_PlayerCache.RememberPlayer(guid, name, classToken)`](#c_playercacherememberplayerguid-name-classtoken)
- [`C_PlayerCache.SetEnabled(enabled)`](#c_playercachesetenabledenabled)
- [`C_PlayerCache.IsEnabled()`](#c_playercacheisenabled)
- [`C_PlayerCache.SetScanEnabled(enabled)`](#c_playercachesetscanenabledenabled)
- [`C_PlayerCache.IsScanEnabled()`](#c_playercacheisscanenabled)
- [PlayerInfo](#playerinfo)
- [`C_PlayerInfo.CanUseItem(itemID)`](#c_playerinfocanuseitemitemid)
- [`C_PlayerInfo.GUIDIsPlayer(guid)` / `GUIDIsCreature` / `GUIDIsPet` / `GUIDIsGameObject`](#c_playerinfoguidisplayerguid--guidiscreature--guidispet--guidisgameobject)
- [Quest](#quest)
- [`C_QuestLog.GetQuestIDForLogIndex(index)`](#c_questlogGetQuestIDForLogIndexindex)
- [`C_QuestLog.RequestLoadQuestByID(questID)`](#c_questlogrequestloadquestbyidquestid)
@@ -356,6 +360,8 @@ build instructions.
- [`C_Spell.CancelSpellByID(spellID)` / `CancelSpellByName(name)`](#c_spellcancelspellbyidspellid--cancelspellbynamename)
- [`C_Spell.UnitCastingInfo(unit)` / `C_Spell.CastingInfo()`](#c_spellunitcastinginfounit--c_spellcastinginfo)
- [`C_Spell.UnitChannelInfo(unit)` / `C_Spell.ChannelInfo()`](#c_spellunitchannelinfounit--c_spellchannelinfo)
- [`C_Spell.GetSpellLevelInfo(spellID)`](#c_spellgetspelllevelinfospellid)
- [`GetSpellRequiredTargetLevel(spellID)`](#getspellrequiredtargetlevelspellid)
- [SpellBook](#spellbook)
- [`FindSpellBookSlotByID(spellID)`](#findspellbookslotbyidspellid)
@@ -418,8 +424,12 @@ build instructions.
- [`UnitIsFeignDeath(unit)`](#unitisfeigndeathunit)
- [`UnitIsInMyGuild(unitOrName)`](#unitisinmyguildunitorname)
- [`UnitIsPossessed(unit)`](#unitispossessedunit)
- [`UnitIsMinion(unit)`](#unitisminionunit)
- [`UnitIsPet(unit)`](#unitispetunit)
- [`UnitIsOtherPlayersPet(unit)`](#unitisotherplayerspetunit)
- [`UnitStandState(unit)`](#unitstandstateunit)
- [`UnitInRange(unit)`](#unitinrangeunit)
- [`UnitDistanceSquared(unit)`](#unitdistancesquaredunit)
- [`UnitPower(unit [, powerType])` / `UnitPowerMax(unit [, powerType])`](#unitpowerunit--powertype--unitpowermaxunit--powertype)
- [`UnitPowerType(unit)`](#unitpowertypeunit)
@@ -2334,7 +2344,7 @@ polling. Register like any engine event
|---|---|---|
| `LOOT_HISTORY_ROLL_CHANGED` | `itemIndex`, `playerIndex` (numbers) | A player rolled or passed on an item. |
| `LOOT_HISTORY_ROLL_COMPLETE` | `itemIndex` (number) | The item was decided — won or all-passed. |
| `LOOT_HISTORY_FULL_UPDATE` | — | Reserved for API / `IsEventValid` completeness (the display frame fires it on open); the DLL doesn't dispatch it. |
| `LOOT_HISTORY_FULL_UPDATE` | — | The item set changed structurally — a new item appeared (roll opened) or the 128-item ring evicted the oldest (every index shifted). Re-read the whole list. Also fired by `C_LootHistory.Clear()`. |
See the [LootHistory](#loothistory) section for the reconstruction and read API.
@@ -4288,6 +4298,38 @@ if IsMouseButtonDown("RightButton") then
end
```
### `GetMouseButtonClicked()`
Returns the name of the button responsible for the mouse handler
currently running (`"LeftButton"`, `"RightButton"`, `"MiddleButton"`,
`"Button4"`, `"Button5"`), or `nil` when no click is being handled.
Modern addons call this inside a mouse handler (`OnClick`,
`OnMouseDown`, `OnMouseUp`, `OnDragStart`, …) to learn which button
drove it — an alternative to reading vanilla's `arg1`, and readable
from nested helper functions where `arg1` isn't in scope. The value
is captured by the same `WH_GETMESSAGE` hook behind
`GLOBAL_MOUSE_DOWN` / `GLOBAL_MOUSE_UP`, then evicted a couple of
frames later so — matching the real API — it reads `nil` outside a
handler rather than lingering as the last click forever.
The button stays readable for the whole frame its click lands in (so
multiple addons hooking the same `OnClick` all see it), and while a
button is physically held (so `OnDragStart` reads it). It is a
best-effort replica: WoW dispatches these handlers deferred from the
OS message, so eviction is time-based (per-frame) rather than exactly
bracketed around the handler as on retail. Values can therefore
linger ~2 frames past a click — harmless, since it's only meaningful
inside a mouse handler.
```lua
button:SetScript("OnClick", function()
if GetMouseButtonClicked() == "RightButton" then
-- right-click behavior
end
end)
```
## Instance
### `GetInstanceInfo()`
@@ -5278,6 +5320,53 @@ C_Item.IsEquippableItem(12640) -- Lionheart Helm → true
C_Item.IsEquippableItem(6948) -- Hearthstone → false
```
### `IsUsableItem(item)` / `C_Item.IsUsableItem(item)`
Returns `(usable, noMana)` for an item's **on-use** ability — the item
analog of [`IsUsableSpell`](#isusablespellspell--isusablespellslot-booktype).
This asks "can I click this item right now?", **not** "can I equip it".
Armor proficiency, class-restricted gear, and the like are *not* this
function's concern (that's the tooltip's red requirement lines); only an
item's usable/right-click effect matters here.
The global `IsUsableItem(item)` returns `1`/`nil` pairs (matching stock
`IsUsableAction`); `C_Item.IsUsableItem(item)` returns proper booleans
per the `C_Item.*` convention. `item` is an itemID number or
`"item:N..."` link.
```lua
IsUsableItem(6948) -- Hearthstone → 1, nil
local usable, noMana = C_Item.IsUsableItem(13446) -- Major Healing Potion
-- usable=true, noMana=false → click it
-- usable=false, noMana=false → no on-use effect, below required level,
-- class/race-restricted, dead, on a form
-- that blocks it, etc.
```
> **What this function checks:**
>
> 1. The item has an **on-use** spell (an `ItemStats` spell slot with
> trigger `ON_USE`). Items with no usable effect — plain gear,
> materials, on-equip procs — return `(nil, nil)`.
> 2. The item's **use requirements** are met: RequiredLevel, and the
> AllowableClass / AllowableRace masks. (These gate *activation* —
> a too-low-level potion, a class-specific trinket — so they belong
> here, unlike equip-only requirements.)
> 3. The on-use spell is castable now — delegated to the engine's own
> spell-castability helper (`FUN_SPELL_IS_USABLE`, `0x006E3D60`),
> which folds in shapeshift/form, mechanic immunities, aura state,
> and the power check. Insufficient power is the *only* condition
> that sets `noMana=true`.
>
> Like `C_Item.IsEquippableItem`, this is a synchronous cache peek:
> uncached items return `(nil/false, nil/false)` with no async load
> fired. Call `C_Item.RequestLoadItemDataByID` first and re-check on
> `ITEM_DATA_LOAD_RESULT` if you need it to wait.
>
> The on-use spell lookup is shared with
> [`C_Item.GetItemSpell`](#c_itemgetitemspellitem)
> (`Item::Spell::FindOnUseSpellIDInRecord`).
### `C_Item.IsEquippedItem(item)`
Returns `true` if `item` is currently equipped in any of the 19
@@ -6196,13 +6285,23 @@ Returns the number of rolled items currently in the history.
### `C_LootHistory.GetItem(itemIndex)`
`1`-based. Returns `itemLink, numPlayers, isDone, winnerName`:
`1`-based. Returns `rollID, itemLink, numPlayers, isDone, winnerIdx, timestamp`
(matches the MoP signature, with `timestamp` as a ClassicAPI extension):
- `rollID` — a **stable** monotonic ID for the roll. Unlike `itemIndex` (which
shifts as the 128-item ring saturates), `rollID` never changes, so key
"keep this row expanded" / "highlight this roll" state on it.
- `itemLink` — `item:<id>:0:<suffix>:<unique>` (valid for `GetItemInfo` /
`GameTooltip:SetHyperlink`), carrying the roll's random suffix so
"of the X" items keep their affix.
- `numPlayers` — how many players have rolled/passed so far.
- `isDone` — `true` once the item is decided (won or all-passed).
- `winnerName` — the winner's name, or `nil` if undecided / all passed.
- `winnerIdx` — the winner's `1`-based **player index**, for
`C_LootHistory.GetPlayerInfo(itemIndex, winnerIdx)` (name, class token,
winning roll). `nil` if undecided / all passed. The winner is guaranteed to
be in the player list even if their individual roll packet never arrived.
- `timestamp` — the roll's creation time in seconds, directly comparable to
`GetTime()` (e.g. `GetTime() - timestamp` for "N seconds ago"). ClassicAPI
extension — no MoP equivalent.
Returns nothing for an out-of-range index.
@@ -6219,6 +6318,12 @@ Both `1`-based. Returns `name, class, rollType, roll, isWinner, isMe`:
Returns nothing for an out-of-range item or player index.
### `C_LootHistory.Clear()`
Wipes the accumulated roll history (returns nothing). A ClassicAPI extension
with no MoP equivalent, for a "clear history" button. Fires
`LOOT_HISTORY_FULL_UPDATE` so any open display rebuilds empty.
## LossOfControl
Backports `C_LossOfControl` — the active crowd-control / interrupt effects on
@@ -6965,12 +7070,18 @@ Three population sources feed the cache when fully enabled:
Storage:
- `WTF\Account\<account>\ClassicAPI.txt` — account-level settings
(`PersistentNameCacheEnabled`, `NameCacheScanEnabled`).
- `WTF\Account\<account>\<realm>\ClassicAPI_NameCache.txt` —
per-realm cache. Tab-separated text, ~30 bytes/entry,
hand-editable. Shared across all characters on the same
account+realm (a 50-character bank alt's chat scraping doesn't
double the storage cost).
(`PersistentNameCacheEnabled`, `NameCacheScanEnabled`). The
opt-in toggles stay per-account.
- `WTF\ClassicAPI\<realmList>\<realm>\ClassicAPI_NameCache.txt` —
per-realm cache, **shared across every account on the same
server**. Keyed by the realmlist address (unambiguous server
identity, can't collide across unrelated private servers) plus the
realm name — so alts on different accounts pool into one file
rather than each rebuilding their own. Tab-separated text,
~30 bytes/entry, hand-editable. On first load with no shared file
yet, a pre-existing per-account cache
(`WTF\Account\<account>\<realm>\ClassicAPI_NameCache.txt`) is
migrated in automatically.
### `GetPlayerInfoByGUID(guid)`
@@ -7155,40 +7266,6 @@ here: GUIDs are permanent for the life of a vanilla character, so
the new character has a different GUID and gets a different cache
entry.
### `C_PlayerInfo.GUIDIsPlayer(guid)` / `GUIDIsCreature` / `GUIDIsPet` / `GUIDIsGameObject`
Type checks on the raw 1.12 GUID format. Vanilla GUIDs encode the
entity type in the high 16 bits of the qword — players have
`0x0000` (low dword = player ID), creatures `0xF130xxxx`, pets
`0xF140xxxx`, game objects (herbs / chests / etc.) `0xF110xxxx`,
items `0x4000xxxx`. Each function returns `true` only for its
specific prefix; the `"0x0000000000000000"` sentinel returns
`false` from all four.
```lua
if C_PlayerInfo.GUIDIsPlayer(UnitGUID("target")) then
-- target is a player
end
-- Combat-log row triage: was that a player kill or a mob kill?
local function OnCombatLogEvent(_, _, eventType, srcGUID, ...)
if eventType == "PARTY_KILL" then
if C_PlayerInfo.GUIDIsPlayer(srcGUID) then ... end
end
end
```
`GUIDIsPlayer` matches modern WoW's signature exactly; the other
three are companions for the most common type-distinction needs.
For other types (corpse, dynamic object, transport, item) the
underlying classifier exists internally — let us know if you need
one exposed.
Accepts either the 16-digit `"0xHHHHHHHHLLLLLLLL"` form or the
8-digit `"0xLLLLLLLL"` shortcut (high dword implicitly zero).
Malformed input returns `false` rather than raising — matching
modern's tolerance for stale GUIDs from addon-side caches.
### `C_PlayerCache.SetEnabled(enabled)`
Opts into (or out of) the persistent name cache. `enabled` is a
@@ -7284,6 +7361,88 @@ if C_PlayerCache.IsScanEnabled()
end
```
## PlayerInfo
### `C_PlayerInfo.CanUseItem(itemID)`
Returns `true` if the local player meets the item's **use/equip
requirements**, `false` otherwise. This is the "would the item be red in
the tooltip" gate, and rounds out the item-usability trio:
| Function | Question it answers |
|----------|---------------------|
| `C_Item.IsEquippableItem(item)` | Does the item fit *some* slot? (static, player-independent) |
| `IsUsableItem(item)` | Is the item's *on-use* ability castable right now? |
| `C_PlayerInfo.CanUseItem(itemID)` | May *this* player equip/use it at all? |
So this is what answers "a Mail chest on a Mage" (armor proficiency), "a
level-40 item at level 20" (RequiredLevel), and class/race-restricted gear.
`itemID` is a number (item links are also accepted). Checks performed:
1. **Proficiency** — for weapons/armor, the engine keeps a per-item-class
subclass-proficiency bitmask (fed by `SMSG_SET_PROFICIENCY`); the item's
subclass bit must be set. Item classes with no proficiency concept
(consumables, trade goods, …) are unrestricted.
2. **RequiredLevel** ≤ player level.
3. **AllowableClass / AllowableRace** masks include the player.
4. **RequiredSkill** — the player must have the item's skill line at an
effective rank ≥ `RequiredSkillRank` (e.g. a mount that "Requires
Riding (150)", a tool that "Requires Engineering (200)").
5. **RequiredSpell** — the player must know the item's prerequisite spell
(or a higher rank of it): a crafting specialization ("Requires
Armorsmith") or a proficiency spell.
6. **RequiredHonorRank / RequiredCityRank** — PvP-rank gates (the player's
honor rank and earned PvP-medal bitmask).
7. **RequiredReputation** — current standing with the item's faction must
reach the required reaction band (e.g. "Requires Honored with …").
```lua
C_PlayerInfo.CanUseItem(12640) -- Lionheart Helm (plate) → true on a Warrior, false on a Mage
C_PlayerInfo.CanUseItem(6948) -- Hearthstone → true (no restrictions)
C_PlayerInfo.CanUseItem(19872) -- a mount → false without Riding (150)
C_PlayerInfo.CanUseItem(12717) -- Plans: Lionheart Helm → false without the Armorsmith spec
```
> Synchronous, like `C_Item.IsEquippableItem`: an uncached item returns
> `false` with no async load fired. Warm the cache
> (`C_Item.RequestLoadItemDataByID`) and re-check on `ITEM_DATA_LOAD_RESULT`
> if needed.
### `C_PlayerInfo.GUIDIsPlayer(guid)` / `GUIDIsCreature` / `GUIDIsPet` / `GUIDIsGameObject`
Type checks on the raw 1.12 GUID format. Vanilla GUIDs encode the
entity type in the high 16 bits of the qword — players have
`0x0000` (low dword = player ID), creatures `0xF130xxxx`, pets
`0xF140xxxx`, game objects (herbs / chests / etc.) `0xF110xxxx`,
items `0x4000xxxx`. Each function returns `true` only for its
specific prefix; the `"0x0000000000000000"` sentinel returns
`false` from all four.
```lua
if C_PlayerInfo.GUIDIsPlayer(UnitGUID("target")) then
-- target is a player
end
-- Combat-log row triage: was that a player kill or a mob kill?
local function OnCombatLogEvent(_, _, eventType, srcGUID, ...)
if eventType == "PARTY_KILL" then
if C_PlayerInfo.GUIDIsPlayer(srcGUID) then ... end
end
end
```
`GUIDIsPlayer` matches modern WoW's signature exactly; the other
three are companions for the most common type-distinction needs.
For other types (corpse, dynamic object, transport, item) the
underlying classifier exists internally — let us know if you need
one exposed.
Accepts either the 16-digit `"0xHHHHHHHHLLLLLLLL"` form or the
8-digit `"0xLLLLLLLL"` shortcut (high dword implicitly zero).
Malformed input returns `false` rather than raising — matching
modern's tolerance for stale GUIDs from addon-side caches.
## Quest
### `C_QuestLog.GetQuestIDForLogIndex(index)`
@@ -8807,6 +8966,55 @@ otherwise it falls back to `name`/`displayName`/`textureID`/`spellID` with
**`nil` times**. The player path is unchanged (full timing). Same
placeholder fields as `C_Spell.UnitCastingInfo`.
### `C_Spell.GetSpellLevelInfo(spellID)`
Returns the raw `Spell.dbc` level fields for a spell:
```
spellLevel, baseLevel, maxLevel = C_Spell.GetSpellLevelInfo(spellID)
```
- `spellLevel` — the rank's effective level (drives the target-level rule
below and per-level scaling).
- `baseLevel` — the spell's base level.
- `maxLevel` — the level at which per-level scaling caps (`0` = no cap).
Returns `nil` for an invalid / unpopulated spellID.
```lua
C_Spell.GetSpellLevelInfo(14752) -- 30, 30, 40 (Divine Spirit Rank 1)
C_Spell.GetSpellLevelInfo(14818) -- 40, 40, 50 (Divine Spirit Rank 2)
```
### `GetSpellRequiredTargetLevel(spellID)`
Also `C_Spell.GetSpellRequiredTargetLevel(spellID)`. Returns the minimum
level a **target** must be for this exact spell (rank) to apply — e.g.
Divine Spirit Rank 1 can only be cast on a target level **20 or higher**.
Returns `0` when the spell has no such restriction, or `nil` for an
invalid spellID.
```lua
GetSpellRequiredTargetLevel(14752) -- 20 (Divine Spirit Rank 1)
GetSpellRequiredTargetLevel(14818) -- 30 (Divine Spirit Rank 2)
GetSpellRequiredTargetLevel(1243) -- 0 (Fortitude Rank 1)
GetSpellRequiredTargetLevel(10060) -- 0 (Power Infusion — single rank)
GetSpellRequiredTargetLevel(133) -- 0 (Fireball — damage, not a buff)
```
Mirrors the server's `SelectAuraRankForLevel` rule: a **ranked beneficial
aura** is castable when `targetLevel + 10 >= spellLevel`, so the minimum
target level is `spellLevel - 10`. The restriction is applied only when the
spell is a ranked positive aura — `spellLevel > 10`, not passive, part of a
rank chain, and applying an aura to a friendly/party/raid target (or an
`APPLY_AREA_AURA_PARTY` effect). Below the minimum, the lowest rank fails
with `SPELL_FAILED_LOWLEVEL`; higher ranks auto-downrank.
> The explicit `MinTargetLevel` / `MaxTargetLevel` spell fields the server
> also enforces are **server-only** columns — they aren't present in the
> 1.12 client's `Spell.dbc`, so the `spellLevel` rule is the only
> client-readable target-level mechanism.
## SpellBook
Functions keyed on the player or pet **spellbook** (slot indices,
@@ -9919,6 +10127,63 @@ Distinct from `UnitIsCharmed`: charm covers any charm-type effect
(including pets summoned via mob-charm spells), possess is the
specific spell-driven take-over effect modern WoW splits out.
### `UnitIsMinion(unit)`
Returns `true` if `unit` is a **minion of a player** — a pet, guardian,
totem, or charmed creature — `false` otherwise. Primarily owner-based: the
unit's owner GUID (SummonedBy / CreatedBy / CharmedBy) must resolve to a
player, with "player-controlled but not itself a player" as a fallback.
Owner-based is needed because summoned **guardians** (shown as "X's
Minion") carry an owner but not the `PLAYER_CONTROLLED` flag, so a
flag-only check would miss them. Players, NPCs, and world creatures return
`false`.
A modern (12.0.0-era) addition with no vanilla or 3.3.5 equivalent —
backported here from vanilla primitives. A bad or unresolved unit token
returns `false`.
```lua
UnitIsMinion("pet") -- your pet → true
UnitIsMinion("player") -- you → false
UnitIsMinion("target") -- another player's felhunter → true; a boar → false
```
### `UnitIsPet(unit)`
Returns `true` if `unit` is a **pet** of a player (as opposed to a
guardian/totem "minion"), `false` otherwise — the pet↔minion
discriminator. Uses the engine's own classifier: owned by a player **and**
the family-based pet/minion test that drives the `UNITNAME_TITLE_PET` vs
`UNITNAME_TITLE_MINION` tooltip label. Neither the GUID (pets and guardians
share the `0xF14…` prefix on this client) nor `UNIT_FLAG_PLAYER_CONTROLLED`
(a "Minion" can carry it) reliably separates them, so the creature-family
classifier is the correct signal; the owner-is-player gate also excludes
wild tameable beasts of the same family.
`UnitIsMinion` is the umbrella (pet + guardian + totem); `UnitIsPet` is the
controllable subset. A ClassicAPI extension, not a stock WoW global. A bad
or unresolved unit token returns `false`.
```lua
UnitIsPet("pet") -- your pet → true
-- another player's "X's Pet" → UnitIsMinion true, UnitIsPet true
-- another player's "X's Minion" → UnitIsMinion true, UnitIsPet false (guardian, not controllable)
```
### `UnitIsOtherPlayersPet(unit)`
Returns `true` if `unit` is a minion (pet, guardian, totem, or charmed
creature) whose owner is a player **other than you**, `false` otherwise.
Owner-based, matching 5.4.8's `Script_UnitIsOtherPlayersPet`: it reads the
unit's owner GUID (SummonedBy / CreatedBy / CharmedBy), requires that owner
to be a player, and requires it to differ from you. World creatures and
players (no owner) return `false`; your own minions (owner is you) return
`false`. A bad or unresolved unit token returns `false`.
```lua
UnitIsOtherPlayersPet("target") -- a party member's pet → true; your own pet → false
```
### `UnitStandState(unit)`
Returns the unit's standstate as an integer, matching the modern
@@ -9986,6 +10251,44 @@ mouseover, etc.). For raid members in a different zone or otherwise
not in the engine's sync window, the position virtual returns null
and the function reports `(false, false)`.
### `UnitDistanceSquared(unit)`
Returns `(distanceSquared, checkedPosition)` — the **squared** world
distance (yards²) from the player to `unit`, and a flag indicating
whether both positions were available.
```lua
local distSq, checked = UnitDistanceSquared("target")
if checked and distSq <= 30 * 30 then
-- target is within 30 yards
end
```
| Return | Meaning |
|--------|---------|
| `distanceSquared` | Squared distance player→unit. `0` when `checkedPosition` is `false` (a placeholder — matches retail's "always a number" shape). |
| `checkedPosition` | `true` when both positions were read. `false` when `unit`'s position isn't available (empty `partyN` slot, no target, raid member outside the sync window, etc.). |
The value is **squared** on purpose: nearly all distance logic is a
threshold compare (`distSq <= range * range`) or a nearest-unit sort,
neither of which needs the square root — so retail exposes only the
squared form (added 5.0.4) and never a plain `UnitDistance`. Take
`math.sqrt(distanceSquared)` only when you need a yard number to show a
human.
Reads world positions via the `CGObject::GetPosition` vtable virtual
(slot 5, offset `+0x14`) — the same path `UnitInRange` /
`CheckInteractDistance` use. **Center-to-center**, not reach-aware (that
edge-to-edge nicety is UnitXP_SP3's niche); the value equals what you'd
compute from SuperWoW's `UnitPosition`, but it's self-contained (no
sibling-DLL dependency, and no clash with SuperWoW's own `UnitPosition`,
which is left untouched).
> No self-quirk (unlike `UnitInRange`): `UnitDistanceSquared("player")`
> returns a legitimate `(0, true)`. Because a real `0` (self, or two
> exactly co-located units) is indistinguishable by value from the miss
> placeholder, always branch on `checkedPosition`.
### `UnitClassBase(unit)`
Returns `(classFile, classID)` — the locale-independent class
+2 -1
View File
@@ -56,7 +56,8 @@ namespace Lua {
F(NewThread, lua_newthread, LUA_NEW_THREAD) \
F(Resume, lua_resume, LUA_RESUME) \
F(Yield, lua_yield, LUA_YIELD) \
F(ArgError, luaL_argerror, LUAL_ARG_ERROR)
F(ArgError, luaL_argerror, LUAL_ARG_ERROR) \
F(SetN, luaL_setn, LUAL_SETN)
#define CLASSICAPI_BIND_LUA(Name, Typedef, Offset) \
const Typedef##_t Name = reinterpret_cast<Typedef##_t>(Offsets::Offset);
+2
View File
@@ -99,6 +99,7 @@ using lua_newthread_t = void *(__fastcall *)(void *L);
using lua_resume_t = int(__fastcall *)(void *L, int nargs);
using lua_yield_t = int(__fastcall *)(void *L, int nresults);
using luaL_argerror_t = void(__fastcall *)(void *L, int narg, const char *msg);
using luaL_setn_t = void(__fastcall *)(void *L, int t, int n);
extern const lua_isnumber_t IsNumber;
extern const lua_isstring_t IsString;
@@ -140,6 +141,7 @@ extern const lua_newthread_t NewThread;
extern const lua_resume_t Resume;
extern const lua_yield_t Yield;
extern const luaL_argerror_t ArgError;
extern const luaL_setn_t SetN;
// Returns the global `lua_State *` (read on demand from the engine's global).
// Callable outside a Lua callback, e.g. during LoadScriptFunctions setup.
+72
View File
@@ -504,6 +504,29 @@ enum Offsets {
// layout.
OFF_UNIT_FIELD_TARGET = 0x28,
// Owner/controller GUID fields within `m_objectFields`, each a 64-bit
// GUID (lo/hi). Both byte-verified from engine readers:
// CHARMEDBY +0x10 — `Script_UnitIsCharmed`
// (`mov ecx,[eax+0x10]; or ecx,[eax+0x14]; jz`).
// CREATEDBY +0x20 — the unit owner/title builder `FUN_0052FD30`
// reads `[desc+0x20]`/`[+0x24]` as the summoner GUID.
// A pet/guardian/totem's owner lives in CREATEDBY; a charmed unit's in
// CHARMEDBY. `Unit::Pet` reads these to find a minion's player owner.
// (The engine uses only these two for ownership — there's a GUID slot
// at +0x18 too, but nothing reads it as an owner, so it's left out.)
OFF_UNIT_FIELD_CHARMEDBY = 0x10,
OFF_UNIT_FIELD_CREATEDBY = 0x20,
// Pet-vs-minion discriminator for an owned unit: `int __fastcall(unit)`.
// Returns 1 for a controllable **pet** (classified by creature family
// at descriptor +0x212 → family DBC, with a ChrRaces fallback), a
// non-1 value otherwise. The engine's unit-title builder `FUN_0052FD30`
// uses it as `(fn(unit) != 1) + 1` → 1 = "X's Pet" title, 2 = "X's
// Minion" — so it's the exact signal separating pet from minion, which
// neither the GUID (pets and guardians share the 0xF14… prefix) nor
// UNIT_FLAG_PLAYER_CONTROLLED reliably does.
FUN_UNIT_PET_MINION_CLASS = 0x00605570,
// 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
@@ -903,6 +926,22 @@ enum Offsets {
// folds in the descriptor cost mods).
FUN_GET_SPELL_COST = 0x006E31B0,
// Full spell-castability check for the LOCAL player:
// `char __fastcall(const uint8_t *spellRecord /*ecx*/,
// int *outNoMana /*edx*/)`.
// Resolves the local player internally (no unit arg), then gates
// on casting state, stun/confuse flags, mechanic immunities,
// shapeshift/form + required stances, aura-state requirements,
// combo points (finishers), spell RequiredSkill, and finally the
// power check: `cost(FUN_GET_SPELL_COST) <= currentPower` → usable,
// else sets `*outNoMana = 1`. Returns nonzero (low byte) when
// usable. Does NOT check spell knowledge — so it's valid to feed
// an item's on-use spell record (which the player never "knows").
// This is the helper the action-usability recompute uses for
// player spell slots; `Item::Usable` reuses it for item on-use
// spells. Returns 0 cleanly pre-world (no player).
FUN_SPELL_IS_USABLE = 0x006E3D60,
// Effective spell/channel duration (ms):
// `__fastcall int(const void *spellRecord, int unit /*0 = player,
// nonzero = pet*/, int skipMod /*0 = apply mods*/)`. SpellDuration
@@ -1288,6 +1327,17 @@ enum Offsets {
// exists on the +0xE68 sub-struct here.
OFF_CGPLAYER_INFO = 0xE68,
// PvP rank fields within the CGPlayer +0xE68 sub-struct (siblings of
// the combo-point byte at +0x1029). Read by the item tooltip's
// "Requires <rank>" / PVP_MEDAL red-text logic (FUN_0052B650) and
// mirrored by C_PlayerInfo.CanUseItem:
// +0x102B u8 current PvP honor rank (compared >= item
// RequiredHonorRank).
// +0x1034 u32 earned PvP-medal/rank bitmask (item RequiredCityRank
// R passes when bit (R-1) is set).
OFF_CGPLAYER_HONOR_RANK = 0x102B,
OFF_CGPLAYER_PVP_MEDALS = 0x1034,
// CGObject's `GetPosition` virtual — vtable slot 5 (offset 0x14).
// Signature: `__thiscall(this, float outBuf[3]) → float *position`.
// The returned float* may equal outBuf (the object filled it
@@ -2842,6 +2892,17 @@ enum Offsets {
// size matches Spell.dbc's row count. Pre-login the slot is NULL.
VAR_PLAYER_SPELL_BITMAP = 0x00B710FC,
// Spell-rank-chain knowledge check — `char __thiscall(player /*ecx*/,
// spellID)`. Walks `spellID`'s forward rank chain (the class/race-
// resolved chain via `0x0060C7C0`) and returns nonzero if the player
// knows any *higher* rank than `spellID` (each node tested through the
// bitmap helper `0x0060C740`). The item tooltip's "Requires <spell>"
// gate is `bitmap(spellID) OR this(spellID)`; C_PlayerInfo.CanUseItem
// mirrors that so a RequiredSpell satisfied by a higher known rank
// still passes. Does NOT test `spellID` itself — that's the caller's
// direct bitmap check.
FUN_SPELL_RANK_CHAIN_KNOWN = 0x0060C8D0,
// Per-spell cooldown query. `__fastcall(int spellID, int bookType,
// int *outDuration, int *outStart, int *outEnable)`. Same path
// `Script_GetSpellCooldown` (`0x004B40A0`) uses internally after
@@ -3415,6 +3476,17 @@ enum Offsets {
// '%s' (%s)" (or "calling '%s' on bad self (%s)" for method-style
// calls) and calls `lua_error`. Doesn't return.
LUAL_ARG_ERROR = 0x6F4810,
// `luaL_setn(L, t, n)` — Lua 5.0 auxlib table-length setter
// (verified: `FUN_006f4ea0`, __fastcall L/ecx, t/edx, n/stack,
// RET 4). Matches 5.0.2 `luaL_setn`: sets `t.n = n` if a numeric
// `n` field exists, else `sizes[t] = n` in the registry's weak
// LUA_SIZES table. Found via `table.insert` (`FUN_007fb6b0`), which
// calls it to record the new length; `luaL_getn` is `FUN_006f5050`.
// Needed by `table.wipe` to reset the length after clearing keys —
// the length is out-of-band from the keys, so a `lua_next` +
// `rawset(nil)` clear leaves it stale and subsequent `table.insert`
// appends past the wiped slots.
LUAL_SETN = 0x6F4EA0,
// `lua_iscfunction(L, idx)` — returns 1 if value is a function
// closure with the `isC` byte set (`Closure.c.isC == 1`), 0
// otherwise. Used by `coroutine.create` to reject C functions
+27
View File
@@ -492,6 +492,31 @@ void PushFromCache(void *L, const uint8_t *unit,
c.casterGuid);
}
// Reconciles the `Aura::Source` cache against `unit`'s descriptor: when the
// descriptor holds at least one raw aura the unit is in view and fully synced,
// so it's authoritative — any cached entry for the unit whose spell isn't in
// the array was removed while we couldn't see it (e.g. cancelled out of range,
// so no OnAuraRemoved reached us) and must be dropped before the fallback
// resurfaces it as a phantom (an enumerated aura whose SetUnitAura tooltip is
// empty). Reads the RAW slots (not IsSlotPopulated): a spell that's present
// but flag-hidden is still legitimately on the unit. Skips when the array is
// empty — that can't be told apart from an out-of-range descriptor drop, and
// reconciling then would wipe still-valid out-of-range entries.
void ReconcileCache(const uint8_t *unit) {
if (unit == nullptr)
return;
uint32_t present[Offsets::UNIT_AURA_TOTAL];
int n = 0;
for (int slot = 0; slot < Offsets::UNIT_AURA_TOTAL; ++slot) {
const uint32_t id = ReadSpellID(unit, slot);
if (id != 0)
present[n++] = id;
}
if (n == 0)
return;
Aura::Source::EvictAbsent(UnitGuid(unit), present, n);
}
// True if `unit`'s descriptor currently exposes any visible aura in either
// range. The cache fallback is only trustworthy when the descriptor has been
// wholesale-cleared: the two states it exists for — rogue stealth and party
@@ -559,6 +584,7 @@ bool PushNthCacheFallback(void *L, const uint8_t *unit, int oneBasedIndex,
Filter filter, bool playerOnly) {
if (unit == nullptr)
return false;
ReconcileCache(unit); // drop entries the (synced) descriptor contradicts
if (DescriptorHasVisibleAura(unit))
return false; // descriptor authoritative — see DescriptorHasVisibleAura
// The descriptor held `descCount` matches; the caller already found fewer
@@ -589,6 +615,7 @@ void AppendCacheFallbacks(void *L, const uint8_t *unit, Filter filter,
bool playerOnly, int outerIdx, int &nextKey) {
if (unit == nullptr)
return;
ReconcileCache(unit); // drop entries the (synced) descriptor contradicts
if (DescriptorHasVisibleAura(unit))
return; // descriptor authoritative — see DescriptorHasVisibleAura
const uint64_t guid = UnitGuid(unit);
+29 -4
View File
@@ -325,10 +325,18 @@ const Game::HookAutoRegister _hookAuraStacks{
reinterpret_cast<void **>(&g_origOnAuraStacksChanged)};
// OnAuraRemoved — a descriptor aura slot went empty: the aura fell off, was
// dispelled, or was replaced by a higher rank (the diff dispatcher fires
// Removed(old) + Added(new)). Evict the cache entry so the fallback stops
// surfacing it. Same ABI as OnAuraAdded (unit in ecx, slot + spellId on the
// stack); `slot` is unused here — we evict by spellId.
// dispelled, was cancelled by its owner, or was replaced by a higher rank
// (the diff dispatcher fires Removed(old) + Added(new)). Evict the cache
// entry so the descriptor-drop fallback stops surfacing it. Same ABI as
// OnAuraAdded (unit in ecx, slot + spellId on the stack); `slot` is unused
// here — we evict by spellId.
//
// Always evict, even for a death-persistent aura (flask, world buff) on a
// dead unit: the server keeps those through death WITHOUT changing any
// field, so death itself fires no OnAuraRemoved. Any removal we do receive
// for one is therefore genuine — e.g. the owner cancelled the flask while
// dead — and must clear the cache, or the fallback keeps surfacing a phantom
// whose SetUnitAura tooltip is empty.
using OnAuraRemoved_t = void(__fastcall *)(void *unit, void *edx, uint32_t slot,
uint32_t spellId);
OnAuraRemoved_t g_origOnAuraRemoved = nullptr;
@@ -361,6 +369,23 @@ bool Get(uint64_t unitGuid, uint32_t spellId, uint64_t *outCaster,
return false;
}
void EvictAbsent(uint64_t unitGuid, const uint32_t *presentSpellIds, int count) {
if (unitGuid == 0 || presentSpellIds == nullptr || count <= 0)
return;
for (auto &e : g_cache) {
if (!e.used || e.targetGuid != unitGuid)
continue;
bool present = false;
for (int i = 0; i < count; ++i)
if (presentSpellIds[i] == e.spellId) {
present = true;
break;
}
if (!present)
e.used = false;
}
}
int Enumerate(uint64_t unitGuid, bool harmful, CachedAura *out, int maxOut) {
if (unitGuid == 0 || out == nullptr || maxOut <= 0)
return 0;
+12
View File
@@ -50,6 +50,18 @@ struct CachedAura {
uint32_t durationMs; // applied duration (incl. caster mods); 0 = none
};
// Evicts every cached entry for `unitGuid` whose spellId is NOT present in
// `presentSpellIds[0..count)`. Used to reconcile the cache against a unit's
// authoritative descriptor when it is back in view (a populated aura array
// means the unit is fully synced): an entry the descriptor no longer lists
// was removed while we couldn't observe it — e.g. a buff the owner cancelled
// while out of our range, whose `OnAuraRemoved` we never received — so drop
// it before the descriptor-drop fallback resurfaces it as a phantom. The
// caller must only invoke this when the descriptor is populated (count > 0);
// an empty array can't distinguish "out of range" from "genuinely buffless",
// so reconciling then would wrongly wipe still-valid out-of-range entries.
void EvictAbsent(uint64_t unitGuid, const uint32_t *presentSpellIds, int count);
// Fills `out` with up to `maxOut` cached, non-expired auras on `unitGuid`
// whose helpful/harmful classification matches `harmful`. Returns the count
// written. The classification is recorded from the aura's descriptor slot at
+82
View File
@@ -24,6 +24,26 @@
// as the events (single source of truth — events firing and the
// state being readable happen at the same point in the pipeline).
//
// `GetMouseButtonClicked()` — the button name of the most recent
// mouse button message (`"LeftButton"` / … / `"Button5"`), or nil
// when no click is being handled. Modern addons call it inside a
// mouse handler to learn which button drove it; the value is evicted
// shortly after, so — like the real API — it reads nil outside a
// handler rather than returning a stale last-click forever.
//
// Modern WoW backs this with a frame-manager field set right before a
// mouse handler runs and cleared right after (verified in 3.3.5:
// `GetMouseButtonClicked` pushes `[frameMgr + 0x1234]`, nil when that
// field is null). We can't bracket the handler the same way here: the
// `WH_GETMESSAGE` hook sees the button at message-dequeue, but WoW
// dispatches the frame-script mouse handlers *deferred* (during the
// frame update, not the message pump). So instead we capture the
// button on the message and evict it from the per-frame `WorldTick`
// once a couple of quiet ticks pass with no button held — the value
// survives the whole frame its click lands in (so every handler,
// including multiple addons hooking one `OnClick`, sees it) then
// clears. A held button keeps it set so `OnDragStart` still reads it.
//
// Same `WH_GETMESSAGE` thread-message hook pattern as
// [Input::Modifier](Modifier.cpp): we intercept the WM_*BUTTON*
// messages on the engine's main thread *before* dispatch. The hook
@@ -33,6 +53,7 @@
#include "Game.h"
#include "event/Custom.h"
#include "tick/WorldTick.h"
#include <cstdint>
#include <cstring>
@@ -59,6 +80,24 @@ constexpr uint32_t MASK_ANY_BUTTON = 0x1F;
uint32_t g_mouseButtonMask = 0;
// Name of the button from the most recent mouse-button message, or
// nullptr when no click is being handled. Points at one of
// DecodeButton's string literals (stable for the process lifetime),
// so storing the pointer is safe. `lua_pushstring(L, nullptr)`
// tail-jumps to pushnil, so a null here surfaces as nil to the Lua
// caller with no extra branch. Set on each button message; evicted by
// EvictTick (see the header note on GetMouseButtonClicked).
const char *g_lastButton = nullptr;
// Set true whenever g_lastButton is (re)assigned, so EvictTick can
// tell a just-clicked frame from a quiet one. Number of consecutive
// quiet ticks (no activity, nothing held) required before eviction —
// >1 so a handler dispatched a frame after its message still reads
// the button.
bool g_buttonActivity = false;
int g_quietTicks = 0;
constexpr int kQuietTicksToEvict = 2;
HHOOK g_msgHook = nullptr;
// Maps WM_*BUTTON{DOWN,UP} → ("LeftButton"|..., isDown). Returns
@@ -101,6 +140,13 @@ void ProcessMouseMessage(UINT msg, WPARAM wParam) {
if (name == nullptr)
return;
// Capture for GetMouseButtonClicked on both press and release —
// during an OnClick / OnMouseUp handler the "clicked" button is the
// one just released, same as the press for a normal click.
g_lastButton = name;
g_buttonActivity = true;
g_quietTicks = 0;
const int bitIdx = NameToBitIdx(name);
if (bitIdx >= 0) {
const uint32_t bit = 1u << bitIdx;
@@ -136,6 +182,39 @@ int __fastcall Script_IsMouseButtonDown(void *L) {
return 1;
}
int __fastcall Script_GetMouseButtonClicked(void *L) {
// Null → pushnil (the engine's pushstring tail-jumps on NULL), so
// callers outside a mouse handler see nil, matching the modern API.
Game::Lua::PushString(L, g_lastButton);
return 1;
}
// Per-frame eviction of g_lastButton. Runs at the tail of each frame's
// world tick. While a button is held we keep the value (drag handlers
// still read it) and reset the quiet counter. A frame that saw a fresh
// button message consumes its activity flag but doesn't yet evict —
// that one-frame grace lets a handler dispatched the frame after its
// message still read the button. Only after kQuietTicksToEvict ticks
// with nothing held and no activity do we clear, so outside a click
// GetMouseButtonClicked reads nil instead of a stale last-click.
void EvictTick() {
if (g_lastButton == nullptr)
return;
if (g_mouseButtonMask != 0) {
g_quietTicks = 0;
return;
}
if (g_buttonActivity) {
g_buttonActivity = false;
g_quietTicks = 0;
return;
}
if (++g_quietTicks >= kQuietTicksToEvict) {
g_lastButton = nullptr;
g_quietTicks = 0;
}
}
LRESULT CALLBACK GetMsgHook(int code, WPARAM wParam, LPARAM lParam) {
// Only process when the message is being removed from the queue
// (`PM_REMOVE` / `GetMessage`); `PM_NOREMOVE` peeks would deliver
@@ -157,11 +236,14 @@ void InstallHook() {
void RegisterLuaFunctions() {
Game::Lua::RegisterGlobalFunction("IsMouseButtonDown",
&Script_IsMouseButtonDown);
Game::Lua::RegisterGlobalFunction("GetMouseButtonClicked",
&Script_GetMouseButtonClicked);
InstallHook();
}
const Event::Custom::AutoReserve _reserveDown{kDownEvent};
const Event::Custom::AutoReserve _reserveUp{kUpEvent};
const Tick::WorldTick::AutoSubscribe _evict{&EvictTick};
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace
+23 -23
View File
@@ -48,29 +48,6 @@ const uint8_t *FetchItemRecord(uint32_t itemID) {
return fn(cache, itemID, &zeroGuid, nullptr, nullptr, false);
}
// Walks the item's 5 spell slots looking for the first ON_USE
// (trigger=0) entry. Modern `GetItemSpell` returns only this kind;
// passive-on-equip procs (trigger=1), weapon procs (trigger=2),
// soulstones (trigger=4), and recipe-learn entries (trigger=6) all
// stay invisible to the API.
//
// Vanilla server data on Turtle WoW has been observed to put the
// ON_USE entry at slot index 0 for most items, but a few customized
// items use later slots, so we scan all 5.
int FindOnUseSpellIDInRecord(const uint8_t *record) {
auto *spellIDs = reinterpret_cast<const uint32_t *>(
record + Offsets::OFF_ITEMSTATS_SPELL_ID);
auto *triggers = reinterpret_cast<const uint32_t *>(
record + Offsets::OFF_ITEMSTATS_SPELL_TRIGGER);
for (int i = 0; i < Offsets::ITEMSTATS_SPELL_SLOT_COUNT; i++) {
if (spellIDs[i] != 0 &&
triggers[i] == Offsets::ITEM_SPELLTRIGGER_ON_USE) {
return static_cast<int>(spellIDs[i]);
}
}
return 0;
}
const char *SpellNameForID(int spellID) {
const uint8_t *record = ::Spell::Lookup::RecordForID(spellID);
if (record == nullptr)
@@ -110,6 +87,29 @@ int __fastcall Script_GetItemSpell(void *L) {
} // namespace
// Walks the item's 5 spell slots looking for the first ON_USE
// (trigger=0) entry. Modern `GetItemSpell` returns only this kind;
// passive-on-equip procs (trigger=1), weapon procs (trigger=2),
// soulstones (trigger=4), and recipe-learn entries (trigger=6) all
// stay invisible to the API.
//
// Vanilla server data on Turtle WoW has been observed to put the
// ON_USE entry at slot index 0 for most items, but a few customized
// items use later slots, so we scan all 5.
int FindOnUseSpellIDInRecord(const uint8_t *record) {
auto *spellIDs = reinterpret_cast<const uint32_t *>(
record + Offsets::OFF_ITEMSTATS_SPELL_ID);
auto *triggers = reinterpret_cast<const uint32_t *>(
record + Offsets::OFF_ITEMSTATS_SPELL_TRIGGER);
for (int i = 0; i < Offsets::ITEMSTATS_SPELL_SLOT_COUNT; i++) {
if (spellIDs[i] != 0 &&
triggers[i] == Offsets::ITEM_SPELLTRIGGER_ON_USE) {
return static_cast<int>(spellIDs[i]);
}
}
return 0;
}
int OnUseSpellIDForItemID(uint32_t itemID) {
if (itemID == 0)
return 0;
+8
View File
@@ -29,4 +29,12 @@ namespace Item::Spell {
// the same use-spell still register).
int OnUseSpellIDForItemID(uint32_t itemID);
// Walks a cached `ItemStats_C` record's 5 spell slots and returns the
// first ON_USE (trigger=0) spell ID, or 0 if none. Split out so callers
// that already hold the record (e.g. `Item::Usable`, which also reads the
// record's use requirements) don't peek the cache twice or re-derive the
// slot walk. `OnUseSpellIDForItemID` is the peek-by-itemID convenience
// wrapper over this.
int FindOnUseSpellIDInRecord(const uint8_t *record);
} // namespace Item::Spell
+180
View File
@@ -0,0 +1,180 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU 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 General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
// `IsUsableItem(item)` / `C_Item.IsUsableItem(item)` — is the item's
// *on-use* ability usable by the local player right now, returning
// `(usable, noMana)`. This is the item analog of `IsUsableSpell`; it has
// nothing to do with whether the item can be *equipped* (armor
// proficiency / class-restricted gear is not this function's concern —
// that's the tooltip's red requirement lines, evaluated separately).
//
// Semantics (mirrors 3.3.5's `Script_IsUsableItem` structure, rebuilt on
// 1.12 primitives — vanilla ships no `IsUsableItem` and its item-action
// usability path never computed on-use power/noMana):
// 1. Item must have an on-use spell (a `SPELL_TRIGGER[i] == ON_USE(0)`
// slot with a non-zero `SPELL_ID[i]`). No on-use ability → not
// usable (there's nothing to "use").
// 2. The item's own *use* requirements must be met: RequiredLevel, and
// the AllowableClass / AllowableRace masks. These gate activation
// (e.g. a potion below your level, a class-restricted trinket) and
// are use — not equip — requirements, so they belong here.
// 3. The on-use spell must be castable now: delegated to the engine's
// own spell-castability helper `FUN_SPELL_IS_USABLE`, which folds in
// form/stance, mechanic immunities, aura state, and the power check.
// Insufficient power is the only condition that flips `noMana` (and
// leaves `usable` false), exactly like `IsUsableSpell`.
//
// Cache-miss handling matches `C_Item.IsEquippableItem`: a sync peek with
// no load fired, returning `(false, false)` if the item isn't cached yet.
#include "Game.h"
#include "Offsets.h"
#include "item/Arg.h"
#include "item/Spell.h"
#include "spell/Lookup.h"
#include <cstdint>
namespace Item::Usable {
namespace {
using GetItemRecord_t = const uint8_t *(__thiscall *)(void *cache, uint32_t itemID,
const uint64_t *guid, void *callback,
void *userData, int unused);
using ResolveUnitToken_t = void *(__fastcall *)(const char *token);
using SpellIsUsable_t = int(__fastcall *)(const uint8_t *spellRecord, int *outNoMana);
// Same item-cache peek pattern as the other item modules (no shared
// helper — each call site is one line different). Sync, no load fired.
const uint8_t *PeekItemRecord(uint32_t itemID) {
auto fn = reinterpret_cast<GetItemRecord_t>(Offsets::FUN_DBCACHE_ITEMSTATS_GET_RECORD);
auto *cache = reinterpret_cast<void *>(Offsets::VAR_ITEMDB_CACHE);
const uint64_t zeroGuid = 0;
return fn(cache, itemID, &zeroGuid, nullptr, nullptr, 0);
}
const uint8_t *PlayerDescriptor() {
auto resolve = reinterpret_cast<ResolveUnitToken_t>(Offsets::FUN_RESOLVE_UNIT_TOKEN);
auto *player = static_cast<const uint8_t *>(resolve("player"));
if (player == nullptr)
return nullptr;
return *reinterpret_cast<const uint8_t *const *>(
player + Offsets::OFF_UNIT_DESCRIPTOR);
}
// True iff `mask` restricts a 1-based `index` (class/race) the player
// isn't part of. Mask 0 and 0xFFFFFFFF both mean "no restriction".
bool RestrictedOut(uint32_t mask, uint32_t index1Based) {
if (mask == 0 || mask == 0xFFFFFFFF || index1Based == 0)
return false;
return (mask & (1u << (index1Based - 1))) == 0;
}
struct Usability {
bool usable;
bool noMana;
};
Usability ComputeUsability(int itemID) {
Usability r{false, false};
if (itemID <= 0)
return r;
auto *record = PeekItemRecord(static_cast<uint32_t>(itemID));
if (record == nullptr)
return r; // not cached → sync "false", no load fired
const int onUseSpellID = Item::Spell::FindOnUseSpellIDInRecord(record);
if (onUseSpellID == 0)
return r; // no on-use ability → nothing to use
auto *desc = PlayerDescriptor();
if (desc == nullptr)
return r; // pre-login / no player
// Use requirements (NOT equip requirements): level + class/race.
const int playerLevel = *reinterpret_cast<const int *>(
desc + Offsets::OFF_UNIT_FIELD_LEVEL);
const uint32_t reqLevel = *reinterpret_cast<const uint32_t *>(
record + Offsets::OFF_ITEMSTATS_REQUIRED_LEVEL);
if (static_cast<int>(reqLevel) > playerLevel)
return r;
const uint32_t allowClass = *reinterpret_cast<const uint32_t *>(
record + Offsets::OFF_ITEMSTATS_ALLOWABLE_CLASS);
const uint32_t playerClass = *(desc + Offsets::OFF_UNIT_DESCRIPTOR_CLASS_BYTE);
if (RestrictedOut(allowClass, playerClass))
return r;
const uint32_t allowRace = *reinterpret_cast<const uint32_t *>(
record + Offsets::OFF_ITEMSTATS_ALLOWABLE_RACE);
const uint32_t playerRace = *(desc + Offsets::OFF_UNIT_DESCRIPTOR_RACE_BYTE);
if (RestrictedOut(allowRace, playerRace))
return r;
// On-use spell castability + noMana, from the engine's own helper.
// `::Spell::Lookup` (leading `::`) — inside `Item::Usable`, a bare
// `Spell::` binds to the included `Item::Spell` namespace.
auto *spellRec = ::Spell::Lookup::RecordForID(onUseSpellID);
if (spellRec == nullptr)
return r;
auto isUsable = reinterpret_cast<SpellIsUsable_t>(Offsets::FUN_SPELL_IS_USABLE);
int noMana = 0;
const int usable = isUsable(spellRec, &noMana) & 0xFF;
r.usable = usable != 0;
r.noMana = noMana != 0;
return r;
}
// `IsUsableItem(item)` — legacy global. Returns `(usable, noMana)` as
// 1/nil pairs, matching stock 1.12's `IsUsableAction` / our
// `IsUsableSpell` convention.
int __fastcall Script_IsUsableItem(void *L) {
const int itemID = Item::Arg::ResolveItemID(L, 1);
const Usability r = ComputeUsability(itemID);
if (r.usable) {
Game::Lua::PushNumber(L, 1.0);
Game::Lua::PushNil(L);
} else if (r.noMana) {
Game::Lua::PushNil(L);
Game::Lua::PushNumber(L, 1.0);
} else {
Game::Lua::PushNil(L);
Game::Lua::PushNil(L);
}
return 2;
}
// `C_Item.IsUsableItem(item)` — modern table-namespace form. Returns
// proper booleans (`usable`, `noMana`) per the `C_Item.*` convention.
int __fastcall Script_C_Item_IsUsableItem(void *L) {
const int itemID = Item::Arg::ResolveItemID(L, 1);
const Usability r = ComputeUsability(itemID);
Game::Lua::PushBool(L, r.usable);
Game::Lua::PushBool(L, r.noMana);
return 2;
}
} // namespace
static void RegisterLuaFunctions() {
Game::Lua::RegisterGlobalFunction("IsUsableItem", &Script_IsUsableItem);
Game::Lua::RegisterTableFunction("C_Item", "IsUsableItem",
&Script_C_Item_IsUsableItem);
}
static const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace Item::Usable
+125 -32
View File
@@ -62,6 +62,15 @@ T Read(const void *base, int off) {
return *reinterpret_cast<const T *>(static_cast<const uint8_t *>(base) + off);
}
// Engine millisecond clock — same source (and epoch) as Lua's `GetTime()`
// (which reads this scaled by 0.001), so a value stored here is directly
// comparable to `GetTime()` once divided by 1000.
uint32_t NowMs() {
using TickCount_t = uint32_t(__fastcall *)();
return reinterpret_cast<TickCount_t>(
static_cast<uintptr_t>(Offsets::FUN_OS_TICKCOUNT_MS))();
}
// Modern rollType constants (what C_LootHistory.GetPlayerInfo returns).
constexpr int ROLL_PASS = 0;
constexpr int ROLL_NEED = 1;
@@ -152,15 +161,16 @@ struct PlayerRoll {
constexpr int kMaxPlayers = 40;
struct HistItem {
uint32_t rollID; // stable monotonic ID — survives ring-index shifts
uint64_t sourceGuid; // looted-object GUID — the roll's identity
uint32_t slot;
uint32_t itemId;
int32_t itemSuffix; // random suffix/property ID (link suffix field)
uint32_t itemSeed; // random seed (link unique field)
uint64_t winnerGuid;
char winnerName[kNameLen];
uint64_t winnerGuid; // winner's roller GUID (0 if undecided)
bool hasWinner;
bool isDone;
uint32_t createdMs; // engine tick at creation (GetTime()*1000 epoch)
PlayerRoll players[kMaxPlayers];
int playerCount;
bool used;
@@ -175,6 +185,13 @@ constexpr int kMaxItems = 128;
HistItem g_items[kMaxItems];
int g_write = 0;
int g_count = 0;
uint32_t g_nextRollId = 1; // 0 is reserved as "invalid" (never assigned)
// Set by FindOrCreate for the most recent call, read by the hooks to decide
// whether a structural LOOT_HISTORY_FULL_UPDATE is warranted (a new item
// appeared, or the ring evicted an old one so every index shifted).
bool g_createdNew = false;
bool g_evicted = false;
HistItem *Find(uint64_t sourceGuid, uint32_t slot) {
for (auto &e : g_items) {
@@ -185,6 +202,8 @@ HistItem *Find(uint64_t sourceGuid, uint32_t slot) {
}
HistItem *FindOrCreate(uint64_t sourceGuid, uint32_t slot, uint32_t itemId) {
g_createdNew = false;
g_evicted = false;
HistItem *e = Find(sourceGuid, slot);
if (e != nullptr) {
if (itemId != 0)
@@ -192,17 +211,54 @@ HistItem *FindOrCreate(uint64_t sourceGuid, uint32_t slot, uint32_t itemId) {
return e;
}
e = &g_items[g_write];
g_evicted = e->used; // overwriting a live slot => ring full => eviction
g_write = (g_write + 1) % kMaxItems;
if (g_count < kMaxItems)
++g_count;
*e = HistItem{};
e->used = true;
e->rollID = g_nextRollId++;
e->createdMs = NowMs();
e->sourceGuid = sourceGuid;
e->slot = slot;
e->itemId = itemId;
g_createdNew = true;
return e;
}
// Find the player-roll slot for `guid`, appending a fresh one (name/class
// resolved from the NameCache) if absent and there's room. Returns the
// 0-based index, or -1 when the roster is full. Shared by the roll recorder
// and the winner recorder — the winner may never have sent an individual
// SMSG_LOOT_ROLL (joined late / master loot), so ensuring they're present
// lets GetItem's winnerIdx resolve through GetPlayerInfo.
int FindOrAddPlayer(HistItem *e, uint64_t guid) {
for (int i = 0; i < e->playerCount; ++i) {
if (e->players[i].guid == guid)
return i;
}
if (e->playerCount >= kMaxPlayers)
return -1;
const int idx = e->playerCount++;
const uint8_t *rec = LookupPlayerRecord(guid); // cache loaded at hook time
e->players[idx] = PlayerRoll{};
e->players[idx].guid = guid;
CopyName(e->players[idx].name, NameFromRecord(rec));
e->players[idx].classId = ClassIdFromRecord(rec);
return idx;
}
// 1-based index within players[] of the winner, or 0 if undecided / absent.
int WinnerIndex(const HistItem *e) {
if (!e->hasWinner || e->winnerGuid == 0)
return 0;
for (int i = 0; i < e->playerCount; ++i) {
if (e->players[i].guid == e->winnerGuid)
return i + 1;
}
return 0;
}
// 1-based access in oldest→newest order (matching GetNumItems' count).
HistItem *GetEntry(int index1Based) {
if (index1Based < 1 || index1Based > g_count)
@@ -251,20 +307,7 @@ int RecordRoll(const void *msg, int *outPlayerIdx) {
if (roller == 0)
return IndexOf(e);
int slotIdx = -1;
for (int i = 0; i < e->playerCount; ++i) {
if (e->players[i].guid == roller) {
slotIdx = i;
break;
}
}
if (slotIdx < 0 && e->playerCount < kMaxPlayers) {
slotIdx = e->playerCount++;
const uint8_t *rec = LookupPlayerRecord(roller); // cache loaded now
e->players[slotIdx].guid = roller;
CopyName(e->players[slotIdx].name, NameFromRecord(rec));
e->players[slotIdx].classId = ClassIdFromRecord(rec);
}
const int slotIdx = FindOrAddPlayer(e, roller);
if (slotIdx >= 0) {
e->players[slotIdx].rollType = rollType;
e->players[slotIdx].roll = rollNum;
@@ -281,9 +324,10 @@ int RecordWinner(const void *msg) {
HistItem *e = FindOrCreate(src, slot, itemId);
e->winnerGuid = winner;
CopyName(e->winnerName, NameFromRecord(LookupPlayerRecord(winner)));
e->hasWinner = true;
e->isDone = true;
if (winner != 0)
FindOrAddPlayer(e, winner); // so GetItem's winnerIdx resolves
return IndexOf(e);
}
@@ -330,22 +374,39 @@ WonFn_t g_origWon = nullptr;
PassedFn_t g_origPassed = nullptr;
StartFn_t g_origStart = nullptr;
// Fire LOOT_HISTORY_FULL_UPDATE (no payload) — the "rebuild everything"
// signal, matching the UI's full refresh. Called when the item set changes
// structurally: a new item appears or the ring evicts an old one (shifting
// every index).
void FireFullUpdate() {
Event::Custom::Fire(Event::Custom::Lookup(kFullUpdate), "");
}
// START_ROLL: let the original build the store node, then read the item's
// random suffix off it. Adds the item to the history so a UI can show it as
// soon as the roll opens (before any roll comes in).
// soon as the roll opens (before any roll comes in) — hence the FULL_UPDATE.
void __fastcall Start_h(void *packet) {
g_origStart(packet);
RecordStart();
if (g_createdNew || g_evicted)
FireFullUpdate();
}
// Record fields (message is freed by the original), run the original, THEN
// fire the event — firing after the original keeps the engine's own handler
// off our re-entrancy (same order Item::Data uses from its packet hook).
// off our re-entrancy (same order Item::Data uses from its packet hook). A
// structural FULL_UPDATE (if any) precedes the granular event so a listener
// that rebuilds on FULL_UPDATE sees the new row before the row-level refresh.
void __fastcall Roll_h(void *msg, void *ctx, int a3) {
int itemIdx = 0, playerIdx = 0;
if (msg != nullptr)
bool structural = false;
if (msg != nullptr) {
itemIdx = RecordRoll(msg, &playerIdx);
structural = g_createdNew || g_evicted;
}
g_origRoll(msg, ctx, a3);
if (structural)
FireFullUpdate();
if (itemIdx > 0)
Event::Custom::Fire(Event::Custom::Lookup(kRollChanged), "%d%d", itemIdx,
playerIdx);
@@ -353,18 +414,28 @@ void __fastcall Roll_h(void *msg, void *ctx, int a3) {
void __fastcall Won_h(void *ctx, void *msg, int a3) {
int itemIdx = 0;
if (msg != nullptr)
bool structural = false;
if (msg != nullptr) {
itemIdx = RecordWinner(msg);
structural = g_createdNew || g_evicted;
}
g_origWon(ctx, msg, a3);
if (structural)
FireFullUpdate();
if (itemIdx > 0)
Event::Custom::Fire(Event::Custom::Lookup(kRollComplete), "%d", itemIdx);
}
void __fastcall Passed_h(void *ctx, void *msg) {
int itemIdx = 0;
if (msg != nullptr)
bool structural = false;
if (msg != nullptr) {
itemIdx = RecordAllPassed(msg);
structural = g_createdNew || g_evicted;
}
g_origPassed(ctx, msg);
if (structural)
FireFullUpdate();
if (itemIdx > 0)
Event::Custom::Fire(Event::Custom::Lookup(kRollComplete), "%d", itemIdx);
}
@@ -390,10 +461,15 @@ int __fastcall Script_GetNumItems(void *L) {
return 1;
}
// `C_LootHistory.GetItem(itemIndex)` -> itemLink, numPlayers, isDone, winner.
// itemLink is the `item:id:enchant:suffix:unique` form (valid for GetItemInfo
// / SetHyperlink), carrying the roll's random suffix so "of the X" items keep
// their affix; `winner` is the winner's name, or nil if undecided.
// `C_LootHistory.GetItem(itemIndex)` ->
// rollID, itemLink, numPlayers, isDone, winnerIdx, timestamp
// Matches the MoP signature: `rollID` is a stable ID (unlike itemIndex, which
// shifts as the ring saturates — key expanded/highlighted rows on it);
// `itemLink` is the `item:id:enchant:suffix:unique` form (valid for
// GetItemInfo / SetHyperlink), carrying the roll's random suffix so "of the X"
// items keep their affix; `winnerIdx` is the winner's 1-based playerIndex for
// GetPlayerInfo(itemIndex, winnerIdx) (nil if undecided). `timestamp` is a
// ClassicAPI extension: the `GetTime()`-comparable creation time in seconds.
int __fastcall Script_GetItem(void *L) {
if (!Game::Lua::IsNumber(L, 1)) {
Game::Lua::Error(L, "Usage: GetItem(itemIndex)");
@@ -407,14 +483,17 @@ int __fastcall Script_GetItem(void *L) {
// random suffix/seed so "of the Bear"-style items keep their affix.
std::snprintf(link, sizeof(link), "item:%u:0:%d:%u", e->itemId,
e->itemSuffix, e->itemSeed);
Game::Lua::PushString(L, link); // 1 itemLink
Game::Lua::PushNumber(L, static_cast<double>(e->playerCount)); // 2 numPlayers
Game::Lua::PushBool(L, e->isDone); // 3 isDone
if (e->hasWinner && e->winnerName[0] != '\0')
Game::Lua::PushString(L, e->winnerName); // 4 winner
Game::Lua::PushNumber(L, static_cast<double>(e->rollID)); // 1 rollID
Game::Lua::PushString(L, link); // 2 itemLink
Game::Lua::PushNumber(L, static_cast<double>(e->playerCount)); // 3 numPlayers
Game::Lua::PushBool(L, e->isDone); // 4 isDone
const int winnerIdx = WinnerIndex(e);
if (winnerIdx > 0)
Game::Lua::PushNumber(L, static_cast<double>(winnerIdx)); // 5 winnerIdx
else
Game::Lua::PushNil(L);
return 4;
Game::Lua::PushNumber(L, e->createdMs / 1000.0); // 6 timestamp
return 6;
}
// `C_LootHistory.GetPlayerInfo(itemIndex, playerIndex)` ->
@@ -450,6 +529,19 @@ int __fastcall Script_GetPlayerInfo(void *L) {
return 6;
}
// `C_LootHistory.Clear()` -> wipe the accumulated history. A ClassicAPI
// extension (no MoP equivalent) so a UI can offer a "clear history" button.
// Fires LOOT_HISTORY_FULL_UPDATE so any open display rebuilds empty.
int __fastcall Script_Clear(void *L) {
(void)L;
for (auto &e : g_items)
e.used = false;
g_write = 0;
g_count = 0;
FireFullUpdate();
return 0;
}
void RegisterLuaFunctions() {
Game::Lua::RegisterTableFunction("C_LootHistory", "GetNumItems",
&Script_GetNumItems);
@@ -457,6 +549,7 @@ void RegisterLuaFunctions() {
&Script_GetItem);
Game::Lua::RegisterTableFunction("C_LootHistory", "GetPlayerInfo",
&Script_GetPlayerInfo);
Game::Lua::RegisterTableFunction("C_LootHistory", "Clear", &Script_Clear);
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
+312
View File
@@ -0,0 +1,312 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU 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 General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
// `C_PlayerInfo.CanUseItem(itemID)` — true iff the local player meets the
// item's *use/equip* requirements. This is the "is the item red in the
// tooltip" gate, and is distinct from its siblings:
// - `C_Item.IsEquippableItem` — does the item fit *some* slot (static).
// - `IsUsableItem` — is the item's on-use ability castable now.
// - `C_PlayerInfo.CanUseItem` — may the player equip/use it at all.
//
// So this is the answer to "mail chest on a Mage" (proficiency), "level-40
// item on a level-20" (RequiredLevel), and class/race-restricted gear.
//
// Not in vanilla 1.12 nor in our 3.3.5 reference client (the modern API
// landed in 1.14.4 / 3.4.2 / 10.0.5), so there's no cross-binary source;
// the logic mirrors the requirement checks the 1.12 item tooltip builder
// (`FUN_0052B650`) inlines for its red/green line coloring — the same
// `bVar23` "can't use this" flag, minus the display.
//
// Checks (all must pass):
// 1. Proficiency — for weapon/armor the engine keeps a per-item-class
// subclass-proficiency bitmask (VAR_PROFICIENCY_TABLE, fed by
// SMSG_SET_PROFICIENCY). `mask[m_class] & (1 << m_subClass)` must be
// set. Item classes with no proficiency concept store mask 0 and are
// unrestricted (matches the tooltip's `mask != 0` gate).
// 2. RequiredLevel <= player level.
// 3. AllowableClass / AllowableRace masks include the player (0 or all-
// bits = no restriction).
// 4. RequiredSkill — the player must have the item's skill line at an
// effective rank (current + temp bonus) >= RequiredSkillRank (e.g. a
// mount "Requires Riding (150)"). Via FUN_SKILL_LINE_TO_SLOT.
// 5. RequiredSpell — the player must know the item's prerequisite spell,
// or a higher rank of it: a crafting specialization ("Requires
// Armorsmith") or a proficiency spell. Via the spell-knowledge bitmap
// plus the engine's rank-chain walk.
// 6. RequiredHonorRank / RequiredCityRank — PvP-rank gates: the player's
// honor rank (>= required) and earned PvP-medal bitmask.
// 7. RequiredReputation — current standing with the item's faction must
// reach the required reaction band.
//
// Cache-miss handling matches `C_Item.IsEquippableItem`: a synchronous
// peek that returns false without firing a load.
#include "Game.h"
#include "Offsets.h"
#include "item/Arg.h"
#include "spell/Lookup.h"
#include <cstdint>
namespace Player::CanUseItem {
namespace {
using GetItemRecord_t = const uint8_t *(__thiscall *)(void *cache, uint32_t itemID,
const uint64_t *guid, void *callback,
void *userData, int unused);
using ResolveUnitToken_t = void *(__fastcall *)(const char *token);
// `__thiscall(player /*ecx*/, skillLineID) -> slot` (-1 if the player
// doesn't have the skill line). See VAR docs on FUN_SKILL_LINE_TO_SLOT.
using SkillLineToSlot_t = int(__thiscall *)(void *player, uint32_t skillLineID);
// `__fastcall(factionID) -> current total reputation` (base + bonus), 0
// for factions not in the player's list. See FUN_REPUTATION_GET_STANDING.
using RepStanding_t = int(__fastcall *)(int factionID);
// `__thiscall(player /*ecx*/, spellID) -> nonzero` if the player knows a
// higher rank in spellID's chain. See FUN_SPELL_RANK_CHAIN_KNOWN.
using SpellRankChainKnown_t = int(__thiscall *)(void *player, uint32_t spellID);
const uint8_t *PeekItemRecord(uint32_t itemID) {
auto fn = reinterpret_cast<GetItemRecord_t>(Offsets::FUN_DBCACHE_ITEMSTATS_GET_RECORD);
auto *cache = reinterpret_cast<void *>(Offsets::VAR_ITEMDB_CACHE);
const uint64_t zeroGuid = 0;
return fn(cache, itemID, &zeroGuid, nullptr, nullptr, 0);
}
// The local player's CGUnit/CGPlayer object (holds the descriptor at
// +OFF_UNIT_DESCRIPTOR and the CGPlayer sub-struct at +OFF_CGPLAYER_INFO).
const uint8_t *ResolvePlayer() {
auto resolve = reinterpret_cast<ResolveUnitToken_t>(Offsets::FUN_RESOLVE_UNIT_TOKEN);
return static_cast<const uint8_t *>(resolve("player"));
}
// True iff `mask` restricts a 1-based `index` (class/race) the player
// isn't part of. Mask 0 and 0xFFFFFFFF both mean "no restriction".
bool RestrictedOut(uint32_t mask, uint32_t index1Based) {
if (mask == 0 || mask == 0xFFFFFFFF || index1Based == 0)
return false;
return (mask & (1u << ((index1Based - 1) & 31))) == 0;
}
// Per-item-class proficiency bitmask lookup. Returns false only when the
// item's class *has* a proficiency concept (mask != 0) and the item's
// subclass bit is unset.
bool ProficiencyMet(const uint8_t *record) {
const uint32_t itemClass = *reinterpret_cast<const uint32_t *>(
record + Offsets::OFF_ITEMSTATS_CLASS);
const uint32_t subClass = *reinterpret_cast<const uint32_t *>(
record + Offsets::OFF_ITEMSTATS_SUBCLASS);
if (itemClass > 16) // table is 17 entries (m_class 0..16)
return true;
auto *table = reinterpret_cast<const uint32_t *>(
static_cast<uintptr_t>(Offsets::VAR_PROFICIENCY_TABLE));
const uint32_t mask = table[itemClass];
if (mask == 0)
return true; // class has no proficiency concept (consumables, …)
return (mask & (1u << (subClass & 31))) != 0;
}
// RequiredSkill + RequiredSkillRank gate (e.g. a mount "Requires Riding
// (150)", a profession tool "Requires Engineering (200)"). Maps the
// item's SkillLine.dbc row to the player's skill slot; the player must
// have the line at an effective rank (current + temp bonus) >= required.
bool RequiredSkillMet(const uint8_t *player, const uint8_t *record) {
const uint32_t reqSkill = *reinterpret_cast<const uint32_t *>(
record + Offsets::OFF_ITEMSTATS_REQUIRED_SKILL);
if (reqSkill == 0)
return true; // no skill requirement
const uint32_t reqRank = *reinterpret_cast<const uint32_t *>(
record + Offsets::OFF_ITEMSTATS_REQUIRED_SKILL_RANK);
auto toSlot = reinterpret_cast<SkillLineToSlot_t>(Offsets::FUN_SKILL_LINE_TO_SLOT);
const int slot = toSlot(const_cast<uint8_t *>(player), reqSkill);
if (slot < 0)
return false; // player doesn't have the skill line at all
auto *sub = *reinterpret_cast<const uint8_t *const *>(
player + Offsets::OFF_CGPLAYER_INFO);
if (sub == nullptr)
return false;
const uint8_t *rec = sub + Offsets::OFF_SKILL_INFO_TABLE +
slot * Offsets::SKILL_INFO_STRIDE;
uint32_t rank = *reinterpret_cast<const uint16_t *>(rec + Offsets::OFF_SKILL_INFO_CUR);
if (rank != 0) // add the temp bonus only when there's a base value
rank += *reinterpret_cast<const uint16_t *>(rec + Offsets::OFF_SKILL_INFO_BONUS);
return rank >= reqRank;
}
// True if the local player knows `spellID`, via the engine's spell-
// knowledge bitmap (the active-player path of `FUN_0060c740`, read
// directly — no player object needed). Covers trained abilities, talents,
// racials, profession recipes, and crafting specializations (Armorsmith,
// Weaponsmith, the Engineering branches, …), which are all single learned
// spells that land in this bitmap.
bool PlayerKnowsSpell(int spellID) {
if (spellID <= 0)
return false;
auto *bitmap = *reinterpret_cast<const uint32_t *const *>(
static_cast<uintptr_t>(Offsets::VAR_PLAYER_SPELL_BITMAP));
if (bitmap == nullptr)
return false;
const int spellCount = *reinterpret_cast<const int *>(
static_cast<uintptr_t>(Offsets::VAR_SPELL_RECORD_COUNT));
if (spellID > spellCount)
return false;
return (bitmap[spellID >> 5] & (1u << (spellID & 31))) != 0;
}
// RequiredSpell gate — the item's `RequiredSpell` (a Spell.dbc row) is the
// prerequisite the "Requires <spell>" tooltip line reflects: a crafting
// specialization like Armorsmith, or an armor/weapon proficiency spell.
// The player must know it. An unset (0) or invalid (no Spell.dbc record)
// requirement is no gate — matching the tooltip's validity guard, which
// skips the line for a missing row.
//
// Mirrors the tooltip's `bitmap(reqSpell) OR rank-chain(reqSpell)`: met if
// the player knows the exact RequiredSpell, or a higher rank of it (the
// latter covers a ranked RequiredSpell — `FUN_SPELL_RANK_CHAIN_KNOWN`
// walks the forward chain). Item RequiredSpell is a non-ranked
// specialization/proficiency in practice, so the chain walk is
// belt-and-suspenders, but it makes the check exact.
bool RequiredSpellMet(const uint8_t *player, const uint8_t *record) {
const int reqSpell = *reinterpret_cast<const int32_t *>(
record + Offsets::OFF_ITEMSTATS_REQUIRED_SPELL);
if (reqSpell <= 0)
return true; // no requirement
if (::Spell::Lookup::RecordForID(reqSpell) == nullptr)
return true; // invalid Spell.dbc row → tooltip skips the gate
if (PlayerKnowsSpell(reqSpell))
return true; // knows the exact spell
auto chainKnown = reinterpret_cast<SpellRankChainKnown_t>(
Offsets::FUN_SPELL_RANK_CHAIN_KNOWN);
return chainKnown(const_cast<uint8_t *>(player),
static_cast<uint32_t>(reqSpell)) != 0;
}
// RequiredHonorRank — the player's PvP honor rank must be >= the item's.
// `sub` is the CGPlayer +0xE68 sub-struct (may be null pre-world).
bool RequiredHonorRankMet(const uint8_t *sub, const uint8_t *record) {
const uint32_t req = *reinterpret_cast<const uint32_t *>(
record + Offsets::OFF_ITEMSTATS_REQUIRED_HONOR_RANK);
if (req == 0)
return true;
if (sub == nullptr)
return false;
const uint8_t rank = *(sub + Offsets::OFF_CGPLAYER_HONOR_RANK);
return rank >= req;
}
// RequiredCityRank — a PvP-medal bitmask gate: the item's rank R passes
// when the player has earned bit (R-1) in the medal mask.
bool RequiredCityRankMet(const uint8_t *sub, const uint8_t *record) {
const uint32_t req = *reinterpret_cast<const uint32_t *>(
record + Offsets::OFF_ITEMSTATS_REQUIRED_CITY_RANK);
if (req == 0)
return true;
if (sub == nullptr)
return false;
const uint32_t medals = *reinterpret_cast<const uint32_t *>(
sub + Offsets::OFF_CGPLAYER_PVP_MEDALS);
return (medals & (1u << ((req - 1) & 31))) != 0;
}
// RequiredReputation — current standing with the item's faction must
// reach the required reaction band. The band index (0=Hated … 7=Exalted)
// indexes the engine's reaction-min threshold table; the player's total
// rep with the faction must be >= that threshold.
bool RequiredReputationMet(const uint8_t *record) {
const int faction = *reinterpret_cast<const int32_t *>(
record + Offsets::OFF_ITEMSTATS_REQUIRED_FACTION);
if (faction == 0)
return true;
const uint32_t band = *reinterpret_cast<const uint32_t *>(
record + Offsets::OFF_ITEMSTATS_REQUIRED_FACTION_RANK);
if (band > 7) // reaction-min table is 8 bands (0..7); guard OOB
return true;
auto getStanding = reinterpret_cast<RepStanding_t>(Offsets::FUN_REPUTATION_GET_STANDING);
const int current = getStanding(faction);
const int threshold = *reinterpret_cast<const int *>(
static_cast<uintptr_t>(Offsets::VAR_REACTION_MIN_TABLE) + band * 4);
return current >= threshold;
}
bool ComputeCanUse(int itemID) {
if (itemID <= 0)
return false;
auto *record = PeekItemRecord(static_cast<uint32_t>(itemID));
if (record == nullptr)
return false; // not cached → sync false, no load fired
auto *player = ResolvePlayer();
if (player == nullptr)
return false; // pre-login / no player
auto *desc = *reinterpret_cast<const uint8_t *const *>(
player + Offsets::OFF_UNIT_DESCRIPTOR);
if (desc == nullptr)
return false;
if (!ProficiencyMet(record))
return false;
const int playerLevel = *reinterpret_cast<const int *>(
desc + Offsets::OFF_UNIT_FIELD_LEVEL);
const uint32_t reqLevel = *reinterpret_cast<const uint32_t *>(
record + Offsets::OFF_ITEMSTATS_REQUIRED_LEVEL);
if (static_cast<int>(reqLevel) > playerLevel)
return false;
const uint32_t allowClass = *reinterpret_cast<const uint32_t *>(
record + Offsets::OFF_ITEMSTATS_ALLOWABLE_CLASS);
const uint32_t playerClass = *(desc + Offsets::OFF_UNIT_DESCRIPTOR_CLASS_BYTE);
if (RestrictedOut(allowClass, playerClass))
return false;
const uint32_t allowRace = *reinterpret_cast<const uint32_t *>(
record + Offsets::OFF_ITEMSTATS_ALLOWABLE_RACE);
const uint32_t playerRace = *(desc + Offsets::OFF_UNIT_DESCRIPTOR_RACE_BYTE);
if (RestrictedOut(allowRace, playerRace))
return false;
if (!RequiredSkillMet(player, record))
return false;
if (!RequiredSpellMet(player, record))
return false;
// PvP rank + reputation gates read the CGPlayer +0xE68 sub-struct.
auto *sub = *reinterpret_cast<const uint8_t *const *>(
player + Offsets::OFF_CGPLAYER_INFO);
if (!RequiredHonorRankMet(sub, record))
return false;
if (!RequiredCityRankMet(sub, record))
return false;
if (!RequiredReputationMet(record))
return false;
return true;
}
int __fastcall Script_CanUseItem(void *L) {
const int itemID = Item::Arg::ResolveItemID(L, 1);
Game::Lua::PushBool(L, ComputeCanUse(itemID));
return 1;
}
} // namespace
static void RegisterLuaFunctions() {
Game::Lua::RegisterTableFunction("C_PlayerInfo", "CanUseItem", &Script_CanUseItem);
}
static const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace Player::CanUseItem
+117 -22
View File
@@ -23,13 +23,19 @@
// parse callbacks, and the registry handles load/save so neither
// this module nor `Charlist::Order` clobbers the other's lines.
//
// `WTF\Account\<acct>\<realm>\ClassicAPI_NameCache.txt`
// Per-realm cache contents. v2 format. One entry per line:
// `guidHi guidLo classID raceID sex name`
// `WTF\ClassicAPI\<realmList>\<realm>\ClassicAPI_NameCache.txt`
// Per-realm cache contents, SHARED across every account on the
// same server. Keyed by realmlist address (unambiguous server
// identity — can't collide across unrelated private servers) plus
// the realm name, rather than by account, so alts on different
// accounts pool into one file. Not under `Account\`, so we create
// the directory tree ourselves. v4 format. One entry per line:
// `name guid classID raceID sex`
// (fields tab-separated). Loaded once the realm name is
// available post-login. Saved when (a) a recent write hasn't
// been flushed AND (b) at least 30 seconds have elapsed since
// the prior save, OR (c) DLL teardown / `Flush()` is called.
// available post-login (migrating a pre-existing per-account file
// in on first load). Saved when (a) a recent write hasn't been
// flushed AND (b) at least 30 seconds have elapsed since the prior
// save, OR (c) DLL teardown / `Flush()` is called.
//
// Population sources:
// 1. The engine's NameCache writer (`FUN_PLAYER_INFO_CACHE_WRITE`)
@@ -69,6 +75,7 @@
#include <cstdlib>
#include <cstring>
#include <fstream>
#include <istream>
#include <ostream>
#include <sstream>
#include <string>
@@ -139,7 +146,57 @@ const char *ReadRealmName() {
// ----- path resolution -----
// realmList CVar value (the realmlist address, e.g.
// "logon.turtle-wow.org") — the unambiguous server identity. Unlike the
// friendly realm name it can't collide across unrelated private servers,
// so it's the outer key for the shared cache path. Mirrors
// `Security::CredStore`'s realm scoping.
const char *ReadRealmList() {
using GetRealmList_t = const char *(__fastcall *)(int forceRefresh);
return reinterpret_cast<GetRealmList_t>(
static_cast<uintptr_t>(Offsets::FUN_CVAR_REALM_LIST))(0);
}
// Filesystem-safe path component. We create these directories ourselves
// (WoW doesn't), so any character Windows rejects in a path — or that
// could surprise, like the space/apostrophe in "N'Zoth" — becomes '_'.
// Deterministic, so a realm always maps to the same folder.
std::string SanitizeComponent(const char *s) {
std::string out;
for (const char *p = s; p != nullptr && *p; ++p) {
const char c = *p;
const bool safe = (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') ||
(c >= '0' && c <= '9') || c == '.' || c == '-' ||
c == '_';
out.push_back(safe ? c : '_');
}
return out;
}
// Shared, server-specific cache path:
// WTF\ClassicAPI\<realmList>\<realmName>\ClassicAPI_NameCache.txt
// Not under Account\, so alts on different accounts of the same server
// share one pool. Empty (defer) until both realmlist + realm are known.
std::string CachePath() {
const char *realmList = ReadRealmList();
if (realmList == nullptr || realmList[0] == '\0')
return {};
const char *realm = ReadRealmName();
if (realm == nullptr || realm[0] == '\0')
return {};
std::string out = "WTF\\ClassicAPI\\";
out += SanitizeComponent(realmList);
out += '\\';
out += SanitizeComponent(realm);
out += "\\ClassicAPI_NameCache.txt";
return out;
}
// The pre-shared per-account path, used only to migrate an existing
// cache into the shared location on first load. Built exactly as it was
// written (raw account + realm, unsanitized) so it matches the file
// sitting in WoW's own folders.
std::string OldCachePath() {
const char *account = ReadAccountName();
if (account == nullptr || account[0] == '\0')
return {};
@@ -154,6 +211,21 @@ std::string CachePath() {
return out;
}
// Creates every parent directory of `filePath` (mkdir -p). WoW makes
// `WTF\` but not `WTF\ClassicAPI\...`, and ofstream won't create
// directories, so a save into a fresh realm folder would silently fail
// without this. CreateDirectory on an existing dir no-ops.
void EnsureParentDirs(const std::string &filePath) {
for (size_t pos = 0;;) {
const size_t slash = filePath.find('\\', pos);
if (slash == std::string::npos)
break; // remainder is the filename
if (slash > 0)
CreateDirectoryA(filePath.substr(0, slash).c_str(), nullptr);
pos = slash + 1;
}
}
// ----- account settings (registered with Settings::Account) -----
void SettingsReset() {
@@ -259,21 +331,12 @@ bool Upsert(uint64_t guid, const std::string &name,
return changed;
}
void LoadCacheIfNeeded() {
if (g_cacheLoaded)
return;
const std::string path = CachePath();
if (path.empty())
return; // pre-realm; retry on next call (g_cacheLoaded stays false)
g_cacheLoaded = true;
std::ifstream file(path);
if (!file.is_open())
return; // first run for this realm
// v4 format: `name\tguid\tclass\trace\tsex`. Name first since
// it's the primary key; GUID is a peripheral field carried for
// GUID-keyed lookups. Variable-width hex GUID (no leading zeros).
// Parses v4 cache lines (`name\tguid\tclass\trace\tsex`) from `file`
// into the store. Name first since it's the primary key; GUID is a
// peripheral field carried for GUID-keyed lookups. Variable-width hex
// GUID (no leading zeros). Shared by the normal load and the one-time
// migration from the old per-account file.
void LoadFromStream(std::istream &file) {
std::string line;
while (std::getline(file, line)) {
while (!line.empty() && (line.back() == '\r' || line.back() == '\n'))
@@ -301,10 +364,42 @@ void LoadCacheIfNeeded() {
}
}
void LoadCacheIfNeeded() {
if (g_cacheLoaded)
return;
const std::string path = CachePath();
if (path.empty())
return; // pre-realm; retry on next call (g_cacheLoaded stays false)
g_cacheLoaded = true;
std::ifstream file(path);
if (file.is_open()) {
LoadFromStream(file);
return;
}
// No shared file yet. One-time migration: seed from the old
// per-account file if it exists, then mark dirty so the next save
// writes the merged data into the shared path. Only when the shared
// file is absent — once it exists it's authoritative (a second
// account's old-only entries just repopulate via scan / SMSG).
const std::string oldPath = OldCachePath();
if (oldPath.empty())
return;
std::ifstream oldFile(oldPath);
if (oldFile.is_open()) {
LoadFromStream(oldFile);
g_dirty = true;
}
}
void SaveCache() {
const std::string path = CachePath();
if (path.empty())
return;
// `WTF\ClassicAPI\<realmList>\<realm>\` isn't created by WoW — make
// the tree first, else the ofstream open below silently fails.
EnsureParentDirs(path);
const std::string tmp = path + ".tmp";
{
std::ofstream out(tmp, std::ios::binary | std::ios::trunc);
@@ -319,7 +414,7 @@ void SaveCache() {
// sort/scan key when hand-editing. GUID is variable-width
// hex — 1-8 digits for player GUIDs, up to 16 if a non-player
// GUID ever lands here.
out << "# ClassicAPI name cache v4 (per-realm)\n";
out << "# ClassicAPI name cache v4 (per-realm, shared across accounts)\n";
out << "# name\tguid\tclassID\traceID\tsex\n";
for (const auto &kv : g_entries) {
const std::string &name = kv.first;
+139
View File
@@ -117,6 +117,139 @@ bool SpellHiddenFromAutoLearn(int spellID) {
return (attrEx2 & SPELL_ATTR_EX2_HIDE_FROM_AUTOLEARN) != 0;
}
// ---- Target-level gate for ranked beneficial auras ----------------------
//
// A ranked beneficial aura can't be applied to a target too low for the
// rank — e.g. Divine Spirit Rank 1 (14752) requires the target to be level
// >= 20. Server-enforced via `SpellMgr::SelectAuraRankForLevel`, whose rule
// is: a ranked positive aura is castable when `targetLevel + 10 >=
// spellLevel`, i.e. the minimum target level is `spellLevel - 10`. Below
// it the lowest rank fails (SPELL_FAILED_LOWLEVEL); higher ranks auto-
// downrank. The gate only applies to *ranked* *positive* *auras* — see
// RequiredTargetLevel. Fully derivable from the client's Spell.dbc.
//
// (The explicit MinTargetLevel / MaxTargetLevel fields the server also
// checks are NOT in the 1.12 client's Spell.dbc — server-only columns — so
// the spellLevel rule is the only client-readable target-level mechanism.)
constexpr int OFF_SPELL_MAX_LEVEL = 0x6C; // uint32 (scaling cap level)
constexpr int OFF_SPELL_SPELL_LEVEL = 0x74; // uint32 (rank's effective level)
constexpr int OFF_SPELL_EFFECT = 0xF4; // uint32[3] effect type
constexpr int OFF_SPELL_EFFECT_TARGET_A = 0x148; // uint32[3] implicit target A
constexpr int OFF_SPELL_RANK = 0x204; // char*[9] localized rank text
constexpr uint32_t SPELL_ATTR_PASSIVE = 0x40; // Attributes bit
constexpr uint32_t SPELL_EFFECT_APPLY_AURA = 6;
constexpr uint32_t SPELL_EFFECT_APPLY_AREA_AURA_PARTY = 35;
int LocaleIndex() {
return *reinterpret_cast<const int *>(
static_cast<uintptr_t>(Offsets::VAR_LOCALE_INDEX));
}
// The friendly-target IDs the server's SelectAuraRankForLevel treats as
// "positive" for rank selection: IsExplicitPositiveTarget (21/35/45/57/61)
// plus IsAreaEffectPossitiveTarget (20/30/31/33/34/37/56/61). Narrower than
// a general "is helpful" set — this is the exact gate the rank rule uses
// (self-cast / pet targets don't count).
bool IsPositiveRankTarget(uint32_t t) {
switch (t) {
case 21: // TARGET_UNIT_FRIEND
case 35: // TARGET_UNIT_PARTY
case 45: // TARGET_UNIT_FRIEND_CHAIN_HEAL
case 57: // TARGET_UNIT_RAID
case 61: // TARGET_UNIT_RAID_AND_CLASS
case 20: // TARGET_ENUM_UNITS_PARTY_WITHIN_CASTER_RANGE
case 30: // TARGET_ENUM_UNITS_FRIEND_AOE_AT_SRC_LOC
case 31: // TARGET_ENUM_UNITS_FRIEND_AOE_AT_DEST_LOC
case 33: // TARGET_ENUM_UNITS_PARTY_AOE_AT_SRC_LOC
case 34: // TARGET_ENUM_UNITS_PARTY_AOE_AT_DEST_LOC
case 37: // TARGET_UNIT_FRIEND_AND_PARTY
case 56: // TARGET_ENUM_UNITS_RAID_WITHIN_CASTER_RANGE
return true;
default:
return false;
}
}
// True if the record carries a localized "Rank N" string — the client's
// marker for a spell in a ranked line (single-rank spells leave it empty).
// Proxy for the server's `GetSpellRank(id) != 0`, verified against
// single-rank buffs (Power Infusion / Innervate / Blessing of Kings, all
// empty Rank and un-gated) vs ranked ones (Divine Spirit / Fortitude).
bool HasRankString(const uint8_t *record) {
const char *rank = *reinterpret_cast<const char *const *>(
record + OFF_SPELL_RANK + LocaleIndex() * 4);
return rank != nullptr && *rank != '\0';
}
// Minimum target level required to apply this exact spell (rank), or 0 if
// the spell isn't subject to the ranked-positive-aura target-level rule.
int RequiredTargetLevel(const uint8_t *record) {
const int spellLevel =
*reinterpret_cast<const int *>(record + OFF_SPELL_SPELL_LEVEL);
if (spellLevel <= 10) // targetLevel + 10 >= spellLevel holds for any level
return 0;
const uint32_t attr =
*reinterpret_cast<const uint32_t *>(record + OFF_SPELL_RECORD_ATTRIBUTES);
if (attr & SPELL_ATTR_PASSIVE)
return 0;
if (!HasRankString(record)) // single-rank spell → not gated
return 0;
auto *effect = reinterpret_cast<const uint32_t *>(record + OFF_SPELL_EFFECT);
auto *targetA =
reinterpret_cast<const uint32_t *>(record + OFF_SPELL_EFFECT_TARGET_A);
bool positiveAura = false;
for (int i = 0; i < 3; ++i) {
if ((effect[i] == SPELL_EFFECT_APPLY_AURA &&
IsPositiveRankTarget(targetA[i])) ||
effect[i] == SPELL_EFFECT_APPLY_AREA_AURA_PARTY) {
positiveAura = true;
break;
}
}
if (!positiveAura)
return 0;
return spellLevel - 10;
}
int ArgSpellID(void *L) {
if (!Game::Lua::IsNumber(L, 1))
return 0;
return static_cast<int>(Game::Lua::ToNumber(L, 1));
}
// `C_Spell.GetSpellLevelInfo(spellID)` → spellLevel, baseLevel, maxLevel.
// Raw Spell.dbc level fields: spellLevel (this rank's effective level),
// baseLevel, maxLevel (level at which scaling caps). nil for an unknown
// spellID.
int __fastcall Script_GetSpellLevelInfo(void *L) {
const uint8_t *record = Spell::Lookup::RecordForID(ArgSpellID(L));
if (record == nullptr)
return 0;
Game::Lua::PushNumber(
L, static_cast<double>(
*reinterpret_cast<const int *>(record + OFF_SPELL_SPELL_LEVEL)));
Game::Lua::PushNumber(
L, static_cast<double>(
*reinterpret_cast<const int *>(record + Offsets::OFF_SPELL_RECORD_BASE_LEVEL)));
Game::Lua::PushNumber(
L, static_cast<double>(
*reinterpret_cast<const int *>(record + OFF_SPELL_MAX_LEVEL)));
return 3;
}
// `GetSpellRequiredTargetLevel(spellID)` → the minimum level a target must
// be for this spell (rank) to apply (0 = no restriction). nil for an
// unknown spellID. Ranked beneficial auras only; see RequiredTargetLevel.
int __fastcall Script_GetSpellRequiredTargetLevel(void *L) {
const uint8_t *record = Spell::Lookup::RecordForID(ArgSpellID(L));
if (record == nullptr)
return 0;
Game::Lua::PushNumber(L, static_cast<double>(RequiredTargetLevel(record)));
return 1;
}
// `C_SpellBook.GetSpellLevelLearned(spellID)` — returns the level
// at which a spell becomes available (the `BaseLevel` field in
// Spell.dbc). Direct read off the record — no class/race filtering,
@@ -315,6 +448,12 @@ void RegisterLuaFunctions() {
&Script_GetSpellLevelLearned);
Game::Lua::RegisterTableFunction("C_SpellBook", "GetCurrentLevelSpells",
&Script_GetCurrentLevelSpells);
Game::Lua::RegisterGlobalFunction("GetSpellRequiredTargetLevel",
&Script_GetSpellRequiredTargetLevel);
Game::Lua::RegisterTableFunction("C_Spell", "GetSpellLevelInfo",
&Script_GetSpellLevelInfo);
Game::Lua::RegisterTableFunction("C_Spell", "GetSpellRequiredTargetLevel",
&Script_GetSpellRequiredTargetLevel);
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
+16
View File
@@ -29,6 +29,11 @@ namespace Table::Wipe {
// guarantee holds in 1.12's Lua 5.0 (`luaH_next` at `0x006FA2A0`),
// so the pattern ports verbatim.
//
// One divergence from the 3.3.5 source: 1.12's Lua 5.0 keeps a table's
// `table.insert`/`getn` length out of band from its keys (see the
// `luaL_setn` note below), so after the key-clear loop we must also
// reset that counter — the 5.1 twipe had no such legacy to clear.
//
// Stack discipline matches 3.3.5 to the call:
// 1. `lua_pushnil(L)` — initial "no key" sentinel for next
// 2. `lua_next(L, 1)` — pops key, pushes (newKey, value)
@@ -51,6 +56,17 @@ static int __fastcall Script_table_wipe(void *L) {
Game::Lua::RawSet(L, 1); // t[key] = nil
}
// Reset the Lua 5.0 length counter. In 5.0 a table's `table.insert`/
// `getn` length is stored OUT of band from its keys (in a `t.n`
// field or a weak `sizes[t]` registry entry), so the rawset(nil)
// loop above — which only clears keys — leaves it at its old value.
// Without this, `table.insert` after a wipe appends at
// `getn(t)+1`, past the now-nil slots, and `t[1]` stays nil. 5.1's
// wipe doesn't need this (no getn legacy), which is why the ported
// logic looked complete. `luaL_setn(L, 1, 0)` puts it back to 0 so
// inserts resume at `t[1]`.
Game::Lua::SetN(L, 1, 0);
// The table is still at stack[1] and is the result we return.
Game::Lua::SetTop(L, 1);
return 1;
+139 -21
View File
@@ -126,8 +126,9 @@ int SlotsForInvType(int invType, int *out) {
// English fallback labels, used when the client doesn't define the
// modern `_G[key]` global-string (vanilla 1.12 largely doesn't). Phrasing
// mirrors pfUI's eqcompare so the deltas read consistently on this
// client. Percent stats keep a "%" suffix since the values are native
// vanilla percentages.
// client. Percent stats carry NO "%" here — `IsPercentStat` appends the
// "%" to the value ("-5% Block"), so the unit is correct regardless of
// which label (Blizzard `_SHORT` or this fallback) is used.
struct KeyLabel {
const char *key;
const char *label;
@@ -149,25 +150,46 @@ constexpr KeyLabel kFallbackLabels[] = {
{"RESISTANCE6_NAME", "Arcane Resistance"},
{"ITEM_MOD_ATTACK_POWER_SHORT", "Attack Power"},
{"ITEM_MOD_RANGED_ATTACK_POWER_SHORT", "Ranged Attack Power"},
{"ITEM_MOD_CRIT_MELEE_RATING", "Melee Crit %"},
{"ITEM_MOD_CRIT_RANGED_RATING", "Ranged Crit %"},
{"ITEM_MOD_HIT_MELEE_RATING", "Melee Hit %"},
{"ITEM_MOD_HIT_RANGED_RATING", "Ranged Hit %"},
{"ITEM_MOD_HIT_SPELL_RATING", "Spell Hit %"},
{"ITEM_MOD_CRIT_SPELL_RATING", "Spell Crit %"},
{"ITEM_MOD_CRIT_MELEE_RATING", "Melee Crit"},
{"ITEM_MOD_CRIT_RANGED_RATING", "Ranged Crit"},
{"ITEM_MOD_HIT_MELEE_RATING", "Melee Hit"},
{"ITEM_MOD_HIT_RANGED_RATING", "Ranged Hit"},
{"ITEM_MOD_HIT_SPELL_RATING", "Spell Hit"},
{"ITEM_MOD_CRIT_SPELL_RATING", "Spell Crit"},
{"ITEM_MOD_SPELL_DAMAGE_DONE_SHORT", "Spell Damage"},
{"ITEM_MOD_SPELL_HEALING_DONE_SHORT", "Spell Healing"},
{"ITEM_MOD_MANA_REGENERATION", "Mana Regen"},
{"ITEM_MOD_DEFENSE_SKILL_RATING", "Defense"},
{"ITEM_MOD_DODGE_RATING", "Dodge %"},
{"ITEM_MOD_PARRY_RATING", "Parry %"},
{"ITEM_MOD_BLOCK_RATING", "Block %"},
{"ITEM_MOD_DODGE_RATING", "Dodge"},
{"ITEM_MOD_PARRY_RATING", "Parry"},
{"ITEM_MOD_BLOCK_RATING", "Block"},
{"ITEM_MOD_BLOCK_VALUE", "Block Value"},
{"ITEM_MOD_DAMAGE_PER_SECOND_SHORT", "Damage Per Second"},
{"ITEM_DELTA_DESCRIPTION",
"If you replace this item, the following stat changes will occur:"},
};
// Percent-valued stat keys: the block/dodge/parry *chances* and the
// crit/hit percentages. Vanilla stores these as native percentages (a
// value of 5 means 5%), so the delta must read "-5% Block", not "-5
// Block". The "%" is attached to the NUMBER rather than the label, so it
// stays correct in every locale and requires no `_SHORT` change.
// NB: ITEM_MOD_DEFENSE_SKILL_RATING (skill points) and
// ITEM_MOD_BLOCK_VALUE (flat block amount) are NOT percentages.
bool IsPercentStat(const char *key) {
static const char *const kPercent[] = {
"ITEM_MOD_CRIT_MELEE_RATING", "ITEM_MOD_CRIT_RANGED_RATING",
"ITEM_MOD_CRIT_SPELL_RATING", "ITEM_MOD_HIT_MELEE_RATING",
"ITEM_MOD_HIT_RANGED_RATING", "ITEM_MOD_HIT_SPELL_RATING",
"ITEM_MOD_DODGE_RATING", "ITEM_MOD_PARRY_RATING",
"ITEM_MOD_BLOCK_RATING",
};
for (const char *k : kPercent)
if (std::strcmp(k, key) == 0)
return true;
return false;
}
const char *FallbackLabel(const char *key) {
for (const auto &e : kFallbackLabels)
if (std::strcmp(e.key, key) == 0)
@@ -175,23 +197,112 @@ const char *FallbackLabel(const char *key) {
return key; // last resort — raw key beats crashing
}
// Resolve a display label for `key` into `out`. Prefers the client's
// `_G[key]` when it's a plain, non-empty string (respects localization /
// server-defined labels), read via a direct globals-table access — no
// pcall. Ignores format strings (a "%"-bearing global like "+%d Strength"
// would render garbage), falling back to the built-in English label.
void LabelFor(void *L, const char *key, char *out, int outSize) {
// True if `s` contains a printf conversion (`%d`, `%s`, `%1$s`, …) as
// opposed to a literal `%` used as a unit. A `%` at end-of-string, before
// a space, or escaped as `%%` is treated as literal. Lets locale labels
// like "Dodge %" through while still rejecting a format-string global
// such as "+%d Strength" (which would render garbage via snprintf "%s").
bool HasFormatSpecifier(const char *s) {
for (const char *p = std::strchr(s, '%'); p != nullptr;
p = std::strchr(p + 1, '%')) {
const char n = p[1];
if (n == '%') { // escaped "%%" — skip both, keep scanning
++p;
continue;
}
if (n != '\0' && n != ' ')
return true; // "%d", "%s", "%1$..." → real conversion
}
return false;
}
bool EndsWithShort(const char *key) {
const size_t n = std::strlen(key);
return n >= 6 && std::strcmp(key + n - 6, "_SHORT") == 0;
}
// Raw `_G[key]` string fetch — no format-specifier filtering. Direct
// globals-table access, no pcall. Returns whether a non-empty string was
// copied into `out`.
bool TryGlobalRaw(void *L, const char *key, char *out, int outSize) {
const int saved = Game::Lua::GetTop(L);
Game::Lua::PushString(L, key);
Game::Lua::GetTable(L, Game::Lua::GLOBALS_INDEX);
const char *g = (Game::Lua::Type(L, -1) == Game::Lua::TYPE_STRING)
? Game::Lua::ToString(L, -1)
: nullptr;
if (g != nullptr && *g != '\0' && std::strchr(g, '%') == nullptr)
const bool ok = (g != nullptr && *g != '\0');
if (ok)
std::snprintf(out, outSize, "%s", g);
else
std::snprintf(out, outSize, "%s", FallbackLabel(key));
Game::Lua::SetTop(L, saved);
return ok;
}
// Copies `_G[key]` into `out` iff it's a plain, non-empty, non-format
// string. Returns whether it took.
bool TryGlobalLabel(void *L, const char *key, char *out, int outSize) {
char tmp[192];
if (!TryGlobalRaw(L, key, tmp, sizeof(tmp)) || HasFormatSpecifier(tmp))
return false;
std::snprintf(out, outSize, "%s", tmp);
return true;
}
// Formats a signed value token (e.g. "-5") as a localized percentage via
// the PERCENTAGE_STRING global (typically "%s%%" → "-5%"). The locale
// format is NEVER passed to snprintf — we substitute `num` into its first
// conversion slot (`%s`/`%d`) by hand and collapse `%%` → `%`, so a
// hostile or unexpected format can't misread the argument list. Falls back
// to a plain "%" suffix when the global is absent.
void FormatPercent(void *L, const char *num, char *out, int outSize) {
char fmt[64];
if (!TryGlobalRaw(L, "PERCENTAGE_STRING", fmt, sizeof(fmt))) {
std::snprintf(out, outSize, "%s%%", num);
return;
}
int o = 0;
bool inserted = false;
for (const char *p = fmt; *p != '\0' && o < outSize - 1; ++p) {
if (p[0] == '%' && p[1] == '%') { // literal percent
out[o++] = '%';
++p;
} else if (p[0] == '%' && p[1] != '\0' && !inserted) { // value slot
for (const char *q = num; *q != '\0' && o < outSize - 1; ++q)
out[o++] = *q;
++p; // consume the conversion letter (s / d)
inserted = true;
} else {
out[o++] = *p;
}
}
out[o] = '\0';
}
// Resolve a display label for a stat `key` into `out`, respecting
// localization. The rating/regen tokens GetItemStats keys on
// (`ITEM_MOD_CRIT_MELEE_RATING`, …) carry a "%s" *description* as their
// own global; their `_SHORT` sibling is the compact label a delta wants.
// So for a key that isn't already `_SHORT`, prefer `_G[key.."_SHORT"]`:
// 1. `_G[key.."_SHORT"]` — the compact label (skipped if `key` already
// ends in `_SHORT`, e.g. the primary stats, or has no `_SHORT`
// variant like `RESISTANCE*_NAME` / `CURRENTLY_EQUIPPED`).
// 2. `_G[key]` — the key itself (primary stats resolve here; rating
// tokens only land here if no `_SHORT` global exists).
// 3. Built-in English `FallbackLabel(key)`.
// Preferring `_SHORT` first (rather than only when `_G[key]` fails) means
// a server/addon defining the base rating token as a plain string can't
// shadow the compact `_SHORT` label. StatAccum keys stay the canonical
// GetItemStats tokens; this `_SHORT` mapping is display-only.
void LabelFor(void *L, const char *key, char *out, int outSize) {
if (!EndsWithShort(key)) {
char shortKey[160];
std::snprintf(shortKey, sizeof(shortKey), "%s_SHORT", key);
if (TryGlobalLabel(L, shortKey, out, outSize))
return;
}
if (TryGlobalLabel(L, key, out, outSize))
return;
std::snprintf(out, outSize, "%s", FallbackLabel(key));
}
// Delta colors — the canonical GlobalColor.dbc tags modern WoW uses for
@@ -237,7 +348,14 @@ void RenderDeltas(void *self, void *L, const Accum *acc, double dpsDelta) {
if (v == 0)
continue;
LabelFor(L, acc[i].key, label, sizeof(label));
std::snprintf(line, sizeof(line), "%s%ld %s", v > 0 ? "+" : "", v, label);
char num[48];
std::snprintf(num, sizeof(num), "%s%ld", v > 0 ? "+" : "", v);
if (IsPercentStat(acc[i].key)) {
char pct[64];
FormatPercent(L, num, pct, sizeof(pct));
std::snprintf(num, sizeof(num), "%s", pct);
}
std::snprintf(line, sizeof(line), "%s %s", num, label);
AddColoredLine(self, line, v > 0 ? kGain : kLoss);
}
}
+177
View File
@@ -0,0 +1,177 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU 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 General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
// Pet / minion classification of a unit:
// - `UnitIsMinion(unit)` — modern backport: is the unit a
// minion of a player (pet, guardian, totem, charmed creature)?
// - `UnitIsOtherPlayersPet(unit)` — the real API: is the unit a pet
// controlled by a player OTHER than you?
//
// Neither exists in vanilla 1.12 nor in our 3.3.5 reference client
// (`UnitIsMinion` is a 12.0.0/5.5.4 addition; `UnitIsOtherPlayersPet`
// is 5.0.4/1.13.2), so both are derived from vanilla primitives:
// * `Unit::Flags::IsPlayerControlled` — UNIT_FLAG_PLAYER_CONTROLLED,
// set on players and every unit a player controls (pets, guardians,
// totems, charmed mobs).
// * The unit's GUID type — vanilla packs the entity type in the GUID's
// high bits (`Guid::Classify`): players `0x0000`, pets `0xF140`,
// creatures `0xF130`. A pet-type GUID is intrinsically a player's pet.
//
// `UnitIsMinion` = owned by a player (its SummonedBy/CreatedBy/CharmedBy
// GUID resolves to a player), OR player-controlled-and-not-a-player as a
// fallback. Owner-based is primary because the player-controlled flag
// alone misses summoned guardians — units shown as "X's Minion" that carry
// an owner GUID but not the flag (a real hunter/warlock "X's Pet" has
// both). The union keeps flagged pets working regardless of the owner
// fields.
//
// `UnitIsOtherPlayersPet` is **owner-based**, matching 5.4.8's real
// `Script_UnitIsOtherPlayersPet` (`0x0089FD70`): resolve the unit, read
// its owner GUID (SummonedBy / CreatedBy / CharmedBy), and return true iff
// that owner is a *player* other than you. Owner-based (not a pet-GUID
// heuristic) so it covers pets, guardians, totems, and charmed units of
// other players alike, and excludes *all* of your own minions (owner ==
// you), not just the active pet. The owner-field offsets are byte-verified
// / adjacency-derived — see `OFF_UNIT_FIELD_CHARMEDBY`.
#include "Game.h"
#include "Offsets.h"
#include "guid/Guid.h"
#include "unit/Flags.h"
#include "unit/Identity.h"
#include <cstdint>
namespace Unit::Pet {
namespace {
using ResolveUnitToken_t = void *(__fastcall *)(const char *token);
// `int __fastcall(unit)` → 1 for a controllable pet, non-1 otherwise.
// The engine's pet-vs-minion title discriminator. See
// FUN_UNIT_PET_MINION_CLASS.
using PetMinionClass_t = int(__fastcall *)(const void *unit);
const uint8_t *ResolveUnit(void *L) {
if (!Game::Lua::IsString(L, 1))
return nullptr;
const char *token = Game::Lua::ToString(L, 1);
if (token == nullptr)
return nullptr;
auto resolve = reinterpret_cast<ResolveUnitToken_t>(Offsets::FUN_RESOLVE_UNIT_TOKEN);
return static_cast<const uint8_t *>(resolve(token));
}
// The unit's owner/controller GUID: CharmedBy, else CreatedBy (the two
// fields the engine itself uses for ownership). 0 for players, world
// creatures, and anything unowned.
uint64_t OwnerGuid(const uint8_t *unit) {
auto *desc = *reinterpret_cast<const uint8_t *const *>(
unit + Offsets::OFF_UNIT_DESCRIPTOR);
if (desc == nullptr)
return 0;
const int owners[] = {
Offsets::OFF_UNIT_FIELD_CHARMEDBY,
Offsets::OFF_UNIT_FIELD_CREATEDBY,
};
for (int off : owners) {
const uint64_t g = *reinterpret_cast<const uint64_t *>(desc + off);
if (g != 0)
return g;
}
return 0;
}
// `UnitIsMinion(unit)` — true iff the unit is a player's minion (pet,
// guardian, totem, or charmed creature): player-controlled but not itself
// a player. NPCs, world creatures, and players return false. A bad or
// unresolved unit token returns false (no error), matching the tolerant
// modern predicate convention.
int __fastcall Script_UnitIsMinion(void *L) {
const uint8_t *unit = ResolveUnit(L);
if (unit == nullptr) {
Game::Lua::PushBool(L, 0);
return 1;
}
// Primary: owned by a player (pet, guardian, totem, charmed).
bool minion = Guid::IsPlayer(OwnerGuid(unit));
// Fallback: a player-controlled non-player with no owner GUID.
if (!minion && Unit::Flags::IsPlayerControlled(unit))
minion = !Guid::IsPlayer(Unit::Identity::GuidForObject(unit));
Game::Lua::PushBool(L, minion);
return 1;
}
// `UnitIsPet(unit)` — true iff the unit is a **pet** of a player (as
// opposed to a guardian/totem "minion"): owned by a player AND the
// engine's pet/minion classifier `FUN_UNIT_PET_MINION_CLASS` returns 1.
// That classifier — reading the creature *family*, not the GUID or the
// player-controlled flag — is exactly what the engine uses to title a unit
// "X's Pet" vs "X's Minion". On 1.12/Turtle the GUID can't be used (pets
// and guardians share the `0xF14…` prefix) and the PLAYER_CONTROLLED flag
// is unreliable (a "Minion" can carry it), so the family classifier is the
// only correct signal. The owner-is-player gate also rules out wild
// tameable beasts of the same family (which aren't anyone's pet).
// `UnitIsMinion` is the broad umbrella (pet + guardian + totem); this is
// the pet subset. A ClassicAPI extension — not a stock WoW global.
int __fastcall Script_UnitIsPet(void *L) {
const uint8_t *unit = ResolveUnit(L);
if (unit == nullptr) {
Game::Lua::PushBool(L, 0);
return 1;
}
// Owned by a player (rules out wild same-family beasts), AND the
// engine's own pet/minion classifier says PET (1). The classifier —
// not the GUID, not the player-controlled flag — is what the engine
// uses to title "X's Pet" vs "X's Minion".
bool pet = false;
if (Guid::IsPlayer(OwnerGuid(unit))) {
auto classify = reinterpret_cast<PetMinionClass_t>(
Offsets::FUN_UNIT_PET_MINION_CLASS);
pet = (classify(unit) == 1);
}
Game::Lua::PushBool(L, pet);
return 1;
}
// `UnitIsOtherPlayersPet(unit)` — true iff the unit is a minion (pet,
// guardian, totem, charmed) whose owner is a player other than you.
// Owner-based, mirroring 5.4.8: read the unit's owner GUID, require it to
// be a player, and require it to differ from the local player. World
// creatures and players (no owner) return false; your own minions (owner
// == you) return false.
int __fastcall Script_UnitIsOtherPlayersPet(void *L) {
const uint8_t *unit = ResolveUnit(L);
if (unit == nullptr) {
Game::Lua::PushBool(L, 0);
return 1;
}
const uint64_t owner = OwnerGuid(unit);
const bool otherPlayers = Guid::IsPlayer(owner) &&
owner != Unit::Identity::PlayerGuid();
Game::Lua::PushBool(L, otherPlayers);
return 1;
}
} // namespace
static void RegisterLuaFunctions() {
Game::Lua::RegisterGlobalFunction("UnitIsMinion", &Script_UnitIsMinion);
Game::Lua::RegisterGlobalFunction("UnitIsPet", &Script_UnitIsPet);
Game::Lua::RegisterGlobalFunction("UnitIsOtherPlayersPet",
&Script_UnitIsOtherPlayersPet);
}
static const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace Unit::Pet
+62 -14
View File
@@ -8,11 +8,21 @@
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
// `UnitInRange(unit) → (inRange, checkedRange)` — 40-yard healing-
// range check, modern's `UnitInRange` shape. Vanilla 1.12 doesn't
// ship the function but has all the underlying machinery — the
// same `GetPosition` virtual (vtable slot 5) `CheckInteractDistance`
// uses, applied with a fixed 40-yard threshold.
// Player↔unit proximity, modern's shape. Vanilla 1.12 ships neither
// function but has all the underlying machinery — the object's
// `GetPosition` virtual (vtable slot 5) `CheckInteractDistance` uses:
//
// `UnitInRange(unit) → (inRange, checkedRange)` — 40-yard healing-
// range check (fixed threshold on the squared distance).
// `UnitDistanceSquared(unit) → (distanceSquared, checkedPosition)` —
// the raw squared world distance player→unit. Squared because
// nearly every consumer compares against a threshold (`distSq <=
// range*range`) or ranks by nearest, neither of which needs the
// sqrt; that's why retail exposes only the squared form (added
// 5.0.4). Center-to-center — NOT reach-aware (that's UnitXP_SP3's
// niche); equivalent to computing it from SuperWoW `UnitPosition`,
// but self-contained (reads the position in C, no sibling-DLL
// dependency, no clash with SuperWoW's own `UnitPosition`).
#include "Game.h"
#include "unit/Position.h"
@@ -26,6 +36,24 @@ namespace {
constexpr float kRangeYards = 40.0f;
constexpr float kRangeYardsSq = kRangeYards * kRangeYards;
// Squared world distance from the player to `token`'s unit. Returns true
// and writes *outSq on success; false when either position is unavailable
// (unresolved / absent token, or an object with no known position yet —
// e.g. a party member outside the client's sync range). This false is
// exactly the `checkedPosition = false` case retail flags.
bool PlayerToUnitDistSq(const char *token, float *outSq) {
float unitPos[3] = {};
float playerPos[3] = {};
if (!Unit::Position::ReadToken(token, unitPos) ||
!Unit::Position::ReadToken("player", playerPos))
return false;
const float dx = unitPos[0] - playerPos[0];
const float dy = unitPos[1] - playerPos[1];
const float dz = unitPos[2] - playerPos[2];
*outSq = dx * dx + dy * dy + dz * dz;
return true;
}
int __fastcall Script_UnitInRange(void *L) {
if (!Game::Lua::IsString(L, 1)) {
Game::Lua::Error(L, "Usage: UnitInRange(\"unit\")");
@@ -44,27 +72,47 @@ int __fastcall Script_UnitInRange(void *L) {
return 2;
}
float unitPos[3] = {};
float playerPos[3] = {};
if (!Unit::Position::ReadToken(token, unitPos) ||
!Unit::Position::ReadToken("player", playerPos)) {
float distSq = 0.0f;
if (!PlayerToUnitDistSq(token, &distSq)) {
Game::Lua::PushBool(L, false);
Game::Lua::PushBool(L, false);
return 2;
}
const float dx = unitPos[0] - playerPos[0];
const float dy = unitPos[1] - playerPos[1];
const float dz = unitPos[2] - playerPos[2];
const float distSq = dx * dx + dy * dy + dz * dz;
Game::Lua::PushBool(L, distSq <= kRangeYardsSq);
Game::Lua::PushBool(L, true);
return 2;
}
// `UnitDistanceSquared("unit") → (distanceSquared, checkedPosition)`.
// Unlike UnitInRange there's no self-quirk: `UnitDistanceSquared("player")`
// is a legitimate `(0, true)`. On a position miss returns `(0, false)` —
// matching retail's "always a number" shape; consumers must branch on
// `checkedPosition`, since a real `0` (self, or exactly co-located units)
// is indistinguishable from the miss placeholder by value alone.
int __fastcall Script_UnitDistanceSquared(void *L) {
if (!Game::Lua::IsString(L, 1)) {
Game::Lua::Error(L, "Usage: UnitDistanceSquared(\"unit\")");
return 0;
}
const char *token = Game::Lua::ToString(L, 1);
float distSq = 0.0f;
if (!PlayerToUnitDistSq(token, &distSq)) {
Game::Lua::PushNumber(L, 0.0);
Game::Lua::PushBool(L, false);
return 2;
}
Game::Lua::PushNumber(L, static_cast<double>(distSq));
Game::Lua::PushBool(L, true);
return 2;
}
void RegisterLuaFunctions() {
Game::Lua::RegisterGlobalFunction("UnitInRange", &Script_UnitInRange);
Game::Lua::RegisterGlobalFunction("UnitDistanceSquared",
&Script_UnitDistanceSquared);
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};