Compare commits

...
143 Commits
Author SHA1 Message Date
Brues a5b6cf367d aura: cache fallback for out-of-range group auras (server delta gap)
The server resends out-of-range party/raid stats as deltas only, so a
member seen in range and then moved away again leaves an empty party-
stats aura array (it's cleared when they become visible and never re-
sent), and C_UnitAuras showed nothing until a full roster resync (relog).
Their auras still sit in the Aura::Source cache from when we observed them
in range, so the four group enumerate paths now fall back to the cache
when the group array is empty -- gated by GroupArrayHasVisibleAura, the
same "container non-empty => authoritative" rule the descriptor path uses
(DescriptorHasVisibleAura). A populated array is trusted as-is (no
phantoms); only the empty-array gap consults the cache, deduped and
visibility/playerOnly-filtered.
2026-07-26 18:19:47 -05:00
Brues b496568670 Add C_Spell.GetSpellEffectMechanics(spellID)
Returns the spell's three per-effect SpellMechanic ids (Spell.dbc
EffectMechanic[3], +0x13C) as a 1-based array, or nil for an invalid spell.
Complements GetSpellMechanicByID, which reads only the spell-level Mechanic
field (+0x14): vanilla stores some mechanics on an effect instead -- e.g.
Rake's bleed is effect-level (spell-level 0, EffectMechanic[2]=15), so
GetSpellMechanicByID misses it while this returns {0,15,0}.

Add OFF_SPELL_RECORD_EFFECT_MECHANIC offset, API.md section + TOC entry, and
README index entry.
2026-07-26 15:42:25 -05:00
Brues a37c590a72 aura: clamp elapsed player-buff expiration to 0 (permanent auras)
Permanent auras (paladin auras, passives, racials) carry an infinite
SpellDuration (base -1); the server stores their fixed cast-time in the
player buff-expiration table, so it reads as already-elapsed and drifts
into the past. We were reporting that stale timestamp, giving a permanent
aura like Devotion Aura a negative time-left. Mirror the engine's own
GetPlayerBuffTimeLeft (Script at 0x004e4930 -> FUN_004e4450), which reads
the same table and returns 0 once now >= expiration. Signed compare
absorbs the GetTickCount wrap, matching the engine.
2026-07-26 12:16:48 -05:00
Brues 60479677c1 aura: best-effort duration guess for out-of-range group/raid auras
An out-of-range party/raid member has no CGUnit and never yields
SMSG_SPELL_GO, so the caster/duration cache never learned their auras'
timing and expirationTime stayed 0. But SMSG_PARTY_MEMBER_STATS carries
their aura spell IDs and the server sends it on change
(SendUpdateToOutOfRangeGroupMembers flushes the moment an aura add/remove
dirties the group mask), so a spell ID that newly appears in the group
array did so ~one tick + latency after the real application.

Aura::Source::ObserveGroupAuras (called from the group enumerate paths in
Aura::Data) keeps a per-member snapshot of the last-seen spell-ID set and,
on a genuine absent->present transition, stamps now + base duration into
the same cache PushEnriched already reads. Covers party and raid alike
(AuraArray reads the raid slot too; snapshot table sized for a 40-man).
Base duration only (no remote caster mods) and no caster recorded
(sourceUnit/GUID stay nil). Auras already present at first sight have
unknown age and are not stamped; snapshots reset on map change and after
30s unpolled. The Store guard keeps any real SpellGo timing, so this
never downgrades better data.
2026-07-26 03:05:54 -05:00
Brues b5038dc3fd util: add VectorLib (Vector2D / Vector3D / Vector4D)
Verbatim backport of retail's Blizzard_SharedXML vector mixins
(CreateVector2D/3D/4D + Vector*DMixin + the free Vector*_ helpers).
Pure math on CreateFromMixins and Lua 5.0 math.*, so no adaptation was
needed. Registered the three files in the TOC (we list .lua directly
rather than via retail's VectorLib.xml include).
2026-07-26 03:00:03 -05:00
Brues 0bbf802316 Use BASE_MOVEMENT_SPEED for speed calculation
Replace the magic divisor 7 with the BASE_MOVEMENT_SPEED constant
2026-07-26 02:53:34 -05:00
Brues 0fcaa2e064 util: add RectangleMixin / CreateRectangle
Backport of retail's Rectangle helper (sides, stretch/move/adjust,
contains/intersects, center, width/height accessors), built on
CreateFromMixins. Registered in the TOC.
2026-07-26 02:49:02 -05:00
Brues 6724ddd2bd mathutil: CreateCounter takes an optional step
Retail added a second `step` parameter (default 1); mirror it. Backward
compatible -- existing no-step callers are unchanged.
2026-07-26 02:46:58 -05:00
Brues 50d001a71f mathutil: add CreateAccumulator / AccumulatorMixin
Backport of retail's AccumulatorMixin (Init/Add/Subtract/Count/Reset)
and the CreateAccumulator factory, built on CreateAndInitFromMixin.
2026-07-26 02:42:40 -05:00
Brues 3aad341a91 table: add table.count(tbl)
Backport of retail's table.count (added 11.2.5): returns
(numTableNodes, numArrayNodes, maxArrayIndex) -- total entries, entries
with an integer key in [1..numTableNodes], and the largest positive
integral key (0 if none). Pure lua_next walk, no Lua-internal layout
dependency; keys read via ToNumber only (ToString on a numeric key
would corrupt traversal). All five cases verified to match retail,
including negative-key handling ({[-3]=x} -> 1, 0, 0).
2026-07-26 02:24:19 -05:00
Brues 3141acf547 baselib: provide Mixin and CreateFromMixins as C natives
Modern WoW implements these FrameXML table primitives as engine C
functions (TableUtil); do the same so they exist whenever the DLL is
injected -- even if the !!!ClassicAPI addon is disabled. Behaviour
matches the Lua form exactly: shallow key/value copy from each table
arg, non-table args skipped, returns the object / new table. Registered
on both the in-game and glue states.

Slimmed the addon Mixin.lua to just CreateAndInitFromMixin (calls a Lua
:Init, so it stays Lua), mirroring retail's Mixin.lua. The taint-based
SecureMixin family is omitted -- vanilla 1.12 has no execution taint.
2026-07-26 02:12:22 -05:00
Brues 05e7bda5da color: add C_ColorUtil and wire the addon Color files to it
New C_ColorUtil native module (src/color/Util.cpp) with the modern color
helpers, all pure math/string: ConvertRGBToHSV, ConvertHSVToRGB,
ConvertHSVToHSL, ConvertHSLToHSV, ConvertHSLToRGB, GenerateTextColorCode,
WrapTextInColor, WrapTextInColorCode. Conventions verified in-game against
a retail-family client: hue in degrees [0,360), -1 achromatic sentinel,
bare AARRGGBB from GenerateTextColorCode, |c..|r wrapping.

Ported the addon Color.lua / ColorUtil.lua to match retail now that the
natives exist: ColorMixin:GetHSL/GenerateHexColor/WrapText* delegate to
C_ColorUtil (GetHSL now returns degrees), SetRGBA defaults alpha to 1,
byte getters use Round; added COLOR_FORMAT_* constants and
CreateColorFromBestRGBHexString. Kept 1.12-Lua-5.0-safe syntax and the
ClassicAPI-only CAPI_ApplyStandardColorGlobals / ITEM_QUALITY_COLORS bits.
2026-07-26 01:57:56 -05:00
Brues c4b48f7f42 spell: add CanDualWield()
New global returning whether the player can equip an off-hand weapon.
The server tracks this as Player::m_canDualWield, flipped on only by
SPELL_EFFECT_DUAL_WIELD (effect 40); exactly one client Spell.dbc row
carries that effect -- 674 "Dual Wield" -- so CanDualWield() is exactly
IsPlayerSpell(674). Factored the known-spell bitmap read into a shared
PlayerKnowsSpell() helper used by both.
2026-07-26 01:30:11 -05:00
Brues 4d97b1c449 clipboard: add CopyToClipboard(text [, removeMarkup])
New Win32-backed global that copies text to the Windows clipboard and
returns the byte length copied. Optional removeMarkup strips WoW UI
escape sequences (color codes, hyperlinks keeping visible text,
textures, |n, || -> |) so the clipboard gets plain text. UTF-8 in,
CF_UNICODETEXT out. Registered on both the in-game and glue states.

Docs: folded the lone Screen section and this into a single System
section rather than adding a one-function Clipboard section.
2026-07-26 01:18:42 -05:00
Brues e447c70ca3 aura: recover player-talented DoT duration in the application hooks
A player DoT with a duration talent (e.g. Improved Shadow Word: Pain,
2/2 -> 24s vs 18s base) intermittently showed the base duration. The
displayed value is the Aura::Source cache entry's durationMs, which is
computed two ways: SpellGo knows caster == player so it applies the
player's duration mods (skipMod 0 -> 24s), while the OnAuraAdded /
OnAuraStacksChanged application hooks have no caster and take the
unmodified base (skipMod 1 -> 18s).

Normally SpellGo owns the entry and the application hooks can't clobber
it, but the base value wins whenever SpellGo can't land its talented
value on THIS target's entry: an empty SMSG_SPELL_GO hit list files it
under the caster instead (numTargets == 0), or a slot reshuffle
evicts + refills the entry after SpellGo ran. Both are packet-order
dependent, hence the flicker.

Fix: SpellGo (which knows caster == player) records recent player aura
casts by spellId; the application hooks recompute with skipMod 0 and
attribute the caster when the spellId matches a recent player cast.
Robust against both the attribution miss and the refill race, and
leaves other units' auras on the base path (we lack their mod tables).
2026-07-26 00:00:42 -05:00
Brues 72fa36956e tooltip: add OnTooltipSetSpell/Unit/GameObject scripts
Generalize the item-only Tooltip::SetItemEvent into Tooltip::SetEvents,
backing all four object tooltip scripts through one shared fire path
(stack save/restore + recursion guard + Suppressor). One resolver
co-hook recognizes all four OnTooltipSet* names (the per-tooltip cell
now holds a slot per kind), plus one builder co-hook per kind firing its
script after the original build:

- Spell  -> FUN_0052E610 (SetSpell/SetSpellByID/SetTalent/SetShapeshift;
  7 stack args, RET 0x1c)
- Unit   -> FUN_00529fe0 (SetUnit + mouseover; 1 arg, returns an int the
  caller tests -> co-hook forwards it)
- GameObject -> FUN_0052aa20 (GO hover populator; 1 arg)

Each hook's declared stack-arg count matches its callee RET N (verified
from disassembly) so the caller's stack isn't corrupted.

Auras deliberately do NOT fire OnTooltipSetSpell: SetUnitBuff/Debuff/
PlayerBuff funnel through a separate aura-tooltip builder (FUN_0052f880)
that bypasses the spell builder -- matching retail, where unit auras are
a distinct tooltip data type.

Verified in-game: all three new scripts fire (spell hover, unit
mouseover, gameobject mouseover), item still fires, no crash.
2026-07-25 22:57:32 -05:00
Brues 40813a715c talent: GetTalentSpellID takes an optional classID too
Mirror the classID extension just added to GetTalentIDByIndex. classID
is the 4th arg (GetTalentSpellID(tab, idx[, rank[, classID]])); pass rank
as nil to keep its default. A numeric classID resolves the talent
against the Talent.dbc / TalentTab.dbc flat arrays via the shared
ResolveTalentRecordForClass, then reads SpellRank[rank-1] off the record.

One semantic difference from the same-class path: there's no player
currentRank for a class you aren't, so a cross-class query with no
explicit rank defaults to rank 1 (the canonical spellID) rather than
delegating to the engine's GetTalentInfo.

Verified in-game: own-class parity at rank 1 (14524 == 14524), Mage (8)
tab1 talent1 -> Arcane Subtlety (11210 r1, 12592 r2, r9 nil),
default-rank cross-class == rank 1, and bad tab/index/class all nil.
2026-07-25 15:05:34 -05:00
Brues a14ccfacbb talent: GetTalentIDByIndex takes an optional classID
Previously it only read the local player's talent tree (the engine's
runtime per-tab arrays at VAR_TALENT_TAB_INFO_ARRAY, built only for the
player's class). Addons that want another class's talent IDs -- build
tooling, spec previews -- had no way to get them.

Add an optional 3rd arg: nil/omitted keeps the runtime path (player's
class, unchanged); a numeric classID resolves against the Talent.dbc /
TalentTab.dbc flat arrays instead, so any class works.

Ordering matches the player-class path exactly because it replicates the
engine's tree builder FUN_004f2c00: tabs taken from TalentTab.dbc in row
order filtered by classMask (+0x30; mask 0 = all, race not a factor --
FUN_004f2e50 is a plain bitmask test), then talents from Talent.dbc in
row order within each TabID. The runtime arrays are built from that same
walk, so GetTalentIDByIndex(t,i) == GetTalentIDByIndex(t,i,<player class>).

New Offsets: the flat (row-order) DBC array globals the builder uses
(VAR_TALENTTAB_DBC_FLAT_* / VAR_TALENT_DBC_FLAT_*), distinct from the
sparse by-ID index SetTalentByID uses.

Verified in-game: own-class parity (166 == 166), Mage (8) tabs 1/2/3
first talents = 37/1/18, and out-of-range tab/index/class all return nil
with no fault.
2026-07-25 14:58:49 -05:00
Brues 4afe0b9eae aura: read out-of-range groupmate auras from the roster
C_UnitAuras.* returned nothing for a party/raid member with no live
CGUnit (different map / far out of range): every path bailed on the
null unit, and the only fallback was the Aura::Source SMSG_SPELL_GO
cache, which needs the object to exist.

The server keeps transmitting each group member's current aura spell
IDs via SMSG_PARTY_MEMBER_STATS (GROUP_UPDATE_FLAG_AURAS / _NEGATIVE)
and the client stashes them in the group-member roster structs -- the
same source the built-in UnitBuff/UnitDebuff read out of range. Surface
them.

- Group::MemberStats::AuraArray(guid) mirrors the engine's own
  out-of-range fallback in Script_UnitBuff/Script_UnitDebuff exactly
  (party stats block +0x1a gated on the online flag, else raid slot
  +0x64; both 48x u16).
- Factor aura-table enrichment (caster/expiration/duration) into a
  single descriptor-independent PushEnriched core; Push and
  PushFromCache now delegate to it.
- New group-array enumerate branch (PushNthGroupAura /
  PushGroupAuraBySpellID / ...BySpellName / AppendGroupAuras), applying
  the engine's per-slot visibility predicate and merging any
  caster/timing the SpellGo cache observed.
- Api.cpp routes unit==null to the group array for every C_UnitAuras
  entry point. Safe: the token resolver never raises (returns 0), and
  the GUID lookup only runs after ResolveUnit already accepted the
  token.

Spell IDs only (no stacks; IDs are u16-truncated) -- same limitations
as UnitBuff. Verified in-game: GetUnitAuras("raid1") cross-map returns
Power Word: Fortitude (10938), matching UnitBuff.
2026-07-25 14:33:58 -05:00
Brues c01dceabb8 unit: class/race fall back to the player-info cache out of range
UnitClassBase / UnitRaceBase resolved via FUN_RESOLVE_UNIT_TOKEN and returned
(nil,nil) for a groupmate on another map -- but the engine's own UnitClass /
UnitRace fall back to the cached player-info record there (verified in-game:
UnitClass("raid1")="Priest", UnitRace("raid1")="Human" while our descriptor
path yielded nil). Give ours the same fallback: on a descriptor miss, read
class (+0x140) / race (+0x138) from the NameCache record by GUID. Verified
fixed: UnitClassBase("raid1")=("PRIEST",5), UnitRaceBase("raid1")=("Human",1).

That engine-NameCache peek existed in four places (the two above plus
loot/History and player/Info). Collapsed them into one primitive,
Unit::Identity::PlayerInfoRecord(guid) -> record* (null callback = peek only),
and pointed all four at it. FUN_PLAYER_INFO_LOOKUP is now called in exactly
one spot.
2026-07-25 13:19:04 -05:00
Brues 365df6b882 group: add Group::MemberStats, generalize the out-of-range roster reader
Extract the roster fallback (added for UnitPower) into a shared
Group::MemberStats reader and give it the full picture. Lookup(guid) tries
the party stats block (FUN_GROUP_MEMBER_STATS_LOOKUP) then the raid slot
(FUN_GROUP_MEMBER_SLOT_LOOKUP) -- the object-independent group roster fed by
SMSG_PARTY_MEMBER_STATS -- and returns health, maxHealth, power, maxPower,
powerType, level, areaId, online (both paths) plus dead, subgroup, rank (raid
path). Raid-struct layout mapped from Script_GetRaidRosterInfo (0x004bb560):
subgroup@+8, rank@+0xc, flags@+0x18 (bit0 online, bit2 dead), level@+0x22,
areaId@+0x24, plus the health/power fields already used.

UnitPower/Max/Missing now go through it (no behavior change -- verified target
still reads UnitPower == UnitMana == 2896 on a different map), and
UnitHealthMissing gains the same fallback so it no longer returns 0 for an
out-of-range groupmate. UnitPowerType already had the engine's own roster
fallback (0x00517940) and needs nothing.

The extra fields (level/area/online/dead/subgroup/rank) are populated but not
yet consumed -- groundwork for fixing UnitLevel / UnitIsConnected / UnitIsDead
and a full GetRaidRosterInfo for out-of-range members.
2026-07-25 12:59:55 -05:00
Brues 72de6c0960 unit: fall back to the group roster for out-of-range power reads
UnitPower / UnitPowerMax / UnitPowerMissing read the CGUnit descriptor
directly, so they returned 0 for a party/raid member on a different map
(no live object). The engine's UnitMana/UnitManaMax don't -- they fall back
to the cached member stats from SMSG_PARTY_MEMBER_STATS.

Mirror that fallback: when ResolveArgs finds no live object, resolve
token -> GUID and read the member's primary power from the party stats block
(FUN_GROUP_MEMBER_STATS_LOOKUP: type@+9, cur@+0xe, max@+0x10) then the raid
slot (FUN_GROUP_MEMBER_SLOT_LOOKUP: type@+0x58, cur@+0x60, max@+0x62), all
u16 -- exactly Script_UnitMana (0x00517670) / Script_UnitManaMax (0x005177e0).
Verified in-game: a different-map target now reports UnitPower == UnitMana
(2896) instead of 0.

Limitation (inherent): the roster caches only the member's primary power, so
an explicit non-primary powerType returns 0 out of range. Reached only after
ResolveArgs fails on an already-valid token, so TokenToGUID can't raise.
2026-07-25 12:42:06 -05:00
Brues eeba4ada9d focus: keep focus on party/raid members that leave range
3.3.5 does not drop focus the instant a unit leaves the object table -- its
object-teardown purge (FUN_00524350) is gated by FUN_00512a30, which is
"party-member (FUN_0052d310) OR raid-member (FUN_00573200)". So a groupmate
whose CGUnit is destroyed by going out of range/LoS keeps focus and reattaches
when they return; only non-group units clear on despawn. (Verified in 3.3.5
via Ghidra: focus GUID at DAT_00bd07d0/d4, sole writer FUN_0051ff20, purge
gate decoded to the party/raid roster checks.)

OnWorldTick now mirrors that gate: on an ObjectByGUID miss it keeps focus when
IsGroupMemberGuid(g_focusGUID) -- reading the object-independent roster GUIDs
(VAR_PARTY_GUIDS 4-slot array + VAR_RAID_MEMBER_PTRS 40-slot member-ptr array,
GUID at *member+0) -- otherwise clears as before. A new g_focusObserved flag
tracks whether the "focus" unit-event observers are attached to a live object;
they die with the object on despawn, so OnWorldTick re-registers them when a
groupmate returns to range, and Set() registers only against a resolvable
object (deferring to OnWorldTick when focusing an out-of-range member).

Corrects the earlier note claiming vanilla/modern drop focus on mere range
loss -- that was wrong; the drop is object-teardown gated by group membership.
While a groupmate is out of range UnitExists("focus") is still false (no client
object to read), but the focus survives and snaps back live on their return.
2026-07-25 12:19:51 -05:00
Brues 5f13c63c99 unit: add UnitSpellHaste(unit) -- backport of the TBC+ haste getter
Vanilla 1.12 has no spell-haste API. UnitSpellHaste reads UNIT_MOD_CAST_SPEED
(the cast-time multiplier at descriptor +0x22c that the server folds into
SpellEntry::GetCastTime) and converts it to the modern haste percentage:

    haste% = (1 / modCastSpeed - 1) * 100

so 1.0 -> 0, 0.5 (half cast time) -> 100, and a slow like Curse of Tongues
(>1.0) -> negative. This is the Blizzard-shaped surface over the same field
nampower exposes raw as GetUnitField(unit, "modCastSpeed"), letting addons
drop that dependency (raw multiplier recoverable as 1/(1 + haste/100)).

Formula verified byte-exact against 4.3.4's Script_UnitSpellHaste
(FUN_004555b0): `(C1 / mult) * C2 - C2` with C1=1.0, C2=100.0 and a
div-by-zero guard -- the constants are forced by "unhasted must read 0" and
"return a percentage". The 4.3.4 field lives at desc+0x10c; +0x22c is the
verified 1.12 offset. Our div-guard uses `> 0` vs retail's `!= 0` (identical
for the always-positive cast-speed field, marginally more defensive).

New src/unit/SpellHaste.cpp + OFF_UNIT_MOD_CAST_SPEED; docs + README updated.
2026-07-25 00:27:17 -05:00
Brues afa1f62720 unit: add the unmodified arg to UnitPower / UnitPowerMax
Completes the retail signature UnitPower(unit [, powerType [, unmodified]])
(and UnitPowerMax). A truthy third arg skips the per-type display divisor and
returns the raw internal value, via the same ToBoolean(L, 3) path already used
by UnitPowerMissing.

Verified in-game on a warrior (rage, divisor 10):
  UnitPower(1)=96  UnitPower(1,true)=961   (floor(961/10)=96)
  UnitPowerMax(1)=100  UnitPowerMax(1,true)=1000
  UnitPowerMissing(1)=4  UnitPowerMissing(1,true)=39
The missing=4 (not 3) confirms the display-value subtraction: 100-96, not
(1000-961)/10 floored to 3.

Docs updated (signature, TOC anchor, unmodified note).
2026-07-25 00:10:09 -05:00
Brues c369373efe unit: add UnitPowerMissing(unit [, powerType [, unmodified]])
The power analogue of UnitHealthMissing: returns UnitPowerMax - UnitPower for
the given type in one native call. Reuses the Power module's ResolveArgs /
PowerDivisor, so it inherits the primary-power fallback (powerType omitted)
and per-type display divisor. The unmodified third arg (truthy) skips the
divisor for the raw internal deficit, matching retail's UnitPower.

Subtracts the two display values (each integer-divided) rather than dividing
the raw difference, so it equals UnitPowerMax(...) - UnitPower(...) exactly --
the rage case would otherwise be off by one (100/5 -> 95, not (1000-55)/10 =
94). Returns 0 for invalid units and for types the unit has no pool for
(e.g. energy on a mana user). Verified in-game: mana 6685-5929 = 756.

Docs + README updated.
2026-07-25 00:04:16 -05:00
Brues 1c86d0dead unit: add UnitHealthMissing(unit)
Returns the health deficit -- the equivalent of
UnitHealthMax(unit) - UnitHealth(unit) -- in one native call, so healing
addons don't call both engine getters and subtract in Lua every frame.
One token resolve + two descriptor field reads (HEALTH +0x40, MAXHEALTH
+0x58); no display divisor (health is stored at face value), clamped at 0.

Tracks the engine's own UnitHealth/UnitHealthMax exactly in both forms:
real values (player 3600-1 = 3599) and vanilla's percentage form for
non-grouped units (target 100-85 = 15). Returns 0 for a valid-but-absent
unit, matching UnitHealth's convention.
2026-07-24 23:58:46 -05:00
Brues cedbd04553 cast: mirror the server's +500ms ranged aim time in the cast-time prediction
FUN_006e3340 (the spell tooltip's cast-time helper, which our zero-latency
CastStartSet_h stamp reuses) omits the flat +500ms "aim time" the server adds
to every ranged, non-autorepeat spell -- SpellEntry::GetCastTime does
`castTime += 500` for SPELL_ATTR_RANGED. So the client prediction ran ~500ms
short of the server on Volley / Aimed Shot / Multi-Shot (Volley's real cast is
3000ms base + 500 = 3500ms), and the confirming SMSG_SPELL_START then snapped
the bar out ~1 RTT later. This was the dominant cause of the Volley overhang,
present with or without Quick Shots.

CastTimeMs now adds the +500 for SPELL_ATTR_RANGED spells (excluding Auto Shot
/ Shoot via the AttributesEx2 autorepeat bit, matching the server), so the
prediction is right from frame 1. Only applied when the spell already has a
cast time; instant ranged abilities the server gives a 500ms aim time still
surface through the packet path. The HandleSpellStart reconcile stays as the
backstop for any residual client/server divergence.
2026-07-24 23:21:26 -05:00
Brues 3b1bba2131 cast: reconcile local-player cast time to the server on the confirming packet
The client-side FUN_CAST_START_SET hook stamps g_cast with a zero-latency
prediction from FUN_006e3340, which folds in the caster's cast-speed /
ranged-haste multiplier (descriptor +0x22c). That is correct for spells the
server also hastes (Aimed Shot / Multi-Shot), but wrong for ones it does
not. Volley (1510 / 14294 / 14295) is the case: it is NOT channeled in the
client DBC (AttrEx has no 0x4/0x40) so it takes the regular-cast path, and
its Attributes & 0x30 == 0 so FUN_006e3340 applies the +0x22c multiplier --
yet Volley gains no benefit from Quick Shots server-side. The predicted bar
therefore ended ~before the real cast finished ("still casting after the bar
ends").

SMSG_SPELL_START carries the server's authoritative cast time, but for a
cast the client already stamped we were deduping the packet away entirely to
avoid restarting the bar. Instead, keep startMs (no visual restart) and snap
endMs to the server's castTime (preserving any accumulated pushback). Aimed
Shot / Multi-Shot are unaffected -- the server hastes them too, so client and
server agree and the reconcile is a no-op.

Closes https://github.com/brues-code/pfUI/issues/28
2026-07-24 22:40:27 -05:00
Brues 786428c9fb focus/who: reset per-character state on logout
Both modules held per-character state in process globals that the DLL
never cleared on a logout -> character-select -> login (the DLL is not
reloaded across that transition), the same bug class as the equipment-set
leak.

Focus: g_focusGUID / g_focusTargetGUID carried a dead GUID from the prior
character into the next. The WorldTick despawn probe self-healed it within
a tick, but fired a spurious PLAYER_FOCUS_CHANGED and could not cover a
GUID collision. Clear it from a GlueModuleAutoRegister, which fires on the
world->glue return a logout triggers but NOT on /reload -- so focus still
survives /reload (retail behavior) and drops only on logout.

Who: a /who whose SMSG_WHO response never crossed a logout left g_inFlight
stuck (IsWhoQueryPending wrongly true for the next character) and the
VAR_WHO_TO_UI_FLAG override forced on (routing the next character's /who to
the WhoList). Reset in-flight state and restore the flag on every in-game
Lua (re)build via ModuleAutoRegister, which fires per login and per /reload
before any addon runs -- previously only ResetIfStuck's lazy 10s-on-next-
send backstop covered this.
2026-07-24 22:29:05 -05:00
Brues f5ecdc69c5 equipmentset: reload sets per character, not once per process
EnsureLoaded latched on a process-global g_loaded flag that was never
reset. The injected DLL survives a logout -> character-select -> login
(no process restart), so opening the Equipment Manager on character A
cached A's file path and sets permanently; character B then read, and
saved into, A's data. Only a full game restart cleared the globals, and
never opening the manager on A left B correct -- matching the report.

Re-resolve the per-character path on every EnsureLoaded call and reload
when it changes, so the cache self-heals across any character/account
switch. Also clear the per-session ignored-slot state on switch. Every
mutation Persist()s immediately, so dropping the old in-memory sets
never loses unsaved work.

Fixes https://github.com/brues-code/pfUI/issues/29
2026-07-24 22:13:55 -05:00
Brues 7531223b9a nameplate: assign stable slots instead of a compacting list
Switch nameplateN indexing from a compacting vector (append on ADDED,
erase + shift-down on REMOVED) to retail-exact slot assignment: a plate
keeps its slot for its whole lifetime, a removal frees the slot, and the
next new plate reuses the lowest free one. Survivors are never renumbered
when another plate is removed - matching modern WoW, where a middle plate
vanishing leaves the others' tokens unchanged and its slot goes vacant
until reused.

g_orderedGUIDs -> g_slots (sparse; free slots hold GUID 0, trailing
empties trimmed). GetGUIDByIndex returns 0 for a free or out-of-range
slot; new GetSlotCount exposes the high-water mark. The reverse token walk
in Unit::Identity now scans 1..GetSlotCount() and skips gaps instead of
stopping at the first 0. The per-plate observer lifecycle is unchanged
(keyed on GUID: register on ADD, unregister on REMOVE).

Verified in-game: 3 plates in slots 1/2/3, removing the middle one leaves
slots 1 and 3 occupied and 2 vacant - no renumber.
2026-07-24 18:45:42 -05:00
Brues cb4e428592 unit: fire per-token unit events for focustarget
Extend the focus observer to the focus unit's current target, so
UNIT_HEALTH / UNIT_AURA / ... fire with arg1 == "focustarget" like a
native token. Unlike focus (a sticky GUID), the focustarget GUID is
volatile - it changes whenever the focus re-targets, and vanilla fires no
event for a unit changing its target (UNIT_FIELD_TARGET, field 10, has no
event-table slot). So the WorldTick watcher re-reads the focus's target
each tick and re-points a second TokenObserver at it only when that
identity changes; the focustarget events themselves still fire
event-driven through the observer (only the re-pointing is tick-gated,
<=1 tick lag). Set() drops the stale focustarget observer immediately on
a focus change/clear.

Verified in-game: focustarget events fire and re-point when the focus
switches targets.
2026-07-24 18:13:08 -05:00
Brues e7e2fe05dc unit: guard TokenObserver against a null observer-registry root
Register/Unregister funnel through FUN_00464890, which dereferences the
client/player-rooted observer registry at [VAR_LOCAL_PLAYER_PTR] (+0x24).
That root is null out of world (login, loading screens, logout teardown),
where the helper null-derefs -> ERROR #132 (ACCESS_VIOLATION reading 0x24).

The nameplate teardown on world-exit fires NAME_PLATE_UNIT_REMOVED for
every plate, each calling Unregister while the root was already null ->
crash. Player::Equipment never hit this because it only registers from the
enter-world hook; focus/nameplate register from Lua / WorldTick, which can
run mid-transition.

Gate both Register and Unregister on RegistryReady() ([VAR_LOCAL_PLAYER_PTR]
!= null). Out of world there are no live objects or observer nodes to touch
anyway - nodes die with their objects on teardown, and in-world callers
re-register / clean up on the next tick.
2026-07-24 18:10:42 -05:00
Brues d7078827fd unit: fire per-token unit events for focus and nameplateN
Make the synthetic focus / nameplateN unit tokens first-class sources of
the standard per-token unit events (UNIT_HEALTH, UNIT_MANA, UNIT_AURA,
UNIT_LEVEL, ...): arg1 == "focus" / "nameplateN" now fires just like
"party1", so focus and nameplate frames can update event-driven instead
of polling.

Vanilla fires these events only for units it "watches"
(target/party/raid/pet/mouseover) - FUN_0060c520 recomputes a unit's
token-category mask and, on 0->nonzero, FUN_0051bbb0 registers bank-UNIT
descriptor-field observers whose callback broadcasts the field's event to
the guid's tokens (field index == event id; health +0x40 = event 16 =
UNIT_HEALTH). Focus and nameplates are not watch categories, so they
never fired.

New Unit::TokenObserver replicates that watch registration (FUN_0051bbb0's
field set + per-index sizes) with a caller-supplied callback, via the
engine's own observer registrar - the same API Player::Equipment uses. No
hooks: zero MinHook trampolines, hence no collision risk (the reason this
beats co-hooking the broadcast). The registrar appends a fresh node per
(guid, field), so our observers coexist with the engine's - a unit that is
target AND focus AND a nameplate fires all three tokens. Node lifetime is
engine-managed (nodes die with the object; unregister no-ops post-despawn).

- Focus: register/unregister in Unit::Focus::Set.
- Nameplates: register on NAME_PLATE_UNIT_ADDED, unregister on _REMOVED
  and (all) in PrepareForReload, since objects survive /reload; the
  callback resolves the changed guid to its current plate index at fire
  time.

Offsets cross-confirmed against nampower's offsets.hpp (SendUnitSignal
0x515e50, StringParamFormat "%s" 0x82e280, SignalEventParam 0x703f50,
UNIT_HEALTH 16); the GUID->token reverse map nampower left as a TODO is
FUN_00515c50.
2026-07-24 17:26:23 -05:00
Brues c571c1a852 equipmentset: build the tooltip natively, not via Lua method dispatch
SetEquipmentSet built its lines by calling back through self:SetText /
self:AddLine / self:Show - a PushValue/GetTable/Insert/Call dance per
line. Replace that with the engine's own per-tooltip clear plus raw
add-line (with a small r,g,b to packed-color helper) - the same native
path SetTotem and SetHyperlinkCompareItem use. Localization is unchanged:
PushLocalizedFormatInt for the count lines, Lua string.format for the
ITEM_MISSING %s template.

Shows itself via Script_Show at the end, matching retail (the inner
builder tail-calls the show) and PaperDollFrame.lua, which sets the
anchor then calls SetEquipmentSet with no trailing :Show().
2026-07-23 22:38:12 -05:00
Brues a7b205cd56 totem: add GameTooltip:SetTotem(slot)
Backport of the TBC (2.4.0) tooltip method. Fills the tooltip with the
slot's active totem as two native lines - name (NORMAL_FONT_COLOR) and
time remaining (white) - mirroring retail's Script_GameTooltip_SetTotem
(verified against the 3.3.5 binary; the two line colors are read verbatim
from its color data). Built with the engine's own per-tooltip clear plus
raw add-line - no Lua method dispatch - and shows itself via Script_Show,
matching retail's TotemFrame.xml OnEnter which calls SetTotem with no
trailing :Show().

The time line mirrors SecondsToTimeAbbrev (raw seconds under a minute,
minutes rounded up above) and localizes through the FrameXML GlobalStrings
SPELL_TIME_REMAINING_SEC/_MIN with English fallbacks; slot data is the
existing GetTotemInfo tracker.
2026-07-23 22:38:00 -05:00
Brues a3f224876f event: add a correctly-timed on-demand table-grow valve
Re-introduces the event-table grow valve (removed in 538beda as
mis-timed) at the correct point. Event::Custom::Grow runs from
RetryClaims -- the frame:RegisterEvent hook -- on the first in-game
registration, which is AFTER RebuildEventTable (and SuperWoW/nampower's
clamp-to-700 that co-hooks it) but BEFORE any chain is populated. So
every chain is still an empty self-sentinel and the buffer can be moved
safely. A one-shot latch (reset per reload) runs it at most once per
build, and a hard "abort if any chain is live" scan backs the timing.

Same realloc logic the old EnsureCapacity used -- SMemAlloc a new
buffer, copy name pointers, re-init empty-sentinel anchors, swap the
{cap,count,base} globals, SMemFree the old -- bypassing SetEventCount's
destructive =700 clamp. Consumers read base fresh; the engine's reload
teardown frees our buffer + names as its own.

Dormant in practice (~200 low gaps vs ~46 reservations). Validated via a
new _classicapi_ForceEventGrow() diagnostic that forces a grow on the
next /reload: table grew 700->716, all events (ours, engine,
SuperWoW/nampower, VMT) survived with names intact, dispatch worked on
the grown table, and a following /reload cleanly freed the buffer.
2026-07-23 21:27:29 -05:00
Brues a40236eb84 spell: add GameTooltip:AddSpellByID(spellID)
Append counterpart to SetSpellByID -- adds a spell's tooltip (name, cast
time, cost, range, description) to the current tooltip WITHOUT clearing
existing lines, so callers can compose tooltips like
AddLine(header) + AddSpellByID(id).

BuildSpellTooltip's last arg gates the clear: SetSpellByID passes 0
(clears via FUN_00530050 first); AddSpellByID passes 1 (skips the clear
and appends). That append path is the engine's talent "next rank"
preview, so it emits a "Next rank:" header instead of the spell name and
doesn't stash the displayed spellID -- we overwrite that header line (at
the pre-build numLines index) with the real name from Spell.dbc and let
GetSpell keep reflecting the base tooltip. Verified in-game.
2026-07-23 21:12:06 -05:00
Brues 538beda499 event: remove the mis-timed EnsureCapacity table-grow valve
EnsureCapacity was meant to grow the event table when the engine's ~200
low NULL gaps ran short of our ~46 reservations, by reallocating the
entry buffer via Storm and swapping the base/count globals. But it was
called from LoadScriptFunctions_h, which runs BEFORE RebuildEventTable
inside FUN_0048FBF0 -- so it always operated on the previous cycle's
still-populated table, tripped its own "abort if any chain is live"
guard, and never actually grew anything. Mis-timed dead code doing risky
buffer surgery on the shared event table.

Claim-NULL into the engine's low gaps is the sole mechanism now; it
always suffices, and TryClaim logs once if it somehow doesn't. Drop the
function, its Storm alloc/free helpers, and the now-inaccurate offset
comments.
2026-07-23 21:06:36 -05:00
Brues af01a4b408 event: claim NULL slots low-first + on-demand table expansion
Reworks custom-event slot reservation to be coordination-free and
durable without depending on DLL load/hook order.

- Claim NULL-name slots walking LOW->high instead of from the tail.
  SuperWoW/nampower own the high range (fixed IDs 549..655) and
  destructively clamp the table to 700, and VanillaMinimapTracking also
  walks the tail, so the top is contested; the engine's low gaps (~200,
  incl. a ~64-slot hole at 42..106) are stable and uncontested. Verified
  in-game: our events now land at 1..64, VMT alone at 699 -- clean
  separation, independent of the clamp.

- Add Event::Custom::EnsureCapacity: on-demand grow run once from
  LoadScriptFunctions_h (after module registration so all reservations
  incl. lazy TTS are counted, before any frame registers). If NULL slots
  < reserved, it reallocates the entry buffer via Storm and swaps the
  {cap,count,base} globals directly, bypassing SetEventCount's clamp --
  so it's load/hook-order independent. Defensive: reads fresh, verifies
  all chains empty before moving the buffer (aborts otherwise), frees
  only the old buffer (names are shared), publishes base before count.
  Dormant in the common case (low gaps dwarf our count).

- Drops the SetEventCount co-hook (Capacity.cpp) entirely -- nothing new
  on a SuperWoW/nampower-contended function. Keeps the one-shot
  table-full log as the backstop.

Adds Storm SMemAlloc/SMemFree + VAR_EVENT_TABLE_CAP offsets.
2026-07-23 18:13:41 -05:00
Brues 33c8c3e37c Add C_TaxiMap.GetTaxiRoute(slotIndex)
Exposes the client's computed flight route to a taxi-map slot as an array
of TaxiNodes.dbc IDs in flight order { currentNodeID, ..., destNodeID } --
the exact chain TakeTaxiNode sends the server.

Mirrors the TakeTaxiNode worker (FUN_004dc8d0): a single direct hop when a
TaxiPath edge exists (via the FUN_004dbad0 adjacency matrix), otherwise the
client's precomputed multi-hop route array (slot +0x14, length +0x18). This
lets addons identify every hop of a per-character route without matching
flight-map coordinates back to nodes.

Offsets added: per-slot route array/count/flag, the current-node record
pointer, and the node adjacency matrix. Documented in docs/API.md + README.
2026-07-23 06:00:55 -05:00
Brues c5d5415afe taxi: add C_TaxiMap.GetTaxiPaths + GetTaxiPathWaypoints
Expose the taxi routing graph and per-path geometry that vanilla keeps in
TaxiPath.dbc / TaxiPathNode.dbc but never surfaces to Lua (and addons can't
read DBCs at all).

- GetTaxiPaths() -> array of { pathID, fromNodeID, toNodeID, cost } (every
  directed flight-path edge; cost is the routing weight in copper).
- GetTaxiPathWaypoints(pathID) -> array of { x, y, z, mapID } world
  coordinates ordered by node index; sum the 3D chords for path length
  (/ 32 yd/s ~= single-hop flight seconds).

Lets addons build the flight graph, run travel-time / routing math, or drive
a map overlay without baking the geometry in. Pure DBC readers, no flight
master needed. Offsets for TaxiPathNode.dbc + TaxiPath cost added to
Offsets.h.
2026-07-23 03:59:04 -05:00
Brues 39dcfc73de event: add GetFramesRegisteredForEvent(event)
Returns the frames currently subscribed to an event as multiple return
values (retail's vararg signature). Reads the engine's own subscriber
chain off the event-table entry (entry+0xC head; node next @+0x04,
frame @+0x08, low-bit/null end sentinel) — the exact list
frame:RegisterEvent appends to, so it stays in sync with real
registrations, including AutoReserve custom events. Each frame is
pushed via the shared UI::FrameObject wrapper so frame methods work.
A counting pass drives lua_checkstack up front against heavily
subscribed events.
2026-07-21 14:41:10 -05:00
Brues 6e5f7c3e43 tradeskilllink: gate detail wheel on overflow
The detail ScrollFrame had mouse wheel enabled unconditionally, so even
when the content fit (bar hidden, 0 range) it still consumed wheel events
— clamping to a no-op but swallowing the scroll instead of letting it pass
through. Enable the wheel from SetDetailBarShown alongside the bar, so it's
on only when there's something to scroll.
2026-07-21 14:25:43 -05:00
Brues 2e05e86362 tradeskilllink: fix detail-pane scroll range + auto-hide
The trade-skill-link viewer's reagent pane couldn't scroll to the last
row for some recipes (issue #10). Root cause, found by comparing against
Blizzard's Blizzard_TradeSkillUI: resizing a ScrollFrame's child does NOT
recompute its scroll range in 1.12 — you must call UpdateScrollChildRect()
(TradeSkillFrame_SetSelection does exactly this). We resized the child but
never refreshed the range, so the bar's travel was capped at a stale max.

- Call detailScroll:UpdateScrollChildRect() after (re)sizing the child in
  RenderDetail and ClearDetail.
- Size the child from actual laid-out geometry (bottom of the last reagent
  button relative to the child top + name-plate overhang) instead of a
  hand-tallied constant that undercounted.
- Drive scrollbar visibility from the computed overflow (SetDetailBarShown)
  rather than the engine's range-changed event: scrollBarHideable only
  auto-hides on a range CHANGE, so content that fits from the start (e.g.
  Turtle's roomy pane) never hid, and GetVerticalScrollRange isn't reliably
  readable the same tick as UpdateScrollChildRect.

Net removes the old detailNeedScroll bool-tracking, per-need mouse-wheel
toggling, and the OnShow re-assert workaround — all of which were
compensating for the missing range recompute.

Fixes #10
2026-07-21 14:21:25 -05:00
Brues bcc631c4ed tradeskilllink: fix ColorMixin clobber from Fonts.xml re-includers
Addons that re-include ..\..\FrameXML\Fonts.xml in their TOC (notably
SpecialTalentUI, LoadOnDemand — pulled in when the talent frame is first
opened) re-run its inline script, which resets NORMAL_FONT_COLOR /
HIGHLIGHT_FONT_COLOR / GRAY_FONT_COLOR to plain {r,g,b} tables and strips
the ColorMixin methods our UI relies on. That crashed the trade-skill-link
viewer's NORMAL_FONT_COLOR:GetRGB() (issue #10).

Factor the standard color-global setup into CAPI_ApplyStandardColorGlobals
(idempotent — only (re)creates a tag that's missing :GetRGB) and re-run it
after SpecialTalentUI loads via EventUtil.ContinueOnAddOnLoaded, next to the
existing pfUI compat in AddOnCompat.lua.

Also simplify the viewer frame anchor to SetPoint("CENTER").
2026-07-21 12:54:39 -05:00
Brues f9fb32186d TradeSkillLink: Play Open/Close Sound 2026-07-21 11:11:41 -05:00
Brues 60adbba7c3 pointless fallback 2026-07-21 11:11:09 -05:00
Brues c35d883af0 targeting: backport TargetDirection*/TargetNearest* + GetPlayerFacing
Adds the post-vanilla TargetScript selection functions 1.12.1 lacks:

  TargetDirectionEnemy(facing[,cone]) / TargetDirectionFriend
  TargetNearest([reverse])
  TargetNearestEnemyPlayer([reverse]) / TargetNearestFriendPlayer
  GetPlayerFacing()

All five targeting functions reuse the engine's own tab-target core: the
visible-unit enumeration, the per-mode validity predicate FUN_00493e40
(reaction/attackability/alive, modes 1=enemy/2=friend), and the selection
commit. The only new logic is a player-GUID filter for the *Player variants
and the cone geometry for the direction variants. Cycle functions keep a
frozen per-filter snapshot advanced within a ~1s window (no ping-pong).

Target-setting routes through FUN_TARGET_BY_GUID (0x00489A40), which
rejects a GUID that no longer resolves to a live unit rather than
committing it — a stale snapshot GUID would otherwise leave "target"
pointing at freed unit data and crash the next handler that reads it.

GetPlayerFacing reads the CGUnit movement-block orientation at +0x9C4
(located by probing the player object across a known 180-degree turn); it's
the facing source the direction-target functions need.

Not backported (no vanilla equivalent): TargetPriorityHighlightStart/End,
IsTargetLoose, TargetToggle.

New offsets: FUN_TARGET_CANDIDATE_VALID (0x00493E40),
FUN_SET_SELECTION_BY_GUID (0x00493540), VAR_CURRENT_SELECTION_GUID_LO/HI,
OFF_UNIT_FACING (0x9C4).
2026-07-21 00:49:05 -05:00
Brues c202c1000f chatbubbles: add C_ChatBubbles.GetAllChatBubbles
Returns the active chat-bubble Frame objects, matching modern WoW's
7.2.5 signature. Vanilla's CGChatBubbleFrames are full CSimpleFrames
the engine creates in C++ without CreateFrame (same as default
nameplates), kept on an intrusive Storm TSList at 0x00B6F038 (next
link +0x318, low-bit sentinel). Mirror the engine's own walk
(FUN_004b1060) and push each as its canonical engine wrapper, so the
returned frames are real and method-capable and their text FontString
(+0x31c) surfaces via :GetRegions().

includeForbidden is accepted and ignored (vanilla has no forbidden
frames).

Factor the ScriptRegister->rawgeti wrapper push out of nameplate/Info
into a shared UI::FrameObject::Push; PushNamePlateFrame now forwards to
it (no behavior change).
2026-07-20 23:00:03 -05:00
Brues 98b4ae14e7 totem: add GetTotemDuration(slot)
Returns the slot's active totem total duration in seconds (0 when none) —
the totem's full lifetime from cast, same value as GetTotemInfo's 4th
return, companion to GetTotemTimeLeft. Reads the tracker's durationMs.
Verified in-game (constant 45 while time-left counts down).
2026-07-20 21:27:58 -05:00
Brues 8d957eb424 totem: add TargetTotem(slot)
Target the player's totem in a slot (1-4). Looks up the totem creature's
live GUID (object-manager scan for the player-owned creature of the
slot's tracked entry) and sets the target via FUN_00489a40 — the
set-target-by-GUID path Script_TargetUnit uses after its token→GUID
resolve. No-op when the slot is empty or the totem isn't visible. Added
Offsets::FUN_TARGET_BY_GUID.
2026-07-20 21:25:34 -05:00
Brues 78618ea2cc totem: add GetTotemTimeLeft(slot)
Returns seconds remaining before the slot's totem auto-destroys (0 when
no totem is active), computed from the existing Totem::Tracker's
startMs + durationMs. Slots 1-4 = Fire/Earth/Water/Air. Verified in-game
counting down.
2026-07-20 21:11:01 -05:00
Brues 1a4583e16e totem: fire PLAYER_TOTEM_UPDATE on slot changes
Backport the TBC PLAYER_TOTEM_UPDATE(totemSlot) event (absent from
vanilla's table, so reserved as a custom event) on top of the existing
Totem::Tracker. Every slot state change marks a dirty bit — the cast
path (SpellGo), duration expiry, and the WorldTick death-scan removal —
and OnTick flushes the dirty slots with Fire(id, "%d", slot). Flushing
from the tick keeps Lua handlers out of mid-packet context. Drops fire
next frame; removals within the ~250ms scan interval. Verified in-game.
2026-07-20 21:04:15 -05:00
Brues a20e1206fb totem: add GetTotemInfo(slot) with a self-tracking totem subsystem
Vanilla 1.12 tracks nothing about totems client-side (the totem bar,
PLAYER_TOTEM_UPDATE, and GetTotemInfo are all TBC additions), so
Totem::Tracker tracks the four element slots itself — data-driven:

- Slot from the summon spell's Spell.dbc effect (87-90 =
  SUMMON_TOTEM_SLOT1..4 = Fire/Earth/Water/Air; slot = effect - 86).
- Creature entry from the effect's EffectMiscValue (for death detection).
- Duration from SpellDuration.dbc via the engine duration helper.
- haveTotem tool item from the spell's Totem[0] field (0xA0) — the totem
  the spell requires (a tool, not a consumed reagent) — resolved from the
  player's spellbook on SPELLS_CHANGED, not hardcoded.

Casts are observed through Aura::Source's existing SMSG_SPELL_GO hook (a
one-line notify before its aura gate, since a summon applies no aura) —
no new hook, respects one-hook-per-target. Tool items resolve on
SPELLS_CHANGED (a no-arg event via the shared Event::SignalHook), when
the spellbook is populated. A slot clears when its duration elapses OR
when the totem creature disappears early (killed / Totemic Recall /
destroyed), detected by a throttled WorldTick object-manager scan for the
player-owned creature of that entry (seen-then-gone latch; CreatedBy ==
player so another shaman's same-entry totem can't mask yours).

GetTotemInfo returns haveTotem, totemName, startTime, duration, icon,
modRate=1, spellID. Turtle-custom totems are tracked automatically.
Also exposes Item::Count::InInventory (equipped + bags) as a shared
helper for the tool-presence check. Verified in-game: Earthbind lands in
slot 2, haveTotem reflects the Earth Totem tool, and Totemic Recall
clears the slot early.
2026-07-20 20:53:01 -05:00
Brues 9f67cade87 unit: add UnitOwnerGUID(unit)
Returns the GUID string of a unit's owner — the summoner of a
pet/guardian/totem or the charmer of a charmed unit — or nil for
players, world creatures, and unresolved units. Reuses Unit::Pet's
existing OwnerGuid helper (CharmedBy, else CreatedBy in m_objectFields),
the same field the pet predicates share. Verified in-game:
UnitOwnerGUID('target') == UnitGUID('targetowner').
2026-07-20 18:35:47 -05:00
Brues 8629e13e1f gameobject: add ClosestGameObjectPosition(gameObjectID)
Returns xPos, yPos, distance for the nearest visible game object of a
given GO-template ID, or nothing when none is in range — the game-object
counterpart of ClosestUnitPosition.

Extract the shared visible-object-manager scan into
Object::ClosestByEntry (object/ClosestByEntry.{h,cpp}): enumerate visible
objects, match GUID type + template entry (bits 24-47), return the
closest one's world position + center distance. Both ClosestUnitPosition
(TYPEMASK_UNIT / Creature) and ClosestGameObjectPosition
(TYPEMASK_GAMEOBJECT / GameObject) are now thin wrappers over it;
ClosestUnitPosition behavior is unchanged.

Same retail-vs-vanilla caveat as the unit version: returns the nearest
CURRENTLY-VISIBLE object rather than reading retail's static starting-
zone database (which vanilla lacks). Documented in docs/API.md.
2026-07-20 18:26:46 -05:00
Brues ca9bfbab5e unit: add ClosestUnitPosition(creatureID)
Returns xPos, yPos, distance for the nearest creature of a given NPC
entry ID, or nothing when none is in range. Walks the visible-object
manager (Loot::Nearby pattern), matches creatures whose GUID encodes
the entry (bits 24-47, per guid/CreatureInfo), and returns the closest
one's world position + center distance in yards.

Semantic note: retail's ClosestUnitPosition reads a static client spawn
database (starting-zone mobs only); vanilla 1.12 has none, so this
returns the nearest CURRENTLY-VISIBLE creature of that entry (anywhere)
— more general than retail, but can't point at un-synced spawns.
Documented in docs/API.md.
2026-07-20 18:18:52 -05:00
Brues 8fdc767cc7 item: add Enum.ItemQuality
Poor=0 through Artifact=6, matching the numeric quality GetItemInfo /
C_Item.GetItemQuality report. Heirloom (7) and WoWToken (8) are omitted
— no such items exist on 1.12. Registered in src/item/TypeInfo.cpp
alongside Enum.ItemClass / Enum.InventoryType. Docs + README updated.
2026-07-20 17:36:47 -05:00
Brues aa31c25be5 item: add GetItemClassInfo, GetItemSubClassInfo global, Enum.ItemClass
- GetItemClassInfo(classID) -> localized ItemClass.dbc name, registered
  as both a global and C_Item.GetItemClassInfo. Obsolete class slots
  return the client's literal "...(OBSOLETE)" string.
- GetItemSubClassInfo(classID, subClassID) exposed as a global (the
  C_Item form already existed); returns name + the armor-material bool.
- Enum.ItemClass: 0..15 with modern key names on the obsolete slots
  (Gem/ItemEnhancement/CurrencyTokenObsolete/PermanentObsolete), plus
  post-vanilla 16..19 for parity, matching the Enum.InventoryType
  precedent in the same module.

All added to src/item/TypeInfo.cpp alongside the existing subclass /
inventory-type surface. Docs + README updated.
2026-07-20 17:27:28 -05:00
Brues 29aafe88f8 turtle: move combo-finisher & armor-healing custom data out of stock
Two pieces of hardcoded Turtle-specific data were embedded in stock
modules; relocate them to src/turtle/ behind extension points so the
stock code carries only generic mechanism.

- aura/ComboDuration: add an AutoDanglingResolver extension point; the
  stock module keeps the dangling-row detection + scaling formula, and
  Turtle's reworked-Rip base/max values move to
  src/turtle/ComboDuration.cpp. Not gated on Turtle::Detected() — the
  dangling-row condition stays the gate (gate on the bug, not the
  realm), preserving prior behavior exactly.

- spell/BonusDamage: add a Spell::HealingAura resolver extension point;
  the stock GetSpellBonusHealing decoder keeps auras 135 (flat) and 175
  (xSpirit), and Turtle's Ironclad aura 199 (xArmor) moves to
  src/turtle/HealingAura.cpp, gated on Turtle::Detected() (an aura index
  is a generic slot that could collide on another server).

Behavior is unchanged on Turtle; both extractions are pure relocation.
2026-07-20 16:49:45 -05:00
Brues 461f15a4a6 item: derive bag family from (class, subclass) for bags with empty field
Turtle's item data leaves m_bagFamily at 0 on the bags/quivers
themselves — the four profession bags (Soul/Herb/Enchanting/
Engineering, Container class) and quivers/ammo pouches (Quiver
class) all read 0, so C_Item.GetItemFamily and
C_Container.GetContainerNumFreeSlots reported 0 for them even
though the tooltip clearly shows "Soul Bag" etc. Recover the family
from the item's class + subclass instead (keyed on class because
subclass numbers repeat: Container sub 2 = Herb Bag, Quiver sub 2 =
Quiver). Loot items keep carrying the field directly.

Factored the derivation into a shared Item::BagFamily helper
(BitmaskForRecord) used by both GetItemFamily and the free-slot
functions, so a bag and the items it holds always agree on the
family bit. Side effect: CalculateTotalNumberOfFreeBagSlots now
correctly excludes specialty bags from the general free-slot total.

Non-stock data lives in its own module: src/turtle/ with a
TURTLE_WOW_VERSION-based Turtle::Detected() gate (no realm sniffing)
and the custom Meat/Fish/Leather/Mining families (ItemBagFamily.dbc
rows 10-13) plugged into Item::BagFamily via an AutoResolver
extension point, so the stock table stays pure vanilla.
2026-07-20 16:38:42 -05:00
Brues 6245d72694 unit: make UnitInRange reach-aware
Compare center distance against `40 + playerReach + targetReach`
instead of a flat 40, reading each unit's bounding radius from
`[m_objectFields + 0x1F0]` — the same per-unit size factor the
engine's spell-range helper (FUN_006e3480) and the interact/loot
range checks add. A 40-yard heal reaches a target whose center is
slightly past 40 yards but whose body is within reach; the old flat
cap reported those out-of-range. UnitDistanceSquared stays raw
center-to-center, matching retail.
2026-07-20 14:52:35 -05:00
Brues bd14228ad7 event: add PLAYER_ENTERING_WORLD payload (isInitialLogin, isReloadingUi)
Backport the 8.0.1 PEW payload onto vanilla's arg-less PLAYER_ENTERING_WORLD.

PEW is a no-arg event, so it broadcasts through FUN_FIRE_EVENT_NO_ARGS
(__fastcall(int), the no-arg dispatcher) — NOT the variadic FUN_FIRE_EVENT.
So we intercept that dispatcher and, for the PEW event id, suppress the
no-arg fire and re-broadcast through the variadic dispatcher with the two
booleans (true->1 / false->nil, the %d/%s+nullptr trick). Both fire paths
walk the same frame chain, so PEW-registered frames get the args once.

Flags need no PEW-specific engine hook — they fall out of signals we already
produce: GlueModuleAutoRegister (glue-screen boot) latches "next enter-world
is a fresh login"; ModuleAutoRegister (in-game Lua re-init) marks
login-or-reload; a zone/instance transition rebuilds neither, so both false.

New shared Event::SignalHook: FUN_FIRE_EVENT_NO_ARGS was already hooked by
loot/Scan (silent-scan suppression), and MinHook allows one hook per target,
so — like Tick::WorldTick for the per-frame tick — this module now owns the
single hook and fans out to interceptors. loot/Scan refactored from owning
the hook to being a subscriber (behavior identical). Event::EnteringWorld is
a second subscriber.

Verified in-game: fresh login, /reload, and instance transition each report
the correct (isInitialLogin, isReloadingUi).
2026-07-20 03:22:07 -05:00
Brues 5b15172894 coroutine: guard AuxResume xmoves with lua_checkstack
lua_xmove moves values via api_incr_top, which asserts room on the
destination rather than growing it. AuxResume xmoved an unbounded count both
ways without a guard: nres (a coroutine body can yield/return arbitrarily
many values) onto L, and nargs (a caller can resume(co, unpack(bigTable)))
onto co. Moving more than the ~20-slot C-call guarantee corrupted the stack
-- the same latent bug strsplit had, now fixable thanks to the bound
Game::Lua::CheckStack.

Guard both xmoves; on overflow (>2048) push an error and return the error
path (false,msg for resume; re-raise for wrap). Verified in-game: a coroutine
yielding 100 values via unpack now returns all 100 cleanly (previously stack
corruption), and normal small returns still work.
2026-07-20 00:48:28 -05:00
Brues b5470e8d3d lua: add string.reverse/strrev, math.modf, math.huge
Close the remaining 5.0->5.1 string/math stdlib gaps:

- string.reverse(s) / strrev(s) — byte-wise reverse; 5.1 added it, 5.0's
  strlib lacks it. Registered on the string table and as the strrev global.
- math.modf(x) -> (integral, fractional) — integral truncated toward zero,
  fractional keeps x's sign (via C modf). Genuinely new in 5.1 (unlike fmod,
  which 1.12 has as math.mod).
- math.huge — positive-infinity constant (HUGE_VAL) set directly on the math
  table (it's a value, not a function).

All on both Lua states. Verified in-game: reverse, modf on +/- inputs, and
math.huge as a true infinity sentinel.
2026-07-20 00:31:20 -05:00
Brues c601967fdb lua: add strjoin / strtrim / strreplace
Round out the WoW string globals alongside strsplit. These are FrameXML Lua
in 3.3.5 (no C original — no name strings in the binary), so they're
implemented from standard semantics rather than disassembled:

- strjoin(delimiter, ...) — inverse of strsplit; concatenate varargs with the
  delimiter. nil/non-coercible pieces become "" so delimiters stay aligned.
- strtrim(str [, chars]) — trim a literal character set (default whitespace)
  from both ends via a two-ended scan; no allocation, embedded NULs preserved.
- strreplace(str, find, replace) -> (result, count) — plain-text (non-pattern)
  substring replace; empty find returns str unchanged. Not a stock WoW global
  (that build's compat script added it); documented as a ClassicAPI
  convenience complementing gsub.

Registered as globals on both Lua states. Verified in-game: join, empty
delimiter, trim (default + custom set), plain replace with count, and a
strsplit->strjoin round-trip.
2026-07-20 00:21:59 -05:00
Brues 5c063ade73 lua: add strsplit; bind lua_checkstack
Backport the WoW global strsplit(sep, str [, pieces]) to 1.12, ported from
3.3.5's strsplit (FUN_00816a60): split str on any character in sep, returning
the pieces as multiple values; pieces>0 caps the count with the unsplit
remainder as the final piece (0/omitted = unlimited, 1 = whole string).
Consecutive/trailing delimiters yield empty pieces, matching retail.

Two deliberate changes from the 3.3.5 source:
- Skip its lua_settop(L, 0). Popping the args un-roots the source string, so a
  GC step during a pushlstring could free the buffer we're still scanning.
  Leaving the args on the stack keeps the source GC-rooted; Lua returns the
  top N (the pieces) regardless of the args below.
- Bind lua_checkstack (0x006F2F30, __fastcall(L, size) -> int, found via its
  cmp ...,0x800 LUA_MAXCSTACK test) as Game::Lua::CheckStack and guard every
  push with it, erroring "strsplit(): Stack overflow" like 3.3.5 on genuine
  exhaustion (strsplit pushes an unbounded number of results). Reusable for
  any future multi-return C function.

Registered on both Lua states. Verified in-game across basic split, pieces
cap, multi-char separator set, empty middle piece, and empty-separator cases.
2026-07-20 00:05:24 -05:00
Brues 2bd33288c9 event: raise custom-event reservation cap (32 -> 64)
AutoReserve silently drops any reservation past MAX_RESERVED, and the
codebase crossed 32 file-scope reservations, so a shifting subset of custom
events never claimed a table slot -> Lookup returned -1 and Fire no-op'd.
Adding the three AUCTION_MULTISELL_* events moved the overflow onto
ITEM_DATA_LOAD_RESULT / GET_ITEM_INFO_RECEIVED, which stopped firing.

Raise the cap to 64 (well above the ~45 total incl. the lazy TTS events) and
add a comment warning to keep it above the AutoReserve count. Verified via
_classicapi_DumpAllEvents: all custom events now claim slots, item events
included.
2026-07-19 18:29:20 -05:00
Brues bf7e476c10 quest: add C_QuestLog.GetHeaderIndexForQuest
Returns the 1-based log index of the collapsible header (zone / category)
a questID sits under, or nil if the quest isn't in the log. The log is laid
out header-then-its-quests, so it locates the quest's entry and walks back to
the nearest preceding header row (+8 non-NULL). Pairs with GetQuestLogTitle
to fetch the category name.

Verified in-game: quest 60142 -> header index 1 -> GetQuestLogTitle "Dun
Morogh" with isHeader true.
2026-07-19 17:34:52 -05:00
Brues 8f3ed96d51 quest: add C_QuestLog.GetLogIndexForQuestID
The inverse of GetQuestIDForLogIndex: returns the 1-based quest-log index of
a questID, or nil if the quest isn't in the log. Walks VAR_QUEST_LOG_ENTRIES,
skips header rows via the +8 header-pointer gate, and returns the first
matching entry's index. The index spans the full entry list (headers
included), so it feeds straight into GetQuestLogTitle / the other index-based
quest-log accessors.

Verified in-game: index->ID->index and ID->index->ID both round-trip.
2026-07-19 17:30:46 -05:00
Brues e1ffaab6b4 item: add native C_Item.GetItemInfo (full modern tuple)
Implement C_Item.GetItemInfo(itemInfo) in C, returning the full modern
tuple instead of the Lua alias to the vanilla global (which only returned
1.12's short set). Sourced entirely from the cached ItemStats_C record plus
the class/subclass/inventory-type DBC name lookups item/Info.cpp already
does for GetItemInfoInstant — no new duplication.

Returns 18 values: itemName, itemLink, itemQuality, itemLevel, itemMinLevel,
itemType, itemSubType, itemStackCount, itemEquipLoc, itemTexture, sellPrice,
classID, subclassID, bindType, expansionID (0), setID (nil if none),
isCraftingReagent (false; no such flag in 1.12), itemDescription. itemTexture
is the icon path (no fileID system in 1.12). On a cache miss it warms the
cache and returns nil, landing on the GET_ITEM_INFO_RECEIVED retry — same
async contract as the stock global.

Drops the `C_Item.GetItemInfo = GetItemInfo` shim from ItemUtil.lua so the
native implementation isn't clobbered at addon load.

Verified in-game against Rancher's Trousers (nil setID) and Blackened Defias
Boots (setID 161) — all 18 fields correct.
2026-07-19 16:52:53 -05:00
Brues c13b1c8eb7 item: fix C_NewItems flagging unequipped / bank-pulled items as new
The new-item diff only tracked bag contents, so an item moving INTO a bag
from anywhere out of scope looked identical to a fresh loot. Unequipping a
piece of gear (equipment -> bag) flagged it new; same for pulling an item
out of the bank.

Widen the "already owned" baseline to the player's whole inventory — bags,
worn equipment (paperdoll + equipped bags), and the bank (main + bank bags)
— while still only flagging bag items. An item whose GUID was already owned
somewhere is a move, not an acquisition, so it isn't flagged. Bank GUIDs are
read straight from the invMgr array (the gate-bypass C_Item.GetItemCount
already uses; the array is server-populated at login), so no bank window is
needed. Cap raised 256 -> 512 to hold bags + equipment + a full bank.
2026-07-19 01:43:09 -05:00
Brues bedc5d6740 Fix OnEvent handler example to use vanilla globals 2026-07-18 15:44:40 -05:00
Brues bdef428ff7 Update README.md 2026-07-18 15:42:32 -05:00
Brues 25b1a1a95c auctionhouse: add C_AuctionHouse.PostItem (multi-stack auto-split)
Post numStacks auctions of `quantity` each from one source stack in a
single call (retail multi-sell style). Vanilla's CMSG_AUCTION_SELL_ITEM
(opcode 0x256, verified from Script_StartAuction 0x004CE770) has no count
field like WotLK's, so the server posts the whole item object a GUID points
at; posting a partial quantity requires the client to split it into a real
bag item first. Both the split and the post are server-authoritative and
async, so this runs a WorldTick state machine:

  Idle -> (source > quantity) split `quantity` into a reused free general
          bag slot via Item::Swap::MoveCount (CMSG_SPLIT_ITEM)
       -> SplitWait until the stack materializes
       -> point the sell-slot globals at it and call the engine's own
          Script_StartAuction (reusing all its per-stack validation)
       -> PostWait until the server pulls it from the slot -> next stack.
  The final exact stack posts the source object directly, no split.

At most one reused free slot is needed (each post frees it for the next
split); a whole-stack post needs none. Per-step ~10s timeout aborts
gracefully. Progress via retail event names AUCTION_MULTISELL_START /
_UPDATE(done,total) / _FAILURE. Immediate rejections return (false, reason).

New offsets: FUN_SCRIPT_START_AUCTION, VAR_AUCTION_AUCTIONEER_GUID_LO/HI,
VAR_AUCTION_DURATION_TABLE ({120,480,1440} minutes = 2h/8h/24h).

v1 limits: single source stack (no cross-bag aggregation), general-purpose
bags only for the temp split, one job at a time.
2026-07-18 14:05:31 -05:00
Brues 5e23332880 lua: add math.fmod
Backport math.fmod to 1.12's Lua 5.0. Pure 5.0->5.1 rename: 5.0 ships the
identical C function as math.mod (mathlib entry, FUN 0x007FAFD0 —
checknumber(1/2) -> fmod -> pushnumber); 5.1 renamed the Lua-facing entry
to math.fmod. Registered as a direct alias of that function pointer on both
the in-game and glue states (BaseLib::MathLib), no wrapper. math.mod stays
available, so code written for either name works.
2026-07-18 05:05:39 -05:00
Brues eb36b3e09e lua: add string.match / string.gmatch
Backport the Lua 5.1 pattern helpers to 1.12's Lua 5.0 (BaseLib::StringLib),
both reusing pattern machinery 5.0 already ships:

- string.match(s, pattern [, init]) calls the engine's str_find
  (0x007FC3B0) and reshapes the result — captures, or the whole match when
  the pattern has none, or nil. match and find are the same operation in
  lstrlib.c; find returns start/end, match returns the captures.
- string.gmatch is 5.0's string.gfind verbatim (renamed in 5.1), registered
  as a direct alias of the engine gfind (0x007FCFA0) — no wrapper.

Registered on both the in-game and glue Lua states. Only the
string.foo(s, ...) form works, not ("x"):foo(...): WoW's VM strips
type-metatables for non-table/userdata values (both lua_setmetatable and
luaT_gettmbyobj hard-return no-metamethod for strings), so string method
sugar can't be enabled client-side without hooking a hot VM core function.
Documented as a known limitation.

Drops the Util/String.lua polyfill (was just string.gmatch = string.gfind),
now redundant, and its .toc entry.
2026-07-18 04:47:45 -05:00
Brues 16452519a6 lua: add select(index, ...); consolidate Lua backports under one section
Backport Lua 5.1's select() to 1.12's Lua 5.0 (BaseLib::Select). Two
forms: select('#', ...) returns the vararg count; select(n, ...) returns
the args from position n on (negative n counts from the end). Ported from
luaB_select (lbaselib.c) — the numeric form pushes nothing and just
returns top-n, handing back the slice already on the stack. Registered on
both the in-game and glue Lua states (both are 5.0 and equally missing it).

Docs: gather the scattered Lua base-library restorations (select,
table.wipe, coroutine.*) into a single "Lua" section in API.md, and
collapse the separate Coroutine/Table rows in the README table into one
Lua row. No prose lost — the coroutine and table.wipe entries moved
verbatim under the new section (coroutine.* as level-4 subsections).
2026-07-18 04:21:40 -05:00
Brues 33c5f3041d container: add C_Container.GetContainerItemInfo
Backport the modern structured-table accessor. Vanilla's global
GetContainerItemInfo (Script_GetContainerItemInfo, 0x004F9670) returns
only a flat texture/itemCount/locked/quality/readable tuple; assemble the
full ContainerItemInfo table from the same descriptor + item-cache sources
plus the fields 1.12 never surfaced (hyperlink, hasNoValue, itemID,
isBound, itemName).

Live per-instance fields (stackCount, isLocked, isBound, itemID, hyperlink)
are always present; cache-derived fields (iconFileID, quality, hasLoot,
hasNoValue, itemName) are omitted/nil until the item's static data lands.
Reuses Item::StatAccum::FetchRecord and Item::Link for the cache peek and
link/name build (no new duplication); gathers all data into locals before
building the table so the engine link/name builders never interleave with
the half-built table on the Lua stack.

readable mirrors the engine branch at 0x004F97BD (static page text OR the
per-instance readable-text id at descriptor +0x9C, added as
OFF_DESCRIPTOR_READABLE_TEXT_ID). hasLoot is a best-effort static LOOTABLE
approximation (vanilla can't track post-loot emptiness client-side).
2026-07-18 04:06:11 -05:00
Brues e8a03d584b spell: add GetSpellBonusDamage / GetSpellBonusHealing
GetSpellBonusDamage(school) reads the player's per-school spell damage
directly from the CGPlayer sub-struct at [player+0xE68]+0xFD4 (POS) / +0xFF0
(NEG), net = POS-NEG. That field isn't in the broadcast descriptor (+0x110
reads zero — the vanilla server never sends it there), but the client keeps a
fully-computed copy at +0xE68 with gear/enchants/buffs/talents/sets baked in.
Same source nampower's GetSpellPower reads; verified 613 generic / 647 Fire on
a geared mage. 1-based school, matching 3.3.5's Script_GetSpellBonusDamage.

GetSpellBonusHealing() has no such field (vanilla lacks a healing-done field
entirely), so it's derived to mirror the server's SpellBaseHealingBonusDone:
  - flat healing: SPELL_AURA_MOD_HEALING_DONE (135) off gear equip-effects,
    item enchants, random suffixes, and active buffs
  - Spirit conversion: SPELL_AURA_MOD_SPELL_HEALING_OF_STAT_PERCENT (175) x
    total Spirit / 100  (Priest Spiritual Guidance)
  - Armor conversion: SPELL_AURA_MOD_SPELL_HEALING_OF_ARMOR_PERCENT (199) x
    armor / 100  (Turtle Ironclad)
Percents are read straight from Spell.dbc, so it's generic across any
aura-175/199 talent/buff including Turtle customs, and gated on SPELL_ATTR_
PASSIVE for the known-spell-bitmap walk. Spirit/Armor come from UNIT_FIELD_
STAT4 (+0x250) / UNIT_FIELD_RESISTANCES[0] (+0x254).

Item flat healing routes through the shared Item::StatAccum engine rather than
re-implementing the item spell/suffix/enchant walk: extract a reusable
ApplyEnchant() (from ApplyRandomSuffix) plus a Value() reader, and read
ITEM_MOD_SPELL_HEALING_DONE_SHORT.

The healing sum is expensive (StatAccum over 19 items + buff table + the 27k
known-spell bitmap), so it's cached and recomputed only when a player stat
input changes. New Player::StatSignal (a pull-based epoch) is bumped from the
aura hooks (player-guarded), the equipment observer, and the spell learn/
unlearn writers (0x4B25B0 / 0x4B2C50) -- so buffs, gear, and talent respecs
all invalidate instantly. A coarse world-tick recompute remains only as a
backstop against a missed signal.
2026-07-18 03:51:20 -05:00
Brues adba73a6d8 build: stamp PE version resource on the DLL
Wire a Windows VERSIONINFO resource to the existing CLASSICAPI_TAG so the
DLL's file/product version (Explorer -> Properties -> Details, and
GetFileVersionInfo) matches the CLASSIC_API_VERSION Lua global. Both derive
from the vMAJOR.MINOR.PATCH tag; an untagged dev build gets the 9999.99.99
"newer than everything" sentinel.

Enables the RC language, keeps the tag's major/minor/patch components that
were previously discarded after the integer math, and configure_file's a
version.rc into the build. The resource lands in .rsrc, untouched by the
.rdata/.pdata section merges.
2026-07-17 15:39:04 -05:00
Brues d09137cae0 player: add C_PlayerInfo Get{Name,Class,Race,Sex} / IsConnected(playerLocation)
The PlayerLocation-taking C_PlayerInfo accessors. Resolve the location to a
unit token - unit locations use their token, GUID locations via
Unit::Identity::TokenFromGUID (while the GUID has a live token) - then:

- GetName / GetSex / IsConnected tail-call the engine's Script_UnitName /
  Script_UnitSex / Script_UnitIsConnected (the GetFactionInfoByID
  forward-the-return pattern), reusing all vanilla unit logic. GetSex must go
  through the engine because the raw UNIT_FIELD_BYTES_0 gender byte is 0/1
  while the API reports 2/3. IsConnected defaults to the local player when
  called with no location.
- GetRace / GetClass read UNIT_FIELD_BYTES_0 for the numeric ids vanilla's
  UnitRace / UnitClass don't return (login-time mirror globals for "player",
  else the descriptor bytes); GetClass resolves className / classFile from
  ChrClasses.dbc like C_CreatureInfo.GetClassInfo. classID/raceID round-trip
  with C_CreatureInfo.

Only the unit and guid location kinds resolve on 1.12; the chat/who/BG/
community/voice kinds return nil. New file - player/Info.cpp is the separate
NameCache/GetPlayerInfoByGUID module. Adds the three Script_Unit* offsets
from raw_globals.txt.
2026-07-17 01:20:01 -05:00
Brues 5721fb65a7 addon: add PlayerLocation / PlayerLocationMixin
Backport the modern PlayerLocation parameter object (Blizzard_ObjectAPI) - a
lightweight descriptor wrapping one way to identify a player (GUID, unit
token, chat line, /who index, battlefield-score index, ...). Pure table
bookkeeping like the existing ItemLocation mixin, so ported addon code can
construct and inspect it unchanged.

The GUID / unit / chatLine / whoIndex / battlefieldScore kinds map to real
1.12 concepts; community / voice / Battle.net are kept as harmless data
holders so the mixin matches retail exactly. The only two methods that reach
into systems 1.12 lacks - IsValid (Club) and IsBattleNetGUID (account info) -
are guarded so they degrade gracefully instead of erroring on the missing
namespaces.
2026-07-17 01:08:43 -05:00
Brues 3afd403eae container: add C_Container.HasContainerItem
Classic Era addition (present in the 1.15.x client, absent in 5.4.8) — a
plain occupancy check for a bag slot. Occupancy sibling of
GetContainerItemID: resolves (bagIndex, slotIndex) the same way and returns
whether the CGItem is non-null. Presence only, so it reports true for a
just-looted item even before its static data caches. Returns false for
empty slots and out-of-range indices.
2026-07-17 00:37:23 -05:00
Brues e1030213d3 container: add C_Container.CalculateTotalNumberOfFreeBagSlots
Sum of free slots across the player's general-purpose bags (backpack +
equipped bags 0..4 with BagFamily 0), matching FrameXML's implementation.
Reuses the module's existing ResolveBagInfo (slot count + family) and
CountFreeSlotsInBag, so it's just their sum with the family == 0 filter.

Same sparse-BagFamily caveat as GetContainerNumFreeSlots: vanilla server
data leaves most bags' family field at 0, so specialty bags retail would
exclude get counted here - consistent with the per-bag values it sums.
2026-07-16 23:30:02 -05:00
Brues c1cf437bf8 item: route all local player resolvers through Unit::Identity
Nine modules each rolled their own ResolvePlayer / PlayerInvMgr helper on the
throwing FUN_RESOLVE_UNIT_TOKEN("player"). Delete them all and route through
the canonical non-throwing Unit::Identity::PlayerObject(), plus a new
Unit::Identity::PlayerInventoryManager() (PlayerObject + inventory-manager
offset, mirroring the existing PlayerDescriptor accessor).

Verified equivalent by disassembly: FUN_RESOLVE_UNIT_TOKEN is TokenToGUID ->
FUN_OBJECT_RESOLVE_BY_GUID, the same object-manager lookup PlayerObject()
performs, differing only in a type-mask the player object passes either way.
Callers therefore get the identical CGPlayer (and inventory manager) but
non-throwing - null pre-world instead of a raised Lua error. That also makes
item/Location's equipment resolver safe to call outside a Lua callback (e.g.
the C_NewItems WorldTick bag scan), where the token resolver could have
raised.

Migrated: item/Count, item/AverageLevel, item/Swap, item/Location,
player/CanUseItem, spell/Cancel, equipmentset/Data, equipmentset/Locations,
merchant/Frame. debug/Probe keeps its own (deliberately standalone).
2026-07-16 23:17:24 -05:00
Brues 07a91feff0 item: add C_NewItems (new-item glow tracking)
Backport the modern C_NewItems system - IsNewItem / RemoveNewItem /
ClearAll - flagging items freshly added to bags as "new", cleared once
acknowledged. Vanilla has none of the machinery (no DBC, no server data),
so it is pure client-side bookkeeping, done entirely in C++ like retail.

Newness is keyed on each item's instance GUID (read off the CGItem), so a
flag survives the item being moved between slots - impossible for a Lua
addon, which can only track by (bag, slot). Bags 0..4 only; equipment and
bank are out of scope, matching retail.

The feed is event-driven, not polled: it subscribes to Bag::UpdateDelayed
(which co-hooks the engine's three BAG_UPDATE fire sites), so a bag rescan
runs only on frames where a bag actually changed. A one-time baseline taken
~1.5s after login (re-armed on character switch) keeps items owned at login
from being flagged. BAG_NEW_ITEMS_UPDATED fires when the set changes.

Consolidations along the way:
- New Bag::UpdateDelayed::AutoSubscribe exposes the authoritative
  "a bag changed this frame" signal as a C++ subscription.
- New Item::Location::ContainerInventory factors the CGContainer -> its
  inventory (vtable +0x10) dispatch hand-rolled in item/Count,
  item/AverageLevel, and equipmentset/Locations; all now share it.
- New Item::Location::EquippedBagInventory (equipped bag -> inventory) plus
  the FUN_GET_EQUIPPED_BAG_GUID / OFF_CONTAINER_GET_INVENTORY offsets.
- NewItems resolves the player via the shared Unit::Identity::PlayerObject.
2026-07-16 22:54:45 -05:00
Brues 88ea4356a0 creature: add UnitCreatureTypeID + C_CreatureInfo type lookups
Raw CreatureType id (CreatureType.dbc row: 1=Beast … 11=Totem), the
counterpart to the family additions:

  UnitCreatureTypeID(unit)              -> id, nil if none
  C_CreatureInfo.GetCreatureTypeInfo(id) -> { id, name }
  C_CreatureInfo.GetCreatureTypeIDs()    -> ascending id array (1..11)

UnitCreatureTypeID calls the engine's own type resolver at 0x00605570
(Script_UnitCreatureType's inner helper) directly — it handles all three
engine paths (display-override, creature-cache +0x18, player-race
ChrRaces+0x24), so it matches UnitCreatureType for every unit kind
(players resolve to Humanoid). The C_ lookups read CreatureType.dbc
(records 0x00C0DE2C, count 0x00C0DE30, name +0x04).

Also documented that 0x00605570 is really the creature-type resolver
(new canonical alias FUN_UNIT_CREATURE_TYPE); the existing
FUN_UNIT_PET_MINION_CLASS name is a historical label — Pet.cpp's
"!= 1" test works because it is effectively "is this a Beast (type 1)":
hunter pets are Beasts, warlock minions are Demons (3). Verified
in-game: target -> 7 Humanoid, IDs[8] -> 8 Critter, round-trips.
2026-07-16 21:20:47 -05:00
Brues 50909ce440 creature: add C_CreatureInfo.GetFactionInfo(raceID)
The by-raceID form of vanilla's UnitFactionGroup, returning
{ name (localized), groupTag (english "Alliance"/"Horde") } for a race
with no unit required. Mirrors the player branch of Script_UnitFactionGroup
(0x00516630) exactly: ChrRaces[race]+0x08 -> FactionTemplate id ->
FactionTemplate[id]+0x0C group mask -> scan FactionGroup.dbc for the row
whose bit the mask sets and whose localized name is non-empty (the
non-empty test is how the engine skips the always-set "Player" bit and
the nameless "Monster" row to land on Alliance/Horde). nil for
non-numeric / non-positive / unknown race or a race with no named group.

Every field offset ground-truthed against ChrRaces/FactionTemplate/
FactionGroup .dbc files. FactionGroup is stored contiguously (base +
i*0x30, direct field reads), so it's iterated by hand rather than via
DBC::Record. Its records-base/count globals (0x00C0DD5C / 0x00C0DD60)
are taken from the disassembly, which is authoritative over DBCs.md's
standard-shape tabulation for this table (0x00C0DD64 / 0x00C0DD68) --
verified in-game: race 1 -> Alliance, race 2 -> Horde.
2026-07-16 21:11:04 -05:00
Brues d48d003828 creature: add C_CreatureInfo.GetCreatureFamilyInfo / GetCreatureFamilyIDs
Retail backports over CreatureFamily.dbc (records 0x00C0DE7C, count
0x00C0DE80 — the globals Script_UnitCreatureFamily already indexes), on
the existing C_CreatureInfo table:

  GetCreatureFamilyInfo(id) -> { id, name, iconFile }
  GetCreatureFamilyIDs()    -> ascending array of populated family ids

Localized name at record +0x20 (via LocalizedField), icon at +0x44 (via
StringField). GetCreatureFamilyIDs skips the sparse unused rows
(10/13/14/22/...); each element round-trips with GetCreatureFamilyInfo.

Faithful deviation: retail's iconFile is a numeric fileID, but vanilla's
DBC stores the icon as a texture-path string, so iconFile is a string
(usable directly with SetTexture) and "" for families with no icon
(warlock pets). Verified in-game: 27 -> Wind Serpent + icon path, 15 ->
Felhunter + "", IDs array sparse, unused id 10 -> nil.
2026-07-16 21:02:01 -05:00
Brues d51f5f72a9 unit: add UnitCreatureFamilyID(unit)
Extension exposing the numeric CreatureFamily id (CreatureFamily.dbc
row) for a unit, the value addons want for stable checks — stock
UnitCreatureFamily only returns the locale-dependent display name.

It's the same id the engine's Script_UnitCreatureFamily (0x0051A310)
resolves internally: its family helper FUN_006055e0 returns
[unit+0xB30] ? [row+0x1C] : 0, then maps it through CreatureFamily.dbc
for the name. We stop at the id. Resolves the token via the same
FUN_RESOLVE_UNIT_TOKEN, returns nil for family 0 (players, non-beast
NPCs) and unsynced/unresolvable units — matching UnitCreatureFamily.
Shares UnitSubName's +0xB30 creature-cache read path. Verified in-game:
target (cat) -> 2, targetpet -> 2.
2026-07-16 20:52:31 -05:00
Brues 04efd4caf1 input: reconcile modifier state on focus regain
MODIFIER_STATE_CHANGED never fired for a modifier released while WoW was
in the background: WM_KEY* messages are only delivered to the focused
window, so the WH_GETMESSAGE hook never saw the release and the cached
bit stayed stuck down (also leaving IsLeft/RightShiftKeyDown etc. wrong).

Add a WH_CALLWNDPROC thread hook to catch WM_ACTIVATEAPP (sent, not
posted, so invisible to WH_GETMESSAGE). On app activation, resync the
cached 6-bit mask from GetAsyncKeyState (global physical key state,
unaffected by focus), firing MODIFIER_STATE_CHANGED for any bit that
drifted while WoW was away. Both the keyboard path and the resync now
funnel through a shared SetBit helper so the "only transitions fire"
invariant holds regardless of source.

Caveat (documented): the engine's native IsShiftKeyDown/IsControlKeyDown/
IsAltKeyDown bottom out at synchronous GetKeyState and share the blind
spot; they are not reconciled here — only ClassicAPI's L/R functions and
the event reflect the corrected state.
2026-07-16 20:18:43 -05:00
Brues 329d6b75ba creature: add C_CreatureInfo.GetRaceInfo / GetClassInfo
Retail backports over ChrRaces.dbc / ChrClasses.dbc, on the existing
C_CreatureInfo table. By-id lookups (no unit token needed, unlike
UnitRace/UnitClass) returning the localized name plus the non-localized
file token that round-trips with the unit APIs:

  GetRaceInfo(raceID)   -> { raceName, clientFileString, raceID }
  GetClassInfo(classID) -> { className, classFile, classID }

Reuses the already-verified DBC offsets (OFF_CHRRACES_* / OFF_CHRCLASSES_*),
StringField for the token and LocalizedField for the name. nil for
non-numeric / non-positive / unknown ids. Verified in-game: race 4 ->
Night Elf/NightElf, class 1 -> Warrior/WARRIOR, unknown id -> nil.
2026-07-16 18:32:35 -05:00
Brues 6a677ce447 taxi/map: capital city areaIDs + C_Map.GetMapAreaIDs
Taxi areaID resolution now tries the node's LOCATION part first
("Orgrimmar" in "Orgrimmar, Durotar") -- accepted only when it's a
mapped area whose WorldMapArea rect contains the node -- then the zone
suffix, then the geometric resolver. Capitals thus pin on their own city
map (Orgrimmar 1637, Undercity 1497) instead of the surrounding region,
while normal nodes (location is an unmapped subzone) still resolve to the
outdoor zone. Map::Area::PercentInZone is now containment-checked so a
candidate zone is taken only if it actually holds the point.

Add C_Map.GetMapAreaIDs() -> { [mapName] = areaID } from WorldMapArea:
the missing bridge (vanilla has no GetCurrentMapAreaID) for resolving a
browsed city/instance map -- which GetMapZones() doesn't enumerate -- to
its zone id. Keys on the WorldMapArea name (matches GetMapInfo()'s dir
for nearly all maps; a few misspelled Blizzard folders like "Ogrimmar"
differ, documented).

Verified in-game: Orgrimmar/Undercity flight masters pin on their city
maps; GetMapAreaIDs()["Orgrimmar"] = 1637.
2026-07-16 18:06:55 -05:00
Brues f6f88ab4ee taxi/map: accurate zone resolution + reachable flag
Zone assignment (Map::Area::ZonePercent, shared by GetTaxiNodesForMap
and GetAreaTriggerInfo) chose among overlapping WorldMapArea bounding
boxes by smallest rect, mis-assigning border points. Now resolve by the
zone whose DRAWN LANDMASS the point sits on -- WorldMapOverlay hit rects,
the runtime analog of an ADT area-grid lookup -- tie-broken by how deep
inside an overlay the point is (a node deep in its own zone's overlay
beats one clipping a neighbor's edge). Falls back to most-interior
bounding box when on no overlay. Fixes Ratchet, Un'Goro, Nijel's Point.

For taxi specifically, resolve areaID from the node's own name suffix
("..., Western Plaguelands" -> AreaTable, exact then word-prefix) --
authoritative, and right at borders geometry can't separate (Chillwind
Camp -> WPL, not the Alterac box it sits in). New Map::Area::PercentInZone
projects the position into the named zone; geometric resolver remains the
fallback and the area-trigger path.

Add a `reachable` field: a flight path leads to the node (in-degree > 0).
false marks departure-only nodes reachable only by other means -- druid
Teleport (Nighthaven, Moonglade) or a boat dock (Stormwind Harbor) -- so
a consumer can hide them from a "where can I fly" map without a hardcoded
druid guess. BuildTaxiGraph tracks endpoint + destination sets in one pass.

Verified in-game: Nijel's->Desolace(405), Marshal's->Un'Goro(490),
Ratchet->Barrens(17), Chillwind->WPL(28).
2026-07-16 16:44:58 -05:00
Brues 17c928028e taxi: GetTaxiNodesForMap() with no arg returns all continents
Previously no-arg meant the currently-viewed continent; make it return
every flight master on every continent instead -- a one-call flight DB.
A numeric mapID still filters to that continent. Each entry already
carries its own mapID, and the mount + TaxiPath-endpoint filters apply
uniformly. Verified: no-arg 97, GetTaxiNodesForMap(0) 48.
2026-07-16 14:20:41 -05:00
Brues a47f38417f taxi: add C_TaxiMap.GetTaxiNodesForMap / GetAllTaxiNodes
GetTaxiNodesForMap(mapID) reads TaxiNodes.dbc: real flight masters on a
continent, faction-tagged, discovered or not -- the static DB pfQuest's
pfDB.meta.flight wants. Non-flight rows are filtered out: a node must
have a flight mount (horde @+0x38 / alliance @+0x3C -- drops transports/
boats/zeppelins) AND be a TaxiPath.dbc endpoint (drops Northshire Abbey,
spurious duplicate hub rows, standalone markers with no connecting
path), plus a guard for Blizzard's lone "Generic, World target"
placeholder. Returns retail nodeID/name/faction/position (continent
0-1) + ext mapID/x/y/z and derived areaID/mapX/mapY (zone-relative).

GetAllTaxiNodes([uiMapID]) wraps the open flight master's live node
list (VAR_TAXI_NODE_ARRAY stride 0x20: slot+0 = TaxiNodes record ptr,
+0x04/+0x08 = flight-map x/y; count VAR_TAXI_NODE_COUNT; type via
FUN_004dc4e0): slotIndex (TakeTaxiNode), name, nodeID, state
(Enum.FlightPathState). Empty when no taxi map is open.

Registers Enum.FlightPathFaction / Enum.FlightPathState (retail values).
Extracts the zone-percent projection into shared Map::Area::ZonePercent
+ adds ContinentPercent; refactors AreaTriggers onto ZonePercent.
Verified: EK 48 real masters (Northshire/EPL/transports gone), Stormwind
Alliance / Undercity Horde, continent position matches projection; live
24 nodes with Stormwind state=Current.
2026-07-16 14:00:18 -05:00
Brues 1282e3f6db map: add C_MapExplorationInfo explored/unexplored map textures
GetExploredMapTextures(areaID) backports the retail (9.0-era) call:
only overlays the local player has discovered. GetUnexploredMapTextures
is the ClassicAPI-extension complement (the reveal-over-fog pieces); its
inverse, C_Map.GetMapOverlays, still returns all overlays.

Refactor the overlay walk into a shared Map::Overlays::PushZoneOverlays
with a Filter {All, Explored, Unexplored}; GetMapOverlays is now the
All wrapper. "Explored" is computed per-areaID from the player's
explored-areas bitfield ([player+0xE68]+0xE6C, indexed by AreaTable
AreaBit +0x0C, gated by +0x28) -- mirrors the engine's discovered-
overlay rebuild FUN_004a67a0 -- so the areaID arg works for any zone,
not just the current view. Also add a fileDataIDs field (ordered tile
paths, retail's field name) to every overlay entry.

New offsets: OFF_AREATABLE_AREA_BIT, OFF_AREATABLE_EXPLORE_GATE,
OFF_PLAYER_EXPLORED_BITS. Verified: Elwynn explored(10) + unexplored(2)
== all(12).
2026-07-16 02:25:10 -05:00
Brues 4e4fad9196 map: add C_Map.GetAreaInfo / GetAreas
Surface AreaTable.dbc's localized name column. GetAreaInfo(areaID) is
the retail backport -- localized name for one zone/subzone id, nil for
bad input / no row. GetAreas() is an extension: the full { [areaID] =
name } map (empty-name rows omitted, round-trips with GetAreaInfo), the
piece name databases need for name->id / reverse search. Same "surface
the hidden DBC" pattern as GetAreaTriggers / GetMapOverlays.

AreaTable.dbc is a client DBC (always loaded, synchronous, localized),
so no WDB cold-cache concerns. Uses existing VAR_AREATABLE_* /
OFF_AREATABLE_NAMES wiring; no new offsets. Verified: (12) Elwynn
Forest, (9) Northshire Valley, GetAreas()[12] Elwynn Forest.
2026-07-15 23:48:15 -05:00
Brues 67063876da spell: add C_SpellBook.GetSkillLineRank / GetSkillLineName
GetSkillLineRank(skillLineID) returns the local player's curRank,
maxRank, modifier for a SkillLine.dbc id, or nil if unlearned -- keyed
by id directly instead of forcing addons to map id -> localized name and
string-match GetSkillLineInfo's by-name, by-position walk. Resolves the
id to the player's skill slot via FUN_SKILL_LINE_TO_SLOT, then reads the
CGPlayer +0xE68 skill table (the storage Player::CanUseItem's
require-skill gate already uses). GetSkillLineName(skillLineID) is the
id -> localized name half (pure SkillLine.dbc read, learned or not).

Both live in the existing Spell::SkillLine module; no new offsets.
Verified: Enchanting(333) 171/225/15, Tailoring(197) 261/300/0,
name(182)=Herbalism, unlearned line -> nil.
2026-07-15 23:02:42 -05:00
Brues b34368ef14 map: expose overlay hit rect in C_Map.GetMapOverlays
Each overlay now carries hitRectTop/Left/Bottom/Right (WorldMapOverlay.dbc
+0x34..+0x40) -- the tight clickable landmass bounds in world-map canvas
px (~1002x668), the subzone-center source map addons scrape into their
zone tables. Verified: areaID 12 overlay 8 (NorthshireValley, areaID 9)
-> hitRect 164/400/388/617.

Also stop dropping texture-less rows: a row with an areaID + hit rect but
no texture now appears with textureName == "", zero size, and an empty
tiles table (ipairs-safe), instead of being skipped. No-op on stock/Octo
data (every overlay has a texture) but matches the schema and the "every
overlay of a zone" contract.
2026-07-15 22:07:35 -05:00
Brues 4983c9fd83 map: expose overlay areaIDs in C_Map.GetMapOverlays
Each overlay table now carries areaID (primary AreaTable id revealed,
first nonzero) and areaIDs (the full nonzero list -- WorldMapOverlay.dbc
areaID[4]; 52 of 707 overlays span 2-3 subzones). Lets consumers map an
overlay to its subzone(s) / exploration state. Verified: overlay 1 of
areaID 85 -> areaID 157.
2026-07-15 21:30:19 -05:00
Brues 9e5114141e map: add C_Map.GetMapWorldSize; extract shared Map::Area resolver
Backport the retail C_Map.GetMapWorldSize (9.0): return a zone's world
size in yards as width, height, read from the WorldMapArea.dbc loc rect
(width = locLeft - locRight east-west, height = locTop - locBottom
north-south). Takes an AreaTable areaID, or defaults to the world map's
current view like GetMapOverlays. Verified: (85) -> 4518.75, 3012.5.

Extract the two WorldMapArea row resolvers (areaID -> row, current view
-> row) out of map/Overlays.cpp into a shared Map::Area helper; both
GetMapOverlays and GetMapWorldSize key off the same rows, so the two
ways of naming a zone now live in one place.
2026-07-15 21:14:03 -05:00
Brues 6be25d78e0 map: add C_Map.GetAreaTriggerInfo / GetAreaTriggers
Expose AreaTrigger.dbc (517 static trigger volumes: subzone-entry /
exploration / teleport), which the client loads but never surfaces to
Lua -- the reason map addons ship hand-scraped trigger tables. Same
"read the hidden DBC" pattern as C_Map.GetMapOverlays.

Each trigger returns both the authoritative raw geometry (mapID, world
x/y/z, radius, isBox + box length/width/height/yaw) and a derived
consumable form (areaID + mapX/mapY as a 0..100 zone-relative percent)
via the WorldMapArea rect, picking the smallest zone whose rect
contains the point. GetAreaTriggerInfo(id) returns one trigger;
GetAreaTriggers([mapID]) enumerates all, optionally filtered to a map.

Adds the AreaTrigger.dbc record offsets and the WorldMapArea mapID /
loc-rect offsets to Offsets.h.
2026-07-15 20:04:08 -05:00
Brues 06e68d04a8 docs: document C_Item.PickupItem
Add the API.md entry (input shapes, equipment-then-bags search, no-op
conditions, direct-cursor-primitive note) plus its TOC link, and the
README Item-row index entry.
2026-07-15 19:56:25 -05:00
Brues 8bfe0b008c item: EquipItemByName auto-slot uses Item::Cursor::PickupBagItem
Replace the auto-slot path's Script_PickupContainerItem stack-fake
(SetTop + PushNumber x2 + call) with the shared
Item::Cursor::PickupBagItem, which drives the engine's cursor primitive
directly -- the same one C_Item.PickupItem uses. The ClearCursor at the
top still runs first, so PickupBagItem's empty-cursor guard is satisfied;
bail if the pickup didn't take (locked item / busy cursor) rather than
auto-equipping nothing. Drops the now-unused ScriptFn_t typedef.

FUN_SCRIPT_PICKUP_CONTAINER_ITEM now has no callers -- the Script-pickup
stack-fake is gone from the codebase. Explicit-dstSlot path (atomic
FUN_INVENTORY_SWAP) is unchanged. Verified in-game (auto-slot equip by
name and by id).
2026-07-15 17:35:42 -05:00
Brues e9c2e7416a item: add C_Item.PickupItem, via direct engine cursor primitives
C_Item.PickupItem(itemInfo) picks the matching item up onto the cursor.
itemInfo is an itemID / "item:NNN" / link / name (same input family as
UseItemByName / EquipItemByName): resolved via Item::Arg::Resolve and
found by Item::Location::FindByArg across equipment (1..19, checked first)
and bags (0..4) using the shared MatchesArg predicate (itemID, or the
decorated name for a name arg). An equipped match is lifted off the
paperdoll; a bag match off the container. Verified in-game (bag + equipped,
by id and by name).

Drives the engine's cursor-pickup primitives DIRECTLY rather than the Lua
Script_Pickup* state-machine wrappers (no faking the Lua stack), via a new
shared Item::Cursor:
  - PickupEquipmentSlot -> FUN_004C7300 (self-contained; own guards)
  - PickupBagItem       -> FUN_00494B60, replicating the two guards the
    Script wrapper applies (empty cursor + unlocked item) and the
    post-pickup source lock. The empty-cursor check reuses a new shared
    Cursor::Info::HasItem() (the same cursor-GUID globals GetCursorInfo
    reads), rather than duplicating it.

Supporting consolidation in Item::Location:
  - ResolveBagSlotDetail exposes the container object + linear slot the
    cursor primitive needs; ResolveBagSlot now delegates to it.
  - FindByArg (equipment-then-bags, MatchesArg) reuses FindByArgInBags for
    the bag half; FindByItemID is now its itemID-only specialization.

C_EquipmentSet.UseEquipmentSet was considered but already uses the atomic
FUN_INVENTORY_SWAP (no cursor). EquipItemByName's auto-slot path still uses
the Script wrapper; migrating it to Item::Cursor::PickupBagItem is a
follow-up.
2026-07-15 17:27:36 -05:00
Brues 6641e5ac13 item: add C_Item.IsConsumableItem (+ global IsConsumableItem)
Returns true if an item is a consumable: item class Consumable (0), or
inventory type INVTYPE_AMMO (24) / INVTYPE_THROWN (25). Registered as both
the namespaced C_Item form and the bare global, like IsUsableItem.

Definition established by decoding 3.3.5's Script_IsConsumableItem
(0x005173E0) then verifying the surviving contract on a modern client:
3.3.5 walked the item's on-use spells and returned true for any with a
real effect (which would include on-use trinkets), but that heuristic did
NOT survive -- an on-use trinket with a numeric Use: effect (Talisman of
Ephemeral Power) returns false on modern. It's a plain class/ammo check.
Verified in-game on our client, matching modern exactly: potion / bandage
/ Faintly Glowing Skull (all class 0) and Rough Arrow (Ammo) -> true;
Hearthstone, Talisman, plain armor -> false.

itemID / "item:NNN" / link input (names -> false, no vanilla name->ID
map); uncached -> false with no async load, same contract as
C_Item.IsEquippableItem. Documented in API.md (+ TOC) and the README
Item-row index.
2026-07-15 16:35:43 -05:00
Brues db1f00e0a3 item: add C_Item.IsItemInRange, via a shared Spell::Range core
C_Item.IsItemInRange(item, targetUnit) resolves the item to its on-use
spell (Item::Spell::OnUseSpellIDForItemID) and runs the same geometric
range test as C_Spell.IsSpellInRange: true / false in-or-out of range,
nil when the check doesn't apply (no on-use spell, a rangeless one, or an
unresolvable item/unit). Matches retail, which also returns nil for items
with no range restriction. item accepts itemID / "item:NNN" / link (names
aren't resolvable in vanilla -> nil). Range-only, ignoring LoS/faction,
like IsSpellInRange.

The range predicate SpellIDHasRange was about to become its fourth copy,
so extract the shared core into Spell::Range (SpellIDHasRange +
PlayerVsUnit, returning 1/0/-1). C_Spell.IsSpellInRange and
C_Item.IsItemInRange are now thin Lua wrappers over PlayerVsUnit, and
C_Spell.SpellHasRange / SpellHasRange call the shared SpellIDHasRange --
range logic single-sourced.
2026-07-15 15:59:41 -05:00
Brues 82062309a9 docs: item name/GetItem/by-name APIs return the decorated (suffixed) name
Follow-up to the random-suffix work (aa0279a, e8a9003, 2a218a0). API.md
still described the old base-name behavior in five places; update them:

- GameTooltip:GetItem() example: name is now the decorated name that
  agrees with the returned link ("Superior Cloak of the Eagle").
- C_Item.GetItemName: split the two forms -- location returns the
  decorated name (live instance, via the engine name builder), ByID
  returns the base name (an itemID has no instance/suffix).
- C_Item.IsEquippedItem / EquipItemByName / UseItemByName: name matching
  is against the decorated name (a suffixed item matches its full name,
  not the base), noting the one shared predicate behind all three.

Left the C_Item.GetItemData return-table `name` row as base m_name[0] on
purpose: that table is a static itemID-keyed record dump (same contract
as GetItemInfoInstant, which doesn't decorate either).
2026-07-15 15:36:54 -05:00
Brues 2a218a0a76 item: match decorated (suffixed) name via shared MatchesArg predicate
IsEquippedItem(name) and the bag find-by-name (FindByArgInBags, behind
UseItemByName/EquipItemByName/UseAtCursor) compared the caller's string
against the base ItemStats m_name[0], so a random-suffix item ("Foo of
the Owl") only matched when queried by its base name ("Foo"). The modern
client does the opposite -- verified in-game on both the modern client
and ours:

  IsEquippedItem("Iridium Chain")               -> false
  IsEquippedItem("Iridium Chain of the Owl")    -> true
  IsEquippedItem("iridium chain of the owl")    -> true  (case-insensitive)
  IsEquippedItem(12022)                         -> true  (itemID)

Match the decorated display name instead, via the engine name builder
(Item::Link::NameFromCGItem), which falls back to the base name for
unsuffixed items so plain items still match.

The match predicate was duplicated inline in both call sites -- which is
why this took two edits. Consolidate it into a single
Item::Location::MatchesArg(cgItem, resolvedArg): itemID-compare when
arg.itemID > 0, else case-insensitive decorated-name compare. Both
IsEquippedItem and FindByArgInBags now delegate to it, so the full chain
is single-sourced (Item::Arg parses input -> MatchesArg decides the match
-> NameFromCGItem / ID::FromCGItem supply the comparands). Also drop a
now-unused <string.h> from Equipment.cpp and fix the stale m_name[0] doc
on FindByArgInBags.
2026-07-15 15:31:55 -05:00
Brues e8a9003bf0 item: dress GameTooltip:GetItem() name with random suffix
GameTooltip:GetItem() resolved the CGItem and built the suffixed link via
Item::Link::FromCGItem, but returned the base ItemStats name beside it --
so name and link disagreed ("Iridium Chain" vs "[Iridium Chain of the
Owl]"). The old comment claimed the engine didn't expose the dressed name
as a separate string; it does, via the same builder the link uses.

Extract that builder into a shared Item::Link::NameFromCGItem helper
(next to FromCGItem, wrapping FUN_ITEM_BUILD_INSTANCE_NAME) and use it in
both GetItem and C_Item.GetItemName so the returned name matches the link
and modern behavior. Verified in-game: GetItem() now returns "Iridium
Chain of the Owl" alongside its matching link.

Left the base-name reads that are correct as-is: itemID-only paths (mail,
cursor-by-id, GetItemInfo, equipment-set missing items) have no instance,
and the by-name *matching* in IsEquippedItem / bag lookups compares
against a caller string (a separate semantics question).
2026-07-15 15:13:52 -05:00
Brues aa0279a600 item: decorate C_Item.GetItemName location form with random suffix
The location form of C_Item.GetItemName points at a real item instance,
so its name should carry random-suffix decoration ("... of the Owl") the
way modern WoW does -- but we were reading m_name[0] off the itemID-keyed
ItemStats_C cache, which only holds the base name.

Resolve the location to its CGItem and build the name via the engine's
own per-instance name builder (FUN_005D8BC0): it reads the suffix ID off
the descriptor (+0x98) and formats base + ItemRandomProperties localized
suffix through the engine's ITEM_SUFFIX_TEMPLATE. This is the same
builder FUN_GAMETOOLTIP_BUILD_ITEM_LINK wraps in brackets, so the output
now matches GetItemLink's bracketed name and modern C_Item.GetItemName
exactly (verified in-game: "Iridium Chain of the Owl"). Falls back to the
base ItemStats name (and warms the cache) when the instance build yields
nothing.

GetItemNameByID is unchanged -- an itemID has no instance, so base name
only is correct.
2026-07-15 15:06:04 -05:00
Brues a754d1ba22 Update TODO.md 2026-07-15 01:22:14 -05:00
Brues d55877649f item, unit: drop hand-rolled unit-token grammar mirrors
FUN_TOKEN_TO_GUID (0x00515970) is a pure parser: it returns 0 for any
input it doesn't recognize and never raises -- verified from the
disassembly (plain stack frame, no assert/SEH prologue; every
unknown-input path is xor eax,eax; ret). Both call sites had mirrored
the engine's token grammar by hand to pre-screen input, on the mistaken
belief this function throws (it was conflated with its sibling
FUN_RESOLVE_UNIT_TOKEN 0x00515940, which does throw).

- item/Use.cpp UseItemByName: delete LooksLikeToken / MatchBase /
  SkipTargetSuffixes / HasPrefix; just call FUN_TOKEN_TO_GUID and let it
  return 0 for non-tokens.

- unit/Flags.cpp UnitIsInMyGuild: delete LooksLikeKnownToken / HasPrefix
  / AllDigits. Resolve the input to a GUID via FUN_TOKEN_TO_GUID, then
  resolve GUID -> object via the non-throwing FUN_OBJECT_RESOLVE_BY_GUID
  (unit|player mask) for the fast guild-key compare, and GUID -> name via
  the NameCache for the roster fallback. This avoids the throwing
  resolver entirely, and -- because the token->GUID step goes through
  our TokenExtensions hook -- now supports focus / nameplateN, which the
  old grammar mirror silently treated as literal names. Matches what
  3.3.5's Script_UnitIsInMyGuild does (resolve to a GUID first).

No grammar to keep in sync with the engine anymore. Behavior otherwise
unchanged (self, guildmate/other-guild target, NPC, OOR raid member,
literal name, empty slot all verified case-by-case).
2026-07-14 16:29:14 -05:00
Brues f55779f87e item: fix UseItemByName ignoring the unit target for all but current target
UseItemByName resolved the unit token (arg 2) AFTER FindByArgInBags,
which stomps the Lua stack (clears it and pushes bag-slot numbers for
PackBagSlot). So ResolveUnitGuid read a leftover slot number instead of
the token: IsString failed, the GUID came back 0, and the engine fell
back to its default target. That default is the player's current target
for a targeted-use item, so "target" appeared to work while "focus",
"party1", "mouseover", etc. silently did nothing useful.

Resolve the target GUID before the bag walk (as the sibling UseAtUnit
already does). ResolveUnitGuid only reads slot 2 and resolves a C
string -- no stack stomp -- and leaves arg.name (slot 1) intact for
FindByArgInBags; the GUID is a plain value by the time the stack is
blown away.
2026-07-14 15:59:07 -05:00
Brues 91e88d768a unit: add UnitInLineOfSight(unit)
ClassicAPI extension (retail has no such global): true if the player
has clear LoS to unit, false if world geometry blocks it, nil when the
check can't apply (absent/unresolvable token, or non-unit object).

Traces a ray between the two units through the client's world-collision
core CWorld::Intersect (0x672170, verified in-binary: __fastcall(start,
end, unused, hitOut, tOut, flags), tail-calls the real intersect
FUN_0069BFF0). Flags 0x100111 = terrain + WMO (0x100171 also tests M2
doodads but crashes in some dungeons; matches vanilla server LoS which
ignores doodads). Endpoints are raised by each unit's collision-box
height (CMovement+0xB4) so foot-to-foot rays don't false-block on
terrain, with a two-pass level/look-up trace for height mismatches and
a 150yd guard against a long-segment crash.

Mirrors UnitXP_SP3's proven implementation (same VA, flags, guard, and
two-pass logic) but native, so it works with or without that addon --
we call the intersect rather than hooking it, and our flag isn't one
UnitXP_SP3's detour acts on. Gated on object type (unit/player) so it
never reads CMovement off a GameObject.

Adds FUN_CWORLD_INTERSECT, OFF_CMOVEMENT_COLLISION_BOX_HEIGHT,
OFF_CGOBJECT_TYPE_ID, OBJECT_TYPE_UNIT/PLAYER (reuses the existing
OFF_UNIT_MOVEMENT_INFO_PTR = 0x118), plus API.md and README entries.
2026-07-14 15:48:55 -05:00
Brues d358bd9515 spell: add C_Spell.IsSpellInRange(spellIdentifier, targetUnit)
Returns true/false for in/out of range, nil when the check does not
apply (rangeless spell, or unresolvable spell/unit). spellIdentifier
accepts a spellID, link, or spellbook name; targetUnit is a unit token.

Backed by the engine's own geometric range core FUN_006E47B0 -- the
same test the action-button range coloring uses (Script_IsActionInRange
to FUN_004E56F0 to FUN_006E4440 to FUN_006E47B0). It derives the
spell min/max range via FUN_006E3480 (which folds in the target's
bounding radius AND applies the range SpellMod op 5, so range-extending
talents/items like Hawk Eye and Nature's Reach count) then compares
center-to-center distance, so the boundary matches the client exactly,
melee and min-range spells included.

We call the pure core rather than FUN_006E4440: that wrapper also gates
on target type and folds range-not-applicable into the same truthy
return as in-range, so it cannot yield a clean true/false/nil. The core
is range-only, matching retail (ignores line of sight; does not reject
wrong-faction targets, documented). Positions are guarded via
Unit::Position::Read before the engine call, which dereferences the
GetPosition result without a null check.

Adds Offsets::FUN_SPELL_RANGE_CHECK (0x006E47B0) with derivation notes,
plus API.md and README entries.
2026-07-14 15:00:28 -05:00
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
Brues 8e9e30567c compat: hide HookScript on older pfUI forks' action bar buttons
Our DLL adds the modern HookScript widget method; older pfUI forks branch
on `if button.HookScript` and take a modern path they weren't written for
on pfActionBar buttons, breaking them. While the actionbar module builds,
wrap CreateFrame to shadow HookScript with a falsy instance field (nil
doesn't work — it's a metatable method, so the lookup falls through) so
those forks use their vanilla SetScript path. The maintained "brues" fork
is detected via the TOC X-Website tag and left alone; the wrapper is
removed once the actionbar module finishes loading.
2026-07-10 20:19:37 -05:00
Brues 8be78ad946 Fix trade skill link button positioning and add ATSW support
Make profession link button position responsive to title text changes and justification. The button now repositions whenever the title's text or visibility changes, using the title's string width and justification mode to calculate the correct anchor point. Also add support for AdvancedTradeSkillWindow and AdvancedTradeSkillWindow2 addons with appropriate link callbacks.

Closes #7
2026-07-10 18:22:25 -05:00
Brues 0dea869ab1 tradeskill: hide detail scrollbar track groove when not scrolling
The ClassTrainer detail template's decorative track pieces
($parentTop/$parentBottom) stayed visible when the slider was hidden,
leaving an empty scrollbar groove on recipes that fit. Toggle them with
the slider in UpdateDetailScrollbar (no-ops on Turtle's plain template).
2026-07-10 17:11:26 -05:00
Brues b4dffb75c0 tradeskill: reagent tooltip fires over the whole element
Extend each reagent button's hit rect rightward across the name plate
(capped short of the next column so hovers don't bleed across), so the
item tooltip shows anywhere on the element instead of only over the icon.
2026-07-10 16:56:42 -05:00
Brues 9445fd6333 tradeskill: native-style detail scroll, reagent overflow, separator
Rework the trade-skill link viewer's detail pane to match the native
1.12 TradeSkillFrame:

- Detail is now a real ScrollFrame (ClassTrainer templates on stock,
  plain on Turtle) whose scroll child holds the product header + reagent
  grid, so the whole pane pixel-scrolls and clips partial reagent rows
  instead of the old fixed 2-row faux-scroll that clipped many-reagent
  recipes. Slider is managed explicitly (shown only when content
  overflows; re-asserted a frame after show so the engine's layout pass
  can't leave a dead scrollbar on Turtle's roomy pane).
- Reagent buttons built on demand (any count) inside the scroll child.
- Add the recipe/detail separator (stock only) using the top-quarter rod
  of UI-ClassTrainer-HorizontalBar, stretched to width.
- Stock list/detail scrolls use the ClassTrainer scroll templates for the
  decorative UI-ClassTrainer-ScrollBar track; Turtle keeps plain ones.
2026-07-10 16:45:56 -05:00
Brues c8effdd6ec addons: fix crash in GetAddOnOptionalDependencies numeric path
FUN_ADDON_GET_BY_INDEX returns a name pointer, not the AddOnEntry
record, so reading the OptionalDeps offsets (+0x3C/+0x40) directly off
it landed in addon-name string memory and dereferenced garbage as an
array base -> ACCESS_VIOLATION (pfUI's addon list iterates by index and
hit it immediately). Route both numeric and string args through the
by-name hash resolver FUN_ADDON_RESOLVE_REQ_DEPS to reach the real
record, exactly as stock Script_GetAddOnDependencies does for both.
2026-07-10 02:33:14 -05:00
Brues b3ac1c07f7 addons: add C_AddOns.GetAddOnOptionalDependencies
Backport the optional-deps getter vanilla never exposed (it ships
GetAddOnDependencies for the required array only). Resolves the arg
the same way stock Script_GetAddOnDependencies does -- numeric via
FUN_ADDON_GET_BY_INDEX, string via the by-name hash lookup 0x0051E350
(returns record+0x48; subtract to recover the base) -- then walks the
OptionalDeps descriptor at record+0x38 (count +0x3C, data +0x40),
verified against the TOC parser FUN_0051C9B0. Arg/error behavior
matches stock: out-of-range index or a non-string/number arg errors;
an unknown name returns nothing.
2026-07-10 02:22:28 -05:00
Brues cfbc4f4e9c loot: add C_LootHistory (group-loot roll history backport)
Backport of the MoP C_LootHistory namespace. Vanilla 1.12 has no
loot-history store, but it receives the live group-loot roll traffic,
which the stock UI only turns into chat. This reconstructs the history
client-side (like Aura::Source): four co-hooks on the loot-roll packet
handlers accumulate a ring (128) of rolled items with per-player
Need/Greed/Pass results and the winner, exposed through the namespace.

Handlers, from the opcode dispatch FUN_005E6010:
  - START_ROLL  0x2A1 FUN_0061B310 (__fastcall(packet), RET 0)
  - ALL_PASSED  0x29E FUN_0061B640 (msg=EDX, RET 0)
  - ROLL_WON    0x29F FUN_0061B9E0 (ctx=ECX, msg=EDX, +stack, RET 4)
  - ROLL        0x2A2 FUN_0061C0B0 (msg=ECX, ctx=EDX, +stack, RET 4)
The per-handler ABI (message register, stack arg, RET) is load-bearing:
matching it exactly is what keeps the co-hooks from corrupting ESP.

Names and class come from the engine NameCache (FUN_0055F080, null-
callback read of a loaded entry), captured at roll time — so they
resolve even for a roller who has left the group by the time their roll
arrives. The item's random suffix is only in SMSG_LOOT_START_ROLL (the
result packets send 0), so it's read off the store-head node right after
that handler runs, giving a correct item:id:0:suffix:unique link.

Surface: C_LootHistory.GetNumItems / GetItem / GetPlayerInfo (name,
class token, rollType, roll, isWinner, isMe) + LOOT_HISTORY_ROLL_CHANGED
/ _ROLL_COMPLETE events (_FULL_UPDATE reserved). The MoP master-loot
functions (SetExpiration / GiveMasterLoot / CanMasterLoot) are omitted —
they need server support 1.12 lacks. Documented in docs/API.md + README.
2026-07-10 01:59:52 -05:00
174 changed files with 19067 additions and 1777 deletions
+5 -1
View File
@@ -12,7 +12,6 @@ Util\Print.lua
Util\Mixin.lua
Util\Color.lua
Util\Constants.lua
Util\String.lua
Util\TableUtil.lua
Util\MathUtil.lua
Util\PixelUtil.lua
@@ -23,6 +22,7 @@ Util\CallbackRegistry.lua
Util\GlobalCallbackRegistry.lua
Util\ColorUtil.lua
Util\ItemLocation.lua
Util\PlayerLocation.lua
Util\EventUtil.lua
Util\ItemUtil.lua
Util\AddOnCompat.lua
@@ -38,3 +38,7 @@ Util\LinkUtil.lua
Util\TradeSkillLink.lua
Util\IconDataProvider.lua
Util\GameTooltip.lua
Util\Rectangle.lua
Util\Vector2D.lua
Util\Vector3D.lua
Util\Vector4D.lua
+34 -1
View File
@@ -8,5 +8,38 @@ if C_AddOns.DoesAddOnExist('pfUI') then
unknownColor.colorStr = unknownColor:GenerateHexColor()
return unknownColor
end})
-- Compatibility with OLDER forks of pfUI. Our DLL adds the modern
-- HookScript widget method; older pfUI forks branch on
-- `if button.HookScript` and take a modern code path they were never
-- written for on their pfActionBar buttons, which breaks them. While
-- the actionbar module builds those buttons, temporarily wrap
-- CreateFrame to shadow HookScript with a falsy field so those forks
-- fall back to their vanilla SetScript path. The maintained "brues"
-- fork handles HookScript itself, so it's detected via the TOC
-- 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 strfind(website, 'brues') then
local _createFrame = CreateFrame
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
end
return frame
end
hooksecurefunc(pfUI, 'LoadModule', function(frame, moduleName)
if moduleName == "updatenotify" then
CreateFrame = HookedCreateFrame
elseif moduleName == "actionbar" then
CreateFrame = _createFrame
end
end)
end
end)
end
end
if C_AddOns.DoesAddOnExist("SpecialTalentUI") then
EventUtil.ContinueOnAddOnLoaded("SpecialTalentUI", CAPI_ApplyStandardColorGlobals)
end
+24 -44
View File
@@ -24,8 +24,14 @@ function ColorMixin:GetRGB()
return self.r, self.g, self.b
end
function ColorMixin:GetHSL()
local r, g, b, a = self.r, self.g, self.b, self.a
local h, s, l = C_ColorUtil.ConvertHSVToHSL(C_ColorUtil.ConvertRGBToHSV(r, g, b))
return h, s, l, a or 1
end
function ColorMixin:GetRGBAsBytes()
return self.r * 255, self.g * 255, self.b * 255
return Round(self.r * 255), Round(self.g * 255), Round(self.b * 255)
end
function ColorMixin:GetRGBA()
@@ -33,14 +39,14 @@ function ColorMixin:GetRGBA()
end
function ColorMixin:GetRGBAAsBytes()
return self.r * 255, self.g * 255, self.b * 255, (self.a or 1) * 255
return Round(self.r * 255), Round(self.g * 255), Round(self.b * 255), Round((self.a or 1) * 255)
end
function ColorMixin:SetRGBA(r, g, b, a)
self.r = r
self.g = g
self.b = b
self.a = a
self.a = a or 1
end
function ColorMixin:SetRGB(r, g, b)
@@ -48,56 +54,27 @@ function ColorMixin:SetRGB(r, g, b)
end
function ColorMixin:GenerateHexColor()
return string.format("ff%.2x%.2x%.2x", self:GetRGBAsBytes())
return C_ColorUtil.GenerateTextColorCode(self)
end
function ColorMixin:GenerateHexColorNoAlpha()
return string.format("%.2X%.2X%.2X", self:GetRGBAsBytes())
end
function ColorMixin:GenerateHexColorMarkup()
return "|c"..self:GenerateHexColor()
end
function ColorMixin:GenerateHexColorNoAlpha()
return string.format("%.2x%.2x%.2x", self:GetRGBAsBytes())
end
function ColorMixin:GetHSL()
local r, g, b, a = self.r, self.g, self.b, self.a
local max, min = math.max(r, g, b), math.min(r, g, b)
local h, s, l
l = (max + min) / 2
if max == min then
h, s = 0, 0 -- achromatic
else
local d = max - min
if l > 0.5 then
s = d / (2 - max - min)
else
s = d / (max + min)
end
if max == r then
h = (g - b) / d
if g < b then
h = h + 6
end
elseif max == g then
h = (b - r) / d + 2
elseif max == b then
h = (r - g) / d + 4
end
h = h / 6
end
return h, s, l, a or 1
end
function ColorMixin:WrapTextInColorCode(text)
return WrapTextInColorCode(text, self:GenerateHexColor())
return C_ColorUtil.WrapTextInColor(text, self)
end
function WrapTextInColorCode(text, colorHexString)
return string.format("|c%s%s|r", colorHexString, text)
return C_ColorUtil.WrapTextInColorCode(text, colorHexString)
end
function WrapTextInColor(text, color)
return WrapTextInColorCode(text, color:GenerateHexColor())
return C_ColorUtil.WrapTextInColor(text, color)
end
function ColorMixin:WrapTextInColorTableCode(text)
@@ -108,11 +85,14 @@ for _, v in pairs(ITEM_QUALITY_COLORS) do
v.color = CreateColor(v.r, v.g, v.b, 1)
end
do
function CAPI_ApplyStandardColorGlobals()
local envTbl = _G
for _, dbColor in ipairs(C_UIColor.GetColors()) do
local color = CreateColor(dbColor.color.r, dbColor.color.g, dbColor.color.b, dbColor.color.a)
envTbl[dbColor.baseTag] = color
envTbl[dbColor.baseTag.."_CODE"] = color:GenerateHexColorMarkup()
if not envTbl[dbColor.baseTag] or not envTbl[dbColor.baseTag].GetRGB then
local color = CreateColor(dbColor.color.r, dbColor.color.g, dbColor.color.b, dbColor.color.a)
envTbl[dbColor.baseTag] = color
envTbl[dbColor.baseTag.."_CODE"] = color:GenerateHexColorMarkup()
end
end
end
CAPI_ApplyStandardColorGlobals()
+26 -13
View File
@@ -1,3 +1,8 @@
COLOR_FORMAT_RGBA = "RRGGBBAA"
COLOR_FORMAT_RGB = "RRGGBB"
COLOR_FORMAT_ARGB = "AARRGGBB"
FONT_COLOR_CODE_CLOSE = "|r"
do
local ColorMixin = ColorMixin
for _, classColor in pairs(RAID_CLASS_COLORS) do
@@ -12,30 +17,40 @@ function ExtractColorValueFromHex(str, index)
end
function CreateColorFromHexString(hexColor)
if string.len(hexColor) == 8 then
if string.len(hexColor) == string.len(COLOR_FORMAT_ARGB) then
local a, r, g, b = ExtractColorValueFromHex(hexColor, 1), ExtractColorValueFromHex(hexColor, 3), ExtractColorValueFromHex(hexColor, 5), ExtractColorValueFromHex(hexColor, 7);
return CreateColor(r, g, b, a);
else
error("CreateColorFromHexString input must be hexadecimal digits in this format: AARRGGBB.", 2);
end
error("CreateColorFromHexString input must be hexadecimal digits in this format: "..COLOR_FORMAT_ARGB..".", 2);
return nil;
end
function CreateColorFromRGBAHexString(hexColor)
if string.len(hexColor) == 8 then
if string.len(hexColor) == string.len(COLOR_FORMAT_RGBA) then
local r, g, b, a = ExtractColorValueFromHex(hexColor, 1), ExtractColorValueFromHex(hexColor, 3), ExtractColorValueFromHex(hexColor, 5), ExtractColorValueFromHex(hexColor, 7);
return CreateColor(r, g, b, a);
else
error("CreateColorFromRGBAHexString input must be hexadecimal digits in this format: RRGGBBAA.", 2);
end
error("CreateColorFromRGBAHexString input must be hexadecimal digits in this format: "..COLOR_FORMAT_RGBA..".", 2);
return nil;
end
function CreateColorFromRGBHexString(hexColor)
if string.len(hexColor) == 6 then
if string.len(hexColor) == string.len(COLOR_FORMAT_RGB) then
local r, g, b = ExtractColorValueFromHex(hexColor, 1), ExtractColorValueFromHex(hexColor, 3), ExtractColorValueFromHex(hexColor, 5);
return CreateColor(r, g, b, 1);
else
error("CreateColorFromRGBHexString input must be hexadecimal digits in this format: RRGGBB.", 2);
end
error("CreateColorFromRGBHexString input must be hexadecimal digits in this format: "..COLOR_FORMAT_RGB..".", 2);
return nil;
end
function CreateColorFromBestRGBHexString(hexColor)
if string.len(hexColor) == string.len(COLOR_FORMAT_RGBA) then
return CreateColorFromRGBAHexString(hexColor);
elseif string.len(hexColor) == string.len(COLOR_FORMAT_RGB) then
return CreateColorFromRGBHexString(hexColor);
end
error("CreateColorFromBestRGBHexString input must be hexadecimal digits in either of these formats: "..COLOR_FORMAT_RGBA.." / "..COLOR_FORMAT_RGB..".", 2);
return nil;
end
function CreateColorFromBytes(r, g, b, a)
@@ -68,9 +83,7 @@ end
function GetClassColoredTextForUnit(unit, text)
local _, classFilename = UnitClass(unit);
local color = GetClassColorObj(classFilename);
if (color) then
return color:WrapTextInColorCode(text);
end
return color and color:WrapTextInColorCode(text) or text;
end
function GetFactionColor(factionGroupTag)
@@ -82,7 +95,7 @@ function GetFactionColor(factionGroupTag)
end
function RGBToColorCode(r, g, b)
return string.format("|cff%.2x%.2x%.2x", r * 255, g * 255, b * 255);
return string.format("|cff%02x%02x%02x", r * 255, g * 255, b * 255);
end
function RGBTableToColorCode(rgbTable)
-2
View File
@@ -15,8 +15,6 @@
-- - 1.12 has no concept of an item GUID and no public item-lock API,
-- so those return nil / no-op.
C_Item.GetItemInfo = GetItemInfo
----------------------------------------------------------------------
Item = {}
+38 -3
View File
@@ -11,12 +11,19 @@ local random = math.random
local atan2 = math.atan2
local sqrt = math.sqrt
math.huge = 1 / 0
MathUtil =
{
Epsilon = .000001,
};
function CreateCounter(initialCount)
MathUtil.ApproxZero = MathUtil.Epsilon;
MathUtil.ApproxOne = 1.0 - MathUtil.Epsilon;
function CreateCounter(initialCount, step)
local count = initialCount or 0
step = step or 1
return function()
count = count + 1
count = count + step
return count
end
end
@@ -130,3 +137,31 @@ end
function CalculateAngleBetween(x1, y1, x2, y2)
return atan2(y2 - y1, x2 - x1)
end
AccumulatorMixin = {};
function AccumulatorMixin:Init(initialCount)
self.count = initialCount or 0;
end
function AccumulatorMixin:Add(count)
self.count = self.count + count;
return self.count;
end
function AccumulatorMixin:Subtract(count)
self.count = self.count - count;
return self.count;
end
function AccumulatorMixin:Count()
return self.count;
end
function AccumulatorMixin:Reset(resetCount)
self.count = resetCount or 0;
end
function CreateAccumulator(initialCount)
return CreateAndInitFromMixin(AccumulatorMixin, initialCount);
end
+7 -20
View File
@@ -1,24 +1,11 @@
-- Backport of 3.3.5 FrameXML/Mixin.lua to vanilla 1.12 / Lua 5.0.
-- `Mixin` and `CreateFromMixins` are engine (DLL) natives — see
-- src/baselib/Mixin.cpp — so they exist whenever ClassicAPI is injected,
-- even with this addon disabled, mirroring retail where they live in
-- TableUtil as C functions. Only the composite helper (which calls a Lua
-- `:Init` method) stays here, like retail's Mixin.lua.
--
-- Implementation differences from the 3.3.5 source:
-- - Lua 5.0 has no select(); inside a vararg function the args are
-- gathered into an implicit `arg` table (`arg.n` for the count).
function Mixin(object, ...)
for i = 1, arg.n do
local mixin = arg[i]
if mixin then
for k, v in pairs(mixin) do
object[k] = v
end
end
end
return object
end
function CreateFromMixins(...)
return Mixin({}, unpack(arg))
end
-- Lua 5.0 note: varargs arrive in the implicit `arg` table (`arg.n` count),
-- so we forward them with unpack(arg) rather than 5.1's `...`.
function CreateAndInitFromMixin(mixin, ...)
local object = CreateFromMixins(mixin)
@@ -0,0 +1,228 @@
-- Backport of the modern `PlayerLocation` / `PlayerLocationMixin` parameter
-- object (Blizzard_ObjectAPI). Like `ItemLocation`, it's a lightweight
-- descriptor that wraps one of several ways to identify a player — a GUID, a
-- unit token, a chat line, a /who index, a battlefield-scoreboard index, etc.
-- — so retail/Classic addon code can construct and inspect it unchanged.
--
-- It's pure table bookkeeping: every Create/Set/Get/Is is just fields, no
-- engine access. The GUID / unit / chatLine / whoIndex / battlefieldScore
-- kinds all map to things vanilla 1.12 genuinely has. The community, voice,
-- and Battle.net kinds have no vanilla producer, but they're kept as harmless
-- data holders so the mixin matches retail exactly for any code that builds
-- or reads them. Only the two methods that reach into systems 1.12 lacks —
-- `IsValid` (Club) and `IsBattleNetGUID` (account info) — are guarded so they
-- degrade gracefully instead of erroring on the missing `C_Club` /
-- `C_AccountInfo` namespaces.
local CreateFromMixins = CreateFromMixins
PlayerLocation = PlayerLocation or {}
PlayerLocationMixin = PlayerLocationMixin or {}
--[[static]] function PlayerLocation:CreateFromGUID(guid)
local playerLocation = CreateFromMixins(PlayerLocationMixin)
playerLocation:SetGUID(guid)
return playerLocation
end
--[[static]] function PlayerLocation:CreateFromUnit(unit)
local playerLocation = CreateFromMixins(PlayerLocationMixin)
playerLocation:SetUnit(unit)
return playerLocation
end
--[[static]] function PlayerLocation:CreateFromChatLineID(lineID)
local playerLocation = CreateFromMixins(PlayerLocationMixin)
playerLocation:SetChatLineID(lineID)
return playerLocation
end
--[[static]] function PlayerLocation:CreateFromBattlefieldScoreIndex(battlefieldScoreIndex)
local playerLocation = CreateFromMixins(PlayerLocationMixin)
playerLocation:SetBattlefieldScoreIndex(battlefieldScoreIndex)
return playerLocation
end
--[[static]] function PlayerLocation:CreateFromWhoIndex(whoIndex)
local playerLocation = CreateFromMixins(PlayerLocationMixin)
playerLocation:SetWhoIndex(whoIndex)
return playerLocation
end
--[[static]] function PlayerLocation:CreateFromVoiceID(memberID, channelID)
local playerLocation = CreateFromMixins(PlayerLocationMixin)
playerLocation:SetVoiceID(memberID, channelID)
return playerLocation
end
--[[static]] function PlayerLocation:CreateFromBattleNetID(battleNetID)
local playerLocation = CreateFromMixins(PlayerLocationMixin)
playerLocation:SetBattleNetID(battleNetID)
return playerLocation
end
--[[static]] function PlayerLocation:CreateFromCommunityChatData(clubID, streamID, epoch, position)
local playerLocation = CreateFromMixins(PlayerLocationMixin)
playerLocation:SetCommunityData(clubID, streamID, epoch, position)
return playerLocation
end
--[[static]] function PlayerLocation:CreateFromCommunityInvitation(clubID, guid)
local playerLocation = CreateFromMixins(PlayerLocationMixin)
playerLocation:SetCommunityInvitation(clubID, guid)
return playerLocation
end
--[[public api]]
function PlayerLocationMixin:SetGUID(guid)
self:ClearAndSetField("guid", guid)
end
function PlayerLocationMixin:IsValid()
if self:IsCommunityData() then
-- Vanilla has no Community/Club system; if the namespace is
-- absent a community location can never be resolved.
if C_Club and C_Club.CanResolvePlayerLocationFromClubMessageData then
return C_Club.CanResolvePlayerLocationFromClubMessageData(self.communityClubID, self.communityStreamID, self.communityEpoch, self.communityPosition)
end
return false
end
return true
end
function PlayerLocationMixin:IsGUID()
return self.guid ~= nil
end
function PlayerLocationMixin:IsBattleNetGUID()
-- No Battle.net accounts on 1.12; guarded so it returns false instead
-- of erroring on a missing C_AccountInfo.
if self.guid and C_AccountInfo and C_AccountInfo.IsGUIDBattleNetAccountType then
return C_AccountInfo.IsGUIDBattleNetAccountType(self.guid)
end
return false
end
function PlayerLocationMixin:GetGUID()
return self.guid or self.communityClubInviterGUID
end
function PlayerLocationMixin:SetUnit(unit)
self:ClearAndSetField("unit", unit)
end
function PlayerLocationMixin:IsUnit()
return self.unit ~= nil
end
function PlayerLocationMixin:GetUnit()
return self.unit
end
function PlayerLocationMixin:SetChatLineID(lineID)
self:ClearAndSetField("chatLineID", lineID)
end
function PlayerLocationMixin:IsChatLineID()
return self.chatLineID ~= nil
end
function PlayerLocationMixin:GetChatLineID()
return self.chatLineID
end
function PlayerLocationMixin:SetBattlefieldScoreIndex(index)
self:ClearAndSetField("battlefieldScoreIndex", index)
end
function PlayerLocationMixin:IsBattlefieldScoreIndex()
return self.battlefieldScoreIndex ~= nil
end
function PlayerLocationMixin:GetBattlefieldScoreIndex()
return self.battlefieldScoreIndex
end
function PlayerLocationMixin:SetWhoIndex(whoIndex)
self:ClearAndSetField("whoIndex", whoIndex)
end
function PlayerLocationMixin:IsWhoIndex()
return self.whoIndex ~= nil
end
function PlayerLocationMixin:GetWhoIndex()
return self.whoIndex
end
function PlayerLocationMixin:SetVoiceID(memberID, channelID)
self:Clear()
self.voiceMemberID = memberID
self.voiceChannelID = channelID
end
function PlayerLocationMixin:IsVoiceID()
return self.voiceMemberID ~= nil and self.voiceChannelID ~= nil
end
function PlayerLocationMixin:GetVoiceID()
return self.voiceMemberID, self.voiceChannelID
end
function PlayerLocationMixin:SetBattleNetID(battleNetID)
self:Clear()
self.battleNetID = battleNetID
end
function PlayerLocationMixin:IsBattleNetID()
return self.battleNetID ~= nil
end
function PlayerLocationMixin:GetBattleNetID()
return self.battleNetID
end
function PlayerLocationMixin:SetCommunityData(clubID, streamID, epoch, position)
self:Clear()
self.communityClubID = clubID
self.communityStreamID = streamID
self.communityEpoch = epoch
self.communityPosition = position
end
function PlayerLocationMixin:IsCommunityData()
return self.communityClubID ~= nil and self.communityStreamID ~= nil and self.communityEpoch ~= nil and self.communityPosition ~= nil
end
function PlayerLocationMixin:SetCommunityInvitation(clubID, guid)
self:Clear()
self.communityClubID = clubID
self.communityClubInviterGUID = guid
end
function PlayerLocationMixin:IsCommunityInvitation()
return self.communityClubID ~= nil and self.communityClubInviterGUID ~= nil
end
--[[private api]]
function PlayerLocationMixin:Clear()
self.guid = nil
self.unit = nil
self.chatLineID = nil
self.battlefieldScoreIndex = nil
self.whoIndex = nil
self.voiceMemberID = nil
self.voiceChannelID = nil
self.communityClubID = nil
self.communityStreamID = nil
self.communityEpoch = nil
self.communityPosition = nil
self.communityClubInviterGUID = nil
self.battleNetID = nil
end
function PlayerLocationMixin:ClearAndSetField(fieldName, field)
self:Clear()
self[fieldName] = field
end
+1 -1
View File
@@ -28,7 +28,7 @@ function PlayerUtil.GetClassColor()
end
function PlayerUtil.GetCurrentSpeed()
return GetUnitSpeed("player") / 7 * 100
return GetUnitSpeed("player") / BASE_MOVEMENT_SPEED * 100
end
function GetPlayerGuid()
+141
View File
@@ -0,0 +1,141 @@
RectangleMixin = {};
function CreateRectangle(left, right, top, bottom)
local rectangle = CreateFromMixins(RectangleMixin);
rectangle:OnLoad(left, right, top, bottom);
return rectangle;
end
function RectangleMixin:OnLoad(left, right, top, bottom)
self:SetSides(left or 0.0, right or 0.0, top or 0.0, bottom or 0.0);
end
function RectangleMixin:SetSides(left, right, top, bottom)
self.left = left;
self.right = right;
self.top = top;
self.bottom = bottom;
end
function RectangleMixin:Reset()
self.left = 0.0;
self.right = 0.0;
self.top = 0.0;
self.bottom = 0.0;
end
function RectangleMixin:Stretch(x, y)
self:Adjust(-x, x, -y, y);
end
function RectangleMixin:Move(x, y)
self:Adjust(x, x, y, y);
end
function RectangleMixin:Adjust(left, right, top, bottom)
self.left = self.left + left;
self.right = self.right + right;
self.top = self.top + top;
self.bottom = self.bottom + bottom;
end
function RectangleMixin:IsEmpty()
return self.left == self.right or self.top == self.bottom;
end
function RectangleMixin:IsInsideOut()
return self.left > self.right or self.top > self.bottom;
end
function RectangleMixin:EnclosesPoint(x, y)
return x >= self.left and x <= self.right and y >= self.top and y <= self.bottom;
end
function RectangleMixin:EnclosesRect(otherRect)
return self:EnclosesPoint(otherRect:GetLeft(), otherRect:GetTop()) and self:EnclosesPoint(otherRect:GetRight(), otherRect:GetBottom());
end
function RectangleMixin:IntersectsRect(otherRect)
return not (
self.left > otherRect.right or
self.right < otherRect.left or
self.top > otherRect.bottom or
self.bottom < otherRect.top
);
end
function RectangleMixin:GetTop()
return self.top;
end
function RectangleMixin:GetBottom()
return self.bottom;
end
function RectangleMixin:GetLeft()
return self.left;
end
function RectangleMixin:GetRight()
return self.right;
end
function RectangleMixin:GetWidth()
return self.right - self.left;
end
function RectangleMixin:GetHeight()
return self.bottom - self.top;
end
function RectangleMixin:GetCenter()
return Lerp(self.left, self.right, .5), Lerp(self.top, self.bottom, .5);
end
function RectangleMixin:GetCenterH()
return Lerp(self.left, self.right, .5);
end
function RectangleMixin:GetCenterV()
return Lerp(self.top, self.bottom, .5);
end
function RectangleMixin:SetTop(top)
self.top = top;
end
function RectangleMixin:SetBottom(bottom)
self.bottom = bottom;
end
function RectangleMixin:SetLeft(left)
self.left = left;
end
function RectangleMixin:SetRight(right)
self.right = right;
end
function RectangleMixin:SetWidth(width)
self.right = self.left + width;
end
function RectangleMixin:SetHeight(height)
self.bottom = self.top + height;
end
function RectangleMixin:SetSize(width, height)
self:SetWidth(width);
self:SetHeight(height);
end
function RectangleMixin:SetCenter(x, y)
local width = self:GetWidth();
local height = self:GetHeight();
self.left = x - width * .5;
self.right = x + width * .5;
self.top = y - height * .5;
self.bottom = y + height * .5;
end
-13
View File
@@ -1,13 +0,0 @@
function string.match(s, pattern, init)
if s == nil then return nil end
local r1, r2, c1, c2, c3, c4, c5, c6, c7, c8, c9 =
string.find(s, pattern, init)
if r1 == nil then return nil end
if c1 ~= nil then
return c1, c2, c3, c4, c5, c6, c7, c8, c9
end
return string.sub(s, r1, r2)
end
string.gmatch = string.gfind
+216 -66
View File
@@ -36,7 +36,9 @@ local LO = TURTLE and {
summary = { "TOPRIGHT", -110, -70 },
drop = { 40, -60 },
scroll = { 296, 330, 37, -98 },
detail = { 379, -113 },
-- detail scroll frame: { x, y, width, height }. Tall enough that the
-- content fits (scrollbar auto-hides) — Turtle's right column is roomy.
detail = { 371, -110, 340, 290 },
exit = { "CENTER", "BOTTOMRIGHT", -128, 92 },
} or {
hit = { 0, 34, 0, 75 },
@@ -52,10 +54,32 @@ local LO = TURTLE and {
summary = { "TOPRIGHT", -40, -58 },
drop = { 12, -62 },
scroll = { 296, 130, 21, -96 },
detail = { 28, -237 },
-- detail scroll frame { x, y, width, height } — native TradeSkillFrame's
-- TradeSkillDetailScrollFrame values. Small enough that many-reagent
-- recipes scroll (clipping partial rows), like native.
detail = { 20, -234, 297, 176 },
exit = { "CENTER", "TOPLEFT", 305, -422 },
sep = { 15, -221, 331 },
};
-- Reagents render in a 2-column grid inside the detail scroll child (see
-- the detail pane below). The detail is a real ScrollFrame like the
-- native TradeSkillFrame, so the whole pane pixel-scrolls and clips
-- partial reagent rows — `REAGENT_ROW_HEIGHT` is just the vertical stride
-- of one reagent row.
local REAGENT_ROW_HEIGHT = 43;
-- Scroll-frame templates. Stock mimics the native TradeSkillFrame, whose
-- list/detail scrolls use the ClassTrainer templates (they add the
-- decorative UI-ClassTrainer-ScrollBar track). Turtle's HD frame art
-- doesn't want that groove, so it keeps the plain templates. Both detail
-- templates are real ScrollFrames (ClassTrainerDetail... inherits
-- UIPanelScrollFrameTemplate), so the pixel-scroll logic is identical.
local LIST_SCROLL_TEMPLATE = TURTLE and "FauxScrollFrameTemplate"
or "ClassTrainerListScrollFrameTemplate";
local DETAIL_SCROLL_TEMPLATE = TURTLE and "UIPanelScrollFrameTemplate"
or "ClassTrainerDetailScrollFrameTemplate";
local PROFESSION_ICONS = {
[171] = "Trade_Alchemy",
[164] = "Trade_BlackSmithing",
@@ -224,7 +248,7 @@ end
local function CreateFrame_TradeSkillLink()
local f = CreateFrame("Frame", "ClassicAPITradeSkillLinkFrame", UIParent);
f:SetSize(FRAME_W, FRAME_H);
f:SetPoint("CENTER", 0, 0);
f:SetPoint("CENTER");
f:SetMovable(true);
f:EnableMouse(true);
f:SetHitRectInsets(LO.hit[1], LO.hit[2], LO.hit[3], LO.hit[4]);
@@ -235,6 +259,9 @@ local function CreateFrame_TradeSkillLink()
f:Hide();
tinsert(UISpecialFrames, f:GetName())
f:SetScript("OnShow", function() PlaySound("igCharacterInfoOpen"); end);
f:SetScript("OnHide", function() PlaySound("igCharacterInfoClose"); end);
local portrait = f:CreateTexture(nil, "BACKGROUND");
portrait:SetSize(60, 60);
portrait:SetPoint("TOPLEFT", 7, -6);
@@ -248,6 +275,20 @@ local function CreateFrame_TradeSkillLink()
tx:SetPoint(q[4]);
end
-- Horizontal divider between the recipe list and the detail pane.
-- Stock only (Turtle's HD frame art already has its own).
if LO.sep then
-- Decoded the BLP: it's a thin divider rod whose graphic lives in the
-- top quarter of the 256x64 texture (a 2nd rod fills the next quarter,
-- bottom half empty). So sample the top quarter, stretched to width —
-- the same region the native TradeSkillFrame uses for this divider.
local bar = f:CreateTexture(nil, "ARTWORK");
bar:SetTexture("Interface\\ClassTrainerFrame\\UI-ClassTrainer-HorizontalBar");
bar:SetTexCoord(0, 1, 0, 0.25);
bar:SetSize(LO.sep[3], 16);
bar:SetPoint("TOPLEFT", LO.sep[1], LO.sep[2]);
end
local title = f:CreateFontString(nil, "OVERLAY", "GameFontNormal");
title:SetPoint("TOP", 0, -18);
f.title = title;
@@ -258,7 +299,7 @@ local function CreateFrame_TradeSkillLink()
local exit = CreateFrame("Button", nil, f, "UIPanelButtonTemplate");
exit:SetSize(80, 22);
exit:SetPoint(LO.exit[1], f, LO.exit[2], LO.exit[3], LO.exit[4]);
exit:SetText(EXIT or "Exit");
exit:SetText(EXIT);
exit:SetScript("OnClick", function() f:Hide(); end);
local bar = CreateFrame("StatusBar", nil, f);
@@ -300,7 +341,7 @@ local function CreateFrame_TradeSkillLink()
f.subclassFilter = nil; -- nil = all subclasses
local scroll = CreateFrame("ScrollFrame", "ClassicAPITradeSkillLinkScroll",
f, "FauxScrollFrameTemplate");
f, LIST_SCROLL_TEMPLATE);
scroll:SetSize(LO.scroll[1], LO.scroll[2]);
scroll:SetPoint("TOPLEFT", LO.scroll[3], LO.scroll[4]);
scroll:SetScript("OnVerticalScroll", function()
@@ -346,17 +387,38 @@ local function CreateFrame_TradeSkillLink()
f.rows[i] = row;
end
local NUM_REAGENTS = 8;
-- Detail pane: a real ScrollFrame (like the native TradeSkillFrame's
-- TradeSkillDetailScrollFrame) whose scroll child holds the product
-- header + reagent grid, so the whole pane pixel-scrolls — clipping
-- partial reagent rows — with the standard UIPanelScrollBar. The bar
-- auto-hides when the content fits (scrollBarHideable).
local detailScroll = CreateFrame("ScrollFrame",
"ClassicAPITradeSkillLinkDetailScroll", f, DETAIL_SCROLL_TEMPLATE);
detailScroll:SetSize(LO.detail[3], LO.detail[4]);
detailScroll:SetPoint("TOPLEFT", LO.detail[1], LO.detail[2]);
-- Bar visibility + wheel are driven per-render by SetDetailBarShown (from
-- the computed overflow). `scrollBarHideable` still lets the template's
-- ScrollFrame_OnScrollRangeChanged manage the slider on range changes.
-- Start with the wheel off; the first RenderDetail/ClearDetail sets it.
detailScroll.scrollBarHideable = 1;
detailScroll:EnableMouseWheel(false);
f.detailScroll = detailScroll;
local prodIcon = CreateFrame("Button", nil, f);
prodIcon:SetSize(37, 37); -- TradeSkillSkillIcon is 37x37
prodIcon:SetPoint("TOPLEFT", LO.detail[1], LO.detail[2]);
local detailChild = CreateFrame("Frame",
"ClassicAPITradeSkillLinkDetailChild", detailScroll);
detailChild:SetSize(LO.detail[3], LO.detail[4]);
detailScroll:SetScrollChild(detailChild);
f.detailChild = detailChild;
local prodIcon = CreateFrame("Button", nil, detailChild);
prodIcon:SetSize(37, 37); -- native TradeSkillSkillIcon (37x37 @ 8,-3)
prodIcon:SetPoint("TOPLEFT", 8, -3);
prodIcon:SetNormalTexture("Interface\\Icons\\INV_Misc_QuestionMark");
local hdrLeft = prodIcon:CreateTexture(nil, "BACKGROUND");
local hdrLeft = detailChild:CreateTexture(nil, "BACKGROUND");
hdrLeft:SetTexture("Interface\\ClassTrainerFrame\\UI-ClassTrainer-DetailHeaderLeft");
hdrLeft:SetSize(256, 64); -- Blizzard's native fill width (+64 cap = 320 total)
hdrLeft:SetPoint("TOPLEFT", prodIcon, "TOPLEFT", -8, 6);
local hdrRight = prodIcon:CreateTexture(nil, "BACKGROUND");
local hdrRight = detailChild:CreateTexture(nil, "BACKGROUND");
hdrRight:SetTexture("Interface\\ClassTrainerFrame\\UI-ClassTrainer-DetailHeaderRight");
hdrRight:SetSize(64, 64);
hdrRight:SetPoint("TOPLEFT", hdrLeft, "TOPRIGHT", 0, 0);
@@ -365,44 +427,65 @@ local function CreateFrame_TradeSkillLink()
prodIcon.count = prodCount;
f.prodIcon = prodIcon;
local prodName = prodIcon:CreateFontString(nil, "ARTWORK", "GameFontNormal");
local prodName = detailChild:CreateFontString(nil, "ARTWORK", "GameFontNormal");
prodName:SetPoint("LEFT", prodIcon, "RIGHT", 5, 0);
prodName:SetWidth(240);
prodName:SetWidth(LO.detail[3] - 60);
prodName:SetJustifyH("LEFT");
prodName:SetJustifyV("MIDDLE");
f.prodName = prodName;
local prodDesc = f:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall");
local prodDesc = detailChild:CreateFontString(nil, "ARTWORK", "GameFontHighlightSmall");
prodDesc:SetPoint("TOPLEFT", prodIcon, "BOTTOMLEFT", 0, -6);
prodDesc:SetWidth(280);
prodDesc:SetWidth(LO.detail[3] - 20);
prodDesc:SetJustifyH("LEFT");
prodDesc:SetJustifyV("TOP");
f.prodDesc = prodDesc;
local reagentLabel = f:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall");
local reagentLabel = detailChild:CreateFontString(nil, "ARTWORK", "GameFontNormalSmall");
reagentLabel:SetPoint("TOPLEFT", prodDesc, "BOTTOMLEFT", 0, -8);
reagentLabel:SetText(SPELL_REAGENTS or "Reagents:");
f.reagentLabel = reagentLabel;
prodIcon:SetScript("OnEnter", function()
GameTooltip:SetOwner(this, "ANCHOR_RIGHT");
if this.itemLink then
GameTooltip:SetHyperlink(this.itemLink);
elseif this.spellID then
GameTooltip:SetSpellByID(this.spellID);
end
GameTooltip:Show();
end);
prodIcon:SetScript("OnLeave", GameTooltip_Hide);
-- Reagent buttons live in the scroll child (2 per row), built on
-- demand so any reagent count is handled and they scroll/clip with
-- the child.
f.reagents = {};
for i = 1, NUM_REAGENTS do
local function AcquireReagent(i)
if f.reagents[i] then
return f.reagents[i];
end
local rowN = math.floor((i - 1) / 2);
local col = (i - 1) - rowN * 2;
local btn = CreateFrame("Button", nil, f);
local btn = CreateFrame("Button", nil, detailChild);
btn:SetSize(39, 39);
btn:SetPoint("TOPLEFT", reagentLabel, "BOTTOMLEFT",
col * 147 - 5, -3 - rowN * 43);
col * 134 - 5, -3 - rowN * REAGENT_ROW_HEIGHT);
-- Extend the hit area rightward over the name plate so the tooltip
-- fires anywhere on the element, not just the icon. Stop short of the
-- next column (pitch 134 − icon 39) so hovers don't bleed across.
btn:SetHitRectInsets(0, -(134 - 39), 0, 0);
btn:SetNormalTexture("Interface\\Icons\\INV_Misc_QuestionMark");
local plate = btn:CreateTexture(nil, "BACKGROUND");
plate:SetTexture("Interface\\QuestFrame\\UI-QuestItemNameFrame");
plate:SetSize(128, 64);
plate:SetSize(114, 64);
plate:SetPoint("LEFT", btn, "RIGHT", -10, 0);
local cnt = btn:CreateFontString(nil, "ARTWORK", "NumberFontNormal");
cnt:SetPoint("BOTTOMRIGHT", -4, 1);
btn.count = cnt;
local nm = btn:CreateFontString(nil, "ARTWORK", "GameFontHighlight");
nm:SetPoint("LEFT", plate, "LEFT", 15, 0);
nm:SetSize(90, 36);
nm:SetSize(80, 36);
nm:SetJustifyH("LEFT");
nm:SetJustifyV("MIDDLE");
btn.nameText = nm;
@@ -414,8 +497,24 @@ local function CreateFrame_TradeSkillLink()
end
end);
btn:SetScript("OnLeave", GameTooltip_Hide);
btn:Hide();
f.reagents[i] = btn;
return btn;
end
-- Show the detail bar/borders only when the content overflows the pane.
-- Driven by our own computed overflow, NOT the engine: scrollBarHideable's
-- auto-hide only fires on a range CHANGE (so content that fits from the
-- start never hides), and GetVerticalScrollRange isn't reliably readable
-- the same tick as UpdateScrollChildRect.
function f:SetDetailBarShown(show)
self.detailScroll:EnableMouseWheel(show);
local base = "ClassicAPITradeSkillLinkDetailScroll";
local sb = getglobal(base .. "ScrollBar");
if sb then sb:SetShown(show) end
local top = getglobal(base .. "Top");
if top then top:SetShown(show) end
local bottom = getglobal(base .. "Bottom");
if bottom then bottom:SetShown(show) end
end
function f:RenderDetail(entry)
@@ -442,11 +541,9 @@ local function CreateFrame_TradeSkillLink()
prodText = entry.name;
prodTex = C_Spell.GetSpellTexture(entry.spellID);
end
if created > 0 then
self.prodDesc:SetText("");
else
self.prodDesc:SetText(C_Spell.GetSpellDescription(entry.spellID) or "");
end
local descText = (created > 0) and ""
or (C_Spell.GetSpellDescription(entry.spellID) or "");
self.prodDesc:SetText(descText);
self.prodIcon:SetNormalTexture(prodTex or
"Interface\\Icons\\INV_Misc_QuestionMark");
self.prodName:SetText(prodText or "");
@@ -457,50 +554,71 @@ local function CreateFrame_TradeSkillLink()
self.prodIcon.count:SetText(made > 1 and tostring(made) or "");
self.prodIcon:Show();
-- Fill every reagent slot; the scroll child clips/scrolls the
-- overflow. Buttons past the count are hidden.
local reagents = entry.reagents or {};
for i = 1, NUM_REAGENTS do
local btn = self.reagents[i];
local r = reagents[i];
if r then
local name, tex, link = itemInfo(r.itemID);
btn:SetNormalTexture(tex or
"Interface\\Icons\\INV_Misc_QuestionMark");
btn.itemLink = link;
btn.nameText:SetText(name or ("item:" .. r.itemID));
local need = r.count or 1;
local have = C_Item.GetItemCount(r.itemID) or 0;
if have >= need then
btn:GetNormalTexture():SetVertexColor(1, 1, 1);
btn.nameText:SetTextColor(HIGHLIGHT_FONT_COLOR:GetRGB());
else
btn:GetNormalTexture():SetVertexColor(0.5, 0.5, 0.5);
btn.nameText:SetTextColor(GRAY_FONT_COLOR:GetRGB());
end
local haveText = (have >= 100) and "*" or tostring(have);
btn.count:SetText(haveText .. " /" .. need);
btn:Show();
local total = table.getn(reagents);
for i = 1, total do
local btn = AcquireReagent(i);
local reagent = reagents[i];
local name, tex, link = itemInfo(reagent.itemID);
btn:SetNormalTexture(tex or "Interface\\Icons\\INV_Misc_QuestionMark");
btn.itemLink = link;
btn.nameText:SetText(name or ("item:" .. reagent.itemID));
local need = reagent.count or 1;
local have = C_Item.GetItemCount(reagent.itemID) or 0;
if have >= need then
local r, g, b = HIGHLIGHT_FONT_COLOR:GetRGB()
btn:GetNormalTexture():SetVertexColor(r, g, b);
btn.nameText:SetTextColor(r, g, b);
else
btn.itemLink = nil;
btn:Hide();
local r, g, b = GRAY_FONT_COLOR:GetRGB()
btn:GetNormalTexture():SetVertexColor(r, g, b);
btn.nameText:SetTextColor(r, g, b);
end
local haveText = (have >= 100) and "*" or tostring(have);
btn.count:SetText(haveText .. " /" .. need);
btn:Show();
end
for i = total + 1, table.getn(self.reagents) do
self.reagents[i].itemLink = nil;
self.reagents[i]:Hide();
end
end
prodIcon:SetScript("OnEnter", function()
GameTooltip:SetOwner(this, "ANCHOR_RIGHT");
if this.itemLink then
GameTooltip:SetHyperlink(this.itemLink);
elseif this.spellID then
GameTooltip:SetSpellByID(this.spellID);
local function applyContentHeight()
local contentH = LO.detail[4];
if total > 0 then
local childTop = self.detailChild:GetTop();
local lastBtn = self.reagents[total];
local lastBottom = lastBtn and lastBtn:GetBottom();
if childTop and lastBottom then
-- +22: ~12px name-plate overhang below the icon + ~10px pad.
contentH = (childTop - lastBottom) + 22;
end
end
self.detailChild:SetHeight(math.max(contentH, LO.detail[4]));
-- Recompute the scroll range from the child's new height, then
-- show the bar only if the content actually overflows the pane.
self.detailScroll:UpdateScrollChildRect();
self:SetDetailBarShown(contentH > LO.detail[4] + 0.5);
end
GameTooltip:Show();
end);
prodIcon:SetScript("OnLeave", GameTooltip_Hide);
applyContentHeight();
local sb = getglobal("ClassicAPITradeSkillLinkDetailScrollScrollBar");
if sb then
sb:SetValue(0);
end
self.detailScroll:SetVerticalScroll(0);
RunNextFrame(function()
if self:IsShown() and self.selectedSpellID == entry.spellID then
applyContentHeight();
end
end)
end
function f:SelectRecipe(entry)
self.selectedSpellID = entry.spellID;
self.selectedEntry = entry;
self:RenderDetail(entry);
self:RenderDetail(entry); -- also resets the detail scroll to top
self:Refresh();
end
@@ -510,9 +628,14 @@ local function CreateFrame_TradeSkillLink()
self.prodIcon:Hide();
self.prodName:SetText("");
self.prodDesc:SetText("");
for i = 1, NUM_REAGENTS do
for i = 1, table.getn(self.reagents) do
self.reagents[i].itemLink = nil;
self.reagents[i]:Hide();
end
self.detailChild:SetHeight(LO.detail[4]);
self.detailScroll:UpdateScrollChildRect();
self:SetDetailBarShown(false);
self.detailScroll:SetVerticalScroll(0);
end
function f:Refresh()
@@ -664,9 +787,24 @@ local function CreateProfessionLinkButton(parentName, getLink)
if _G[btnName] or not parent then
return;
end
local title = _G[parentName .. "TitleText"];
local btn = CreateFrame("Button", btnName, parent);
btn:SetSize(16, 16);
btn:SetPoint("LEFT", _G[parentName .. "TitleText"], "RIGHT", 4, 0);
local function reposition()
if not title then
return;
end
btn:ClearAllPoints();
if title:GetJustifyH() == "LEFT" then
btn:SetPoint("LEFT", title, "LEFT", title:GetStringWidth() + 4, 0);
else
btn:SetPoint("LEFT", title, "CENTER", title:GetStringWidth() / 2 + 4, 0);
end
end
reposition()
btn:SetScript("OnShow", function() RunNextFrame(reposition) end)
hooksecurefunc(title, "SetText", function() RunNextFrame(reposition) end)
local icon = "Interface\\GossipFrame\\PetitionGossipIcon";
btn:SetNormalTexture(icon);
@@ -692,8 +830,20 @@ end
EventUtil.ContinueOnAddOnLoaded("Blizzard_TradeSkillUI", function()
CreateProfessionLinkButton("TradeSkillFrame", C_TradeSkillUI.GetTradeSkillListLink);
end);
end)
EventUtil.ContinueOnAddOnLoaded("Blizzard_CraftUI", function()
CreateProfessionLinkButton("CraftFrame", C_TradeSkillUI.GetCraftListLink);
end);
CreateProfessionLinkButton("CraftFrame", C_TradeSkillUI.GetCraftListLink)
end)
EventUtil.ContinueOnAddOnLoaded("AdvancedTradeSkillWindow", function()
CreateProfessionLinkButton("ATSWFrame", function()
return atsw_oldmode and C_TradeSkillUI.GetCraftListLink() or C_TradeSkillUI.GetTradeSkillListLink()
end)
end)
EventUtil.ContinueOnAddOnLoaded("AdvancedTradeSkillWindow2", function()
CreateProfessionLinkButton("ATSWFrame", function()
return ATSW_TradeSkill() and C_TradeSkillUI.GetTradeSkillListLink() or C_TradeSkillUI.GetCraftListLink()
end)
end)
+142
View File
@@ -0,0 +1,142 @@
-- Verbatim backport of retail's Blizzard_SharedXML/Vector2D.lua. Pure vector
-- math on CreateFromMixins (a ClassicAPI C native) + Lua 5.0 math.*; no 1.12
-- adaptation needed. (Retail's "used in secure code" note doesn't apply here —
-- vanilla has no taint system.)
local cos = math.cos;
local sin = math.sin;
local atan2 = math.atan2;
local sqrt = math.sqrt;
function Vector2D_ScaleBy(scalar, x, y)
return x * scalar, y * scalar;
end
function Vector2D_DivideBy(divisor, x, y)
return x / divisor, y / divisor;
end
function Vector2D_Add(leftX, leftY, rightX, rightY)
return leftX + rightX, leftY + rightY;
end
function Vector2D_Subtract(leftX, leftY, rightX, rightY)
return leftX - rightX, leftY - rightY;
end
function Vector2D_Cross(leftX, leftY, rightX, rightY)
return leftX * rightY - leftY * rightX;
end
function Vector2D_Dot(leftX, leftY, rightX, rightY)
return leftX * rightX + leftY * rightY;
end
function Vector2D_GetLengthSquared(x, y)
return Vector2D_Dot(x, y, x, y);
end
function Vector2D_GetLength(x, y)
return sqrt(Vector2D_GetLengthSquared(x, y));
end
function Vector2D_Normalize(x, y)
return Vector2D_DivideBy(Vector2D_GetLength(x, y), x, y);
end
function Vector2D_CalculateAngleBetween(leftX, leftY, rightX, rightY)
return atan2(Vector2D_Cross(leftX, leftY, rightX, rightY), Vector2D_Dot(leftX, leftY, rightX, rightY));
end
function Vector2D_RotateDirection(rotationRadians, x, y)
local cosValue = cos(rotationRadians);
local sinValue = sin(rotationRadians);
return x * cosValue - y * sinValue, x * sinValue + y * cosValue;
end
Vector2DMixin = {};
function CreateVector2D(x, y)
local vector = CreateFromMixins(Vector2DMixin);
vector:OnLoad(x, y);
return vector;
end
function AreVector2DEqual(left, right)
if left and right then
return left:IsEqualTo(right);
end
return left == right;
end
function Vector2DMixin:OnLoad(x, y)
self:SetXY(x, y);
end
function Vector2DMixin:IsEqualTo(otherVector)
return self.x == otherVector.x
and self.y == otherVector.y;
end
function Vector2DMixin:GetXY()
return self.x, self.y;
end
function Vector2DMixin:SetXY(x, y)
self.x = x;
self.y = y;
end
function Vector2DMixin:ScaleBy(scalar)
self:SetXY(Vector2D_ScaleBy(scalar, self:GetXY()));
return self;
end
function Vector2DMixin:DivideBy(scalar)
self:SetXY(Vector2D_DivideBy(scalar, self:GetXY()));
return self;
end
function Vector2DMixin:Add(other)
self:SetXY(Vector2D_Add(self.x, self.y, other:GetXY()));
return self;
end
function Vector2DMixin:Subtract(other)
self:SetXY(Vector2D_Subtract(self.x, self.y, other:GetXY()));
return self;
end
function Vector2DMixin:Cross(other)
self:SetXY(Vector2D_Cross(self.x, self.y, other:GetXY()));
return self;
end
function Vector2DMixin:Dot(other)
return Vector2D_Dot(self.x, self.y, other:GetXY());
end
function Vector2DMixin:IsZero()
return self.x == 0 and self.y == 0;
end
function Vector2DMixin:GetLengthSquared()
return Vector2D_GetLengthSquared(self:GetXY());
end
function Vector2DMixin:GetLength()
return Vector2D_GetLength(self:GetXY());
end
function Vector2DMixin:Normalize()
self:SetXY(Vector2D_Normalize(self:GetXY()));
return self;
end
function Vector2DMixin:RotateDirection(rotationRadians)
self:SetXY(Vector2D_RotateDirection(rotationRadians, self:GetXY()));
return self;
end
function Vector2DMixin:Clone()
return CreateVector2D(self:GetXY());
end
+173
View File
@@ -0,0 +1,173 @@
-- Verbatim backport of retail's Blizzard_SharedXML/Vector3D.lua. Pure vector
-- math on CreateFromMixins (a ClassicAPI C native) + Lua 5.0 math.*; no 1.12
-- adaptation needed. (Retail's "used in secure code" note doesn't apply here —
-- vanilla has no taint system.)
local cos = math.cos;
local sin = math.sin;
local atan2 = math.atan2;
local asin = math.asin;
local sqrt = math.sqrt;
function Vector3D_ScaleBy(scalar, x, y, z)
return x * scalar, y * scalar, z * scalar;
end
function Vector3D_DivideBy(divisor, x, y, z)
return x / divisor, y / divisor, z / divisor;
end
function Vector3D_Add(leftX, leftY, leftZ, rightX, rightY, rightZ)
return leftX + rightX, leftY + rightY, leftZ + rightZ;
end
function Vector3D_Subtract(leftX, leftY, leftZ, rightX, rightY, rightZ)
return leftX - rightX, leftY - rightY, leftZ - rightZ;
end
function Vector3D_Cross(leftX, leftY, leftZ, rightX, rightY, rightZ)
return leftY * rightZ - leftZ * rightY, leftZ * rightX - leftX * rightZ, leftX * rightY - leftY * rightX;
end
function Vector3D_Dot(leftX, leftY, leftZ, rightX, rightY, rightZ)
return leftX * rightX + leftY * rightY + leftZ * rightZ;
end
function Vector3D_GetLengthSquared(x, y, z)
return Vector3D_Dot(x, y, z, x, y, z);
end
function Vector3D_GetLength(x, y, z)
return sqrt(Vector3D_GetLengthSquared(x, y, z));
end
function Vector3D_Normalize(x, y, z)
return Vector3D_DivideBy(Vector3D_GetLength(x, y, z), x, y, z);
end
function Vector3D_AddVector(left, right)
local clone = left:Clone();
clone:Add(right);
return clone;
end
function Vector3D_SubtractVector(left, right)
local clone = left:Clone();
clone:Subtract(right);
return clone;
end
function Vector3D_NormalizeVector(vector)
local clone = vector:Clone();
clone:Normalize();
return clone;
end
function Vector3D_ScaleVector(scalar, vector)
local clone = vector:Clone();
clone:ScaleBy(scalar);
return clone;
end
function Vector3D_CalculateNormalFromYawPitch(yaw, pitch)
return cos(-pitch) * cos(yaw),
cos(-pitch) * sin(yaw),
sin(-pitch);
end
function Vector3D_CalculateYawPitchFromNormal(x, y, z)
if x ~= 0 or y ~= 0 then
return atan2(y, x), asin(-z);
end
return 0, asin(-z);
end
function Vector3D_CalculateYawPitchFromNormalVector(vector)
return Vector3D_CalculateYawPitchFromNormal(vector:GetXYZ());
end
function Vector3D_CreateNormalVectorFromYawPitch(yawRadians, pitchRadians)
return CreateVector3D(Vector3D_CalculateNormalFromYawPitch(yawRadians, pitchRadians));
end
Vector3DMixin = {};
function CreateVector3D(x, y, z)
local vector = CreateFromMixins(Vector3DMixin);
vector:OnLoad(x, y, z);
return vector;
end
function AreVector3DEqual(left, right)
if left and right then
return left:IsEqualTo(right);
end
return left == right;
end
function Vector3DMixin:OnLoad(x, y, z)
self:SetXYZ(x, y, z);
end
function Vector3DMixin:IsEqualTo(otherVector)
return self.x == otherVector.x
and self.y == otherVector.y
and self.z == otherVector.z;
end
function Vector3DMixin:GetXYZ()
return self.x, self.y, self.z;
end
function Vector3DMixin:SetXYZ(x, y, z)
self.x = x;
self.y = y;
self.z = z;
end
function Vector3DMixin:ScaleBy(scalar)
self:SetXYZ(Vector3D_ScaleBy(scalar, self:GetXYZ()));
return self;
end
function Vector3DMixin:DivideBy(scalar)
self:SetXYZ(Vector3D_DivideBy(scalar, self:GetXYZ()));
return self;
end
function Vector3DMixin:Add(other)
self:SetXYZ(Vector3D_Add(self.x, self.y, self.z, other:GetXYZ()));
return self;
end
function Vector3DMixin:Subtract(other)
self:SetXYZ(Vector3D_Subtract(self.x, self.y, self.z, other:GetXYZ()));
return self;
end
function Vector3DMixin:Cross(other)
self:SetXYZ(Vector3D_Cross(self.x, self.y, self.z, other:GetXYZ()));
return self;
end
function Vector3DMixin:Dot(other)
return Vector3D_Dot(self.x, self.y, self.z, other:GetXYZ());
end
function Vector3DMixin:GetLengthSquared()
return Vector3D_GetLengthSquared(self:GetXYZ());
end
function Vector3DMixin:GetLength()
return Vector3D_GetLength(self:GetXYZ());
end
function Vector3DMixin:Normalize()
self:SetXYZ(Vector3D_Normalize(self:GetXYZ()));
return self;
end
function Vector3DMixin:Clone()
return CreateVector3D(self:GetXYZ());
end
+143
View File
@@ -0,0 +1,143 @@
-- Verbatim backport of retail's Blizzard_SharedXML/Vector4D.lua. Pure vector
-- math on CreateFromMixins (a ClassicAPI C native) + Lua 5.0 math.*; no 1.12
-- adaptation needed. (Retail's "used in secure code" note doesn't apply here —
-- vanilla has no taint system.)
local cos = math.cos;
local sin = math.sin;
local atan2 = math.atan2;
local asin = math.asin;
local sqrt = math.sqrt;
function Vector4D_ScaleBy(scalar, x, y, z, w)
return x * scalar, y * scalar, z * scalar, w * scalar;
end
function Vector4D_DivideBy(divisor, x, y, z, w)
return x / divisor, y / divisor, z / divisor, w / divisor;
end
function Vector4D_Add(leftX, leftY, leftZ, leftW, rightX, rightY, rightZ, rightW)
return leftX + rightX, leftY + rightY, leftZ + rightZ, leftW + rightW;
end
function Vector4D_Subtract(leftX, leftY, leftZ, leftW, rightX, rightY, rightZ, rightW)
return leftX - rightX, leftY - rightY, leftZ - rightZ, leftW - rightW;
end
function Vector4D_Dot(leftX, leftY, leftZ, leftW, rightX, rightY, rightZ, rightW)
return leftX * rightX + leftY * rightY + leftZ * rightZ + leftW * rightW;
end
function Vector4D_GetLengthSquared(x, y, z, w)
return Vector4D_Dot(x, y, z, w, x, y, z, w);
end
function Vector4D_GetLength(x, y, z, w)
return sqrt(Vector4D_GetLengthSquared(x, y, z, w));
end
function Vector4D_Normalize(x, y, z, w)
return Vector4D_DivideBy(Vector4D_GetLength(x, y, z, w), x, y, z, w);
end
function Vector4D_AddVector(left, right)
local clone = left:Clone();
clone:Add(right);
return clone;
end
function Vector4D_SubtractVector(left, right)
local clone = left:Clone();
clone:Subtract(right);
return clone;
end
function Vector4D_NormalizeVector(vector)
local clone = vector:Clone();
clone:Normalize();
return clone;
end
function Vector4D_ScaleVector(scalar, vector)
local clone = vector:Clone();
clone:ScaleBy(scalar);
return clone;
end
Vector4DMixin = {};
function CreateVector4D(x, y, z, w)
local vector = CreateFromMixins(Vector4DMixin);
vector:OnLoad(x, y, z, w);
return vector;
end
function AreVector4DEqual(left, right)
if left and right then
return left:IsEqualTo(right);
end
return left == right;
end
function Vector4DMixin:OnLoad(x, y, z, w)
self:SetXYZW(x, y, z, w);
end
function Vector4DMixin:IsEqualTo(otherVector)
return self.x == otherVector.x
and self.y == otherVector.y
and self.z == otherVector.z
and self.w == otherVector.w;
end
function Vector4DMixin:GetXYZW()
return self.x, self.y, self.z, self.w;
end
function Vector4DMixin:SetXYZW(x, y, z, w)
self.x = x;
self.y = y;
self.z = z;
self.w = w;
end
function Vector4DMixin:ScaleBy(scalar)
self:SetXYZW(Vector4D_ScaleBy(scalar, self:GetXYZW()));
return self;
end
function Vector4DMixin:DivideBy(scalar)
self:SetXYZW(Vector4D_DivideBy(scalar, self:GetXYZW()));
return self;
end
function Vector4DMixin:Add(other)
self:SetXYZW(Vector4D_Add(self.x, self.y, self.z, self.w, other:GetXYZW()));
return self;
end
function Vector4DMixin:Subtract(other)
self:SetXYZW(Vector4D_Subtract(self.x, self.y, self.z, self.w, other:GetXYZW()));
return self;
end
function Vector4DMixin:Dot(other)
return Vector4D_Dot(self.x, self.y, self.z, self.w, other:GetXYZW());
end
function Vector4DMixin:GetLengthSquared()
return Vector4D_GetLengthSquared(self:GetXYZW());
end
function Vector4DMixin:GetLength()
return Vector4D_GetLength(self:GetXYZW());
end
function Vector4DMixin:Normalize()
self:SetXYZW(Vector4D_Normalize(self:GetXYZW()));
return self;
end
function Vector4DMixin:Clone()
return CreateVector4D(self:GetXYZW());
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 = "點擊這裡以製造一個你專業技能的連結。";
+24 -6
View File
@@ -1,6 +1,6 @@
cmake_minimum_required(VERSION 3.10)
project(ClassicAPI LANGUAGES CXX)
project(ClassicAPI LANGUAGES CXX RC)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
@@ -77,17 +77,20 @@ add_library(${PROJECT_NAME} SHARED
# `CLASSIC_API_VERSION >= X` always pass during local development;
# CI release builds overwrite it via `-DCLASSICAPI_TAG=vX.Y.Z`.
set(CLASSICAPI_VERSION_VALUE 99999999)
set(CLASSICAPI_VER_MAJOR 9999)
set(CLASSICAPI_VER_MINOR 99)
set(CLASSICAPI_VER_PATCH 99)
set(CLASSICAPI_TAG
""
CACHE STRING "Release tag like v1.2.3")
if(CLASSICAPI_TAG MATCHES "^v([0-9]+)\\.([0-9]+)\\.([0-9]+)$")
set(_MAJOR "${CMAKE_MATCH_1}")
set(_MINOR "${CMAKE_MATCH_2}")
set(_PATCH "${CMAKE_MATCH_3}")
set(CLASSICAPI_VER_MAJOR "${CMAKE_MATCH_1}")
set(CLASSICAPI_VER_MINOR "${CMAKE_MATCH_2}")
set(CLASSICAPI_VER_PATCH "${CMAKE_MATCH_3}")
math(EXPR CLASSICAPI_VERSION_VALUE
"${_MAJOR} * 10000 + ${_MINOR} * 100 + ${_PATCH}")
"${CLASSICAPI_VER_MAJOR} * 10000 + ${CLASSICAPI_VER_MINOR} * 100 + ${CLASSICAPI_VER_PATCH}")
elseif(CLASSICAPI_TAG)
message(
FATAL_ERROR
@@ -99,10 +102,25 @@ target_compile_definitions(
${PROJECT_NAME}
PRIVATE CLASSICAPI_VERSION_VALUE=${CLASSICAPI_VERSION_VALUE})
# Stamp the PE VERSIONINFO resource from the same version components, so the
# DLL's file/product version (Explorer -> Properties -> Details) matches the
# CLASSIC_API_VERSION Lua global. The .rsrc section the resource lands in is
# untouched by the .rdata/.pdata section merges below.
set(CLASSICAPI_VER_STRING
"${CLASSICAPI_VER_MAJOR}.${CLASSICAPI_VER_MINOR}.${CLASSICAPI_VER_PATCH}.0")
set(CLASSICAPI_VERSION_RC "${CMAKE_BINARY_DIR}/generated/version.rc")
configure_file(
"${CMAKE_CURRENT_SOURCE_DIR}/cmake/version.rc.in"
"${CLASSICAPI_VERSION_RC}"
@ONLY)
target_sources(${PROJECT_NAME} PRIVATE "${CLASSICAPI_VERSION_RC}")
# ole32: COM (CoInitializeEx / CoCreateInstance / CoTaskMemFree) for the
# SAPI text-to-speech backend in src/voicechat/Tts.cpp. The SAPI GUIDs are
# instantiated in that TU via <initguid.h>, so no sapi.lib is needed.
target_link_libraries(${PROJECT_NAME} PRIVATE minhook advapi32 ole32)
# user32: Win32 clipboard API (OpenClipboard / SetClipboardData) for
# CopyToClipboard in src/clipboard/Copy.cpp.
target_link_libraries(${PROJECT_NAME} PRIVATE minhook advapi32 ole32 user32)
target_include_directories(
${PROJECT_NAME} PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/src
+32 -17
View File
@@ -18,49 +18,58 @@ Full per-function reference: **[docs/API.md](docs/API.md)**.
| Namespace | Calls |
|-----------|-------|
| [Action](docs/API.md#action) | `GetActionInfo` |
| [AddOns](docs/API.md#addons) | `C_AddOns.DoesAddOnExist`, `C_AddOns.GetAddOnName`, `C_AddOns.GetAddOnNotes`, `C_AddOns.GetAddOnSecurity`, `C_AddOns.GetAddOnTitle`, `C_AddOns.IsAddOnLoadable`, `C_AddOns.IsAddOnLoaded` |
| [AddOns](docs/API.md#addons) | `C_AddOns.DoesAddOnExist`, `C_AddOns.GetAddOnName`, `C_AddOns.GetAddOnNotes`, `C_AddOns.GetAddOnOptionalDependencies`, `C_AddOns.GetAddOnSecurity`, `C_AddOns.GetAddOnTitle`, `C_AddOns.IsAddOnLoadable`, `C_AddOns.IsAddOnLoaded` |
| [AuctionHouse](docs/API.md#auctionhouse) | `C_AuctionHouse.PostItem` |
| [Chat](docs/API.md#chat) | `GetCurrentChatGUID` |
| [ChatBubbles](docs/API.md#chatbubbles) | `C_ChatBubbles.GetAllChatBubbles` |
| [Class](docs/API.md#class) | `FillLocalizedClassList` |
| [ColorUtil](docs/API.md#colorutil) | `C_ColorUtil.ConvertRGBToHSV`, `C_ColorUtil.ConvertHSVToRGB`, `C_ColorUtil.ConvertHSVToHSL`, `C_ColorUtil.ConvertHSLToHSV`, `C_ColorUtil.ConvertHSLToRGB`, `C_ColorUtil.GenerateTextColorCode`, `C_ColorUtil.WrapTextInColor`, `C_ColorUtil.WrapTextInColorCode` |
| [Combat](docs/API.md#combat) | `InCombatLockdown` |
| [CVar](docs/API.md#cvar) | `C_CVar.GetCVarBool` |
| [Cursor](docs/API.md#cursor) | `GetCursorInfo` |
| [Container](docs/API.md#container) | `C_Container.GetContainerItemCharges`, `C_Container.GetContainerItemDurability`, `C_Container.GetContainerItemID`, `C_Container.GetContainerItemRepairCost`, `C_Container.GetContainerNumFreeSlots`, `C_Container.GetItemCooldown`, `C_Container.IsContainerItemOpenable`, `C_Container.MoveItem`, `C_Container.PlayerHasHearthstone`, `C_Container.SwapItems`, `C_Container.UseHearthstone`, `GetItemCooldown` |
| [Coroutine](docs/API.md#coroutine) | `coroutine.create`, `coroutine.resume`, `coroutine.yield`, `coroutine.status`, `coroutine.wrap` |
| [Creature](docs/API.md#creature) | `C_CreatureInfo.GetCreatureID`, `C_CreatureInfo.GetCreatureInfoByID`, `C_CreatureInfo.RequestLoadCreatureByID` |
| [Container](docs/API.md#container) | `C_Container.CalculateTotalNumberOfFreeBagSlots`, `C_Container.GetContainerItemCharges`, `C_Container.GetContainerItemDurability`, `C_Container.GetContainerItemID`, `C_Container.GetContainerItemInfo`, `C_Container.GetContainerItemRepairCost`, `C_Container.GetContainerNumFreeSlots`, `C_Container.GetItemCooldown`, `C_Container.HasContainerItem`, `C_Container.IsContainerItemOpenable`, `C_Container.MoveItem`, `C_Container.PlayerHasHearthstone`, `C_Container.SwapItems`, `C_Container.UseHearthstone`, `GetItemCooldown` |
| [Creature](docs/API.md#creature) | `C_CreatureInfo.GetCreatureID`, `C_CreatureInfo.GetCreatureInfoByID`, `C_CreatureInfo.RequestLoadCreatureByID`, `C_CreatureInfo.GetRaceInfo`, `C_CreatureInfo.GetClassInfo`, `C_CreatureInfo.GetCreatureFamilyInfo`, `C_CreatureInfo.GetCreatureFamilyIDs`, `C_CreatureInfo.GetFactionInfo`, `C_CreatureInfo.GetCreatureTypeInfo`, `C_CreatureInfo.GetCreatureTypeIDs` |
| EncodingUtil | `C_EncodingUtil.CompressString`, `C_EncodingUtil.DecompressString`, `C_EncodingUtil.EncodeBase64`, `C_EncodingUtil.DecodeBase64`, `C_EncodingUtil.EncodeHex`, `C_EncodingUtil.DecodeHex`, `C_EncodingUtil.SerializeJSON`, `C_EncodingUtil.DeserializeJSON`, `C_EncodingUtil.SerializeCBOR`, `C_EncodingUtil.DeserializeCBOR` |
| [EquipmentSet](docs/API.md#equipmentset) | `C_EquipmentSet.CanUseEquipmentSets`, `C_EquipmentSet.ClearIgnoredSlotsForSave`, `C_EquipmentSet.CreateEquipmentSet`, `C_EquipmentSet.DeleteEquipmentSet`, `C_EquipmentSet.EquipmentSetContainsLockedItems`, `C_EquipmentSet.GetEquipmentSetID`, `C_EquipmentSet.GetEquipmentSetIDs`, `C_EquipmentSet.GetEquipmentSetInfo`, `C_EquipmentSet.GetIgnoredSlots`, `C_EquipmentSet.GetItemIDs`, `C_EquipmentSet.GetItemLocations`, `C_EquipmentSet.GetNumEquipmentSets`, `C_EquipmentSet.IgnoreSlotForSave`, `C_EquipmentSet.IsSlotIgnoredForSave`, `C_EquipmentSet.ModifyEquipmentSet`, `C_EquipmentSet.SaveEquipmentSet`, `C_EquipmentSet.UnignoreSlotForSave`, `C_EquipmentSet.UseEquipmentSet` |
| [Events](docs/API.md#events) | `C_EventUtils.IsEventValid` |
| [Events](docs/API.md#events) | `C_EventUtils.IsEventValid`, `GetFramesRegisteredForEvent` |
| [Expansion](docs/API.md#expansion) | `ClassicExpansionAtLeast`, `ClassicExpansionAtMost`, `GetClassicExpansionLevel` |
| [Faction](docs/API.md#faction) | `C_Reputation.GetFactionDataByIndex`, `C_Reputation.GetFactionStandings`, `C_Reputation.GetLastStandingChange`, `C_Reputation.GetWatchedFactionData`, `C_Reputation.SetWatchedFactionByID`, `GetFactionIDByIndex`, `GetFactionInfoByID`, `GetFactionParentID` |
| [Focus](docs/API.md#focus) | `ClearFocus`, `FocusUnit` |
| [Frame](docs/API.md#frame) | `region:SetPoint("point")` (one-arg form), `region:SetSize`, `region:GetSize`, `frame:SetShown`, `frame:SetResizeBounds`, `frame:HookScript`, `frame:IsEventRegistered` |
| [FriendList](docs/API.md#friendlist) | `C_FriendList.IsWhoQueryPending`, `C_FriendList.SendWhoQueryByName` |
| [GameObject](docs/API.md#gameobject) | `C_GameObjectInfo.GetGameObjectInfoByID`, `C_GameObjectInfo.RequestLoadGameObjectByID` |
| [GameObject](docs/API.md#gameobject) | `C_GameObjectInfo.GetGameObjectInfoByID`, `C_GameObjectInfo.RequestLoadGameObjectByID`, `ClosestGameObjectPosition` |
| [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) |
| [GameTooltip](docs/API.md#gametooltip) | `GameTooltip:AddSpellByID`, `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:SetTotem`, `GameTooltip:SetUnitAura`, `OnTooltipSetItem`, `OnTooltipSetSpell`, `OnTooltipSetUnit`, `OnTooltipSetGameObject` (scripts) |
| [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` |
| [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.GetItemInfo`, `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.GetItemClassInfo`, `C_Item.GetItemSubClassInfo`, `C_Item.GetItemUniqueness`, `C_Item.GetItemUniquenessByID`, `C_Item.GetStackCount`, `C_Item.GetWeaponEnchantInfo`, `C_Item.IsBound`, `C_Item.IsConsumableItem`, `C_Item.IsEquippableItem`, `C_Item.IsEquippedItem`, `C_Item.IsItemDataCached`, `C_Item.IsItemDataCachedByID`, `C_Item.IsItemGUIDInInventory`, `C_Item.IsItemInRange`, `C_Item.IsItemOpenable`, `C_Item.IsLocked`, `C_Item.LockItem`, `C_Item.LockItemByGUID`, `C_Item.PickupItem`, `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`, `GetItemClassInfo`, `GetItemIcon`, `GetItemSubClassInfo`, `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`, `C_LootHistory.Clear` |
| [LossOfControl](docs/API.md#lossofcontrol) | `C_LossOfControl.GetActiveLossOfControlData`, `C_LossOfControl.GetActiveLossOfControlDataCount` |
| [Lua](docs/API.md#lua) | `coroutine.create`, `coroutine.resume`, `coroutine.status`, `coroutine.wrap`, `coroutine.yield`, `CreateFromMixins`, `math.fmod`, `math.huge`, `math.modf`, `Mixin`, `select`, `string.gmatch`, `string.match`, `string.reverse`, `strjoin`, `strreplace`, `strrev`, `strsplit`, `strtrim`, `table.count`, `table.wipe` |
| [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` |
| [Map](docs/API.md#map) | `C_Map.GetAreaInfo`, `C_Map.GetAreas`, `C_Map.GetAreaTriggerInfo`, `C_Map.GetAreaTriggers`, `C_Map.GetBestMapForUnit`, `C_Map.GetMapAreaIDs`, `C_Map.GetMapOverlays`, `C_Map.GetMapWorldSize` |
| [MapExplorationInfo](docs/API.md#mapexplorationinfo) | `C_MapExplorationInfo.GetExploredMapTextures`, `C_MapExplorationInfo.GetUnexploredMapTextures` |
| [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` |
| [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` |
| [SpellBook](docs/API.md#spellbook) | `C_SpellBook.GetCurrentLevelSpells`, `C_SpellBook.GetSpellLevelLearned`, `C_SpellBook.GetSpellSkillLine`, `C_SpellBook.IsAutoAttackSpellBookItem`, `C_SpellBook.IsRangedAutoAttackSpellBookItem`, `FindSpellBookSlotByID` |
| [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` |
| [NewItems](docs/API.md#newitems) | `C_NewItems.ClearAll`, `C_NewItems.IsNewItem`, `C_NewItems.RemoveNewItem` |
| [PlayerInfo](docs/API.md#playerinfo) | `C_PlayerInfo.CanUseItem`, `C_PlayerInfo.GetClass`, `C_PlayerInfo.GetName`, `C_PlayerInfo.GetRace`, `C_PlayerInfo.GetSex`, `C_PlayerInfo.GUIDIsCreature`, `C_PlayerInfo.GUIDIsGameObject`, `C_PlayerInfo.GUIDIsPet`, `C_PlayerInfo.GUIDIsPlayer`, `C_PlayerInfo.IsConnected` |
| [Quest](docs/API.md#quest) | `C_QuestLog.GetNumQuestObjectives`, `C_QuestLog.GetQuestDetails`, `C_QuestLog.GetHeaderIndexForQuest`, `C_QuestLog.GetLogIndexForQuestID`, `C_QuestLog.GetQuestIDForLogIndex`, `C_QuestLog.GetTitleForQuestID`, `C_QuestLog.IsOnQuest`, `C_QuestLog.IsQuestDataCachedByID`, `C_QuestLog.IsUnitOnQuest`, `C_QuestLog.RequestLoadQuestByID`, `GetQuestLogLeaderBoardID` |
| [Spell](docs/API.md#spell) | `C_Spell.CancelSpellByID`, `C_Spell.CastAtCursor`, `C_Spell.CastAtUnit`, `C_Spell.CastingInfo`, `C_Spell.ChannelInfo`, `C_Spell.DoesSpellExist`, `C_Spell.GetSchoolString`, `C_Spell.GetSpellCooldown`, `C_Spell.GetSpellDescription`, `C_Spell.GetSpellEffectMechanics`, `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.IsSpellInRange`, `C_Spell.IsSpellPassive`, `C_Spell.IsSpellUsable`, `C_Spell.SpellHasRange`, `C_Spell.UnitCastingInfo`, `C_Spell.UnitChannelInfo`, `CanDualWield`, `CancelSpellByName`, `CastSpellNoToggle`, `GetCraftSpellID`, `GetSpellBonusDamage`, `GetSpellBonusHealing`, `GetSpellInfo`, `GetSpellLink`, `GetSpellRadius`, `GetSpellRequiredTargetLevel`, `GetSpellSchool`, `IsHarmfulSpell`, `IsHelpfulSpell`, `IsPassiveSpell`, `IsPlayerSpell`, `IsSpellKnown`, `IsUsableSpell`, `SpellHasRange` |
| [SpellBook](docs/API.md#spellbook) | `C_SpellBook.GetCurrentLevelSpells`, `C_SpellBook.GetSkillLineName`, `C_SpellBook.GetSkillLineRank`, `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` |
| [System](docs/API.md#system) | `CopyToClipboard`, `GetPhysicalScreenSize` |
| [Talent](docs/API.md#talent) | `GetTalentIDByIndex`, `GetTalentSpellID` |
| [Targeting](docs/API.md#targeting) | `GetPlayerFacing`, `TargetDirectionEnemy`, `TargetDirectionFriend`, `TargetNearest`, `TargetNearestEnemyPlayer`, `TargetNearestFriendPlayer` |
| [TaxiMap](docs/API.md#taximap) | `C_TaxiMap.GetTaxiNodesForMap`, `C_TaxiMap.GetAllTaxiNodes`, `C_TaxiMap.GetTaxiPaths`, `C_TaxiMap.GetTaxiPathWaypoints`, `C_TaxiMap.GetTaxiRoute` |
| [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` |
| [Totem](docs/API.md#totem) | `GetTotemInfo`, `GetTotemTimeLeft`, `GetTotemDuration`, `TargetTotem` |
| [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) | `ClosestUnitPosition`, `GetUnitSpeed`, `UnitClassBase`, `UnitCreatureFamilyID`, `UnitCreatureTypeID`, `UnitDistanceSquared`, `UnitGUID`, `UnitHealthMissing`, `UnitInLineOfSight`, `UnitInRange`, `UnitIsAFK`, `UnitIsDND`, `UnitIsFeignDeath`, `UnitIsInMyGuild`, `UnitIsMinion`, `UnitIsOtherPlayersPet`, `UnitIsPet`, `UnitIsPossessed`, `UnitOwnerGUID`, `UnitPower`, `UnitPowerMax`, `UnitPowerMissing`, `UnitPowerType`, `UnitRaceBase`, `UnitSpellHaste`, `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` |
@@ -112,6 +121,7 @@ when launching with `-console`), not as Lua functions. See the
| Event | Payload |
|-------|---------|
| `BAG_NEW_ITEMS_UPDATED` | *(none)* |
| `BAG_UPDATE_DELAYED` | *(none)* |
| `EQUIPMENT_SETS_CHANGED` | *(none)* |
| `EQUIPMENT_SWAP_PENDING` | `setID` |
@@ -121,6 +131,8 @@ when launching with `-console`), not as Lua functions. See the
| `GLOBAL_MOUSE_UP` | `button` |
| `HEARTHSTONE_BOUND` | *(none)* |
| `ITEM_DATA_LOAD_RESULT` | `itemID, success` |
| `LOOT_HISTORY_ROLL_CHANGED` | `itemIndex, playerIndex` |
| `LOOT_HISTORY_ROLL_COMPLETE` | `itemIndex` |
| `LOOT_SCAN_COMPLETED` | *(none)* |
| `LOSS_OF_CONTROL_ADDED` | `eventIndex` |
| `LOSS_OF_CONTROL_UPDATE` | `unitToken` (always `"player"`) |
@@ -136,6 +148,7 @@ when launching with `-console`), not as Lua functions. See the
| `PLAYER_STOPPED_MOVING` | *(none)* |
| `PLAYER_STARTED_TURNING` | *(none)* |
| `PLAYER_STOPPED_TURNING` | *(none)* |
| `PLAYER_TOTEM_UPDATE` | `totemSlot` |
| `QUEST_ACCEPTED` | `questLogIndex, questID` |
| `QUEST_DATA_LOAD_RESULT` | `questID, success` |
| `QUEST_REMOVED` | `questID` |
@@ -158,6 +171,8 @@ when launching with `-console`), not as Lua functions. See the
| Addon security | `Enum.AddOnSecurityStatus.{Secure,Insecure,Banned,NotAvailable}` |
| Power type | `Enum.PowerType.{HealthCost,None,Mana,Rage,Focus,Energy,Happiness}` |
| Inventory type | `Enum.InventoryType.Index*Type` (0–34, e.g. `IndexHeadType`=1 … `IndexRelicType`=28) |
| Item class | `Enum.ItemClass.{Consumable,Container,Weapon,Gem,Armor,Reagent,Projectile,Tradegoods,ItemEnhancement,Recipe,Quiver,Questitem,Key,Miscellaneous,…}` (0–19) |
| Item quality | `Enum.ItemQuality.{Poor,Common,Uncommon,Rare,Epic,Legendary,Artifact}` (0–6) |
### Unit tokens
+200
View File
@@ -3615,3 +3615,203 @@ vs. any hidden fixed-30 assumption elsewhere; and that the Lua-created
lines inherit height/spacing identical to the template's 1..30 so the
tooltip grows seamlessly. Both are observable on the first >30-line
hover.
## 98. `UnitThreatSituation` / `UnitDetailedThreatSituation` / `GetThreatStatusColor` — NOT FEASIBLE in a client DLL
Modern (WotLK+) exposes three threat APIs:
[`UnitThreatSituation`](https://warcraft.wiki.gg/wiki/API_UnitThreatSituation)
(unit → 0..3 aggro status),
[`UnitDetailedThreatSituation`](https://warcraft.wiki.gg/wiki/API_UnitDetailedThreatSituation)
(isTanking, status, threatPct, rawThreatPct, threatValue), and
[`GetThreatStatusColor`](https://warcraft.wiki.gg/wiki/API_GetThreatStatusColor)
(status → RGB). None of them can be backed by ClassicAPI, because
**the underlying data does not exist on the 1.12 client** — not in a
form a DLL can read, and not by any amount of RE.
### Threat is 100% server-authoritative in vanilla; no threat opcode exists
Vanilla 1.12's threat lives entirely inside the server's
`ThreatManager` (per-creature `ThreatList` of `HostileReference`s —
see Turtle's
[`src/game/Threat/ThreatManager.cpp`](C:\Git\turtle-wow\Dumps\Source%20Code\16%20-%20Development_server\patch_1172\src\game\Threat\ThreatManager.cpp)).
The 3.3.5 client can show a threat meter because WotLK added
`SMSG_THREAT_UPDATE` / `SMSG_HIGHEST_THREAT_UPDATE` (and the client
keeps a per-unit threat table those packets feed). **1.12 has no such
opcode** — the protocol never transmits per-unit threat values, so
there is nothing in client memory to read. This is the same class of
limitation as `C_QuestLog.IsQuestFlaggedCompleted` (TODO #1): the
client simply isn't told. Unlike cast timing (TODO #91, recoverable
from `SMSG_SPELL_START`) or aura casters (recoverable from
`SMSG_SPELL_GO`), there is **no incidental packet** that leaks threat
either — the server computes tanking/aggro decisions internally and
broadcasts none of the numbers.
### How Turtle actually does it — a *server-side* addon-channel protocol
TWThreat (`C:\WoW\Octo\Interface\Addons\TWThreat`) is not reading
client memory; it round-trips through a **custom server patch**:
1. Client → server: the addon `SendAddonMessage("TWT_UDTSv4"[.._TM],
"limit=NN", "PARTY"/"RAID")` — a normal `CMSG_MESSAGECHAT` addon
message (`TWThreat.lua:1207` `TWT.UnitDetailedThreatSituation`).
2. Server intercepts the prefix in
[`ChatHandler.cpp:1051`](turtle-wow\Dumps\Source%20Code\16%20-%20Development_server\patch_1172\src\game\Handlers\ChatHandler.cpp)
(`strstr(msg, "TWT_UDTSv4")`), validates
`GetSelectedCreature()->CanHaveThreatList()`, and calls
`ThreatManager::UnitDetailedThreatSituation(creature, player, limit,
tankMode)`.
3. That serializer (`ThreatManager.cpp:441`) walks the real
`getThreatList()` and builds a `TWTv4=name:pct:threat;…` string,
then `SendAddonMessage`s it back.
4. Client parses it in `CHAT_MSG_ADDON` → `TWT.handleThreatPacket`
(`TWThreat.lua:790`).
Every threat number the meter shows originated on the server and was
put on the wire by a server-side code change ClassicAPI has no
counterpart to. A client DLL cannot manufacture data the server never
sends.
### Even where a server *does* expose it, the transport is async
The Turtle path is a request/response over the addon chat channel —
fundamentally asynchronous (poll on `OnUpdate`, handle the reply on a
later frame). It cannot back a **synchronous** `UnitThreatSituation()`
that returns a value in the same call, which is exactly why TWThreat is
structured around events and a cached `TWT.threats` table rather than a
blocking query. A ClassicAPI shim would at best have to fake a
synchronous return from a stale cache, and the cache only exists if a
Turtle-style server is feeding it — i.e. the shim would add nothing the
addon isn't already doing in Lua.
### `GetThreatStatusColor` is the only client-pure piece — and it's trivial Lua
`GetThreatStatusColor(status)` is just a fixed 0..3 → RGB map (grey /
yellow / orange / red); no engine data involved. But it's meaningless
without a real `status` to feed it, and it's a two-line Lua table
lookup — no reason to spend a DLL global on it. If we ever ship a
threat polyfill it belongs beside the (Lua, server-dependent) status
source, not in the DLL.
**Verdict:** skip all three. The data is server-only with no vanilla
opcode, the only working implementation (Turtle) is a server patch plus
an async addon-channel protocol that lives correctly in Lua, and the
one client-pure function is a trivial color table. Nothing here is a
client-DLL problem.
### If we synthesized it anyway
"Can't read it" and "can't estimate it" are separate claims. We can't
read threat (above); *estimating* it is possible, but it splits into two
tiers, and neither one is a DLL feature.
**Tier 1 — client-only proxy from the mob's target (small, honest,
nearly useless).** The one threat-correlated fact vanilla *does* sync to
the client is the creature's current target (`UNIT_FIELD_TARGET` in the
descriptor — the same field `"<mob>target"` / `targettarget` reads). A
mob attacks its highest-threat valid target, so `mobTarget == unit` ≈
"unit is tanking". That yields:
- `UnitThreatSituation(unit, mob)` → `3` when the mob targets `unit`,
else `nil`. It **cannot** produce `1` ("higher than tank, about to
pull") or distinguish `2` from `3` — those are numeric comparisons and
we have no numbers.
- `UnitDetailedThreatSituation` → `isTanking` only; `status` degenerate,
`threatpct` / `rawthreatpct` / `threatvalue` all unavailable.
- Laggy: it changes only when the mob switches target (a descriptor
update), so it *trails* real threat and gives zero predictive warning
— the entire reason a threat meter exists.
This needs no DLL: `UnitIsUnit("<mob>target", unit)` already does it in
pure Lua. Shipping it under the real API names would be actively
misleading (callers expect the 0–3 gradient plus percentages), so it's
not worth it even as a stub.
**Tier 2 — a real threat model (accurate-ish, huge, cooperative, ~95%
Lua).** This is what KTM / KLHThreatMeter / vanilla-Omen actually do, and
the only synthesis that fills the percentage/prediction dimensions the
API is *for*:
1. **Model your own threat.** Parse your outgoing combat events and
convert them to threat via a per-spell coefficient database (damage ≈
1 threat per point, healing ≈ 0.5/HP applied to *every* mob in combat,
flat-threat abilities like Sunder Armor / Demo Shout, per-spell
bonus/penalty coefficients), then apply your active multipliers —
stance/form (Defensive 1.3×, Berserker 0.8×, Bear/Cat), Righteous Fury
(+Holy threat), Blessing of Salvation (0.7×), threat-reduction
trinkets — and handle the sets/wipes: taunt (sets you to current top),
Feign Death, Vanish, Fade, Cower.
2. **Broadcast** your per-mob estimate over `SendAddonMessage` and
**aggregate** everyone else's broadcasts into a per-mob table.
3. Derive `isTanking` / `status` / the percentages by comparing each
member to the current tank against the **110% melee / 130% ranged**
pull threshold — the exact constants both Turtle's server serializer
(`ThreatManager.cpp:494`, `threatValue*100 / (tankThreat * (isMelee ?
1.1 : 1.3))`) and TWThreat's client fallback (`TWThreat.lua:932`)
use. Worth stealing verbatim so a synthesized `threatpct` lines up
with what real-threat clients show.
Why it isn't ours to build:
- The coefficient DB is **game-knowledge, not in the binary** — you
cannot RE it out of the client; it's the classic hand-maintained
Threat-1.0/KTM data. The parsing, broadcast, and aggregation are all
Lua. The talent/aura/stance *inputs* are already exposed by existing
ClassicAPI functions (`GetTalentInfo`, the aura APIs, `IsPlayerSpell`,
the spell-mod reads). **None of it is a client-DLL problem.**
- **Cooperative-only.** You can precisely model only members running the
same addon and broadcasting; a non-participating tank is invisible (the
original KTM caveat). The intuitive objection — "I can see everyone's
damage in the combat log, why can't I model them from one client?" —
fails for three compounding reasons, none liftable by the DLL:
1. **You don't actually see everyone's events.** Vanilla's combat log
is visibility/range-gated — the server only broadcasts combat events
for units near you, not the whole raid. A stacked 5-man logs most of
it (so a single-observer model degrades *gracefully* in dungeons); a
spread 40-man leaves you blind to the ranged camp, the back-line
healers, anyone outside your bubble. The DLL can't recover these —
the server never sends the packets, so there's nothing to hook.
2. **Threat ≠ damage, and the missing factor is hidden.** Threat is
`damage × stance × talents × buffs`, not 1:1. The talent factor
(Defiance, Subtlety, Feral Instinct, Shadow Affinity, Frost
Channeling — the passive threat-mod talents) is **unreadable for
other players in vanilla**; there's no inspect path to it. Stance /
Blessing of Salvation / Fade are at least observable auras when the
unit is in range, but talents are pure hidden state, so an observer
can't derive a stranger's coefficient from their damage.
3. **Much threat has no attributable log line.** In-combat healing
generates threat on *every* mob the healer is engaged with (not the
mob the heal touched); buffs / HoTs / PW:Fortitude, flat-threat
abilities, and pet threat either don't appear in the log or can't be
pinned to the right mob from a single line.
Broadcasting sidesteps all three because each client computes threat
from *private, authoritative* knowledge — its own exact casts, its own
talents, its own active stance/buffs — then shares the finished number.
The Tier-1 mob-target read is worth keeping here *only* as a cross-check
— it reveals who actually holds aggro regardless of who's broadcasting.
- **It's an estimate that drifts.** Addon threat models desync from the
server's true numbers (rounding, unmodeled effects, out-of-range or
missed events). That drift is precisely why Blizzard later added
`SMSG_THREAT_UPDATE` and why Turtle chose a server serializer over
blessing an addon guess.
**The only DLL-shaped angle — and it's an *input*, not threat.** The one
place a DLL beats Lua is the feed: vanilla's combat log is localized text
keyed by unit *name* (ambiguous with duplicate names, breaks per-locale).
Hooking the damage/heal packet handlers (`SMSG_SPELLNONMELEEDAMAGELOG`,
`SMSG_ATTACKERSTATEUPDATE`, `SMSG_PERIODICAURALOG`) to emit precise,
GUID-keyed, unlocalized damage/heal events would make any Lua threat
model far more robust. But (a) that's a **separate feature** — a
`COMBAT_LOG_EVENT`-style feed, useful well beyond threat — that should be
filed on its own merits; (b) it still only *feeds* the Lua model — threat
stays an estimate, computed cooperatively in Lua; and (c) on the actual
target environment SuperWoW already exposes GUID-keyed combat info +
`UNIT_CASTEVENT`, so even this niche is largely pre-filled.
**Bottom line on synthesis:** Tier 1 is a Lua one-liner too degenerate to
wear the API's name; Tier 2 is a full KTM/Omen reimplementation that is
almost entirely Lua data + logic, cooperative-only, and inherently an
estimate. Anyone who wants vanilla threat should run an existing threat
addon. ClassicAPI's only worthwhile contribution in this space is an
unrelated precise-combat-log event feed — filed as its own thing, not as
"threat".
+38
View File
@@ -0,0 +1,38 @@
// Generated from cmake/version.rc.in by CMake configure_file().
// Stamps the PE VERSIONINFO resource so Explorer -> Properties -> Details
// and GetFileVersionInfo() report the same version as the CLASSIC_API_VERSION
// Lua global. Both derive from the CLASSICAPI_TAG (vMAJOR.MINOR.PATCH) cache
// var; an untagged dev build gets the 9999.99.99 "newer than everything"
// sentinel.
#include <winver.h>
VS_VERSION_INFO VERSIONINFO
FILEVERSION @CLASSICAPI_VER_MAJOR@,@CLASSICAPI_VER_MINOR@,@CLASSICAPI_VER_PATCH@,0
PRODUCTVERSION @CLASSICAPI_VER_MAJOR@,@CLASSICAPI_VER_MINOR@,@CLASSICAPI_VER_PATCH@,0
FILEFLAGSMASK VS_FFI_FILEFLAGSMASK
FILEFLAGS 0x0L
FILEOS VOS_NT_WINDOWS32
FILETYPE VFT_DLL
FILESUBTYPE VFT2_UNKNOWN
BEGIN
BLOCK "StringFileInfo"
BEGIN
BLOCK "040904b0"
BEGIN
VALUE "CompanyName", "ClassicAPI Team"
VALUE "FileDescription", "ClassicAPI - modern WoW API backport for 1.12.1"
VALUE "FileVersion", "@CLASSICAPI_VER_STRING@"
VALUE "InternalName", "ClassicAPI"
VALUE "LegalCopyright", "Licensed under GPLv3"
VALUE "OriginalFilename", "ClassicAPI.dll"
VALUE "ProductName", "ClassicAPI"
VALUE "ProductVersion", "@CLASSICAPI_VER_STRING@"
END
END
BLOCK "VarFileInfo"
BEGIN
// 0x0409 = U.S. English, 1200 = Unicode codepage.
VALUE "Translation", 0x409, 1200
END
END
+3111 -344
View File
File diff suppressed because it is too large Load Diff
+3 -1
View File
@@ -56,7 +56,9 @@ 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) \
F(CheckStack, lua_checkstack, LUA_CHECK_STACK)
#define CLASSICAPI_BIND_LUA(Name, Typedef, Offset) \
const Typedef##_t Name = reinterpret_cast<Typedef##_t>(Offsets::Offset);
+4
View File
@@ -99,6 +99,8 @@ 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);
using lua_checkstack_t = int(__fastcall *)(void *L, int size);
extern const lua_isnumber_t IsNumber;
extern const lua_isstring_t IsString;
@@ -140,6 +142,8 @@ 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;
extern const lua_checkstack_t CheckStack;
// Returns the global `lua_State *` (read on demand from the engine's global).
// Callable outside a Lua callback, e.g. during LoadScriptFunctions setup.
+738
View File
@@ -140,6 +140,23 @@ enum Offsets {
// and call the inner invoker, which is self-contained.
FUN_GAMETOOLTIP_SCRIPT_RESOLVER = 0x005295D0,
FUN_FRAME_INVOKE_SCRIPT = 0x00704D50,
// The other per-object tooltip builders, co-hooked the same way as
// FUN_GAMETOOLTIP_BUILD_ITEM to back OnTooltipSetSpell / OnTooltipSetUnit /
// OnTooltipSetGameObject (see Tooltip::SetEvents). Each is the single funnel
// its Set*/mouseover paths converge on and clears the tooltip
// (FUN_00530050) before repopulating, so firing after it covers every way
// that object type gets set:
// - Spell FUN_GAMETOOLTIP_BUILD_SPELL_TOOLTIP (0x0052E610, 7 stack args,
// RET 0x1c) — SetSpell/SetSpellByID/SetTalent/SetShapeshift funnel.
// - Unit (0x00529fe0, __thiscall(self, guid*), RET 4) — SetUnit +
// the two engine mouseover paths (FUN_004919d0 / FUN_00492890).
// Returns an int the caller (Script_GameTooltip_SetUnit) tests, so the
// co-hook must forward the original's return value.
// - GameObject (0x0052aa20, __thiscall(self, guid*), RET 4) — the GO
// hover populator; resolves the GO (OBJ_TYPE_GAMEOBJECT) and writes the
// GO field at +0x370.
FUN_GAMETOOLTIP_BUILD_UNIT = 0x00529FE0,
FUN_GAMETOOLTIP_BUILD_GAMEOBJECT = 0x0052AA20,
FUN_GAMETOOLTIP_ADD_LINE = 0x00530270, // __thiscall(self, left, right, lColorBGRA*, rColorBGRA*, wrap)
OFF_GAMETOOLTIP_NUM_LINES = 0x31C, // int — live line count (AddLine index; +0x320 is the cap)
// The displayed item's identity is read via the existing
@@ -287,6 +304,18 @@ enum Offsets {
// the item isn't in the player's bag/equipment.
FUN_GAMETOOLTIP_BUILD_ITEM_LINK = 0x0052AE00,
// CGItem → decorated instance display name. __thiscall(ecx = CGItem *,
// char *outBuf, uint outSize). Reads the instance's random-suffix ID
// off the descriptor (+0x98, gated on the broken flag) and writes the
// plain — uncolored, unbracketed — display name into outBuf:
// "Ethereum Torque of the Sorcerer" when a suffix is present, the base
// ItemStats name otherwise (via the item cache + ItemRandomProperties
// localized suffix at record +0x1c). This is the inner name builder
// FUN_GAMETOOLTIP_BUILD_ITEM_LINK wraps in brackets, so the name it
// produces matches GetItemLink's bracketed name and modern
// C_Item.GetItemName exactly. Inner formatter FUN_005D8B00.
FUN_ITEM_BUILD_INSTANCE_NAME = 0x005D8BC0,
// Engine's inventory swap-and-send. Same primitive
// `Script_EquipCursorItem` (0x00489660) uses after the cursor's
// source location has been resolved. Sends opcode 0x10D
@@ -442,6 +471,39 @@ enum Offsets {
// pointer the inventory routines expect.
FUN_RESOLVE_UNIT_TOKEN = 0x00515940,
// Set the player's current target to a GUID —
// `__fastcall(uint64_t *guid)`. Validates the GUID resolves to a unit
// (typemask 8) then commits the selection (CMSG_SET_SELECTION +
// client-side target). This is what `Script_TargetUnit` (0x004899D0)
// calls after resolving its token→GUID; we call it directly with a
// GUID we already have (e.g. a totem creature). NOTE: reads `guid[0]`
// (lo) and `guid[1]` (hi) through the pointer.
FUN_TARGET_BY_GUID = 0x00489A40,
// Tab-targeting internals, shared with our backported TargetNearest* /
// TargetDirection* family (`target/Nearest.cpp`).
//
// `FUN_TARGET_CANDIDATE_VALID` (0x00493E40) — the engine's per-mode
// candidate validity predicate used by the native `TargetNearestEnemy`
// / `TargetNearestFriend` cycle. `__fastcall(CGUnit *player /*ecx*/,
// CGUnit *candidate /*edx*/, int mode) → int` (nonzero = valid). Modes:
// 1 = enemy (attackable + alive + not-critter), 2 = friend (assistable +
// alive), 3 = party, 4 = raid. Wraps the reaction cores
// `FUN_00606980` (can-attack) / `FUN_006066f0` (can-assist), so calling
// it gives us the engine's exact hostility semantics for free.
FUN_TARGET_CANDIDATE_VALID = 0x00493E40,
// `FUN_SET_SELECTION_BY_GUID` (0x00493540) — canonical target setter the
// native tab-cycle commits through. `__cdecl(uint32_t guidLo, uint32_t
// guidHi)`. Validates + fires CMSG_SET_SELECTION + client target +
// event 0xC3, and maintains the current-selection globals below.
// Passing {0,0} clears the target.
FUN_SET_SELECTION_BY_GUID = 0x00493540,
// Current target-selection GUID (lo/hi), written by
// `FUN_SET_SELECTION_BY_GUID`. We read it to detect whether the player's
// target changed out from under an in-progress tab-cycle.
VAR_CURRENT_SELECTION_GUID_LO = 0x00B4E2D8,
VAR_CURRENT_SELECTION_GUID_HI = 0x00B4E2DC,
// Local-player CGObject-like global. Not the same pointer as
// ResolveUnitToken("player") returns — that one's the canonical
// CGPlayer_C used by inventory etc. This pointer's +0xC0 field
@@ -504,6 +566,38 @@ 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)`.
// `0x00605570` is really the engine's **creature-type resolver** — it's
// `Script_UnitCreatureType`'s (`0x0051A280`) inner helper and returns the
// `CreatureType.dbc` id (1=Beast, 3=Demon, 7=Humanoid, …), via three
// paths in order: a display-override at descriptor `+0x212`, then the
// creature-cache type at `[unit+0xB30]+0x18`, then the player-race type
// at `ChrRaces[[unit+0x110]+0x78]+0x24`. `FUN_UNIT_CREATURE_TYPE` is the
// canonical name; `FUN_UNIT_PET_MINION_CLASS` is a historical alias for
// the same address.
//
// The pet/minion use: the unit-title builder `FUN_0052FD30` calls it as
// `(fn(unit) != 1) + 1` → 1 = "X's Pet" title, 2 = "X's Minion". That
// works because it's really testing "is this a Beast (type 1)": hunter
// pets are Beasts, warlock minions are Demons (type 3) — so `!= 1`
// cleanly separates them, which neither the GUID (pets and guardians
// share the 0xF14… prefix) nor UNIT_FLAG_PLAYER_CONTROLLED reliably does.
FUN_UNIT_PET_MINION_CLASS = 0x00605570,
FUN_UNIT_CREATURE_TYPE = 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
@@ -587,6 +681,25 @@ enum Offsets {
// The live (new) value is NOT passed — read it from the object (the
// engine's bag callback reads the invMgr GUID array).
// ---- Unit-event token observers (Unit::TokenObserver) ---------------
// The engine makes target/party/raid/pet/mouseover units fire per-token
// unit events (UNIT_HEALTH, UNIT_MANA, UNIT_AURA, …) by watching bank-UNIT
// descriptor fields: FUN_0051bbb0 loops event index i in
// [0, EVENT_NAME_UNIT_MAX); for each whose static name slot is non-null it
// registers a bank-3 observer on field i*4 (per-index size below) with
// callback FUN_0051bd50 → the broadcast FUN_00515e50(guid, i), which fires
// event i once per token referencing the guid. The FIELD INDEX IS THE EVENT
// ID (health field +0x40 → event 16 = UNIT_HEALTH). Unit::TokenObserver
// replicates that exact loop with a caller's own callback to make synthetic
// tokens (focus, nameplateN) first-class; the registrar always appends a
// fresh node (FUN_00467f00, no dedup), so ours coexists with the engine's —
// a unit that is also the target fires both "target" and our token. The
// broadcast always fires with format "%s" + the token (DAT_0082e280).
// Per-index watched size (FUN_0051bbb0's switch): i∈{0,2,4,10}=8,
// 0x29=0xD8 (UNIT_FIELD_AURA), 0x6B=0x30, 0xA7/0xAE=0x1C, else 4.
VAR_EVENT_NAME_TABLE_STATIC = 0x00BE1198, // char*[] boot name array; slot i (base+i*4) non-null ⇒ real event i
EVENT_NAME_UNIT_MAX = 0xB6, // FUN_0051bbb0's loop bound
// The engine's inventory observer setup — registers the bag-slot
// GUID-field observers above, once per enter-world (sole caller is the
// enter-world initializer FUN_004908C0, latched by DAT_00B4B424).
@@ -602,6 +715,7 @@ enum Offsets {
OFF_DESC_PLAYER_EQUIP_FIRST = 0x4A8,
DESC_PLAYER_EQUIP_SLOTS = 19,
DESC_OBSERVER_BANK_PLAYER = 4,
DESC_OBSERVER_BANK_UNIT = 3, // CGUnit descriptor bank (FUN_0051bbb0's watch loop)
// ITEM_FIELD_DURABILITY, ITEM-bank-relative: descriptor-absolute
// +0xA0 (field index 0x28, which counts the 6 OBJECT fields) minus
@@ -671,11 +785,19 @@ enum Offsets {
// +0x00 ?
// +0x0C name C-string ptr (alias of UNIT_NAME for creatures)
// +0x10 subName C-string ptr ("Innkeeper", "<Master Trainer>", etc.)
// +0x1C CreatureFamily id (→ CreatureFamily.dbc), 0 = none
//
// Used by `Script_UnitName`'s creature path and by VanillaMinimapTracking's
// subname-based blip filter (`src/Blips.cpp::ExtractUnitSubName`).
OFF_UNIT_CREATURE_CACHE_ROW = 0xB30,
OFF_CREATURE_CACHE_SUB_NAME = 0x10,
// CreatureFamily id within the +0xB30 cache row — the value the engine's
// `Script_UnitCreatureFamily` (`0x0051A310`) reads via its family helper
// `FUN_006055e0` (`[unit+0xB30] ? [row+0x1C] : 0`) before mapping it
// through `CreatureFamily.dbc` for the localized name. `UnitCreatureFamilyID`
// returns the raw id. Same field as `OFF_CREATURE_FAMILY` in the creature
// cache data block, reached through the unit here.
OFF_CREATURE_CACHE_FAMILY = 0x1C,
// Pointer to the CGUnit's 8-byte GUID at `*(CGUnit + 0x08)`. Verified
// in `Script_GetInventoryItemLink` at `0x004C8CB0`-`0x004C8CB5`:
// `mov eax, [esi+8]; mov edi, [eax]; mov ecx, [eax+4]` reads the
@@ -721,6 +843,15 @@ enum Offsets {
// mana/rage/focus/energy/happiness order as POWER1..5). Vanilla
// 1.12 has 5 power types; the WotLK additions (Runes / Runic Power)
// don't exist in this descriptor layout.
// UNIT_MOD_CAST_SPEED — the cast-time multiplier float (1.0 = normal,
// <1.0 = faster). The server folds it into `SpellEntry::GetCastTime`
// (`castTime *= GetFloatValue(UNIT_MOD_CAST_SPEED)`); the client reads it
// in `FUN_006e3340` at descriptor `+0x22c`. Backs `UnitSpellHaste`
// (`haste% = (1/mult - 1) * 100`). nampower surfaces the raw float as its
// `modCastSpeed` unit field.
OFF_UNIT_MOD_CAST_SPEED = 0x22C,
UNIT_POWER_MIN_TYPE = 0,
UNIT_POWER_MAX_TYPE = 4, // happiness; types 5/6 only valid post-WotLK
@@ -746,6 +877,19 @@ enum Offsets {
// mov edx, [eax + 0x110]
// movzx eax, byte ptr [edx + 0x7B]
OFF_UNIT_DESCRIPTOR_POWER_TYPE_BYTE = 0x7B,
// UNIT_FIELD_STAT0..4 — the fully-computed (base+item+buff) primary
// stats, broadcast in the descriptor. Order Str/Agi/Sta/Int/Spirit;
// STAT4 (Spirit) = fieldIndex 0x94 → +0x250 (from the UpdateField name
// table; cross-checked in-game: descriptor +0x250 read 173→182 when a
// +9-Spirit item was equipped). What GetStat(STAT_SPIRIT) returns
// server-side; used by GetSpellBonusHealing for the Spiritual-Guidance
// (aura 175) spirit→healing conversion.
OFF_UNIT_FIELD_STAT_SPIRIT = 0x250,
// UNIT_FIELD_RESISTANCES[0] = armor (the fully-computed total), fieldIndex
// 0x95 → +0x254 (7 resistances: armor, then the 6 magic schools). What
// GetArmor() / GetResistance(SPELL_SCHOOL_NORMAL) returns; used by
// GetSpellBonusHealing for Turtle's Ironclad (aura 199) armor→healing.
OFF_UNIT_FIELD_RESISTANCE_ARMOR = 0x254,
OFF_UNIT_FIELD_FLAGS = 0xA0,
UNIT_FLAG_PLAYER_CONTROLLED = 0x08,
// Bit 19 of UNIT_FIELD_FLAGS — `Script_UnitAffectingCombat` at
@@ -816,6 +960,27 @@ enum Offsets {
UNIT_AURA_TOTAL = 48,
UNIT_AURA_VISIBLE_MASK = 0x0E, // nibble mask used by the engine's visibility gate
// PLAYER_FIELD_MOD_DAMAGE_DONE_POS/NEG — the player's per-school spell
// damage bonus. Offsets from the engine's own UpdateField name/index
// table in `.rdata` (20-byte entries `{char* name, u32 fieldIndex, u32
// count, ...}` at VA 0x83B6C8): POS = fieldIndex 0x3F5 count 7 → byte
// offset fieldIndex*4 = 0xFD4; NEG = 0x3FC → 0xFF0. Seven int32 each,
// one per school (0 physical .. 6 arcane); net bonus = POS − NEG.
//
// **Read these off `[player + OFF_CGPLAYER_INFO]` (the +0xE68 CGPlayer
// sub-struct), NOT the broadcast descriptor at +0x110.** The descriptor
// copy is never sent to the client and reads zero (verified in-game: the
// whole post-skills PLAYER descriptor region is zero on a geared L60 —
// vanilla never displayed spell power, so the server doesn't broadcast
// it). The +0xE68 struct holds the client's fully-computed value
// (gear + enchants + buffs + talents + sets baked in). Same base +
// offsets nampower's GetSpellPower uses; verified 613 generic / 647 Fire
// on a geared mage. Read by GetSpellBonusDamage in [src/spell/BonusDamage.cpp].
// (No MOD_HEALING_DONE equivalent — vanilla lacks the field entirely;
// GetSpellBonusHealing is derived instead.)
OFF_PLAYER_FIELD_MOD_DAMAGE_DONE_POS = 0xFD4,
OFF_PLAYER_FIELD_MOD_DAMAGE_DONE_NEG = 0xFF0,
// Reusable "is this spell record a user-visible aura" predicate.
// `__fastcall(spellRecord*) -> bool`. Checks Spell.dbc Attributes
// (high bit of byte at +0x18 must be clear), AttributesEx bit
@@ -903,6 +1068,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
@@ -912,6 +1093,25 @@ enum Offsets {
// RECORD pointer, not a spellID.
FUN_GET_SPELL_DURATION = 0x006EA000,
// Pure spell range check against a target:
// `char __fastcall(void *caster /*ecx*/, void *target /*edx*/,
// int spellID, char *outMinMaxFlag)`.
// Computes the spell's min/max range via `FUN_006e3480` (which folds
// in the target's bounding radius), reads both objects' world
// positions (GetPosition, vtable+0x14), and compares center-to-center
// squared distance. Returns low byte 1 = in range, 0 = out (too close
// OR too far); `*outMinMaxFlag` is set to 1 on a max-range (too far)
// failure so the caller can pick a "too close" vs "out of range" error
// string — we ignore it. Both objects must be non-null (it dereferences
// the GetPosition result without a null check). This is the geometric
// core the action-button range coloring uses: `Script_IsActionInRange`
// (`0x004E7550`) → `FUN_004E56F0` → `FUN_006E4440` → here. We call it
// directly for `C_Spell.IsSpellInRange` rather than `FUN_006E4440`,
// which additionally gates on target type and folds "range N/A" into
// the same truthy return as "in range" (so it can't yield true/false/
// nil cleanly).
FUN_SPELL_RANGE_CHECK = 0x006E47B0,
// Spell.dbc `m_durationIndex` field — pointer into SpellDuration.dbc.
// Verified via `FUN_004E44B0` (`0x004e44b0`) and `FUN_006EA000`
// (`0x006ea000`), both of which read `[spellRec + 0x78]` and use
@@ -1137,8 +1337,33 @@ enum Offsets {
// locLeft/locRight/locTop/locBottom floats}
VAR_WORLDMAP_AREA_RECORDS = 0x00C0D5BC,
VAR_WORLDMAP_AREA_COUNT = 0x00C0D5C0,
OFF_WMA_MAP_ID = 0x04,
OFF_WMA_AREA_ID = 0x08,
OFF_WMA_NAME = 0x0C,
OFF_WMA_LOC_LEFT = 0x10, // world Y at the map's left edge (max Y)
OFF_WMA_LOC_RIGHT = 0x14, // world Y at the map's right edge (min Y)
OFF_WMA_LOC_TOP = 0x18, // world X at the map's top edge (max X)
OFF_WMA_LOC_BOTTOM = 0x1C, // world X at the map's bottom edge (min X)
// AreaTrigger.dbc record (10 fields, 0x28 bytes) — instance cataloged
// in docs/DBCs.md (records slot 0x00C0E034, count 0x00C0E038). The
// client loads it but exposes NO geometry to Lua, which is why map
// addons (pfQuest et al.) ship hand-scraped trigger tables. Schema:
// {ID@+0, mapID@+4 (Map.dbc — continent or instance),
// x@+8, y@+0xC, z@+0x10 (continent-space world coords),
// radius@+0x14 (sphere trigger; 0 => box trigger),
// boxLength@+0x18, boxWidth@+0x1C, boxHeight@+0x20, boxYaw@+0x24}
VAR_AREATRIGGER_RECORDS = 0x00C0E034,
VAR_AREATRIGGER_COUNT = 0x00C0E038,
OFF_AT_MAP_ID = 0x04,
OFF_AT_X = 0x08,
OFF_AT_Y = 0x0C,
OFF_AT_Z = 0x10,
OFF_AT_RADIUS = 0x14,
OFF_AT_BOX_LENGTH = 0x18,
OFF_AT_BOX_WIDTH = 0x1C,
OFF_AT_BOX_HEIGHT = 0x20,
OFF_AT_BOX_YAW = 0x24,
// WorldMapOverlay.dbc record (17 fields, 0x44 bytes):
// {ID@+0, worldMapAreaID@+4 (-> WorldMapArea row),
@@ -1153,6 +1378,7 @@ enum Offsets {
VAR_WORLDMAP_OVERLAY_RECORDS = 0x00C0D594,
VAR_WORLDMAP_OVERLAY_COUNT = 0x00C0D598,
OFF_WMO_WORLDMAP_AREA = 0x04,
OFF_WMO_AREA_ID = 0x08, // areaID[4] @ +0x08..+0x14 (AreaTable ids revealed)
OFF_WMO_MAP_POINT_X = 0x18,
OFF_WMO_MAP_POINT_Y = 0x1C,
OFF_WMO_TEXTURE_NAME = 0x20,
@@ -1160,6 +1386,10 @@ enum Offsets {
OFF_WMO_TEXTURE_HEIGHT = 0x28,
OFF_WMO_OFFSET_X = 0x2C,
OFF_WMO_OFFSET_Y = 0x30,
OFF_WMO_HITRECT_TOP = 0x34, // hit rect in world-map canvas px (~1002x668):
OFF_WMO_HITRECT_LEFT = 0x38, // the tight clickable bounds of the landmass,
OFF_WMO_HITRECT_BOTTOM = 0x3C, // used for subzone-center placement (pfQuest's
OFF_WMO_HITRECT_RIGHT = 0x40, // pfDB["zones"] was scraped from these)
// Current world-map view selection — the globals the engine's
// current-map-name resolver (FUN_004a6cf0, behind GetMapInfo) reads:
@@ -1218,6 +1448,42 @@ enum Offsets {
// aura that's still inside its computed duration window.
FUN_ON_AURA_REMOVED = 0x00612320,
// Group-loot roll packet handlers (the inner display functions in
// LootRoll.cpp). Each is `__fastcall`; the deserialized 0x48-byte
// PendingLootRoll message struct is fully populated at entry and freed
// at exit, so read its fields BEFORE calling the original. The register
// the message arrives in differs per handler (matched in
// loot/History.cpp): ROLL passes it in ECX, WON / ALL_PASSED in EDX.
// Backs the C_LootHistory backport (loot/History.cpp) — 1.12 has no
// loot-history store, so we reconstruct one from these packets.
FUN_LOOT_ROLL_HANDLER = 0x0061C0B0, // SMSG_LOOT_ROLL (msg=ECX)
FUN_LOOT_ROLL_WON_HANDLER = 0x0061B9E0, // SMSG_LOOT_ROLL_WON (msg=EDX)
FUN_LOOT_ALL_PASSED_HANDLER = 0x0061B640, // SMSG_LOOT_ALL_PASSED (msg=EDX)
// SMSG_LOOT_START_ROLL (opcode 0x2A1) handler — `__fastcall(ECX = packet)`,
// RET 0. The result packets (ROLL/WON/PASSED) carry 0 for the item's
// random suffix/seed; only START_ROLL does. It deserializes a fresh
// PendingLootRoll node (same 0x20/0x24 field offsets as the message) and
// publishes it as the store head at VAR_LOOTROLL_STORE_HEAD, so we read
// the suffix straight off that node right after the original runs.
FUN_LOOT_START_ROLL_HANDLER = 0x0061B310,
VAR_LOOTROLL_STORE_HEAD = 0x00C4DC00, // PendingLootRoll* — newest node
// PendingLootRoll message field offsets, derived from the SMSG_LOOT_ROLL
// deserializer FUN_0061BFA0 (reads GUID/u32/byte fields off the packet).
OFF_LOOTROLL_SOURCE_GUID = 0x10, // u64 looted-object GUID (roll identity)
OFF_LOOTROLL_SLOT = 0x18, // u32 loot slot
OFF_LOOTROLL_ITEM_ID = 0x1C, // u32 itemID
// Random-enchant fields for the item link `item:id:enchant:suffix:unique`.
// +0x20 is the suffix/random-property ID the engine's item-name builder
// FUN_005D8B00 uses (indexes the random-properties DBC for the "of the X"
// name); +0x24 is the paired seed/unique value.
OFF_LOOTROLL_ITEM_SUFFIX = 0x20, // i32 random suffix / property ID
OFF_LOOTROLL_ITEM_SEED = 0x24, // u32 random seed (link unique field)
OFF_LOOTROLL_ROLLER_GUID = 0x30, // u64 roller / winner GUID
OFF_LOOTROLL_ROLL_NUM = 0x38, // u8 roll (1..100); high bit = didn't roll
OFF_LOOTROLL_ROLL_TYPE = 0x40, // u32 rollType: 0 = need, 2 = greed
// CGPlayer-side sub-struct, allocated for any player-controlled
// unit (local self, target, party, raid, inspect targets — all of
// them). Holds player-specific data that's *not* in the broadcast
@@ -1251,6 +1517,22 @@ enum Offsets {
// (see OFF_UNIT_FIELD_CHANNEL_SPELL below); PLAYER_FLAGS only
// exists on the +0xE68 sub-struct here.
OFF_CGPLAYER_INFO = 0xE68,
// Explored-areas bitfield inside the CGPlayer info sub-struct
// (`[player+0xE68] + 0xE6C`): bit `AreaBit` set == that AreaTable area
// is explored. 0x100 bytes. Read by the discovered-overlay rebuild
// FUN_004a67a0 as `bits[AreaBit>>3] & (1 << (AreaBit&7))`.
OFF_PLAYER_EXPLORED_BITS = 0xE6C,
// 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`.
@@ -1264,6 +1546,41 @@ enum Offsets {
// `0x0048BACF` / `0x0048BADC`.
OFF_CGOBJECT_VTBL_GET_POSITION = 0x14,
// CGUnit movement-block orientation (facing) in radians, 0..2*pi. Lives
// right after the {x,y,z} position block (at +0x9B8), i.e. pos+0xC. The
// `GetPosition` virtual copies only x/y/z into the caller's buffer, so
// read facing off the object directly. Backs `GetPlayerFacing`. Empirically
// located by probing the player object across a known 180-degree turn
// (value flipped by ~pi, stayed in range); mirrored at +0x9F8 / +0xC98
// (last-sent / render copies) — +0x9C4 is the authoritative one.
OFF_UNIT_FACING = 0x9C4,
// CGObject `m_objectType` field — 0 = (base) object, 1 = item,
// 2 = container, 3 = unit, 4 = player, 5 = gameobject, … Read as
// `*(int *)(obj + 0x14)`. (Distinct from GET_POSITION above, which
// is a *vtable* byte-offset that happens to also be 0x14.)
OFF_CGOBJECT_TYPE_ID = 0x14,
OBJECT_TYPE_UNIT = 3,
OBJECT_TYPE_PLAYER = 4,
// World line-segment collision test (client-side LoS core):
// `char __fastcall(const Vec3 *start /*ecx*/, const Vec3 *end /*edx*/,
// int unused, Vec3 *hitOut, float *tOut, uint32 flags)`.
// Returns nonzero if the ray hit world geometry; `*tOut` is the hit
// fraction along start→end (t in [0,1] ⇒ hit lies between the two
// points ⇒ blocked; t outside [0,1] or no hit ⇒ clear). `flags`
// selects geometry: 0x100111 = terrain + WMO (the LoS combo;
// 0x100171 also tests M2 doodads but crashes in some dungeons per
// UnitXP_SP3). Thin `__fastcall` shim that tail-calls the real
// intersect `FUN_0069BFF0`; verified in-binary and cross-checked
// against UnitXP_SP3's `CWorld_Intersect` (same VA + flags).
FUN_CWORLD_INTERSECT = 0x00672170,
// Collision-box height of a unit, read from its CMovement sub-struct
// (`[unit + OFF_UNIT_MOVEMENT_INFO_PTR]`) at `+0xB4`. Used as the
// eye-height offset for LoS so foot-to-foot traces don't false-block
// on terrain. (CMovement pointer offset 0x118 = OFF_UNIT_MOVEMENT_INFO_PTR.)
OFF_CMOVEMENT_COLLISION_BOX_HEIGHT = 0xB4,
// Quest list array inside the `+0xE68` sub-struct. 20 fixed
// slots of 0xC (12) bytes each — `questID` at slot+0x00, and
// some flags/state in the remaining 8 bytes. Walked by
@@ -1344,6 +1661,25 @@ enum Offsets {
// Reads bagID at Lua stack[1] and slot at stack[2], validates them, and
// returns the inventory manager + linear slot ready to feed into GetItemBySlot.
FUN_PACK_BAG_SLOT = 0x004F9820,
// Equipped-bag container-GUID getter — `uint64 __fastcall(uint bagIndex0)`
// where `bagIndex0` is 0-based (Lua bagID 1..4 → 0..3; 4..9 are bank bags,
// gated on the bank-open globals). Returns the CGContainer GUID of the bag
// equipped in that slot, or 0 if none. This is the internal PackBagSlot
// uses to resolve bags 1..4: get the bag GUID here, resolve the container
// via FUN_OBJECT_RESOLVE_BY_GUID(OBJ_TYPE_CONTAINER, guid), then call the
// container's vtable[+OFF_CONTAINER_GET_INVENTORY] to get the inventory
// object GetItemBySlot indexes. Lets us enumerate bag contents in pure C++
// without PackBagSlot's Lua-stack coupling. (Reads GUID arrays at
// 0x00BDD060 lo / 0x00BDD064 hi, stride 8.)
FUN_GET_EQUIPPED_BAG_GUID = 0x004F93E0,
// CGContainer vtable slot (byte offset into the vtable) whose method
// returns the container's inventory object — `void* __thiscall(container)`.
// The inventory object's slot count lives at its +0x00 (OFF_INVMGR_SLOT_COUNT).
OFF_CONTAINER_GET_INVENTORY = 0x10,
// Backpack (bagID 0) linear-slot base in the player invMgr GUID/item
// arrays: a 1-based backpack slot S maps to linear (S - 1) + 23. Matches
// PackBagSlot's `*param_3 += 0x17` for the backpack branch.
BACKPACK_LINEAR_BASE = 23,
// Player inventory manager layout — used for direct GUID-array reads
// that bypass `GetItemBySlot`'s bank gate at `0x006228C1`.
// +0x00 uint32 max slot count
@@ -1452,6 +1788,17 @@ enum Offsets {
// an item, swaps with the bag slot). Used by `EquipItemByName` to
// pick up the source item before dispatching to the equip helpers.
FUN_SCRIPT_PICKUP_CONTAINER_ITEM = 0x004F9B30,
// Container-content cursor-pickup PRIMITIVE — the plain-pickup branch
// inside `Script_PickupContainerItem`'s state machine, called directly
// (no Lua stack, no state-machine modes).
// void __fastcall(uint linearSlot, int count, uint itemGuidLo,
// uint itemGuidHi, uint containerGuidLo,
// uint containerGuidHi, int flag)
// Self-resolves the player, sets the cursor-state globals
// (VAR_CURSOR_ITEM_GUID_*, cursor type = 1) and fires the cursor-update
// event itself. Does NOT apply the cursor-empty / item-lock guards the
// Script wrapper does — callers replicate those (Item::Cursor::PickupBagItem).
FUN_CURSOR_PICKUP_ITEM = 0x00494B60,
// `CGItem::UseItem` — the engine's actual "use this item" primitive.
// It's the fallback dispatch in `Script_UseContainerItem` (the call
// site at 0x004FA430 after every special-cursor-mode branch is
@@ -1579,6 +1926,16 @@ enum Offsets {
// last use"). Negative = single-use, destroyed when count hits 0.
// `GetItemCount(includeUses=true)` uses abs() for the multiplier.
OFF_DESCRIPTOR_SPELL_CHARGES_0 = 0x28,
// ITEM_FIELD_ENCHANTMENT block (fields 16..36, per the +0x48
// enchant-charges note above) — 7 enchant slots, 3 dwords each
// `{enchantID, duration, charges}`. Slot 0 = permanent enchant,
// slot 1 = temporary (weapon oils / sharpening stones). The
// enchant ID indexes SpellItemEnchantment.dbc. Block base = field
// 16 = +0x40 (field 15 FLAGS at +0x3C precedes it); slot stride
// 0x0C, enchant ID at slot+0x00. Used by spell/BonusDamage.cpp to
// fold enchant-granted spell power into GetSpellBonusDamage.
OFF_DESCRIPTOR_ENCHANTMENT_ID = 0x40,
DESCRIPTOR_ENCHANTMENT_SLOT_STRIDE = 0x0C,
// ITEM_FIELD_DURABILITY (current) and ITEM_FIELD_MAXDURABILITY (max) live
// adjacent to each other in the descriptor as plain dwords. Verified in
// `Script_GetInventoryItemBroken` (`0x004C8590`): after resolving the
@@ -1587,6 +1944,12 @@ enum Offsets {
// no durability concept (consumables, materials) have both fields 0.
OFF_DESCRIPTOR_DURABILITY = 0xA0,
OFF_DESCRIPTOR_MAX_DURABILITY = 0xA4,
// Per-instance "readable text" id (letters / mail-scroll items carry a
// server-assigned text id here). Drives the `readable` return of
// `Script_GetContainerItemInfo` (`0x004F9670`): at `0x004F97C1` it reads
// `[descriptor+0x9C]` and, when non-zero, reports the slot as readable
// even when the static ItemStats page-text is 0. Field index 0x27.
OFF_DESCRIPTOR_READABLE_TEXT_ID = 0x9C,
// Per-item repair-cost helper used by Script_GameTooltip_SetInventoryItem
// (3rd return value). __fastcall(ecx = CGItem *) -> int copperCost.
@@ -2023,6 +2386,56 @@ enum Offsets {
OFF_CHRRACES_FILENAME = 0x3C,
OFF_CHRRACES_NAMES = 0x44,
// CreatureFamily.dbc — pet/beast family metadata. Standard 5-DWORD
// class shape (instance 0x00C0DE74; records 0x00C0DE7C, count
// 0x00C0DE80 — the same globals `Script_UnitCreatureFamily`
// (`0x0051A310`) bounds-checks/indexes). 72-byte stride, familyID-keyed
// and sparse (10/13/14/22/… are unused). Localized `Name[]` at +0x20
// (the engine reads `record + 0x20 + locale*4`), `IconFile` texture-path
// string at +0x44 (last field; empty for warlock-pet families). Backs
// `C_CreatureInfo.GetCreatureFamilyInfo` / `GetCreatureFamilyIDs`.
VAR_CREATUREFAMILY_RECORDS = 0x00C0DE7C,
VAR_CREATUREFAMILY_COUNT = 0x00C0DE80,
OFF_CREATUREFAMILY_NAMES = 0x20,
OFF_CREATUREFAMILY_ICON = 0x44,
// CreatureType.dbc — pet/NPC classification (1=Beast … 11=Totem;
// contiguous ids, 11 rows). Records 0x00C0DE2C, count 0x00C0DE30 (the
// globals `Script_UnitCreatureType` (`0x0051A280`) bounds-checks/indexes),
// localized `Name[]` at +0x04 (engine reads `record + 0x04 + locale*4`).
// Backs `C_CreatureInfo.GetCreatureTypeInfo` / `GetCreatureTypeIDs`;
// `UnitCreatureTypeID` returns the id `FUN_UNIT_CREATURE_TYPE` resolves.
VAR_CREATURETYPE_RECORDS = 0x00C0DE2C,
VAR_CREATURETYPE_COUNT = 0x00C0DE30,
OFF_CREATURETYPE_NAMES = 0x04,
// Race → faction group, mirroring the player branch of
// `Script_UnitFactionGroup` (`0x00516630`) — backs
// `C_CreatureInfo.GetFactionInfo(raceID)`. Chain:
// ChrRaces[race] + 0x08 → FactionTemplate id
// FactionTemplate[id] + 0x0C → group mask (ourMask)
// scan FactionGroup for the row whose bit is set (and whose
// localized name is non-empty — that non-empty test is what
// skips the always-set "Player" bit and lands on Alliance/Horde)
// push { groupTag = row+0x08 (english), name = row+0x0C+locale*4 }
// FactionTemplate is a pointer-array DBC (records[id]); FactionGroup
// is stored CONTIGUOUSLY (records-base + i*stride, direct field reads,
// NOT a pointer array), so iterate it by hand rather than via DBC::Record.
// FactionTemplate globals match docs/DBCs.md; the FactionGroup
// records-base/count globals are the ones the engine actually
// dereferences per the disassembly (0x00C0DD5C / 0x00C0DD60), which
// differ from DBCs.md's standard-shape tabulation for this table.
OFF_CHRRACES_FACTION_TEMPLATE = 0x08,
VAR_FACTIONTEMPLATE_RECORDS = 0x00C0DD3C,
VAR_FACTIONTEMPLATE_COUNT = 0x00C0DD40,
OFF_FACTIONTEMPLATE_GROUP_MASK = 0x0C,
VAR_FACTIONGROUP_RECORDS = 0x00C0DD5C,
VAR_FACTIONGROUP_COUNT = 0x00C0DD60,
FACTIONGROUP_STRIDE = 0x30,
OFF_FACTIONGROUP_BIT = 0x04,
OFF_FACTIONGROUP_ENGLISH = 0x08,
OFF_FACTIONGROUP_NAMES = 0x0C,
// AreaTable.dbc — used by `Script_GetCharacterInfo` to resolve
// each character's last-known-area to a localized zone name.
// Same shape as the other DBCs (records pointer + count globals,
@@ -2030,6 +2443,95 @@ enum Offsets {
VAR_AREATABLE_RECORDS = 0x00C0E048,
VAR_AREATABLE_COUNT = 0x00C0E04C,
OFF_AREATABLE_NAMES = 0x2C,
// AreaBit @ +0x0C: index into the player's explored-areas bitfield
// (OFF_PLAYER_EXPLORED_BITS). Gate @ +0x28: when < 0 the area needs no
// exploration (always counts explored); when >= 0 the AreaBit check
// applies. Source: the discovered-overlay rebuild FUN_004a67a0.
OFF_AREATABLE_AREA_BIT = 0x0C,
OFF_AREATABLE_EXPLORE_GATE = 0x28,
// TaxiNodes.dbc (16 fields, 0x40 bytes) — flight points. records/count
// per docs/DBCs.md. mapID @ +0x04 is the Map.dbc continent; x/y/z floats;
// Name[8] localized @ +0x14; the two MountCreatureID columns are
// [Horde @ +0x38, Alliance @ +0x3C] (0 = that faction has no flight
// master at this node — both 0 = a transport/system waypoint).
VAR_TAXINODES_RECORDS = 0x00C0D6C0,
VAR_TAXINODES_COUNT = 0x00C0D6C4,
OFF_TAXINODE_MAP_ID = 0x04,
OFF_TAXINODE_X = 0x08,
OFF_TAXINODE_Y = 0x0C,
OFF_TAXINODE_Z = 0x10,
OFF_TAXINODE_NAME = 0x14,
OFF_TAXINODE_HORDE_MOUNT = 0x38,
OFF_TAXINODE_ALLIANCE_MOUNT = 0x3C,
// Live taxi session — the open flight master's reachable node list,
// populated between TAXIMAP_OPENED/CLOSED (count 0 otherwise). Backs the
// legacy NumTaxiNodes/TaxiNodeName/TaxiNodePosition/TaxiNodeGetType Lua
// globals. Node slots are 0x20-stride records at VAR_TAXI_NODE_ARRAY:
// +0x00 = pointer to the node's TaxiNodes.dbc record (id @ +0, name @
// +0x14), +0x04 = flight-map x (0-1 float), +0x08 = y. Count at
// VAR_TAXI_NODE_COUNT. Derived from Script_TaxiNode* (FUN_004dc380 name,
// FUN_004dc3b0 position); the reachability type comes from
// FUN_TAXI_NODE_TYPE.
VAR_TAXI_NODE_COUNT = 0x00BB4AA8,
VAR_TAXI_NODE_ARRAY = 0x00BB4F10,
TAXI_NODE_STRIDE = 0x20,
OFF_TAXI_SLOT_RECORD = 0x00,
OFF_TAXI_SLOT_X = 0x04,
OFF_TAXI_SLOT_Y = 0x08,
// __fastcall(uint slot0Based) -> const char* ("CURRENT" / "REACHABLE" /
// "DISTANT" / other) — the node's reachability for the open session.
FUN_TAXI_NODE_TYPE = 0x004DC4E0,
// Per-slot computed route (the chain that TakeTaxiNode sends to the
// server). Derived from FUN_004dc8d0 (the TakeTaxiNode worker) and the
// route builder FUN_004dbce0:
// +0x0C (byte) nonzero when the multi-hop route array below is built
// +0x14 (int*) array of TaxiNodes.dbc IDs, current->dest in flight
// order, length at +0x18
// +0x18 (int) route length (node count; hops = count - 1)
// A direct hop (a TaxiPath edge exists current->dest) is flown as just
// {current, dest} and does NOT populate +0x14 — see VAR_TAXI_ADJACENCY.
OFF_TAXI_SLOT_MULTIHOP = 0x0C,
OFF_TAXI_SLOT_ROUTE = 0x14,
OFF_TAXI_SLOT_ROUTE_COUNT = 0x18,
// Pointer to the CURRENT flight master's TaxiNodes.dbc record (id @ +0);
// the origin of every route. Used by FUN_004dc8d0 / FUN_004dbce0.
VAR_TAXI_CURRENT_REC = 0x00BB4A80,
// Node x node -> pathID adjacency matrix (256-wide rows, int cells; cell
// [from*0x100 + to] is the direct TaxiPath id, or 0 if none). Backs the
// direct-hop test FUN_004dbad0. IDs are 1..256.
VAR_TAXI_ADJACENCY = 0x00B7365C,
TAXI_ADJACENCY_DIM = 0x100,
// TaxiPath.dbc (4 fields, 0x10 bytes): {id, fromNode@+0x04, toNode@+0x08,
// cost}. A real flight master is a node some flight path connects to;
// Northshire Abbey / spurious duplicate rows / standalone markers are not
// endpoints and so are filtered out of GetTaxiNodesForMap.
VAR_TAXIPATH_RECORDS = 0x00C0D698,
VAR_TAXIPATH_COUNT = 0x00C0D69C,
OFF_TAXIPATH_FROM = 0x04,
OFF_TAXIPATH_TO = 0x08,
OFF_TAXIPATH_COST = 0x0C,
// TaxiPathNode.dbc — the per-path flight waypoints. 1-based
// record-pointer array (records/count slots per docs/DBCs.md). Record
// (verified against the extracted DBC + the loading-screen spline
// reader FUN_004075A0, which reads mapID@+0xC, X@+0x10, Y@+0x14):
// {ID@+0, pathID@+4, nodeIndex@+8, mapID@+0xC,
// X@+0x10, Y@+0x14, Z@+0x18 (float), flags@+0x1C, delay@+0x20}
// Waypoints of a path share pathID and order by nodeIndex.
VAR_TAXIPATHNODE_RECORDS = 0x00C0D6AC,
VAR_TAXIPATHNODE_COUNT = 0x00C0D6B0,
OFF_TAXIPATHNODE_PATH_ID = 0x04,
OFF_TAXIPATHNODE_INDEX = 0x08,
OFF_TAXIPATHNODE_MAP = 0x0C,
OFF_TAXIPATHNODE_X = 0x10,
OFF_TAXIPATHNODE_Y = 0x14,
OFF_TAXIPATHNODE_Z = 0x18,
// Engine player-info cache — populated by the
// SMSG_NAME_QUERY_RESPONSE handler (opcode 0x51) at 0x005551A0,
@@ -2806,6 +3308,29 @@ enum Offsets {
// size matches Spell.dbc's row count. Pre-login the slot is NULL.
VAR_PLAYER_SPELL_BITMAP = 0x00B710FC,
// Player spell-knowledge writers — the two functions that mutate the
// bitmap above. Learn (`FUN_004b25b0`) sets the bit, rebuilds the
// spellbook arrays, and fires SPELLS_CHANGED; unlearn (`FUN_004b2c50`)
// clears the bit and removes the spell. Both `__fastcall`: learn is
// (uint spellID, int notify, uint replacedSpellID), unlearn is
// (uint spellID, int). Co-hooked (spell/Info.cpp) to bump
// Player::StatSignal so a talent respec invalidates GetSpellBonusHealing's
// talent-conversion cache. They fire at login (SMSG_INITIAL_SPELLS) and on
// each learn/unlearn — never per-frame, so cool hook targets.
FUN_LEARN_SPELL = 0x004B25B0,
FUN_UNLEARN_SPELL = 0x004B2C50,
// 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
@@ -3261,6 +3786,69 @@ enum Offsets {
// shape from SMSG_PARTY_MEMBER_STATS_FULL).
FUN_GROUP_MEMBER_STATS_LOOKUP = 0x00496400,
OFF_GROUP_MEMBER_AREA_ID = 0x14,
// `+0x08` status-flags byte of the stats block (see field map above);
// bit 0 is the online flag `Script_UnitIsConnected` tests. Sibling
// `FUN_GROUP_MEMBER_SLOT_LOOKUP` (`0x00496420`, `__fastcall(u64 *guid)
// → member slot*`) is the presence fallback UnitIsConnected consults
// when no stats block exists — a non-null slot means the member is
// present (and thus connected). Both take a pointer to the 64-bit GUID.
OFF_GROUP_MEMBER_STATUS_FLAGS = 0x08,
GROUP_MEMBER_STATUS_ONLINE = 0x1,
FUN_GROUP_MEMBER_SLOT_LOOKUP = 0x00496420,
// Health/power fields within the two group-member blocks — the
// out-of-range fallback the engine's UnitHealth/UnitMana (and their Max
// variants) use when a groupmate has no live CGUnit (different map / out
// of range), sourced from SMSG_PARTY_MEMBER_STATS. The two blocks have
// DIFFERENT layouts (all u16):
//
// Stats block (FUN_GROUP_MEMBER_STATS_LOOKUP): statusFlags@+8,
// powerType@+9, health@+0xa, maxHealth@+0xc, power@+0xe, maxPower@+0x10,
// level@+0x12, areaId@+0x14. The full record.
// Raid slot (FUN_GROUP_MEMBER_SLOT_LOOKUP): powerType@+0x58,
// health@+0x5c, maxHealth@+0x5e, power@+0x60, maxPower@+0x62. Leaner —
// no level/area/flags.
//
// Power values are raw (divide by the power divisor for display). Verified
// from Script_UnitHealth (0x005174d0) / Script_UnitHealthMax (0x005175b0)
// / Script_UnitMana (0x00517670) / Script_UnitManaMax (0x005177e0).
OFF_GROUP_MEMBER_STATS_HEALTH = 0xa,
OFF_GROUP_MEMBER_STATS_MAX_HEALTH = 0xc,
OFF_GROUP_MEMBER_STATS_POWER_TYPE = 0x9,
OFF_GROUP_MEMBER_STATS_POWER = 0xe,
OFF_GROUP_MEMBER_STATS_MAX_POWER = 0x10,
OFF_GROUP_MEMBER_STATS_LEVEL = 0x12,
OFF_RAID_SLOT_POWER_TYPE = 0x58,
OFF_RAID_SLOT_HEALTH = 0x5c,
OFF_RAID_SLOT_MAX_HEALTH = 0x5e,
OFF_RAID_SLOT_POWER = 0x60,
OFF_RAID_SLOT_MAX_POWER = 0x62,
// Remaining raid-member-struct fields, mapped from Script_GetRaidRosterInfo
// (0x004bb560). Same struct FUN_GROUP_MEMBER_SLOT_LOOKUP returns; all
// object-independent (readable for out-of-range raiders). Name + class are
// NOT here — GetRaidRosterInfo reads those from the NameCache by GUID.
OFF_RAID_SLOT_SUBGROUP = 0x08, // u32, 0-based (roster UI shows +1)
OFF_RAID_SLOT_RANK = 0x0c, // u32
OFF_RAID_SLOT_FLAGS = 0x18, // u32
OFF_RAID_SLOT_LEVEL = 0x22, // u16
OFF_RAID_SLOT_AREA_ID = 0x24, // u16 -> AreaTable.dbc
RAID_SLOT_FLAG_ONLINE = 0x1,
RAID_SLOT_FLAG_DEAD = 0x4,
// Group-member aura spell-ID arrays — the out-of-range fallback the
// engine's Script_UnitBuff (0x00519500) / Script_UnitDebuff (0x005198f0)
// read when a groupmate has no live CGUnit. The server transmits a
// member's current auras (spell IDs only — no caster/duration/stacks) in
// SMSG_PARTY_MEMBER_STATS via GROUP_UPDATE_FLAG_AURAS (buffs, slots 0..31)
// + GROUP_UPDATE_FLAG_AURAS_NEGATIVE (debuffs, slots 32..47); the client
// keeps them in the two group-member blocks. Both arrays are 48 u16
// entries (UNIT_AURA_TOTAL) indexed by the same absolute slot the
// descriptor uses. Spell IDs are truncated to u16 on the wire, so custom
// IDs > 65535 come through wrong (same limitation as the built-in
// UnitBuff/UnitDebuff). Read the party array only when the member's online
// flag (OFF_GROUP_MEMBER_STATUS_FLAGS bit 0) is set — the engine does.
OFF_GROUP_MEMBER_STATS_AURAS = 0x1a, // party stats block (FUN_00496400)
OFF_RAID_SLOT_AURAS = 0x64, // raid slot (FUN_00496420)
// Per-slot GUID tables — used to map party/raid token strings
// ("party1", "raid17") to GUIDs without going through
@@ -3379,6 +3967,46 @@ 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_checkstack(L, n)` — `int __fastcall(L /*ecx*/, n /*edx*/)`.
// Ensures room for `n` more stack values, growing if needed; returns 0
// (without growing) when `(top-base)/16 + n` would exceed LUA_MAXCSTACK
// (0x800 = 2048), 1 otherwise. Verified by the `cmp reg, 0x800` overflow
// test at 0x006F2F43. Needed by any C function that pushes an unbounded
// number of results (e.g. `strsplit`) — pushing past the ~20-slot
// C-call guarantee without growing corrupts the stack.
LUA_CHECK_STACK = 0x006F2F30,
// `str_find` — the Lua 5.0 string-library `string.find` C function
// (`int __fastcall(void *L)`), entry in the strlib luaL_reg table at
// `0x00822dd0`. Returns `1` (nil pushed, no match), `2` (start, end),
// or `2 + ncaptures`. `string.match` (a Lua 5.1 addition, missing here)
// shares the exact same pattern engine — it's `find` that yields the
// captures / whole match instead of the indices — so `BaseLib::StringLib`
// calls this and transforms the result rather than reimplementing the
// matcher. Verified by decoding the function at this address.
FUN_LUA_STR_FIND = 0x007FC3B0,
// `str_gfind` — the Lua 5.0 `string.gfind` C function (strlib entry at
// `0x00822dd8`). 5.1 renamed it to `string.gmatch` with identical
// behaviour (returns a stateful iterator closure over the matches), so
// `BaseLib::StringLib` registers `string.gmatch` as a direct alias of
// this function pointer — no wrapper.
FUN_LUA_STR_GFIND = 0x007FCFA0,
// `math_fmod` — the Lua 5.0 `math.mod` C function (mathlib entry at
// `0x00822cb0`): `luaL_checknumber(1/2)` → `fmod(x, y)` → `lua_pushnumber`.
// 5.1 renamed the Lua-facing name to `math.fmod` with the identical C
// implementation, so `BaseLib::MathLib` registers `math.fmod` as a direct
// alias of this function pointer — no wrapper. Verified by decode.
FUN_LUA_MATH_FMOD = 0x007FAFD0,
// `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
@@ -3459,6 +4087,10 @@ enum Offsets {
// the frame into the matching entry's chain.
VAR_EVENT_TABLE_BASE_PTR = 0x00CEEF68,
VAR_EVENT_TABLE_COUNT = 0x00CEEF64,
// The FrameScript_EventObject is a `{cap@+0, count@+4, data@+8}` at
// 0x00CEEF60, so COUNT/BASE above are its +4/+8. `Event::Custom::Grow`
// writes all three (base, cap, count) when it enlarges the table.
VAR_EVENT_TABLE_CAP = 0x00CEEF60,
EVENT_ENTRY_STRIDE = 0x10,
OFF_EVENT_ENTRY_NAME = 0x00,
OFF_EVENT_ENTRY_HEAD = 0x0C,
@@ -3499,6 +4131,17 @@ enum Offsets {
// table after the rebuild settles. Kept for reference.
FUN_REBUILD_EVENT_TABLE = 0x00703D90,
// Event-table allocator — `__thiscall(EventObject *this, uint32 count)`.
// Called by `RebuildEventTable` to (re)size the entry array. SuperWoW and
// nampower co-hook it to destructively clamp the FrameXML count (`> 200`;
// glue is smaller) to 700. We deliberately DON'T hook it: our default is
// claim-NULL into the engine's ~200 low gaps (which always suffices), and
// when a grow IS needed `Event::Custom::Grow` reallocates the buffer +
// swaps the globals directly (bypassing this clamp) rather than routing a
// size through here. Also documents the empty-entry init the grow mirrors
// ({name=0, +4=0, +8=&self, +0xC=(&self)|1}). Kept for reference.
FUN_FRAMESCRIPT_SET_EVENT_COUNT = 0x007053B0,
// `FireEvent` — the engine's event dispatcher. 149 callers in the
// binary. `__cdecl void(int eventID, const char *format, ...)`.
// Indexes into `[VAR_EVENT_TABLE_BASE_PTR] + eventID * 0x10` and walks
@@ -3712,6 +4355,16 @@ enum Offsets {
// from `SMemAlloc` (which it requires).
FUN_STORM_SSTRDUP = 0x0064A620,
// Storm allocator/free — the pair the engine uses for the event-table
// buffer, so its reload teardown `SMemFree`s our grown buffer as its own.
// Both `__stdcall`, 4 args (`ret 0x10`); SMemAlloc flag bit 8 (`& 8`)
// zero-fills. `Event::Custom::Grow` uses them to reallocate the event
// buffer. (Same addresses as FUN_STORM_SMEM_ALLOC / _FREE above.)
// void* __stdcall SMemAlloc(uint size, const char *file, int line, int flags)
// char __stdcall SMemFree (void *ptr, const char *file, int line, int flags)
FUN_STORM_SMEMALLOC = 0x006462E0,
FUN_STORM_SMEMFREE = 0x00646430,
// Talent system — per-player talent state populated at login from
// (class, race) + Talent.dbc / TalentTab.dbc. The engine maintains
// one `TabInfo *` per talent tab in the array at
@@ -3773,6 +4426,31 @@ enum Offsets {
VAR_TALENT_DBC_RECORDS = 0x00C0D6E8,
VAR_TALENT_DBC_COUNT = 0x00C0D6EC,
// Flat (row-order) DBC arrays used by the talent-tree builder
// `FUN_004f2c00` — DISTINCT from the sparse by-ID index above. The builder
// walks TalentTab.dbc in row order, filtering each tab by
// `FUN_004f2e50(race, class, raceMask@+0x2c, classMask@+0x30)` (a plain
// bitmask test; mask 0 = all), then walks Talent.dbc in row order grouping
// contiguous TabID runs — so `GetTalentInfo(tab, idx)` ordering IS just
// "TalentTab rows for the class in row order" × "Talent.dbc rows for that
// TabID in row order". Reading these arrays with the same walk (but an
// arbitrary class-mask) reproduces the engine's ordering for any class,
// which is how `GetTalentIDByIndex`'s optional classID arg works.
//
// Each global holds a base POINTER (deref) to the contiguous record array;
// the sibling +4 holds the record count. Strides match the by-ID records:
// TalentTab 0x3c, Talent 0x54 (TALENT_ENTRY_STRIDE).
// TalentTab record: ID@+0x00, raceMask@+0x2c, classMask@+0x30
// Talent record: ID@+0x00, TabID@+0x04 (rest per the by-ID layout)
VAR_TALENTTAB_DBC_FLAT_RECORDS = 0x00C0D6CC,
VAR_TALENTTAB_DBC_FLAT_COUNT = 0x00C0D6D0,
TALENTTAB_ENTRY_STRIDE = 0x3c,
OFF_TALENTTAB_ID = 0x00,
OFF_TALENTTAB_CLASS_MASK = 0x30,
VAR_TALENT_DBC_FLAT_RECORDS = 0x00C0D6E0,
VAR_TALENT_DBC_FLAT_COUNT = 0x00C0D6E4,
OFF_TALENT_DBC_TAB_ID = 0x04,
// Engine's `Script_GetTalentInfo` Lua C function. We call it from
// `GetTalentSpellID` to derive the player's currentRank without
// re-implementing the spell-knowledge checks at `0x0060C740` /
@@ -3834,6 +4512,30 @@ enum Offsets {
FUN_ADDON_GET_TITLE_BY_NAME = 0x0051DF20, // (name) → title string or NULL
FUN_ADDON_GET_NOTES_BY_NAME = 0x0051E050, // (name) → notes string or NULL
// `__fastcall(const char *nameOrEntry) → AddOnEntry*+0x48 or NULL`.
// Hash-table lookup by name (accepts an entry pointer too — its
// first 12 bytes are the inline name), returning a pointer to the
// matched record's RequiredDeps descriptor (`record + 0x48`), or
// NULL on a miss. Stock `Script_GetAddOnDependencies` (`0x0048E5E0`)
// calls this then reads count@+4 / data@+8 off the return to walk
// the required deps. We reuse it only to recover the record base for
// string input: `record = resolve(name) - OFF_ADDON_REQDEPS_DESC`.
FUN_ADDON_RESOLVE_REQ_DEPS = 0x0051E350,
// AddOnEntry dependency-array descriptors are `{cap, count, data,
// quantum}` (each field 4 bytes) laid out 0x10 apart, parsed by the
// TOC parser `FUN_0051c9b0`:
// OptionalDeps desc@+0x38 (count@+0x3C, data@+0x40)
// RequiredDeps desc@+0x48 (count@+0x4C, data@+0x50) ← stock walks
// LoadWith desc@+0x58
// Optional offsets verified against the parser's append site at
// `0x0051CDBA` (`mov esi,[rec+0x3C]` count, `lea edi,[rec+0x38]`);
// required desc@+0x48 confirmed by `FUN_ADDON_RESOLVE_REQ_DEPS`'s
// `lea eax,[rec+0x48]` return.
OFF_ADDON_REQDEPS_DESC = 0x48,
OFF_ADDON_OPTIONALDEPS_COUNT = 0x3C,
OFF_ADDON_OPTIONALDEPS_ARRAY = 0x40,
// Per-field accessors `Script_GetAddOnInfo` calls internally for
// returns 5 (loadable), 6 (reason string), 7 (security category).
// We dispatch to these directly so the wrappers don't need to
@@ -4016,6 +4718,15 @@ enum Offsets {
// assemble the pickup→equip chain for each set item.
FUN_SCRIPT_CLEAR_CURSOR = 0x004895B0,
FUN_SCRIPT_PICKUP_INVENTORY_ITEM = 0x004C8DA0,
// Equipment/bag-slot pickup PRIMITIVE behind Script_PickupInventoryItem
// (`FUN_004C8DA0` is just `FUN_004C7300(arg - 1)`). Called directly:
// void __fastcall(uint slot0Based)
// Self-contained — resolves the player, applies its own cursor/lock
// guards, and handles the drop case (slot == 0xFFFFFFFF). Covers the
// 0-based equipment (0..18) and equipped-bag (19..22) slots; container
// contents go through FUN_CURSOR_PICKUP_ITEM instead. Modern
// `equipmentSlotIndex` is 1-based, so pass `equipmentSlotIndex - 1`.
FUN_PICKUP_INVENTORY_SLOT = 0x004C7300,
// Bag-update fire sites — see TODO #67 for the full Ghidra
// investigation. Hook these three to detect BAG_UPDATE fires and
@@ -4508,8 +5219,27 @@ enum Offsets {
// Auction sell slot — holds a single CGItem GUID for the item
// currently sitting in the "sell" frame. Engine resolves it via
// a typed object lookup; pass `OBJECT_TYPE_ITEM` and the GUID.
// `Script_StartAuction` posts whatever GUID is here, so writing an
// arbitrary bag item's GUID + calling it posts that item — no cursor,
// no ClickAuctionSellItemButton. Used by `AuctionHouse::PostItem`.
VAR_AUCTION_SELL_GUID_LO = 0x00B72608,
VAR_AUCTION_SELL_GUID_HI = 0x00B7260C,
// Auctioneer NPC GUID — set while an auction house window is open;
// `Script_StartAuction` writes it as the packet target and bails if
// it's zero (so this doubles as an "AH is open" probe).
VAR_AUCTION_AUCTIONEER_GUID_LO = 0x00B725F8,
VAR_AUCTION_AUCTIONEER_GUID_HI = 0x00B725FC,
// `Script_StartAuction` (`0x004CE770`) — `int __fastcall(void *L)`;
// reads (minBid, buyout, runTime) from L[1..3], validates the sell
// slot + auctioneer + runTime, and sends CMSG_AUCTION_SELL_ITEM
// (opcode 0x256 = { auctioneerGuid, sellItemGuid, bid, buyout,
// runTime }). Reused verbatim by `AuctionHouse::PostItem` per stack.
FUN_SCRIPT_START_AUCTION = 0x004CE770,
// Valid `runTime` values `Script_StartAuction` accepts, in minutes:
// {120, 480, 1440} = 2h / 8h / 24h (vanilla's three durations). The
// modern `duration` arg (1/2/3) indexes this table.
VAR_AUCTION_DURATION_TABLE = 0x00807028,
AUCTION_DURATION_TABLE_COUNT = 3,
// `__fastcall(ECX = typeCode, EDX = unused, [stack] = guidLo,
// [stack] = guidHi, [stack] = anyInt) → CGObject *`.
// Returns NULL when no object matches or the type doesn't match.
@@ -4608,6 +5338,12 @@ enum Offsets {
// (form ID for shapeshift, channel index for periodic, etc.).
OFF_SPELL_RECORD_EFFECT_APPLY_AURA_NAME = 0x16C, // int32[3]
OFF_SPELL_RECORD_EFFECT_MISC_VALUE = 0x1A8, // int32[3]
// Per-effect SpellMechanic ids (EffectMechanic[3]). One int32[3] (0xC)
// after EffectBasePoints (0x130); cross-checked against EffectRadiusIndex
// (0x160) and EffectApplyAuraName (0x16C). Vanilla often stores a spell's
// mechanic on an effect rather than the spell-level Mechanic (0x14), so
// bleed / DoT detection needs this array in addition to that field.
OFF_SPELL_RECORD_EFFECT_MECHANIC = 0x13C, // int32[3]
SPELL_RECORD_EFFECT_COUNT = 3,
// SPELL_EFFECT_* id array (`Effect[3]`) — five arrays before
@@ -4651,6 +5387,8 @@ enum Offsets {
// sum). AttributesEx2 (+0x24) bit 0x20000000 disables spell mods.
OFF_SPELL_RECORD_FAMILY_NAME = 0x280, // u32
OFF_SPELL_RECORD_FAMILY_FLAGS = 0x284, // u64
OFF_SPELL_RECORD_ATTRIBUTES = 0x18, // u32 (base Attributes)
SPELL_ATTR_PASSIVE = 0x40, // bit 6 — always-on aura
OFF_SPELL_RECORD_ATTRIBUTES_EX2 = 0x24, // u32
SPELL_ATTR_EX2_NO_SPELL_MODS = 0x20000000,
+64
View File
@@ -41,6 +41,11 @@ using CanLoadFn_t = uint8_t(__fastcall *)(const uint8_t *entry, char fullCheck,
int *outStatus, int *outDepHandle,
const char *accountName);
using GetAccountName_t = const char *(*)();
// Hash-lookup by name → the matched record's RequiredDeps descriptor
// (`record + 0x48`), NULL on miss. Accepts an entry pointer too (its
// first bytes are the inline name). We use it only to recover the
// record base for string input; see `Script_GetAddOnOptionalDependencies`.
using ResolveReqDeps_t = uint8_t *(__fastcall *)(const char *nameOrEntry);
// Resolves the user's arg to a `const uint8_t *` that the engine's
// per-field accessors can consume. They all read the addon name as
@@ -295,6 +300,63 @@ int __fastcall Script_DoesAddOnExist(void *L) {
return 1;
}
// `C_AddOns.GetAddOnOptionalDependencies(indexOrName)` — the addon's
// `## OptionalDeps:` names as multiple return values, in declared order.
// The exact shape of the stock `GetAddOnDependencies`, but reading the
// *optional* dep array instead of the required one. No optional deps →
// returns nothing.
//
// Vanilla parses `## OptionalDeps:` in the TOC parser `FUN_0051c9b0`
// into a `{cap, count, data, quantum}` name-array descriptor at
// `record+0x38` (count@+0x3C, data@+0x40), but ships no getter for it.
//
// Arg/error handling mirrors stock `Script_GetAddOnDependencies`
// (`0x0048E5E0`): numeric out-of-range or a non-string/non-number arg
// raises the usage error; an unknown *name* string returns nothing
// (no error), matching the engine's own not-found path.
//
// Resolution mirrors stock `Script_GetAddOnDependencies` exactly: the
// arg is first turned into a name-usable pointer (`ResolveAddOnName` —
// numeric via `FUN_ADDON_GET_BY_INDEX`, string is the raw string), then
// routed through the by-name hash lookup `FUN_ADDON_RESOLVE_REQ_DEPS`
// to reach the deps-bearing record. `FUN_ADDON_GET_BY_INDEX` returns a
// *name pointer*, NOT the record — reading dep offsets off it lands in
// string memory and crashes (a numeric-path caller like pfUI's addon
// list hit exactly that). The resolver returns the RequiredDeps
// descriptor at `record+0x48`; subtract to recover the base. An unknown
// name yields NULL → return nothing (no error), like stock.
int __fastcall Script_GetAddOnOptionalDependencies(void *L) {
static constexpr const char *kUsage =
"Usage: C_AddOns.GetAddOnOptionalDependencies(index or \"name\")";
// NULL ⇒ out-of-range numeric index or a non-string/non-number arg;
// stock raises the usage error in both cases.
const uint8_t *nameOrEntry = ResolveAddOnName(L);
if (nameOrEntry == nullptr) {
Game::Lua::Error(L, "%s", kUsage);
return 0;
}
auto resolve = reinterpret_cast<ResolveReqDeps_t>(
Offsets::FUN_ADDON_RESOLVE_REQ_DEPS);
uint8_t *reqDesc = resolve(reinterpret_cast<const char *>(nameOrEntry));
if (reqDesc == nullptr)
return 0; // unknown addon name — no error, push nothing
const uint8_t *record = reqDesc - Offsets::OFF_ADDON_REQDEPS_DESC;
const uint32_t count = *reinterpret_cast<const uint32_t *>(
record + Offsets::OFF_ADDON_OPTIONALDEPS_COUNT);
if (count == 0)
return 0;
auto names = *reinterpret_cast<const char *const *const *>(
record + Offsets::OFF_ADDON_OPTIONALDEPS_ARRAY);
if (names == nullptr)
return 0;
for (uint32_t i = 0; i < count; ++i)
Game::Lua::PushString(L, names[i]); // NULL → pushnil, per engine
return static_cast<int>(count);
}
// `Enum.AddOnSecurityStatus` — what `C_AddOns.GetAddOnSecurity`
// returns. Slots 0..2 match the engine's own ordering at
// `VAR_ADDON_SECURITY_TABLE`; slot 3 is our addition for
@@ -323,6 +385,8 @@ static void RegisterLuaFunctions() {
&Script_DoesAddOnExist);
Game::Lua::RegisterTableFunction("C_AddOns", "IsAddOnLoaded",
&Script_IsAddOnLoaded);
Game::Lua::RegisterTableFunction("C_AddOns", "GetAddOnOptionalDependencies",
&Script_GetAddOnOptionalDependencies);
Game::Lua::RegisterIntegerEnum(
"Enum", "AddOnSecurityStatus",
kAddOnSecurityStatusEntries,
+427
View File
@@ -0,0 +1,427 @@
// 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_AuctionHouse.PostItem(itemLocation, duration, quantity, numStacks, bid,
// buyout)` — post `numStacks` auctions of `quantity` each from a single
// source stack, in one call. A ClassicAPI extension modelled on the retail
// multi-sell flow; vanilla's `StartAuction` posts exactly one item object.
//
// Why this is a tick-driven state machine (not a synchronous loop):
// vanilla's `CMSG_AUCTION_SELL_ITEM` (opcode 0x256) is
// `{ auctioneerGuid, itemGuid, bid, buyout, runTime }` — NO count field
// (WotLK added the server-side count; 1.12 has none, verified from
// `Script_StartAuction`). So the server posts the *entire* item object a
// GUID points at. To post a partial `quantity` out of a larger source, the
// client must first materialize that quantity as its own server-committed
// item object — i.e. split it into a bag slot (`CMSG_SPLIT_ITEM`) — and both
// the split and the resulting post are server-authoritative and async (the
// new stack's GUID doesn't exist client-side until `SMSG_UPDATE_OBJECT`
// lands). We drive the split→post→split→post sequence off `WorldTick`,
// keying each transition on observed bag state.
//
// Free-slot cost: a partial post needs at most ONE reused general-bag slot
// (split `quantity` off → post that stack → the post empties the slot →
// reuse it for the next split). The final stack (once the source is whittled
// to exactly `quantity`) posts the source object directly, no split. Posting
// a whole existing stack (`quantity` == the source count) needs zero free
// slots.
//
// Posting reuses the engine's `Script_StartAuction` verbatim (write the
// target stack's GUID into the sell-slot globals, push bid/buyout/runTime,
// call it) so all of its validation runs per stack — damaged-item reject,
// buyout normalization, runTime table check, auctioneer/"AH open" check.
//
// Events (retail multi-sell names, fired via Event::Custom):
// AUCTION_MULTISELL_START — job accepted, posting begins
// AUCTION_MULTISELL_UPDATE(done, total) — after each stack lands
// AUCTION_MULTISELL_FAILURE — aborted (source changed, no slot,
// AH closed, or a step timed out)
//
// Limitations (v1): source is the single stack at `itemLocation` (not
// aggregated across bags); temp splits use general-purpose bags only, so
// family-restricted items (arrows/shards in specialty bags) aren't
// supported; one job at a time.
#include "Game.h"
#include "Offsets.h"
#include "event/Custom.h"
#include "item/ID.h"
#include "item/Location.h"
#include "item/Swap.h"
#include "tick/WorldTick.h"
#include <cstdint>
namespace AuctionHouse::PostItem {
namespace {
constexpr const char *kEvtStart = "AUCTION_MULTISELL_START";
constexpr const char *kEvtUpdate = "AUCTION_MULTISELL_UPDATE";
constexpr const char *kEvtFailure = "AUCTION_MULTISELL_FAILURE";
const Event::Custom::AutoReserve _r1{kEvtStart};
const Event::Custom::AutoReserve _r2{kEvtUpdate};
const Event::Custom::AutoReserve _r3{kEvtFailure};
// Generous per-step abort bound. A split or a post is one server round-trip
// (~tens of frames); 600 frames (~10s at 60fps) means "something's wrong"
// (disconnect, server refusal we can't see) rather than normal latency.
constexpr int kMaxWaitTicks = 600;
enum class Phase { Idle, SplitWait, PostWait };
struct Job {
bool active;
int srcBag, srcSlot; // source stack (shrinks in place as we split off it)
int itemID; // guards against the source slot changing under us
int quantity;
int totalStacks;
int postedStacks;
int bid, buyout, runTime;
Phase phase;
int workBag, workSlot; // slot currently being split-into / posted-from
uint64_t postGuid; // GUID we posted — detect it leaving bags
int waitTicks;
};
Job g_job{};
// --- small CGItem / cache readers ---------------------------------------
using GetItemRecord_t = const uint8_t *(__thiscall *)(void *cache, uint32_t itemID,
const uint64_t *guid, void *callback,
void *userData, int unused);
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);
}
int CGItemCount(const uint8_t *item) {
auto *desc = *reinterpret_cast<const uint8_t *const *>(
item + Offsets::OFF_ITEM_DESCRIPTOR);
if (desc == nullptr)
return 0;
return *reinterpret_cast<const int *>(desc + Offsets::OFF_DESCRIPTOR_STACK_COUNT);
}
uint64_t CGItemGuid(const uint8_t *item) {
auto *inst = *reinterpret_cast<const uint8_t *const *>(
item + Offsets::OFF_ITEM_INSTANCE_BLOCK);
if (inst == nullptr)
return 0;
return *reinterpret_cast<const uint64_t *>(inst + Offsets::OFF_INSTANCE_BLOCK_GUID);
}
struct SlotInfo {
const uint8_t *item; // null = empty slot
int itemID;
int count;
uint64_t guid;
};
SlotInfo ReadSlot(void *L, int bag, int slot) {
SlotInfo r{nullptr, 0, 0, 0};
const uint8_t *item = Item::Location::ResolveBag(L, bag, slot);
if (item == nullptr)
return r;
r.item = item;
r.itemID = Item::ID::FromCGItem(item);
r.count = CGItemCount(item);
r.guid = CGItemGuid(item);
return r;
}
// --- auction-house globals ----------------------------------------------
uint64_t AuctioneerGuid() {
const uint32_t lo = *reinterpret_cast<const uint32_t *>(
Offsets::VAR_AUCTION_AUCTIONEER_GUID_LO);
const uint32_t hi = *reinterpret_cast<const uint32_t *>(
Offsets::VAR_AUCTION_AUCTIONEER_GUID_HI);
return (static_cast<uint64_t>(hi) << 32) | lo;
}
bool AHOpen() { return AuctioneerGuid() != 0; }
void SetSellGuid(uint64_t guid) {
*reinterpret_cast<uint32_t *>(Offsets::VAR_AUCTION_SELL_GUID_LO) =
static_cast<uint32_t>(guid);
*reinterpret_cast<uint32_t *>(Offsets::VAR_AUCTION_SELL_GUID_HI) =
static_cast<uint32_t>(guid >> 32);
}
// Point the sell slot at `guid` and fire the engine's own StartAuction for
// this bid/buyout/runTime. StartAuction reads its args from L[1..3]; we own
// the stack at frame-tail, so set top to 0, push the three, call, reset.
void PostStack(void *L, uint64_t guid) {
SetSellGuid(guid);
Game::Lua::SetTop(L, 0);
Game::Lua::PushNumber(L, static_cast<double>(g_job.bid));
Game::Lua::PushNumber(L, static_cast<double>(g_job.buyout));
Game::Lua::PushNumber(L, static_cast<double>(g_job.runTime));
auto fn = reinterpret_cast<Game::Lua::CFunction>(Offsets::FUN_SCRIPT_START_AUCTION);
fn(L);
Game::Lua::SetTop(L, 0);
}
// --- free-slot discovery (general-purpose bags only) --------------------
bool BagIsGeneral(int bag) {
if (bag == 0)
return true; // backpack is always general
if (bag < 1 || bag > 4)
return false;
const uint8_t *bagItem =
Item::Location::ResolveEquipmentSlot(Offsets::INVSLOT_BAG1 + bag - 1);
if (bagItem == nullptr)
return false; // no bag equipped there
const int id = Item::ID::FromCGItem(bagItem);
if (id == 0)
return false;
const uint8_t *rec = PeekItemRecord(static_cast<uint32_t>(id));
if (rec == nullptr)
return false;
return *reinterpret_cast<const uint32_t *>(
rec + Offsets::OFF_ITEMSTATS_BAG_FAMILY) == 0;
}
bool FindFreeGeneralSlot(void *L, int *outBag, int *outSlot) {
for (int bag = 0; bag <= 4; ++bag) {
if (!BagIsGeneral(bag))
continue;
const int n = Item::Location::GetBagSlotCount(bag);
for (int slot = 1; slot <= n; ++slot) {
if (Item::Location::ResolveBag(L, bag, slot) == nullptr) {
*outBag = bag;
*outSlot = slot;
return true;
}
}
}
return false;
}
// --- state machine ------------------------------------------------------
void Finish(bool success) {
g_job.active = false;
if (!success)
Event::Custom::Fire(Event::Custom::Lookup(kEvtFailure), "");
// success needs no event: the last PostWait already fired
// AUCTION_MULTISELL_UPDATE(total, total).
}
void Tick() {
if (!g_job.active)
return;
void *L = Game::Lua::State();
if (L == nullptr)
return;
const int savedTop = Game::Lua::GetTop(L);
if (!AHOpen()) { // window closed / walked away mid-job
Finish(false);
Game::Lua::SetTop(L, savedTop);
return;
}
switch (g_job.phase) {
case Phase::Idle: {
if (g_job.postedStacks >= g_job.totalStacks) {
Finish(true);
break;
}
const SlotInfo s = ReadSlot(L, g_job.srcBag, g_job.srcSlot);
if (s.item == nullptr || s.itemID != g_job.itemID || s.count < g_job.quantity) {
Finish(false); // source moved / consumed / insufficient
break;
}
if (s.count == g_job.quantity) {
// Final exact stack — post the source object directly, no split.
g_job.workBag = g_job.srcBag;
g_job.workSlot = g_job.srcSlot;
g_job.postGuid = s.guid;
PostStack(L, s.guid);
g_job.phase = Phase::PostWait;
g_job.waitTicks = 0;
} else {
int fb = 0, fs = 0;
if (!FindFreeGeneralSlot(L, &fb, &fs)) {
Finish(false); // no room to materialize the stack
break;
}
if (!Item::Swap::MoveCount(L, g_job.srcBag, g_job.srcSlot, fb, fs,
g_job.quantity)) {
Finish(false);
break;
}
g_job.workBag = fb;
g_job.workSlot = fs;
g_job.phase = Phase::SplitWait;
g_job.waitTicks = 0;
}
break;
}
case Phase::SplitWait: {
const SlotInfo w = ReadSlot(L, g_job.workBag, g_job.workSlot);
if (w.item != nullptr && w.itemID == g_job.itemID && w.count == g_job.quantity) {
g_job.postGuid = w.guid;
PostStack(L, w.guid);
g_job.phase = Phase::PostWait;
g_job.waitTicks = 0;
} else if (++g_job.waitTicks > kMaxWaitTicks) {
Finish(false);
}
break;
}
case Phase::PostWait: {
const SlotInfo w = ReadSlot(L, g_job.workBag, g_job.workSlot);
// Post confirmed once the server pulls the item out of the slot.
if (w.item == nullptr || w.guid != g_job.postGuid) {
++g_job.postedStacks;
Event::Custom::Fire(Event::Custom::Lookup(kEvtUpdate), "%d%d",
g_job.postedStacks, g_job.totalStacks);
g_job.phase = Phase::Idle;
g_job.waitTicks = 0;
} else if (++g_job.waitTicks > kMaxWaitTicks) {
Finish(false);
}
break;
}
}
Game::Lua::SetTop(L, savedTop);
}
const Tick::WorldTick::AutoSubscribe _tick{&Tick};
// --- Lua entry ----------------------------------------------------------
// Reads `loc.field` as an int. False if missing/non-numeric. Leaves the
// stack as found. (Same shape as Item::Location's file-static helper.)
bool ReadIntField(void *L, int locIdx, const char *field, int *out) {
Game::Lua::PushString(L, field);
Game::Lua::GetTable(L, locIdx);
const bool ok = Game::Lua::IsNumber(L, -1);
if (ok)
*out = static_cast<int>(Game::Lua::ToNumber(L, -1));
Game::Lua::SetTop(L, -2);
return ok;
}
// Map the modern `duration` arg to a valid engine runTime (minutes).
// Accepts the 1/2/3 index form and the raw {120,480,1440} value form.
// Returns 0 for anything else.
int MapDuration(int d) {
auto *table = reinterpret_cast<const uint32_t *>(Offsets::VAR_AUCTION_DURATION_TABLE);
if (d >= 1 && d <= Offsets::AUCTION_DURATION_TABLE_COUNT)
return static_cast<int>(table[d - 1]);
for (int i = 0; i < Offsets::AUCTION_DURATION_TABLE_COUNT; ++i)
if (static_cast<int>(table[i]) == d)
return d;
return 0;
}
// Push (false, reason) and return 2 — the immediate-rejection shape.
int Reject(void *L, const char *reason) {
Game::Lua::PushBool(L, false);
Game::Lua::PushString(L, reason);
return 2;
}
constexpr int kLuaTable = 5; // LUA_TTABLE (no named constant in Game::Lua)
int __fastcall Script_C_AuctionHouse_PostItem(void *L) {
if (Game::Lua::Type(L, 1) != kLuaTable) {
Game::Lua::Error(L,
"Usage: C_AuctionHouse.PostItem(itemLocation, duration, quantity, numStacks, bid, buyout)");
return 0;
}
if (!Game::Lua::IsNumber(L, 2) || !Game::Lua::IsNumber(L, 3) ||
!Game::Lua::IsNumber(L, 4) || !Game::Lua::IsNumber(L, 5)) {
Game::Lua::Error(L,
"Usage: C_AuctionHouse.PostItem(itemLocation, duration, quantity, numStacks, bid, buyout)");
return 0;
}
int bagID = 0, slotIndex = 0;
if (!ReadIntField(L, 1, "bagID", &bagID) ||
!ReadIntField(L, 1, "slotIndex", &slotIndex))
return Reject(L, "itemLocation must be a { bagID, slotIndex } table");
const int duration = static_cast<int>(Game::Lua::ToNumber(L, 2));
const int quantity = static_cast<int>(Game::Lua::ToNumber(L, 3));
const int numStacks = static_cast<int>(Game::Lua::ToNumber(L, 4));
const int bid = static_cast<int>(Game::Lua::ToNumber(L, 5));
const int buyout = Game::Lua::IsNumber(L, 6) ? static_cast<int>(Game::Lua::ToNumber(L, 6)) : 0;
const int runTime = MapDuration(duration);
if (runTime == 0)
return Reject(L, "invalid duration (use 1/2/3 or 120/480/1440)");
if (quantity < 1 || quantity > 255)
return Reject(L, "quantity must be 1..255");
if (numStacks < 1)
return Reject(L, "numStacks must be >= 1");
if (bid < 1)
return Reject(L, "bid must be >= 1");
if (!AHOpen())
return Reject(L, "auction house is not open");
if (g_job.active)
return Reject(L, "a multi-post is already in progress");
const SlotInfo s = ReadSlot(L, bagID, slotIndex);
if (s.item == nullptr)
return Reject(L, "source slot is empty");
const long need = static_cast<long>(quantity) * numStacks;
if (static_cast<long>(s.count) < need)
return Reject(L, "not enough items for numStacks x quantity");
// A partial post (source larger than one stack) needs one reusable
// general-bag slot to materialize each split. A whole-stack post
// (count == quantity, numStacks == 1) needs none.
if (s.count > quantity) {
int fb = 0, fs = 0;
if (!FindFreeGeneralSlot(L, &fb, &fs))
return Reject(L, "need at least one free general bag slot to split");
}
g_job = Job{};
g_job.active = true;
g_job.srcBag = bagID;
g_job.srcSlot = slotIndex;
g_job.itemID = s.itemID;
g_job.quantity = quantity;
g_job.totalStacks = numStacks;
g_job.postedStacks = 0;
g_job.bid = bid;
g_job.buyout = buyout;
g_job.runTime = runTime;
g_job.phase = Phase::Idle;
Event::Custom::Fire(Event::Custom::Lookup(kEvtStart), "");
Game::Lua::PushBool(L, true);
return 1;
}
void RegisterLuaFunctions() {
Game::Lua::RegisterTableFunction("C_AuctionHouse", "PostItem",
&Script_C_AuctionHouse_PostItem);
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace
} // namespace AuctionHouse::PostItem
+70 -25
View File
@@ -29,6 +29,7 @@
#include "Game.h"
#include "Offsets.h"
#include "ui/ColorData.h"
#include "unit/Identity.h"
#include <cstdint>
#include <cstring>
@@ -47,6 +48,18 @@ const uint8_t *ResolveUnit(const char *token) {
return static_cast<const uint8_t *>(fn(token));
}
// Resolves a token to its GUID for the out-of-range (no live CGUnit) path.
// Only valid to call AFTER `ResolveUnit(token)` returned null *without raising*:
// `ResolveUnit` runs the engine's token→GUID resolver first, so reaching a null
// return means the token was well-formed (the resolver never raised). This runs
// the same non-raising resolver and returns 0 for a token that maps to no
// rostered member — which the group-array push paths treat as "no auras".
uint64_t GuidForOutOfRange(const char *token) {
if (token == nullptr)
return 0;
return Unit::Identity::GuidForToken(token);
}
// Parses the filter string. Returns Helpful by default; Harmful if
// the string contains the substring "HARMFUL". Case-sensitive — the
// modern API documents the filter tokens as upper-case constants
@@ -91,16 +104,25 @@ const char *ArgOptString(void *L, int idx) {
int PushAuraByIndex(void *L, const char *unitToken, int index,
Data::Filter filter, bool playerOnly = false) {
const uint8_t *unit = ResolveUnit(unitToken);
const int slot = Data::FindNthSlot(unit, index, filter, playerOnly);
if (slot >= 0) {
Data::Push(L, unit, slot);
return 1;
if (unit != nullptr) {
const int slot = Data::FindNthSlot(unit, index, filter, playerOnly);
if (slot >= 0) {
Data::Push(L, unit, slot);
return 1;
}
// Descriptor exhausted — an aura the engine dropped from the slot array
// (rogue stealth, nearby party range fluctuation) may still be live in
// the Aura::Source cache. Surface it after the descriptor entries.
if (Data::PushNthCacheFallback(L, unit, index, filter, playerOnly))
return 1;
} else {
// No live CGUnit — an out-of-range / cross-map groupmate. The server
// still sends their aura spell IDs via SMSG_PARTY_MEMBER_STATS; read
// them the same way the built-in UnitBuff/UnitDebuff do.
if (Data::PushNthGroupAura(L, GuidForOutOfRange(unitToken), index,
filter, playerOnly))
return 1;
}
// Descriptor exhausted — an aura the engine dropped from the slot array
// (rogue stealth, party range fluctuation) may still be live in the
// Aura::Source cache. Surface it after the descriptor entries.
if (Data::PushNthCacheFallback(L, unit, index, filter, playerOnly))
return 1;
Game::Lua::PushNil(L);
return 1;
}
@@ -152,13 +174,21 @@ int __fastcall Script_GetUnitAuraBySpellID(void *L) {
f = ParseFilter(filterStr);
fp = &f;
}
const int slot = Data::FindSlotBySpellID(unit, static_cast<uint32_t>(spellID),
fp, HasPlayerFilter(filterStr));
if (slot < 0) {
Game::Lua::PushNil(L);
const bool playerOnly = HasPlayerFilter(filterStr);
if (unit != nullptr) {
const int slot = Data::FindSlotBySpellID(
unit, static_cast<uint32_t>(spellID), fp, playerOnly);
if (slot >= 0) {
Data::Push(L, unit, slot);
return 1;
}
} else if (Data::PushGroupAuraBySpellID(L, GuidForOutOfRange(unitToken),
static_cast<uint32_t>(spellID), fp,
playerOnly)) {
// Out-of-range groupmate — read from the group-member aura array.
return 1;
}
Data::Push(L, unit, slot);
Game::Lua::PushNil(L);
return 1;
}
@@ -177,13 +207,19 @@ int __fastcall Script_GetAuraDataBySpellName(void *L) {
f = ParseFilter(filterStr);
fp = &f;
}
const int slot = Data::FindSlotBySpellName(unit, spellName, fp,
HasPlayerFilter(filterStr));
if (slot < 0) {
Game::Lua::PushNil(L);
const bool playerOnly = HasPlayerFilter(filterStr);
if (unit != nullptr) {
const int slot = Data::FindSlotBySpellName(unit, spellName, fp, playerOnly);
if (slot >= 0) {
Data::Push(L, unit, slot);
return 1;
}
} else if (Data::PushGroupAuraBySpellName(L, GuidForOutOfRange(unitToken),
spellName, fp, playerOnly)) {
// Out-of-range groupmate — read from the group-member aura array.
return 1;
}
Data::Push(L, unit, slot);
Game::Lua::PushNil(L);
return 1;
}
@@ -234,8 +270,6 @@ int __fastcall Script_GetUnitAuras(void *L) {
Game::Lua::SetTop(L, 0);
Game::Lua::NewTable(L);
if (unit == nullptr)
return 1;
// Range tokens are independent of PLAYER: an explicit HELPFUL/HARMFUL
// selects that range, neither selects both. So `"PLAYER"` alone returns
@@ -246,10 +280,21 @@ int __fastcall Script_GetUnitAuras(void *L) {
const bool both = !hasHelpful && !hasHarmful;
int nextKey = 1;
if (hasHelpful || both)
AppendRangeToArray(L, unit, 1, Data::Filter::Helpful, nextKey, playerOnly);
if (hasHarmful || both)
AppendRangeToArray(L, unit, 1, Data::Filter::Harmful, nextKey, playerOnly);
if (unit != nullptr) {
if (hasHelpful || both)
AppendRangeToArray(L, unit, 1, Data::Filter::Helpful, nextKey, playerOnly);
if (hasHarmful || both)
AppendRangeToArray(L, unit, 1, Data::Filter::Harmful, nextKey, playerOnly);
} else {
// No live CGUnit — out-of-range / cross-map groupmate. Enumerate the
// group-member aura array (spell IDs the server still transmits), the
// same source the built-in UnitBuff/UnitDebuff read out of range.
const uint64_t guid = GuidForOutOfRange(unitToken);
if (hasHelpful || both)
Data::AppendGroupAuras(L, guid, Data::Filter::Helpful, playerOnly, 1, nextKey);
if (hasHarmful || both)
Data::AppendGroupAuras(L, guid, Data::Filter::Harmful, playerOnly, 1, nextKey);
}
return 1;
}
+21 -28
View File
@@ -132,30 +132,19 @@ const Override *FindOverride(uint32_t spellId) {
return nullptr;
}
// Known values for spells whose duration row DANGLES — a non-zero
// DurationIndex pointing at a SpellDuration row the client doesn't have.
// Consulted ONLY when that exact condition holds, so the gate is the bug
// itself, not a server fingerprint: on a stock client every one of these
// resolves to a valid row and this table is never reached, and if the
// broken client data is ever fixed the DBC row takes over automatically.
//
// Turtle's reworked Rip is the known case: all six ranks point at
// SpellDuration row 87, which Turtle added on the SERVER only
// ({8000, 0, 18000} — live server data shows 10/12/14/16/18s at 1..5 CP,
// i.e. 8s + 2s per combo point); their client patch never shipped the
// row (the client table jumps 86 -> 105). Verified the only dangling
// duration index in the entire client Spell.dbc.
constexpr Override kDanglingRowKnown[] = {
{1079, 8000, 18000}, {9492, 8000, 18000}, {9493, 8000, 18000},
{9752, 8000, 18000}, {9894, 8000, 18000}, {9896, 8000, 18000},
};
// Resolver chain for dangling-duration-row spells (client DBC missing the
// row). The KNOWN VALUES themselves are server-custom data and live in
// their own module (e.g. `src/turtle/ComboDuration.cpp` for Turtle's
// reworked Rip); this stock module only owns the mechanism and the
// dangling-condition gate. Chained at static-init by `AutoDanglingResolver`.
AutoDanglingResolver *g_danglingResolvers = nullptr;
const Override *FindDanglingRowKnown(uint32_t spellId) {
for (const auto &k : kDanglingRowKnown) {
if (k.spellId == spellId)
return &k;
}
return nullptr;
bool ResolveDangling(uint32_t spellId, int32_t *baseMs, int32_t *maxMs) {
for (AutoDanglingResolver *r = g_danglingResolvers; r != nullptr;
r = r->next)
if (r->fn(spellId, baseMs, maxMs))
return true;
return false;
}
// `C_UnitAuras.RegisterComboDuration(spellID, baseSeconds, maxSeconds)`
@@ -203,6 +192,11 @@ const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace
AutoDanglingResolver::AutoDanglingResolver(DanglingResolver f)
: fn(f), next(g_danglingResolvers) {
g_danglingResolvers = this;
}
uint32_t TryComboScaledMs(const uint8_t *spellRecord, uint32_t spellId) {
if (spellRecord == nullptr || spellId == 0)
return 0;
@@ -227,12 +221,11 @@ uint32_t TryComboScaledMs(const uint8_t *spellRecord, uint32_t spellId) {
maxMs = *reinterpret_cast<const int32_t *>(row + 0xC);
} else if (idx != 0) {
// Dangling duration index — the client data is broken for this
// spell. Fall back to the built-in known values (Turtle Rip).
const Override *k = FindDanglingRowKnown(spellId);
if (k == nullptr)
// spell. Defer to a registered resolver (the known values are
// server-custom data owned by a platform module, e.g.
// src/turtle for Turtle's reworked Rip).
if (!ResolveDangling(spellId, &baseMs, &maxMs))
return 0;
baseMs = k->baseMs;
maxMs = k->maxMs;
} else {
return 0; // no duration row at all
}
+18
View File
@@ -47,4 +47,22 @@ namespace Aura::ComboDuration {
// duration path".
uint32_t TryComboScaledMs(const uint8_t *spellRecord, uint32_t spellId);
// Extension point for finishers whose SpellDuration row DANGLES — a
// non-zero DurationIndex pointing at a row the client DBC doesn't have.
// A resolver returns true and fills *baseMs/*maxMs if it knows the spell,
// false otherwise. Consulted by `TryComboScaledMs` ONLY in that dangling
// condition (after any Lua-registered override), so the activation gate is
// the client-data bug itself, not a server fingerprint — the deliberate
// design choice (a realm-sniffing approach was rejected). Server-custom
// data (e.g. Turtle's reworked Rip) registers its values here instead of
// living in this stock module: declare a file-scope
// `static const Aura::ComboDuration::AutoDanglingResolver`.
using DanglingResolver = bool (*)(uint32_t spellId, int32_t *baseMs,
int32_t *maxMs);
struct AutoDanglingResolver {
explicit AutoDanglingResolver(DanglingResolver fn);
DanglingResolver fn;
AutoDanglingResolver *next;
};
} // namespace Aura::ComboDuration
+356 -41
View File
@@ -17,6 +17,7 @@
#include "Offsets.h"
#include "aura/Source.h"
#include "dbc/Lookup.h"
#include "group/MemberStats.h"
#include "guid/Guid.h"
#include "unit/Identity.h"
@@ -139,6 +140,22 @@ double PlayerBuffExpirationSeconds(const uint8_t *entry) {
const uint32_t expirationMs = expirationTable[slotCode];
if (expirationMs == 0)
return 0.0;
// Report "no timer" once the stored expiration has elapsed — this is not a
// heuristic, it's exactly what the engine's own GetPlayerBuffTimeLeft does.
// Script_GetPlayerBuffTimeLeft (0x004e4930) → FUN_004e4450 reads this same
// expiration table and returns:
// if ((int)(now - expiration[slot]) >= 0) return 0; // elapsed -> 0
// return expiration[slot] - now; // else remaining
// Permanent auras (paladin auras, passives, racials) carry an infinite
// Spell.dbc duration (SpellDuration base -1); the server stores the fixed
// cast-time in this slot for them, so it's always "already elapsed" and the
// engine shows no countdown. The signed `(int)` compare also absorbs the
// ~24.86-day GetTickCount wrap (both operands wrap together).
using TickCount_t = uint32_t(__fastcall *)();
const uint32_t nowMs = reinterpret_cast<TickCount_t>(
static_cast<uintptr_t>(Offsets::FUN_OS_TICKCOUNT_MS))();
if (static_cast<int32_t>(nowMs - expirationMs) >= 0)
return 0.0; // expiration reached/passed → permanent or no timer
return static_cast<double>(expirationMs) * 0.001;
}
@@ -419,46 +436,39 @@ static void BuildTable(void *L, uint32_t spellID, int applications,
// yields nil, so the table doesn't need an explicit entry.
}
void Push(void *L, const uint8_t *unit, int slot) {
const uint32_t spellID = ReadSpellID(unit, slot);
const bool isHelpful = slot < Offsets::UNIT_AURA_BUFF_COUNT;
// Timing fields. Vanilla doesn't broadcast cast time / duration for ANY
// unit's auras except the local player's — for everyone else (target,
// party, raid, mouseover) `expirationTime` stays 0 unless the
// `Aura::Source` cache observed the cast. For the player we read the
// engine's `VAR_PLAYER_BUFF_TABLE` (same data `GetPlayerBuffTimeLeft`
// returns), which gives a real expiration timestamp.
//
// `duration` comes from Spell.dbc → SpellDuration.dbc with level scaling
// (the *base* value); talent / glyph duration-extension modifiers are
// already baked into the expiration timestamp on the caster's side, so
// `expirationTime - GetTime()` reflects true remaining time even when the
// base `duration` doesn't include the talent boost.
// Enriches a bare `(guid, spellID)` into a full AuraData table — the shared
// core behind every push path (descriptor, cache fallback, group-array). No
// dependence on a live descriptor: the descriptor path passes what it read
// (stacks, level, is-this-the-player), the others pass their known-or-default
// values.
//
// Timing rules (identical across paths): `duration` is the Spell.dbc →
// SpellDuration.dbc base scaled by `unitLevel` (0 skips scaling); talent/glyph
// duration modifiers are baked into the caster-side expiration, so
// `expirationTime - GetTime()` is the true remaining time even when `duration`
// omits the boost. `expirationTime` for the local player comes from the engine
// buff table (gated on `isPlayer`); for everyone else it, the caster, and the
// applied (caster-modified) duration come from the `Aura::Source` SMSG_SPELL_GO
// cache when it observed the cast — a miss leaves the modern-truthful defaults
// (expiration 0, no sourceUnit/GUID).
static void PushEnriched(void *L, uint64_t guid, uint32_t spellID,
bool isHelpful, int applications, int unitLevel,
bool isPlayer) {
double duration = 0.0;
double expirationTime = 0.0;
uint64_t casterGuid = 0;
const uint8_t *player = LocalPlayer();
if (spellID != 0) {
const int unitLevel = (unit != nullptr) ? PlayerLevel(unit) : 0;
if (spellID != 0)
duration = SpellBaseDurationSeconds(spellID, unitLevel);
}
if (unit != nullptr && unit == player) {
if (isPlayer && spellID != 0) {
const uint8_t *entry = FindPlayerBuffEntry(spellID);
if (entry != nullptr)
expirationTime = PlayerBuffExpirationSeconds(entry);
}
// Caster + non-player expiration from the SMSG_SPELL_GO cache, captured at
// cast time (the descriptor has neither). The player-buff table above is
// authoritative for the player's own expiration; the cache fills it for
// everyone else. Prefer the applied (caster-modified) duration so it stays
// consistent with `expirationTime` — otherwise a talented DoT (Improved
// Shadow Word: Pain) would show remaining time exceeding the base.
if (spellID != 0) {
if (spellID != 0 && guid != 0) {
uint64_t c = 0;
uint32_t expMs = 0;
uint32_t durMs = 0;
if (Aura::Source::Get(UnitGuid(unit), spellID, &c, &expMs, &durMs)) {
if (Aura::Source::Get(guid, spellID, &c, &expMs, &durMs)) {
if (expirationTime == 0.0 && expMs != 0)
expirationTime = static_cast<double>(expMs) * 0.001;
if (durMs != 0)
@@ -466,9 +476,16 @@ void Push(void *L, const uint8_t *unit, int slot) {
casterGuid = c;
}
}
BuildTable(L, spellID, applications, isHelpful, duration, expirationTime,
casterGuid);
}
BuildTable(L, spellID, ReadStacks(unit, slot), isHelpful, duration,
expirationTime, casterGuid);
void Push(void *L, const uint8_t *unit, int slot) {
const uint32_t spellID = ReadSpellID(unit, slot);
const bool isHelpful = slot < Offsets::UNIT_AURA_BUFF_COUNT;
const int unitLevel = (unit != nullptr) ? PlayerLevel(unit) : 0;
PushEnriched(L, UnitGuid(unit), spellID, isHelpful, ReadStacks(unit, slot),
unitLevel, unit != nullptr && unit == LocalPlayer());
}
namespace {
@@ -479,17 +496,37 @@ namespace {
// applied (caster-modified) ms when known, else the Spell.dbc base.
void PushFromCache(void *L, const uint8_t *unit,
const Aura::Source::CachedAura &c, bool isHelpful) {
double duration = 0.0;
if (c.durationMs != 0) {
duration = static_cast<double>(c.durationMs) * 0.001;
} else {
const int unitLevel = (unit != nullptr) ? PlayerLevel(unit) : 0;
duration = SpellBaseDurationSeconds(c.spellId, unitLevel);
// `PushEnriched` re-reads the same cache entry `c` came from (by
// guid+spellID) to fill caster / expiration / applied duration, so the
// result is identical to reading `c`'s fields directly. Stacks aren't in
// SMSG_SPELL_GO, so `applications` is 1.
const int unitLevel = (unit != nullptr) ? PlayerLevel(unit) : 0;
PushEnriched(L, UnitGuid(unit), c.spellId, isHelpful, 1, unitLevel, false);
}
// 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;
}
const double expirationTime =
(c.expirationMs != 0) ? static_cast<double>(c.expirationMs) * 0.001 : 0.0;
BuildTable(L, c.spellId, 1, isHelpful, duration, expirationTime,
c.casterGuid);
if (n == 0)
return;
Aura::Source::EvictAbsent(UnitGuid(unit), present, n);
}
// True if `unit`'s descriptor currently exposes any visible aura in either
@@ -559,6 +596,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 +627,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);
@@ -606,4 +645,280 @@ void AppendCacheFallbacks(void *L, const uint8_t *unit, Filter filter,
}
}
// ── Out-of-range groupmate path ────────────────────────────────────────────
namespace {
// Absolute slot range [start, end) for a filter, matching the descriptor
// convention (helpful 0..31, harmful 32..47).
void GroupRange(Filter filter, int &start, int &end) {
start = (filter == Filter::Harmful) ? Offsets::UNIT_AURA_BUFF_COUNT : 0;
end = (filter == Filter::Harmful) ? Offsets::UNIT_AURA_TOTAL
: Offsets::UNIT_AURA_BUFF_COUNT;
}
// Spell ID at group-array slot (0..47), 0 if `arr` null / OOB / empty.
uint32_t GroupSpellID(const uint16_t *arr, int slot) {
if (arr == nullptr || slot < 0 || slot >= Offsets::UNIT_AURA_TOTAL)
return 0;
return arr[slot];
}
// Whether the group-array slot holds a surfacable aura for this query. Mirrors
// the engine's per-slot test in Script_UnitBuff/UnitDebuff — a non-zero spell
// ID with a real Spell.dbc record that passes the visibility predicate (the
// server only transmits *applied* auras, so there is no flag nibble to check).
// `playerOnly` additionally requires the `Aura::Source` cache to attribute the
// cast to the local player (a miss excludes it — we can't confirm the caster
// from the group array alone).
bool GroupSlotEligible(uint64_t guid, const uint16_t *arr, int slot,
bool playerOnly) {
const uint32_t id = GroupSpellID(arr, slot);
if (id == 0 || !IsVisible(SpellRecord(id)))
return false;
if (playerOnly) {
uint64_t c = 0;
uint32_t e = 0;
uint32_t d = 0;
if (!Aura::Source::Get(guid, id, &c, &e, &d) ||
c != Unit::Identity::PlayerGuid())
return false;
}
return true;
}
// The groupmate's level, for Spell.dbc duration scaling (0 = skip). Cheap
// roster lookup; only hit on the out-of-range path.
int GroupMemberLevel(uint64_t guid) {
return Group::MemberStats::Lookup(guid).level;
}
// True if the group array exposes ANY visible aura (either kind). When it
// does, the server's out-of-range aura data is present and authoritative, so
// we do NOT supplement from the Aura::Source cache — mirroring how the
// descriptor path treats a populated descriptor (DescriptorHasVisibleAura).
// Only when the array is entirely empty do we fall back to the cache: the
// server resends out-of-range stats as deltas only, so a member seen in range
// and then moved away again leaves an empty party-stats aura array (the array
// is cleared when they become visible and never re-sent), yet their auras
// still sit in the cache from when we observed them in range.
bool GroupArrayHasVisibleAura(const uint16_t *arr) {
for (int slot = 0; slot < Offsets::UNIT_AURA_TOTAL; ++slot) {
const uint32_t id = GroupSpellID(arr, slot);
if (id != 0 && IsVisible(SpellRecord(id)))
return true;
}
return false;
}
// Whether cache entry `c` may supplement the (empty) group array: a visible
// aura, not already present in the array, and — when player-filtered — cast by
// us. See GroupArrayHasVisibleAura for when this path runs.
bool GroupFallbackEligible(const uint16_t *arr, const Aura::Source::CachedAura &c,
bool playerOnly) {
if (!IsVisible(SpellRecord(c.spellId)))
return false;
for (int slot = 0; slot < Offsets::UNIT_AURA_TOTAL; ++slot)
if (GroupSpellID(arr, slot) == c.spellId)
return false;
if (playerOnly && c.casterGuid != Unit::Identity::PlayerGuid())
return false;
return true;
}
// Nth (1-based) cache-fallback aura of `filter`'s kind for an out-of-range
// member whose group array is empty. Pushes the AuraData table on a hit.
bool PushNthGroupCacheFallback(void *L, uint64_t guid, const uint16_t *arr,
int oneBasedIndex, Filter filter, bool playerOnly) {
Aura::Source::CachedAura buf[kFallbackMax];
const int n =
Aura::Source::Enumerate(guid, filter == Filter::Harmful, buf, kFallbackMax);
int seen = 0;
for (int i = 0; i < n; ++i) {
if (!GroupFallbackEligible(arr, buf[i], playerOnly))
continue;
if (++seen == oneBasedIndex) {
PushEnriched(L, guid, buf[i].spellId, filter == Filter::Helpful, 1,
GroupMemberLevel(guid), false);
return true;
}
}
return false;
}
// Append every cache-fallback aura of `filter`'s kind into the array table.
void AppendGroupCacheFallbacks(void *L, uint64_t guid, const uint16_t *arr,
Filter filter, bool playerOnly, int outerIdx,
int &nextKey) {
Aura::Source::CachedAura buf[kFallbackMax];
const int n =
Aura::Source::Enumerate(guid, filter == Filter::Harmful, buf, kFallbackMax);
const int level = GroupMemberLevel(guid);
for (int i = 0; i < n; ++i) {
if (!GroupFallbackEligible(arr, buf[i], playerOnly))
continue;
Game::Lua::PushNumber(L, static_cast<double>(nextKey++));
PushEnriched(L, guid, buf[i].spellId, filter == Filter::Helpful, 1, level,
false);
Game::Lua::SetTable(L, outerIdx);
}
}
// Single cache-fallback aura matching `spellID` (when nonzero) or `spellName`
// (when non-null), of the given kind. Backs the by-ID / by-name group queries.
bool PushGroupCacheFallbackMatch(void *L, uint64_t guid, const uint16_t *arr,
bool harmful, uint32_t spellID,
const char *spellName, bool playerOnly) {
Aura::Source::CachedAura buf[kFallbackMax];
const int n = Aura::Source::Enumerate(guid, harmful, buf, kFallbackMax);
for (int i = 0; i < n; ++i) {
if (spellID != 0 && buf[i].spellId != spellID)
continue;
if (spellName != nullptr) {
const char *nm = LocalizedSpellName(SpellRecord(buf[i].spellId));
if (nm == nullptr || std::strcmp(nm, spellName) != 0)
continue;
}
if (!GroupFallbackEligible(arr, buf[i], playerOnly))
continue;
PushEnriched(L, guid, buf[i].spellId, !harmful, 1, GroupMemberLevel(guid),
false);
return true;
}
return false;
}
} // namespace
bool PushNthGroupAura(void *L, uint64_t guid, int oneBasedIndex, Filter filter,
bool playerOnly) {
if (guid == 0 || oneBasedIndex < 1)
return false;
const uint16_t *arr = Group::MemberStats::AuraArray(guid);
if (arr == nullptr)
return false;
Aura::Source::ObserveGroupAuras(guid, arr);
int start, end;
GroupRange(filter, start, end);
int matches = 0;
for (int slot = start; slot < end; ++slot) {
if (!GroupSlotEligible(guid, arr, slot, playerOnly))
continue;
if (++matches == oneBasedIndex) {
PushEnriched(L, guid, GroupSpellID(arr, slot),
filter == Filter::Helpful, 1, GroupMemberLevel(guid),
false);
return true;
}
}
// Empty array (server delta-resend gap): supplement from the cache.
if (!GroupArrayHasVisibleAura(arr))
return PushNthGroupCacheFallback(L, guid, arr, oneBasedIndex, filter,
playerOnly);
return false;
}
bool PushGroupAuraBySpellID(void *L, uint64_t guid, uint32_t spellID,
const Filter *filter, bool playerOnly) {
if (guid == 0 || spellID == 0)
return false;
const uint16_t *arr = Group::MemberStats::AuraArray(guid);
if (arr == nullptr)
return false;
Aura::Source::ObserveGroupAuras(guid, arr);
const int start = (filter != nullptr && *filter == Filter::Harmful)
? Offsets::UNIT_AURA_BUFF_COUNT
: 0;
const int end = (filter != nullptr && *filter == Filter::Helpful)
? Offsets::UNIT_AURA_BUFF_COUNT
: Offsets::UNIT_AURA_TOTAL;
for (int slot = start; slot < end; ++slot) {
if (GroupSpellID(arr, slot) != spellID)
continue;
if (!GroupSlotEligible(guid, arr, slot, playerOnly))
continue;
PushEnriched(L, guid, spellID, slot < Offsets::UNIT_AURA_BUFF_COUNT, 1,
GroupMemberLevel(guid), false);
return true;
}
if (!GroupArrayHasVisibleAura(arr)) {
const bool both = (filter == nullptr);
if ((both || *filter == Filter::Helpful) &&
PushGroupCacheFallbackMatch(L, guid, arr, false, spellID, nullptr,
playerOnly))
return true;
if ((both || *filter == Filter::Harmful) &&
PushGroupCacheFallbackMatch(L, guid, arr, true, spellID, nullptr,
playerOnly))
return true;
}
return false;
}
bool PushGroupAuraBySpellName(void *L, uint64_t guid, const char *spellName,
const Filter *filter, bool playerOnly) {
if (guid == 0 || spellName == nullptr || *spellName == '\0')
return false;
const uint16_t *arr = Group::MemberStats::AuraArray(guid);
if (arr == nullptr)
return false;
Aura::Source::ObserveGroupAuras(guid, arr);
const int start = (filter != nullptr && *filter == Filter::Harmful)
? Offsets::UNIT_AURA_BUFF_COUNT
: 0;
const int end = (filter != nullptr && *filter == Filter::Helpful)
? Offsets::UNIT_AURA_BUFF_COUNT
: Offsets::UNIT_AURA_TOTAL;
for (int slot = start; slot < end; ++slot) {
const uint32_t id = GroupSpellID(arr, slot);
if (id == 0)
continue;
const char *name = LocalizedSpellName(SpellRecord(id));
if (name == nullptr || std::strcmp(name, spellName) != 0)
continue;
if (!GroupSlotEligible(guid, arr, slot, playerOnly))
continue;
PushEnriched(L, guid, id, slot < Offsets::UNIT_AURA_BUFF_COUNT, 1,
GroupMemberLevel(guid), false);
return true;
}
if (!GroupArrayHasVisibleAura(arr)) {
const bool both = (filter == nullptr);
if ((both || *filter == Filter::Helpful) &&
PushGroupCacheFallbackMatch(L, guid, arr, false, 0, spellName,
playerOnly))
return true;
if ((both || *filter == Filter::Harmful) &&
PushGroupCacheFallbackMatch(L, guid, arr, true, 0, spellName,
playerOnly))
return true;
}
return false;
}
void AppendGroupAuras(void *L, uint64_t guid, Filter filter, bool playerOnly,
int outerIdx, int &nextKey) {
if (guid == 0)
return;
const uint16_t *arr = Group::MemberStats::AuraArray(guid);
if (arr == nullptr)
return;
Aura::Source::ObserveGroupAuras(guid, arr);
int start, end;
GroupRange(filter, start, end);
const int level = GroupMemberLevel(guid);
for (int slot = start; slot < end; ++slot) {
if (!GroupSlotEligible(guid, arr, slot, playerOnly))
continue;
Game::Lua::PushNumber(L, static_cast<double>(nextKey++));
PushEnriched(L, guid, GroupSpellID(arr, slot),
filter == Filter::Helpful, 1, level, false);
Game::Lua::SetTable(L, outerIdx);
}
// Empty array (server delta-resend gap): supplement from the cache.
if (!GroupArrayHasVisibleAura(arr))
AppendGroupCacheFallbacks(L, guid, arr, filter, playerOnly, outerIdx,
nextKey);
}
} // namespace Aura::Data
+41
View File
@@ -132,4 +132,45 @@ bool PushNthCacheFallback(void *L, const uint8_t *unit, int oneBasedIndex,
void AppendCacheFallbacks(void *L, const uint8_t *unit, Filter filter,
bool playerOnly, int outerIdx, int &nextKey);
// ── Out-of-range groupmate path ────────────────────────────────────────────
//
// When a party/raid member has no live CGUnit at all (different map, far out
// of range) the token resolves to a GUID but no object, so there is no
// descriptor to read. The server still transmits that member's current aura
// spell IDs via SMSG_PARTY_MEMBER_STATS, which the client keeps in the
// group-member stats structs (`Group::MemberStats::AuraArray`). These functions
// enumerate that array — exactly what the built-in `UnitBuff`/`UnitDebuff` do
// out of range — and build the same AuraData table.
//
// Spell IDs only come off the wire: `applications` is 1 (stacks aren't sent)
// and IDs are u16-truncated (custom IDs > 65535 are wrong). Any caster / real
// expirationTime we observed for the member via SMSG_SPELL_GO is still merged
// in from the `Aura::Source` cache. `guid` of 0 (not a rostered member) yields
// no results. These are only meaningful when the unit has NO descriptor —
// callers use them in the `unit == nullptr` branch, so there is no descriptor
// to dedup against.
// Pushes AuraData for the `oneBasedIndex`-th group-array aura on `guid`
// matching `filter`. On a hit pushes the table and returns true; otherwise
// pushes nothing and returns false (caller pushes nil).
bool PushNthGroupAura(void *L, uint64_t guid, int oneBasedIndex, Filter filter,
bool playerOnly = false);
// Pushes AuraData for the group-array aura with `spellID` on `guid`, optionally
// restricted to one filter range (nullptr = both, helpful first). Returns true
// on a hit (table pushed), false otherwise (nothing pushed).
bool PushGroupAuraBySpellID(void *L, uint64_t guid, uint32_t spellID,
const Filter *filter, bool playerOnly = false);
// As `PushGroupAuraBySpellID` but matched by locale-resolved spell name
// (case-sensitive exact match), mirroring `FindSlotBySpellName`.
bool PushGroupAuraBySpellName(void *L, uint64_t guid, const char *spellName,
const Filter *filter, bool playerOnly = false);
// Appends every group-array aura on `guid` matching `filter` into the array
// table at `outerIdx`, continuing from `nextKey` (updated in place). The
// bulk-enumeration analog of `PushNthGroupAura`, for `GetUnitAuras`.
void AppendGroupAuras(void *L, uint64_t guid, Filter filter, bool playerOnly,
int outerIdx, int &nextKey);
} // namespace Aura::Data
+205 -6
View File
@@ -20,8 +20,10 @@
#include "Game.h"
#include "Offsets.h"
#include "net/PacketReader.h"
#include "player/StatSignal.h"
#include "spell/Lookup.h"
#include "tick/WorldTick.h"
#include "totem/Tracker.h"
#include "unit/Identity.h"
#include <cstdint>
@@ -68,6 +70,49 @@ uint32_t SpellDurationMs(const uint8_t *spellRecord, bool casterIsPlayer) {
return ms > 0 ? static_cast<uint32_t>(ms) : 0;
}
// ---- Recent local-player aura casts --------------------------------------
// The aura-application hooks (OnAuraAdded / OnAuraStacksChanged) get no caster,
// so they compute the UNMODIFIED base duration (`skipMod = 1`). That's correct
// for other units' auras, but wrong for one the LOCAL PLAYER just cast: it
// should carry the player-talented duration (Improved Shadow Word: Pain → 24s
// vs the 18s base). SpellGo normally owns that entry with the talented value,
// but when it can't attribute the cast to this target — an empty SMSG_SPELL_GO
// hit list files it under the caster instead, or a slot reshuffle evicts and
// refills the entry after SpellGo ran — the base value is what's left, so the
// duration flickers 24 → 18. SpellGo (which knows caster == player) records the
// spellId here so the application hooks can recover the player-modified
// duration for it.
constexpr int kRecentCastCount = 16;
constexpr uint32_t kRecentCastTtlMs = 1500;
struct RecentCast {
uint32_t spellId;
uint32_t tMs; // 0 = empty
};
RecentCast g_recentCasts[kRecentCastCount];
int g_recentCastCursor = 0;
void RememberPlayerCast(uint32_t spellId) {
const uint32_t now = NowMs();
for (auto &r : g_recentCasts)
if (r.spellId == spellId) {
r.tMs = now;
return;
}
g_recentCasts[g_recentCastCursor] = {spellId, now};
g_recentCastCursor = (g_recentCastCursor + 1) % kRecentCastCount;
}
bool WasRecentPlayerCast(uint32_t spellId) {
if (spellId == 0)
return false;
const uint32_t now = NowMs();
for (const auto &r : g_recentCasts)
if (r.spellId == spellId && r.tMs != 0 && now - r.tMs <= kRecentCastTtlMs)
return true;
return false;
}
// ---- Cache ---------------------------------------------------------------
// Helpful/harmful classification, recorded from the aura's descriptor slot
@@ -133,6 +178,45 @@ void Store(uint64_t targetGuid, uint32_t spellId, uint64_t casterGuid,
true};
}
// ---- Out-of-range group-member aura snapshots ---------------------------
//
// Transition state for `ObserveGroupAuras`: the last aura spell-ID set we saw
// for each out-of-range group member, so we can stamp a duration guess only
// when an aura genuinely appears (not for auras already present when we first
// started watching that member). See the header for the rationale.
struct GroupSnapshot {
uint64_t guid;
uint32_t touchMs;
bool used;
uint16_t ids[Offsets::UNIT_AURA_TOTAL];
};
constexpr int kGroupSnapshots = 44; // MAX_RAID (40) + slack
constexpr uint32_t kGroupSnapshotTtlMs = 30000; // forget members not polled for 30s
GroupSnapshot g_groupSnaps[kGroupSnapshots];
bool SnapshotHasId(const GroupSnapshot &s, uint16_t id) {
for (uint16_t v : s.ids)
if (v == id)
return true;
return false;
}
// Stamp a base-duration expiration guess for a group aura that just appeared.
// casterGuid stays 0 (unknown), so `Store`'s guard preserves any real SpellGo
// timing we already hold. Skips non-aura spells and auras with no finite base
// duration (nothing meaningful to guess — leave expiration unknown).
void StampGroupGuess(uint64_t guid, uint16_t spellId, int8_t kind, uint32_t now) {
const uint8_t *rec = Spell::Lookup::RecordForID(static_cast<int>(spellId));
if (!SpellAppliesAura(rec))
return;
const uint32_t base = SpellDurationMs(rec, /*casterIsPlayer*/ false);
if (base == 0)
return;
Store(guid, spellId, /*casterGuid*/ 0, now + base, base, /*fromCast*/ false,
kind);
}
// Evict the entry for an aura the engine reports gone. Keyed by (target,
// spell) like the rest of the cache. Without this, the GetAuraDataByIndex
// fallback would keep surfacing a dropped aura until its computed expiry —
@@ -154,6 +238,10 @@ void Evict(uint64_t targetGuid, uint32_t spellId) {
void FlushAll() {
for (auto &e : g_cache)
e.used = false;
// Reset transition baselines too: post-transition group auras re-sync and
// should be treated as first-sight (unknown age), not diffed as new.
for (auto &s : g_groupSnaps)
s.used = false;
}
// -1 = no map seen yet (never a valid Map.dbc id), so the first tick just
@@ -189,6 +277,12 @@ void OnWorldTick() {
if (e.used && e.expirationMs != 0 && now >= e.expirationMs)
e.used = false;
}
// Forget snapshots for members we haven't polled recently (left the group,
// or no longer displayed) so a later re-appearance re-baselines cleanly.
for (auto &s : g_groupSnaps) {
if (s.used && now - s.touchMs > kGroupSnapshotTtlMs)
s.used = false;
}
}
const Tick::WorldTick::AutoSubscribe _tickSub{&OnWorldTick};
@@ -229,6 +323,11 @@ void __fastcall SpellGo_h(uint64_t *itemGUID, uint64_t *casterGUID,
if (caster == 0 || spellId == 0)
return;
// Feed the totem tracker BEFORE the aura gate below — a totem summon
// applies no aura, so it would otherwise be dropped. Player casts only.
if (caster == Unit::Identity::PlayerGuid())
Totem::Tracker::OnPlayerSpellGo(spellId);
const uint8_t *rec = Spell::Lookup::RecordForID(static_cast<int>(spellId));
if (!SpellAppliesAura(rec))
return;
@@ -245,6 +344,12 @@ void __fastcall SpellGo_h(uint64_t *itemGUID, uint64_t *casterGUID,
durationMs = SpellDurationMs(rec, casterIsPlayer);
const uint32_t expirationMs = durationMs > 0 ? NowMs() + durationMs : 0;
// Let the application hooks recover the player-talented duration for this
// spell if they end up owning the target's entry (empty hit list / refill
// race) — see WasRecentPlayerCast.
if (casterIsPlayer)
RememberPlayerCast(spellId);
if (numTargets == 0) {
// No explicit hit list (self-cast with caster-implicit target).
Store(caster, spellId, caster, expirationMs, durationMs, true,
@@ -276,9 +381,15 @@ void StampApplication(void *unit, uint32_t spellId, int8_t kind) {
const uint64_t unitGuid = Unit::Identity::GuidForObject(unit);
if (unitGuid == 0)
return;
const uint32_t durationMs = SpellDurationMs(rec, /*casterIsPlayer*/ false);
// If the local player just cast this aura, use its player-talented duration
// (and attribute the caster) — SpellGo couldn't always land the talented
// value on this target's entry. Otherwise base (we lack other casters'
// mods). See WasRecentPlayerCast.
const bool byPlayer = WasRecentPlayerCast(spellId);
const uint32_t durationMs = SpellDurationMs(rec, byPlayer);
const uint64_t caster = byPlayer ? Unit::Identity::PlayerGuid() : 0;
const uint32_t expirationMs = durationMs > 0 ? NowMs() + durationMs : 0;
Store(unitGuid, spellId, /*casterGuid*/ 0, expirationMs, durationMs,
Store(unitGuid, spellId, caster, expirationMs, durationMs,
/*fromCast*/ false, kind);
}
@@ -288,6 +399,15 @@ int8_t KindForSlot(int slot) {
return slot >= Offsets::UNIT_AURA_BUFF_COUNT ? KIND_HARMFUL : KIND_HELPFUL;
}
// Bump the player-stat-inputs signal when an aura change hits the LOCAL
// player — buffs/debuffs move GetSpellBonusHealing's flat and Spirit/Armor
// terms, so its lazy cache must invalidate. Guarded on the player object so
// other units' aura churn (combat) doesn't needlessly invalidate it.
void NotifyIfPlayer(void *unit) {
if (static_cast<const uint8_t *>(unit) == Unit::Identity::PlayerObject())
Player::StatSignal::Notify();
}
// OnAuraAdded — a new aura occupies a slot (gives the spellId directly).
using OnAuraAdded_t = void(__fastcall *)(void *unit, void *edx, uint32_t slot,
uint32_t spellId);
@@ -297,6 +417,7 @@ void __fastcall OnAuraAdded_h(void *unit, void *edx, uint32_t slot,
uint32_t spellId) {
g_origOnAuraAdded(unit, edx, slot, spellId);
StampApplication(unit, spellId, KindForSlot(static_cast<int>(slot)));
NotifyIfPlayer(unit);
}
const Game::HookAutoRegister _hookAuraAdded{
@@ -317,6 +438,7 @@ void __fastcall OnAuraStacksChanged_h(void *unit, void *edx, int slot,
unit,
Aura::Data::ReadSpellID(static_cast<const uint8_t *>(unit), slot),
KindForSlot(slot));
NotifyIfPlayer(unit);
}
const Game::HookAutoRegister _hookAuraStacks{
@@ -325,10 +447,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;
@@ -338,6 +468,7 @@ void __fastcall OnAuraRemoved_h(void *unit, void *edx, uint32_t slot,
g_origOnAuraRemoved(unit, edx, slot, spellId);
(void)slot;
Evict(Unit::Identity::GuidForObject(unit), spellId);
NotifyIfPlayer(unit);
}
const Game::HookAutoRegister _hookAuraRemoved{
@@ -361,6 +492,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;
@@ -379,4 +527,55 @@ int Enumerate(uint64_t unitGuid, bool harmful, CachedAura *out, int maxOut) {
return n;
}
void ObserveGroupAuras(uint64_t guid, const uint16_t *auraArray) {
if (guid == 0 || auraArray == nullptr)
return;
const int total = Offsets::UNIT_AURA_TOTAL;
const int buffCount = Offsets::UNIT_AURA_BUFF_COUNT;
const uint32_t now = NowMs();
GroupSnapshot *snap = nullptr;
for (auto &s : g_groupSnaps)
if (s.used && s.guid == guid) {
snap = &s;
break;
}
if (snap == nullptr) {
// First sight of this member — record a baseline without stamping;
// their current auras have unknown age.
GroupSnapshot *dst = nullptr;
for (auto &s : g_groupSnaps)
if (!s.used) {
dst = &s;
break;
}
if (dst == nullptr) { // full — evict the least-recently-polled
dst = &g_groupSnaps[0];
for (auto &s : g_groupSnaps)
if (s.touchMs < dst->touchMs)
dst = &s;
}
dst->guid = guid;
dst->used = true;
dst->touchMs = now;
for (int i = 0; i < total; ++i)
dst->ids[i] = auraArray[i];
return;
}
// Stamp a guess for every spell ID present now but absent from the prior
// snapshot (a genuine appearance), then refresh the snapshot.
for (int slot = 0; slot < total; ++slot) {
const uint16_t id = auraArray[slot];
if (id == 0 || SnapshotHasId(*snap, id))
continue;
StampGroupGuess(guid, id, slot < buffCount ? KIND_HELPFUL : KIND_HARMFUL,
now);
}
for (int i = 0; i < total; ++i)
snap->ids[i] = auraArray[i];
snap->touchMs = now;
}
} // namespace Aura::Source
+27
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
@@ -62,4 +74,19 @@ struct CachedAura {
// the aura is still active server-side: the cache survives descriptor clears.
int Enumerate(uint64_t unitGuid, bool harmful, CachedAura *out, int maxOut);
// Feeds the cache from an out-of-range group member's aura spell-ID array
// (`Group::MemberStats::AuraArray` — 48 slots, buffs 0..BUFF_COUNT-1, debuffs
// after). Such a member has no CGUnit and never yields SMSG_SPELL_GO, so the
// SpellGo path never learns their auras' timing — but SMSG_PARTY_MEMBER_STATS
// carries the spell IDs and the server sends it promptly when an aura is
// added/removed. So a spell ID that newly *appears* here did so ~one server
// tick + latency after the real application: we stamp a best-effort
// `now + base duration` expiration (base only — no remote caster mods, no
// caster recorded). Auras already present the first time we see a member have
// unknown age and are NOT stamped; only genuine absent->present transitions
// are. Idempotent within a frame; call it before enumerating the array so a
// just-appeared aura carries its guess on the same poll. The Store guard keeps
// any real SpellGo timing we already hold, so this never downgrades better data.
void ObserveGroupAuras(uint64_t guid, const uint16_t *auraArray);
} // namespace Aura::Source
+11
View File
@@ -56,6 +56,7 @@
#include "Game.h"
#include "Offsets.h"
#include "bag/UpdateDelayed.h"
#include "event/Custom.h"
#include "tick/WorldTick.h"
@@ -71,8 +72,15 @@ const Event::Custom::AutoReserve _reserve{kEventName};
// Single bool, no atomics needed — the engine is single-threaded.
bool g_pending = false;
// C++ subscribers notified once per bag-change frame (see UpdateDelayed.h).
AutoSubscribe *g_subs = nullptr;
} // namespace
AutoSubscribe::AutoSubscribe(Callback cb) : cb(cb), next(g_subs) {
g_subs = this;
}
// `FUN_004F91A0` — `__cdecl void()`. Bag-slot diff loop. Walks the
// 10 bag slots, fires BAG_UPDATE for each that changed.
using BagSlotDiff_t = void(__cdecl *)();
@@ -123,6 +131,9 @@ void OnWorldTick() {
const int slot = Event::Custom::Lookup(kEventName);
if (slot >= 0)
Event::Custom::Fire(slot, "");
// Notify C++ subscribers of the bag-change frame (see UpdateDelayed.h).
for (auto *node = g_subs; node != nullptr; node = node->next)
node->cb();
}
static const Game::HookAutoRegister _hookSlotDiff{
+39
View File
@@ -0,0 +1,39 @@
// 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/>.
#pragma once
// Authoritative "a bag changed this frame" subscription. `Bag::UpdateDelayed`
// co-hooks the three (and only three) engine `BAG_UPDATE` fire sites and
// coalesces them into a single per-frame signal — the same signal that backs
// the `BAG_UPDATE_DELAYED` event. Subscribe here to run C++ work exactly once
// per frame in which any bag actually changed, instead of polling every frame.
//
// Callbacks fire at the tail of the world tick, after every BAG_UPDATE for the
// frame has already fired (so bag contents are settled). No firing on frames
// where nothing changed. Order across subscribers is unspecified.
//
// Module pattern, matching `Tick::WorldTick::AutoSubscribe`: declare a
// file-scope `static const Bag::UpdateDelayed::AutoSubscribe _s{&Callback};`.
namespace Bag::UpdateDelayed {
using Callback = void (*)();
struct AutoSubscribe {
explicit AutoSubscribe(Callback cb);
Callback cb;
AutoSubscribe *next;
};
} // namespace Bag::UpdateDelayed
+82
View File
@@ -0,0 +1,82 @@
// 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/>.
// Lua 5.1 math-library additions that 1.12's Lua 5.0 is missing:
//
// - `math.fmod(x, y)` — floating-point remainder of `x / y`.
// - `math.modf(x)` — split into integral + fractional parts.
// - `math.huge` — positive infinity constant.
//
// `math.fmod` is a pure rename: Lua 5.0 ships the exact same C function under
// the name `math.mod`; 5.1 renamed the Lua-facing entry to `math.fmod`. We
// register it as a direct alias of the engine's `math.mod` C function
// (`FUN_LUA_MATH_FMOD`) — no wrapper. `math.mod` stays available too, so code
// written for either name works. `modf` and `huge` are genuinely new in 5.1
// (not in 1.12's mathlib table), so we implement/define them here.
#include "Game.h"
#include "Offsets.h"
#include <cmath>
namespace BaseLib::MathLib {
namespace {
// `math.fmod` is 5.0's `math.mod` verbatim — register the engine function
// pointer directly rather than wrapping it.
const auto Script_math_fmod =
reinterpret_cast<Game::Lua::CFunction>(Offsets::FUN_LUA_MATH_FMOD);
// `math.modf(x)` — returns `(integral, fractional)`, the integral part
// truncated toward zero and the fractional part carrying `x`'s sign
// (`modf(3.7) → 3, 0.7`; `modf(-3.7) → -3, -0.7`). Matches C `modf` / Lua 5.1.
int __fastcall Script_math_modf(void *L) {
if (!Game::Lua::IsNumber(L, 1)) {
Game::Lua::Error(L, "Usage: math.modf(x)");
return 0; // unreachable
}
double integral = 0.0;
const double fractional = std::modf(Game::Lua::ToNumber(L, 1), &integral);
Game::Lua::PushNumber(L, integral);
Game::Lua::PushNumber(L, fractional);
return 2;
}
void RegisterFns() {
Game::Lua::RegisterTableFunction("math", "fmod", Script_math_fmod);
Game::Lua::RegisterTableFunction("math", "modf", &Script_math_modf);
// `math.huge` — a numeric constant, not a function. Fetch the (now
// guaranteed-present) `math` table and set the field directly.
void *L = Game::Lua::State();
if (L != nullptr) {
Game::Lua::PushString(L, "math");
Game::Lua::GetTable(L, Game::Lua::GLOBALS_INDEX); // [math]
if (Game::Lua::Type(L, -1) == 5 /* LUA_TTABLE */) {
Game::Lua::SetFieldNumber(L, "huge", HUGE_VAL); // math.huge = inf; [math]
Game::Lua::SetTop(L, -2); // pop [math]
} else {
Game::Lua::SetTop(L, -2); // pop whatever GetTable returned
}
}
}
// Both states are Lua 5.0 and equally missing this; RegisterTableFunction
// targets whichever state is active in each registration hook.
const Game::ModuleAutoRegister _autoreg{&RegisterFns};
const Game::GlueModuleAutoRegister _autoregGlue{&RegisterFns};
} // namespace
} // namespace BaseLib::MathLib
+83
View File
@@ -0,0 +1,83 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU 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/>.
// `Mixin(object, ...)` and `CreateFromMixins(...)` — the FrameXML table-mixin
// primitives. Modern WoW provides these as engine C functions (in TableUtil);
// we do the same rather than leaving them in the !!!ClassicAPI addon so they
// exist whenever the DLL is injected, even if the addon is disabled. The
// composite `CreateAndInitFromMixin` (which calls a Lua `:Init` method) stays
// Lua-side in the addon, mirroring retail's Mixin.lua.
//
// Behaviour matches the canonical Lua implementation exactly:
// Mixin(object, ...) -- shallow-copy every key/value from each table
// argument onto `object`; returns `object`.
// CreateFromMixins(...) -- Mixin({}, ...); returns the new table.
// Non-table extra arguments are skipped (the Lua form guards nil the same way).
#include "Game.h"
namespace BaseLib::Mixin {
namespace {
// Shallow-copy every pair from the table at `srcIdx` onto the table at
// `dstIdx` (both absolute, positive stack indices). Stack-balanced.
void MixinInto(void *L, int dstIdx, int srcIdx) {
Game::Lua::PushNil(L);
while (Game::Lua::Next(L, srcIdx) != 0) {
// stack top: ..., key (-2), value (-1). Copy both so the originals
// survive for the next Next() iteration.
Game::Lua::PushValue(L, -2); // key copy
Game::Lua::PushValue(L, -2); // value copy
Game::Lua::SetTable(L, dstIdx); // dst[key] = value; pops the two copies
Game::Lua::SetTop(L, -2); // pop value, leave key for Next()
}
}
int __fastcall Script_Mixin(void *L) {
const int top = Game::Lua::GetTop(L);
if (top < 1 || Game::Lua::Type(L, 1) != Game::Lua::TYPE_TABLE) {
Game::Lua::Error(L, "Usage: Mixin(object, ...)");
return 0; // unreachable
}
for (int i = 2; i <= top; ++i)
if (Game::Lua::Type(L, i) == Game::Lua::TYPE_TABLE)
MixinInto(L, 1, i);
Game::Lua::PushValue(L, 1); // return object
return 1;
}
int __fastcall Script_CreateFromMixins(void *L) {
const int top = Game::Lua::GetTop(L);
Game::Lua::NewTable(L); // new object at index top + 1
const int objIdx = top + 1;
for (int i = 1; i <= top; ++i)
if (Game::Lua::Type(L, i) == Game::Lua::TYPE_TABLE)
MixinInto(L, objIdx, i);
Game::Lua::SetTop(L, objIdx); // the new table is the sole result
return 1;
}
void RegisterFns() {
Game::Lua::RegisterGlobalFunction("Mixin", &Script_Mixin);
Game::Lua::RegisterGlobalFunction("CreateFromMixins", &Script_CreateFromMixins);
}
// Pure table helpers — register on both the in-game and glue states so they're
// always present (matching the "always available" intent).
const Game::ModuleAutoRegister _autoreg{&RegisterFns};
const Game::GlueModuleAutoRegister _autoregGlue{&RegisterFns};
} // namespace
} // namespace BaseLib::Mixin
+87
View File
@@ -0,0 +1,87 @@
// 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/>.
// `select(index, ...)` — the Lua 5.1 base-library vararg helper, missing
// from 1.12's Lua 5.0 (it predates the function). Two forms:
// - `select('#', ...)` → the number of extra arguments.
// - `select(n, ...)` → all arguments from position `n` onward.
// `n` may be negative to index from the end (`-1` = the last arg).
//
// Ported from Lua 5.1's `luaB_select` (lbaselib.c). The numeric form needs
// no pushes at all: the varargs are already sitting on the stack at
// positions `2..top`, so returning `top - n` hands back exactly the slice
// from `n+1` onward — the same trick the reference implementation uses.
// The `'#'` form pushes the count and returns 1.
//
// Registered on BOTH Lua states (in-game + glue): it's a language-level
// helper, state-agnostic, and both states are 5.0 and equally missing it.
#include "Game.h"
namespace BaseLib::Select {
namespace {
int __fastcall Script_select(void *L) {
const int n = Game::Lua::GetTop(L);
if (n < 1) {
Game::Lua::Error(L, "bad argument #1 to 'select' (number expected, got no value)");
return 0; // unreachable
}
// `select('#', ...)` — count form. Only inspect the string when the
// arg *is* a string: calling lua_tostring on a number would convert
// the stack value in place (Lua 5.0/5.1 semantics), corrupting arg 1.
if (Game::Lua::Type(L, 1) == Game::Lua::TYPE_STRING) {
const char *s = Game::Lua::ToString(L, 1);
if (s != nullptr && s[0] == '#') {
Game::Lua::PushNumber(L, static_cast<double>(n - 1));
return 1;
}
// A non-'#' string falls through: lua_isnumber accepts numeric
// strings (e.g. "2"), matching the reference's luaL_checkint
// coercion; a non-numeric string errors below.
}
if (!Game::Lua::IsNumber(L, 1)) {
Game::Lua::Error(L, "bad argument #1 to 'select' (number expected)");
return 0; // unreachable
}
int i = static_cast<int>(Game::Lua::ToNumber(L, 1));
if (i < 0)
i = n + i; // negative indexes count back from the last argument
else if (i > n)
i = n;
if (i < 1) {
Game::Lua::Error(L, "bad argument #1 to 'select' (index out of range)");
return 0; // unreachable
}
// The varargs from position i+1..n are already on the stack; returning
// their count hands exactly that slice back to the caller.
return n - i;
}
void RegisterInGame() {
Game::Lua::RegisterGlobalFunction("select", &Script_select);
}
void RegisterGlue() {
Game::Lua::RegisterGlueFunction("select", &Script_select);
}
const Game::ModuleAutoRegister _autoreg{&RegisterInGame};
const Game::GlueModuleAutoRegister _autoregGlue{&RegisterGlue};
} // namespace
} // namespace BaseLib::Select
+282
View File
@@ -0,0 +1,282 @@
// 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/>.
// String helpers that 1.12's Lua 5.0 is missing:
//
// - `string.match(s, pattern [, init])` — first-match extraction (5.1).
// - `string.gmatch(s, pattern)` — match iterator (5.1).
// - `strsplit(sep, str [, pieces])` — WoW global; split on any char.
// - `strjoin(delimiter, ...)` — WoW global; join varargs.
// - `strtrim(str [, chars])` — WoW global; trim a char set.
// - `strreplace(str, find, replace)` — plain-text replace (see below).
// - `string.reverse(s)` / `strrev(s)` — 5.1 string function, missing in 5.0.
//
// The two pattern `string.*` ones reuse machinery 5.0 already ships:
// - `match` is `find` returning captures / the whole match instead of the
// start/end indices — so we call the engine's `str_find` and transform.
// - `gmatch` is exactly 5.0's `string.gfind` (renamed in 5.1); we register
// it as a direct alias of the engine's `gfind` C function.
// `strsplit` is a hand-rolled port of 3.3.5's `strsplit` (see below).
//
// NOTE: only the `string.foo(s, ...)` call form works — NOT the
// `("x"):foo(...)` method sugar. WoW's Lua VM strips type-metatables for
// every value except tables/userdata (both `lua_setmetatable` and the VM's
// `luaT_gettmbyobj` hard-return "no metamethod" for strings), so strings have
// no `__index`, and there's no client-side way to add one short of hooking a
// hot VM-core function. This is the long-standing vanilla constraint.
#include "Game.h"
#include "Offsets.h"
#include <cstddef>
#include <cstring>
#include <string>
namespace BaseLib::StringLib {
namespace {
// `string.match(s, pattern [, init])`. Calls the engine `str_find` and
// reshapes its result: no match → nil; match with captures → the captures;
// match with no captures → the whole matched substring.
int __fastcall Script_string_match(void *L) {
// Normalize to exactly (s, pattern, init): pad missing args with nil so
// str_find defaults `init` to 1, and drop any stray 4th value so it can't
// be misread as find's `plain` flag (match has no plain form).
Game::Lua::SetTop(L, 3);
constexpr int kBase = 3;
auto strFind = reinterpret_cast<Game::Lua::CFunction>(Offsets::FUN_LUA_STR_FIND);
const int nres = strFind(L); // pushes start,end,caps.. above kBase, or a nil
if (nres <= 1) {
// No match. str_find pushed the nil (return 1); guard the degenerate
// nres==0 case by pushing our own nil so the shape holds.
if (nres < 1)
Game::Lua::PushNil(L);
return 1;
}
if (nres == 2) {
// No captures → the whole matched substring s[start..end] (1-based,
// inclusive). An empty match has end == start-1 → an empty string.
const int start = static_cast<int>(Game::Lua::ToNumber(L, kBase + 1));
const int end = static_cast<int>(Game::Lua::ToNumber(L, kBase + 2));
// str_find already coerced arg 1 to a string, so this is safe.
const char *s = Game::Lua::ToString(L, 1);
Game::Lua::SetTop(L, kBase); // drop start, end
const size_t len = (end >= start) ? static_cast<size_t>(end - start + 1) : 0;
Game::Lua::PushLString(L, s + start - 1, static_cast<unsigned int>(len));
return 1;
}
// Captures present → drop start & end, hand back only the captures.
Game::Lua::Remove(L, kBase + 1); // remove start; end → kBase+1, caps → kBase+2..
Game::Lua::Remove(L, kBase + 1); // remove end; caps → kBase+1..
return nres - 2;
}
// `string.gmatch` is 5.0's `string.gfind` verbatim — register the engine
// function pointer directly rather than wrapping it.
const auto Script_string_gmatch =
reinterpret_cast<Game::Lua::CFunction>(Offsets::FUN_LUA_STR_GFIND);
// `strsplit(sep, str [, pieces])` — the WoW global (also aliased as
// `string.split`), split `str` on ANY character in `sep` and return the
// pieces as multiple values. `pieces > 0` caps the result count, with the
// unsplit remainder as the final piece; `0` / omitted = unlimited. Ported
// from 3.3.5's `strsplit` (FUN_00816a60) with one deliberate change: we do
// NOT `lua_settop(L, 0)` up front. Popping the args would leave the source
// string unreferenced, so a GC step triggered by a `pushlstring` could free
// the very buffer we're still scanning. Keeping the args on the stack keeps
// the source GC-rooted; Lua returns the top N values (our pieces) regardless
// of the args sitting below them. Each push is guarded by `lua_checkstack`
// (this pushes an unbounded number of results), erroring like 3.3.5 on
// genuine stack exhaustion.
int __fastcall Script_strsplit(void *L) {
const char *sep = Game::Lua::ToString(L, 1);
const char *str = Game::Lua::ToString(L, 2);
if (sep == nullptr || str == nullptr) {
Game::Lua::Error(L, "Usage: strsplit(\"separators\", str [, pieces])");
return 0; // unreachable
}
const int pieces =
Game::Lua::IsNumber(L, 3) ? static_cast<int>(Game::Lua::ToNumber(L, 3)) : 0;
const char *segStart = str;
int count = 0;
// Only scan for separators while unlimited (pieces == 0) or the cap
// hasn't been reached (pieces > 1). pieces == 1 (or <= 0 but non-zero)
// yields the whole string as a single piece — matches 3.3.5.
if (pieces == 0 || pieces > 1) {
for (const char *p = str; *p != '\0'; ++p) {
bool isSep = false;
for (const char *s = sep; *s != '\0'; ++s) {
if (*p == *s) {
isSep = true;
break;
}
}
if (!isSep)
continue;
++count;
if (Game::Lua::CheckStack(L, count) == 0) {
Game::Lua::Error(L, "strsplit(): Stack overflow");
return 0; // unreachable
}
Game::Lua::PushLString(L, segStart,
static_cast<unsigned int>(p - segStart));
segStart = p + 1;
if (count == pieces - 1)
break; // cap hit; remainder becomes the final piece
}
}
if (Game::Lua::CheckStack(L, count + 1) == 0) {
Game::Lua::Error(L, "strsplit(): Stack overflow");
return 0; // unreachable
}
Game::Lua::PushString(L, segStart); // final piece (remainder to end)
return count + 1;
}
// `strjoin(delimiter, ...)` — the inverse of `strsplit`: concatenate the
// vararg pieces with `delimiter` between each. Standard WoW global (FrameXML
// defines it in Lua; there's no C original to mirror). A nil / non-coercible
// piece is treated as an empty string rather than erroring, so the delimiter
// placement stays predictable. `strjoin(",")` with no pieces returns "".
int __fastcall Script_strjoin(void *L) {
const char *delim = Game::Lua::ToString(L, 1);
if (delim == nullptr) {
Game::Lua::Error(L, "Usage: strjoin(delimiter, ...)");
return 0; // unreachable
}
const unsigned delimLen = Game::Lua::StrLen(L, 1);
const int top = Game::Lua::GetTop(L);
std::string out; // no Error() past this point — a longjmp would leak it
for (int i = 2; i <= top; ++i) {
if (i > 2)
out.append(delim, delimLen);
const char *piece = Game::Lua::ToString(L, i);
if (piece != nullptr)
out.append(piece, Game::Lua::StrLen(L, i));
}
Game::Lua::PushLString(L, out.data(), static_cast<unsigned int>(out.size()));
return 1;
}
// `strtrim(str [, chars])` — remove any of the characters in `chars` from
// both ends of `str` (default: whitespace). `chars` is a literal set of
// characters, not a pattern. Standard WoW global. Two-ended scan — no
// allocation, embedded NULs preserved (bounds come from the Lua length).
int __fastcall Script_strtrim(void *L) {
const char *str = Game::Lua::ToString(L, 1);
if (str == nullptr) {
Game::Lua::Error(L, "Usage: strtrim(str [, chars])");
return 0; // unreachable
}
const unsigned len = Game::Lua::StrLen(L, 1);
const char *chars = Game::Lua::IsString(L, 2) ? Game::Lua::ToString(L, 2)
: " \t\n\v\f\r";
bool trim[256] = {false};
for (const char *c = chars; *c != '\0'; ++c)
trim[static_cast<unsigned char>(*c)] = true;
unsigned start = 0, end = len;
while (start < end && trim[static_cast<unsigned char>(str[start])])
++start;
while (end > start && trim[static_cast<unsigned char>(str[end - 1])])
--end;
Game::Lua::PushLString(L, str + start, end - start);
return 1;
}
// `strreplace(str, find, replace)` — replace every occurrence of the literal
// substring `find` with `replace`; returns `(result, count)`. NOT a stock WoW
// global and NOT pattern-based (that's `gsub`) — a plain-text replace that
// needs no magic-character escaping. Empty `find` returns `str` unchanged
// (count 0) rather than looping forever.
int __fastcall Script_strreplace(void *L) {
const char *str = Game::Lua::ToString(L, 1);
const char *find = Game::Lua::ToString(L, 2);
const char *repl = Game::Lua::ToString(L, 3);
if (str == nullptr || find == nullptr || repl == nullptr) {
Game::Lua::Error(L, "Usage: strreplace(str, find, replace)");
return 0; // unreachable
}
const unsigned strLen = Game::Lua::StrLen(L, 1);
const unsigned findLen = Game::Lua::StrLen(L, 2);
const unsigned replLen = Game::Lua::StrLen(L, 3);
if (findLen == 0) { // nothing to match — return input unchanged
Game::Lua::PushLString(L, str, strLen);
Game::Lua::PushNumber(L, 0.0);
return 2;
}
std::string out; // no Error() past this point
int count = 0;
unsigned i = 0;
while (i < strLen) {
if (i + findLen <= strLen && std::memcmp(str + i, find, findLen) == 0) {
out.append(repl, replLen);
i += findLen;
++count;
} else {
out.push_back(str[i]);
++i;
}
}
Game::Lua::PushLString(L, out.data(), static_cast<unsigned int>(out.size()));
Game::Lua::PushNumber(L, static_cast<double>(count));
return 2;
}
// `string.reverse(s)` / `strrev(s)` — return `s` with its bytes reversed.
// Lua 5.1 added `string.reverse`; 5.0's strlib lacks it. Byte-wise (not
// UTF-8 aware), matching stock Lua; embedded NULs preserved (bounds from the
// Lua length).
int __fastcall Script_string_reverse(void *L) {
const char *s = Game::Lua::ToString(L, 1);
if (s == nullptr) {
Game::Lua::Error(L, "Usage: string.reverse(s)");
return 0; // unreachable
}
const unsigned len = Game::Lua::StrLen(L, 1);
std::string out(len, '\0'); // no Error() past this point
for (unsigned i = 0; i < len; ++i)
out[i] = s[len - 1 - i];
Game::Lua::PushLString(L, out.data(), len);
return 1;
}
void RegisterFns() {
Game::Lua::RegisterTableFunction("string", "match", &Script_string_match);
Game::Lua::RegisterTableFunction("string", "gmatch", Script_string_gmatch);
Game::Lua::RegisterTableFunction("string", "reverse", &Script_string_reverse);
Game::Lua::RegisterGlobalFunction("strsplit", &Script_strsplit);
Game::Lua::RegisterGlobalFunction("strjoin", &Script_strjoin);
Game::Lua::RegisterGlobalFunction("strtrim", &Script_strtrim);
Game::Lua::RegisterGlobalFunction("strreplace", &Script_strreplace);
Game::Lua::RegisterGlobalFunction("strrev", &Script_string_reverse);
}
// Both states are Lua 5.0 and equally missing these; RegisterTableFunction
// targets whichever state is active in each registration hook.
const Game::ModuleAutoRegister _autoreg{&RegisterFns};
const Game::GlueModuleAutoRegister _autoregGlue{&RegisterFns};
} // namespace
} // namespace BaseLib::StringLib
+82
View File
@@ -0,0 +1,82 @@
// 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_ChatBubbles.GetAllChatBubbles([includeForbidden])` — returns an
// array of the currently-active chat-bubble Frame objects, matching
// modern WoW's 7.2.5 signature. Addons use it to recolor / translate /
// reposition speech bubbles by reading the bubble's FontString region.
//
// Vanilla 1.12 chat bubbles are `CGChatBubbleFrame`s — full
// `CSimpleFrame`s created purely in C++ (never via `CreateFrame`), so
// they have no name and no Lua wrapper until something pushes them. This
// is exactly the default-nameplate case: we push each via the shared
// `UI::FrameObject::Push`, which delegates to the engine's own
// `FrameScript_Object::ScriptRegister` to lazily build the canonical
// wrapper. The returned frames respond to real frame methods — the
// FontString holding the spoken text (bubble + 0x31c, created with the
// bubble as its parent) shows up in `bubble:GetRegions()`, so the modern
// "iterate GetRegions, find the FontString, read GetText()" idiom works
// unchanged. This is the pre-7.2.5 "walk WorldFrame children" pattern the
// wiki describes, but handed the exact set instead of the whole world.
//
// The engine keeps active bubbles on an intrusive Storm `TSList` whose
// head is `VAR_CHAT_BUBBLE_LIST_HEAD` (`0x00B6F038`); each node's forward
// link sits at `+0x318` and the list terminator carries the low-bit
// sentinel. We mirror the engine's own walk in the per-frame update
// `FUN_004b1060` / teardown `FUN_004b0f80` verbatim. Bubbles whose owner
// unit has despawned are pruned on the engine's next update tick, so a
// just-orphaned bubble can linger one frame — harmless, and addons that
// care filter on `bubble:IsShown()` (a real method here) anyway.
//
// `includeForbidden` is accepted and ignored: vanilla has no forbidden
// frames, so every bubble is returned regardless.
#include "Game.h"
#include "ui/FrameObject.h"
#include <cstdint>
namespace ChatBubble::Info {
namespace {
// Storm TSList of live CGChatBubbleFrames. Head pointer, per-node
// forward link, and the end-of-list low-bit sentinel — see the engine
// walks at 0x004b1060 / 0x004b0f80.
constexpr uintptr_t kListHead = 0x00B6F038;
constexpr int kOffNextLink = 0x318;
} // namespace
static int __fastcall Script_GetAllChatBubbles(void *L) {
Game::Lua::NewTable(L);
uintptr_t node = *reinterpret_cast<const uintptr_t *>(kListHead);
int nextIndex = 1;
while (node != 0 && (node & 1) == 0) {
Game::Lua::PushNumber(L, static_cast<double>(nextIndex++));
UI::FrameObject::Push(L, reinterpret_cast<void *>(node));
Game::Lua::SetTable(L, -3);
node = *reinterpret_cast<const uintptr_t *>(node + kOffNextLink);
}
return 1;
}
static void RegisterLuaFunctions() {
Game::Lua::RegisterTableFunction("C_ChatBubbles", "GetAllChatBubbles",
&Script_GetAllChatBubbles);
}
static const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace ChatBubble::Info
+170
View File
@@ -0,0 +1,170 @@
// 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/>.
// `length = CopyToClipboard(text [, removeMarkup])` — copy `text` to the
// Windows clipboard and return the number of bytes copied. Vanilla has no
// clipboard access at all (Blizzard never exposed one), so this is a pure
// ClassicAPI addition backed by the Win32 clipboard API.
//
// - `text` the string to copy (numbers are coerced, like Lua does).
// - `removeMarkup` optional; when truthy, strip WoW UI escape sequences
// (|cAARRGGBB..|r color, |H..|h..|h hyperlinks keeping the
// visible text, |T..|t textures, |n newline, || → |) before
// copying, so the clipboard gets plain text.
// - returns byte length of the copied (possibly stripped) string —
// consistent with Lua's `#s`. 0 on an empty string or if
// the clipboard couldn't be opened.
//
// WoW's in-memory strings are UTF-8 here (same assumption the credential and
// TTS modules make), so we convert to UTF-16 and set CF_UNICODETEXT; Windows
// synthesizes CF_TEXT for legacy consumers.
#include "Game.h"
#include <string>
#include <windows.h>
namespace Clipboard::Copy {
namespace {
// Strip WoW UI escape sequences, leaving readable text. See header comment for
// the set handled; unknown `|x` sequences are left verbatim so real text that
// merely contains a pipe survives.
std::string StripMarkup(const char *s, unsigned len) {
std::string out;
out.reserve(len);
unsigned i = 0;
while (i < len) {
const char c = s[i];
if (c != '|') {
out.push_back(c);
++i;
continue;
}
const char n = (i + 1 < len) ? s[i + 1] : '\0';
switch (n) {
case '|': // escaped pipe
out.push_back('|');
i += 2;
break;
case 'n': // newline token
out.push_back('\n');
i += 2;
break;
case 'r': // color reset
i += 2;
break;
case 'c': // color open: |c + up to 8 hex digits
i += 2;
for (int k = 0; k < 8 && i < len; ++k) {
const char h = s[i];
const bool isHex = (h >= '0' && h <= '9') || (h >= 'a' && h <= 'f') ||
(h >= 'A' && h <= 'F');
if (!isHex)
break;
++i;
}
break;
case 'h': // hyperlink boundary (text separator or closer) — drop
i += 2;
break;
case 'H': // hyperlink data opener — skip through to the next |h
i += 2;
while (i < len && !(s[i] == '|' && i + 1 < len && s[i + 1] == 'h'))
++i;
break; // the |h is dropped by the 'h' case on the next iteration
case 't': // stray texture closer — drop
i += 2;
break;
case 'T': // texture opener — skip through to the next |t
i += 2;
while (i < len && !(s[i] == '|' && i + 1 < len && s[i + 1] == 't'))
++i;
break; // the |t is dropped by the 't' case on the next iteration
default: // unknown sequence — keep the pipe literally
out.push_back('|');
++i;
break;
}
}
return out;
}
// Place a UTF-8 byte range on the clipboard as CF_UNICODETEXT. Returns true on
// success. Does not free the allocation on success — ownership passes to the
// clipboard.
bool SetClipboardUtf8(const char *data, int byteLen) {
if (!OpenClipboard(nullptr))
return false;
bool ok = false;
if (EmptyClipboard()) {
// Empty string still writes a valid (empty) clipboard entry.
const int wlen =
byteLen > 0 ? MultiByteToWideChar(CP_UTF8, 0, data, byteLen, nullptr, 0) : 0;
HGLOBAL h = GlobalAlloc(GMEM_MOVEABLE, (static_cast<SIZE_T>(wlen) + 1) * sizeof(wchar_t));
if (h != nullptr) {
auto *dst = static_cast<wchar_t *>(GlobalLock(h));
if (dst != nullptr) {
if (wlen > 0)
MultiByteToWideChar(CP_UTF8, 0, data, byteLen, dst, wlen);
dst[wlen] = L'\0';
GlobalUnlock(h);
if (SetClipboardData(CF_UNICODETEXT, h) != nullptr)
ok = true; // clipboard owns h now
}
if (!ok)
GlobalFree(h);
}
}
CloseClipboard();
return ok;
}
int __fastcall Script_CopyToClipboard(void *L) {
if (!Game::Lua::IsString(L, 1) && !Game::Lua::IsNumber(L, 1)) {
Game::Lua::Error(L, "Usage: CopyToClipboard(text [, removeMarkup])");
return 0; // unreachable
}
const char *text = Game::Lua::ToString(L, 1);
const unsigned len = Game::Lua::StrLen(L, 1);
const bool removeMarkup = Game::Lua::ToBoolean(L, 2) != 0;
int copiedLen;
if (removeMarkup) {
const std::string stripped = StripMarkup(text, len);
copiedLen = static_cast<int>(stripped.size());
SetClipboardUtf8(stripped.data(), copiedLen);
} else {
copiedLen = static_cast<int>(len);
SetClipboardUtf8(text, copiedLen);
}
Game::Lua::PushNumber(L, static_cast<double>(copiedLen));
return 1;
}
void RegisterFns() {
Game::Lua::RegisterGlobalFunction("CopyToClipboard", &Script_CopyToClipboard);
}
const Game::ModuleAutoRegister _autoreg{&RegisterFns};
const Game::GlueModuleAutoRegister _autoregGlue{&RegisterFns};
} // namespace
} // namespace Clipboard::Copy
+292
View File
@@ -0,0 +1,292 @@
// 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_ColorUtil` — the modern color-space + text-color-code helpers, backported
// to 1.12. Every one is pure math / string formatting (no engine state), so
// this is a plain reimplementation rather than a hook.
//
// Conventions verified in-game against a retail-family client that ships
// C_ColorUtil natively:
// - HSV/HSL hue is in DEGREES, [0, 360); saturation / value / lightness are
// [0, 1]. Achromatic (gray) inputs yield hue = -1 (a sentinel, not 0).
// - GenerateTextColorCode(color) returns the BARE 8-hex string "AARRGGBB"
// (e.g. red -> "ffff0000"), alpha forced to ff (the arg type is colorRGB,
// no alpha channel).
// - WrapTextInColorCode(text, code) returns "|c" .. code .. text .. "|r".
// - WrapTextInColor(text, color) = WrapTextInColorCode(text,
// GenerateTextColorCode(color)) -> "|cffff0000Hi|r".
//
// The color arg is a table read by raw r/g/b fields (ColorMixin-shaped); an
// optional a field is honored (default 1.0) though retail's colorRGB type
// omits it — for the documented no-alpha case both give ff.
#include "Game.h"
#include <cmath>
#include <cstdio>
#include <string>
namespace Color::Util {
namespace {
double Clamp01(double v) { return v < 0.0 ? 0.0 : (v > 1.0 ? 1.0 : v); }
int ToByte(double v) {
const double c = Clamp01(v);
return static_cast<int>(c * 255.0 + 0.5); // round to nearest
}
double Max3(double a, double b, double c) {
double m = a > b ? a : b;
return m > c ? m : c;
}
double Min3(double a, double b, double c) {
double m = a < b ? a : b;
return m < c ? m : c;
}
double MinLuma(double l) { return l < 1.0 - l ? l : 1.0 - l; }
// --- Pure conversions --------------------------------------------------
void RgbToHsv(double r, double g, double b, double &h, double &s, double &v) {
const double mx = Max3(r, g, b), mn = Min3(r, g, b), d = mx - mn;
v = mx;
s = (mx <= 0.0) ? 0.0 : d / mx;
if (d <= 0.0) { // achromatic
h = -1.0;
return;
}
if (mx == r)
h = 60.0 * std::fmod((g - b) / d, 6.0);
else if (mx == g)
h = 60.0 * ((b - r) / d + 2.0);
else
h = 60.0 * ((r - g) / d + 4.0);
if (h < 0.0)
h += 360.0;
}
void HsvToRgb(double h, double s, double v, double &r, double &g, double &b) {
if (s <= 0.0 || h < 0.0) { // achromatic (incl. the -1 sentinel)
r = g = b = v;
return;
}
h = std::fmod(h, 360.0);
if (h < 0.0)
h += 360.0;
const double hh = h / 60.0;
const int i = static_cast<int>(std::floor(hh));
const double f = hh - i;
const double p = v * (1.0 - s);
const double q = v * (1.0 - s * f);
const double t = v * (1.0 - s * (1.0 - f));
switch (i % 6) {
case 0: r = v; g = t; b = p; break;
case 1: r = q; g = v; b = p; break;
case 2: r = p; g = v; b = t; break;
case 3: r = p; g = q; b = v; break;
case 4: r = t; g = p; b = v; break;
default: r = v; g = p; b = q; break;
}
}
void HsvToHsl(double h, double sv, double v, double &ho, double &sl, double &l) {
ho = h; // hue (incl. -1) passes through unchanged
l = v * (1.0 - sv / 2.0);
sl = (l <= 0.0 || l >= 1.0) ? 0.0 : (v - l) / MinLuma(l);
}
void HslToHsv(double h, double sl, double l, double &ho, double &sv, double &v) {
ho = h;
v = l + sl * MinLuma(l);
sv = (v <= 0.0) ? 0.0 : 2.0 * (1.0 - l / v);
}
void HslToRgb(double h, double s, double l, double &r, double &g, double &b) {
if (s <= 0.0 || h < 0.0) { // achromatic
r = g = b = l;
return;
}
h = std::fmod(h, 360.0);
if (h < 0.0)
h += 360.0;
const double c = (1.0 - std::fabs(2.0 * l - 1.0)) * s;
const double hh = h / 60.0;
const double x = c * (1.0 - std::fabs(std::fmod(hh, 2.0) - 1.0));
const double m = l - c / 2.0;
double r1, g1, b1;
switch (static_cast<int>(std::floor(hh)) % 6) {
case 0: r1 = c; g1 = x; b1 = 0.0; break;
case 1: r1 = x; g1 = c; b1 = 0.0; break;
case 2: r1 = 0.0; g1 = c; b1 = x; break;
case 3: r1 = 0.0; g1 = x; b1 = c; break;
case 4: r1 = x; g1 = 0.0; b1 = c; break;
default: r1 = c; g1 = 0.0; b1 = x; break;
}
r = r1 + m;
g = g1 + m;
b = b1 + m;
}
// --- Lua glue ----------------------------------------------------------
// Read arg `n` as a number, or raise the usage error.
double Arg(void *L, int n, const char *usage) {
if (!Game::Lua::IsNumber(L, n)) {
Game::Lua::Error(L, "%s", usage);
return 0.0; // unreachable
}
return Game::Lua::ToNumber(L, n);
}
// Read a numeric field from the color table at (absolute, positive) index
// `idx`. Raw access — ColorMixin stores r/g/b/a as direct keys.
double ColorField(void *L, int idx, const char *key, double dflt) {
Game::Lua::PushString(L, key);
Game::Lua::RawGet(L, idx);
const double v = Game::Lua::IsNumber(L, -1) ? Game::Lua::ToNumber(L, -1) : dflt;
Game::Lua::SetTop(L, -2); // pop the value
return v;
}
// "AARRGGBB" from a color table at `idx`, alpha default ff.
std::string HexFromColor(void *L, int idx) {
const int a = ToByte(ColorField(L, idx, "a", 1.0));
const int r = ToByte(ColorField(L, idx, "r", 0.0));
const int g = ToByte(ColorField(L, idx, "g", 0.0));
const int b = ToByte(ColorField(L, idx, "b", 0.0));
char buf[9];
std::snprintf(buf, sizeof(buf), "%02x%02x%02x%02x", a, r, g, b);
return std::string(buf, 8);
}
int __fastcall Script_ConvertRGBToHSV(void *L) {
const double r = Arg(L, 1, "Usage: C_ColorUtil.ConvertRGBToHSV(r, g, b)");
const double g = Arg(L, 2, "Usage: C_ColorUtil.ConvertRGBToHSV(r, g, b)");
const double b = Arg(L, 3, "Usage: C_ColorUtil.ConvertRGBToHSV(r, g, b)");
double h, s, v;
RgbToHsv(r, g, b, h, s, v);
Game::Lua::PushNumber(L, h);
Game::Lua::PushNumber(L, s);
Game::Lua::PushNumber(L, v);
return 3;
}
int __fastcall Script_ConvertHSVToRGB(void *L) {
const double h = Arg(L, 1, "Usage: C_ColorUtil.ConvertHSVToRGB(h, s, v)");
const double s = Arg(L, 2, "Usage: C_ColorUtil.ConvertHSVToRGB(h, s, v)");
const double v = Arg(L, 3, "Usage: C_ColorUtil.ConvertHSVToRGB(h, s, v)");
double r, g, b;
HsvToRgb(h, s, v, r, g, b);
Game::Lua::PushNumber(L, r);
Game::Lua::PushNumber(L, g);
Game::Lua::PushNumber(L, b);
return 3;
}
int __fastcall Script_ConvertHSVToHSL(void *L) {
const double h = Arg(L, 1, "Usage: C_ColorUtil.ConvertHSVToHSL(h, s, v)");
const double s = Arg(L, 2, "Usage: C_ColorUtil.ConvertHSVToHSL(h, s, v)");
const double v = Arg(L, 3, "Usage: C_ColorUtil.ConvertHSVToHSL(h, s, v)");
double ho, sl, l;
HsvToHsl(h, s, v, ho, sl, l);
Game::Lua::PushNumber(L, ho);
Game::Lua::PushNumber(L, sl);
Game::Lua::PushNumber(L, l);
return 3;
}
int __fastcall Script_ConvertHSLToHSV(void *L) {
const double h = Arg(L, 1, "Usage: C_ColorUtil.ConvertHSLToHSV(h, s, l)");
const double s = Arg(L, 2, "Usage: C_ColorUtil.ConvertHSLToHSV(h, s, l)");
const double l = Arg(L, 3, "Usage: C_ColorUtil.ConvertHSLToHSV(h, s, l)");
double ho, sv, v;
HslToHsv(h, s, l, ho, sv, v);
Game::Lua::PushNumber(L, ho);
Game::Lua::PushNumber(L, sv);
Game::Lua::PushNumber(L, v);
return 3;
}
int __fastcall Script_ConvertHSLToRGB(void *L) {
const double h = Arg(L, 1, "Usage: C_ColorUtil.ConvertHSLToRGB(h, s, l)");
const double s = Arg(L, 2, "Usage: C_ColorUtil.ConvertHSLToRGB(h, s, l)");
const double l = Arg(L, 3, "Usage: C_ColorUtil.ConvertHSLToRGB(h, s, l)");
double r, g, b;
HslToRgb(h, s, l, r, g, b);
Game::Lua::PushNumber(L, r);
Game::Lua::PushNumber(L, g);
Game::Lua::PushNumber(L, b);
return 3;
}
int __fastcall Script_GenerateTextColorCode(void *L) {
if (Game::Lua::Type(L, 1) != Game::Lua::TYPE_TABLE) {
Game::Lua::Error(L, "Usage: C_ColorUtil.GenerateTextColorCode(color)");
return 0; // unreachable
}
const std::string hex = HexFromColor(L, 1);
Game::Lua::PushString(L, hex.c_str());
return 1;
}
int __fastcall Script_WrapTextInColor(void *L) {
const char *text = Game::Lua::ToString(L, 1);
if (text == nullptr || Game::Lua::Type(L, 2) != Game::Lua::TYPE_TABLE) {
Game::Lua::Error(L, "Usage: C_ColorUtil.WrapTextInColor(text, color)");
return 0; // unreachable
}
std::string out = "|c" + HexFromColor(L, 2) + text + "|r";
Game::Lua::PushString(L, out.c_str());
return 1;
}
int __fastcall Script_WrapTextInColorCode(void *L) {
const char *text = Game::Lua::ToString(L, 1);
const char *code = Game::Lua::ToString(L, 2);
if (text == nullptr || code == nullptr) {
Game::Lua::Error(L, "Usage: C_ColorUtil.WrapTextInColorCode(text, colorCode)");
return 0; // unreachable
}
std::string out = std::string("|c") + code + text + "|r";
Game::Lua::PushString(L, out.c_str());
return 1;
}
void RegisterFns() {
Game::Lua::RegisterTableFunction("C_ColorUtil", "ConvertRGBToHSV",
&Script_ConvertRGBToHSV);
Game::Lua::RegisterTableFunction("C_ColorUtil", "ConvertHSVToRGB",
&Script_ConvertHSVToRGB);
Game::Lua::RegisterTableFunction("C_ColorUtil", "ConvertHSVToHSL",
&Script_ConvertHSVToHSL);
Game::Lua::RegisterTableFunction("C_ColorUtil", "ConvertHSLToHSV",
&Script_ConvertHSLToHSV);
Game::Lua::RegisterTableFunction("C_ColorUtil", "ConvertHSLToRGB",
&Script_ConvertHSLToRGB);
Game::Lua::RegisterTableFunction("C_ColorUtil", "GenerateTextColorCode",
&Script_GenerateTextColorCode);
Game::Lua::RegisterTableFunction("C_ColorUtil", "WrapTextInColor",
&Script_WrapTextInColor);
Game::Lua::RegisterTableFunction("C_ColorUtil", "WrapTextInColorCode",
&Script_WrapTextInColorCode);
}
// Pure helpers — useful on both the in-game and glue states.
const Game::ModuleAutoRegister _autoreg{&RegisterFns};
const Game::GlueModuleAutoRegister _autoregGlue{&RegisterFns};
} // namespace
} // namespace Color::Util
+43 -16
View File
@@ -13,6 +13,7 @@
#include "Game.h"
#include "Offsets.h"
#include "item/BagFamily.h"
#include "item/ID.h"
#include "item/Location.h"
@@ -73,15 +74,14 @@ BagInfo ResolveBagInfo(int bagID) {
const int slots = static_cast<int>(*reinterpret_cast<const uint32_t *>(
record + Offsets::OFF_ITEMSTATS_CONTAINER_SLOTS));
// Convert 1.12's raw BagFamily ID to the modern bitmask. See
// `Offsets::OFF_ITEMSTATS_BAG_FAMILY` for the encoding-shift story
// and `Item::Info::BagFamilyIdToBitmask` for the canonical helper —
// we inline the one-liner here to avoid a header dance for a
// single-instruction conversion.
const uint32_t rawFamily = *reinterpret_cast<const uint32_t *>(
record + Offsets::OFF_ITEMSTATS_BAG_FAMILY);
// BagFamily bitmask: the item's field if populated, else derived from
// the container subclass — Turtle leaves bags' m_bagFamily at 0, so a
// Soul Bag would otherwise report family 0 and get miscounted as a
// general-purpose bag by CalculateTotalNumberOfFreeBagSlots. Shared
// with C_Item.GetItemFamily so a bag and its contents agree. See
// `Item::BagFamily`.
const int family =
(rawFamily == 0) ? 0 : static_cast<int>(1u << (rawFamily - 1));
static_cast<int>(Item::BagFamily::BitmaskForRecord(record));
return {slots, family};
}
@@ -106,14 +106,13 @@ int CountFreeSlotsInBag(void *L, int bagID, int slotCount) {
// `(0, 0)` — matching 3.3.5's behavior of always pushing two values
// rather than nil/nothing.
//
// `bagType` is the `BagFamily` bitmask in modern encoding (raw 1.12
// ID converted via `1 << (ID-1)`). Empirically on Turtle WoW, only
// Light Quiver actually carries a non-zero family for bags themselves
// — Soul Pouch, Enchanting Bag, etc. all return 0 because the
// vanilla-era server data doesn't populate the field. **Individual
// items** (arrows, bullets, shards, herbs) reliably return their
// family, so addons that auto-route loot work correctly via
// `C_Item.GetItemFamily(itemID)` even though `bagType` is sparse.
// `bagType` is the `BagFamily` bitmask in modern encoding. Bags leave the
// raw family field empty in Turtle's data (Soul/Herb/quiver/ammo pouch/…
// all read 0), so it's derived from the bag's class + subclass via
// `Item::BagFamily`: a Soul Bag reports `0x4`, a quiver `0x1`, an ammo
// pouch `0x2` — matching what `C_Item.GetItemFamily` reports for both the
// bag and the items it holds. See that helper for the Turtle custom-family
// caveat (Meat/Fish/Leather/Mining, IDs 10–13).
int __fastcall Script_C_Container_GetContainerNumFreeSlots(void *L) {
if (!Game::Lua::IsNumber(L, 1)) {
Game::Lua::Error(L,
@@ -130,11 +129,39 @@ int __fastcall Script_C_Container_GetContainerNumFreeSlots(void *L) {
return 2;
}
// `C_Container.CalculateTotalNumberOfFreeBagSlots()` — the total number of
// free slots across the player's *general-purpose* bags (backpack + equipped
// bags 0..4 whose BagFamily is 0). Specialty bags (quivers, soul bags, herb
// bags, …) are excluded, mirroring FrameXML's implementation exactly:
//
// for bag = BACKPACK .. lastBag:
// free, family = C_Container.GetContainerNumFreeSlots(bag)
// if family == 0 then total = total + free end
//
// Takes no arguments. Specialty bags are correctly excluded: their family
// comes from `Item::BagFamily` (container-subclass fallback), so a Soul
// Bag / Herb Bag / Meat Bag reports a non-zero family and its slots are
// left out of the general-purpose total — matching retail's FrameXML
// behavior. (An earlier version miscounted them because Turtle leaves the
// raw family field at 0 for bags.)
int __fastcall Script_C_Container_CalculateTotalNumberOfFreeBagSlots(void *L) {
int total = 0;
for (int bagID = 0; bagID <= 4; bagID++) {
const BagInfo info = ResolveBagInfo(bagID);
if (info.slotCount > 0 && info.bagType == 0)
total += CountFreeSlotsInBag(L, bagID, info.slotCount);
}
Game::Lua::PushNumber(L, static_cast<double>(total));
return 1;
}
} // namespace
static void RegisterLuaFunctions() {
Game::Lua::RegisterTableFunction("C_Container", "GetContainerNumFreeSlots",
&Script_C_Container_GetContainerNumFreeSlots);
Game::Lua::RegisterTableFunction("C_Container", "CalculateTotalNumberOfFreeBagSlots",
&Script_C_Container_CalculateTotalNumberOfFreeBagSlots);
}
static const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
+52
View File
@@ -0,0 +1,52 @@
// 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_Container.HasContainerItem(bagIndex, slotIndex)` — returns whether the
// given bag slot currently holds an item (a Classic Era addition). It's the
// occupancy sibling of `GetContainerItemID`: resolves the slot to its CGItem
// the same way (`Item::Location::ResolveBag` → engine `PackBagSlot` →
// `GetItemBySlot`), and a populated slot yields a non-null pointer while an
// empty slot yields null. Presence only — it does not depend on the item's
// static data being cached. Returns `false` for empty slots and invalid
// bag/slot indices.
#include "Game.h"
#include "item/Location.h"
namespace Container::HasItem {
namespace {
int __fastcall Script_C_Container_HasContainerItem(void *L) {
if (!Game::Lua::IsNumber(L, 1) || !Game::Lua::IsNumber(L, 2)) {
Game::Lua::Error(L, "Usage: C_Container.HasContainerItem(bagIndex, slotIndex)");
return 0;
}
const int bagID = static_cast<int>(Game::Lua::ToNumber(L, 1));
const int slotIndex = static_cast<int>(Game::Lua::ToNumber(L, 2));
const bool hasItem = Item::Location::ResolveBag(L, bagID, slotIndex) != nullptr;
Game::Lua::PushBool(L, hasItem);
return 1;
}
void RegisterLuaFunctions() {
Game::Lua::RegisterTableFunction("C_Container", "HasContainerItem",
&Script_C_Container_HasContainerItem);
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace
} // namespace Container::HasItem
+194
View File
@@ -0,0 +1,194 @@
// 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_Container.GetContainerItemInfo(containerIndex, slotIndex)` — the modern
// structured-table accessor (namespaced + table-returning since Patch
// 10.0.2). Vanilla's global `GetContainerItemInfo` returns a flat 5-tuple
// (`texture, itemCount, locked, quality, readable` — verified by decoding
// `Script_GetContainerItemInfo` at `0x004F9670`); backported addons expect
// the `ContainerItemInfo` table with the wider modern field set. We assemble
// that table from the same sources the engine reads plus the extra fields it
// never surfaced in 1.12.
//
// Field sources:
// iconFileID — ItemStats displayInfoID → ItemDisplayInfo.dbc icon path.
// (1.12 has no fileID system; we return the icon *path*
// string, same convention as `GetItemIcon`.)
// stackCount — CGItem descriptor `+OFF_DESCRIPTOR_STACK_COUNT` (the
// engine's `itemCount` return).
// isLocked — CGItem `+OFF_ITEM_CLIENT_LOCK & 1` (the `locked` return).
// quality — ItemStats `+OFF_ITEMSTATS_QUALITY`; nil when uncached
// (modern contract: `Enum.ItemQuality or nil`).
// isReadable — static page text OR the per-instance readable-text id,
// mirroring the engine's `readable` branch at `0x004F97BD`.
// hasLoot — ItemStats LOOTABLE flag (best-effort static approximation:
// vanilla can't track post-loot dynamic emptiness client-side).
// hyperlink — engine link builder via `Item::Link::FromCGItem` (enchant /
// random-suffix decorated, same as `GetContainerItemLink`).
// isFiltered — always false (vanilla has no bag search-filter system).
// hasNoValue — ItemStats sellPrice == 0.
// itemID — instance block itemID (`Item::ID::FromCGItem`).
// isBound — CGItem descriptor SOULBOUND flag (`C_Item.IsBound`'s bit).
// itemName — instance-decorated display name, base ItemStats name on miss.
//
// Returns nil for an empty slot or an invalid bag/slot index (modern
// contract). Cache-derived fields (icon / quality / hasLoot / hasNoValue /
// name) are omitted/nil when the item's static data isn't cached yet — the
// live per-instance fields (count / locked / bound / itemID / link) are always
// present. Passive: does not warm the item cache.
#include "Game.h"
#include "Offsets.h"
#include "dbc/Lookup.h"
#include "item/ID.h"
#include "item/Link.h"
#include "item/Location.h"
#include "item/StatAccum.h"
#include <cstdint>
#include <cstdio>
namespace Container::ItemInfo {
namespace {
bool BuildIconPath(uint32_t displayInfoID, char *out, size_t outSize) {
out[0] = '\0';
const char *iconName = DBC::StringField(
Offsets::VAR_ITEMDISPLAYINFO_RECORDS, Offsets::VAR_ITEMDISPLAYINFO_COUNT,
displayInfoID, Offsets::OFF_ITEMDISPLAYINFO_ICON);
if (iconName == nullptr || iconName[0] == '\0')
return false;
std::snprintf(out, outSize, "Interface\\Icons\\%s", iconName);
return true;
}
// Mirrors the engine's `readable` decision (0x004F97BD..0x004F97CF): a slot
// is readable when its static page text is set OR the per-instance
// readable-text id is non-zero (letters / server-authored notes).
bool ItemReadable(const uint8_t *item, const uint8_t *record) {
if (record != nullptr &&
*reinterpret_cast<const uint32_t *>(record + Offsets::OFF_ITEMSTATS_PAGE_TEXT) != 0)
return true;
const uint8_t *descriptor = *reinterpret_cast<const uint8_t *const *>(
item + Offsets::OFF_ITEM_DESCRIPTOR);
return descriptor != nullptr &&
*reinterpret_cast<const uint32_t *>(
descriptor + Offsets::OFF_DESCRIPTOR_READABLE_TEXT_ID) != 0;
}
int __fastcall Script_C_Container_GetContainerItemInfo(void *L) {
if (!Game::Lua::IsNumber(L, 1) || !Game::Lua::IsNumber(L, 2)) {
Game::Lua::Error(L,
"Usage: C_Container.GetContainerItemInfo(containerIndex, slotIndex)");
return 0;
}
const int bagID = static_cast<int>(Game::Lua::ToNumber(L, 1));
const int slotIndex = static_cast<int>(Game::Lua::ToNumber(L, 2));
const uint8_t *item = Item::Location::ResolveBag(L, bagID, slotIndex);
if (item == nullptr) {
Game::Lua::PushNil(L); // empty slot / bad index → nil (modern contract)
return 1;
}
// --- Gather everything into locals BEFORE touching the Lua stack. The
// engine link/name builders and cache/DBC reads below are pure C; keeping
// them off the half-built table avoids any stack interleaving. ---
const int itemID = Item::ID::FromCGItem(item);
const uint8_t *record = (itemID > 0)
? Item::StatAccum::FetchRecord(static_cast<uint32_t>(itemID))
: nullptr;
// Live per-instance descriptor fields.
const uint8_t *descriptor = *reinterpret_cast<const uint8_t *const *>(
item + Offsets::OFF_ITEM_DESCRIPTOR);
int stackCount = 0;
bool isBound = false;
if (descriptor != nullptr) {
stackCount = *reinterpret_cast<const int *>(
descriptor + Offsets::OFF_DESCRIPTOR_STACK_COUNT);
isBound = (*reinterpret_cast<const uint32_t *>(
descriptor + Offsets::OFF_DESCRIPTOR_FLAGS) &
Offsets::ITEM_FLAG_SOULBOUND) != 0;
}
const bool isLocked = (*reinterpret_cast<const uint32_t *>(
item + Offsets::OFF_ITEM_CLIENT_LOCK) &
Offsets::ITEM_CLIENT_LOCK_BIT) != 0;
const bool isReadable = ItemReadable(item, record);
// Static template fields (cache-dependent).
bool haveIcon = false;
char iconPath[260];
bool haveQuality = false;
uint32_t quality = 0;
bool hasLoot = false;
bool hasNoValue = false;
if (record != nullptr) {
const uint32_t displayInfoID = *reinterpret_cast<const uint32_t *>(
record + Offsets::OFF_ITEMSTATS_DISPLAY_INFO_ID);
haveIcon = BuildIconPath(displayInfoID, iconPath, sizeof(iconPath));
quality = *reinterpret_cast<const uint32_t *>(
record + Offsets::OFF_ITEMSTATS_QUALITY);
haveQuality = true;
const uint32_t flags = *reinterpret_cast<const uint32_t *>(
record + Offsets::OFF_ITEMSTATS_FLAGS);
hasLoot = (flags & Offsets::ITEM_FLAG_LOOTABLE) != 0;
hasNoValue = *reinterpret_cast<const uint32_t *>(
record + Offsets::OFF_ITEMSTATS_SELL_PRICE) == 0;
}
const char *hyperlink = Item::Link::FromCGItem(item);
char nameBuf[256];
const char *itemName = nullptr;
if (Item::Link::NameFromCGItem(item, nameBuf, sizeof(nameBuf))) {
itemName = nameBuf;
} else if (record != nullptr) {
const char *baseName = *reinterpret_cast<const char *const *>(
record + Offsets::OFF_ITEMSTATS_NAME);
if (baseName != nullptr && baseName[0] != '\0')
itemName = baseName;
}
// --- Build the ContainerItemInfo table (only stack-safe SetField* now). ---
Game::Lua::NewTable(L);
if (haveIcon)
Game::Lua::SetFieldString(L, "iconFileID", iconPath);
Game::Lua::SetFieldNumber(L, "stackCount", static_cast<double>(stackCount));
Game::Lua::SetFieldBool(L, "isLocked", isLocked);
if (haveQuality)
Game::Lua::SetFieldNumber(L, "quality", static_cast<double>(quality));
Game::Lua::SetFieldBool(L, "isReadable", isReadable);
Game::Lua::SetFieldBool(L, "hasLoot", hasLoot);
if (hyperlink != nullptr && hyperlink[0] != '\0')
Game::Lua::SetFieldString(L, "hyperlink", hyperlink);
Game::Lua::SetFieldBool(L, "isFiltered", false);
Game::Lua::SetFieldBool(L, "hasNoValue", hasNoValue);
Game::Lua::SetFieldNumber(L, "itemID", static_cast<double>(itemID));
Game::Lua::SetFieldBool(L, "isBound", isBound);
if (itemName != nullptr)
Game::Lua::SetFieldString(L, "itemName", itemName);
return 1;
}
void RegisterLuaFunctions() {
Game::Lua::RegisterTableFunction("C_Container", "GetContainerItemInfo",
&Script_C_Container_GetContainerItemInfo);
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace
} // namespace Container::ItemInfo
+8
View File
@@ -107,11 +107,19 @@ static int AuxResume(void *L, void *co, int nargs) {
: "cannot resume non-suspended coroutine");
return -1;
}
if (Game::Lua::CheckStack(co, nargs) == 0) {
Game::Lua::PushString(L, "too many arguments to resume coroutine");
return -1;
}
Game::Lua::XMove(L, co, nargs);
const int s = Game::Lua::Resume(co, nargs);
const bool yielded = LuaCI(co) > LuaBaseCI(co);
if (s == LUA_OK || yielded) {
const int nres = Game::Lua::GetTop(co);
if (Game::Lua::CheckStack(L, nres) == 0) {
Game::Lua::PushString(L, "too many results to resume coroutine");
return -1;
}
Game::Lua::XMove(co, L, nres);
return nres;
}
+245
View File
@@ -36,10 +36,24 @@
// Field offsets verified against the binary (rank read by the engine's
// classification helper as `[block+0x20]`) and real creaturecache.wdb
// rows. See Offsets.h `OFF_CREATURE_*`.
//
// This file also hosts the two static-DBC lookups on the same table:
//
// C_CreatureInfo.GetRaceInfo(raceID) -> { raceName, clientFileString, raceID }
// C_CreatureInfo.GetClassInfo(classID) -> { className, classFile, classID }
//
// retail backports over `ChrRaces.dbc` / `ChrClasses.dbc`. Unlike the
// creature cache these DBCs are always loaded, localized, and synchronous —
// so a by-id lookup works for any race/class id at any time, with no WDB
// cold-cache problem. The file-string columns (`clientFileString` /
// `classFile`) are the same non-localized tokens `UnitRace`/`UnitClass`
// return as their 2nd value ("NightElf", "WARRIOR"), so they round-trip
// with the unit APIs. See Offsets.h `OFF_CHRRACES_*` / `OFF_CHRCLASSES_*`.
#include "Game.h"
#include "Offsets.h"
#include "cache/QueryLoad.h"
#include "dbc/Lookup.h"
#include "event/Custom.h"
#include <cstdint>
@@ -122,11 +136,242 @@ int __fastcall Script_RequestLoadCreatureByID(void *L) {
return 1;
}
// `C_CreatureInfo.GetRaceInfo(raceID)` — RaceInfo table, or nil for a
// non-numeric / non-positive id or one with no ChrRaces row.
int __fastcall Script_GetRaceInfo(void *L) {
if (!Game::Lua::IsNumber(L, 1))
return 0; // nil
const int raceID = static_cast<int>(Game::Lua::ToNumber(L, 1));
const char *file =
(raceID > 0) ? DBC::StringField(Offsets::VAR_CHRRACES_RECORDS,
Offsets::VAR_CHRRACES_COUNT,
static_cast<uint32_t>(raceID),
Offsets::OFF_CHRRACES_FILENAME)
: nullptr;
if (file == nullptr)
return 0; // unknown id -> nil
const char *name = DBC::LocalizedField(Offsets::VAR_CHRRACES_RECORDS,
Offsets::VAR_CHRRACES_COUNT,
static_cast<uint32_t>(raceID),
Offsets::OFF_CHRRACES_NAMES);
Game::Lua::NewTable(L);
Game::Lua::SetFieldString(L, "raceName", name); // NULL coerced to ""
Game::Lua::SetFieldString(L, "clientFileString", file);
Game::Lua::SetFieldNumber(L, "raceID", raceID);
return 1;
}
// `C_CreatureInfo.GetClassInfo(classID)` — ClassInfo table, or nil for a
// non-numeric / non-positive id or one with no ChrClasses row.
int __fastcall Script_GetClassInfo(void *L) {
if (!Game::Lua::IsNumber(L, 1))
return 0; // nil
const int classID = static_cast<int>(Game::Lua::ToNumber(L, 1));
const char *file =
(classID > 0) ? DBC::StringField(Offsets::VAR_CHRCLASSES_RECORDS,
Offsets::VAR_CHRCLASSES_COUNT,
static_cast<uint32_t>(classID),
Offsets::OFF_CHRCLASSES_FILENAME)
: nullptr;
if (file == nullptr)
return 0; // unknown id -> nil
const char *name = DBC::LocalizedField(Offsets::VAR_CHRCLASSES_RECORDS,
Offsets::VAR_CHRCLASSES_COUNT,
static_cast<uint32_t>(classID),
Offsets::OFF_CHRCLASSES_NAMES);
Game::Lua::NewTable(L);
Game::Lua::SetFieldString(L, "className", name); // NULL coerced to ""
Game::Lua::SetFieldString(L, "classFile", file);
Game::Lua::SetFieldNumber(L, "classID", classID);
return 1;
}
// `C_CreatureInfo.GetCreatureFamilyInfo(creatureFamilyID)` — CreatureFamilyInfo
// table for a `CreatureFamily.dbc` row (1 = Wolf, 27 = Wind Serpent, …), or
// nil for a non-numeric / non-positive / unused id. `iconFile` is a texture
// path string here (retail exposes a numeric fileID; vanilla's DBC stores the
// path) — empty "" for families with no icon (e.g. warlock pets).
int __fastcall Script_GetCreatureFamilyInfo(void *L) {
if (!Game::Lua::IsNumber(L, 1))
return 0; // nil
const int familyID = static_cast<int>(Game::Lua::ToNumber(L, 1));
const char *name =
(familyID > 0) ? DBC::LocalizedField(Offsets::VAR_CREATUREFAMILY_RECORDS,
Offsets::VAR_CREATUREFAMILY_COUNT,
static_cast<uint32_t>(familyID),
Offsets::OFF_CREATUREFAMILY_NAMES)
: nullptr;
if (name == nullptr)
return 0; // unused / OOR id -> nil
const char *icon = DBC::StringField(Offsets::VAR_CREATUREFAMILY_RECORDS,
Offsets::VAR_CREATUREFAMILY_COUNT,
static_cast<uint32_t>(familyID),
Offsets::OFF_CREATUREFAMILY_ICON);
Game::Lua::NewTable(L);
Game::Lua::SetFieldNumber(L, "id", familyID);
Game::Lua::SetFieldString(L, "name", name);
Game::Lua::SetFieldString(L, "iconFile", icon); // NULL/empty coerced to ""
return 1;
}
// `C_CreatureInfo.GetCreatureFamilyIDs()` — array of every populated
// `CreatureFamily.dbc` id (sparse: skips the unused rows). Round-trips with
// GetCreatureFamilyInfo for each element.
int __fastcall Script_GetCreatureFamilyIDs(void *L) {
Game::Lua::SetTop(L, 0);
Game::Lua::NewTable(L);
const int count =
*reinterpret_cast<const int *>(Offsets::VAR_CREATUREFAMILY_COUNT);
int n = 0;
for (int id = 1; id <= count; ++id) {
const char *name = DBC::LocalizedField(Offsets::VAR_CREATUREFAMILY_RECORDS,
Offsets::VAR_CREATUREFAMILY_COUNT,
static_cast<uint32_t>(id),
Offsets::OFF_CREATUREFAMILY_NAMES);
if (name == nullptr)
continue; // unused row
++n;
Game::Lua::PushNumber(L, static_cast<double>(n));
Game::Lua::PushNumber(L, static_cast<double>(id));
Game::Lua::SetTable(L, -3);
}
return 1;
}
// `C_CreatureInfo.GetFactionInfo(raceID)` — faction-group info for a race:
// { name = localized ("Alliance"/"Horde"/…), groupTag = english }. Mirrors
// the player branch of `Script_UnitFactionGroup` (`0x00516630`): resolve the
// race's FactionTemplate group mask, then scan `FactionGroup.dbc` for the row
// whose bit the mask sets and whose localized name is non-empty (which is what
// skips the always-set "Player" / the nameless "Monster" rows and lands on
// Alliance / Horde). nil for a non-numeric / non-positive / unknown race or a
// race with no named group. See Offsets.h for the full chain.
int __fastcall Script_GetFactionInfo(void *L) {
if (!Game::Lua::IsNumber(L, 1))
return 0; // nil
const int raceID = static_cast<int>(Game::Lua::ToNumber(L, 1));
if (raceID <= 0)
return 0;
const uint8_t *crRec = DBC::Record(Offsets::VAR_CHRRACES_RECORDS,
Offsets::VAR_CHRRACES_COUNT,
static_cast<uint32_t>(raceID));
if (crRec == nullptr)
return 0; // unknown race
const int ftId = *reinterpret_cast<const int *>(
crRec + Offsets::OFF_CHRRACES_FACTION_TEMPLATE);
if (ftId <= 0)
return 0;
const uint8_t *ftRec = DBC::Record(Offsets::VAR_FACTIONTEMPLATE_RECORDS,
Offsets::VAR_FACTIONTEMPLATE_COUNT,
static_cast<uint32_t>(ftId));
if (ftRec == nullptr)
return 0;
const uint32_t mask = *reinterpret_cast<const uint32_t *>(
ftRec + Offsets::OFF_FACTIONTEMPLATE_GROUP_MASK);
// FactionGroup is stored contiguously (records-base + i*stride), not as a
// pointer array — iterate by hand rather than via DBC::Record.
auto *fgBase = *reinterpret_cast<const uint8_t *const *>(
Offsets::VAR_FACTIONGROUP_RECORDS);
const int fgCount =
*reinterpret_cast<const int *>(Offsets::VAR_FACTIONGROUP_COUNT);
const int locale = *reinterpret_cast<const int *>(Offsets::VAR_LOCALE_INDEX);
if (fgBase == nullptr)
return 0;
for (int i = 0; i < fgCount; ++i) {
const uint8_t *row = fgBase + i * Offsets::FACTIONGROUP_STRIDE;
const int bit = *reinterpret_cast<const int *>(
row + Offsets::OFF_FACTIONGROUP_BIT);
if ((mask & (1u << (bit & 0x1F))) == 0)
continue;
const char *loc = *reinterpret_cast<const char *const *>(
row + Offsets::OFF_FACTIONGROUP_NAMES + locale * 4);
if (loc == nullptr || *loc == '\0')
continue; // "Player" / "Monster" have empty localized names
const char *eng = *reinterpret_cast<const char *const *>(
row + Offsets::OFF_FACTIONGROUP_ENGLISH);
Game::Lua::NewTable(L);
Game::Lua::SetFieldString(L, "name", loc);
Game::Lua::SetFieldString(L, "groupTag", eng);
return 1;
}
return 0; // no named group -> nil
}
// `C_CreatureInfo.GetCreatureTypeInfo(creatureTypeID)` — CreatureTypeInfo
// table for a `CreatureType.dbc` row (1 = Beast … 11 = Totem), or nil for a
// non-numeric / non-positive / unused id.
int __fastcall Script_GetCreatureTypeInfo(void *L) {
if (!Game::Lua::IsNumber(L, 1))
return 0; // nil
const int typeID = static_cast<int>(Game::Lua::ToNumber(L, 1));
const char *name =
(typeID > 0) ? DBC::LocalizedField(Offsets::VAR_CREATURETYPE_RECORDS,
Offsets::VAR_CREATURETYPE_COUNT,
static_cast<uint32_t>(typeID),
Offsets::OFF_CREATURETYPE_NAMES)
: nullptr;
if (name == nullptr)
return 0; // unused / OOR id -> nil
Game::Lua::NewTable(L);
Game::Lua::SetFieldNumber(L, "id", typeID);
Game::Lua::SetFieldString(L, "name", name);
return 1;
}
// `C_CreatureInfo.GetCreatureTypeIDs()` — array of every populated
// `CreatureType.dbc` id (contiguous 1..N in vanilla). Round-trips with
// GetCreatureTypeInfo for each element.
int __fastcall Script_GetCreatureTypeIDs(void *L) {
Game::Lua::SetTop(L, 0);
Game::Lua::NewTable(L);
const int count =
*reinterpret_cast<const int *>(Offsets::VAR_CREATURETYPE_COUNT);
int n = 0;
for (int id = 1; id <= count; ++id) {
const char *name = DBC::LocalizedField(Offsets::VAR_CREATURETYPE_RECORDS,
Offsets::VAR_CREATURETYPE_COUNT,
static_cast<uint32_t>(id),
Offsets::OFF_CREATURETYPE_NAMES);
if (name == nullptr)
continue;
++n;
Game::Lua::PushNumber(L, static_cast<double>(n));
Game::Lua::PushNumber(L, static_cast<double>(id));
Game::Lua::SetTable(L, -3);
}
return 1;
}
void RegisterLuaFunctions() {
Game::Lua::RegisterTableFunction("C_CreatureInfo", "GetCreatureInfoByID",
&Script_GetCreatureInfoByID);
Game::Lua::RegisterTableFunction("C_CreatureInfo", "RequestLoadCreatureByID",
&Script_RequestLoadCreatureByID);
Game::Lua::RegisterTableFunction("C_CreatureInfo", "GetRaceInfo",
&Script_GetRaceInfo);
Game::Lua::RegisterTableFunction("C_CreatureInfo", "GetClassInfo",
&Script_GetClassInfo);
Game::Lua::RegisterTableFunction("C_CreatureInfo", "GetCreatureFamilyInfo",
&Script_GetCreatureFamilyInfo);
Game::Lua::RegisterTableFunction("C_CreatureInfo", "GetCreatureFamilyIDs",
&Script_GetCreatureFamilyIDs);
Game::Lua::RegisterTableFunction("C_CreatureInfo", "GetFactionInfo",
&Script_GetFactionInfo);
Game::Lua::RegisterTableFunction("C_CreatureInfo", "GetCreatureTypeInfo",
&Script_GetCreatureTypeInfo);
Game::Lua::RegisterTableFunction("C_CreatureInfo", "GetCreatureTypeIDs",
&Script_GetCreatureTypeIDs);
Cache::QueryLoad::Register(reinterpret_cast<void *>(Offsets::VAR_CREATURE_CACHE),
kCreatureDataLoadResult, Offsets::FUN_CREATURE_GET_RECORD);
}
+7
View File
@@ -45,6 +45,8 @@
// `CursorHasItem` / engine `PickupX` calls when they need to detect
// that.
#include "cursor/Info.h"
#include "Game.h"
#include "Offsets.h"
#include "item/ID.h"
@@ -273,4 +275,9 @@ const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace
bool HasItem() {
return ReadVar(Offsets::VAR_CURSOR_ITEM_GUID_LO) != 0 ||
ReadVar(Offsets::VAR_CURSOR_ITEM_GUID_HI) != 0;
}
} // namespace Cursor::Info
+24
View File
@@ -0,0 +1,24 @@
// 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/>.
#pragma once
namespace Cursor::Info {
// True if a GUID-bearing item is currently on the cursor —
// `VAR_CURSOR_ITEM_GUID != 0`, the same globals `GetCursorInfo`'s bag-item
// path reads and the exact signal the engine's item-pickup path guards on.
// Used to gate cursor pickups (don't clobber a held item).
bool HasItem();
} // namespace Cursor::Info
+23 -16
View File
@@ -18,6 +18,7 @@
#include "event/Custom.h"
#include "item/ID.h"
#include "item/Location.h"
#include "unit/Identity.h"
#include <cstdint>
#include <cstring>
@@ -31,24 +32,15 @@ std::string g_path;
bool g_loaded = false;
bool g_ignoredForSave[SLOT_COUNT] = {};
void *ResolvePlayerInvMgr() {
using ResolveUnitToken_t = void *(__fastcall *)(const char *);
auto fn = reinterpret_cast<ResolveUnitToken_t>(Offsets::FUN_RESOLVE_UNIT_TOKEN);
auto *player = static_cast<uint8_t *>(fn("player"));
if (player == nullptr)
return nullptr;
return player + Offsets::OFF_PLAYER_INVENTORY_MANAGER;
}
// Reads the GUID currently in paperdoll slot `slot1Based` (1..19) by
// indexing the player invMgr's flat GUID array. Slot 1 (HEAD) lives
// at linear index 0; the equipment paperdoll occupies indices 0..18.
// Returns 0 for empty slots or unresolved player.
uint64_t ReadEquippedGUID(int slot1Based) {
auto *invMgr = static_cast<uint8_t *>(ResolvePlayerInvMgr());
const uint8_t *invMgr = Unit::Identity::PlayerInventoryManager();
if (invMgr == nullptr)
return 0;
auto *guids = *reinterpret_cast<uint64_t **>(
auto *guids = *reinterpret_cast<uint64_t *const *>(
invMgr + Offsets::OFF_INVMGR_GUID_ARRAY);
if (guids == nullptr)
return 0;
@@ -96,12 +88,27 @@ void Persist() {
} // namespace
void EnsureLoaded() {
if (g_loaded)
return;
g_path = Storage::ResolveFilePath();
if (g_path.empty())
return; // session globals not yet populated; retry on next call
// Re-resolve the per-character path on every call rather than latching
// once. The injected DLL persists across a logout→character-select→login
// (no process restart), so a permanent `g_loaded` latch would leave the
// previous character's file path and cached sets in place — the next
// character would then read, and save into, character A's data. Detecting
// a path change here makes the cache self-heal on any character/account
// switch. Every mutation Persist()s immediately, so dropping the old
// in-memory sets never loses unsaved work.
std::string path = Storage::ResolveFilePath();
if (path.empty())
return; // session globals not yet populated; keep current, retry later
if (g_loaded && path == g_path)
return; // already loaded for this character
g_path = std::move(path);
g_sets.clear();
Storage::Load(g_path, &g_sets);
// Per-session "ignore this slot on next save" state belongs to the
// character it was set on — clear it when switching characters.
for (int i = 0; i < SLOT_COUNT; ++i)
g_ignoredForSave[i] = false;
g_loaded = true;
}
+5 -30
View File
@@ -15,6 +15,7 @@
#include "Offsets.h"
#include "item/Location.h"
#include "unit/Identity.h"
#include <cstdint>
@@ -41,19 +42,6 @@ constexpr int LINEAR_BAG_SLOT_FIRST = 19; // bag1 → linear 19
constexpr int LINEAR_BACKPACK_FIRST = 23;
constexpr int LINEAR_BACKPACK_LAST = 38;
void *ResolvePlayer() {
using ResolveUnitToken_t = void *(__fastcall *)(const char *);
auto fn = reinterpret_cast<ResolveUnitToken_t>(Offsets::FUN_RESOLVE_UNIT_TOKEN);
return fn("player");
}
uint8_t *ResolvePlayerInvMgr() {
auto *player = static_cast<uint8_t *>(ResolvePlayer());
if (player == nullptr)
return nullptr;
return player + Offsets::OFF_PLAYER_INVENTORY_MANAGER;
}
uint64_t *InvMgrGuidArray(const uint8_t *invMgr) {
if (invMgr == nullptr)
return nullptr;
@@ -79,20 +67,6 @@ uint8_t *ResolveByGUID(int type, uint64_t guid) {
0x172));
}
// CGContainer exposes its own `CContainerInventory *` via vtable
// `+0x10` — same dispatch `GetItemCount`'s bank walk uses. The
// returned invMgr has the standard shape: slot count at +0x00,
// flat GUID array at +0x04.
uint8_t *BagInvMgr(uint8_t *bag) {
if (bag == nullptr)
return nullptr;
auto *vtable = *reinterpret_cast<uint8_t **>(bag);
using GetInvMgr_t = void *(__thiscall *)(void *);
auto getInvMgr = reinterpret_cast<GetInvMgr_t>(
*reinterpret_cast<uintptr_t *>(vtable + 0x10));
return static_cast<uint8_t *>(getInvMgr(bag));
}
// Walks one bag's contents for a matching GUID. Returns the 1-based
// slot index of the match, or 0 if not found / bag unresolvable.
// Works for both player bags (linear 19..22 of player invMgr) and
@@ -102,7 +76,8 @@ int WalkBagContents(uint64_t bagGuid, uint64_t targetGuid) {
auto *bag = ResolveByGUID(Offsets::OBJ_TYPE_CONTAINER, bagGuid);
if (bag == nullptr)
return 0;
auto *bagInvMgr = BagInvMgr(bag);
auto *bagInvMgr =
static_cast<const uint8_t *>(Item::Location::ContainerInventory(bag));
if (bagInvMgr == nullptr)
return 0;
const int count = InvMgrSlotCount(bagInvMgr);
@@ -122,7 +97,7 @@ int FindGUID(uint64_t targetGuid) {
if (targetGuid == GUID_EMPTY || targetGuid == GUID_IGNORED)
return 0;
auto *invMgr = ResolvePlayerInvMgr();
auto *invMgr = Unit::Identity::PlayerInventoryManager();
auto *guids = InvMgrGuidArray(invMgr);
if (guids == nullptr)
return 0;
@@ -180,7 +155,7 @@ const uint8_t *ResolveItemByGUID(uint64_t guid) {
void SnapshotPaperdoll(uint64_t *out) {
for (int i = 0; i < SLOT_COUNT; ++i)
out[i] = 0;
auto *invMgr = ResolvePlayerInvMgr();
auto *invMgr = Unit::Identity::PlayerInventoryManager();
auto *guids = InvMgrGuidArray(invMgr);
if (guids == nullptr)
return;
+5 -3
View File
@@ -22,9 +22,11 @@ namespace EquipmentSet::Storage {
// Resolves the per-character file path. Returns an empty string until
// the engine has populated all three session globals (account, realm,
// character). Path format: `WTF\Account\<acct>\<realm>\<char>\
// ClassicAPI_EquipmentSets.txt`. Once the engine has the player in
// world the values are stable for the rest of the session, so callers
// can cache after the first non-empty return.
// ClassicAPI_EquipmentSets.txt`. The values are stable only while the
// current character is in world — they change on a logout→login to a
// different character (the DLL is NOT reloaded across that transition),
// so callers must re-resolve and compare rather than caching the first
// non-empty return permanently. See `Data::EnsureLoaded`.
std::string ResolveFilePath();
// Reads the file at `path` and overwrites `outSets` with the parsed
+50 -66
View File
@@ -40,11 +40,12 @@
// `C_EquipmentSet.GetItemIDs` + `C_EquipmentSet.GetItemLocations`
// themselves.
//
// Tooltip filling is done by calling back through Lua (`self:AddLine`
// etc.) rather than hitting the engine's tooltip-line internals
// directly. The Lua dispatch goes through the engine's own frame-
// method registry, so any addon-installed hook on AddLine (DBM,
// pfUI's tooltip skin, etc.) still runs.
// Tooltip lines are built with the engine's own primitives — the
// per-tooltip clear (FUN_GAMETOOLTIP_CLEAR) and the raw add-line
// (FUN_GAMETOOLTIP_ADD_LINE), the same native path SetTotem and
// SetHyperlinkCompareItem use; no Lua method dispatch. It shows itself
// at the end (Script_Show), matching retail: PaperDollFrame.lua sets the
// anchor then calls SetEquipmentSet with no trailing :Show().
#include "Game.h"
#include "Offsets.h"
@@ -58,57 +59,41 @@ namespace EquipmentSet::Tooltip {
namespace {
// Calls `self:Method()` with no Lua args. Stack at entry must have
// the tooltip at index 1 (the standard `frame:method()` self slot).
// Leaves the stack unchanged.
void CallSelfNoArgs(void *L, const char *methodName) {
Game::Lua::PushValue(L, 1);
Game::Lua::PushString(L, methodName);
Game::Lua::GetTable(L, -2); // method = self[methodName]
Game::Lua::Insert(L, -2); // [method, self]
Game::Lua::Call(L, 1, 0);
using AddLine_t = void(__thiscall *)(void *self, const char *left, const char *right,
const void *leftColor, const void *rightColor, int wrap);
using ClearTooltip_t = void(__fastcall *)(void *self);
using ShowScript_t = int(__fastcall *)(void *L); // engine's Script_Show
// Packs r,g,b (0..1) into the 0xAARRGGBB value FUN_GAMETOOLTIP_ADD_LINE's
// color arg wants — its little-endian bytes are the {b,g,r,a} the engine's
// line-color setter reads. Alpha is always opaque.
uint32_t Pack(double r, double g, double b) {
auto ch = [](double v) {
const int i = static_cast<int>(v * 255.0 + 0.5);
return static_cast<uint32_t>(i < 0 ? 0 : (i > 255 ? 255 : i));
};
return 0xFF000000u | (ch(r) << 16) | (ch(g) << 8) | ch(b);
}
// Calls `self:AddLine(text, r, g, b)`. Same self-on-stack precondition.
void AddLine(void *L, const char *text, double r, double g, double b) {
Game::Lua::PushValue(L, 1);
Game::Lua::PushString(L, "AddLine");
Game::Lua::GetTable(L, -2);
Game::Lua::Insert(L, -2);
Game::Lua::PushString(L, text);
Game::Lua::PushNumber(L, r);
Game::Lua::PushNumber(L, g);
Game::Lua::PushNumber(L, b);
Game::Lua::Call(L, 5, 0);
// Left-aligned colored line via the engine's raw add-line. The packed
// color is a local so its address stays valid across the call.
void AddColoredLine(void *self, const char *text, double r, double g, double b) {
const uint32_t color = Pack(r, g, b);
reinterpret_cast<AddLine_t>(Offsets::FUN_GAMETOOLTIP_ADD_LINE)(
self, text, nullptr, &color, nullptr, 0);
}
// Calls `self:SetText(text, r, g, b)`. Same precondition. Used for
// the header line — `SetText` is the standard vanilla entry for
// "first line of a fresh tooltip" and handles internal redraw state
// `AddLine` skips when it's the first content after a clear.
void SetText(void *L, const char *text, double r, double g, double b) {
Game::Lua::PushValue(L, 1);
Game::Lua::PushString(L, "SetText");
Game::Lua::GetTable(L, -2);
Game::Lua::Insert(L, -2);
Game::Lua::PushString(L, text);
Game::Lua::PushNumber(L, r);
Game::Lua::PushNumber(L, g);
Game::Lua::PushNumber(L, b);
Game::Lua::Call(L, 5, 0); // self + text + r + g + b
}
// Format-and-add one localized count line. Calls
// `Game::Lua::PushLocalizedFormatInt` to resolve the format string
// (Blizzard's FrameXML globals like `ITEMS_EQUIPPED` first, with the
// C `fallback` for stripped-down servers), then dispatches `AddLine`.
void AddLocalizedLineInt(void *L, const char *globalName, const char *fallback, int n,
double r, double g, double b) {
// Adds one localized "N unit" line: `string.format(_G[globalName] or
// fallback, n)` (the project's GlobalString localize-or-fallback helper,
// with the C `fallback` for servers stripped of the standard strings),
// then the native colored add-line.
void AddLocalizedLineInt(void *L, void *self, const char *globalName,
const char *fallback, int n, double r, double g, double b) {
const int savedTop = Game::Lua::GetTop(L);
Game::Lua::PushLocalizedFormatInt(L, globalName, fallback, n);
const char *text = Game::Lua::ToString(L, -1);
if (text != nullptr)
AddLine(L, text, r, g, b);
AddColoredLine(self, text, r, g, b);
Game::Lua::SetTop(L, savedTop);
}
@@ -185,39 +170,36 @@ int __fastcall Script_GameTooltipSetEquipmentSet(void *L) {
if (s == nullptr)
return 0;
void *self = Game::Lua::ResolveObject(L, 1);
if (self == nullptr)
return 0;
const Tally t = TallySet(*s);
const int totalSlots = t.equipped + t.inInventory + t.missing;
// Trim the Lua stack to just the tooltip self-arg so our internal
// method-call sequences don't accumulate the original args past
// their useful lifetime. `setName` was a `const char *` lifted out
// by ToString — Lua's strings are pinned by reference count, so
// dropping the string from the stack doesn't invalidate the
// pointer for the lifetime of this call.
Game::Lua::SetTop(L, 1);
// Header — set name, white. Doubles as the "ClearLines" boundary;
// SetText replaces line 1 and clears subsequent lines in vanilla.
SetText(L, s->name.c_str(), 1.0, 1.0, 1.0);
// Header — set name, white. Clear first so it lands as line 0
// (the clear resets the line count the add-line body indexes from).
reinterpret_cast<ClearTooltip_t>(Offsets::FUN_GAMETOOLTIP_CLEAR)(self);
AddColoredLine(self, s->name.c_str(), 1.0, 1.0, 1.0);
// Body lines — try Blizzard's localized FORMAT_* globals first.
// Same key names retail FrameXML uses (`ITEMS_VARIABLE_QUANTITY`,
// `ITEMS_EQUIPPED`, ...); addons that need their own wording can
// override by reassigning `_G[name]` at runtime. The fallbacks
// only fire on servers stripped of the standard GlobalStrings.
AddLocalizedLineInt(L, "ITEMS_VARIABLE_QUANTITY", "%d items",
AddLocalizedLineInt(L, self, "ITEMS_VARIABLE_QUANTITY", "%d items",
totalSlots, 1.0, 1.0, 1.0);
if (t.equipped > 0) {
AddLocalizedLineInt(L, "ITEMS_EQUIPPED", "%d equipped",
AddLocalizedLineInt(L, self, "ITEMS_EQUIPPED", "%d equipped",
t.equipped, 0.0, 1.0, 0.0); // green
}
if (t.inInventory > 0) {
AddLocalizedLineInt(L, "ITEMS_IN_INVENTORY", "%d in inventory",
AddLocalizedLineInt(L, self, "ITEMS_IN_INVENTORY", "%d in inventory",
t.inInventory, 1.0, 1.0, 1.0);
}
if (t.ignored > 0) {
AddLocalizedLineInt(L, "ITEM_SLOTS_IGNORED", "%d slots ignored",
AddLocalizedLineInt(L, self, "ITEM_SLOTS_IGNORED", "%d slots ignored",
t.ignored, 0.5, 0.5, 0.5); // gray
}
// Missing slots: list each by name using the itemID we stored at
@@ -255,18 +237,20 @@ int __fastcall Script_GameTooltipSetEquipmentSet(void *L) {
Game::Lua::Call(L, 2, 1);
const char *line = Game::Lua::ToString(L, -1);
if (line != nullptr)
AddLine(L, line, 1.0, 0.0, 0.0); // red
AddColoredLine(self, line, 1.0, 0.0, 0.0); // red
Game::Lua::SetTop(L, savedTop);
}
if (unnamedMissing > 0) {
// Pre-itemID-format files or items the cache has no record of.
// No Blizzard format global maps cleanly here; addons can
// override with `_G.CLASSICAPI_EQUIPMENTSET_MISSING`.
AddLocalizedLineInt(L, "CLASSICAPI_EQUIPMENTSET_MISSING",
AddLocalizedLineInt(L, self, "CLASSICAPI_EQUIPMENTSET_MISSING",
"%d missing", unnamedMissing, 1.0, 0.0, 0.0);
}
CallSelfNoArgs(L, "Show");
// Show ourselves — self is still at Lua stack index 1 (Script_Show
// reads it there). Retail's SetEquipmentSet shows internally too.
reinterpret_cast<ShowScript_t>(Offsets::FUN_SCRIPT_FRAME_SHOW)(L);
return 0;
}
+189 -8
View File
@@ -28,7 +28,17 @@ namespace {
// any engine code runs. `RetryClaims` (triggered by the
// `Frame::RegisterEvent` hook) walks this list and claims a NULL slot
// for each unclaimed entry.
constexpr int MAX_RESERVED = 32;
//
// MUST stay comfortably above the total number of `AutoReserve` instances
// across the codebase (file-scope + the lazily-registered TTS ones) — the
// constructor SILENTLY drops any reservation past the cap, and which ones
// overflow depends on unspecified cross-TU static-init order, so exceeding
// it makes an arbitrary subset of custom events quietly stop firing (their
// `Lookup` returns -1 and `Fire` no-ops). We hit exactly that at 32: ~40
// reservations meant a shifting handful (e.g. ITEM_DATA_LOAD_RESULT /
// GET_ITEM_INFO_RECEIVED) never claimed a slot. Grep `AutoReserve` before
// bumping the reservation count near this ceiling.
constexpr int MAX_RESERVED = 64;
struct ReservedName {
const char *name;
int slot; // -1 until claimed
@@ -36,6 +46,22 @@ struct ReservedName {
ReservedName g_reserved[MAX_RESERVED];
int g_reservedCount = 0;
bool g_writesEnabled = false;
// One-shot latch so a genuinely full event table (no NULL slot left to
// claim) is logged once per session instead of silently dropping the
// event — the failure mode that would otherwise rot unnoticed. Re-armed on
// `/reload` (the table is rebuilt fresh). In practice this never triggers:
// SuperWoW/nampower clamp the FrameXML table to 700 and the engine leaves
// ~200 NULL gaps in the low range, dwarfing our ~46 reservations. The log is
// purely a backstop if some future install somehow drains the gap pool.
bool g_warnedTableFull = false;
// One grow attempt per in-game table build. Reset in `PrepareForReload`
// (the table is rebuilt fresh on every /reload + logout). See `Grow`.
bool g_growLatched = false;
// Debug: force a grow on the next table build regardless of headroom, so
// the (otherwise dormant) grow path can actually be exercised in-game.
// Armed by `_classicapi_ForceEventGrow`, consumed by the next `Grow`.
bool g_forceGrowNext = false;
// Engine's SStrDup at `FUN_STORM_SSTRDUP` — uses `SMemAlloc` internally
// and is exactly what the engine itself does when filling event table
@@ -51,14 +77,38 @@ char *SStrDup(const char *s) {
return fn(s, __FILE__, __LINE__);
}
// Walk the engine's event table from the END looking for a NULL-name
// Storm allocator/free — the same pair the engine uses for the event-table
// buffer (see RebuildEventTable), so its reload teardown `SMemFree`s our
// grown buffer exactly as its own. Both `__stdcall`, 4 args (`ret 0x10`).
// SMemAlloc flag bit 8 zero-fills (matching SetEventCount's fresh entries).
void *SMemAlloc(unsigned size) {
using SMemAlloc_t = void *(__stdcall *)(unsigned size, const char *file,
int line, int flags);
auto fn = reinterpret_cast<SMemAlloc_t>(Offsets::FUN_STORM_SMEMALLOC);
return fn(size, __FILE__, __LINE__, 8 /* zero-fill */);
}
void SMemFree(void *ptr) {
using SMemFree_t = int(__stdcall *)(void *ptr, const char *file, int line,
int flags);
auto fn = reinterpret_cast<SMemFree_t>(Offsets::FUN_STORM_SMEMFREE);
fn(ptr, __FILE__, __LINE__, 0);
}
// Walk the engine's event table from the LOW end looking for a NULL-name
// slot. The bulk-init at `0x0051AB30` leaves ~200 NULL gaps scattered
// through the 548-slot input names array (it only writes ~350 of the
// slots explicitly; the rest stay zero-initialized from .data). UP
// and DOWN walks both find these gaps — we go DOWN for convention with
// VanillaMinimapTracking and so the dump in `_classicapi_DumpAllEvents`
// shows our events grouped at the tail, visually distinct from engine
// events.
// slots explicitly; the rest stay zero-initialized from .data) — a big
// contiguous hole around 42..106 alone dwarfs our reservation count.
//
// We go LOW (ascending) deliberately: SuperWoW and nampower own the HIGH
// range (fixed event IDs 549..655, and they destructively clamp the table
// to 700), and other claim-based DLLs (VanillaMinimapTracking) also walk
// from the tail — so the top is the contested end. Claiming from the low
// engine gaps keeps us entirely out of their way and independent of their
// clamp / hook order. The engine's ~200 low NULL gaps dwarf our ~46
// reservations, so a free slot is essentially always available; the
// skip-occupied invariant keeps this safe against future engine additions
// regardless of direction.
int TryClaim(const char *eventName) {
if (!g_writesEnabled)
return -1;
@@ -66,7 +116,7 @@ int TryClaim(const char *eventName) {
const int count = *reinterpret_cast<int *>(Offsets::VAR_EVENT_TABLE_COUNT);
if (base == nullptr || count <= 0)
return -1;
for (int i = count - 1; i >= 0; --i) {
for (int i = 0; i < count; ++i) {
auto **namePtr = reinterpret_cast<const char **>(
base + i * Offsets::EVENT_ENTRY_STRIDE +
Offsets::OFF_EVENT_ENTRY_NAME);
@@ -78,9 +128,119 @@ int TryClaim(const char *eventName) {
return i;
}
}
// Writes are enabled and the table is valid, but every slot is taken —
// real capacity exhaustion despite `Grow`. Warn once so it's diagnosable.
if (!g_warnedTableFull) {
g_warnedTableFull = true;
Debug::Log::Printf(
"[event] table full (%d slots, no NULL left) — cannot claim '%s'"
" and any further custom events; even Grow couldn't make room.",
count, eventName ? eventName : "?");
}
return -1;
}
// On-demand event-table grow. Correctly TIMED: called once per in-game
// table build from `RetryClaims` (the `frame:RegisterEvent` hook), on the
// FIRST such call — which is AFTER `RebuildEventTable` has built the fresh
// table (frames can only register once it exists) but BEFORE any frame has
// joined a chain (it's the first registration, and we run before the
// original registers it). So every chain is still an empty self-sentinel,
// and the buffer can be moved safely.
//
// (This is what the removed `EnsureCapacity` got wrong: it ran from
// `LoadScriptFunctions_h`, BEFORE `RebuildEventTable`, so it saw the stale
// previous/glue table and always aborted — it never actually grew. The
// realloc logic itself was fine; only the timing was broken.)
//
// Grows only when the engine's low NULL gaps are fewer than our
// reservations — which never happens in practice (~200 gaps vs ~46
// reservations), so this is a dormant durability valve. `g_forceGrowNext`
// (debug) forces it so the path can be exercised. It grows by reallocating
// the entry buffer with the engine's own Storm allocator and swapping the
// `{cap@0, count@4, base@8}` globals at `0x00CEEF60` — bypassing
// SetEventCount's destructive `= 700` clamp. All consumers (RegisterEvent,
// the dispatcher, SuperWoW, nampower) read the base pointer fresh, so the
// swap is picked up everywhere; the engine's reload teardown `SMemFree`s
// our buffer and frees the (shared) name strings via its per-entry helper,
// exactly as its own.
void Grow() {
if (g_growLatched || !g_writesEnabled)
return;
g_growLatched = true; // one attempt per build, whether or not it grows
const int count = *reinterpret_cast<int *>(Offsets::VAR_EVENT_TABLE_COUNT);
auto *base = *reinterpret_cast<uint8_t **>(Offsets::VAR_EVENT_TABLE_BASE_PTR);
if (base == nullptr || count <= 0 || g_reservedCount <= 0)
return;
int nulls = 0;
for (int i = 0; i < count; ++i)
if (*reinterpret_cast<const char *const *>(
base + i * Offsets::EVENT_ENTRY_STRIDE +
Offsets::OFF_EVENT_ENTRY_NAME) == nullptr)
++nulls;
const bool force = g_forceGrowNext;
g_forceGrowNext = false;
if (nulls >= g_reservedCount && !force)
return; // plenty of room — the common (only) real case
// Growing moves the buffer, invalidating every entry's self-referential
// list anchor, so it's safe ONLY while all chains are empty. We run
// before the first registration, so that holds — but VERIFY it (a live
// chain means someone registered earlier via a path we didn't intercept;
// moving the buffer would corrupt their subscription). Abort if so and
// fall back to claim-NULL.
for (int i = 0; i < count; ++i) {
const uint32_t head = *reinterpret_cast<const uint32_t *>(
base + i * Offsets::EVENT_ENTRY_STRIDE + Offsets::OFF_EVENT_ENTRY_HEAD);
if (head != 0 && (head & 1) == 0) { // populated chain (not 0 / sentinel)
Debug::Log::Printf(
"[event] grow aborted — chain live at slot %d (not first-reg)", i);
return;
}
}
constexpr int kMargin = 16;
const int deficit = (nulls < g_reservedCount) ? (g_reservedCount - nulls) : 0;
const int newCount = count + deficit + kMargin;
auto *newBuf = static_cast<uint8_t *>(
SMemAlloc(static_cast<unsigned>(newCount) * Offsets::EVENT_ENTRY_STRIDE));
if (newBuf == nullptr)
return; // alloc failed — keep the old table; claiming still works
// Rebuild each entry at its NEW address, mirroring SetEventCount's own
// per-entry init: copy the name pointer (shared string, not duplicated);
// the `+0x04` field stays 0 (zero-filled, matching the engine); re-init
// the anchor as an empty self-sentinel at the new location. Old entries
// past the copy and all new entries are already zero-filled + get fresh
// anchors. Safe because every chain is empty.
for (int i = 0; i < newCount; ++i) {
auto *e = reinterpret_cast<uint32_t *>(
newBuf + i * Offsets::EVENT_ENTRY_STRIDE);
if (i < count) // copy name; new slots stay null
e[0] = *reinterpret_cast<const uint32_t *>(
base + i * Offsets::EVENT_ENTRY_STRIDE);
const uint32_t anchor = static_cast<uint32_t>(
reinterpret_cast<uintptr_t>(&e[2])); // entry+0x08
e[2] = anchor; // self-referential list anchor
e[3] = anchor | 1; // empty-chain sentinel head (+0x0C)
}
// Publish base BEFORE count so the globals never advertise more entries
// than the buffer holds. Then free the OLD buffer only — the name
// strings live on in newBuf and are freed by the reload teardown.
*reinterpret_cast<uint8_t **>(Offsets::VAR_EVENT_TABLE_BASE_PTR) = newBuf;
*reinterpret_cast<int *>(Offsets::VAR_EVENT_TABLE_CAP) = newCount;
*reinterpret_cast<int *>(Offsets::VAR_EVENT_TABLE_COUNT) = newCount;
SMemFree(base);
Debug::Log::Printf("[event] grew table %d -> %d (%d NULL, %d reserved%s)",
count, newCount, nulls, g_reservedCount,
force ? ", FORCED" : "");
}
} // namespace
AutoReserve::AutoReserve(const char *name) {
@@ -123,6 +283,11 @@ int LookupByName(const char *name) {
}
void RetryClaims() {
// Grow the table first if the low gap pool is short (dormant valve).
// Runs at most once per table build; on the first call every chain is
// still empty (this is the first registration), which is the only safe
// time to move the buffer.
Grow();
for (int i = 0; i < g_reservedCount; ++i) {
if (g_reserved[i].slot < 0)
g_reserved[i].slot = TryClaim(g_reserved[i].name);
@@ -135,6 +300,8 @@ void PrepareForReload() {
for (int i = 0; i < g_reservedCount; ++i)
g_reserved[i].slot = -1;
g_writesEnabled = false;
g_warnedTableFull = false; // fresh table after the rebuild — re-arm
g_growLatched = false; // re-arm the one-shot grow for the new table
}
// Diagnostic Lua functions — registered via the auto-register pattern
@@ -180,10 +347,24 @@ int __fastcall Script_DumpAllEvents(void *) {
return 0;
}
// `_classicapi_ForceEventGrow()` — arms a one-shot forced table grow for
// the NEXT in-game table build, so the (otherwise dormant) grow path can be
// exercised: call it, then `/reload`. The grow fires at the first
// `RegisterEvent` after the rebuild (chains still empty) and logs
// `[event] grew table N -> M (..., FORCED)`. Diagnostic only.
int __fastcall Script_ForceEventGrow(void *) {
g_forceGrowNext = true;
Debug::Log::Printf(
"[event] force-grow armed — /reload to trigger it on the next build");
return 0;
}
void RegisterDiagnostics() {
Game::Lua::RegisterGlobalFunction("_classicapi_DumpSlot", &Script_DumpSlot);
Game::Lua::RegisterGlobalFunction("_classicapi_DumpAllEvents",
&Script_DumpAllEvents);
Game::Lua::RegisterGlobalFunction("_classicapi_ForceEventGrow",
&Script_ForceEventGrow);
}
const Game::ModuleAutoRegister _autoreg{&RegisterDiagnostics};
+3 -2
View File
@@ -37,8 +37,9 @@ namespace Event::Custom {
// DLL operate on the table independently of the others.
//
// The name pointer must outlive the engine (a string literal does).
// Same name reserved twice is deduped; reserving more than 32 names
// total silently drops the overflow.
// Same name reserved twice is deduped; reserving more than `MAX_RESERVED`
// names total (see Custom.cpp) silently drops the overflow — keep that cap
// above the codebase-wide `AutoReserve` count.
struct AutoReserve {
explicit AutoReserve(const char *name);
};
+109
View File
@@ -0,0 +1,109 @@
// 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/>.
// `PLAYER_ENTERING_WORLD(isInitialLogin, isReloadingUi)` — the 8.0.1 payload,
// backported. Vanilla fires PEW with no args; backported addons expect the
// two booleans to distinguish a fresh login from a `/reload` from an
// instance/zone transition.
//
// Delivery: PEW is a no-arg event, so the engine broadcasts it through
// `FUN_FIRE_EVENT_NO_ARGS` (the `__fastcall(int)` dispatcher), NOT the
// variadic `FUN_FIRE_EVENT`. We intercept that dispatcher (via the shared
// `Event::SignalHook`) and, for the PEW event id, suppress the no-arg fire
// and instead re-broadcast through the variadic `FUN_FIRE_EVENT` with a
// two-boolean payload. Both fire paths read the same frame chain off the
// event's table slot, so the frames registered for PEW receive the args
// exactly once. Booleans use the `%d`/`%s`+nullptr trick (`1` / `nil`), so
// `if isInitialLogin then …` reads naturally.
//
// Flags, from signals we already produce — no PEW-specific engine hook:
// - isInitialLogin: our GlueModuleAutoRegister runs on every glue-screen
// boot (initial launch + each logout return), so a set latch means "the
// next enter-world is a fresh login". The first PEW consumes it.
// - isReloadingUi: our ModuleAutoRegister runs whenever the in-game Lua
// state is (re)built — login OR `/reload`, but NOT a zone transition. So
// "script re-init happened and it wasn't the fresh login" == a reload.
// - Instance/zone transition rebuilds neither, so both latches are clear →
// both false, matching retail.
#include "Game.h"
#include "event/Custom.h"
#include "event/SignalHook.h"
namespace Event::EnteringWorld {
namespace {
// Set on glue-screen boot; a pending latch means the next enter-world is a
// fresh character login (from the login/char-select screen).
bool g_atGlue = false;
// Set when the in-game Lua state is (re)built (login or /reload).
bool g_scriptReloaded = false;
// Cached PLAYER_ENTERING_WORLD event-table slot; -1 = unresolved. Reset per
// in-game init (the table is rebuilt, though engine slots are stable) and
// re-resolved lazily at fire time.
int g_pewId = -1;
int PewId() {
if (g_pewId < 0)
g_pewId = Event::Custom::LookupByName("PLAYER_ENTERING_WORLD");
return g_pewId;
}
// Shared-hook interceptor. For PEW, re-broadcast with the payload and
// suppress the engine's no-arg fire; everything else passes through.
bool Intercept(int eventID) {
const int pew = PewId();
if (pew < 0 || eventID != pew)
return false;
const bool isInitialLogin = g_atGlue;
g_atGlue = false;
const bool isReloadingUi = !isInitialLogin && g_scriptReloaded;
g_scriptReloaded = false;
// Re-fire through the variadic dispatcher with the two booleans. true → 1
// (%d), false → nil (%s + nullptr, which the dispatcher's pushstring
// tail-jumps to pushnil).
auto *const kNil = static_cast<const char *>(nullptr);
if (isInitialLogin && isReloadingUi)
Event::Custom::Fire(pew, "%d%d", 1, 1);
else if (isInitialLogin)
Event::Custom::Fire(pew, "%d%s", 1, kNil);
else if (isReloadingUi)
Event::Custom::Fire(pew, "%s%d", kNil, 1);
else
Event::Custom::Fire(pew, "%s%s", kNil, kNil);
return true; // handled — suppress the original no-arg PEW fire
}
const Event::SignalHook::AutoSubscribe _intercept{&Intercept};
// In-game Lua (re)init: login or /reload rebuilt the script state (never a
// zone transition). Also re-arm the PEW slot lookup — the event table was
// rebuilt.
void OnInGameInit() {
g_scriptReloaded = true;
g_pewId = -1;
}
// Glue-screen boot: we're at the login/char-select screen, so the next
// enter-world will be a fresh login.
void OnGlueInit() { g_atGlue = true; }
const Game::ModuleAutoRegister _inGame{&OnInGameInit};
const Game::GlueModuleAutoRegister _glue{&OnGlueInit};
} // namespace
} // namespace Event::EnteringWorld
+98
View File
@@ -0,0 +1,98 @@
// 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/>.
// `GetFramesRegisteredForEvent(event)` → the Frame objects currently
// subscribed to `event`, returned as multiple return values (matching
// modern WoW's vararg signature — not a table). Empty result set returns
// nothing.
//
// The engine already keeps exactly this list: every `frame:RegisterEvent`
// appends the frame to a subscriber chain hanging off the matching event
// table entry. We find the entry by name (same table `Event::Custom`
// walks), then walk the chain the same way `Frame::RegisterEvent`
// (`FUN_00702140`) does — node next at `+0x04`, subscriber frame at
// `+0x08`, with a low-bit-set / null value marking the end sentinel — and
// push each frame via the shared `UI::FrameObject::Push` so callers get
// the canonical wrapper table (real frame methods, `:GetName()`, etc.).
//
// Event names resolve through `Event::Custom::LookupByName`, so this also
// works for our `AutoReserve`-claimed custom events, not just engine ones.
#include "Game.h"
#include "Offsets.h"
#include "event/Custom.h"
#include "ui/FrameObject.h"
#include <cstdint>
namespace Event::Registered {
namespace {
int __fastcall Script_GetFramesRegisteredForEvent(void *L) {
if (!Game::Lua::IsString(L, 1))
return 0;
const char *event = Game::Lua::ToString(L, 1);
if (event == nullptr)
return 0;
const int slot = Event::Custom::LookupByName(event);
if (slot < 0)
return 0;
auto *base = *reinterpret_cast<uint8_t **>(Offsets::VAR_EVENT_TABLE_BASE_PTR);
if (base == nullptr)
return 0;
const uint8_t *entry = base + slot * Offsets::EVENT_ENTRY_STRIDE;
const uintptr_t head = *reinterpret_cast<const uintptr_t *>(
entry + Offsets::OFF_EVENT_ENTRY_HEAD);
// First pass: count subscribers so we can reserve stack up front — an
// event with many registered frames would otherwise risk overflowing
// the default Lua stack slack as we push.
int count = 0;
for (uintptr_t node = head; (node & 1) == 0 && node != 0;
node = *reinterpret_cast<const uintptr_t *>(
node + Offsets::OFF_EVENT_NODE_NEXT)) {
if (*reinterpret_cast<void *const *>(node + Offsets::OFF_EVENT_NODE_FRAME))
++count;
}
if (count == 0)
return 0;
Game::Lua::CheckStack(L, count);
int pushed = 0;
for (uintptr_t node = head; (node & 1) == 0 && node != 0;
node = *reinterpret_cast<const uintptr_t *>(
node + Offsets::OFF_EVENT_NODE_NEXT)) {
void *frame = *reinterpret_cast<void *const *>(
node + Offsets::OFF_EVENT_NODE_FRAME);
if (frame != nullptr) {
UI::FrameObject::Push(L, frame);
++pushed;
}
}
return pushed;
}
void RegisterLuaFunctions() {
Game::Lua::RegisterGlobalFunction("GetFramesRegisteredForEvent",
&Script_GetFramesRegisteredForEvent);
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace
} // namespace Event::Registered
+49
View File
@@ -0,0 +1,49 @@
// 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/>.
#include "SignalHook.h"
#include "Game.h"
#include "Offsets.h"
namespace Event::SignalHook {
namespace {
// Chained at static-init time by each `AutoSubscribe` constructor, before
// `DllMain`. Zero-initialized (namespace static) ahead of any dynamic init,
// so the head is valid when subscribers link in.
AutoSubscribe *g_head = nullptr;
// `FUN_FIRE_EVENT_NO_ARGS` is `__fastcall(int eventID)`; we mirror loot's
// long-standing shape with a dummy EDX so the trampoline call matches the
// register layout, and forward `0` for it (unused by the callee).
using FireNoArgs_t = void(__fastcall *)(int eventID, int edx);
FireNoArgs_t g_orig = nullptr;
void __fastcall Hook(int eventID, int /*edx*/) {
for (AutoSubscribe *s = g_head; s != nullptr; s = s->next)
if (s->fn(eventID))
return; // a subscriber handled it — suppress the original fire
g_orig(eventID, 0);
}
const Game::HookAutoRegister _hook{
Offsets::FUN_FIRE_EVENT_NO_ARGS, reinterpret_cast<void *>(&Hook),
reinterpret_cast<void **>(&g_orig)};
} // namespace
AutoSubscribe::AutoSubscribe(Interceptor f) : fn(f), next(g_head) { g_head = this; }
} // namespace Event::SignalHook
+47
View File
@@ -0,0 +1,47 @@
// 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/>.
#pragma once
// Shared interceptor for the engine's no-arg event dispatcher
// `FUN_FIRE_EVENT_NO_ARGS` (`0x00703E50`) — the `__fastcall(int eventID)`
// path every no-arg event broadcast goes through (PLAYER_ENTERING_WORLD,
// LOOT_OPENED/CLOSED, zone changes, …), as opposed to the variadic
// `FUN_FIRE_EVENT` used by events that carry a printf-style payload.
//
// MinHook allows only ONE hook per target address, and more than one feature
// needs to intercept this dispatcher (loot silent-scan suppression, the
// PLAYER_ENTERING_WORLD payload). So — exactly like `Tick::WorldTick` for the
// per-frame tick — this module owns the single hook and fans out to
// subscribers. Declare a file-scope `static const
// Event::SignalHook::AutoSubscribe` in your module.
//
// An interceptor returns `true` when it has fully handled `eventID` and the
// engine's original no-arg fire must be SUPPRESSED — either because it
// swallowed the event (loot) or re-fired it a different way (the PEW payload
// re-broadcasts through the variadic `FUN_FIRE_EVENT` with args). Returning
// `false` lets the event fall through to the next subscriber and ultimately
// the original. The FIRST subscriber to return `true` wins and the original
// is skipped, so subscribers must claim disjoint event ids.
namespace Event::SignalHook {
using Interceptor = bool (*)(int eventID);
struct AutoSubscribe {
explicit AutoSubscribe(Interceptor fn);
Interceptor fn;
AutoSubscribe *next;
};
} // namespace Event::SignalHook
+18
View File
@@ -282,7 +282,25 @@ void RegisterLuaFunctions() {
&Script_C_FriendList_IsWhoQueryPending);
}
// Clear transient /who state on every in-game Lua (re)build — login OR
// /reload. The DLL survives a logout→character-select→login, so a query
// whose SMSG_WHO response never crossed that boundary would otherwise leave
// g_inFlight stuck (IsWhoQueryPending() wrongly true for the next character)
// and our VAR_WHO_TO_UI_FLAG override forced on (routing the next
// character's unrelated /who to the WhoList). ResetIfStuck only heals this
// lazily on a *subsequent* send; a fresh-init reset makes every character
// start clean. RestoreFlagIfOwned puts the engine flag back to the value we
// snapshotted before overriding. ModuleAutoRegister fires per login and per
// /reload, before any addon runs, so this always precedes a new burst.
void ResetState() {
g_inFlight = 0;
RestoreFlagIfOwned();
g_haveSent = false;
g_lastSendTick = 0;
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
const Game::ModuleAutoRegister _resetOnInit{&ResetState};
} // namespace
+64
View File
@@ -0,0 +1,64 @@
// 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/>.
// `ClosestGameObjectPosition(gameObjectID)` → `xPos, yPos, distance` — the
// world position of the nearest game object with the given
// game-object-template ID, and its distance from the player in yards.
// Returns nothing when none is found (the `MayReturnNothing` contract).
//
// The game-object analog of `ClosestUnitPosition`; same shared scan
// (Object::ClosestByEntry), just filtering on the GameObject GUID type
// (`0xF110`) and `TYPEMASK_GAMEOBJECT`. Same retail-vs-vanilla caveat:
// this returns the nearest CURRENTLY-VISIBLE object of that entry rather
// than reading retail's static starting-zone database (which vanilla
// doesn't have).
#include "Game.h"
#include "Offsets.h"
#include "guid/Guid.h"
#include "object/ClosestByEntry.h"
#include <cmath>
#include <cstdint>
namespace GameObject::ClosestPosition {
namespace {
int __fastcall Script_ClosestGameObjectPosition(void *L) {
if (!Game::Lua::IsNumber(L, 1)) {
Game::Lua::Error(L, "Usage: ClosestGameObjectPosition(gameObjectID)");
return 0;
}
const auto entry = static_cast<uint32_t>(Game::Lua::ToNumber(L, 1));
const Object::ClosestByEntry::Result r = Object::ClosestByEntry::Find(
Offsets::TYPEMASK_GAMEOBJECT, Guid::Type::GameObject, entry);
if (!r.found)
return 0;
Game::Lua::PushNumber(L, static_cast<double>(r.x));
Game::Lua::PushNumber(L, static_cast<double>(r.y));
Game::Lua::PushNumber(L, std::sqrt(static_cast<double>(r.distSq)));
return 3;
}
void RegisterLuaFunctions() {
Game::Lua::RegisterGlobalFunction("ClosestGameObjectPosition",
&Script_ClosestGameObjectPosition);
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace
} // namespace GameObject::ClosestPosition
+112
View File
@@ -0,0 +1,112 @@
// 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/>.
#include "group/MemberStats.h"
#include "Offsets.h"
namespace Group::MemberStats {
namespace {
// Both roster lookups are `__fastcall(u64 *guid) -> block*` (NULL on miss).
using Lookup_t = const uint8_t *(__fastcall *)(uint64_t *guid);
uint16_t U16(const uint8_t *p, int off) {
return *reinterpret_cast<const uint16_t *>(p + off);
}
} // namespace
Stats Lookup(uint64_t guid) {
Stats s;
if (guid == 0)
return s;
// Party stats block — the full record.
auto statsFn = reinterpret_cast<Lookup_t>(
static_cast<uintptr_t>(Offsets::FUN_GROUP_MEMBER_STATS_LOOKUP));
if (const uint8_t *e = statsFn(&guid)) {
s.valid = true;
s.full = true;
s.powerType = e[Offsets::OFF_GROUP_MEMBER_STATS_POWER_TYPE];
s.health = U16(e, Offsets::OFF_GROUP_MEMBER_STATS_HEALTH);
s.maxHealth = U16(e, Offsets::OFF_GROUP_MEMBER_STATS_MAX_HEALTH);
s.power = U16(e, Offsets::OFF_GROUP_MEMBER_STATS_POWER);
s.maxPower = U16(e, Offsets::OFF_GROUP_MEMBER_STATS_MAX_POWER);
s.level = U16(e, Offsets::OFF_GROUP_MEMBER_STATS_LEVEL);
s.areaId = U16(e, Offsets::OFF_GROUP_MEMBER_AREA_ID);
s.online = (e[Offsets::OFF_GROUP_MEMBER_STATUS_FLAGS] &
Offsets::GROUP_MEMBER_STATUS_ONLINE) != 0;
// The party stats block carries no dead / subgroup / rank fields; those
// are raid-only (dead comes from UnitIsDead on the live unit otherwise).
return s;
}
// Raid slot — same struct GetRaidRosterInfo reads.
auto slotFn = reinterpret_cast<Lookup_t>(
static_cast<uintptr_t>(Offsets::FUN_GROUP_MEMBER_SLOT_LOOKUP));
if (const uint8_t *e = slotFn(&guid)) {
s.valid = true;
s.full = false;
s.powerType = e[Offsets::OFF_RAID_SLOT_POWER_TYPE];
s.health = U16(e, Offsets::OFF_RAID_SLOT_HEALTH);
s.maxHealth = U16(e, Offsets::OFF_RAID_SLOT_MAX_HEALTH);
s.power = U16(e, Offsets::OFF_RAID_SLOT_POWER);
s.maxPower = U16(e, Offsets::OFF_RAID_SLOT_MAX_POWER);
s.level = U16(e, Offsets::OFF_RAID_SLOT_LEVEL);
s.areaId = U16(e, Offsets::OFF_RAID_SLOT_AREA_ID);
const uint32_t flags =
*reinterpret_cast<const uint32_t *>(e + Offsets::OFF_RAID_SLOT_FLAGS);
s.online = (flags & Offsets::RAID_SLOT_FLAG_ONLINE) != 0;
s.dead = (flags & Offsets::RAID_SLOT_FLAG_DEAD) != 0;
s.subgroup = static_cast<int>(*reinterpret_cast<const uint32_t *>(
e + Offsets::OFF_RAID_SLOT_SUBGROUP)) + 1;
s.rank = static_cast<int>(
*reinterpret_cast<const uint32_t *>(e + Offsets::OFF_RAID_SLOT_RANK));
return s;
}
return s;
}
const uint16_t *AuraArray(uint64_t guid) {
if (guid == 0)
return nullptr;
// Party stats block first — auras at +0x1a, but only for an online member
// (the engine's Script_UnitBuff bails on the party path when the online bit
// is clear).
auto statsFn = reinterpret_cast<Lookup_t>(
static_cast<uintptr_t>(Offsets::FUN_GROUP_MEMBER_STATS_LOOKUP));
if (const uint8_t *e = statsFn(&guid)) {
if ((e[Offsets::OFF_GROUP_MEMBER_STATUS_FLAGS] &
Offsets::GROUP_MEMBER_STATUS_ONLINE) == 0)
return nullptr;
return reinterpret_cast<const uint16_t *>(
e + Offsets::OFF_GROUP_MEMBER_STATS_AURAS);
}
// Raid slot — auras at +0x64, no online gate (mirrors the engine's raid
// branch, which reads the array unconditionally).
auto slotFn = reinterpret_cast<Lookup_t>(
static_cast<uintptr_t>(Offsets::FUN_GROUP_MEMBER_SLOT_LOOKUP));
if (const uint8_t *e = slotFn(&guid)) {
return reinterpret_cast<const uint16_t *>(
e + Offsets::OFF_RAID_SLOT_AURAS);
}
return nullptr;
}
} // namespace Group::MemberStats
+67
View File
@@ -0,0 +1,67 @@
// 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/>.
#pragma once
#include <cstdint>
namespace Group::MemberStats {
// Cached stats for a party/raid member, read from the group roster
// (fed by SMSG_PARTY_MEMBER_STATS). This is the ONLY source for a member with
// no live CGUnit — a groupmate on a different map or otherwise out of range —
// and is exactly what the engine's UnitHealth / UnitMana fall back to. Any
// module that reads unit fields off the descriptor should consult this when
// the descriptor is unavailable so it doesn't report 0 for out-of-range
// groupmates.
//
// `full` distinguishes the two backing structures: the party stats block and
// the raid slot. Both carry health / power / level / area / online; `dead`,
// `subgroup`, and `rank` come only from the raid slot (0 / false on the party
// path). Power values are RAW (divide by the power divisor for display);
// health is the roster u16, matching what UnitHealth returns out of range.
struct Stats {
bool valid = false;
bool full = false; // true = party stats block; false = raid slot
int powerType = 0;
uint32_t health = 0;
uint32_t maxHealth = 0;
uint32_t power = 0; // raw (pre-divisor)
uint32_t maxPower = 0; // raw (pre-divisor)
int level = 0;
int areaId = 0; // AreaTable.dbc ID
bool online = false;
bool dead = false; // raid slot only
int subgroup = 0; // raid slot only, 1-based (0 = party path)
int rank = 0; // raid slot only
};
// Look up a member by GUID. Tries the party stats block, then the raid slot,
// mirroring the engine's UnitHealth/UnitMana out-of-range fallback. Returns an
// invalid `Stats` (`valid == false`) when the GUID isn't a rostered member.
// Pure — no Lua, no throw; safe from any context.
Stats Lookup(uint64_t guid);
// Pointer to an out-of-range groupmate's 48-slot aura spell-ID array (buffs
// 0..31, debuffs 32..47), or nullptr if `guid` isn't a rostered member — or is
// an *offline* party member (the engine surfaces no auras for those). Mirrors
// the engine's own out-of-range fallback in Script_UnitBuff/Script_UnitDebuff
// exactly: the party stats block at +OFF_GROUP_MEMBER_STATS_AURAS gated on the
// online flag, else the raid slot at +OFF_RAID_SLOT_AURAS. Spell IDs only —
// SMSG_PARTY_MEMBER_STATS carries no caster/duration/stacks, and truncates IDs
// to u16 (custom IDs > 65535 are wrong; same as the built-in UnitBuff). The
// pointer is into live engine memory — read it immediately, don't cache it.
// Pure — no Lua, no throw; safe from any context.
const uint16_t *AuraArray(uint64_t guid);
} // namespace Group::MemberStats
+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
+68 -19
View File
@@ -63,6 +63,14 @@ const char *const kKeyName[6] = {
"LSHIFT", "RSHIFT", "LCTRL", "RCTRL", "LALT", "RALT",
};
// L/R-specific virtual keys, one per bit, for the focus-regain resync.
// `GetAsyncKeyState` accepts the L/R-distinguishing VKs directly (unlike
// the keyboard-message path, where `VK_SHIFT` arrives merged and has to be
// disambiguated via the scancode).
const int kBitVk[6] = {
VK_LSHIFT, VK_RSHIFT, VK_LCONTROL, VK_RCONTROL, VK_LMENU, VK_RMENU,
};
constexpr const char *kModifierStateChanged = "MODIFIER_STATE_CHANGED";
// Static-init reservation — runs before DllMain. The
@@ -72,6 +80,7 @@ const Event::Custom::AutoReserve _reserveModifierStateChanged{kModifierStateChan
uint32_t g_modifierMask = 0;
HHOOK g_msgHook = nullptr;
HHOOK g_wndProcHook = nullptr;
void PushDown(void *L, bool down) {
if (down)
@@ -80,6 +89,28 @@ void PushDown(void *L, bool down) {
Game::Lua::PushNil(L);
}
// Apply a single modifier bit transition: update the cached mask and, on a
// genuine change, fire `MODIFIER_STATE_CHANGED`. Both the keyboard-message
// path and the focus-regain resync funnel through here so the "only
// transitions fire" invariant holds regardless of source.
void SetBit(int bitIdx, bool down) {
const uint32_t bit = 1u << bitIdx;
const bool wasDown = (g_modifierMask & bit) != 0;
if (down == wasDown)
return;
if (down) g_modifierMask |= bit;
else g_modifierMask &= ~bit;
// Slot was assigned when the engine populated its event table; our
// hook on `RebuildEventTable` recorded the index. Lookup at fire
// time rather than caching because `/reload` re-runs the rebuild
// and the index may shift.
const int slot = Event::Custom::Lookup(kModifierStateChanged);
if (slot >= 0)
Event::Custom::Fire(slot, "%s%d", kKeyName[bitIdx], static_cast<int>(down));
}
template <int BitIdx>
int __fastcall Script_IsKeyDown(void *L) {
const bool down = (g_modifierMask & (1u << BitIdx)) != 0;
@@ -131,23 +162,22 @@ void ProcessKeyMessage(UINT msg, WPARAM wParam, LPARAM lParam) {
const int bitIdx = VkToBitIdx(wParam, lParam);
if (bitIdx < 0)
return;
const uint32_t bit = 1u << bitIdx;
const bool wasDown = (g_modifierMask & bit) != 0;
const bool nowDown = (msg == WM_KEYDOWN || msg == WM_SYSKEYDOWN);
if (nowDown == wasDown)
return;
SetBit(bitIdx, nowDown);
}
if (nowDown) g_modifierMask |= bit;
else g_modifierMask &= ~bit;
// Slot was assigned when the engine populated its event table; our
// hook on `RebuildEventTable` recorded the index. Lookup at fire
// time rather than caching because `/reload` re-runs the rebuild
// and the index may shift.
const int slot = Event::Custom::Lookup(kModifierStateChanged);
if (slot >= 0)
Event::Custom::Fire(slot, "%s%d", kKeyName[bitIdx], static_cast<int>(nowDown));
// Reconcile the cached mask with the true physical key state. Needed on
// focus regain: keyboard messages only arrive while WoW has focus, so a
// modifier released while WoW was in the background is never seen by the
// `WH_GETMESSAGE` hook and the bit stays stuck down (and the release event
// never fires). `GetAsyncKeyState` reads global async keyboard state
// regardless of focus, so polling it on activation recovers the missed
// transitions — `SetBit` fires `MODIFIER_STATE_CHANGED` for each drift.
void ResyncFromPhysicalState() {
for (int b = 0; b < 6; ++b) {
const bool down = (GetAsyncKeyState(kBitVk[b]) & 0x8000) != 0;
SetBit(b, down);
}
}
LRESULT CALLBACK GetMsgHook(int code, WPARAM wParam, LPARAM lParam) {
@@ -164,11 +194,30 @@ LRESULT CALLBACK GetMsgHook(int code, WPARAM wParam, LPARAM lParam) {
return CallNextHookEx(nullptr, code, wParam, lParam);
}
// `WM_ACTIVATEAPP` is *sent* (via the window manager), not *posted* to the
// queue, so `WH_GETMESSAGE` never sees it — a `WH_CALLWNDPROC` hook catches
// sent messages before the target WNDPROC runs. On app activation
// (`wParam != FALSE`) we resync from physical key state to recover any
// modifier released while WoW was in the background. Like the message hook,
// this is thread-level (not per-`HWND`), so it survives window recreation.
LRESULT CALLBACK CallWndProcHook(int code, WPARAM wParam, LPARAM lParam) {
if (code == HC_ACTION) {
const CWPSTRUCT *cwp = reinterpret_cast<const CWPSTRUCT *>(lParam);
if (cwp->message == WM_ACTIVATEAPP && cwp->wParam != FALSE)
ResyncFromPhysicalState();
}
return CallNextHookEx(nullptr, code, wParam, lParam);
}
void InstallHook() {
if (g_msgHook != nullptr)
return;
g_msgHook = SetWindowsHookExA(WH_GETMESSAGE, &GetMsgHook, nullptr,
GetCurrentThreadId());
if (g_msgHook == nullptr) {
g_msgHook = SetWindowsHookExA(WH_GETMESSAGE, &GetMsgHook, nullptr,
GetCurrentThreadId());
}
if (g_wndProcHook == nullptr) {
g_wndProcHook = SetWindowsHookExA(WH_CALLWNDPROC, &CallWndProcHook,
nullptr, GetCurrentThreadId());
}
}
void RegisterLuaFunctions() {
+5 -17
View File
@@ -55,6 +55,7 @@
#include "item/Data.h"
#include "item/ID.h"
#include "item/Location.h"
#include "unit/Identity.h"
#include <algorithm>
#include <cstdint>
@@ -153,21 +154,11 @@ ItemInfo ReadItemInfo(const uint8_t *cgItem) {
// Bank-walk helpers — bypass the bank-window gate on `GetItemBySlot`
// by reading the player's invMgr GUID array directly. Same shape as
// `C_Item.GetItemCount`'s bank path.
using ResolveUnitToken_t = void *(__fastcall *)(const char *token);
using ResolveObjectByGuid_t = void *(__fastcall *)(int type,
const char *debugName,
uint32_t guidLo,
uint32_t guidHi,
int priority);
using GetBagInvMgr_t = void *(__thiscall *)(void *bag);
const uint8_t *PlayerInvMgr() {
auto resolveUnit = reinterpret_cast<ResolveUnitToken_t>(Offsets::FUN_RESOLVE_UNIT_TOKEN);
auto *player = static_cast<uint8_t *>(resolveUnit("player"));
if (player == nullptr)
return nullptr;
return player + Offsets::OFF_PLAYER_INVENTORY_MANAGER;
}
const uint8_t *ResolveByGuid(int type, uint64_t guid) {
if (guid == 0)
@@ -229,7 +220,7 @@ void WalkGuidArrayRange(const uint8_t *invMgr, int firstSlot, int lastSlot,
// player invMgr hold the bank bags themselves; each bag has its own
// invMgr accessed via the bag CGContainer's vtable+0x10 method).
void WalkBankBags(std::vector<Candidate> &candidates) {
auto *playerInvMgr = PlayerInvMgr();
auto *playerInvMgr = Unit::Identity::PlayerInventoryManager();
if (playerInvMgr == nullptr)
return;
auto *playerGuidArray = *reinterpret_cast<const uint64_t *const *>(
@@ -242,11 +233,8 @@ void WalkBankBags(std::vector<Candidate> &candidates) {
playerGuidArray[slot]);
if (bag == nullptr)
continue;
auto *vtable = *reinterpret_cast<const uint8_t *const *>(bag);
auto getInvMgr = reinterpret_cast<GetBagInvMgr_t>(
*reinterpret_cast<const uintptr_t *>(vtable + 0x10));
auto *bagInvMgr = static_cast<const uint8_t *>(
getInvMgr(const_cast<uint8_t *>(bag)));
auto *bagInvMgr =
static_cast<const uint8_t *>(Item::Location::ContainerInventory(bag));
if (bagInvMgr == nullptr)
continue;
const int bagSlots =
@@ -295,7 +283,7 @@ static int __fastcall Script_GetAverageItemLevel(void *L) {
// session — GUIDs are populated from server data at login. Main
// bank first (linear slots 39..62 in the player invMgr), then
// each equipped bank bag's contents via the per-bag CGContainer.
const uint8_t *playerInvMgr = PlayerInvMgr();
const uint8_t *playerInvMgr = Unit::Identity::PlayerInventoryManager();
WalkGuidArrayRange(playerInvMgr,
Offsets::INVMGR_BANK_MAIN_FIRST_SLOT,
Offsets::INVMGR_BANK_MAIN_LAST_SLOT,
+80
View File
@@ -0,0 +1,80 @@
// 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/>.
#include "item/BagFamily.h"
#include "Offsets.h"
namespace Item::BagFamily {
namespace {
// Chained at static-init by each `AutoResolver` constructor, before
// `DllMain`. Zero-initialized (namespace static) ahead of any dynamic
// init, so the head is valid when resolvers link in.
AutoResolver *g_resolvers = nullptr;
// Stock vanilla bags/quivers that shipped with an empty `m_bagFamily`
// field, recovered from (class, subclass). Correspondence cross-referenced
// by name between ItemSubClass.dbc and ItemBagFamily.dbc. Everything else
// in stock vanilla either carries the field directly or has no family, so
// it isn't listed — server-custom bags come in through the resolver chain.
uint32_t StockSubclassToId(uint32_t itemClass, uint32_t subclass) {
if (itemClass == kItemClassContainer) {
switch (subclass) {
case 1: return 3; // Soul Bag → Soul Shards
case 2: return 6; // Herb Bag → Herbs
case 3: return 7; // Enchanting Bag → Enchanting Supplies
case 4: return 8; // Engineering Bag → Engineering Supplies
default: return 0;
}
}
if (itemClass == kItemClassQuiver) {
switch (subclass) {
case 2: return 1; // Quiver → Arrows
case 3: return 2; // Ammo Pouch → Bullets
default: return 0;
}
}
return 0;
}
} // namespace
AutoResolver::AutoResolver(Resolver f) : fn(f), next(g_resolvers) {
g_resolvers = this;
}
uint32_t SubclassToId(uint32_t itemClass, uint32_t subclass) {
if (const uint32_t id = StockSubclassToId(itemClass, subclass))
return id;
for (AutoResolver *r = g_resolvers; r != nullptr; r = r->next)
if (const uint32_t id = r->fn(itemClass, subclass))
return id;
return 0;
}
uint32_t BitmaskForRecord(const uint8_t *record) {
uint32_t rawId = *reinterpret_cast<const uint32_t *>(
record + Offsets::OFF_ITEMSTATS_BAG_FAMILY);
if (rawId == 0) {
const uint32_t cls = *reinterpret_cast<const uint32_t *>(
record + Offsets::OFF_ITEMSTATS_CLASS);
const uint32_t sub = *reinterpret_cast<const uint32_t *>(
record + Offsets::OFF_ITEMSTATS_SUBCLASS);
rawId = SubclassToId(cls, sub);
}
return IdToBitmask(rawId);
}
} // namespace Item::BagFamily
+72
View File
@@ -0,0 +1,72 @@
// 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/>.
#pragma once
#include <cstdint>
// Shared BagFamily derivation for a cached `ItemStats_C` record. Used by
// `C_Item.GetItemFamily` and `C_Container.GetContainerNumFreeSlots` /
// `CalculateTotalNumberOfFreeBagSlots` so the family a bag reports matches
// the family the items it holds report.
//
// This module knows only STOCK vanilla data. Server-custom container
// families (e.g. Turtle's cooking/gathering bags) plug in through the
// `AutoResolver` extension point below, so non-stock hardcoded data lives
// in its own module (see `src/turtle/`) rather than here.
namespace Item::BagFamily {
// Item.dbc `m_class` values. Containers hold the profession/soul bags;
// Quivers hold arrows (subclass 2) and bullets/ammo pouches (subclass 3).
// See ItemClass.dbc.
constexpr uint32_t kItemClassContainer = 1;
constexpr uint32_t kItemClassQuiver = 11;
// Convert 1.12's raw 1-based `ItemBagFamily.dbc` ID to the modern bitmask
// encoding (`1 << (id - 1)`); `0` stays `0`. Vanilla stores the raw ID
// (`arrow=1, bullet=2, soul shard=3, herb=6, …`); Wrath+ flipped the same
// field to the bitmask form, which is what backported addons expect.
inline uint32_t IdToBitmask(uint32_t rawId) {
return (rawId == 0) ? 0 : (1u << (rawId - 1));
}
// (class, subclass) → `ItemBagFamily.dbc` raw ID, for bags/quivers whose
// `m_bagFamily` field shipped empty. Covers the stock vanilla profession
// bags (Container class: Soul/Herb/Enchanting/Engineering) and quivers
// (Quiver class: Quiver→arrows, Ammo Pouch→bullets); for anything else it
// consults the registered custom resolvers (see `AutoResolver`) before
// giving up with `0`. Keyed on class because subclass numbers repeat
// across classes (Container subclass 2 = Herb Bag, Quiver subclass 2 =
// Quiver). Returns a raw family ID, not a bitmask.
uint32_t SubclassToId(uint32_t itemClass, uint32_t subclass);
// BagFamily bitmask for a cached `ItemStats_C` record: the item's
// `m_bagFamily` field if populated, otherwise — for a container whose
// field is empty — derived from the container subclass. Pass a non-null
// record.
uint32_t BitmaskForRecord(const uint8_t *record);
// Extension point for server-custom bag families. A resolver maps a
// (class, subclass) pair to an `ItemBagFamily.dbc` raw ID, or returns `0`
// if it doesn't recognize it. Consulted by `SubclassToId` only after the
// stock table misses. Declare a file-scope
// `static const Item::BagFamily::AutoResolver` in a platform module to
// register one at static-init time.
using Resolver = uint32_t (*)(uint32_t itemClass, uint32_t subclass);
struct AutoResolver {
explicit AutoResolver(Resolver fn);
Resolver fn;
AutoResolver *next;
};
} // namespace Item::BagFamily
+95
View File
@@ -0,0 +1,95 @@
// 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_Item.IsConsumableItem(item)` — true iff the item is a consumable:
// item class `Consumable` (0), or an inventory type of `INVTYPE_AMMO`
// (24) / `INVTYPE_THROWN` (25) — ammo and thrown weapons being consumed
// in use. `item` is an itemID, `"item:NNN"` string, or item link (names
// aren't resolvable in vanilla → false).
//
// Definition established by decoding 3.3.5's `Script_IsConsumableItem`
// (`0x005173E0`) and then verifying the surviving contract against a
// modern client with `/dump`: it is NOT 3.3.5's on-use-spell-effect walk
// (an on-use trinket like Talisman of Ephemeral Power returns *false*),
// but a plain class-or-ammo/thrown check. Confirmed true: Minor Healing
// Potion / Linen Bandage / Faintly Glowing Skull (all class 0), Rough
// Arrow (Ammo). Confirmed false: Hearthstone (class Misc, valueless
// teleport), on-use trinkets, plain armor.
//
// Cache-miss handling mirrors `C_Item.IsEquippableItem`: returns false
// without firing a load (sync call, matches modern semantics). Callers
// that want auto-warm behavior should run `C_Item.RequestLoadItemDataByID`
// first and re-check on `ITEM_DATA_LOAD_RESULT`.
#include "Game.h"
#include "Offsets.h"
#include "item/Arg.h"
#include <cstdint>
namespace Item::Consumable {
namespace {
using GetItemRecord_t = const uint8_t *(__thiscall *)(void *cache, uint32_t itemID,
const uint64_t *guid, void *callback,
void *userData, int unused);
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);
}
constexpr uint32_t kItemClassConsumable = 0;
constexpr uint32_t kInvTypeAmmo = 24; // INVTYPE_AMMO
constexpr uint32_t kInvTypeThrown = 25; // INVTYPE_THROWN
int __fastcall Script_C_Item_IsConsumableItem(void *L) {
const int itemID = Item::Arg::ResolveItemID(L, 1);
if (itemID <= 0) {
Game::Lua::PushBoolean(L, 0);
return 1;
}
const uint8_t *record = PeekItemRecord(static_cast<uint32_t>(itemID));
if (record == nullptr) {
Game::Lua::PushBoolean(L, 0);
return 1;
}
const uint32_t classID = *reinterpret_cast<const uint32_t *>(
record + Offsets::OFF_ITEMSTATS_CLASS);
const uint32_t invType = *reinterpret_cast<const uint32_t *>(
record + Offsets::OFF_ITEMSTATS_INVENTORY_TYPE);
const bool consumable = classID == kItemClassConsumable ||
invType == kInvTypeAmmo || invType == kInvTypeThrown;
Game::Lua::PushBoolean(L, consumable);
return 1;
}
void RegisterLuaFunctions() {
// Modern exposes both the namespaced `C_Item.IsConsumableItem` and the
// bare global `IsConsumableItem` (the older, still-live form); register
// both against the same handler, like `IsUsableItem`.
Game::Lua::RegisterTableFunction("C_Item", "IsConsumableItem",
&Script_C_Item_IsConsumableItem);
Game::Lua::RegisterGlobalFunction("IsConsumableItem",
&Script_C_Item_IsConsumableItem);
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace
} // namespace Item::Consumable
+16 -19
View File
@@ -11,11 +11,14 @@
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
#include "item/Count.h"
#include "Game.h"
#include "Offsets.h"
#include "item/Arg.h"
#include "item/ID.h"
#include "item/Location.h"
#include "unit/Identity.h"
#include <cstdint>
@@ -94,22 +97,12 @@ int CountInBag(void *L, int bagID, int targetItemID, bool includeUses) {
// object resolver `FUN_OBJECT_RESOLVE_BY_GUID` (the same function
// `GetItemBySlot` would call internally if the gate let us through).
using ResolveUnitToken_t = void *(__fastcall *)(const char *token);
using ResolveObjectByGuid_t = void *(__fastcall *)(int type,
const char *debugName,
uint32_t guidLo,
uint32_t guidHi,
int priority);
const uint8_t *PlayerInvMgr() {
auto ResolveUnitToken =
reinterpret_cast<ResolveUnitToken_t>(Offsets::FUN_RESOLVE_UNIT_TOKEN);
auto *player = static_cast<uint8_t *>(ResolveUnitToken("player"));
if (player == nullptr)
return nullptr;
return player + Offsets::OFF_PLAYER_INVENTORY_MANAGER;
}
const uint8_t *ResolveByGuid(int type, uint64_t guid) {
if (guid == 0)
return nullptr;
@@ -155,7 +148,7 @@ int CountInGuidArray(const uint8_t *invMgr, int firstSlot, int lastSlot,
// Layout: bag invMgr+0x00 = max slot count (the bag's size); bag
// invMgr+0x04 = its GUID array. Same shape as the player invMgr.
int CountInBankBags(int targetItemID, bool includeUses) {
auto *playerInvMgr = PlayerInvMgr();
auto *playerInvMgr = Unit::Identity::PlayerInventoryManager();
if (playerInvMgr == nullptr)
return 0;
auto *playerGuidArray = *reinterpret_cast<const uint64_t *const *>(
@@ -164,19 +157,14 @@ int CountInBankBags(int targetItemID, bool includeUses) {
return 0;
int total = 0;
using GetBagInvMgr_t = void *(__thiscall *)(void *bag);
for (int slot = Offsets::INVMGR_BANK_BAG_FIRST_SLOT;
slot <= Offsets::INVMGR_BANK_BAG_LAST_SLOT; slot++) {
const uint8_t *bag = ResolveByGuid(Offsets::OBJ_TYPE_CONTAINER,
playerGuidArray[slot]);
if (bag == nullptr)
continue;
// vtable +0x10 returns the bag's own invMgr pointer
auto *vtable = *reinterpret_cast<const uint8_t *const *>(bag);
auto getInvMgr = reinterpret_cast<GetBagInvMgr_t>(
*reinterpret_cast<const uintptr_t *>(vtable + 0x10));
auto *bagInvMgr = static_cast<const uint8_t *>(
getInvMgr(const_cast<uint8_t *>(bag)));
auto *bagInvMgr =
static_cast<const uint8_t *>(Item::Location::ContainerInventory(bag));
if (bagInvMgr == nullptr)
continue;
const int bagSlots = static_cast<int>(*reinterpret_cast<const uint32_t *>(bagInvMgr));
@@ -251,7 +239,7 @@ int __fastcall Script_C_Item_GetItemCount(void *L) {
// Direct GUID-array reads — bypass the bank gate so this works
// without the bank window having ever been opened in the
// session.
total += CountInGuidArray(PlayerInvMgr(),
total += CountInGuidArray(Unit::Identity::PlayerInventoryManager(),
Offsets::INVMGR_BANK_MAIN_FIRST_SLOT,
Offsets::INVMGR_BANK_MAIN_LAST_SLOT,
itemID, includeUses);
@@ -265,6 +253,15 @@ int __fastcall Script_C_Item_GetItemCount(void *L) {
} // namespace
int InInventory(void *L, int itemID) {
if (itemID <= 0)
return 0;
int total = CountEquipped(itemID, /*includeUses*/ false);
for (int bag = 0; bag <= 4; bag++)
total += CountInBag(L, bag, itemID, /*includeUses*/ false);
return total;
}
static void RegisterLuaFunctions() {
Game::Lua::RegisterTableFunction("C_Item", "GetItemCount",
&Script_C_Item_GetItemCount);
+25
View File
@@ -0,0 +1,25 @@
// 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/>.
#pragma once
namespace Item::Count {
// Total plain stack count of `itemID` across the player's equipped slots
// and bags 0..4 (no bank, no charge multiplier) — the "carried on me"
// count. Needs the Lua state `L`: the bag walk uses the engine's slot
// packer, which reads and clears the Lua stack, so the caller must have
// already consumed any stack arguments it still needs before calling.
int InInventory(void *L, int itemID);
} // namespace Item::Count
+91
View File
@@ -0,0 +1,91 @@
// 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/>.
#include "item/Cursor.h"
#include "Game.h"
#include "Offsets.h"
#include "cursor/Info.h"
#include "item/Location.h"
#include <cstdint>
namespace Item::Cursor {
namespace {
using PickupInventorySlot_t = void(__fastcall *)(uint32_t slot0Based);
using CursorPickupItem_t = void(__fastcall *)(uint32_t linearSlot, int count,
uint32_t itemGuidLo, uint32_t itemGuidHi,
uint32_t containerGuidLo,
uint32_t containerGuidHi, int flag);
using LockByGuid_t = void(__stdcall *)(uint32_t guidLo, uint32_t guidHi);
// The container object stores its own GUID inline at +0x08/+0x0C (unlike a
// CGItem, whose GUID sits behind the +0x08 instance-block pointer). This
// is the "source container" the engine records for the eventual drop.
constexpr int OFF_CONTAINER_GUID_LO = 0x08;
constexpr int OFF_CONTAINER_GUID_HI = 0x0C;
bool ItemLocked(const uint8_t *item) {
return (*reinterpret_cast<const uint32_t *>(item + Offsets::OFF_ITEM_CLIENT_LOCK) & 1) != 0;
}
// Reads a CGItem's 64-bit instance GUID into lo/hi. False if the instance
// block pointer is absent.
bool ReadItemGuid(const uint8_t *item, uint32_t *lo, uint32_t *hi) {
auto *instance = *reinterpret_cast<const uint8_t *const *>(
item + Offsets::OFF_ITEM_INSTANCE_BLOCK);
if (instance == nullptr)
return false;
*lo = *reinterpret_cast<const uint32_t *>(instance + Offsets::OFF_INSTANCE_BLOCK_GUID);
*hi = *reinterpret_cast<const uint32_t *>(instance + Offsets::OFF_INSTANCE_BLOCK_GUID + 4);
return true;
}
} // namespace
void PickupEquipmentSlot(int equipmentSlotIndex) {
// The primitive takes a 0-based slot; modern equipmentSlotIndex is 1-based.
reinterpret_cast<PickupInventorySlot_t>(Offsets::FUN_PICKUP_INVENTORY_SLOT)(
static_cast<uint32_t>(equipmentSlotIndex - 1));
}
bool PickupBagItem(void *L, int bagID, int slotIndex) {
Item::Location::BagSlotDetail d;
if (!Item::Location::ResolveBagSlotDetail(L, bagID, slotIndex, &d) || d.item == nullptr)
return false;
// Guards the Script_ wrapper applies that the raw primitive omits: only
// pick up onto an empty cursor, and never a locked (mid-transaction) item.
// `::Cursor` — unqualified `Cursor` would bind to `Item::Cursor` here.
if (::Cursor::Info::HasItem() || ItemLocked(d.item))
return false;
uint32_t itemLo = 0, itemHi = 0;
if (!ReadItemGuid(d.item, &itemLo, &itemHi))
return false;
const uint32_t containerLo =
*reinterpret_cast<const uint32_t *>(d.container + OFF_CONTAINER_GUID_LO);
const uint32_t containerHi =
*reinterpret_cast<const uint32_t *>(d.container + OFF_CONTAINER_GUID_HI);
reinterpret_cast<CursorPickupItem_t>(Offsets::FUN_CURSOR_PICKUP_ITEM)(
static_cast<uint32_t>(d.linearSlot), 1, itemLo, itemHi, containerLo, containerHi, 0);
// Visually lock the source item while it's held — the Script pickup
// does this immediately after the cursor primitive.
reinterpret_cast<LockByGuid_t>(Offsets::FUN_ITEM_LOCK_BY_GUID)(itemLo, itemHi);
return true;
}
} // namespace Item::Cursor
+35
View File
@@ -0,0 +1,35 @@
// 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/>.
#pragma once
// Cursor pickup primitives — thin C++ wrappers over the engine's own
// pickup functions (`FUN_PICKUP_INVENTORY_SLOT` / `FUN_CURSOR_PICKUP_ITEM`),
// called directly rather than through the Lua `Script_Pickup*` state-machine
// wrappers (which would require faking the Lua stack). Shared by
// `C_Item.PickupItem` and `C_Item.EquipItemByName`'s auto-slot path.
namespace Item::Cursor {
// Picks the item in a 1-based character-pane equipment slot onto the
// cursor via the engine's self-contained equipment-pickup primitive
// (which resolves the player and applies its own cursor/lock guards).
void PickupEquipmentSlot(int equipmentSlotIndex);
// Picks the item in a bag slot onto the cursor. Guarded to match the Lua
// wrapper's behavior: no-op (returns false) when the slot is empty, the
// cursor already holds an item, or the item is locked (mid-transaction).
// Returns true when the pickup was issued. Needs `L` for slot resolution
// (PackBagSlot); stomps the Lua stack.
bool PickupBagItem(void *L, int bagID, int slotIndex);
} // namespace Item::Cursor
+15 -36
View File
@@ -14,12 +14,12 @@
#include "Game.h"
#include "Offsets.h"
#include "item/Arg.h"
#include "item/Cursor.h"
#include "item/ID.h"
#include "item/Location.h"
#include "item/Swap.h"
#include <cstdint>
#include <string.h>
namespace Item::Equipment {
@@ -132,31 +132,13 @@ int __fastcall Script_C_Item_IsEquippedItem(void *L) {
return 1;
}
// Equipment-first walk; itemID or decorated-name match via the shared
// `Item::Location::MatchesArg` predicate (same logic the bag find-by-
// name uses, so match semantics live in one place).
for (int slot = Offsets::EQUIPMENT_SLOT_FIRST; slot <= Offsets::EQUIPMENT_SLOT_LAST; ++slot) {
auto *item = Item::Location::ResolveEquipmentSlot(slot);
if (item == nullptr) continue;
const int id = Item::ID::FromCGItem(item);
if (id == 0) continue;
if (arg.itemID > 0) {
if (id == arg.itemID) {
Game::Lua::PushBoolean(L, 1);
return 1;
}
continue;
}
// Name-match path — peek the item-cache record and compare
// `m_name[0]`. Uncached items short-circuit to "no match"
// rather than firing a load; matches modern API semantics
// (sync call, possibly stale, never blocks).
auto *record = PeekItemRecord(static_cast<uint32_t>(id));
if (record == nullptr) continue;
const char *name = *reinterpret_cast<const char *const *>(
record + Offsets::OFF_ITEMSTATS_NAME);
if (name == nullptr) continue;
if (_stricmp(name, arg.name) == 0) {
if (Item::Location::MatchesArg(item, arg)) {
Game::Lua::PushBoolean(L, 1);
return 1;
}
@@ -200,8 +182,6 @@ int __fastcall Script_C_Item_EquipItemByName(void *L) {
const bool hasDstSlot = Game::Lua::IsNumber(L, 2);
const int dstSlot = hasDstSlot ? static_cast<int>(Game::Lua::ToNumber(L, 2)) : 0;
using ScriptFn_t = int(__fastcall *)(void *L);
// Return any held cursor item to its slot BEFORE searching for
// the target. FUN_INVENTORY_SWAP's end-of-call cleanup at
// FUN_00495190(0, 1) clears LOCAL cursor globals but skips the
@@ -222,17 +202,16 @@ int __fastcall Script_C_Item_EquipItemByName(void *L) {
return 0;
}
// Auto-slot path: pickup the item via Script_PickupContainerItem
// (its state-machine handles spell-cast targeting / repair /
// enchant-scroll / etc., none of which we want to replicate), then
// call the engine's `CGPlayer::AutoEquipCursorItem` helper directly
// — `Script_AutoEquipCursorItem` is a thin wrapper that just
// resolves the local player and calls this with flag=0.
Game::Lua::SetTop(L, 0);
Game::Lua::PushNumber(L, static_cast<double>(found.bagID));
Game::Lua::PushNumber(L, static_cast<double>(found.slotIndex));
auto pickup = reinterpret_cast<ScriptFn_t>(Offsets::FUN_SCRIPT_PICKUP_CONTAINER_ITEM);
pickup(L);
// Auto-slot path: pick the item up onto the cursor, then call the
// engine's `CGPlayer::AutoEquipCursorItem` helper directly
// (`Script_AutoEquipCursorItem` is a thin wrapper that just resolves
// the local player and calls this with flag=0). The pickup goes through
// the shared `Item::Cursor::PickupBagItem`, which drives the engine's
// cursor primitive directly (no Script_PickupContainerItem stack-fake);
// its empty-cursor guard is satisfied by the ClearCursor above. Bail if
// the pickup didn't take (locked item / busy cursor) — nothing to equip.
if (!Item::Cursor::PickupBagItem(L, found.bagID, found.slotIndex))
return 0;
using ResolveUnitToken_t = void *(__fastcall *)(const char *token);
using AutoEquipCursor_t = void (__thiscall *)(void *player, int flag);
+74
View File
@@ -0,0 +1,74 @@
// 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_Item.IsItemInRange(item, targetUnit)` — true if the item is in
// range of the unit, false if out of range, nil when the range check
// doesn't apply. `item` is an itemID, `"item:NNN"` string, or item link
// (names aren't resolvable — vanilla has no name→ID map — and yield nil);
// `targetUnit` is a unit token ("target", "mouseover", "party1", …).
//
// An item's range comes from the spell it fires on use: we resolve the
// item to its on-use spell (`Item::Spell::OnUseSpellIDForItemID`) and run
// the same range test as `C_Spell.IsSpellInRange`, via the shared
// `Spell::Range::PlayerVsUnit` core. So items with a targeted on-use
// effect that has a range (nets, bombs, thrown/targeted quest items, …)
// get a true/false answer; items with no on-use spell, or whose on-use
// spell is rangeless (potions, food, self-buff trinkets, hearthstone),
// return nil — matching retail, which also reports nil for items without
// a range restriction.
//
// Range-only, like `IsSpellInRange`: ignores line of sight and target
// faction. Passive reader — an item not yet in the client item cache
// returns nil (no background fetch); items you'd range-check are normally
// in bags / on the action bar and already cached.
#include "Game.h"
#include "item/Arg.h"
#include "item/Spell.h"
#include "spell/Range.h"
#include <cstdint>
namespace Item::InRange {
namespace {
int __fastcall Script_C_Item_IsItemInRange(void *L) {
const int itemID = Item::Arg::ResolveItemID(L, 1);
const int spellID =
itemID > 0 ? Item::Spell::OnUseSpellIDForItemID(
static_cast<uint32_t>(itemID))
: 0;
const char *unit =
Game::Lua::IsString(L, 2) ? Game::Lua::ToString(L, 2) : nullptr;
// `::Spell` — unqualified `Spell` would bind to `Item::Spell` here.
const int result = ::Spell::Range::PlayerVsUnit(spellID, unit);
if (result < 0) {
Game::Lua::PushNil(L);
return 1;
}
Game::Lua::PushBool(L, result == 1);
return 1;
}
void RegisterLuaFunctions() {
Game::Lua::RegisterTableFunction("C_Item", "IsItemInRange",
&Script_C_Item_IsItemInRange);
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace
} // namespace Item::InRange
+96 -18
View File
@@ -15,8 +15,10 @@
#include "Offsets.h"
#include "dbc/Lookup.h"
#include "item/Arg.h"
#include "item/BagFamily.h"
#include "item/Data.h"
#include "item/ID.h"
#include "item/Link.h"
#include "item/Location.h"
#include <cstdint>
@@ -191,17 +193,6 @@ static int __fastcall Script_C_Item_GetItemIconByID(void *L) {
return PushIconForItemID(L, Item::Arg::ResolveItemID(L, 1));
}
// Convert 1.12's raw `BagFamily` ID to the modern bitmask encoding.
// Vanilla stores `arrow=1, bullet=2, soul shard=3, herb=6, ...`; Wrath
// flipped to `1 << (ID-1)` (`arrow=0x1, bullet=0x2, soul shard=0x4,
// herb=0x20, ...`) and addons backported from Wrath+ expect the
// bitmask. We always surface the modern shape so callers don't have to
// branch on client version. See `Offsets::OFF_ITEMSTATS_BAG_FAMILY`
// for the empirical verification.
uint32_t BagFamilyIdToBitmask(uint32_t rawId) {
return (rawId == 0) ? 0 : (1u << (rawId - 1));
}
// `C_Item.GetItemFamily(item)` — returns the BagFamily bitmask for an
// item (modern encoding: `1 << (ID-1)`), or `nil` if the item isn't
// in the cache. Used by auto-routing addons that decide which
@@ -226,10 +217,14 @@ uint32_t BagFamilyIdToBitmask(uint32_t rawId) {
// but has family 0 (general-purpose)". Both are safe to treat as "no
// preference" but keeping them distinct helps debugging.
//
// 1.12 vanilla server data sparsely populates the field for **bags**
// themselves (only quivers seem to carry it on Turtle WoW). Individual
// loot items do carry the field reliably (arrows, bullets, shards,
// herbs all return their proper raw IDs).
// 1.12 vanilla server data leaves the `m_bagFamily` field empty on the
// **bags/quivers** themselves — Soul/Herb/Enchanting/Engineering bags AND
// quivers/ammo pouches all read 0. Individual loot items do carry the
// field reliably (arrows, bullets, shards, herbs return their proper raw
// IDs). For a bag whose field is empty we recover the family from its
// class + subclass (the same signal the "Soul Bag" tooltip line uses), so
// a Felcloth Bag reports the Soul Shard family (0x4), a quiver 0x1, an
// ammo pouch 0x2 — see `Item::BagFamily`.
static int __fastcall Script_C_Item_GetItemFamily(void *L) {
const int itemID = Item::Arg::ResolveItemID(L, 1);
if (itemID <= 0)
@@ -239,13 +234,96 @@ static int __fastcall Script_C_Item_GetItemFamily(void *L) {
Item::Data::WarmCache(static_cast<uint32_t>(itemID));
return 0;
}
const uint32_t rawId = *reinterpret_cast<const uint32_t *>(
record + Offsets::OFF_ITEMSTATS_BAG_FAMILY);
Game::Lua::PushNumber(L, static_cast<double>(BagFamilyIdToBitmask(rawId)));
// Family field if populated, else derived from the container subclass
// (Turtle leaves bags' m_bagFamily at 0 — see Item::BagFamily).
Game::Lua::PushNumber(
L, static_cast<double>(Item::BagFamily::BitmaskForRecord(record)));
return 1;
}
// `C_Item.GetItemInfo(itemInfo)` — the full modern info tuple, sourced
// entirely from the cached `ItemStats_C` record (+ the class/subclass/invtype
// DBC name lookups this file already does for `GetItemInfoInstant`). Accepts
// a numeric itemID, an `"item:NNN"` string, or a full chat link — same parser
// as `GetItemInfoInstant`.
//
// Returns (18 values):
// itemName, itemLink, itemQuality, itemLevel, itemMinLevel, itemType,
// itemSubType, itemStackCount, itemEquipLoc, itemTexture, sellPrice,
// classID, subclassID, bindType, expansionID, setID, isCraftingReagent,
// itemDescription
//
// `expansionID` is always 0 (vanilla) and `isCraftingReagent` always false
// (no such flag in 1.12's item data); `setID` is nil when the item has no
// set. On a cache miss it warms the cache and returns nothing (nil) —
// the value lands on a retry after `GET_ITEM_INFO_RECEIVED`,
// matching the modern async contract.
static int __fastcall Script_C_Item_GetItemInfo(void *L) {
const int itemID = Item::Arg::ResolveItemID(L, 1);
if (itemID <= 0)
return 0;
const uint8_t *record = FetchItemRecord(static_cast<uint32_t>(itemID));
if (record == nullptr) {
Item::Data::WarmCache(static_cast<uint32_t>(itemID));
return 0; // nil this call; GET_ITEM_INFO_RECEIVED fires when ready
}
auto u32 = [record](int off) {
return *reinterpret_cast<const uint32_t *>(record + off);
};
const uint32_t quality = u32(Offsets::OFF_ITEMSTATS_QUALITY);
const uint32_t itemLevel = u32(Offsets::OFF_ITEMSTATS_ITEM_LEVEL);
const uint32_t reqLevel = u32(Offsets::OFF_ITEMSTATS_REQUIRED_LEVEL);
const uint32_t classID = u32(Offsets::OFF_ITEMSTATS_CLASS);
const uint32_t subClassID = u32(Offsets::OFF_ITEMSTATS_SUBCLASS);
const uint32_t stackCount = u32(Offsets::OFF_ITEMSTATS_STACKABLE);
const uint32_t invType = u32(Offsets::OFF_ITEMSTATS_INVENTORY_TYPE);
const uint32_t displayInfoID = u32(Offsets::OFF_ITEMSTATS_DISPLAY_INFO_ID);
const uint32_t sellPrice = u32(Offsets::OFF_ITEMSTATS_SELL_PRICE);
const uint32_t bindType = u32(Offsets::OFF_ITEMSTATS_BONDING);
const uint32_t setID = u32(Offsets::OFF_ITEMSTATS_ITEM_SET);
const char *name = *reinterpret_cast<const char *const *>(
record + Offsets::OFF_ITEMSTATS_NAME);
const char *description = *reinterpret_cast<const char *const *>(
record + Offsets::OFF_ITEMSTATS_DESCRIPTION);
char iconPath[260];
if (!BuildIconPath(displayInfoID, iconPath, sizeof(iconPath)))
iconPath[0] = 0;
char link[256];
const bool haveLink = Item::Link::BasicFromItemID(static_cast<uint32_t>(itemID),
link, sizeof(link));
Game::Lua::PushString(L, PushedOrEmpty(name)); // 1 itemName
if (haveLink)
Game::Lua::PushString(L, link); // 2 itemLink
else
Game::Lua::PushNil(L);
Game::Lua::PushNumber(L, static_cast<double>(quality)); // 3 itemQuality
Game::Lua::PushNumber(L, static_cast<double>(itemLevel)); // 4 itemLevel
Game::Lua::PushNumber(L, static_cast<double>(reqLevel)); // 5 itemMinLevel
Game::Lua::PushString(L, LookupItemClassName(classID)); // 6 itemType
Game::Lua::PushString(L, LookupItemSubClassName(classID, subClassID)); // 7 itemSubType
Game::Lua::PushNumber(L, static_cast<double>(stackCount)); // 8 itemStackCount
Game::Lua::PushString(L, LookupInvType(invType)); // 9 itemEquipLoc
Game::Lua::PushString(L, iconPath); // 10 itemTexture
Game::Lua::PushNumber(L, static_cast<double>(sellPrice)); // 11 sellPrice
Game::Lua::PushNumber(L, static_cast<double>(classID)); // 12 classID
Game::Lua::PushNumber(L, static_cast<double>(subClassID)); // 13 subclassID
Game::Lua::PushNumber(L, static_cast<double>(bindType)); // 14 bindType
Game::Lua::PushNumber(L, 0.0); // 15 expansionID (classic)
if (setID != 0)
Game::Lua::PushNumber(L, static_cast<double>(setID)); // 16 setID
else
Game::Lua::PushNil(L);
Game::Lua::PushBool(L, false); // 17 isCraftingReagent
Game::Lua::PushString(L, PushedOrEmpty(description)); // 18 itemDescription
return 18;
}
static void RegisterLuaFunctions() {
Game::Lua::RegisterTableFunction("C_Item", "GetItemInfo", &Script_C_Item_GetItemInfo);
Game::Lua::RegisterTableFunction("C_Item", "GetItemInfoInstant", &Script_GetItemInfoInstant);
Game::Lua::RegisterGlobalFunction("GetItemIcon", &Script_GetItemIcon);
Game::Lua::RegisterTableFunction("C_Item", "GetItemIcon", &Script_C_Item_GetItemIcon);
+13
View File
@@ -27,6 +27,9 @@ namespace {
using BuildItemLink_t = const char *(__fastcall *)(const void *cgItem);
using BuildInstanceName_t = void(__thiscall *)(const void *cgItem, char *out,
unsigned outSize);
// Cached ItemStats record lookup. Same `_GetRecord` pattern the rest of
// the codebase uses; passes a noop NULL callback so no SMSG fires for
// uncached items — caller treats "not cached" as a clean failure.
@@ -50,6 +53,16 @@ const char *FromCGItem(const uint8_t *cgItem) {
return fn(cgItem);
}
bool NameFromCGItem(const uint8_t *cgItem, char *out, size_t outSize) {
if (cgItem == nullptr || out == nullptr || outSize == 0)
return false;
out[0] = '\0';
auto fn = reinterpret_cast<BuildInstanceName_t>(
Offsets::FUN_ITEM_BUILD_INSTANCE_NAME);
fn(cgItem, out, static_cast<unsigned>(outSize));
return out[0] != '\0';
}
bool BasicFromItemID(uint32_t itemID, char *out, size_t outSize) {
if (out == nullptr || outSize == 0)
return false;
+11
View File
@@ -29,6 +29,17 @@ namespace Item::Link {
// produce for the same slot.
const char *FromCGItem(const uint8_t *cgItem);
// Writes the item's per-instance decorated *display name* into `out` —
// the same `[Name]` the engine stamps into `FromCGItem`'s link, e.g.
// "Iridium Chain of the Owl" (or the base name when the instance carries
// no random suffix). Reads the suffix ID off the CGItem descriptor
// (`+0x98`) and formats it through the engine's own name builder
// (`FUN_ITEM_BUILD_INSTANCE_NAME`). Returns false (and leaves `out`
// empty) for a null item or an empty build result, so callers can fall
// back to the base ItemStats name. Use whenever a name is returned
// alongside a `FromCGItem` link, so the two agree.
bool NameFromCGItem(const uint8_t *cgItem, char *out, size_t outSize);
// Builds a basic itemID-only hyperlink (`"|cffRRGGBB|Hitem:N:0:0:0|h[Name]|h|r"`)
// into `out` from the cached ItemStats record for `itemID`. No
// enchant / random-suffix decoration — use this when the caller has
+102 -75
View File
@@ -16,8 +16,10 @@
#include "../Game.h"
#include "../Offsets.h"
#include "../guid/Guid.h"
#include "../unit/Identity.h"
#include "Arg.h"
#include "ID.h"
#include "Link.h"
#include <cstring>
@@ -28,20 +30,10 @@ namespace {
using GetItemBySlot_t = void *(__thiscall *)(void *thisInvMgr, int slot);
using PackBagSlot_t = int(__fastcall *)(void *L, void **outInvMgr, int *outLinearSlot,
int *outUnused);
using ResolveUnitToken_t = void *(__fastcall *)(const char *token);
using GetItemRecord_t = const uint8_t *(__thiscall *)(void *cache, uint32_t itemID,
const uint64_t *guid, void *callback,
void *userData, int unused);
void *ResolveActivePlayerInvMgr() {
auto ResolveUnitToken =
reinterpret_cast<ResolveUnitToken_t>(Offsets::FUN_RESOLVE_UNIT_TOKEN);
auto *player = static_cast<uint8_t *>(ResolveUnitToken("player"));
if (player == nullptr)
return nullptr;
return player + Offsets::OFF_PLAYER_INVENTORY_MANAGER;
}
// Reads `loc.fieldName` and returns it as an int. Returns false via the
// boolean result if the field is missing or non-numeric. Always leaves the
// Lua stack as it found it.
@@ -56,24 +48,10 @@ bool TryReadIntField(void *L, int locIdx, const char *fieldName, int *out) {
}
const uint8_t *ResolveBagSlot(void *L, int bagID, int slotIndex) {
// PackBagSlot reads bagID/slotIndex from Lua stack[1] and stack[2] (it's
// designed to be called from a Lua-callback context like
// Script_GetContainerItemInfo). Replace the stack so positions 1 and 2
// hold the raw values for PackBagSlot's internal lua_tonumber reads.
Game::Lua::SetTop(L, 0);
Game::Lua::PushNumber(L, static_cast<double>(bagID));
Game::Lua::PushNumber(L, static_cast<double>(slotIndex));
void *invMgr = nullptr;
int linearSlot = 0;
int unused = 0;
auto PackBagSlot = reinterpret_cast<PackBagSlot_t>(Offsets::FUN_PACK_BAG_SLOT);
if (!PackBagSlot(L, &invMgr, &linearSlot, &unused) || invMgr == nullptr)
BagSlotDetail d;
if (!ResolveBagSlotDetail(L, bagID, slotIndex, &d))
return nullptr;
auto GetItemBySlot = reinterpret_cast<GetItemBySlot_t>(
Offsets::FUN_ITEMMGR_GET_ITEM_BY_SLOT);
return static_cast<const uint8_t *>(GetItemBySlot(invMgr, linearSlot));
return d.item;
}
// --- GUID-walk helpers ---------------------------------------------------
@@ -97,7 +75,7 @@ uint64_t ReadCGItemGUID(const uint8_t *item) {
} // namespace
const uint8_t *ResolveEquipmentSlot(int slot1Based) {
void *invMgr = ResolveActivePlayerInvMgr();
void *invMgr = const_cast<uint8_t *>(Unit::Identity::PlayerInventoryManager());
if (invMgr == nullptr)
return nullptr;
// GetItemBySlot expects 0-based linearized slot indices. The built-in
@@ -108,6 +86,34 @@ const uint8_t *ResolveEquipmentSlot(int slot1Based) {
return static_cast<const uint8_t *>(GetItemBySlot(invMgr, slot1Based - 1));
}
void *ContainerInventory(const uint8_t *container) {
if (container == nullptr)
return nullptr;
auto *vtable = *reinterpret_cast<const uint8_t *const *>(container);
using GetInventory_t = void *(__thiscall *)(void *container);
auto getInventory = *reinterpret_cast<const GetInventory_t *>(
vtable + Offsets::OFF_CONTAINER_GET_INVENTORY);
return getInventory(const_cast<uint8_t *>(container));
}
void *EquippedBagInventory(int bagID) {
if (bagID < 1 || bagID > 4)
return nullptr;
using GetBagGuid_t = uint64_t(__fastcall *)(uint32_t bagIndex0);
auto getBagGuid =
reinterpret_cast<GetBagGuid_t>(Offsets::FUN_GET_EQUIPPED_BAG_GUID);
const uint64_t bagGuid = getBagGuid(static_cast<uint32_t>(bagID - 1));
if (bagGuid == 0)
return nullptr; // no bag equipped in that slot
using ResolveByGUID_t = void *(__fastcall *)(int, const char *, uint32_t,
uint32_t, int);
auto resolve = reinterpret_cast<ResolveByGUID_t>(Offsets::FUN_OBJECT_RESOLVE_BY_GUID);
auto *container = static_cast<const uint8_t *>(
resolve(Offsets::OBJ_TYPE_CONTAINER, "ItemMgr", static_cast<uint32_t>(bagGuid),
static_cast<uint32_t>(bagGuid >> 32), 0x172));
return ContainerInventory(container);
}
// Mirrors `Item::Bag::ResolveBagInfo`'s slot-count derivation: backpack
// is fixed at 16, equipped bags read `m_containerSlots` off the cache
// record. Returns 0 for empty bag slots or out-of-range bagIDs.
@@ -129,6 +135,30 @@ int GetBagSlotCount(int bagID) {
record + Offsets::OFF_ITEMSTATS_CONTAINER_SLOTS));
}
bool ResolveBagSlotDetail(void *L, int bagID, int slotIndex, BagSlotDetail *out) {
// PackBagSlot reads bagID/slotIndex from Lua stack[1] and stack[2] (it's
// designed to be called from a Lua-callback context like
// Script_GetContainerItemInfo). Replace the stack so positions 1 and 2
// hold the raw values for PackBagSlot's internal lua_tonumber reads.
Game::Lua::SetTop(L, 0);
Game::Lua::PushNumber(L, static_cast<double>(bagID));
Game::Lua::PushNumber(L, static_cast<double>(slotIndex));
void *container = nullptr;
int linearSlot = 0;
int unused = 0;
auto PackBagSlot = reinterpret_cast<PackBagSlot_t>(Offsets::FUN_PACK_BAG_SLOT);
if (!PackBagSlot(L, &container, &linearSlot, &unused) || container == nullptr)
return false;
auto GetItemBySlot = reinterpret_cast<GetItemBySlot_t>(
Offsets::FUN_ITEMMGR_GET_ITEM_BY_SLOT);
out->container = static_cast<const uint8_t *>(container);
out->linearSlot = linearSlot;
out->item = static_cast<const uint8_t *>(GetItemBySlot(container, linearSlot));
return true;
}
const uint8_t *ResolveBag(void *L, int bagID, int slotIndex) {
return ResolveBagSlot(L, bagID, slotIndex);
}
@@ -152,38 +182,29 @@ const uint8_t *ResolveByGUID(uint64_t guid) {
bool FindByItemID(void *L, int itemID, ByGUIDResult *out) {
if (itemID <= 0)
return false;
Item::Arg::Resolved arg{};
arg.itemID = itemID;
arg.name = nullptr;
return FindByArg(L, arg, out);
}
for (int slot = Offsets::EQUIPMENT_SLOT_FIRST;
slot <= Offsets::EQUIPMENT_SLOT_LAST; ++slot) {
auto *item = ResolveEquipmentSlot(slot);
if (item == nullptr)
continue;
if (Item::ID::FromCGItem(item) == itemID) {
out->equipmentSlotIndex = slot;
out->bagID = 0;
out->slotIndex = 0;
out->item = item;
return true;
}
}
for (int bagID = 0; bagID <= 4; ++bagID) {
const int slotCount = GetBagSlotCount(bagID);
for (int slotIndex = 1; slotIndex <= slotCount; ++slotIndex) {
auto *item = ResolveBag(L, bagID, slotIndex);
if (item == nullptr)
continue;
if (Item::ID::FromCGItem(item) == itemID) {
out->equipmentSlotIndex = 0;
out->bagID = bagID;
out->slotIndex = slotIndex;
out->item = item;
return true;
}
}
}
return false;
bool MatchesArg(const uint8_t *cgItem, const Item::Arg::Resolved &arg) {
const int id = Item::ID::FromCGItem(cgItem);
if (id == 0)
return false;
if (arg.itemID > 0)
return id == arg.itemID;
if (arg.name == nullptr)
return false;
// Compare against the item's *decorated* display name (random suffix
// included) — modern IsEquippedItem / find-by-name match the full name
// only ("Foo of the Owl", not the base "Foo"; verified on the modern
// client). NameFromCGItem falls back to the base name for unsuffixed
// items, so plain items still match; uncached/unnameable → no match.
char name[128];
if (!Item::Link::NameFromCGItem(cgItem, name, sizeof(name)))
return false;
return _stricmp(name, arg.name) == 0;
}
bool FindByArgInBags(void *L, const Item::Arg::Resolved &arg, ByGUIDResult *out) {
@@ -196,24 +217,9 @@ bool FindByArgInBags(void *L, const Item::Arg::Resolved &arg, ByGUIDResult *out)
auto *item = ResolveBag(L, bagID, slotIndex);
if (item == nullptr)
continue;
const int id = Item::ID::FromCGItem(item);
if (id == 0)
if (!MatchesArg(item, arg))
continue;
if (arg.itemID > 0) {
if (id != arg.itemID)
continue;
} else {
auto *record = PeekItemRecord(static_cast<uint32_t>(id));
if (record == nullptr)
continue;
const char *name = *reinterpret_cast<const char *const *>(
record + Offsets::OFF_ITEMSTATS_NAME);
if (name == nullptr || _stricmp(name, arg.name) != 0)
continue;
}
out->equipmentSlotIndex = 0;
out->bagID = bagID;
out->slotIndex = slotIndex;
@@ -224,6 +230,27 @@ bool FindByArgInBags(void *L, const Item::Arg::Resolved &arg, ByGUIDResult *out)
return false;
}
bool FindByArg(void *L, const Item::Arg::Resolved &arg, ByGUIDResult *out) {
if (arg.itemID <= 0 && arg.name == nullptr)
return false;
// Equipment slots first (no Lua stack interaction, cheaper), matching
// `FindByItemID`'s equipment-before-bags order; then fall through to the
// bag walk. Both compare with the shared `MatchesArg` predicate.
for (int slot = Offsets::EQUIPMENT_SLOT_FIRST;
slot <= Offsets::EQUIPMENT_SLOT_LAST; ++slot) {
auto *item = ResolveEquipmentSlot(slot);
if (item != nullptr && MatchesArg(item, arg)) {
out->equipmentSlotIndex = slot;
out->bagID = 0;
out->slotIndex = 0;
out->item = item;
return true;
}
}
return FindByArgInBags(L, arg, out);
}
bool FindByGUID(void *L, uint64_t guid, ByGUIDResult *out) {
if (guid == 0)
return false;
+58 -3
View File
@@ -69,6 +69,28 @@ const uint8_t *ResolveEquipmentSlot(int slot1Based);
// guaranteed cached by the engine's bag-sync flow at login.
int GetBagSlotCount(int bagID);
// Given a `CGContainer*` (an equipped bag or bank bag, resolved by GUID via
// `FUN_OBJECT_RESOLVE_BY_GUID` with `OBJ_TYPE_CONTAINER`), returns its own
// inventory-manager object via the container's vtable method at
// `+OFF_CONTAINER_GET_INVENTORY`. The returned object has the same layout as
// the player inventory manager: slot count at `+0x00`, flat GUID array at
// `+OFF_INVMGR_GUID_ARRAY`. Null-safe (null container → null). Pure C — no
// Lua stack, safe from any context. Consolidates the vtable dispatch that was
// hand-rolled across the bag/bank walks in `item/Count`, `item/AverageLevel`,
// `equipmentset/Locations`, and `item/NewItems`.
void *ContainerInventory(const uint8_t *container);
// Returns the inventory object of the equipped bag in player bag slot `bagID`
// (1..4): its container GUID via `FUN_GET_EQUIPPED_BAG_GUID`, resolved to the
// CGContainer, then its inventory via `ContainerInventory`. The result has the
// player-invMgr shape (slot count at `+0x00`, GUID array at
// `+OFF_INVMGR_GUID_ARRAY`) and is what `GetItemBySlot` indexes with a 0-based
// slot. Returns null for `bagID` outside 1..4 or when no bag is equipped
// there. Pure C — no Lua stack. (The backpack, `bagID` 0, has no container
// object; index it directly off the player inventory manager at
// `BACKPACK_LINEAR_BASE + slot - 1`.)
void *EquippedBagInventory(int bagID);
// GUID-walk result. `equipmentSlotIndex != 0` means the item was
// found in a character-pane equipment slot; otherwise it's in
// `bagID`/`slotIndex`. `item` always points to the found CGItem
@@ -96,6 +118,22 @@ bool FindByGUID(void *L, uint64_t guid, ByGUIDResult *out);
// for link-decoration purposes. Stomps the Lua stack.
bool FindByItemID(void *L, int itemID, ByGUIDResult *out);
// Full detail for a bag slot, for callers that drive the engine's
// cursor-pickup primitive — which needs the *container object* and the
// engine *linear slot*, not just the CGItem.
struct BagSlotDetail {
const uint8_t *container; // PackBagSlot container (invMgr for the backpack, else the bag)
int linearSlot; // engine linear inventory slot
const uint8_t *item; // CGItem at the slot, or null if the slot is empty
};
// Resolves `(bagID, slotIndex)` to its `BagSlotDetail`. Returns false when
// the coordinates don't resolve (bad bag / out-of-range slot); a resolved
// but empty slot returns true with `out->item == nullptr`. Stomps the Lua
// stack (PackBagSlot). `ResolveBag`/`ResolveBagSlot` are thin wrappers that
// keep only `out->item`.
bool ResolveBagSlotDetail(void *L, int bagID, int slotIndex, BagSlotDetail *out);
} // namespace Item::Location
// Forward-declare for `FindByArgInBags` below.
@@ -103,10 +141,19 @@ namespace Item::Arg { struct Resolved; }
namespace Item::Location {
// The single item name/id match predicate: true if `cgItem` satisfies
// the resolved arg. `arg.itemID > 0` ⇒ exact itemID compare; otherwise
// `arg.name` is matched case-insensitively against the item's *decorated*
// display name (random suffix included — via `Item::Link::NameFromCGItem`,
// which falls back to the base name for unsuffixed items). Matches modern
// `IsEquippedItem` / by-name semantics: "Foo of the Owl" matches, the base
// "Foo" does not. Null / uncached / unnameable items → false. Shared by
// `FindByArgInBags` and `C_Item.IsEquippedItem` so the match logic lives
// in exactly one place.
bool MatchesArg(const uint8_t *cgItem, const Item::Arg::Resolved &arg);
// Walks the player's bags (0..4 only — no equipment) looking for the
// first item matching `arg`. `arg.itemID > 0` ⇒ direct ID compare;
// otherwise `arg.name` is matched case-insensitively against the
// cached `ItemStats_C.m_name[0]` of each candidate. On hit, populates
// first item matching `arg` (via `MatchesArg`). On hit, populates
// `out->bagID` / `out->slotIndex` / `out->item` and sets
// `equipmentSlotIndex = 0`. Stomps the Lua stack.
//
@@ -116,6 +163,14 @@ namespace Item::Location {
// equipped or use-from-equipped; the item has to be in bags first).
bool FindByArgInBags(void *L, const Item::Arg::Resolved &arg, ByGUIDResult *out);
// Like `FindByArgInBags` but searches **equipment (1..19) first, then
// bags** — for callers that act on an item wherever it's carried (e.g.
// `C_Item.PickupItem`). On hit `out->equipmentSlotIndex` is non-zero for
// an equipped match, else `out->bagID`/`out->slotIndex` for a bag match.
// `FindByItemID` is the itemID-only specialization of this. Stomps the
// Lua stack.
bool FindByArg(void *L, const Item::Arg::Resolved &arg, ByGUIDResult *out);
// Parses the `"0xHHHHHHHHLLLLLLLL"` GUID string format
// `C_Item.GetItemGUID` returns. Strict: requires exactly `0x` prefix
// + 16 case-insensitive hex digits. Returns false (and leaves `*out`
+25 -5
View File
@@ -16,6 +16,7 @@
#include "item/Arg.h"
#include "item/Data.h"
#include "item/ID.h"
#include "item/Link.h"
#include "item/Location.h"
#include <cstdint>
@@ -56,18 +57,37 @@ int PushNameForItemID(void *L, int itemID) {
return 1;
}
// Pushes the suffix-decorated display name for a resolved `CGItem` via
// the shared engine name builder. Returns 0 (pushes nothing) for a null
// item or an empty build result, so the caller can fall back to the
// base-name-by-itemID path.
int PushInstanceName(void *L, const uint8_t *cgItem) {
char buf[128];
if (!Item::Link::NameFromCGItem(cgItem, buf, sizeof(buf)))
return 0;
Game::Lua::PushString(L, buf);
return 1;
}
} // namespace
// `C_Item.GetItemName(itemLocation)` — returns the cached display name
// of the item at the location, or nil for empty / uncached / invalid
// inputs. Reads `m_name[0]` straight off `ItemStats_C` — no engine
// round-trip, no `GetItemInfo` chaining.
// `C_Item.GetItemName(itemLocation)` — returns the display name of the
// item at the location, or nil for empty / uncached / invalid inputs.
// The location points at a real item instance, so the name carries any
// random-suffix decoration ("... of the Sorcerer") — built off the
// CGItem via the engine's own name builder, matching modern WoW and
// `C_Item.GetItemLink`'s bracketed name. Falls back to the base
// `ItemStats_C` name (and warms the cache) if the instance build yields
// nothing.
static int __fastcall Script_C_Item_GetItemName(void *L) {
if (!Item::Location::IsLocationArg(L, 1)) {
Game::Lua::Error(L, "Usage: C_Item.GetItemName(itemLocation)");
return 0;
}
return PushNameForItemID(L, Item::ID::FromCGItem(Item::Location::Resolve(L, 1)));
const uint8_t *cgItem = Item::Location::Resolve(L, 1);
if (int pushed = PushInstanceName(L, cgItem))
return pushed;
return PushNameForItemID(L, Item::ID::FromCGItem(cgItem));
}
// `C_Item.GetItemNameByID(itemInfo)` — returns the cached display name
+387
View File
@@ -0,0 +1,387 @@
// 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_NewItems` — the modern "new item glow" bookkeeping system. Retail
// flags items freshly added to your bags as "new" (the sparkle/flash on
// the bag button) and clears the flag once you've acknowledged them.
// Vanilla 1.12 never had the feature — no DBC, no server data, no engine
// bit; it's pure client-side bookkeeping over bag contents. So we add it,
// entirely in C++ the same way retail's client owns it — no addon Lua.
//
// Public surface (matches retail exactly):
// C_NewItems.IsNewItem(bagID, slotIndex) -> bool
// C_NewItems.RemoveNewItem(bagID, slotIndex)
// C_NewItems.ClearAll()
//
// Newness is keyed on each item's **instance GUID** (read off the CGItem),
// not on (bagID, slotIndex). That's what makes a flag survive the player
// rearranging a bag — exactly like retail, and impossible for a pure-Lua
// addon (vanilla exposes no per-instance GUID to Lua).
//
// The feed is event-driven, not polled: it hangs off `Bag::UpdateDelayed`,
// which co-hooks the engine's three `BAG_UPDATE` fire sites and signals once
// per frame in which a bag actually changed. On that signal we scan bags
// 0..4, diff the resident item GUIDs against the previous scan, flag anything
// newly acquired, and prune flags for items that left. `BAG_NEW_ITEMS_UPDATED`
// (no payload — matching retail) fires whenever the new-item set changes. So
// nothing runs on quiet frames.
//
// The one exception is establishing the login baseline: a lightweight
// WorldTick handler waits ~1.5s after the player is first resolvable (long
// enough for the initial bag population to finish), takes a single scan as the
// baseline, and then goes idle — items already owned at login are therefore
// never flagged. It re-arms on player-GUID change (relog / character switch).
//
// The "already owned" baseline spans **everything the player owns** — bags,
// worn equipment (paperdoll + equipped bags), AND the bank (main + bank bags)
// — even though only *bag* items are ever flagged new. That's what stops a
// move INTO a bag from any of those (unequip, or a pull from the bank) from
// looking like a fresh loot: the item's GUID was already in the owned set, so
// the bag diff treats it as a move. The bank is reachable without the bank
// window open because its GUIDs are read straight from the invMgr array (the
// same gate-bypass `C_Item.GetItemCount` uses; the array is server-populated
// at login).
//
// Bag enumeration is pure C++: it replicates what `PackBagSlot` does
// internally (see Offsets.h). The backpack is indexed straight off the
// player inventory manager; each equipped bag 1..4 is resolved to its
// container object and inventory via the same GUID getter + vtable call the
// engine uses, then walked with `GetItemBySlot`. None of the Lua-stack-bound
// helpers are touched, so the scan is safe from the frame-loop tick.
#include "Game.h"
#include "Offsets.h"
#include "bag/UpdateDelayed.h"
#include "event/Custom.h"
#include "item/Location.h"
#include "tick/WorldTick.h"
#include "unit/Identity.h"
#include <cstdint>
namespace Item::NewItems {
namespace {
constexpr const char *kEvtNewItems = "BAG_NEW_ITEMS_UPDATED";
const Event::Custom::AutoReserve _r{kEvtNewItems};
// Sized for the whole owned universe the baseline tracks — backpack + four
// bags + 19 worn slots + 4 equipped bags + main bank (24) + six bank bags.
// A full inventory-and-bank can push ~300, so 512 leaves generous headroom.
// Overflow silently drops (AddUnique bails at the cap) — a dropped GUID just
// can't be flagged/recognized, never a crash.
constexpr int kMaxItems = 512;
// Delay after the player is first resolvable before the baseline is taken,
// giving the initial bag population time to finish. Wall-clock (via the
// engine ms counter) so it's independent of frame rate.
constexpr uint32_t kSettleMs = 1500;
uint64_t g_seen[kMaxItems]; // all owned GUIDs (bags + equipment + bank) at the last scan
int g_seenCount = 0;
uint64_t g_new[kMaxItems]; // subset currently flagged "new"
int g_newCount = 0;
bool g_seeded = false; // true once the login baseline has been taken
uint64_t g_playerGuid = 0; // re-arm the baseline when this changes (relog)
uint32_t g_loginMs = 0; // engine ms at which the current player resolved
uint32_t NowMs() {
using TickCount_t = uint32_t(__fastcall *)();
return reinterpret_cast<TickCount_t>(Offsets::FUN_OS_TICKCOUNT_MS)();
}
bool Contains(const uint64_t *arr, int count, uint64_t guid) {
for (int i = 0; i < count; ++i)
if (arr[i] == guid)
return true;
return false;
}
bool AddUnique(uint64_t *arr, int *count, uint64_t guid) {
if (Contains(arr, *count, guid) || *count >= kMaxItems)
return false;
arr[(*count)++] = guid;
return true;
}
bool RemoveFrom(uint64_t *arr, int *count, uint64_t guid) {
for (int i = 0; i < *count; ++i) {
if (arr[i] == guid) {
arr[i] = arr[--(*count)];
return true;
}
}
return false;
}
uint64_t ItemGUID(const uint8_t *item) {
if (item == nullptr)
return 0;
auto *instance = *reinterpret_cast<const uint8_t *const *>(
item + Offsets::OFF_ITEM_INSTANCE_BLOCK);
if (instance == nullptr)
return 0;
return *reinterpret_cast<const uint64_t *>(
instance + Offsets::OFF_INSTANCE_BLOCK_GUID);
}
// --- pure-C++ bag enumeration (no Lua stack) -----------------------------
using GetItemBySlot_t = void *(__thiscall *)(void *inventory, int slot0Based);
// Collects the instance GUIDs of every item in bags 0..4 into `out`.
// Returns the count. Pure C++ — safe to call off the frame-loop tick.
int ScanBags(const uint8_t *player, uint64_t *out) {
auto getItemBySlot =
reinterpret_cast<GetItemBySlot_t>(Offsets::FUN_ITEMMGR_GET_ITEM_BY_SLOT);
int count = 0;
// Backpack (bagID 0): indexed directly off the player inventory manager
// at linear slot (S-1) + 23. GetItemBySlot only reads the manager, so the
// const_cast to satisfy its `this` parameter is safe.
auto *invMgr = const_cast<uint8_t *>(player) + Offsets::OFF_PLAYER_INVENTORY_MANAGER;
const int backpackSlots = Item::Location::GetBagSlotCount(0);
for (int slot = 1; slot <= backpackSlots; ++slot) {
auto *item = static_cast<const uint8_t *>(
getItemBySlot(invMgr, Offsets::BACKPACK_LINEAR_BASE + slot - 1));
const uint64_t guid = ItemGUID(item);
if (guid != 0)
AddUnique(out, &count, guid);
}
// Equipped bags 1..4: resolve each bag's inventory object, then walk it.
for (int bagID = 1; bagID <= 4; ++bagID) {
const int slots = Item::Location::GetBagSlotCount(bagID);
if (slots <= 0)
continue;
void *bagInv = Item::Location::EquippedBagInventory(bagID);
if (bagInv == nullptr)
continue;
for (int slot = 1; slot <= slots; ++slot) {
auto *item = static_cast<const uint8_t *>(getItemBySlot(bagInv, slot - 1));
const uint64_t guid = ItemGUID(item);
if (guid != 0)
AddUnique(out, &count, guid);
}
}
return count;
}
// Appends GUIDs from an invMgr's GUID array (the flat `+OFF_INVMGR_GUID_ARRAY`
// list `GetItemBySlot` indexes) over linear slots [first, last]. Reading the
// array directly is how we reach BANK slots without the bank window open —
// `GetItemBySlot` gates slots 39..68 on a live banker GUID, but the array
// itself is server-populated at login (same bypass `C_Item.GetItemCount`
// uses). We only need the GUID here, not the resolved object.
void AppendGuidArray(const uint8_t *invMgr, int first, int last, uint64_t *out,
int *count) {
if (invMgr == nullptr)
return;
auto *arr = *reinterpret_cast<const uint64_t *const *>(
invMgr + Offsets::OFF_INVMGR_GUID_ARRAY);
if (arr == nullptr)
return;
for (int slot = first; slot <= last; ++slot)
if (arr[slot] != 0)
AddUnique(out, count, arr[slot]);
}
using ResolveByGuid_t = void *(__fastcall *)(int type, const char *debugName,
uint32_t guidLo, uint32_t guidHi,
int priority);
const uint8_t *ResolveByGuid(int type, uint64_t guid) {
if (guid == 0)
return nullptr;
auto fn = reinterpret_cast<ResolveByGuid_t>(Offsets::FUN_OBJECT_RESOLVE_BY_GUID);
return static_cast<const uint8_t *>(
fn(type, "ItemMgr", static_cast<uint32_t>(guid),
static_cast<uint32_t>(guid >> 32), 0x172));
}
// Appends every item the player owns that ISN'T in bags 0..4 — worn
// equipment (paperdoll 1..19 + the four equipped-bag containers 20..23),
// the main bank (24 slots), and each bank bag's contents. Used only to widen
// the "already owned" baseline so a move INTO a bag from any of these
// (unequip, or a pull from the bank) reads as a move, not a fresh loot. We
// never *flag* these as new — this only feeds the seen-set. Pure C++ (no Lua
// stack) — safe off the frame tick.
void AppendNonBagOwned(uint64_t *out, int *count) {
// Worn equipment + equipped bags: GetItemBySlot works for these (no
// banker gate), so go through the normal per-slot resolver.
for (int slot = Offsets::EQUIPMENT_SLOT_FIRST; slot <= Offsets::INVSLOT_BAG1 + 3;
++slot) {
const uint64_t guid = ItemGUID(Item::Location::ResolveEquipmentSlot(slot));
if (guid != 0)
AddUnique(out, count, guid);
}
const uint8_t *playerInvMgr = Unit::Identity::PlayerInventoryManager();
if (playerInvMgr == nullptr)
return;
// Main bank slots 39..62 live in the player invMgr's own GUID array.
AppendGuidArray(playerInvMgr, Offsets::INVMGR_BANK_MAIN_FIRST_SLOT,
Offsets::INVMGR_BANK_MAIN_LAST_SLOT, out, count);
// Bank bags (containers at player slots 63..68) each carry their own
// invMgr; resolve the container, then walk its GUID array.
auto *playerArr = *reinterpret_cast<const uint64_t *const *>(
playerInvMgr + Offsets::OFF_INVMGR_GUID_ARRAY);
if (playerArr == nullptr)
return;
for (int slot = Offsets::INVMGR_BANK_BAG_FIRST_SLOT;
slot <= Offsets::INVMGR_BANK_BAG_LAST_SLOT; ++slot) {
const uint8_t *bag = ResolveByGuid(Offsets::OBJ_TYPE_CONTAINER, playerArr[slot]);
if (bag == nullptr)
continue;
auto *bagInvMgr =
static_cast<const uint8_t *>(Item::Location::ContainerInventory(bag));
if (bagInvMgr == nullptr)
continue;
const int bagSlots = static_cast<int>(*reinterpret_cast<const uint32_t *>(bagInvMgr));
if (bagSlots > 0)
AppendGuidArray(bagInvMgr, 0, bagSlots - 1, out, count);
}
}
void FireChanged() {
Event::Custom::Fire(Event::Custom::Lookup(kEvtNewItems), "");
}
void CopyToSeen(const uint64_t *current, int count) {
for (int i = 0; i < count; ++i)
g_seen[i] = current[i];
g_seenCount = count;
}
// Login-baseline handler. Runs every frame but does no work once the
// baseline is taken — it only watches for (re)login and, after a short
// settle, takes the single authoritative baseline scan. Steady state is just
// a GUID read + `if (g_seeded) return`.
void OnTick() {
const uint64_t guid = Unit::Identity::PlayerGuid();
if (guid == 0)
return; // not in world yet
// Fresh login / character switch: drop all state and re-arm the settle.
if (guid != g_playerGuid) {
g_playerGuid = guid;
g_newCount = 0;
g_seenCount = 0;
g_seeded = false;
g_loginMs = NowMs();
}
if (g_seeded)
return; // baseline established; the bag-change signal drives the rest
if (NowMs() - g_loginMs < kSettleMs)
return; // let the initial bag population finish
const uint8_t *player = Unit::Identity::PlayerObject();
if (player == nullptr)
return; // not resolvable yet — retry next tick
// Everything currently owned — bags AND worn equipment — becomes the
// baseline; nothing owned at login is "new". Including equipment means a
// later unequip (paperdoll → bag) is recognized as a move, not a loot.
g_seenCount = ScanBags(player, g_seen);
AppendNonBagOwned(g_seen, &g_seenCount);
g_newCount = 0;
g_seeded = true;
}
// Authoritative bag-change handler — fires only on frames where the engine
// actually changed a bag (via Bag::UpdateDelayed). Diffs the current bag set
// against the baseline, flags newly-acquired items, prunes departed ones.
void OnBagChanged() {
if (!g_seeded)
return; // pre-baseline changes are folded into the login baseline
const uint64_t guid = Unit::Identity::PlayerGuid();
if (guid == 0 || guid != g_playerGuid)
return; // mid-relog; OnTick re-arms the baseline
const uint8_t *player = Unit::Identity::PlayerObject();
if (player == nullptr)
return;
uint64_t currentBags[kMaxItems];
const int bagCount = ScanBags(player, currentBags);
bool changed = false;
// Drop flags for items no longer in the bags (used, sold, equipped, banked).
for (int i = 0; i < g_newCount;) {
if (!Contains(currentBags, bagCount, g_new[i])) {
g_new[i] = g_new[--g_newCount];
changed = true;
} else {
++i;
}
}
for (int i = 0; i < bagCount; ++i) {
if (!Contains(g_seen, g_seenCount, currentBags[i]))
changed |= AddUnique(g_new, &g_newCount, currentBags[i]);
}
CopyToSeen(currentBags, bagCount);
AppendNonBagOwned(g_seen, &g_seenCount);
if (changed)
FireChanged();
}
const Tick::WorldTick::AutoSubscribe _tick{&OnTick};
const Bag::UpdateDelayed::AutoSubscribe _bagChange{&OnBagChanged};
// --- C_NewItems Lua surface ----------------------------------------------
// Resolves a `(bagID, slotIndex)` Lua-arg pair to the item's instance GUID,
// or 0 if the args are missing/non-numeric or the slot is empty. Uses the
// Lua-context bag resolver — fine here, these run as Lua C functions.
uint64_t SlotGUID(void *L) {
if (!Game::Lua::IsNumber(L, 1) || !Game::Lua::IsNumber(L, 2))
return 0;
const int bagID = static_cast<int>(Game::Lua::ToNumber(L, 1));
const int slotIndex = static_cast<int>(Game::Lua::ToNumber(L, 2));
return ItemGUID(Item::Location::ResolveBag(L, bagID, slotIndex));
}
int __fastcall Script_IsNewItem(void *L) {
const uint64_t guid = SlotGUID(L);
Game::Lua::PushBool(L, guid != 0 && Contains(g_new, g_newCount, guid));
return 1;
}
int __fastcall Script_RemoveNewItem(void *L) {
const uint64_t guid = SlotGUID(L);
if (guid != 0 && RemoveFrom(g_new, &g_newCount, guid))
FireChanged();
return 0;
}
int __fastcall Script_ClearAll(void *L) {
(void)L;
if (g_newCount > 0) {
g_newCount = 0;
FireChanged();
}
return 0;
}
void RegisterLuaFunctions() {
Game::Lua::RegisterTableFunction("C_NewItems", "IsNewItem", &Script_IsNewItem);
Game::Lua::RegisterTableFunction("C_NewItems", "RemoveNewItem", &Script_RemoveNewItem);
Game::Lua::RegisterTableFunction("C_NewItems", "ClearAll", &Script_ClearAll);
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace
} // namespace Item::NewItems
+63
View File
@@ -0,0 +1,63 @@
// 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_Item.PickupItem(itemInfo)` — finds the item matching `itemInfo` in
// the player's equipment or bags and picks it up onto the cursor.
// `itemInfo` is an itemID, `"item:NNN"` string, item link, or item name —
// the same input `C_Item.UseItemByName` / `EquipItemByName` accept,
// resolved via `Item::Arg::Resolve` and matched by `FindByArg` (itemID, or
// decorated name for a name arg) across equipment (1..19, checked first)
// and bags (0..4).
//
// The pickup goes through the shared `Item::Cursor` wrappers, which call
// the engine's cursor primitives directly (no Lua `Script_Pickup*`
// wrapper): an equipped match uses the equipment-slot primitive, a bag
// match the bag primitive (guarded on an empty cursor + unlocked item).
// Silently no-ops on bad input, no match, a busy cursor, or a locked item.
#include "Game.h"
#include "item/Arg.h"
#include "item/Cursor.h"
#include "item/Location.h"
namespace Item::Pickup {
namespace {
int __fastcall Script_C_Item_PickupItem(void *L) {
const auto arg = Item::Arg::Resolve(L, 1);
if (arg.itemID <= 0 && arg.name == nullptr)
return 0;
Item::Location::ByGUIDResult found;
if (!Item::Location::FindByArg(L, arg, &found))
return 0;
// equipmentSlotIndex != 0 → equipped (paperdoll slot); else a bag slot.
if (found.equipmentSlotIndex != 0)
Item::Cursor::PickupEquipmentSlot(found.equipmentSlotIndex);
else
Item::Cursor::PickupBagItem(L, found.bagID, found.slotIndex);
return 0;
}
void RegisterLuaFunctions() {
Game::Lua::RegisterTableFunction("C_Item", "PickupItem",
&Script_C_Item_PickupItem);
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace
} // namespace Item::Pickup
+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
+39 -28
View File
@@ -279,6 +279,15 @@ void AddByKey(Accum *acc, const char *key, long delta) {
}
}
long Value(const Accum *acc, const char *key) {
if (acc == nullptr || key == nullptr)
return 0;
for (int i = 0; i < kCount; ++i)
if (std::strcmp(acc[i].key, key) == 0)
return acc[i].value;
return 0;
}
const uint8_t *FetchRecord(uint32_t itemID) {
auto fn = reinterpret_cast<GetItemRecord_t>(Offsets::FUN_DBCACHE_ITEMSTATS_GET_RECORD);
auto *cache = reinterpret_cast<void *>(Offsets::VAR_ITEMDB_CACHE);
@@ -306,6 +315,34 @@ double ComputeDPS(const uint8_t *record) {
return avgTotal / (static_cast<double>(delayMs) / 1000.0);
}
void ApplyEnchant(Accum *acc, uint32_t enchantID, long sign) {
if (enchantID == 0)
return;
const uint8_t *en = DBC::Record(Offsets::VAR_SPELLITEMENCHANT_RECORDS,
Offsets::VAR_SPELLITEMENCHANT_COUNT, enchantID);
if (en == nullptr)
return;
auto type = reinterpret_cast<const int32_t *>(en + Offsets::OFF_SPELLITEMENCHANT_TYPE);
auto amount = reinterpret_cast<const int32_t *>(en + Offsets::OFF_SPELLITEMENCHANT_AMOUNT);
auto arg = reinterpret_cast<const int32_t *>(en + Offsets::OFF_SPELLITEMENCHANT_ARG);
for (int i = 0; i < 3; ++i) { // SpellItemEnchantment has 3 effect slots
switch (type[i]) {
case 3: // equip spell — stat/resist value lives in the spell
AddSpellStatAuras(acc, arg[i], sign);
break;
case 4: // resistance — arg = school index (0 = armor)
AddByKey(acc, ResistKey(arg[i]), sign * static_cast<long>(amount[i]));
break;
case 5: // direct stat — arg = ItemModType, amount = value
AddByKey(acc, StatKeyForItemModType(static_cast<uint32_t>(arg[i])),
sign * static_cast<long>(amount[i]));
break;
default: // 1 proc / 2 weapon-damage / 6 totem / 7 use spell
break;
}
}
}
void ApplyRandomSuffix(Accum *acc, int suffixID, long sign) {
if (suffixID <= 0)
return;
@@ -316,34 +353,8 @@ void ApplyRandomSuffix(Accum *acc, int suffixID, long sign) {
return;
auto enchants = reinterpret_cast<const uint32_t *>(
rp + Offsets::OFF_ITEMRANDOMPROP_ENCHANT);
for (int s = 0; s < Offsets::ITEMRANDOMPROP_ENCHANT_SLOT_COUNT; ++s) {
const uint32_t enchantID = enchants[s];
if (enchantID == 0)
continue;
const uint8_t *en = DBC::Record(Offsets::VAR_SPELLITEMENCHANT_RECORDS,
Offsets::VAR_SPELLITEMENCHANT_COUNT, enchantID);
if (en == nullptr)
continue;
auto type = reinterpret_cast<const int32_t *>(en + Offsets::OFF_SPELLITEMENCHANT_TYPE);
auto amount = reinterpret_cast<const int32_t *>(en + Offsets::OFF_SPELLITEMENCHANT_AMOUNT);
auto arg = reinterpret_cast<const int32_t *>(en + Offsets::OFF_SPELLITEMENCHANT_ARG);
for (int i = 0; i < 3; ++i) { // SpellItemEnchantment has 3 effect slots
switch (type[i]) {
case 3: // equip spell — stat/resist value lives in the spell
AddSpellStatAuras(acc, arg[i], sign);
break;
case 4: // resistance — arg = school index (0 = armor)
AddByKey(acc, ResistKey(arg[i]), sign * static_cast<long>(amount[i]));
break;
case 5: // direct stat — arg = ItemModType, amount = value
AddByKey(acc, StatKeyForItemModType(static_cast<uint32_t>(arg[i])),
sign * static_cast<long>(amount[i]));
break;
default: // 1 proc / 2 weapon-damage / 6 totem / 7 use spell
break;
}
}
}
for (int s = 0; s < Offsets::ITEMRANDOMPROP_ENCHANT_SLOT_COUNT; ++s)
ApplyEnchant(acc, enchants[s], sign);
}
void AccumulateRecord(Accum *acc, const uint8_t *record, long sign) {
+10
View File
@@ -51,6 +51,9 @@ void Init(Accum *acc);
// a zero delta). Linear scan — the table is small and fixed.
void AddByKey(Accum *acc, const char *key, long delta);
// Read the accumulated value for `key` (0 for an unknown key). Linear scan.
long Value(const Accum *acc, const char *key);
// Passive cache peek for an item's `ItemStats_C` record. Does NOT warm
// the cache (returns nullptr for an uncached item) — see the item-cache
// race note in CLAUDE.md.
@@ -66,6 +69,13 @@ void AccumulateRecord(Accum *acc, const uint8_t *record, long sign);
// suffixID <= 0.
void ApplyRandomSuffix(Accum *acc, int suffixID, long sign);
// Fold a single SpellItemEnchantment record (by enchant ID) into `acc`,
// scaled by `sign`. Handles equip-spell (type 3), resistance (4), and
// direct-stat (5) enchant effects. Shared by `ApplyRandomSuffix` (a suffix's
// enchant slots) and callers reading an item's *applied* permanent/temporary
// enchants off its CGItem descriptor. No-op for enchantID 0.
void ApplyEnchant(Accum *acc, uint32_t enchantID, long sign);
// Weapon DPS = average damage / swing time, or 0 for non-weapons.
double ComputeDPS(const uint8_t *record);
+8 -13
View File
@@ -16,6 +16,7 @@
#include "Game.h"
#include "Offsets.h"
#include "item/Location.h"
#include "unit/Identity.h"
#include <cstdint>
@@ -23,7 +24,6 @@ namespace Item::Swap {
namespace {
using ResolveUnitToken_t = void *(__fastcall *)(const char *token);
using SwapFn_t = void(__thiscall *)(
void *player,
uint32_t srcItemLo, uint32_t srcItemHi,
@@ -33,11 +33,6 @@ using SwapFn_t = void(__thiscall *)(
uint32_t dstLinearSlot,
int flag);
void *ResolvePlayer() {
auto fn = reinterpret_cast<ResolveUnitToken_t>(Offsets::FUN_RESOLVE_UNIT_TOKEN);
return fn("player");
}
// Reads the 64-bit GUID stored at instance-block offset 0 of any
// CGObject-derived pointer (CGItem, CGPlayer, CGContainer all use
// the same +0x8 → instance block → +0x00 GUID layout).
@@ -98,7 +93,7 @@ bool EncodeBagSlot(int bagID, int slotInBag,
if (slotInBag < 1)
return false;
if (bagID == 0) {
void *player = ResolvePlayer();
void *player = const_cast<uint8_t *>(Unit::Identity::PlayerObject());
if (player == nullptr)
return false;
if (!ReadGuid(player, containerLo, containerHi))
@@ -119,7 +114,7 @@ bool EncodeBagSlot(int bagID, int slotInBag,
if (bagID == -1) {
if (slotInBag > BANK_MAIN_NUM_SLOTS)
return false;
void *player = ResolvePlayer();
void *player = const_cast<uint8_t *>(Unit::Identity::PlayerObject());
if (player == nullptr)
return false;
if (!ReadGuid(player, containerLo, containerHi))
@@ -152,7 +147,7 @@ bool SendSwap(const void *srcItem,
if (srcItem == nullptr)
return false;
void *player = ResolvePlayer();
void *player = const_cast<uint8_t *>(Unit::Identity::PlayerObject());
if (player == nullptr)
return false;
@@ -187,7 +182,7 @@ bool FromPaperdoll(const void *cgItem, int srcPaperdollSlot, int dstPaperdollSlo
if (srcPaperdollSlot < 1 || srcPaperdollSlot > 19)
return false;
void *player = ResolvePlayer();
void *player = const_cast<uint8_t *>(Unit::Identity::PlayerObject());
if (player == nullptr)
return false;
uint32_t playerLo = 0, playerHi = 0;
@@ -205,7 +200,7 @@ bool ToBag(const void *cgItem, int srcPaperdollSlot, int dstBagID, int dstSlotIn
if (cgItem == nullptr)
return false;
void *player = ResolvePlayer();
void *player = const_cast<uint8_t *>(Unit::Identity::PlayerObject());
if (player == nullptr)
return false;
uint32_t playerLo = 0, playerHi = 0;
@@ -272,7 +267,7 @@ bool MoveCount(void *L, int srcBag, int srcSlot, int dstBag, int dstSlot, int co
if (count == srcStack)
return Containers(L, srcBag, srcSlot, dstBag, dstSlot);
void *player = ResolvePlayer();
void *player = const_cast<uint8_t *>(Unit::Identity::PlayerObject());
if (player == nullptr)
return false;
@@ -308,7 +303,7 @@ bool Containers(void *L, int srcBag, int srcSlot, int dstBag, int dstSlot) {
if (srcItem == nullptr)
return false; // empty source slot
void *player = ResolvePlayer();
void *player = const_cast<uint8_t *>(Unit::Identity::PlayerObject());
if (player == nullptr)
return false;
+13 -18
View File
@@ -220,24 +220,19 @@ static int __fastcall Script_GameTooltipGetItem(void *L) {
const char *link = Item::Link::FromCGItem(
static_cast<const uint8_t *>(cgItem));
if (link != nullptr && *link != '\0') {
// Engine's link builder also writes the dressed
// (random-suffixed) name into the link's `[Name]`
// slot; pull it out by reading the cached base name
// for the return value. The engine doesn't expose
// the dressed name as a separate string, so we
// return the base name — matches modern semantics
// where (name, link) name is the cached display name
// and the link is the full hyperlink.
const uint8_t *record = PeekItemRecord(static_cast<uint32_t>(itemID));
if (record != nullptr) {
const char *name = *reinterpret_cast<const char *const *>(
record + Offsets::OFF_ITEMSTATS_NAME);
if (name != nullptr && *name != '\0') {
Game::Lua::PushString(L, name);
Game::Lua::PushString(L, link);
Game::Lua::PushNumber(L, static_cast<double>(itemID));
return 3;
}
// Return the dressed (random-suffixed) name the engine
// wrote into the link's `[Name]` slot — built off the same
// CGItem via Item::Link::NameFromCGItem — so the returned
// name agrees with the link ("Iridium Chain of the Owl",
// not the base "Iridium Chain") and matches modern
// GameTooltip:GetItem() on suffixed items.
char name[128];
if (Item::Link::NameFromCGItem(
static_cast<const uint8_t *>(cgItem), name, sizeof(name))) {
Game::Lua::PushString(L, name);
Game::Lua::PushString(L, link);
Game::Lua::PushNumber(L, static_cast<double>(itemID));
return 3;
}
}
}
+89 -1
View File
@@ -13,6 +13,7 @@
#include "Game.h"
#include "Offsets.h"
#include "dbc/Lookup.h"
#include <cstdint>
@@ -25,6 +26,18 @@ int LocaleIndex() {
static_cast<uintptr_t>(Offsets::VAR_LOCALE_INDEX));
}
// Localized ItemClass.dbc name for a class ID (records indexed by ID, the
// standard DBC shape — same lookup `Script_GetItemInfo` uses). Returns ""
// for an unknown class. Obsolete slots keep the client's literal string
// (classID 3 → "Jewelry(OBSOLETE)", 8 → "Generic(OBSOLETE)", …) — that's
// the real vanilla data; Enum.ItemClass supplies the modern key names.
const char *ClassName(uint32_t classID) {
const char *s = DBC::LocalizedField(Offsets::VAR_ITEMCLASS_RECORDS,
Offsets::VAR_ITEMCLASS_COUNT, classID,
Offsets::OFF_ITEMCLASS_NAMES);
return (s != nullptr && s[0] != '\0') ? s : "";
}
// Linear-scan ItemSubClass.dbc for the (classID, subClassID) compound-key
// row — same scan Item::GetData uses. Returns the record or nullptr.
const uint8_t *FindSubClass(uint32_t classID, uint32_t subClassID) {
@@ -72,8 +85,27 @@ const char *InvTypeKey(int invType) {
return (s != nullptr && s[0] != '\0') ? s : "";
}
// `GetItemClassInfo(classID)` → `className` (localized). `nil` for an
// unknown class. Backport of the modern global (also exposed as
// `C_Item.GetItemClassInfo`).
int __fastcall Script_GetItemClassInfo(void *L) {
if (!Game::Lua::IsNumber(L, 1)) {
Game::Lua::Error(L, "Usage: GetItemClassInfo(classID)");
return 0;
}
const char *name = ClassName(static_cast<uint32_t>(Game::Lua::ToNumber(L, 1)));
if (name[0] == '\0') {
Game::Lua::PushNil(L);
return 1;
}
Game::Lua::PushString(L, name);
return 1;
}
// `C_Item.GetItemSubClassInfo(classID, subClassID)` →
// `subClassName, subClassUsesInvType`.
// `subClassName, subClassUsesInvType`. Also exposed as the global
// `GetItemSubClassInfo(classID, subClassID)` → `name, isArmorType`, the
// same two values (the second is true for the armor material subclasses).
//
// `subClassName` is the localized ItemSubClass.dbc name (verbose form, e.g.
// "One-Handed Swords"; falls back to the short form for subclasses that
@@ -184,7 +216,57 @@ constexpr Game::Lua::EnumIntegerEntry kInventoryTypeEntries[] = {
{"IndexEquipablespellWeaponType", 34},
};
// `Enum.ItemClass` — the modern item-class enum. Values 0..15 are the
// classes vanilla items actually report (the numeric IDs match retail; the
// obsolete slots 3/8/10/14 keep their modern key names — Gem /
// ItemEnhancement / CurrencyTokenObsolete / PermanentObsolete — even
// though ItemClass.dbc labels them "…(OBSOLETE)"). 16..19 are post-vanilla
// and never appear in 1.12 item data, included so backport code that
// references those keys resolves (comparisons just never match).
constexpr Game::Lua::EnumIntegerEntry kItemClassEntries[] = {
{"Consumable", 0},
{"Container", 1},
{"Weapon", 2},
{"Gem", 3},
{"Armor", 4},
{"Reagent", 5},
{"Projectile", 6},
{"Tradegoods", 7},
{"ItemEnhancement", 8},
{"Recipe", 9},
{"CurrencyTokenObsolete", 10},
{"Quiver", 11},
{"Questitem", 12},
{"Key", 13},
{"PermanentObsolete", 14},
{"Miscellaneous", 15},
{"Glyph", 16},
{"Battlepet", 17},
{"WoWToken", 18},
{"Profession", 19},
};
// `Enum.ItemQuality` — the numeric `quality` reported by `GetItemInfo` /
// `C_Item.GetItemQuality`. Values 0..6 are all vanilla ever uses; the
// post-vanilla Heirloom (7) and WoWToken (8) tiers are omitted (no such
// items exist on 1.12).
constexpr Game::Lua::EnumIntegerEntry kItemQualityEntries[] = {
{"Poor", 0},
{"Common", 1},
{"Uncommon", 2},
{"Rare", 3},
{"Epic", 4},
{"Legendary", 5},
{"Artifact", 6},
};
void RegisterLuaFunctions() {
Game::Lua::RegisterGlobalFunction("GetItemClassInfo",
&Script_GetItemClassInfo);
Game::Lua::RegisterGlobalFunction("GetItemSubClassInfo",
&Script_GetItemSubClassInfo);
Game::Lua::RegisterTableFunction("C_Item", "GetItemClassInfo",
&Script_GetItemClassInfo);
Game::Lua::RegisterTableFunction("C_Item", "GetItemSubClassInfo",
&Script_GetItemSubClassInfo);
Game::Lua::RegisterTableFunction("C_Item", "GetItemInventorySlotKey",
@@ -194,6 +276,12 @@ void RegisterLuaFunctions() {
Game::Lua::RegisterIntegerEnum(
"Enum", "InventoryType", kInventoryTypeEntries,
sizeof(kInventoryTypeEntries) / sizeof(kInventoryTypeEntries[0]));
Game::Lua::RegisterIntegerEnum(
"Enum", "ItemClass", kItemClassEntries,
sizeof(kItemClassEntries) / sizeof(kItemClassEntries[0]));
Game::Lua::RegisterIntegerEnum(
"Enum", "ItemQuality", kItemQualityEntries,
sizeof(kItemQualityEntries) / sizeof(kItemQualityEntries[0]));
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
+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
+2 -59
View File
@@ -48,7 +48,6 @@
#include "item/Location.h"
#include <cstdint>
#include <cstring>
namespace Item::Use {
@@ -59,67 +58,10 @@ using UseItem_t = unsigned(__thiscall *)(const void *item, const uint64_t *targe
using TokenToGUID_t = uint64_t(__fastcall *)(const char *token);
bool HasPrefix(const char *s, const char *prefix) {
return std::strncmp(s, prefix, std::strlen(prefix)) == 0;
}
// Walk past any "target" suffixes (`targettarget`, `focustargettarget`,
// …); returns a pointer at the first non-"target" character.
const char *SkipTargetSuffixes(const char *s) {
while (HasPrefix(s, "target"))
s += 6;
return s;
}
// Predicate mirror of the engine's token grammar inside
// `FUN_TOKEN_TO_GUID` (`0x00515970`) plus the families our own
// `Unit::TokenExtensions` hook adds (`focus`, `nameplateN`). The
// engine raises a Lua error on unknown input, so we gate before
// dispatching to keep our "silently no-ops on bad input" contract.
//
// Every base token may be followed by zero or more `target` suffixes
// (`playertarget`, `party1targettarget`, …) which the engine's
// resolver walks via `LAB_005159d3..0x00515A2C`.
bool MatchBase(const char *s, const char *base, bool requireDigits) {
if (!HasPrefix(s, base))
return false;
const char *rest = s + std::strlen(base);
if (requireDigits) {
const char *p = rest;
while (*p >= '0' && *p <= '9')
++p;
if (p == rest)
return false;
rest = p;
}
return *SkipTargetSuffixes(rest) == '\0';
}
bool LooksLikeToken(const char *s) {
if (s == nullptr || *s == '\0')
return false;
// Order matters for shared roots: longer prefix first
// (`partypet`/`raidpet` before `party`/`raid`).
if (MatchBase(s, "partypet", true)) return true;
if (MatchBase(s, "raidpet", true)) return true;
if (MatchBase(s, "party", true)) return true;
if (MatchBase(s, "raid", true)) return true;
if (MatchBase(s, "nameplate", true)) return true;
if (MatchBase(s, "mouseover", false)) return true;
if (MatchBase(s, "player", false)) return true;
if (MatchBase(s, "target", false)) return true;
if (MatchBase(s, "focus", false)) return true;
if (MatchBase(s, "npc", false)) return true;
if (MatchBase(s, "pet", false)) return true;
return false;
}
uint64_t ResolveUnitGuid(void *L, int idx) {
if (!Game::Lua::IsString(L, idx))
return 0;
const char *token = Game::Lua::ToString(L, idx);
if (!LooksLikeToken(token))
return 0;
auto fn = reinterpret_cast<TokenToGUID_t>(Offsets::FUN_TOKEN_TO_GUID);
return fn(token);
}
@@ -130,12 +72,13 @@ int __fastcall Script_C_Item_UseItemByName(void *L) {
return 0;
}
const uint64_t targetGuid = ResolveUnitGuid(L, 2);
Item::Location::ByGUIDResult found;
if (!Item::Location::FindByArgInBags(L, arg, &found)) {
return 0;
}
const uint64_t targetGuid = ResolveUnitGuid(L, 2);
auto useItem = reinterpret_cast<UseItem_t>(Offsets::FUN_ITEM_USE);
useItem(found.item, &targetGuid, 0);
return 0;
+548
View File
@@ -0,0 +1,548 @@
// 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_LootHistory backport.
//
// Modern WoW (2.x+, incl. 5.4.8) exposes a `C_LootHistory` namespace backed
// by an engine-maintained ring buffer that a dedicated loot-history server
// packet fills. Vanilla 1.12 has NO such packet or store — but it DOES
// receive the raw group-loot roll traffic (SMSG_LOOT_ROLL per player,
// SMSG_LOOT_ROLL_WON, SMSG_LOOT_ALL_PASSED), which the default UI only turns
// into chat lines. So we reconstruct the history client-side (à la
// Aura::Source): co-hook the three roll-result handlers and accumulate a ring
// of rolled items + per-player results, then expose C_LootHistory reading
// from it.
//
// The handlers run on the deserialized PendingLootRoll message (0x48 bytes,
// LootRoll.cpp) with the fields at the OFF_LOOTROLL_* offsets. The message is
// populated at handler entry and freed at exit, so each co-hook reads its
// fields BEFORE calling the original.
//
// === Handler ABI (verified from disassembly — RET conventions DIFFER) ===
// ROLL FUN_0061C0B0: __fastcall(msg=ECX, ctx=EDX, int stackArg) -> RET 4
// WON FUN_0061B9E0: __fastcall(ctx=ECX, msg=EDX, int stackArg) -> RET 4
// PASSED FUN_0061B640: __fastcall(ctx=ECX, msg=EDX) -> RET 0
// The trailing stack arg (and matching callee cleanup) is LOAD-BEARING: an
// earlier revision declared these as 2-arg hooks (implicit RET 0), so every
// ROLL/WON call left ESP unbalanced by 4 -> stack corruption -> crash on the
// first loot roll. Each hook below matches its handler's exact shape.
//
// Name + class per roller come from the engine NameCache (read at hook time,
// guaranteed loaded then), so they resolve even for someone who has left the
// group by the time their roll packet arrives. Surface: C_LootHistory
// GetNumItems / GetItem / GetPlayerInfo, plus LOOT_HISTORY_ROLL_CHANGED /
// _ROLL_COMPLETE events. The MoP master-loot management functions
// (SetExpiration / GiveMasterLoot / CanMasterLoot) are intentionally omitted
// — they depend on server support 1.12 doesn't have.
#include "Game.h"
#include "Offsets.h"
#include "event/Custom.h"
#include "unit/Identity.h"
#include <cstdint>
#include <cstdio>
namespace Loot::History {
namespace {
template <typename T>
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;
constexpr int ROLL_GREED = 2;
// The SMSG_LOOT_ROLL packet encodes rollType as 0 = need, 2 = greed; map to
// the modern convention. A "didn't actually roll" (pass) is flagged by the
// high bit of the roll-number byte, handled by the caller.
int MapRollType(int packetType) {
return packetType == 2 ? ROLL_GREED : ROLL_NEED;
}
// GUID -> engine NameCache entry-data block. This is the same primitive the
// engine's own `%N` name substitution uses (FUN_005083A0): for a LOADED entry
// the lookup returns the data block (name[48] at +0, class at +0x140, …),
// regardless of the (null) callback and regardless of current group
// membership — so it resolves a roller who has since left the group, which
// is exactly what a loot roll needs (the roll packet arrives after they may
// have gone). Guaranteed loaded at hook time: the roll handler's wrapper
// gates on this same lookup succeeding. Returns nullptr if not cached.
const uint8_t *LookupPlayerRecord(uint64_t guid) {
return Unit::Identity::PlayerInfoRecord(guid);
}
const char *NameFromRecord(const uint8_t *rec) {
return rec != nullptr
? reinterpret_cast<const char *>(rec + Offsets::OFF_PLAYER_INFO_NAME)
: nullptr;
}
uint32_t ClassIdFromRecord(const uint8_t *rec) {
return rec != nullptr
? *reinterpret_cast<const uint32_t *>(rec + Offsets::OFF_PLAYER_INFO_CLASS)
: 0;
}
// ChrClasses.dbc record ID -> class token ("WARRIOR", "MAGE", …). The DBC's
// filename field is the token; RAID_CLASS_COLORS et al. key on it. nullptr if
// the ID is out of range or the record is missing.
const char *ClassTokenForId(uint32_t classId) {
if (classId == 0)
return nullptr;
const int count = *reinterpret_cast<const int *>(
static_cast<uintptr_t>(Offsets::VAR_CHRCLASSES_COUNT));
const uint8_t *const *records = *reinterpret_cast<const uint8_t *const *const *>(
static_cast<uintptr_t>(Offsets::VAR_CHRCLASSES_RECORDS));
if (records == nullptr || static_cast<int>(classId) > count)
return nullptr;
const uint8_t *rec = records[classId];
if (rec == nullptr)
return nullptr;
return *reinterpret_cast<const char *const *>(
rec + Offsets::OFF_CHRCLASSES_FILENAME);
}
constexpr int kNameLen = 48; // engine NameCache inline name[48]
void CopyName(char *dst, const char *src) {
if (src == nullptr) {
dst[0] = '\0';
return;
}
int i = 0;
for (; i < kNameLen - 1 && src[i] != '\0'; ++i)
dst[i] = src[i];
dst[i] = '\0';
}
struct PlayerRoll {
uint64_t guid;
char name[kNameLen];
uint32_t classId; // ChrClasses.dbc ID (0 = unknown)
int rollType; // ROLL_* (modern); ROLL_PASS when the player passed
int roll; // 1..100, or 0 if they passed / didn't roll
};
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; // 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;
};
// Ring of recent rolled items. g_write is the next slot to fill; g_count
// saturates at kMaxItems. GetNumItems returns g_count; item index i (1-based,
// oldest→newest) maps to ring[(g_write - g_count + i - 1) mod kMaxItems].
// ~3 KB per item (players[40] dominates), so 128 is ~380 KB static — bump
// freely if a longer history is wanted.
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) {
if (e.used && e.sourceGuid == sourceGuid && e.slot == slot)
return &e;
}
return nullptr;
}
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)
e->itemId = 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)
return nullptr;
const int start = (g_write - g_count + kMaxItems) % kMaxItems;
return &g_items[(start + index1Based - 1) % kMaxItems];
}
// The 1-based index of an entry in oldest→newest order (0 if not live).
int IndexOf(const HistItem *e) {
const int start = (g_write - g_count + kMaxItems) % kMaxItems;
for (int i = 0; i < g_count; ++i) {
if (&g_items[(start + i) % kMaxItems] == e)
return i + 1;
}
return 0;
}
// Modern loot-history events. ROLL_CHANGED(itemIdx, playerIdx) fires on each
// roll; ROLL_COMPLETE(itemIdx) when the item is decided (winner / all-passed).
// FULL_UPDATE is reserved for API/IsEventValid completeness (the UI fires it
// on open); we don't dispatch it from C.
constexpr const char *kRollChanged = "LOOT_HISTORY_ROLL_CHANGED";
constexpr const char *kRollComplete = "LOOT_HISTORY_ROLL_COMPLETE";
constexpr const char *kFullUpdate = "LOOT_HISTORY_FULL_UPDATE";
const Event::Custom::AutoReserve _evRollChanged{kRollChanged};
const Event::Custom::AutoReserve _evRollComplete{kRollComplete};
const Event::Custom::AutoReserve _evFullUpdate{kFullUpdate};
// Returns the 1-based item index and writes the 1-based player index to
// `*outPlayerIdx` (0 if none), so the hook can fire ROLL_CHANGED.
int RecordRoll(const void *msg, int *outPlayerIdx) {
*outPlayerIdx = 0;
const uint64_t src = Read<uint64_t>(msg, Offsets::OFF_LOOTROLL_SOURCE_GUID);
const uint32_t slot = Read<uint32_t>(msg, Offsets::OFF_LOOTROLL_SLOT);
const uint32_t itemId = Read<uint32_t>(msg, Offsets::OFF_LOOTROLL_ITEM_ID);
const uint64_t roller = Read<uint64_t>(msg, Offsets::OFF_LOOTROLL_ROLLER_GUID);
const uint8_t rollByte = Read<uint8_t>(msg, Offsets::OFF_LOOTROLL_ROLL_NUM);
const int packetType = Read<int32_t>(msg, Offsets::OFF_LOOTROLL_ROLL_TYPE);
const bool didRoll = (rollByte & 0x80) == 0;
const int rollNum = didRoll ? (rollByte & 0x7F) : 0;
const int rollType = didRoll ? MapRollType(packetType) : ROLL_PASS;
HistItem *e = FindOrCreate(src, slot, itemId);
if (roller == 0)
return IndexOf(e);
const int slotIdx = FindOrAddPlayer(e, roller);
if (slotIdx >= 0) {
e->players[slotIdx].rollType = rollType;
e->players[slotIdx].roll = rollNum;
*outPlayerIdx = slotIdx + 1;
}
return IndexOf(e);
}
int RecordWinner(const void *msg) {
const uint64_t src = Read<uint64_t>(msg, Offsets::OFF_LOOTROLL_SOURCE_GUID);
const uint32_t slot = Read<uint32_t>(msg, Offsets::OFF_LOOTROLL_SLOT);
const uint32_t itemId = Read<uint32_t>(msg, Offsets::OFF_LOOTROLL_ITEM_ID);
const uint64_t winner = Read<uint64_t>(msg, Offsets::OFF_LOOTROLL_ROLLER_GUID);
HistItem *e = FindOrCreate(src, slot, itemId);
e->winnerGuid = winner;
e->hasWinner = true;
e->isDone = true;
if (winner != 0)
FindOrAddPlayer(e, winner); // so GetItem's winnerIdx resolves
return IndexOf(e);
}
int RecordAllPassed(const void *msg) {
const uint64_t src = Read<uint64_t>(msg, Offsets::OFF_LOOTROLL_SOURCE_GUID);
const uint32_t slot = Read<uint32_t>(msg, Offsets::OFF_LOOTROLL_SLOT);
const uint32_t itemId = Read<uint32_t>(msg, Offsets::OFF_LOOTROLL_ITEM_ID);
HistItem *e = FindOrCreate(src, slot, itemId);
e->isDone = true;
return IndexOf(e);
}
// SMSG_LOOT_START_ROLL carries the item's random suffix/seed; the result
// packets don't. It's read from the freshly-built store-head node (rather
// than the packet) right after the START_ROLL handler runs — the node has
// the same field offsets as the message. This is the only place the random
// fields are populated, so the result recorders leave them alone.
void RecordStart() {
const uint8_t *node = *reinterpret_cast<const uint8_t *const *>(
static_cast<uintptr_t>(Offsets::VAR_LOOTROLL_STORE_HEAD));
if (node == nullptr)
return;
const uint64_t src = Read<uint64_t>(node, Offsets::OFF_LOOTROLL_SOURCE_GUID);
const uint32_t slot = Read<uint32_t>(node, Offsets::OFF_LOOTROLL_SLOT);
const uint32_t itemId = Read<uint32_t>(node, Offsets::OFF_LOOTROLL_ITEM_ID);
HistItem *e = FindOrCreate(src, slot, itemId);
e->itemSuffix = Read<int32_t>(node, Offsets::OFF_LOOTROLL_ITEM_SUFFIX);
e->itemSeed = Read<uint32_t>(node, Offsets::OFF_LOOTROLL_ITEM_SEED);
}
// ---- Packet co-hooks -------------------------------------------------------
// Signatures match the verified per-handler ABI above (note the differing
// message register, stack arg, and RET). Read the message fields before the
// original runs — it frees the message.
using RollFn_t = void(__fastcall *)(void *msg, void *ctx, int a3); // RET 4
using WonFn_t = void(__fastcall *)(void *ctx, void *msg, int a3); // RET 4
using PassedFn_t = void(__fastcall *)(void *ctx, void *msg); // RET 0
using StartFn_t = void(__fastcall *)(void *packet); // RET 0
RollFn_t g_origRoll = nullptr;
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) — 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). 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;
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);
}
void __fastcall Won_h(void *ctx, void *msg, int a3) {
int itemIdx = 0;
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;
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);
}
const Game::HookAutoRegister _hookRoll{
Offsets::FUN_LOOT_ROLL_HANDLER, reinterpret_cast<void *>(&Roll_h),
reinterpret_cast<void **>(&g_origRoll)};
const Game::HookAutoRegister _hookWon{
Offsets::FUN_LOOT_ROLL_WON_HANDLER, reinterpret_cast<void *>(&Won_h),
reinterpret_cast<void **>(&g_origWon)};
const Game::HookAutoRegister _hookPassed{
Offsets::FUN_LOOT_ALL_PASSED_HANDLER, reinterpret_cast<void *>(&Passed_h),
reinterpret_cast<void **>(&g_origPassed)};
const Game::HookAutoRegister _hookStart{
Offsets::FUN_LOOT_START_ROLL_HANDLER, reinterpret_cast<void *>(&Start_h),
reinterpret_cast<void **>(&g_origStart)};
// ---- Lua surface -----------------------------------------------------------
// `C_LootHistory.GetNumItems()` -> number of rolled items in the history.
int __fastcall Script_GetNumItems(void *L) {
Game::Lua::PushNumber(L, static_cast<double>(g_count));
return 1;
}
// `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)");
return 0;
}
HistItem *e = GetEntry(static_cast<int>(Game::Lua::ToNumber(L, 1)));
if (e == nullptr)
return 0;
static char link[64];
// Vanilla item link: item:id:enchant:suffix:unique. Carry the roll's
// 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::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);
Game::Lua::PushNumber(L, e->createdMs / 1000.0); // 6 timestamp
return 6;
}
// `C_LootHistory.GetPlayerInfo(itemIndex, playerIndex)` ->
// name, classToken, rollType, roll, isWinner, isMe. classToken is the
// ChrClasses filename ("WARRIOR", …) for RAID_CLASS_COLORS-style coloring;
// both name and class come from the engine NameCache captured at roll time.
// isMe compares the roller GUID to the local player's.
int __fastcall Script_GetPlayerInfo(void *L) {
if (!Game::Lua::IsNumber(L, 1) || !Game::Lua::IsNumber(L, 2)) {
Game::Lua::Error(L, "Usage: GetPlayerInfo(itemIndex, playerIndex)");
return 0;
}
HistItem *e = GetEntry(static_cast<int>(Game::Lua::ToNumber(L, 1)));
if (e == nullptr)
return 0;
const int p = static_cast<int>(Game::Lua::ToNumber(L, 2)) - 1;
if (p < 0 || p >= e->playerCount)
return 0;
const PlayerRoll &r = e->players[p];
if (r.name[0] != '\0')
Game::Lua::PushString(L, r.name); // 1 name
else
Game::Lua::PushNil(L);
const char *classToken = ClassTokenForId(r.classId); // 2 class token
if (classToken != nullptr)
Game::Lua::PushString(L, classToken);
else
Game::Lua::PushNil(L);
Game::Lua::PushNumber(L, static_cast<double>(r.rollType)); // 3 rollType
Game::Lua::PushNumber(L, static_cast<double>(r.roll)); // 4 roll
Game::Lua::PushBool(L, e->hasWinner && e->winnerGuid == r.guid); // 5 isWinner
Game::Lua::PushBool(L, r.guid == Unit::Identity::PlayerGuid()); // 6 isMe
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);
Game::Lua::RegisterTableFunction("C_LootHistory", "GetItem",
&Script_GetItem);
Game::Lua::RegisterTableFunction("C_LootHistory", "GetPlayerInfo",
&Script_GetPlayerInfo);
Game::Lua::RegisterTableFunction("C_LootHistory", "Clear", &Script_Clear);
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace
} // namespace Loot::History
+10 -14
View File
@@ -27,6 +27,7 @@
#include "../Game.h"
#include "../Offsets.h"
#include "../event/Custom.h"
#include "../event/SignalHook.h"
#include "../guid/Guid.h"
#include "../tick/WorldTick.h"
@@ -115,9 +116,6 @@ using LootSlotLinkBuilder_t = const char *(__fastcall *)(uint32_t userFacingSlot
using LootController_t = void(__fastcall *)(int lootStructPtr,
int coin,
int unk);
using FireEventNoArgs_t = void(__fastcall *)(int eventID,
int /*edx unused*/);
struct C3Vector {
float x, y, z;
};
@@ -256,7 +254,6 @@ void StartLoot(uint64_t guid) {
// === Hooks ===
LootController_t s_lootController_o = nullptr;
FireEventNoArgs_t s_fireEventNoArgs_o = nullptr;
// `LOOT_OPENED` / `LOOT_CLOSED` get suppressed only while a scan is
// actively interleaving open/close cycles, signaled by
@@ -265,12 +262,14 @@ FireEventNoArgs_t s_fireEventNoArgs_o = nullptr;
// `SendCloseLoot` that follows, then dropped before
// `TryStartNext`-initiated CMSG_LOOTs go out. Every other engine
// event fire — and normal play's loot events — pass through.
void __fastcall FireEventNoArgs_h(int eventID, int /*unusedEdx*/) {
if (s_scan.suppressEvents &&
(eventID == EVENT_LOOT_OPENED || eventID == EVENT_LOOT_CLOSED)) {
return;
}
s_fireEventNoArgs_o(eventID, 0);
//
// Runs as an interceptor on the shared no-arg event hook
// (`Event::SignalHook`) rather than owning the hook directly, so it can
// coexist with other features that intercept the same dispatcher (e.g. the
// PLAYER_ENTERING_WORLD payload). Return true to swallow.
bool SuppressLootEvents(int eventID) {
return s_scan.suppressEvents &&
(eventID == EVENT_LOOT_OPENED || eventID == EVENT_LOOT_CLOSED);
}
// Intercepts the engine's loot-window state controller. Two paths:
@@ -336,10 +335,7 @@ const Game::HookAutoRegister _hookController{
reinterpret_cast<void *>(&LootController_h),
reinterpret_cast<void **>(&s_lootController_o)};
const Game::HookAutoRegister _hookFireEvent{
Offsets::FUN_FIRE_EVENT_NO_ARGS,
reinterpret_cast<void *>(&FireEventNoArgs_h),
reinterpret_cast<void **>(&s_fireEventNoArgs_o)};
const Event::SignalHook::AutoSubscribe _lootIntercept{&SuppressLootEvents};
// === Tick (timeout only) ===

Some files were not shown because too many files have changed in this diff Show More