Compare commits

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Docs updated for the new signature, Clear, and the FULL_UPDATE semantics.
2026-07-10 21:03:51 -05:00
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
Brues bb9579c398 aura: flush source cache on map transition (stuck buffs after leaving BG)
Leaving a battleground (incl. the /afk teleport out while dead) bulk-
clears the player's aura descriptor WITHOUT firing per-aura
OnAuraRemoved, so Aura::Source entries for auras that are now gone
lingered and Aura::Data's descriptor-empty fallback kept surfacing them.
The tell from the report: a real buff (filling a descriptor slot)
temporarily masks the phantoms, removing it brings them back.

Can't evict on descriptor-empty — that's the exact state the fallback
must survive (rogue stealth, party-range drops). The clean distinguisher
is a map transition: a BG/instance/continent change moves the Map.dbc
ID, while stealth/range don't. OnWorldTick now polls VAR_CURRENT_MAP_ID
and flushes the whole cache on any change (no new hook). Every other
unit from the old map has despawned, and the player's real auras re-sync
from the descriptor / PLAYER_BUFF tables on entering the new world, so a
full flush loses nothing legitimate.

Reported via pfUI issue #20.
2026-07-09 23:55:12 -05:00
Brues 5642644833 add ClassicAPITradeSkillLinkFrame to UISpecialFrames 2026-07-09 23:13:42 -05:00
Brues 58e7e1a2c6 frame: add Frame:IsEventRegistered
Vanilla has RegisterEvent / UnregisterEvent as frame methods but not the
query IsEventRegistered (added in a later expansion). There's no engine
Script_* to delegate to, so we replicate the membership check already
embedded in Frame::RegisterEvent (FUN_00702140): find the event by name
in the live event table (SStrCmpI), then walk that entry's subscriber
chain (head@+0xC, next@+4, frame@+8, low-bit/null sentinel) for this
frame. self is resolved via Game::Lua::ResolveObject, yielding the same
CFrameScriptObject* the chain stores.

Registered on the Frame method registry (VAR_FRAME_METHOD_REGISTRY,
alongside RegisterEvent) so it resolves on any frame. Pure read, no new
hooks. Returns a boolean, matching the 3.3.5-era contract; verified
in-game (true for a registered event, false otherwise). Documented in
docs/API.md and README.md.
2026-07-09 18:08:17 -05:00
Brues 5339c97b1e tradeskill: native single-column link viewer layout on stock 1.12.1
The link viewer mirrored Turtle's HD 768x512 two-column TradeSkillFrame,
whose TW-TradeSkill art doesn't exist in the base 1.12.1 client — an
earlier stretch fallback smeared the vanilla art's internal borders.

Split every layout value that differs by client into an LO table gated on
TURTLE_WOW_VERSION. On stock clients the viewer now mirrors the native
Blizzard_TradeSkillUI geometry: 384x512, single column (list on top,
detail below), built from the vanilla ClassTrainer + UI-TradeSkill
quadrant art at native size, 8 visible rows, and the Exit button placed
exactly where the base TradeSkillCancelButton sits. Turtle keeps its
768-wide two-column layout unchanged.
2026-07-09 17:26:10 -05:00
Brues 7406ac5db1 tradeskill: add link buttons to TradeSkillFrame and CraftFrame
Backport of the WotLK "link this profession to chat" button. A
petition-icon button sits right of the window title; clicking inserts
the window's |Htrade:...|h link (C_TradeSkillUI.GetTradeSkillListLink /
GetCraftListLink) into the chat edit box, opening it if hidden. Other
players click the link to open the recipe viewer.

Both windows are LoadOnDemand addons on this client, so creation is
deferred via EventUtil.ContinueOnAddOnLoaded (fires immediately if
already loaded). Uses the native GossipFrame\PetitionGossipIcon since
the 3.3.5 UI-TradeSkill-LinkButton art doesn't exist in 1.12. Adds the
LINK_TRADESKILL_TOOLTIP locale string for the hover text.
2026-07-09 16:57:49 -05:00
Brues 1c263152e3 spell: fix negative cast/channel startTimeMs/endTimeMs on long-lived tick
C_Spell.UnitCastingInfo/UnitChannelInfo returned negative startTimeMs
and endTimeMs for users whose engine ms tick has crossed 2^31. The tick
(FUN_OS_TICKCOUNT_MS) has two backends: GetTickCount, or a scaled rdtsc.
The rdtsc value survives warm reboots (only a full power-off resets the
CPU TSC), so it can sit well past 2^31 even right after a "restart" --
which is why rebooting didn't clear the negatives. We stored the tick in
a signed int and pushed it straight to a double, so the high bit read as
the sign.

Script_GetTime (0x00515EA0) computes (double)(tick & 0xFFFFFFFF) * 0.001,
i.e. it masks the tick to the low 32 bits UNSIGNED. New PushMs helper
reinterprets our stored tick as uint32 before the double conversion,
reproducing GetTime()'s exact value so startTimeMs/endTimeMs stay
consistent with GetTime()*1000 across the whole range (both wrap together
at the ~49.7-day 2^32 boundary). Carrying full 64-bit precision would be
wrong -- it would diverge from GetTime() past 2^32 and break addons'
(now-start)/(end-start) math.

Routes all four timestamp pushes (cast + channel, local and remote)
through PushMs. The aura expirationTime path already handled the tick as
unsigned, so no change there.
2026-07-09 13:10:57 -05:00
Brues ab211962e5 unit: fix IsStealthed via UNIT_FIELD_BYTES_1 sneak flag
The old IsStealthed read descriptor +0x17C & 0x02, which is actually
UNIT_FIELD_AURALEVELS (per-slot caster-level bytes) - it only worked by
coincidence on the original test char and read false at level 60
(0x3C & 0x02 == 0), so Prowl/Stealth reported not-stealthed.

_classicapi_DiffAll on a Prowl toggle from a Cat-Form baseline showed
the real signal: UNIT_FIELD_BYTES_1 (+0x210) byte 3 flips 0x00->0x02
(the sneak/anim-tier flag the client render path reads to draw a
stealthed unit semi-transparent); everything else that changed was
aura-array slot metadata. IsStealthed now reads that broadcast bit
directly - verified in-game for Rogue Stealth, Druid Prowl, and
Shadowmeld, with no mount false-positive.

Also refactored the shared buff-slot scan into FindPlayerBuffWithEffect
(Dismount still uses it for the mount aura) and dropped the bogus
OFF_PLAYER_FIELD_VIS_BYTES / PLAYER_VIS_BIT_STEALTH constants.
2026-07-09 11:53:58 -05:00
Brues 33a23c563c map: make GetMapOverlays overlay tiling HD-map-patch safe
A custom patch-*.mpq that upscales the WorldMap overlay art broke the tile
resolution: the resolver read the actual BLP dimensions but did its grid and
texcoord math against a hardcoded 256px cell, so HD tiles rendered sheared
and half-clipped.

- Grid resolution detects the full-cell file size S (256*scale) and uses it
  as the partial-tile threshold instead of a literal 256, so HD edge tiles
  aren't mistaken for full ones.
- Texcoords now use content/nextPOT(content) — the engine's own WorldMapFrame
  fraction, which is scale-invariant (an HD file is POT*scale, same fraction)
  and needs no file-size guessing. Fixes overlays whose stock file was a 128
  cell (content <=128): the patch doubled them to 256, which the earlier
  S>=256 floor read as un-upscaled and halved their texcoord (the clipped
  dark patches).

Draw sizes/positions stay in logical map pixels; stock data is unchanged
(its files already are next-POT). File reads now only drive grid resolution
and the rare DBC-rounded sliver case.
2026-07-09 11:18:27 -05:00
Brues 17304929b5 tradeskill: show craftable [N] on rows, refresh on BAG_UPDATE_DELAYED
Each recipe row appends " [N]" (min over reagents of floor(onHand/required),
via GetItemCount) like Blizzard's list. The frame registers
BAG_UPDATE_DELAYED and, while shown, re-runs Refresh (row counts) and
re-renders the selected recipe's detail pane (reagent have/need) so the
numbers track the reader's bags live.
2026-07-09 00:50:36 -05:00
Brues d88f02fc90 tradeskill: tighten link-viewer frame, add Exit button + craft bottom art
- SetHitRectInsets(2,82,2,73) so the mouse/drag region matches the visible
  parchment (decoded from the corner BLPs' alpha: opaque art spans frame
  x[2..686], y[2..439]) instead of the full 768x512 texture rect.
- Bottom quadrants use the CraftFrame's TW-TradeSkill-BotLeft2/BotRight2
  (the flat button-bar bottom) and add an Exit button positioned like
  CraftCancelButton.
- Quad helper uses the bare SetPoint(point) one-arg form (enabled by the
  Script_SetPoint co-hook in src/frame/Modern.cpp).
2026-07-09 00:44:39 -05:00
Brues 1c79742cc3 cleanup 2026-07-09 00:09:12 -05:00
Brues d696a16ac4 tradeskill: load link-viewer items via the Item mixin, drop the item-info poll
The detail pane and subclass dropdown now resolve crafted items/reagents
through Item:CreateFromItemID():ContinueOnItemLoad() — each uncached item
re-renders only its own pane/dropdown when its data arrives, guarded so a
load completing after a newer selection can't clobber it. Replaces the
global GET_ITEM_INFO_RECEIVED watcher that re-derived everything on a 0.3s
debounce whenever any item loaded anywhere.

The 'All' subclass view no longer reapplies the filter (and resets scroll)
as items trickle in; have/need counts still use GetItemCount and the
subclass name still uses the synchronous GetItemInfoInstant.
2026-07-08 23:57:53 -05:00
Brues 2c1962b207 tradeskill: add recipe detail pane to the trade-skill link viewer
Clicking a recipe row now shows a Blizzard-styled detail pane: the crafted
item (or enchant) with its icon on the Class-Trainer detail-header bar, the
spell description for enchants, and the reagent grid (icon + name plate +
have/need counts) matching the CraftFrame layout. The first recipe is
auto-selected on open; selecting a row tints the row highlight to the
recipe's difficulty colour and whitens its text.

Rows are now real ClassTrainerSkillButtonTemplate buttons (the template the
CraftFrame/TradeSkillFrame use) so name rendering and spacing match exactly.
Reagent + product data (createdItem, numMade, reagents) is folded into the
existing C_TradeSkillUI.GetTradeSkillListRecipes decoder rather than adding a
new function.

The real GetTradeSkillReagentInfo family is indexed by the player's own open
window, so it can't describe a linked recipe from someone else's profession;
the reagents are read straight from Spell.dbc instead.
2026-07-08 23:15:29 -05:00
Brues 23cdbe196d spell: CastAtCursor/CastAtUnit cast exact rank by spellID, accept spell name 2026-07-08 20:12:16 -05:00
Brues 8b6760cd77 tradeskill: crafting-window-style link viewer (difficulty colors, subclass filter, spell tooltips) 2026-07-08 14:24:25 -05:00
Brues 7c2317aca1 Suppress aura cache fallback when descriptor is authoritative
Stuck auras (pfUI issue #20 part 2): a no-duration debuff (Ghost/Spirit
in battlegrounds) stayed visible after the unit resurrected. It can only
come from the Aura::Source cache fallback — the descriptor path re-reads
the live aura array each call.

The aura is cached with expirationMs == 0 (infinite), so OnWorldTick
never expires it, and its real removal came via a bulk object drop
(death/leave-visibility, then resurrect) rather than a per-aura removal,
so OnAuraRemoved never fired to evict it. The infinite entry lingered and
the fallback resurrected it on the now-alive unit.

The fallback only exists for rogue stealth and party range fluctuation,
which both clear the unit's entire aura array at once. Gate both fallback
entry points on DescriptorHasVisibleAura: if the descriptor exposes any
live aura it's authoritative, so a cache-only aura is genuinely gone.
Stealth/range recovery is unaffected — those produce a wholesale-empty
aura array, so the fallback still fires there exactly as before.
2026-07-08 11:18:37 -05:00
Brues b764b14df1 Filter hidden auras in cache fallback path
Add visibility check to `FallbackEligible` to prevent hidden/internal auras (e.g. SPELL_ATTR_HIDDEN_CLIENTSIDE) from leaking through as phantom buffs. The descriptor path already drops these via `IsSlotPopulated`; the cache fallback must apply the same engine predicate to avoid surfacing invisible auras.
2026-07-08 10:55:33 -05:00
Brues fc1bd30bef tradeskill: add trade-skill list links (GetTradeSkillListLink/GetCraftListLink + viewer) 2026-07-08 00:18:07 -05:00
Brues fe29ec050c loc: fire LOSS_OF_CONTROL_UPDATE with unit payload ("player") 2026-07-07 17:46:51 -05:00
Brues 7fafb89d1d docs: move LOSS_OF_CONTROL events into the Events section 2026-07-07 17:40:53 -05:00
Brues bcefe3b87f loc: add C_LossOfControl (data query + ADDED/UPDATE events)
Synthesize the modern C_LossOfControl surface for the local player, which
vanilla lacks entirely (a Mists-era aggregation layer):

- SCHOOL_INTERRUPT: co-hook the SMSG_SPELL_COOLDOWN handler (FUN_006e9460,
  same __stdcall(opCode*, CDataStore*) shape/registrar as Spell::Cast's
  failure/channel hooks). Turtle's Player::ProhibitSpellSchool sends the
  lockout as ONE cooldown packet listing every spell of the interrupted
  school at one uniform duration -- a shape nothing else emits (verified
  against the server source) -- so a player-GUID batch of >=2 same-school
  spells with equal duration authoritatively yields the locked school
  (1<<Spell.dbc School) + duration. The interrupter isn't in the packet, so
  spellID=0 / displayText="Interrupted".

- CC types (STUN/FEAR/ROOT/CONFUSE/CHARM/POSSESS/SILENCE/PACIFY/
  PACIFYSILENCE/DISARM): scan the player's debuffs, classify each by its
  EffectApplyAuraName (values authoritative from the server's SPELL_AURA
  table). Timing is best-effort from the Aura::Source cache (nil when the
  applying cast wasn't observed -- the fields are nullable).

- LOSS_OF_CONTROL_ADDED / LOSS_OF_CONTROL_UPDATE: a WorldTick poll-and-diff
  of the active set fires ADDED (with the 1-based eventIndex) per new effect
  and one coalesced UPDATE on any change. Chosen over event-driven aura hooks
  because effect EXPIRY has no engine packet (a tick is needed regardless),
  and the WorldTick tail keeps Lua handlers out of the mid-packet contexts
  the state updates in. Scan is bounded (16 debuff slots) and idle-cheap.

Placeholders with no vanilla equivalent: priority=0, displayType=2,
auraInstanceID=nil; iconTexture is the icon path (no FileDataIDs in vanilla).

Verified in-game: Polymorph -> CONFUSE, correct name/icon/duration, nil once
worn off; a counterspelled heal -> SCHOOL_INTERRUPT (lockoutSchool + 10s) +
SILENCE (4s), with two ADDED at cast and UPDATE as each expires.
2026-07-07 17:28:45 -05:00
Brues 9e03f69e9b xml: add C_XMLUtil.GetTemplateInfo() and DoesTemplateExist()
Both resolve by name through the engine's own template lookup
(FUN_006ee6f0) -- the same path inherits= uses -- so they're
case-insensitive and cover Blizzard FrameXML plus addon templates.

GetTemplateInfo returns { type, width, height, keyValues, inherits } or
nil if the template doesn't exist. type is the element tag; width/height
are the RAW values from a <Size> element declared directly on the
template (0 if none / inherited) -- read straight from the attributes
rather than via the engine's Size parser (FUN_006f1eb0), which applies
UI-scale conversion we don't want here. inherits is the inherits=
attribute or nil. keyValues is always empty (vanilla's XML schema has no
<KeyValues> element) and sourceLocation is omitted (a 10.2 feature;
vanilla records no per-node file/line) -- both parity-safe.

DoesTemplateExist returns a bool.

Verified in-game: DoesTemplateExist true/false as expected; GetTemplate-
Info nil for a missing name; StatFrameTemplate -> 104x13; FriendsFrame-
TabTemplate -> inherits "CharacterFrameTabButtonTemplate".
2026-07-06 22:26:15 -05:00
Brues 694a7415ab xml: add C_XMLUtil.GetTemplates()
Backport the modern C_XMLUtil.GetTemplates() -- enumerate every
registered virtual XML template as { name, type }.

The engine keeps virtual templates (the <Frame virtual="true"> etc. that
inherits= targets) in a Storm hash table. The XML parser (FUN_006ede10)
registers each via FUN_006ee500 and the inherits resolver reads the same
table via FUN_006ee6f0, so it's the authoritative template store. The
new module walks it: each node carries the template name (+0x14) and its
parsed definition node (+0x18), whose element tag string (+0x8) is the
frame type ("Frame" / "Button" / ...).

Covers both Blizzard FrameXML and addon templates; rebuilt on /reload.
Fonts are a separate registry and correctly excluded (matching retail).
Returns an empty table before the first template registers (mask ==
0xFFFFFFFF). Pure read, no hooks. Verified in-game: 212 templates,
entry 1 = { name="AtlasLootDefaultFrameButtonTemplate", type="Button" }.
2026-07-06 21:39:55 -05:00
Brues 23b102c60c spell/Cast: clear player channel on early stop; unify player-object access
UnitChannelInfo showed a stale "Ritual of Summoning" countdown after
helping a warlock summon: g_channel only cleared at its computed endMs
or when a timed cast superseded it, so a ritual that ends early (when the
summon fills) lingered, and a following instant cast -- which supersedes
neither cast timing nor the channel -- couldn't dislodge it.

OnWorldTick now polls the player's UNIT_FIELD_CHANNEL_SPELL (+0x228) and
clears g_channel when it drops to 0, gated by a confirm latch so the
field's ~1-tick start lag can't wipe a freshly stamped channel. Both
channel-stamp sites route through a new StampChannel helper that re-arms
the latch. Also fixes early-ended channels generally (interrupt / cancel
/ LoS break), which previously self-expired only at endMs.

Cleanup: factor the player-object/descriptor read into shared
Unit::Identity::PlayerObject() / PlayerDescriptor(), now used by
State.cpp, Cast.cpp's poll, and ComboDuration. PlayerObject() uses the
object-manager GUID resolve (FUN_OBJECT_RESOLVE_BY_GUID) -- the fastest
player-object path, non-throwing (null pre-world) -- replacing the
duplicated resolves (State.cpp's token resolver, ComboDuration's
hand-rolled GUID plumbing).
2026-07-06 19:52:01 -05:00
Brues e165aa6cd4 README: add Frame backports, RegisterComboDuration, GetMapOverlays rows 2026-07-06 00:56:29 -05:00
Brues 8ed32e6c8e C_Map.GetMapOverlays: zone overlays from WorldMapOverlay.dbc
Every overlay of a zone regardless of exploration, replacing the
hand-measured tables map-reveal addons ship (pfUI pfMapOverlayData).
The engine's GetNumMapOverlays/GetMapOverlayInfo only expose
DISCOVERED overlays -- the exploration gate is a server-fed ID list at
0x00B6E630 -- so unexplored areas were previously undrawable without
per-server data. Reads WorldMapOverlay.dbc directly instead, scoped by
the zone's WorldMapArea row (never by folder contents).

No argument = the world map's currently viewed zone (same
continent/zone resolution GetMapInfo performs, globals 0x0084506C /
0x00845070 / 0x00B6E668); numeric argument = AreaTable areaID, the
same identity C_Map.GetBestMapForUnit returns.

Tile grids are resolved in the DLL rather than left to consumers,
because Octo's shipped data breaks the canonical ceil(w/256) layout
three ways (mirroring the InkLab map generator's empirically-derived
disambiguation):
- sliver columns the DBC rect rounds away (Icepoint's Kaneq'nuun has a
  real 8px third column; DBC w=512 implies 2 -- naive layout shears
  the overlay into misplaced strips, the one visibly broken overlay);
- foreign tiles appended to an overlay's number sequence (Kaneqnuun
  ships 12 tiles, the true grid is 3x2 = 6);
- same-rect higher-resolution re-exports (Stonetalon's WindshearCrag:
  4 real tiles for a 1-tile rect, kept and scaled).
The true grid comes from the actual BLP header dimensions (width @
+0x0C, height @ +0x10, BLP1 and BLP2 alike) read through the engine's
own VFS reader, since only a row's last tile may be narrower than 256.
A sweep of all 707 overlays found 108 art/DBC mismatches; Kaneq'nuun
is the only shear-class one, matching the single visible artifact.
Resolution is cached per overlay per session.

Each overlay returns the DBC fields plus a ready-to-draw `tiles`
array (file / width / height / texCoordX / texCoordY / offsetX /
offsetY) so consumers need no grid math at all. WorldMapArea.dbc and
WorldMapOverlay.dbc schemas verified against fresh extractions and
the engine's own Script_GetMapOverlayInfo (0x004A8A00).
2026-07-06 00:54:57 -05:00
Brues 26da489773 re-enable HookScript 2026-07-05 23:40:49 -05:00
Brues 9c73430ddd temporary disable HookScript 2026-07-05 23:40:04 -05:00
Brues e712af11a5 Drop stale WOW_PROJECT_ID rationale from Frame backport docs 2026-07-05 18:19:34 -05:00
Brues 45291cb16b Modern Region/Frame method backports (Frame::Modern)
ClassicAPI advertises WOW_PROJECT_ID = WOW_PROJECT_CLASSIC, which
steers modern addon ports onto their Classic Era code paths -- where
they also assume Era's C-level frame behaviors. Supply the missing
ones (surfaced by AI_VoiceOver's Era path, but generic to every port):

- region:SetPoint("RIGHT") -- the one-arg form. Vanilla's parser
  accepts every other modern shape but raises the usage error when
  arg 3 is fully omitted (LUA_TNONE fails its type validation); a
  co-hook on Script_SetPoint (0x007A2540, the ONE implementation in
  the Region base table) normalizes it to (point, 0, 0) -- vanilla's
  parent-relative form, exactly the modern semantic.
- region:SetSize / region:GetSize -- registered on the REGION base
  method registry (ctx 0x00CF54B4), so they resolve on frames,
  buttons, textures, fontstrings -- including engine-created textures
  (GetNormalTexture():SetSize(...)).
- frame:SetShown -- Show/Hide are per-branch script functions with
  their own object typechecks, so it's registered separately on
  Frame / Texture / FontString with matching delegates.
- frame:SetResizeBounds -- modern rename of SetMinResize/SetMaxResize.
- frame:HookScript -- absent in vanilla entirely. Chains the old
  handler (invoked vanilla-style, reading the this/event/argN
  globals), then invokes the hook handler with modern positional args
  ((frame, event, arg1..arg9) for OnEvent, (frame, arg1..arg9)
  otherwise) via a C closure over (handler, old, isOnEvent).

All methods delegate to the engine's own Script_* implementations by
reshaping the Lua stack -- no re-derived setters, so UI-scale
conversion, object resolution, and type checking stay engine code.
Method-registry contexts beyond GameTooltip's (Region / Frame /
Texture / FontString) are now in Offsets.h per the
docs/BlizzardScriptAPI.md catalogue.

Verified in-game: GameTooltip:GetSize() returns real dimensions,
CreateFrame + SetSize(50,50) reads back 50, and AI_VoiceOver's Era
path runs through its entire frame construction (SetPoint one-arg,
SetSize, SetShown, SetResizeBounds, 14 HookScript sites) where it
previously errored on the first call.
2026-07-05 18:16:47 -05:00
Brues 9bfbb2e6b0 We have other ways to detect 1.12.1 and this is causing conflict 2026-07-05 18:16:13 -05:00
Brues d6bf05c12c Combo-point finisher durations for C_UnitAuras
Rupture at 4 combo points reported duration=6 (the SpellDuration.dbc
base) instead of the real 14s: finisher durations are computed
server-side at cast time and 1.12 never transmits them for debuffs the
player puts on other units. Verified with a whole-protocol sniff --
SMSG_UPDATE_AURA_DURATION (0x137) is self-scoped (slot+duration for the
player's own aura array), 0x2DD has no client handler and never
arrives, and nothing else carries the value. (pfUI issue #17.)

Mirror the server's computation instead (new Aura::ComboDuration):

- Snapshot combo points ([player+0xE68]+0x1029, the field behind
  Script_GetComboPoints) the instant CMSG_CAST_SPELL leaves, via a
  co-hook on the NetClient send funnel (FUN_NET_SEND 0x005379A0). The
  server consumes CP before SMSG_SPELL_GO returns, so reading at
  SpellGo time is too late; the send funnel also covers
  nampower-queued casts, which bypass any Spell_C_CastSpell co-hook
  via trampoline re-entry.
- Compute base + (max - base) * CP / 5 from the spell's own
  SpellDuration.dbc row ((v)mangos CalculateSpellDuration), then apply
  the player's duration SpellMods, matching server order. DBC-driven
  and spell-agnostic: finishers are exactly the rows with base != max
  (Rupture 6/16s, Kidney Shot 1/6s, r1 0/5s, Slice and Dice 6/21s;
  Expose Armor 30/30 self-disables). Consumed by Aura::Source's
  SpellGo hook; 0 falls through to the existing base path.
- Turtle's reworked Rip (8s + 2s per CP) has NO client data: all six
  ranks' DurationIndex points at SpellDuration row 87, which Turtle
  added server-side only -- the client table jumps 86 -> 105. Built-in
  values cover it, gated on the dangling-row condition itself (the
  only dangling cross-reference in the entire client Spell.dbc, per a
  full DurationIndex/CastingTimeIndex/RangeIndex sweep) rather than on
  realm detection: stock clients resolve Rip's index normally, and if
  Turtle ships the missing row the DBC takes over automatically.
- C_UnitAuras.RegisterComboDuration(spellID, baseSec, maxSec) as an
  escape hatch for other servers with the same class of client-data
  gap; a registered override beats DBC and built-ins.

Verified in-game: Rupture at 4 CP now reads duration=14.
2026-07-05 17:12:10 -05:00
46 changed files with 6941 additions and 289 deletions
+1
View File
@@ -35,5 +35,6 @@ Util\EquipmentManager.lua
Util\PlayerUtil.lua
Util\SlashCommandsRegistry.lua
Util\LinkUtil.lua
Util\TradeSkillLink.lua
Util\IconDataProvider.lua
Util\GameTooltip.lua
+29
View File
@@ -8,5 +8,34 @@ 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
-8
View File
@@ -123,14 +123,6 @@ PLAYER_FACTION_COLORS =
[1] = PLAYER_FACTION_COLOR_ALLIANCE,
};
WOW_PROJECT_MAINLINE = 1;
WOW_PROJECT_CLASSIC = 2;
WOW_PROJECT_BURNING_CRUSADE_CLASSIC = 5;
WOW_PROJECT_WRATH_CLASSIC = 11;
WOW_PROJECT_CATACLYSM_CLASSIC = 14;
WOW_PROJECT_MISTS_CLASSIC = 19;
WOW_PROJECT_ID = WOW_PROJECT_CLASSIC;
--
-- Glyph
--
@@ -0,0 +1,855 @@
-- Trade-skill *list* links (`|Htrade:...|h`) — backport of the 2.x+ shareable
-- profession link. The link is built entirely in the DLL
-- (`GetTradeSkillListLink` / `GetCraftListLink`, see src/tradeskill/Link.cpp);
-- this file only intercepts clicks on received links and renders the linker's
-- recipe list in a scroll frame (the UI half).
--
-- link: |cffffd000|Htrade:<skillLineID>:<curRank>:<maxRank>:<linkerName>:<base64 bits>|h[Name]|h|r
--
-- "Which recipes are known" is a bitfield over the skill line's canonical
-- recipe list (deterministic per DBC). The viewer lists the recipes the linker
-- knows (with an "X of Y" summary of the whole skill line), regardless of what
-- the reader can craft themselves.
-- Frame geometry mirrors the real TradeSkillFrame. Turtle ships an HD
-- 768x512 two-column frame (TW-TradeSkill art); stock 1.12.1 only has the
-- vanilla 384x512 single-column art, so on that client we fall back to the
-- native Blizzard_TradeSkillUI geometry (list on top, detail below) rather
-- than stretch the wide art. LO holds every layout value that differs.
local TURTLE = (TURTLE_WOW_VERSION ~= nil);
local FRAME_W = TURTLE and 768 or 384;
local FRAME_H = 512;
local NUM_ROWS = TURTLE and 19 or 8;
local ROW_HEIGHT = 16;
local LO = TURTLE and {
hit = { 2, 82, 2, 73 },
quads = {
{ "Interface\\TradeSkillFrame\\TW-TradeSkill-TopLeft", 512, 256, "TOPLEFT" },
{ "Interface\\TradeSkillFrame\\TW-TradeSkill-TopRight", 256, 256, "TOPRIGHT" },
{ "Interface\\TradeSkillFrame\\TW-TradeSkill-BotLeft2", 512, 256, "BOTTOMLEFT" },
{ "Interface\\TradeSkillFrame\\TW-TradeSkill-BotRight2", 256, 256, "BOTTOMRIGHT" },
},
close = { -77, -8 },
bar = { 596, 15, 76, -44 },
barBdr = { 604, 22 },
summary = { "TOPRIGHT", -110, -70 },
drop = { 40, -60 },
scroll = { 296, 330, 37, -98 },
-- 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 },
quads = {
{ "Interface\\ClassTrainerFrame\\UI-ClassTrainer-TopLeft", 256, 256, "TOPLEFT" },
{ "Interface\\ClassTrainerFrame\\UI-ClassTrainer-TopRight", 128, 256, "TOPRIGHT" },
{ "Interface\\TradeSkillFrame\\UI-TradeSkill-BotLeft", 256, 256, "BOTTOMLEFT" },
{ "Interface\\ClassTrainerFrame\\UI-ClassTrainer-BotRight", 128, 256, "BOTTOMRIGHT" },
},
close = { -29, -8 },
bar = { 268, 15, 73, -37 },
barBdr = { 278, 22 },
summary = { "TOPRIGHT", -40, -58 },
drop = { 12, -62 },
scroll = { 296, 130, 21, -96 },
-- 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",
[185] = "INV_Misc_Food_15", -- Cooking
[333] = "Trade_Engraving", -- Enchanting
[202] = "Trade_Engineering",
[129] = "Spell_Holy_SealOfSacrifice", -- First Aid
[165] = "INV_Misc_ArmorKit_17", -- Leatherworking
[197] = "Trade_Tailoring",
[186] = "Trade_Mining",
[182] = "Trade_Herbalism",
[393] = "INV_Misc_Pelt_Wolf_01", -- Skinning
[356] = "Trade_Fishing",
};
if TURTLE then
PROFESSION_ICONS[142] = "Trade_Survival"
PROFESSION_ICONS[755] = "INV_Misc_Gem_01" -- Jewelcrafting
end
local function ProfessionIcon(skillLineID)
local icon = PROFESSION_ICONS[skillLineID];
return icon and ("Interface\\Icons\\" .. icon)
or "Interface\\Icons\\INV_Misc_QuestionMark";
end
local function DifficultyBand(skill, hi, lo)
if not hi or hi <= 0 then
return 3;
end
if not lo or lo == 0 then
lo = (hi ~= 25) and (hi - 25) or 0;
end
local yellow = (hi + lo) / 2;
if skill < lo then
return 0; -- orange (optimal)
elseif skill < yellow then
return 1; -- yellow (medium)
elseif skill < hi then
return 2; -- green (easy)
end
return 3; -- grey (trivial)
end
local BAND_COLOR = {
[0] = DIFFICULT_DIFFICULTY_COLOR, -- orange
[1] = FAIR_DIFFICULTY_COLOR, -- yellow
[2] = EASY_DIFFICULTY_COLOR, -- green
[3] = TRIVIAL_DIFFICULTY_COLOR, -- grey
};
local function CraftableCount(entry)
local reagents = entry.reagents;
if not reagents or table.getn(reagents) == 0 then
return nil;
end
local best;
for i = 1, table.getn(reagents) do
local r = reagents[i];
local need = r.count or 1;
local n = math.floor((C_Item.GetItemCount(r.itemID) or 0) / need);
if not best or n < best then
best = n;
end
end
return best;
end
local function ParseTradeLink(link)
-- Current format, with the linker's name: trade:id:cur:max:Name:bits
local _, _, id, cur, max, who, bits =
string.find(link, "^trade:(%d+):(%d+):(%d+):([^:]+):([%w%+/]*)$");
if not id then
-- Legacy format (no name): trade:id:cur:max:bits
who = nil;
_, _, id, cur, max, bits =
string.find(link, "^trade:(%d+):(%d+):(%d+):([%w%+/]*)$");
end
if not id then
return nil;
end
return tonumber(id), tonumber(cur), tonumber(max), who, bits or "";
end
------------------------------------------------------------------------------
-- Display frame (built lazily on first use)
------------------------------------------------------------------------------
local frame;
local function ResolveSubclasses(list, onLoaded)
local present, subs, anyItem = {}, {}, false;
for i = 1, table.getn(list) do
local r = list[i];
if r.createdItem and r.createdItem > 0 then
anyItem = true;
if not r.subclass then
-- GetItemInfoInstant's 3rd return is the localized subclass
-- name (or nil when uncached).
local _, _, subType = C_Item.GetItemInfoInstant(r.createdItem);
if subType and subType ~= "" then
r.subclass = subType;
elseif onLoaded then
-- Not cached — re-derive when the item loads.
Item:CreateFromItemID(r.createdItem):ContinueOnItemLoad(onLoaded);
end
end
if r.subclass and not present[r.subclass] then
present[r.subclass] = true;
table.insert(subs, r.subclass);
end
end
end
table.sort(subs);
return subs, anyItem;
end
local SUBCLASS_ALL = {};
local function SubClassButton_OnClick()
local v = this.value;
if v == SUBCLASS_ALL then
v = nil;
end
frame.subclassFilter = v;
UIDropDownMenu_SetText(v or ALL_SUBCLASSES, frame.dropdown);
frame:ApplyFilter();
end
local function SubClassDropDown_Initialize()
local info = {};
info.text = ALL_SUBCLASSES;
info.value = SUBCLASS_ALL;
info.checked = (frame.subclassFilter == nil);
info.func = SubClassButton_OnClick;
UIDropDownMenu_AddButton(info);
local subs = frame.subclasses or {};
for i = 1, table.getn(subs) do
local b = {};
b.text = subs[i];
b.value = subs[i];
b.checked = (frame.subclassFilter == subs[i]);
b.func = SubClassButton_OnClick;
UIDropDownMenu_AddButton(b);
end
end
local function RefreshSubclasses()
if not (frame and frame:IsShown() and frame.recipes) then
return;
end
local subs, anyItem = ResolveSubclasses(frame.recipes, RefreshSubclasses);
frame.subclasses = subs;
if anyItem then
UIDropDownMenu_Initialize(frame.dropdown, SubClassDropDown_Initialize);
UIDropDownMenu_SetWidth(120, frame.dropdown);
UIDropDownMenu_SetText(frame.subclassFilter or ALL_SUBCLASSES, frame.dropdown);
frame.dropdown:Show();
else
frame.dropdown:Hide();
end
if frame.subclassFilter then
frame:ApplyFilter();
end
end
local function CreateFrame_TradeSkillLink()
local f = CreateFrame("Frame", "ClassicAPITradeSkillLinkFrame", UIParent);
f:SetSize(FRAME_W, FRAME_H);
f:SetPoint("CENTER", 0, 0);
f:SetMovable(true);
f:EnableMouse(true);
f:SetHitRectInsets(LO.hit[1], LO.hit[2], LO.hit[3], LO.hit[4]);
f:RegisterForDrag("LeftButton");
f:SetScript("OnDragStart", function() this:StartMoving(); end);
f:SetScript("OnDragStop", function() this:StopMovingOrSizing(); end);
f:SetFrameStrata("DIALOG");
f:Hide();
tinsert(UISpecialFrames, f:GetName())
local portrait = f:CreateTexture(nil, "BACKGROUND");
portrait:SetSize(60, 60);
portrait:SetPoint("TOPLEFT", 7, -6);
f.portrait = portrait;
for i = 1, table.getn(LO.quads) do
local q = LO.quads[i];
local tx = f:CreateTexture(nil, "BORDER");
tx:SetTexture(q[1]);
tx:SetSize(q[2], q[3]);
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;
local close = CreateFrame("Button", nil, f, "UIPanelCloseButton");
close:SetPoint("TOPRIGHT", LO.close[1], LO.close[2]);
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:SetScript("OnClick", function() f:Hide(); end);
local bar = CreateFrame("StatusBar", nil, f);
bar:SetSize(LO.bar[1], LO.bar[2]);
bar:SetPoint("TOPLEFT", LO.bar[3], LO.bar[4]);
bar:SetStatusBarTexture("Interface\\PaperDollInfoFrame\\UI-Character-Skills-Bar");
bar:SetStatusBarColor(0.0, 0.0, 1.0, 0.5);
bar:SetMinMaxValues(0, 1);
local barBg = bar:CreateTexture(nil, "BACKGROUND");
barBg:SetAllPoints(bar);
barBg:SetTexture(1, 1, 1, 0.2);
barBg:SetVertexColor(0.0, 0.0, 0.75, 0.5);
-- Border: tooltip edge around the bar (TradeSkillRankFrameBorder).
local border = CreateFrame("Frame", nil, bar);
border:SetSize(LO.barBdr[1], LO.barBdr[2]);
border:SetPoint("LEFT", -5, 1);
border:SetBackdrop({
edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border", edgeSize = 16,
});
border:SetBackdropBorderColor(0.4, 0.4, 0.4);
local barName = bar:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall");
barName:SetPoint("LEFT", 6, 1);
local barRank = bar:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall");
barRank:SetJustifyH("LEFT");
barRank:SetWidth(128);
barRank:SetPoint("LEFT", barName, "RIGHT", 13, 0);
f.bar = bar;
f.barName = barName;
f.barRank = barRank;
local summary = f:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall");
summary:SetPoint(LO.summary[1], LO.summary[2], LO.summary[3]);
f.summary = summary;
local dropdown = CreateFrame("Frame", "ClassicAPITradeSkillLinkSubClass", f,
"UIDropDownMenuTemplate");
dropdown:SetPoint("TOPLEFT", LO.drop[1], LO.drop[2]);
f.dropdown = dropdown;
f.subclassFilter = nil; -- nil = all subclasses
local scroll = CreateFrame("ScrollFrame", "ClassicAPITradeSkillLinkScroll",
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()
FauxScrollFrame_OnVerticalScroll(ROW_HEIGHT, function() f:Refresh(); end)
end)
f.scroll = scroll;
f.rows = {};
for i = 1, NUM_ROWS do
local rowName = "ClassicAPITradeSkillLinkRow" .. i;
local row = CreateFrame("Button", rowName, f,
"ClassTrainerSkillButtonTemplate");
row:SetWidth(290); -- height (16) comes from the template
if i == 1 then
row:SetPoint("TOPLEFT", scroll, "TOPLEFT", 0, 0);
else
row:SetPoint("TOPLEFT", f.rows[i - 1], "BOTTOMLEFT", 0, 0);
end
row:SetNormalTexture("");
row:SetDisabledTexture("");
row:SetHighlightTexture("");
local txt = getglobal(rowName .. "Text");
if txt then
txt:ClearAllPoints();
txt:SetPoint("LEFT", row, "LEFT", 4, 0);
end
local sel = row:CreateTexture(nil, "BACKGROUND");
sel:SetTexture("Interface\\Buttons\\UI-Listbox-Highlight2");
sel:SetAllPoints(row);
sel:Hide();
row.select = sel;
-- Override the template's trainer scripts with ours.
row:SetScript("OnClick", function()
if this.entry then
f:SelectRecipe(this.entry);
end
end);
f.rows[i] = row;
end
-- 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]);
detailScroll.scrollBarHideable = 1; -- template sets this too; belt-and-suspenders
f.detailScroll = detailScroll;
-- Off until a recipe overflows the pane (toggled in RenderDetail).
-- The template shows its slider and enables the wheel by default; on
-- the roomy Turtle pane that leaves a dead scrollbar that still eats
-- wheel events and over-scrolls a hair. Start both disabled.
detailScroll:EnableMouseWheel(false);
local detailSB = getglobal("ClassicAPITradeSkillLinkDetailScrollScrollBar");
if detailSB then
detailSB:Hide();
end
-- The template re-shows its slider when the frame transitions to shown
-- (on first open, before the auto-selected recipe's RenderDetail runs),
-- leaving a dead scrollbar until the first click. Re-assert the hidden
-- state on show unless the current recipe genuinely overflows.
detailScroll:SetScript("OnShow", function()
f:UpdateDetailScrollbar();
RunNextFrame(function()
if f:IsShown() then
f:UpdateDetailScrollbar();
end
end);
end);
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 = 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 = detailChild:CreateTexture(nil, "BACKGROUND");
hdrRight:SetTexture("Interface\\ClassTrainerFrame\\UI-ClassTrainer-DetailHeaderRight");
hdrRight:SetSize(64, 64);
hdrRight:SetPoint("TOPLEFT", hdrLeft, "TOPRIGHT", 0, 0);
local prodCount = prodIcon:CreateFontString(nil, "ARTWORK", "NumberFontNormal");
prodCount:SetPoint("BOTTOMRIGHT", -5, 2);
prodIcon.count = prodCount;
f.prodIcon = prodIcon;
local prodName = detailChild:CreateFontString(nil, "ARTWORK", "GameFontNormal");
prodName:SetPoint("LEFT", prodIcon, "RIGHT", 5, 0);
prodName:SetWidth(LO.detail[3] - 60);
prodName:SetJustifyH("LEFT");
prodName:SetJustifyV("MIDDLE");
f.prodName = prodName;
local prodDesc = detailChild:CreateFontString(nil, "ARTWORK", "GameFontHighlightSmall");
prodDesc:SetPoint("TOPLEFT", prodIcon, "BOTTOMLEFT", 0, -6);
prodDesc:SetWidth(LO.detail[3] - 20);
prodDesc:SetJustifyH("LEFT");
prodDesc:SetJustifyV("TOP");
f.prodDesc = prodDesc;
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 = {};
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, detailChild);
btn:SetSize(39, 39);
btn:SetPoint("TOPLEFT", reagentLabel, "BOTTOMLEFT",
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(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(80, 36);
nm:SetJustifyH("LEFT");
nm:SetJustifyV("MIDDLE");
btn.nameText = nm;
btn:SetScript("OnEnter", function()
if this.itemLink then
GameTooltip:SetOwner(this, "ANCHOR_RIGHT");
GameTooltip:SetHyperlink(this.itemLink);
GameTooltip:Show();
end
end);
btn:SetScript("OnLeave", GameTooltip_Hide);
f.reagents[i] = btn;
return btn;
end
-- Show the detail slider + enable the wheel only when the current
-- recipe overflows the pane (tracked by `detailNeedScroll`).
function f:UpdateDetailScrollbar()
local need = self.detailNeedScroll and true or false;
self.detailScroll:EnableMouseWheel(need);
local base = "ClassicAPITradeSkillLinkDetailScroll";
local sb = getglobal(base .. "ScrollBar");
if sb then sb:SetShown(need) end
local top = getglobal(base .. "Top");
if top then top:SetShown(need) end
local bottom = getglobal(base .. "Bottom");
if bottom then bottom:SetShown(need) end
end
function f:RenderDetail(entry)
local function itemInfo(id)
local item = Item:CreateFromItemID(id);
if not item:IsItemDataCached() then
item:ContinueOnItemLoad(function()
if self:IsShown() and self.selectedSpellID == entry.spellID then
self:RenderDetail(entry);
end
end);
end
return item:GetItemName(), item:GetItemIcon(), item:GetItemLink();
end
local prodTex, prodText, prodLink;
local created = entry.createdItem or 0;
if created > 0 then
local name, tex, link = itemInfo(created);
prodText = name or entry.name;
prodTex = tex;
prodLink = link;
else
prodText = entry.name;
prodTex = C_Spell.GetSpellTexture(entry.spellID);
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 "");
self.prodName:SetTextColor(NORMAL_FONT_COLOR:GetRGB());
self.prodIcon.itemLink = prodLink; -- item tooltip when created > 0
self.prodIcon.spellID = entry.spellID; -- else spell tooltip
local made = entry.numMade or 0;
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 {};
local total = table.getn(reagents);
for i = 1, total do
local btn = AcquireReagent(i);
local r = reagents[i];
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();
end
for i = total + 1, table.getn(self.reagents) do
self.reagents[i].itemLink = nil;
self.reagents[i]:Hide();
end
-- Grow the scroll child to the content height so the pane scrolls
-- once the header + reagent rows exceed the visible area. Reagent
-- row 0 sits ~67px below the child top (icon + label); add the
-- wrapped description height for spell recipes.
local descH = (descText ~= "") and self.prodDesc:GetHeight() or 0;
local rows = math.ceil(total / 2);
local contentH = 67 + descH + rows * REAGENT_ROW_HEIGHT + 8;
self.detailChild:SetHeight(math.max(contentH, LO.detail[4]));
-- Reset to the top and manage the slider explicitly: a range that
-- stays 0 (content fits, e.g. Turtle's roomy pane) never fires the
-- range-changed event that `scrollBarHideable` relies on, so the
-- slider would otherwise sit visible-but-useless. Show it only when
-- the content actually overflows.
self.detailNeedScroll = contentH > LO.detail[4];
local sb = getglobal("ClassicAPITradeSkillLinkDetailScrollScrollBar");
if sb then
sb:SetValue(0);
end
self.detailScroll:SetVerticalScroll(0);
self:UpdateDetailScrollbar();
RunNextFrame(function()
if self:IsShown() then
self:UpdateDetailScrollbar();
end
end)
end
function f:SelectRecipe(entry)
self.selectedSpellID = entry.spellID;
self.selectedEntry = entry;
self:RenderDetail(entry); -- also resets the detail scroll to top
self:Refresh();
end
function f:ClearDetail()
self.selectedSpellID = nil;
self.selectedEntry = nil;
self.prodIcon:Hide();
self.prodName:SetText("");
self.prodDesc:SetText("");
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.detailNeedScroll = false;
self:UpdateDetailScrollbar();
self.detailScroll:SetVerticalScroll(0);
end
function f:Refresh()
local recipes = self.displayed or {};
local total = table.getn(recipes);
FauxScrollFrame_Update(self.scroll, total, NUM_ROWS, ROW_HEIGHT);
local offset = FauxScrollFrame_GetOffset(self.scroll);
for i = 1, NUM_ROWS do
local row = self.rows[i];
local entry = recipes[offset + i];
if entry then
-- Append " [N]" when the reader can craft any (Blizzard's list).
local craftable = CraftableCount(entry);
local label = entry.name or "";
if craftable and craftable > 0 then
label = label .. " [" .. craftable .. "]";
end
row:SetText(label);
local r, g, b = BAND_COLOR[entry.band or 3]:GetRGB();
if self.selectedSpellID and entry.spellID == self.selectedSpellID then
row.select:SetVertexColor(r, g, b);
row.select:Show();
row:SetTextColor(1, 1, 1);
else
row.select:Hide();
row:SetTextColor(r, g, b);
end
row.spellID = entry.spellID;
row.entry = entry;
row:Show();
else
row.spellID = nil;
row.entry = nil;
row.select:Hide();
row:Hide();
end
end
end
function f:ApplyFilter()
local src = self.recipes or {};
if not self.subclassFilter then
self.displayed = src;
else
local out = {};
for i = 1, table.getn(src) do
if src[i].subclass == self.subclassFilter then
table.insert(out, src[i]);
end
end
self.displayed = out;
end
local sb = getglobal("ClassicAPITradeSkillLinkScrollScrollBar");
if sb then
sb:SetValue(0);
end
self:Refresh();
end
f:RegisterEvent("BAG_UPDATE_DELAYED");
f:SetScript("OnEvent", function()
if not f:IsShown() then
return;
end
f:Refresh();
if f.selectedEntry then
f:RenderDetail(f.selectedEntry);
end
end);
return f;
end
local function ShowTradeSkillLink(link, text)
local id, cur, max, who, bits = ParseTradeLink(link);
if not id then
return false;
end
local recipes = C_TradeSkillUI.GetTradeSkillListRecipes(id, bits);
if not recipes then
return false;
end
local prof = text and string.match(text, "|h%[(.+)%]|h") or "Trade Skills";
local total = table.getn(recipes);
local knownList = {};
for i = 1, total do
local r = recipes[i];
if r.isKnown then
r.band = DifficultyBand(cur or 0, r.trivialLevel, r.greenLevel);
r.name = GetSpellInfo(r.spellID) or ("spell:" .. r.spellID);
table.insert(knownList, r);
end
end
table.sort(knownList, function(a, b)
if a.band ~= b.band then
return a.band < b.band;
end
return a.name < b.name;
end);
local known = table.getn(knownList);
if not frame then
frame = CreateFrame_TradeSkillLink();
end
frame.title:SetText(who and who ~= "" and (who .. " - " .. prof) or prof);
SetPortraitToTexture(frame.portrait, ProfessionIcon(id));
frame.bar:SetMinMaxValues(0, (max and max > 0) and max or 1);
frame.bar:SetValue(cur or 0);
frame.barName:SetText(prof);
frame.barRank:SetText(string.format("%d/%d", cur or 0, max or 0));
frame.summary:SetText(string.format("%d of %d recipes known", known, total));
frame.recipes = knownList;
frame.subclassFilter = nil;
frame:ClearDetail();
frame:Show();
RefreshSubclasses();
frame:ApplyFilter();
local first = frame.displayed and frame.displayed[1];
if first then
frame:SelectRecipe(first);
end
return true;
end
------------------------------------------------------------------------------
-- Click interception: handle `trade:` before the engine's SetHyperlink (which
-- in 1.12 only knows item:/enchant: and would reject the link).
------------------------------------------------------------------------------
local originalSetItemRef = SetItemRef;
function SetItemRef(link, text, button, chatFrame)
if type(link) == "string" and string.sub(link, 1, 6) == "trade:" then
if IsShiftKeyDown() and ChatFrameEditBox and ChatFrameEditBox:IsVisible() then
ChatFrameEditBox:Insert(text);
return;
end
if ShowTradeSkillLink(link, text) then
return;
end
end
return originalSetItemRef(link, text, button, chatFrame);
end
local function CreateProfessionLinkButton(parentName, getLink)
local parent = _G[parentName];
local btnName = parentName .. "LinkButton";
if _G[btnName] or not parent then
return;
end
local title = _G[parentName .. "TitleText"];
local btn = CreateFrame("Button", btnName, parent);
btn:SetSize(16, 16);
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);
btn:SetHighlightTexture(icon, "ADD");
btn:SetScript("OnClick", function()
local link = getLink();
if not link then
return;
end
if not ChatFrameEditBox:IsVisible() then
ChatFrameEditBox:Show();
end
ChatFrameEditBox:Insert(link);
end);
btn:SetScript("OnEnter", function()
GameTooltip:SetOwner(this, "ANCHOR_TOPLEFT");
GameTooltip:SetText(LINK_TRADESKILL_TOOLTIP, nil, nil, nil, nil, 1);
GameTooltip:Show();
end);
btn:SetScript("OnLeave", GameTooltip_Hide);
end
EventUtil.ContinueOnAddOnLoaded("Blizzard_TradeSkillUI", function()
CreateProfessionLinkButton("TradeSkillFrame", C_TradeSkillUI.GetTradeSkillListLink);
end)
EventUtil.ContinueOnAddOnLoaded("Blizzard_CraftUI", function()
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)
+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.";
+25
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";
@@ -24,3 +48,4 @@ SLASH_EQUIP_SET2 = "/equipset";
SLASH_FOCUS1 = "/focus";
SLASH_FOCUS2 = "/focus";
TARGETFOCUS = "Target Focus";
LINK_TRADESKILL_TOOLTIP = "Click here to create a link to your profession.";
+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 = "點擊這裡以製造一個你專業技能的連結。";
+17 -7
View File
@@ -18,7 +18,7 @@ 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` |
| [Chat](docs/API.md#chat) | `GetCurrentChatGUID` |
| [Class](docs/API.md#class) | `FillLocalizedClassList` |
| [Combat](docs/API.md#combat) | `InCombatLockdown` |
@@ -33,33 +33,39 @@ Full per-function reference: **[docs/API.md](docs/API.md)**.
| [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` |
| [Gossip](docs/API.md#gossip) | `C_GossipInfo.CloseGossip`, `C_GossipInfo.GetActiveQuests`, `C_GossipInfo.GetAvailableQuests`, `C_GossipInfo.GetNumActiveQuests`, `C_GossipInfo.GetNumAvailableQuests`, `C_GossipInfo.GetNumOptions`, `C_GossipInfo.GetOptions`, `C_GossipInfo.GetText`, `C_GossipInfo.SelectActiveQuest`, `C_GossipInfo.SelectAvailableQuest`, `C_GossipInfo.SelectOption`, `C_GossipInfo.SelectOptionByIndex` |
| [GameTooltip](docs/API.md#gametooltip) | `GameTooltip:GetGameObject`, `GameTooltip:GetItem`, `GameTooltip:GetOwner`, `GameTooltip:GetSpell`, `GameTooltip:GetUnitGUID`, `GameTooltip:HasGameObject`, `GameTooltip:HasItem`, `GameTooltip:HasSpell`, `GameTooltip:HasUnit`, `GameTooltip:IsEquippedItem`, `GameTooltip:SetEquipmentSet`, `GameTooltip:SetHyperlinkCompareItem`, `GameTooltip:SetInventoryItemByID`, `GameTooltip:SetItemByGUID`, `GameTooltip:SetItemByID`, `GameTooltip:SetSpellByID`, `GameTooltip:SetTalentByID`, `GameTooltip:SetUnitAura`, `OnTooltipSetItem` (script) |
| [Hooks](docs/API.md#hooks) | `hooksecurefunc` |
| [Input](docs/API.md#input) | `IsLeftAltKeyDown`, `IsLeftControlKeyDown`, `IsLeftShiftKeyDown`, `IsModifierKeyDown`, `IsMouseButtonDown`, `IsRightAltKeyDown`, `IsRightControlKeyDown`, `IsRightShiftKeyDown` |
| [Input](docs/API.md#input) | `GetMouseButtonClicked`, `IsLeftAltKeyDown`, `IsLeftControlKeyDown`, `IsLeftShiftKeyDown`, `IsModifierKeyDown`, `IsMouseButtonDown`, `IsRightAltKeyDown`, `IsRightControlKeyDown`, `IsRightShiftKeyDown` |
| [Instance](docs/API.md#instance) | `GetInstanceInfo` |
| [Item](docs/API.md#item) | `C_Item.DoesItemExist`, `C_Item.DoesItemExistByID`, `C_Item.EquipItemByName`, `C_Item.GetCurrentItemLevel`, `C_Item.GetDetailedItemLevelInfo`, `C_Item.GetEnchantInfo`, `C_Item.GetItemCount`, `C_Item.GetItemFamily`, `C_Item.GetItemGUID`, `C_Item.GetItemIcon`, `C_Item.GetItemIconByID`, `C_Item.GetItemID`, `C_Item.GetItemInfoInstant`, `C_Item.GetItemInventorySlotInfo`, `C_Item.GetItemInventorySlotKey`, `C_Item.GetItemInventoryType`, `C_Item.GetItemInventoryTypeByID`, `C_Item.GetItemLink`, `C_Item.GetItemLocation`, `C_Item.GetItemMaxStackSize`, `C_Item.GetItemMaxStackSizeByID`, `C_Item.GetItemName`, `C_Item.GetItemNameByID`, `C_Item.GetItemQuality`, `C_Item.GetItemQualityByID`, `C_Item.GetItemSellPrice`, `C_Item.GetItemSellPriceByID`, `C_Item.GetItemSetID`, `C_Item.GetItemSetIDByID`, `C_Item.GetItemSetInfo`, `C_Item.GetItemSpell`, `C_Item.GetItemStatDelta`, `C_Item.GetItemStats`, `C_Item.GetItemSubClassInfo`, `C_Item.GetItemUniqueness`, `C_Item.GetItemUniquenessByID`, `C_Item.GetStackCount`, `C_Item.GetWeaponEnchantInfo`, `C_Item.IsBound`, `C_Item.IsEquippableItem`, `C_Item.IsEquippedItem`, `C_Item.IsItemDataCached`, `C_Item.IsItemDataCachedByID`, `C_Item.IsItemGUIDInInventory`, `C_Item.IsItemOpenable`, `C_Item.IsLocked`, `C_Item.LockItem`, `C_Item.LockItemByGUID`, `C_Item.UnlockAllItems`, `C_Item.UnlockItem`, `C_Item.RequestLoadItemData`, `C_Item.RequestLoadItemDataByID`, `C_Item.UseAtCursor`, `C_Item.UseAtUnit`, `C_Item.UseItemByName`, `GetAuctionItemID`, `GetAuctionSellItemID`, `GetAverageItemLevel`, `GetCraftReagentItemID`, `GetInboxItemID`, `GetInventoryItemDurability`, `GetInventoryItemID`, `GetInventoryItemsForSlot`, `GetInventoryItemRepairCost`, `GetItemIcon`, `GetLootRollItemID`, `GetLootSlotItemID`, `GetMerchantItemID`, `GetQuestItemID`, `GetQuestLogItemID`, `GetTradePlayerItemID`, `GetTradeSkillItemID`, `GetTradeSkillReagentItemID`, `GetTradeTargetItemID`, `OffhandHasWeapon` |
| [Loot](docs/API.md#loot) | `C_Loot.GetNearbyLootableUnits`, `C_Loot.GetLastScanResults`, `C_Loot.IsScanInProgress`, `C_Loot.LootUnit`, `C_Loot.LootUnitItem`, `C_Loot.ScanNearbyLoot` |
| [LootHistory](docs/API.md#loothistory) | `C_LootHistory.GetNumItems`, `C_LootHistory.GetItem`, `C_LootHistory.GetPlayerInfo`, `C_LootHistory.Clear` |
| [LossOfControl](docs/API.md#lossofcontrol) | `C_LossOfControl.GetActiveLossOfControlData`, `C_LossOfControl.GetActiveLossOfControlDataCount` |
| [Macros](docs/API.md#macros) | `GetLooseMacroIcons`, `GetLooseMacroItemIcons`, `GetMacroIcons`, `GetMacroItemIcons`, `GetMacroSpell` |
| [Mail](docs/API.md#mail) | `GetInboxItemLink`, `GetSendMailItemLink` |
| [Map](docs/API.md#map) | `C_Map.GetBestMapForUnit` |
| [Map](docs/API.md#map) | `C_Map.GetBestMapForUnit`, `C_Map.GetMapOverlays` |
| [MerchantFrame](docs/API.md#merchantframe) | `C_MerchantFrame.GetBuybackItemID`, `C_MerchantFrame.GetItemInfo`, `C_MerchantFrame.GetNumJunkItems`, `C_MerchantFrame.IsMerchantItemRefundable`, `C_MerchantFrame.IsSellAllJunkEnabled`, `C_MerchantFrame.SellAllJunkItems` |
| [NamePlate](docs/API.md#nameplate) | `C_NamePlate.GetNamePlateForGUID`, `C_NamePlate.GetNamePlateForUnit`, `C_NamePlate.GetNamePlateGUIDs`, `C_NamePlate.GetNamePlates` |
| [NameCache](docs/API.md#namecache) | `C_PlayerCache.GetPlayerInfoByName`, `C_PlayerCache.IsEnabled`, `C_PlayerCache.IsScanEnabled`, `C_PlayerCache.RememberPlayer`, `C_PlayerCache.SetEnabled`, `C_PlayerCache.SetScanEnabled`, `C_PlayerInfo.GUIDIsCreature`, `C_PlayerInfo.GUIDIsGameObject`, `C_PlayerInfo.GUIDIsPet`, `C_PlayerInfo.GUIDIsPlayer`, `GetPlayerInfoByGUID`, `UnitNameFromGUID` |
| [NameCache](docs/API.md#namecache) | `C_PlayerCache.GetPlayerInfoByName`, `C_PlayerCache.IsEnabled`, `C_PlayerCache.IsScanEnabled`, `C_PlayerCache.RememberPlayer`, `C_PlayerCache.SetEnabled`, `C_PlayerCache.SetScanEnabled`, `GetPlayerInfoByGUID`, `UnitNameFromGUID` |
| [PlayerInfo](docs/API.md#playerinfo) | `C_PlayerInfo.CanUseItem`, `C_PlayerInfo.GUIDIsCreature`, `C_PlayerInfo.GUIDIsGameObject`, `C_PlayerInfo.GUIDIsPet`, `C_PlayerInfo.GUIDIsPlayer` |
| [Quest](docs/API.md#quest) | `C_QuestLog.GetNumQuestObjectives`, `C_QuestLog.GetQuestDetails`, `C_QuestLog.GetQuestIDForLogIndex`, `C_QuestLog.GetTitleForQuestID`, `C_QuestLog.IsOnQuest`, `C_QuestLog.IsQuestDataCachedByID`, `C_QuestLog.IsUnitOnQuest`, `C_QuestLog.RequestLoadQuestByID`, `GetQuestLogLeaderBoardID` |
| [Screen](docs/API.md#screen) | `GetPhysicalScreenSize` |
| [Spell](docs/API.md#spell) | `C_Spell.CancelSpellByID`, `C_Spell.CastAtCursor`, `C_Spell.CastAtUnit`, `C_Spell.CastingInfo`, `C_Spell.ChannelInfo`, `C_Spell.DoesSpellExist`, `C_Spell.GetSchoolString`, `C_Spell.GetSpellCooldown`, `C_Spell.GetSpellDescription`, `C_Spell.GetSpellInfo`, `C_Spell.GetSpellLink`, `C_Spell.GetSpellMechanicByID`, `C_Spell.GetSpellName`, `C_Spell.GetSpellPowerCost`, `C_Spell.GetSpellRadius`, `C_Spell.GetSpellReagents`, `C_Spell.GetSpellSubtext`, `C_Spell.GetSpellTexture`, `C_Spell.IsAutoAttackSpell`, `C_Spell.IsCurrentSpell`, `C_Spell.IsRangedAutoAttackSpell`, `C_Spell.IsSelfBuff`, `C_Spell.IsSpellHarmful`, `C_Spell.IsSpellHelpful`, `C_Spell.IsSpellPassive`, `C_Spell.IsSpellUsable`, `C_Spell.SpellHasRange`, `C_Spell.UnitCastingInfo`, `C_Spell.UnitChannelInfo`, `CancelSpellByName`, `CastSpellNoToggle`, `GetCraftSpellID`, `GetSpellInfo`, `GetSpellLink`, `GetSpellRadius`, `GetSpellSchool`, `IsHarmfulSpell`, `IsHelpfulSpell`, `IsPassiveSpell`, `IsPlayerSpell`, `IsSpellKnown`, `IsUsableSpell`, `SpellHasRange` |
| [Spell](docs/API.md#spell) | `C_Spell.CancelSpellByID`, `C_Spell.CastAtCursor`, `C_Spell.CastAtUnit`, `C_Spell.CastingInfo`, `C_Spell.ChannelInfo`, `C_Spell.DoesSpellExist`, `C_Spell.GetSchoolString`, `C_Spell.GetSpellCooldown`, `C_Spell.GetSpellDescription`, `C_Spell.GetSpellInfo`, `C_Spell.GetSpellLevelInfo`, `C_Spell.GetSpellLink`, `C_Spell.GetSpellMechanicByID`, `C_Spell.GetSpellName`, `C_Spell.GetSpellPowerCost`, `C_Spell.GetSpellRadius`, `C_Spell.GetSpellReagents`, `C_Spell.GetSpellRequiredTargetLevel`, `C_Spell.GetSpellSubtext`, `C_Spell.GetSpellTexture`, `C_Spell.IsAutoAttackSpell`, `C_Spell.IsCurrentSpell`, `C_Spell.IsRangedAutoAttackSpell`, `C_Spell.IsSelfBuff`, `C_Spell.IsSpellHarmful`, `C_Spell.IsSpellHelpful`, `C_Spell.IsSpellPassive`, `C_Spell.IsSpellUsable`, `C_Spell.SpellHasRange`, `C_Spell.UnitCastingInfo`, `C_Spell.UnitChannelInfo`, `CancelSpellByName`, `CastSpellNoToggle`, `GetCraftSpellID`, `GetSpellInfo`, `GetSpellLink`, `GetSpellRadius`, `GetSpellRequiredTargetLevel`, `GetSpellSchool`, `IsHarmfulSpell`, `IsHelpfulSpell`, `IsPassiveSpell`, `IsPlayerSpell`, `IsSpellKnown`, `IsUsableSpell`, `SpellHasRange` |
| [SpellBook](docs/API.md#spellbook) | `C_SpellBook.GetCurrentLevelSpells`, `C_SpellBook.GetSpellLevelLearned`, `C_SpellBook.GetSpellSkillLine`, `C_SpellBook.IsAutoAttackSpellBookItem`, `C_SpellBook.IsRangedAutoAttackSpellBookItem`, `FindSpellBookSlotByID` |
| [State](docs/API.md#state) | `CancelShapeshiftForm`, `Dismount`, `GetMirrorTimerInfo`, `GetMirrorTimerProgress`, `GetShapeshiftFormID`, `IsAssistingRitual`, `IsFalling`, `IsInGroup`, `IsInRaid`, `IsLoggedIn`, `IsMounted`, `IsStealthed`, `IsSwimming` |
| [Table](docs/API.md#table) | `table.wipe` |
| [Talent](docs/API.md#talent) | `GetTalentIDByIndex`, `GetTalentSpellID` |
| [Time](docs/API.md#time) | `C_DateAndTime.AdjustTimeByDays`, `C_DateAndTime.AdjustTimeByMinutes`, `C_DateAndTime.CompareCalendarTime`, `C_DateAndTime.GetCalendarTimeFromEpoch`, `C_DateAndTime.GetCurrentCalendarTime`, `C_DateAndTime.GetSecondsUntilDailyReset`, `C_DateAndTime.GetServerTimeLocal`, `C_Timer.After`, `C_Timer.NewTicker`, `C_Timer.NewTimer`, `GetServerTime` |
| [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` |
| [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` |
| [Unit](docs/API.md#unit) | `GetUnitSpeed`, `UnitClassBase`, `UnitDistanceSquared`, `UnitGUID`, `UnitInRange`, `UnitIsAFK`, `UnitIsDND`, `UnitIsFeignDeath`, `UnitIsInMyGuild`, `UnitIsMinion`, `UnitIsOtherPlayersPet`, `UnitIsPet`, `UnitIsPossessed`, `UnitPower`, `UnitPowerMax`, `UnitPowerType`, `UnitRaceBase`, `UnitStandState`, `UnitSubName`, `UnitTokenFromGUID` |
| [UnitAuras](docs/API.md#unitauras) | `C_UnitAuras.GetAuraDataByIndex`, `C_UnitAuras.GetAuraDataBySpellName`, `C_UnitAuras.GetAuraDispelTypeColor`, `C_UnitAuras.GetBuffDataByIndex`, `C_UnitAuras.GetDebuffDataByIndex`, `C_UnitAuras.GetPlayerAuraBySpellID`, `C_UnitAuras.GetUnitAuraBySpellID`, `C_UnitAuras.GetUnitAuras`, `C_UnitAuras.RegisterComboDuration` |
| [VoiceChat](docs/API.md#voicechat) | `C_VoiceChat.GetTtsVoices`, `C_VoiceChat.GetRemoteTtsVoices`, `C_VoiceChat.SpeakText`, `C_VoiceChat.StopSpeakingText`, `C_TTSSettings.GetSpeechRate`, `C_TTSSettings.GetSpeechVolume`, `C_TTSSettings.GetSpeechVoiceID`, `C_TTSSettings.GetVoiceOptionName`, `C_TTSSettings.SetSpeechRate`, `C_TTSSettings.SetSpeechVolume`, `C_TTSSettings.SetVoiceOption`, `C_TTSSettings.SetVoiceOptionByName`, `C_TTSSettings.SetDefaultSettings`, `C_TTSSettings.RefreshVoices` |
| [XMLUtil](docs/API.md#xmlutil) | `C_XMLUtil.DoesTemplateExist`, `C_XMLUtil.GetTemplateInfo`, `C_XMLUtil.GetTemplates` |
### GlueXML calls
@@ -117,7 +123,11 @@ 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"`) |
| `MODIFIER_STATE_CHANGED` | `keyName, down` |
| `NAME_PLATE_CREATED` | `nameplateFrame` |
| `NAME_PLATE_UNIT_ADDED` | `unitToken` ("nameplateN") |
+903 -65
View File
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -56,7 +56,8 @@ namespace Lua {
F(NewThread, lua_newthread, LUA_NEW_THREAD) \
F(Resume, lua_resume, LUA_RESUME) \
F(Yield, lua_yield, LUA_YIELD) \
F(ArgError, luaL_argerror, LUAL_ARG_ERROR)
F(ArgError, luaL_argerror, LUAL_ARG_ERROR) \
F(SetN, luaL_setn, LUAL_SETN)
#define CLASSICAPI_BIND_LUA(Name, Typedef, Offset) \
const Typedef##_t Name = reinterpret_cast<Typedef##_t>(Offsets::Offset);
+2
View File
@@ -99,6 +99,7 @@ using lua_newthread_t = void *(__fastcall *)(void *L);
using lua_resume_t = int(__fastcall *)(void *L, int nargs);
using lua_yield_t = int(__fastcall *)(void *L, int nresults);
using luaL_argerror_t = void(__fastcall *)(void *L, int narg, const char *msg);
using luaL_setn_t = void(__fastcall *)(void *L, int t, int n);
extern const lua_isnumber_t IsNumber;
extern const lua_isstring_t IsString;
@@ -140,6 +141,7 @@ extern const lua_newthread_t NewThread;
extern const lua_resume_t Resume;
extern const lua_yield_t Yield;
extern const luaL_argerror_t ArgError;
extern const luaL_setn_t SetN;
// Returns the global `lua_State *` (read on demand from the engine's global).
// Callable outside a Lua callback, e.g. during LoadScriptFunctions setup.
+416 -17
View File
@@ -504,6 +504,29 @@ enum Offsets {
// layout.
OFF_UNIT_FIELD_TARGET = 0x28,
// Owner/controller GUID fields within `m_objectFields`, each a 64-bit
// GUID (lo/hi). Both byte-verified from engine readers:
// CHARMEDBY +0x10 — `Script_UnitIsCharmed`
// (`mov ecx,[eax+0x10]; or ecx,[eax+0x14]; jz`).
// CREATEDBY +0x20 — the unit owner/title builder `FUN_0052FD30`
// reads `[desc+0x20]`/`[+0x24]` as the summoner GUID.
// A pet/guardian/totem's owner lives in CREATEDBY; a charmed unit's in
// CHARMEDBY. `Unit::Pet` reads these to find a minion's player owner.
// (The engine uses only these two for ownership — there's a GUID slot
// at +0x18 too, but nothing reads it as an owner, so it's left out.)
OFF_UNIT_FIELD_CHARMEDBY = 0x10,
OFF_UNIT_FIELD_CREATEDBY = 0x20,
// Pet-vs-minion discriminator for an owned unit: `int __fastcall(unit)`.
// Returns 1 for a controllable **pet** (classified by creature family
// at descriptor +0x212 → family DBC, with a ChrRaces fallback), a
// non-1 value otherwise. The engine's unit-title builder `FUN_0052FD30`
// uses it as `(fn(unit) != 1) + 1` → 1 = "X's Pet" title, 2 = "X's
// Minion" — so it's the exact signal separating pet from minion, which
// neither the GUID (pets and guardians share the 0xF14… prefix) nor
// UNIT_FLAG_PLAYER_CONTROLLED reliably does.
FUN_UNIT_PET_MINION_CLASS = 0x00605570,
// Party / raid roster counts and the party GUID array referenced
// in the `FUN_TOKEN_TO_GUID` dispatch comment above. Used by
// `UnitTokenFromGUID` to cap its candidate iteration — solo
@@ -903,6 +926,22 @@ enum Offsets {
// folds in the descriptor cost mods).
FUN_GET_SPELL_COST = 0x006E31B0,
// Full spell-castability check for the LOCAL player:
// `char __fastcall(const uint8_t *spellRecord /*ecx*/,
// int *outNoMana /*edx*/)`.
// Resolves the local player internally (no unit arg), then gates
// on casting state, stun/confuse flags, mechanic immunities,
// shapeshift/form + required stances, aura-state requirements,
// combo points (finishers), spell RequiredSkill, and finally the
// power check: `cost(FUN_GET_SPELL_COST) <= currentPower` → usable,
// else sets `*outNoMana = 1`. Returns nonzero (low byte) when
// usable. Does NOT check spell knowledge — so it's valid to feed
// an item's on-use spell record (which the player never "knows").
// This is the helper the action-usability recompute uses for
// player spell slots; `Item::Usable` reuses it for item on-use
// spells. Returns 0 cleanly pre-world (no player).
FUN_SPELL_IS_USABLE = 0x006E3D60,
// Effective spell/channel duration (ms):
// `__fastcall int(const void *spellRecord, int unit /*0 = player,
// nonzero = pet*/, int skipMod /*0 = apply mods*/)`. SpellDuration
@@ -1046,6 +1085,147 @@ enum Offsets {
// u16 at the cursor, restore, log.
FUN_NET_MESSAGE_DISPATCH = 0x00537AA0,
// NetClient send — `__thiscall void(void *conn, CDataStore *packet)`.
// The outgoing counterpart of FUN_NET_MESSAGE_DISPATCH: every CMSG the
// client sends funnels through it (the argless wrapper `FUN_005AB630`,
// nampower's ClientServices_Send, resolves the connection singleton
// with the packet in ECX and calls this). The packet's leading u32 is
// the opcode; `m_size - m_read` is what goes on the wire.
// `Aura::ComboDuration` co-hooks it to snapshot combo points the
// moment CMSG_CAST_SPELL leaves — before the server consumes them —
// which also covers nampower-queued casts (they re-enter the engine's
// cast path and send normally, unlike a Spell_C_CastSpell co-hook,
// which nampower's trampoline re-entry would bypass).
FUN_NET_SEND = 0x005379A0,
// Outgoing opcode: CMSG_CAST_SPELL. Body: `spellId(u32),
// targetFlags(u16), <target per flags>`.
OP_CMSG_CAST_SPELL = 0x12E,
// Spell.dbc DurationIndex (column 30) — row into SpellDuration.dbc.
// Offset = 30*4, consistent with the verified neighbors
// (CastingTimeIndex col 18 = +0x48, PowerType col 31 = +0x7C).
OFF_SPELL_RECORD_DURATION_INDEX = 0x78,
// SpellDuration.dbc — records/count slots per docs/DBCs.md. Record:
// `{id@+0, baseMs@+4 (i32), perLevelMs@+8 (i32), maxMs@+0xC (i32)}`.
// Combo-point finishers are exactly the rows with base != max: the
// server computes `base + (max - base) * comboPoints / 5`
// ((v)mangos CalculateSpellDuration — verified against the DBC:
// Rupture 6000/16000 = 6+2*CP, Kidney Shot 1000/6000 = 1+1*CP,
// Slice and Dice 6000/21000 = 6+3*CP, Expose Armor 30000/30000 =
// fixed). NOTE: rows can dangle — Turtle's client DBC has no record
// for Rip's DurationIndex 87 (records[87] == NULL), which is why
// Rip reads duration 0 client-side there.
VAR_SPELL_DURATION_RECORDS = 0x00C0D828,
VAR_SPELL_DURATION_COUNT = 0x00C0D82C,
// Combo points, within the CGPlayer `+0xE68` sub-struct (see
// OFF_CGPLAYER_INFO). Verified from Script_GetComboPoints
// (0x0051A190): CP count byte at `[player+0xE68]+0x1029`, the combo
// TARGET's guid (u64) at `[player+0xE68]+0x838` (compared against
// the current-target global 0x00B4E2D8 before the Lua function
// reports a non-zero count).
OFF_CGPLAYER_COMBO_POINTS = 0x1029,
OFF_CGPLAYER_COMBO_TARGET = 0x838,
// ---- Frame-method registries beyond GameTooltip's ------------------
// Same per-type registry mechanism as VAR_GAMETOOLTIP_METHOD_REGISTRY
// (docs/BlizzardScriptAPI.md "Frame types & their methods" has the
// full 23-table catalogue). The dispatcher resolves methods through
// the type hierarchy, so a method added to the REGION base registry
// (the 19-entry core-layout table: GetWidth/SetPoint/…) is callable
// on every region type — frames, buttons, textures, fontstrings.
// `Frame::Modern` uses these to backport modern API (SetSize,
// SetShown, SetResizeBounds).
VAR_REGION_METHOD_REGISTRY = 0x00CF54B4,
VAR_FRAME_METHOD_REGISTRY = 0x00CF4D38,
VAR_TEXTURE_METHOD_REGISTRY = 0x00CF5434,
VAR_FONTSTRING_METHOD_REGISTRY = 0x00CF5400,
// Engine Script_* method implementations `Frame::Modern` delegates to
// (each `int __fastcall(void *L)`, reading self at stack index 1).
// Addresses from the docs/raw_methods.txt catalogue. Show/Hide are
// PER-BRANCH (Frame vs Texture vs FontString each register their own
// script function with its own object typecheck), so SetShown is
// registered per branch with the matching delegates.
FUN_SCRIPT_REGION_SETWIDTH = 0x007A1F20,
FUN_SCRIPT_REGION_SETHEIGHT = 0x007A2150,
FUN_SCRIPT_REGION_GETWIDTH = 0x007A1E00,
FUN_SCRIPT_REGION_GETHEIGHT = 0x007A2030,
FUN_SCRIPT_FRAME_SHOW = 0x00775750,
FUN_SCRIPT_FRAME_HIDE = 0x00775810,
FUN_SCRIPT_FRAME_SETMINRESIZE = 0x00776020,
FUN_SCRIPT_FRAME_SETMAXRESIZE = 0x007762A0,
FUN_SCRIPT_FRAME_GETSCRIPT = 0x00774780,
FUN_SCRIPT_FRAME_SETSCRIPT = 0x007748D0,
FUN_SCRIPT_TEXTURE_SHOW = 0x0079B770,
FUN_SCRIPT_TEXTURE_HIDE = 0x0079B830,
FUN_SCRIPT_FONTSTRING_SHOW = 0x0079CDB0,
FUN_SCRIPT_FONTSTRING_HIDE = 0x0079CE70,
// ---- World map overlay data (WorldMapOverlay.dbc + WorldMapArea.dbc)
// Backs `C_Map.GetMapOverlays`. Instance VAs per docs/DBCs.md;
// schemas verified against the extracted DBCs AND the engine's own
// reader `Script_GetMapOverlayInfo` (0x004A8A00), which reads the
// same fields for its 7 returns.
//
// WorldMapArea.dbc record (8 fields, 0x20 bytes):
// {ID@+0, mapID@+4, areaID@+8 (AreaTable, 0 for continent rows),
// name@+0xC (char*, the map directory name GetMapInfo returns),
// locLeft/locRight/locTop/locBottom floats}
VAR_WORLDMAP_AREA_RECORDS = 0x00C0D5BC,
VAR_WORLDMAP_AREA_COUNT = 0x00C0D5C0,
OFF_WMA_AREA_ID = 0x08,
OFF_WMA_NAME = 0x0C,
// WorldMapOverlay.dbc record (17 fields, 0x44 bytes):
// {ID@+0, worldMapAreaID@+4 (-> WorldMapArea row),
// areaID[4]@+8..+0x14 (AreaTable ids the overlay reveals),
// mapPointX@+0x18, mapPointY@+0x1C, textureName@+0x20 (char*,
// bare name), textureWidth@+0x24, textureHeight@+0x28,
// offsetX@+0x2C, offsetY@+0x30, hitRect@+0x34..+0x40}
// The engine's Lua surface only exposes EXPLORED overlays: the
// discovered-overlay ID list at 0x00B6E630 (count 0x00B6E6B4) gates
// Script_GetMapOverlayInfo. Reading the DBC directly bypasses the
// exploration gate — every overlay of a zone, explored or not.
VAR_WORLDMAP_OVERLAY_RECORDS = 0x00C0D594,
VAR_WORLDMAP_OVERLAY_COUNT = 0x00C0D598,
OFF_WMO_WORLDMAP_AREA = 0x04,
OFF_WMO_MAP_POINT_X = 0x18,
OFF_WMO_MAP_POINT_Y = 0x1C,
OFF_WMO_TEXTURE_NAME = 0x20,
OFF_WMO_TEXTURE_WIDTH = 0x24,
OFF_WMO_TEXTURE_HEIGHT = 0x28,
OFF_WMO_OFFSET_X = 0x2C,
OFF_WMO_OFFSET_Y = 0x30,
// Current world-map view selection — the globals the engine's
// current-map-name resolver (FUN_004a6cf0, behind GetMapInfo) reads:
// continent index, zone index (-1 = whole continent), the default
// WorldMapArea row used when no continent is selected, and the
// per-continent data blob (base ptr; stride 0x10024 per continent,
// continent's own WMA row at +0x04, per-zone WMA row array ptr at
// +0x10).
VAR_WORLDMAP_CONTINENT_INDEX = 0x0084506C,
VAR_WORLDMAP_ZONE_INDEX = 0x00845070,
VAR_WORLDMAP_DEFAULT_AREA_ROW = 0x00845074,
VAR_WORLDMAP_CONTINENT_DATA = 0x00B6E668,
WORLDMAP_CONTINENT_STRIDE = 0x10024,
// `Script_SetPoint` — the ONE SetPoint implementation, registered in
// the Region base table and inherited by every region type. Vanilla's
// parser accepts (point, region), (point, region, relPoint[, x, y]),
// (point, x, y), and even an explicit nil region — but NOT the bare
// one-arg form (`fs:SetPoint("RIGHT")`): a fully-omitted arg 3 is
// LUA_TNONE, which fails the string/table/number/nil check and raises
// the usage error. Modern clients accept it (anchor to parent at the
// same point, zero offsets). `Frame::Modern` co-hooks this and
// normalizes the one-arg call to (point, 0, 0) — the vanilla
// (point, x, y) form, which is parent-relative and thus exactly the
// modern semantic.
FUN_SCRIPT_REGION_SETPOINT = 0x007A2540,
// `CGUnit_C::OnAuraAdded` — `__thiscall void(CGUnit *unit, uint32_t
// slot, uint32_t spellId)` (i.e. `__fastcall(unit /*ecx*/, edx_unused,
// slot, spellId)`). Fires for EVERY aura that lands on a tracked unit,
@@ -1077,6 +1257,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
@@ -1111,6 +1327,17 @@ enum Offsets {
// exists on the +0xE68 sub-struct here.
OFF_CGPLAYER_INFO = 0xE68,
// PvP rank fields within the CGPlayer +0xE68 sub-struct (siblings of
// the combo-point byte at +0x1029). Read by the item tooltip's
// "Requires <rank>" / PVP_MEDAL red-text logic (FUN_0052B650) and
// mirrored by C_PlayerInfo.CanUseItem:
// +0x102B u8 current PvP honor rank (compared >= item
// RequiredHonorRank).
// +0x1034 u32 earned PvP-medal/rank bitmask (item RequiredCityRank
// R passes when bit (R-1) is set).
OFF_CGPLAYER_HONOR_RANK = 0x102B,
OFF_CGPLAYER_PVP_MEDALS = 0x1034,
// CGObject's `GetPosition` virtual — vtable slot 5 (offset 0x14).
// Signature: `__thiscall(this, float outBuf[3]) → float *position`.
// The returned float* may equal outBuf (the object filled it
@@ -2043,6 +2270,15 @@ enum Offsets {
OFF_SLA_CLASS_MASK = 0x10,
OFF_SLA_EXCLUDE_RACE = 0x14,
OFF_SLA_EXCLUDE_CLASS = 0x18,
// Skill-up threshold ranks (grey / green) at record fields 10/11.
// A row with either nonzero is a craftable recipe; profession
// rank-up / passive rows have both zero. Empirically: the
// Blacksmithing skill line has 343 SLA rows, 334 with a nonzero
// threshold — the 9 excluded are the Apprentice..Artisan rank
// spells and skill grants. This is the discriminator
// `TradeSkill::Link` uses to build the canonical recipe universe.
OFF_SLA_TRIVIAL_SKILL_HIGH = 0x28,
OFF_SLA_TRIVIAL_SKILL_LOW = 0x2C,
// SkillLine.dbc — the skill/category each SLA row points into via
// its `skillId` field. Each record carries a 9-locale localized
@@ -2075,6 +2311,24 @@ enum Offsets {
OFF_PLAYER_SKILL_LINE_ID = 0x00,
OFF_PLAYER_SKILL_RANK = 0x10,
// Live per-skill current/max rank. These are NOT in
// `VAR_PLAYER_SKILL_LIST` (that entry's `+0x10` is numTempPoints) —
// the real ranks live in the CGPlayer `+0xE68` sub-struct
// (`OFF_CGPLAYER_INFO`). `FUN_SKILL_LINE_TO_SLOT`
// (`__thiscall(player, skillLineID) -> slot`) maps a SkillLine.dbc
// row to its slot; the slot indexes a `SKILL_INFO_STRIDE`-byte
// record at `[player+0xE68] + OFF_SKILL_INFO_TABLE + slot*stride`:
// current `+0x0`, max `+0x2`, rank bonus `+0x6` (all u16 — add the
// bonus when the base value is nonzero). Source: `Script_GetTradeSkillLine`
// (`0x004FDD40`) / `Script_GetSkillLineInfo` (`0x004D3610`). Verified
// in-game vs. those functions (Tailoring 261/300, Enchanting 186/240).
FUN_SKILL_LINE_TO_SLOT = 0x005EA3F0,
OFF_SKILL_INFO_TABLE = 0x84C,
SKILL_INFO_STRIDE = 0xC,
OFF_SKILL_INFO_CUR = 0x0,
OFF_SKILL_INFO_MAX = 0x2,
OFF_SKILL_INFO_BONUS = 0x6,
// Per-itemClass proficiency bitmap maintained by the engine.
// 17 u32 entries (indexed by Item.dbc `m_class`, 0..16). Each
// entry is a bitmask where bit N is set if the player has
@@ -2111,7 +2365,18 @@ enum Offsets {
// Verified via the `IsAssistingRitual` development session by
// observing `0xEE00 → 0xEE05` on `/sit` over a chair (the medium-
// chair sit value 5).
//
// Byte 3 (the AnimTier / misc-flags byte, mask 0x02000000 on the
// u32) is the **stealth / sneak flag**: set while the unit is in
// the sneaking anim tier (Rogue Stealth, Druid Prowl, Shadowmeld),
// which is what drives the client's semi-transparent render of a
// stealthed unit. Verified via `_classicapi_DiffAll`: a Prowl
// toggle from a Cat-Form baseline flips byte 3 `0x00 → 0x02` while
// every other descriptor change is aura-array slot metadata — so
// this is the only standalone broadcast bit that means "stealthed".
// Read by `IsStealthed()`.
OFF_UNIT_FIELD_BYTES_1 = 0x210,
UNIT_BYTES_1_STEALTH_FLAG = 0x02000000,
// `__fastcall(void)` engine helper that refreshes the bonus
// action bar from the local player's current shapeshift form
@@ -2627,6 +2892,17 @@ enum Offsets {
// size matches Spell.dbc's row count. Pre-login the slot is NULL.
VAR_PLAYER_SPELL_BITMAP = 0x00B710FC,
// Spell-rank-chain knowledge check — `char __thiscall(player /*ecx*/,
// spellID)`. Walks `spellID`'s forward rank chain (the class/race-
// resolved chain via `0x0060C7C0`) and returns nonzero if the player
// knows any *higher* rank than `spellID` (each node tested through the
// bitmap helper `0x0060C740`). The item tooltip's "Requires <spell>"
// gate is `bitmap(spellID) OR this(spellID)`; C_PlayerInfo.CanUseItem
// mirrors that so a RequiredSpell satisfied by a higher known rank
// still passes. Does NOT test `spellID` itself — that's the caller's
// direct bitmap check.
FUN_SPELL_RANK_CHAIN_KNOWN = 0x0060C8D0,
// Per-spell cooldown query. `__fastcall(int spellID, int bookType,
// int *outDuration, int *outStart, int *outEnable)`. Same path
// `Script_GetSpellCooldown` (`0x004B40A0`) uses internally after
@@ -3021,6 +3297,7 @@ enum Offsets {
SPELLMOD_SLOT_STRIDE = 0x74, // bytes per familyFlagBit row
SPELLMOD_SLOT_COUNT = 64, // SpellFamilyFlags is 64-bit
SPELLMOD_OP_RADIUS = 6, // op index for spell radius
SPELLMOD_OP_DURATION = 1, // op index for aura duration
// SpellDispelType.dbc — maps dispel-type IDs (1..N) to localized
// names like "Magic", "Curse", "Disease", "Poison". Used by
@@ -3199,6 +3476,17 @@ enum Offsets {
// '%s' (%s)" (or "calling '%s' on bad self (%s)" for method-style
// calls) and calls `lua_error`. Doesn't return.
LUAL_ARG_ERROR = 0x6F4810,
// `luaL_setn(L, t, n)` — Lua 5.0 auxlib table-length setter
// (verified: `FUN_006f4ea0`, __fastcall L/ecx, t/edx, n/stack,
// RET 4). Matches 5.0.2 `luaL_setn`: sets `t.n = n` if a numeric
// `n` field exists, else `sizes[t] = n` in the registry's weak
// LUA_SIZES table. Found via `table.insert` (`FUN_007fb6b0`), which
// calls it to record the new length; `luaL_getn` is `FUN_006f5050`.
// Needed by `table.wipe` to reset the length after clearing keys —
// the length is out-of-band from the keys, so a `lua_next` +
// `rawset(nil)` clear leaves it stale and subsequent `table.insert`
// appends past the wiped slots.
LUAL_SETN = 0x6F4EA0,
// `lua_iscfunction(L, idx)` — returns 1 if value is a function
// closure with the `isC` byte set (`Closure.c.isC == 1`), 0
// otherwise. Used by `coroutine.create` to reject C functions
@@ -3283,6 +3571,15 @@ enum Offsets {
OFF_EVENT_ENTRY_NAME = 0x00,
OFF_EVENT_ENTRY_HEAD = 0x0C,
// Subscriber-chain node layout — the linked list hanging off each
// entry's +0x0C head, derived from Frame::RegisterEvent
// (FUN_00702140): each node is `{ ?, next@+0x04, frame@+0x08 }`, where
// `frame` is the CFrameScriptObject* subscriber. A node value with its
// low bit set (or null) is the end/empty sentinel. Walked by
// Frame:IsEventRegistered to test membership.
OFF_EVENT_NODE_NEXT = 0x04,
OFF_EVENT_NODE_FRAME = 0x08,
// `Frame::RegisterEvent` — the C++ helper called by the Lua
// `frame:RegisterEvent(eventName)` method (`Script_RegisterEvent` at
// `0x00774A40`). `__thiscall` with `(this=frame, eventName)`. Walks
@@ -3645,6 +3942,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
@@ -3711,23 +4032,15 @@ enum Offsets {
VAR_ADDON_ARRAY = 0x00BE1B94,
VAR_ADDON_COUNT = 0x00BE1B90,
// Player visibility/state byte field, broadcast in UpdateFields.
// Found empirically via _classicapi_DescDump (see src/debug/).
// Treated as a u32 but the meaningful bits all live in byte 0:
// 0x02 — set when the player has any visibility/form-modifying
// state active (stealth, mount, possibly shapeshift —
// set by stealth and mount in our test data).
// 0x08, 0x10 — set together when mounted (alongside 0x02).
//
// Ambiguity: bit 0x02 alone doesn't distinguish stealth from
// other visibility-modifying states. Stealth detection AND-gates
// with `mountDisplayID == 0` to exclude mount; if shapeshift
// also sets bit 0x02 we'd false-positive on druids in form
// (untested). The robust path is to walk the player's aura
// list looking for the actual stealth/prowl spell — left as
// future work if false-positives turn up.
OFF_PLAYER_FIELD_VIS_BYTES = 0x17C,
PLAYER_VIS_BIT_STEALTH = 0x02,
// NOTE: an earlier revision defined OFF_PLAYER_FIELD_VIS_BYTES /
// PLAYER_VIS_BIT_STEALTH here at 0x17C, believing bit 0x02 was a
// "stealth bit". That was wrong: 0x17C is UNIT_FIELD_AURALEVELS
// (per-slot caster-level bytes; see OFF_UNIT_FIELD_AURALEVELS above),
// and the old read only appeared to work by coincidence (a caster-
// level byte that happened to have bit 0x02 set on the test char;
// at level 60 = 0x3C it doesn't, so it read false). The real stealth
// flag is UNIT_FIELD_BYTES_1 byte 3 (UNIT_BYTES_1_STEALTH_FLAG); see
// that field above. IsStealthed reads it.
// Mount display ID — the creature display ID the engine renders
// under the mounted player. Zero when not mounted, non-zero
@@ -4392,6 +4705,19 @@ enum Offsets {
// with craft).
VAR_TRADESKILL_ENTRIES = 0x00BDDFC0,
VAR_TRADESKILL_COUNT = 0x00BDE04C,
// SkillLine.dbc row ID of the currently-open trade skill window
// (the profession the player has open). Bounds-checked against
// `VAR_SKILL_LINE_COUNT` and used as `records[id]` by
// `Script_GetTradeSkillLine` (`0x004FDD40`). <= 0 / stale when no
// window is open. `FUN_004FC870` zeroes it on close (and fires
// event `0x13B`), so `> 0` reliably means "trade window open".
VAR_CURRENT_TRADESKILL_LINE = 0x00BDE040,
// Craft window (Enchanting, pet training) open-state flag — nonzero
// (3 for the enchanting-style craft) while open; `FUN_004F5FE0`
// zeroes it on close (fires event `0x169`). The Craft window has no
// skill-line-ID global — `TradeSkill::Link` derives the skill line
// from the first craft recipe — so this is the "craft open" gate.
VAR_CRAFT_WINDOW_STATE = 0x00BDCFB8,
// Spell.dbc "produced item" field for crafting recipes — the
// itemID this spell creates when cast. Reagent itemIDs and
// counts live at `OFF_SPELL_REAGENT_ID` / `OFF_SPELL_REAGENT_COUNT`
@@ -4416,6 +4742,18 @@ enum Offsets {
OFF_SPELL_RECORD_EFFECT_MISC_VALUE = 0x1A8, // int32[3]
SPELL_RECORD_EFFECT_COUNT = 3,
// SPELL_EFFECT_* id array (`Effect[3]`) — five arrays before
// EffectBasePoints (`+0x130`), so `0x130 - 5*0xC`. Used to tell craft
// recipes from profession abilities (Disenchant etc.) that also carry
// skill-up thresholds. Recipe-producing effects: CREATE_ITEM (items),
// ENCHANT_ITEM / _TEMPORARY (enchants), and LEARN_SPELL (Turtle's custom
// recipes, e.g. "Steel Plate Boots" = effect 36).
OFF_SPELL_RECORD_EFFECT = 0xF4, // int32[3]
SPELL_EFFECT_CREATE_ITEM = 24,
SPELL_EFFECT_LEARN_SPELL = 36,
SPELL_EFFECT_ENCHANT_ITEM = 53,
SPELL_EFFECT_ENCHANT_ITEM_TEMPORARY = 54,
// EffectBasePoints[3] — the base magnitude of each effect, stored as
// (value - 1) for the fixed-die spells that back item stats (the
// engine adds a 1..dieSides roll; dieSides == 1 makes it value = base
@@ -4456,9 +4794,33 @@ enum Offsets {
// reads `spellRec[+0x14]` as the spell's mechanic when the per-
// effect EffectMiscValue is 0. Read by C_Spell.GetSpellMechanicByID.
OFF_SPELL_RECORD_MECHANIC = 0x14,
SPELL_AURA_MOD_STEALTH = 16,
SPELL_AURA_MOD_SHAPESHIFT = 36,
SPELL_AURA_MOUNTED = 78,
// Control-loss aura effect types (EffectApplyAuraName values), used by
// C_LossOfControl to classify a debuff. Values are authoritative from the
// Turtle server's indexed SPELL_AURA proc-handler table (SharedDefines).
SPELL_AURA_MOD_POSSESS = 2,
SPELL_AURA_MOD_CONFUSE = 5,
SPELL_AURA_MOD_CHARM = 6,
SPELL_AURA_MOD_FEAR = 7,
SPELL_AURA_MOD_STUN = 12,
SPELL_AURA_MOD_PACIFY = 25,
SPELL_AURA_MOD_ROOT = 26,
SPELL_AURA_MOD_SILENCE = 27,
SPELL_AURA_MOD_PACIFY_SILENCE = 60,
SPELL_AURA_MOD_DISARM = 67,
// SMSG_SPELL_COOLDOWN (0x134) handler, registered by the spell-opcode
// boot registrar FUN_006e7150 exactly like the failure/channel handlers
// Spell::Cast co-hooks — same `int __stdcall(uint32_t *opCode,
// CDataStore *packet)` shape. Body: GUID(u64) then (spellID, durationMs)
// u32 pairs. A player-GUID packet carrying >=2 spells that all share one
// duration is a school-interrupt lockout (Player::ProhibitSpellSchool is
// the only sender of that shape); see src/lossofcontrol/Info.cpp.
FUN_SPELL_COOLDOWN_HANDLER = 0x006E9460,
// Gossip menu data. Populated by the SMSG_GOSSIP_MESSAGE packet
// handler at 0x004E26E0 and reset by FUN_004E26A0 each time a new
// gossip window opens. Two parallel storage arrays:
@@ -4751,4 +5113,41 @@ enum Offsets {
// matching what `FUN_00494B60` and the trade-attached paths pass
// when re-resolving the cursor GUID to a `CGItem *`.
FUN_OBJECT_FROM_GUID = 0x00468460,
// Virtual XML-template registry (a Storm hash table) — the store that
// backs `inherits=` and `C_XMLUtil.GetTemplates`. The XML parser
// (`FUN_006ede10`) registers every `virtual="true"` frame into it via
// `FUN_006ee500`; the inherits resolver reads it via `FUN_006ee6f0`.
// Both operate on these globals, so it's the authoritative template list.
// See src/xml/Templates.cpp.
//
// VAR_XML_TEMPLATE_TABLE holds a POINTER to the bucket array (0 / the
// array unallocated) and VAR_XML_TEMPLATE_MASK the bucket-count mask;
// mask == 0xFFFFFFFF means no template has ever registered (walk = empty).
VAR_XML_TEMPLATE_TABLE = 0x00CEEA1C,
VAR_XML_TEMPLATE_MASK = 0x00CEEA24,
// Bucket struct (0xC bytes): { linkOffset@+0x0, ?@+0x4, chainHead@+0x8 }.
// A node's next pointer is at `*(node + linkOffset + 4)`; the tail
// sentinel has its low bit set (chain ends on `(ptr & 1) != 0`).
XML_TEMPLATE_BUCKET_STRIDE = 0xC,
OFF_XML_TEMPLATE_BUCKET_LINKOFF = 0x0,
OFF_XML_TEMPLATE_BUCKET_HEAD = 0x8,
// Registry node payload: template name string @+0x14, parsed definition
// XML node @+0x18 (hash @+0x0, virtual flag @+0x1C — unused here).
OFF_XML_TEMPLATE_NODE_NAME = 0x14,
OFF_XML_TEMPLATE_NODE_DEF = 0x18,
// Parsed XML element node: its tag-name string ("Frame" / "Button" /
// "CheckButton" / …) lives at +0x8 — this is the frame "type".
OFF_XML_NODE_TAG = 0x8,
// The definition node retains its full parsed DOM subtree — used by
// GetTemplateInfo to read `<Size>` / `inherits=`. First child @+0x4, next
// sibling @+0x1C. Attributes are read by name via the node method
// FUN_006f2cf0 (`__thiscall(node, name) -> value string`, null if absent).
OFF_XML_NODE_CHILD = 0x4,
OFF_XML_NODE_SIBLING = 0x1C,
FUN_XML_NODE_GET_ATTRIBUTE = 0x006F2CF0,
// By-name template lookup — the same one `inherits=` resolves through.
// `__fastcall(const char *name) -> definition node`, 0 if unregistered
// (case-insensitive, hashed). See FUN_006ee6f0 in Templates.cpp notes.
FUN_XML_TEMPLATE_LOOKUP = 0x006EE6F0,
};
+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,
+259
View File
@@ -0,0 +1,259 @@
// 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/>.
// See ComboDuration.h for the contract and the protocol findings that
// motivate the design (the server never transmits target-debuff
// durations in 1.12).
#include "ComboDuration.h"
#include "Game.h"
#include "Offsets.h"
#include "dbc/Lookup.h"
#include "net/PacketReader.h"
#include "spell/Mod.h"
#include "unit/Identity.h"
#include <cstdint>
namespace Aura::ComboDuration {
namespace {
using Net::CDataStore;
uint32_t NowMs() {
using TickCount_t = uint32_t(__fastcall *)();
return reinterpret_cast<TickCount_t>(
static_cast<uintptr_t>(Offsets::FUN_OS_TICKCOUNT_MS))();
}
// ---- Combo-point snapshot (NetClient send co-hook) ------------------------
// The player's live combo points, read the same way Script_GetComboPoints
// (0x0051A190) does: the CP byte on the CGPlayer +0xE68 sub-struct. The player
// comes from the shared Unit::Identity::PlayerObject() (the object-manager GUID
// resolve, our fastest player-object path — returns null pre-world rather than
// throwing), so this stays safe from any context the send hook can fire in.
uint8_t CurrentComboPoints() {
const uint8_t *player = Unit::Identity::PlayerObject();
if (player == nullptr)
return 0;
const uint8_t *info = *reinterpret_cast<const uint8_t *const *>(
player + Offsets::OFF_CGPLAYER_INFO);
if (info == nullptr)
return 0;
return *(info + Offsets::OFF_CGPLAYER_COMBO_POINTS);
}
// One cast in flight at a time is the practical case (a second finisher
// can't be cast until the server consumes the first one's combo points),
// so a single slot with a spellID match and a short TTL suffices.
struct Capture {
uint32_t spellId;
uint8_t comboPoints;
uint32_t capturedMs;
};
Capture g_capture{0, 0, 0};
// Worst-case send -> SMSG_SPELL_GO roundtrip. Past this the snapshot is
// stale (the cast failed / was rejected) and must not scale a later cast.
constexpr uint32_t kCaptureTtlMs = 2000;
int ConsumeCapturedCP(uint32_t spellId) {
const Capture c = g_capture;
g_capture = {0, 0, 0};
if (c.spellId != spellId || c.comboPoints == 0)
return 0;
if (NowMs() - c.capturedMs >= kCaptureTtlMs)
return 0;
return c.comboPoints;
}
// Co-hook on the NetClient send — the one funnel every outgoing CMSG
// passes through, including casts released from nampower's spell queue
// (which bypass any co-hook on Spell_C_CastSpell itself via trampoline
// re-entry, but still build and send CMSG_CAST_SPELL through the engine).
// Snapshot the combo points at the moment the cast request leaves: the
// server consumes them before SMSG_SPELL_GO arrives, so reading at
// SpellGo time is too late.
using NetSend_t = void(__fastcall *)(void *conn, void *edx,
CDataStore *packet);
NetSend_t g_origNetSend = nullptr;
void __fastcall NetSend_h(void *conn, void *edx, CDataStore *packet) {
if (packet != nullptr) {
const uint32_t saved = packet->m_read;
const uint32_t opcode = Net::Read<uint32_t>(packet);
if (opcode == Offsets::OP_CMSG_CAST_SPELL) {
const uint32_t spellId = Net::Read<uint32_t>(packet);
if (spellId != 0)
g_capture = {spellId, CurrentComboPoints(), NowMs()};
}
packet->m_read = saved;
}
g_origNetSend(conn, edx, packet);
}
const Game::HookAutoRegister _sendHook{
Offsets::FUN_NET_SEND, reinterpret_cast<void *>(&NetSend_h),
reinterpret_cast<void **>(&g_origNetSend)};
// ---- Duration data (SpellDuration.dbc + Lua overrides) --------------------
// Lua-registered overrides for spells whose client DBC row is missing or
// wrong. Turtle's reworked Rip is the motivating case: its DurationIndex
// dangles (records[87] == NULL in Turtle's SpellDuration.dbc) because the
// rework lives entirely server-side, so the values must come from config.
struct Override {
uint32_t spellId;
int32_t baseMs;
int32_t maxMs;
};
constexpr int kMaxOverrides = 64;
Override g_overrides[kMaxOverrides];
int g_overrideCount = 0;
const Override *FindOverride(uint32_t spellId) {
for (int i = 0; i < g_overrideCount; ++i) {
if (g_overrides[i].spellId == spellId)
return &g_overrides[i];
}
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},
};
const Override *FindDanglingRowKnown(uint32_t spellId) {
for (const auto &k : kDanglingRowKnown) {
if (k.spellId == spellId)
return &k;
}
return nullptr;
}
// `C_UnitAuras.RegisterComboDuration(spellID, baseSeconds, maxSeconds)`
// -> true on success. Re-registering a spellID replaces its entry.
// Values are in seconds to match the C_UnitAuras duration convention;
// the formula applied is the server's own:
// `base + (max - base) * comboPoints / 5`.
int __fastcall Script_RegisterComboDuration(void *L) {
if (!Game::Lua::IsNumber(L, 1) || !Game::Lua::IsNumber(L, 2) ||
!Game::Lua::IsNumber(L, 3)) {
Game::Lua::Error(
L, "Usage: C_UnitAuras.RegisterComboDuration(spellID, "
"baseSeconds, maxSeconds)");
return 0;
}
const auto spellId = static_cast<uint32_t>(Game::Lua::ToNumber(L, 1));
const auto baseMs = static_cast<int32_t>(Game::Lua::ToNumber(L, 2) * 1000.0);
const auto maxMs = static_cast<int32_t>(Game::Lua::ToNumber(L, 3) * 1000.0);
if (spellId == 0 || baseMs < 0 || maxMs <= 0) {
Game::Lua::PushBool(L, false);
return 1;
}
for (int i = 0; i < g_overrideCount; ++i) {
if (g_overrides[i].spellId == spellId) {
g_overrides[i] = {spellId, baseMs, maxMs};
Game::Lua::PushBool(L, true);
return 1;
}
}
if (g_overrideCount >= kMaxOverrides) {
Game::Lua::PushBool(L, false);
return 1;
}
g_overrides[g_overrideCount++] = {spellId, baseMs, maxMs};
Game::Lua::PushBool(L, true);
return 1;
}
void RegisterLuaFunctions() {
Game::Lua::RegisterTableFunction("C_UnitAuras", "RegisterComboDuration",
&Script_RegisterComboDuration);
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace
uint32_t TryComboScaledMs(const uint8_t *spellRecord, uint32_t spellId) {
if (spellRecord == nullptr || spellId == 0)
return 0;
const int cp = ConsumeCapturedCP(spellId);
if (cp <= 0 || cp > 5)
return 0;
int32_t baseMs = 0;
int32_t maxMs = 0;
if (const Override *o = FindOverride(spellId)) {
baseMs = o->baseMs;
maxMs = o->maxMs;
} else {
const int idx = *reinterpret_cast<const int *>(
spellRecord + Offsets::OFF_SPELL_RECORD_DURATION_INDEX);
const uint8_t *row =
DBC::Record(Offsets::VAR_SPELL_DURATION_RECORDS,
Offsets::VAR_SPELL_DURATION_COUNT,
static_cast<uint32_t>(idx));
if (row != nullptr) {
baseMs = *reinterpret_cast<const int32_t *>(row + 0x4);
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)
return 0;
baseMs = k->baseMs;
maxMs = k->maxMs;
} else {
return 0; // no duration row at all
}
}
// The server scales only when the row has a combo range: base != max
// ((v)mangos CalculateSpellDuration). base < 0 is the "infinite"
// sentinel; fixed-duration rows (base == max) take the regular path.
if (baseMs < 0 || maxMs <= 0 || baseMs == maxMs)
return 0;
int32_t durationMs = baseMs + (maxMs - baseMs) * cp / 5;
if (durationMs <= 0)
return 0;
// Player duration SpellMods apply on top of the combo-scaled value,
// matching the server's order (scale, then ApplySpellMod).
durationMs = static_cast<int32_t>(Spell::Mod::Apply(
spellRecord, Offsets::SPELLMOD_OP_DURATION,
static_cast<float>(durationMs)));
return durationMs > 0 ? static_cast<uint32_t>(durationMs) : 0;
}
} // namespace Aura::ComboDuration
+50
View File
@@ -0,0 +1,50 @@
// 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>
// `Aura::ComboDuration` — combo-point-scaled durations for finisher
// auras (Rupture, Kidney Shot, Slice and Dice, Rip, …).
//
// The 1.12 server computes a finisher's real duration at cast time as
// `base + (max - base) * comboPoints / 5` from the spell's
// SpellDuration.dbc row and never transmits the result for auras the
// player puts on OTHER units (verified with a whole-protocol sniff:
// SMSG_UPDATE_AURA_DURATION is self-scoped, and nothing else carries
// it). So this module mirrors the computation client-side:
//
// - a co-hook on the NetClient send (`FUN_NET_SEND`) snapshots the
// player's combo points the instant CMSG_CAST_SPELL leaves — the
// one moment the count is still intact (the server clears it
// before SMSG_SPELL_GO comes back);
// - `TryComboScaledMs` consumes that snapshot when `Aura::Source`'s
// SpellGo hook stores the aura, computing the scaled duration from
// SpellDuration.dbc and applying the player's duration SpellMods on
// top, matching the server's order of operations. Spells whose
// duration index DANGLES (client data bug — Turtle's reworked Rip
// is the known case) fall back to built-in values gated on that
// exact condition; a Lua-registered override
// (`C_UnitAuras.RegisterComboDuration`) beats both, as an escape
// hatch for other servers whose values differ from the client DBC.
namespace Aura::ComboDuration {
// Combo-scaled duration (ms) for a player-cast spell, or 0 when it
// doesn't apply: no combo points captured for this cast, the spell's
// duration row isn't combo-scaled (base == max) and has no override,
// or the row is missing. A 0 return means "use the regular base
// duration path".
uint32_t TryComboScaledMs(const uint8_t *spellRecord, uint32_t spellId);
} // namespace Aura::ComboDuration
+60 -4
View File
@@ -492,6 +492,50 @@ void PushFromCache(void *L, const uint8_t *unit,
c.casterGuid);
}
// Reconciles the `Aura::Source` cache against `unit`'s descriptor: when the
// descriptor holds at least one raw aura the unit is in view and fully synced,
// so it's authoritative — any cached entry for the unit whose spell isn't in
// the array was removed while we couldn't see it (e.g. cancelled out of range,
// so no OnAuraRemoved reached us) and must be dropped before the fallback
// resurfaces it as a phantom (an enumerated aura whose SetUnitAura tooltip is
// empty). Reads the RAW slots (not IsSlotPopulated): a spell that's present
// but flag-hidden is still legitimately on the unit. Skips when the array is
// empty — that can't be told apart from an out-of-range descriptor drop, and
// reconciling then would wipe still-valid out-of-range entries.
void ReconcileCache(const uint8_t *unit) {
if (unit == nullptr)
return;
uint32_t present[Offsets::UNIT_AURA_TOTAL];
int n = 0;
for (int slot = 0; slot < Offsets::UNIT_AURA_TOTAL; ++slot) {
const uint32_t id = ReadSpellID(unit, slot);
if (id != 0)
present[n++] = id;
}
if (n == 0)
return;
Aura::Source::EvictAbsent(UnitGuid(unit), present, n);
}
// True if `unit`'s descriptor currently exposes any visible aura in either
// range. The cache fallback is only trustworthy when the descriptor has been
// wholesale-cleared: the two states it exists for — rogue stealth and party
// range fluctuation — clear the *entire* aura array at once. If the unit shows
// even one live aura, the descriptor is authoritative, so a cache entry the
// descriptor lacks is a genuinely-gone aura we never saw removed (e.g. a
// no-duration debuff — Ghost/Spirit — on a unit that died and resurrected out
// of our visibility, so no OnAuraRemoved fired and its infinite entry never
// expired). Surfacing it then produces a stuck phantom.
bool DescriptorHasVisibleAura(const uint8_t *unit) {
if (unit == nullptr)
return false;
for (int slot = 0; slot < Offsets::UNIT_AURA_TOTAL; ++slot) {
if (IsSlotPopulated(unit, slot))
return true;
}
return false;
}
// Counts populated descriptor slots matching `filter` (and `playerOnly`).
int CountSlots(const uint8_t *unit, Filter filter, bool playerOnly) {
if (unit == nullptr)
@@ -515,12 +559,18 @@ int CountSlots(const uint8_t *unit, Filter filter, bool playerOnly) {
constexpr int kFallbackMax = Offsets::UNIT_AURA_TOTAL;
// Whether cache entry `c` should be surfaced as a fallback for `unit` under
// `filter`/`playerOnly`: it must not already be in a populated descriptor slot
// (no double-listing the same live aura) and, when player-filtered, must have
// been cast by us. Superseded / dispelled auras are kept out of the cache by
// `Aura::Source`'s removal eviction, not filtered here.
// `filter`/`playerOnly`: the spell must be a user-visible aura, it must not
// already be in a populated descriptor slot (no double-listing the same live
// aura) and, when player-filtered, must have been cast by us. Superseded /
// dispelled auras are kept out of the cache by `Aura::Source`'s removal
// eviction, not filtered here.
bool FallbackEligible(const uint8_t *unit, const Aura::Source::CachedAura &c,
Filter filter, bool playerOnly) {
if (!IsVisible(SpellRecord(c.spellId)))
return false; // hidden/internal aura (e.g. SPELL_ATTR_HIDDEN_CLIENTSIDE):
// the descriptor path drops these via IsSlotPopulated, so
// the cache path must apply the same engine predicate or
// they leak through as tooltip-less phantom buffs.
if (FindSlotBySpellID(unit, c.spellId, &filter, false) >= 0)
return false; // still in the descriptor — returned via the slot path
if (playerOnly && c.casterGuid != Unit::Identity::PlayerGuid())
@@ -534,6 +584,9 @@ 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
// than `oneBasedIndex` there, so the cache supplies index
// `oneBasedIndex - descCount` (1-based) of the entries it didn't.
@@ -562,6 +615,9 @@ 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);
if (guid == 0)
return;
+69 -5
View File
@@ -15,6 +15,7 @@
#include "Source.h"
#include "ComboDuration.h"
#include "Data.h"
#include "Game.h"
#include "Offsets.h"
@@ -149,10 +150,40 @@ void Evict(uint64_t targetGuid, uint32_t spellId) {
}
}
// Wipe the whole cache. Used on a map transition (see OnWorldTick).
void FlushAll() {
for (auto &e : g_cache)
e.used = false;
}
// -1 = no map seen yet (never a valid Map.dbc id), so the first tick just
// records the current map without flushing.
int g_lastMapId = -1;
// Drop entries whose timed aura has elapsed so the table doesn't fill with
// stale combat debuffs. Infinite-duration entries (expirationMs == 0) stay
// until overwritten.
//
// Also flush the entire cache on a map-ID change. A battleground/instance/
// continent transition (including the teleport out of a BG via /afk) bulk-
// clears the player's aura descriptor WITHOUT firing per-aura OnAuraRemoved,
// so entries for auras that are now gone would linger and the
// GetAuraDataByIndex fallback would keep surfacing them — the "buffs/debuffs
// stuck after leaving a battleground" report. Every other unit from the old
// map has despawned too, and the local player's real auras re-sync into the
// descriptor / PLAYER_BUFF tables on entering the new world, so a full flush
// loses nothing legitimate. Crucially, the descriptor drops the fallback
// must survive — rogue stealth and party-range fluctuation — do NOT change
// the map ID, so they're unaffected.
void OnWorldTick() {
const int mapId = *reinterpret_cast<const int *>(
static_cast<uintptr_t>(Offsets::VAR_CURRENT_MAP_ID));
if (mapId != g_lastMapId) {
if (g_lastMapId != -1)
FlushAll();
g_lastMapId = mapId;
}
const uint32_t now = NowMs();
for (auto &e : g_cache) {
if (e.used && e.expirationMs != 0 && now >= e.expirationMs)
@@ -203,7 +234,15 @@ void __fastcall SpellGo_h(uint64_t *itemGUID, uint64_t *casterGUID,
return;
const bool casterIsPlayer = (caster == Unit::Identity::PlayerGuid());
const uint32_t durationMs = SpellDurationMs(rec, casterIsPlayer);
// Combo-point finishers (Rupture, Kidney Shot, …) have their real
// duration computed at cast time from the combo points spent — the
// base-duration helper can't know it. ComboDuration mirrors the
// server's computation from the CP snapshot its send hook captured;
// 0 means "not combo-scaled", fall through to the base path.
uint32_t durationMs =
casterIsPlayer ? ComboDuration::TryComboScaledMs(rec, spellId) : 0;
if (durationMs == 0)
durationMs = SpellDurationMs(rec, casterIsPlayer);
const uint32_t expirationMs = durationMs > 0 ? NowMs() + durationMs : 0;
if (numTargets == 0) {
@@ -286,10 +325,18 @@ const Game::HookAutoRegister _hookAuraStacks{
reinterpret_cast<void **>(&g_origOnAuraStacksChanged)};
// OnAuraRemoved — a descriptor aura slot went empty: the aura fell off, was
// dispelled, or was replaced by a higher rank (the diff dispatcher fires
// Removed(old) + Added(new)). Evict the cache entry so the fallback stops
// surfacing it. Same ABI as OnAuraAdded (unit in ecx, slot + spellId on the
// stack); `slot` is unused here — we evict by spellId.
// dispelled, was cancelled by its owner, or was replaced by a higher rank
// (the diff dispatcher fires Removed(old) + Added(new)). Evict the cache
// entry so the descriptor-drop fallback stops surfacing it. Same ABI as
// OnAuraAdded (unit in ecx, slot + spellId on the stack); `slot` is unused
// here — we evict by spellId.
//
// Always evict, even for a death-persistent aura (flask, world buff) on a
// dead unit: the server keeps those through death WITHOUT changing any
// field, so death itself fires no OnAuraRemoved. Any removal we do receive
// for one is therefore genuine — e.g. the owner cancelled the flask while
// dead — and must clear the cache, or the fallback keeps surfacing a phantom
// whose SetUnitAura tooltip is empty.
using OnAuraRemoved_t = void(__fastcall *)(void *unit, void *edx, uint32_t slot,
uint32_t spellId);
OnAuraRemoved_t g_origOnAuraRemoved = nullptr;
@@ -322,6 +369,23 @@ bool Get(uint64_t unitGuid, uint32_t spellId, uint64_t *outCaster,
return false;
}
void EvictAbsent(uint64_t unitGuid, const uint32_t *presentSpellIds, int count) {
if (unitGuid == 0 || presentSpellIds == nullptr || count <= 0)
return;
for (auto &e : g_cache) {
if (!e.used || e.targetGuid != unitGuid)
continue;
bool present = false;
for (int i = 0; i < count; ++i)
if (presentSpellIds[i] == e.spellId) {
present = true;
break;
}
if (!present)
e.used = false;
}
}
int Enumerate(uint64_t unitGuid, bool harmful, CachedAura *out, int maxOut) {
if (unitGuid == 0 || out == nullptr || maxOut <= 0)
return 0;
+12
View File
@@ -50,6 +50,18 @@ struct CachedAura {
uint32_t durationMs; // applied duration (incl. caster mods); 0 = none
};
// Evicts every cached entry for `unitGuid` whose spellId is NOT present in
// `presentSpellIds[0..count)`. Used to reconcile the cache against a unit's
// authoritative descriptor when it is back in view (a populated aura array
// means the unit is fully synced): an entry the descriptor no longer lists
// was removed while we couldn't observe it — e.g. a buff the owner cancelled
// while out of our range, whose `OnAuraRemoved` we never received — so drop
// it before the descriptor-drop fallback resurfaces it as a phantom. The
// caller must only invoke this when the descriptor is populated (count > 0);
// an empty array can't distinguish "out of range" from "genuinely buffless",
// so reconciling then would wrongly wipe still-valid out-of-range entries.
void EvictAbsent(uint64_t unitGuid, const uint32_t *presentSpellIds, int count);
// Fills `out` with up to `maxOut` cached, non-expired auras on `unitGuid`
// whose helpful/harmful classification matches `harmful`. Returns the count
// written. The classification is recorded from the aura's descriptor slot at
+321
View File
@@ -0,0 +1,321 @@
// 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/>.
// Modern Region/Frame method backports.
//
// Modern addon ports routinely call these methods unconditionally —
// they're the C-level frame surface later clients take for granted.
// This module supplies the ones vanilla lacks (surfaced via
// AI_VoiceOver's modern code path, but generic to every port):
//
// - `region:SetPoint("RIGHT")` — the one-arg form. Vanilla's parser
// accepts every other modern form but errors when arg 3 is fully
// omitted; a co-hook on Script_SetPoint normalizes it to
// `(point, 0, 0)` (parent-relative, zero offsets — the exact modern
// semantic).
// - `region:SetSize(w, h)` / `region:GetSize()` — registered on the
// REGION base method registry, so they resolve on frames, buttons,
// textures, fontstrings — everything.
// - `frame:SetShown(shown)` — Show/Hide are per-branch script functions
// (each with its own object typecheck), so SetShown is registered
// separately on Frame / Texture / FontString with matching delegates.
// - `frame:SetResizeBounds(minW, minH [, maxW, maxH])` — the modern
// rename of vanilla's SetMinResize/SetMaxResize pair.
// - `frame:HookScript(scriptType, handler)` — absent in vanilla
// entirely. Chains after any existing handler and invokes `handler`
// with modern positional args (see the implementation comment).
//
// Implementation style: each new method delegates to the ENGINE's own
// Script_* implementations by reshaping the Lua stack and calling them
// (`int __fastcall(void *L)`, self at index 1) — no re-derived setters,
// so pixel/UI-scale conversion, object resolution, and type checking are
// all the engine's own code. Values are read out BEFORE any stack
// reshaping.
#include "Game.h"
#include "Offsets.h"
#include <cstdint>
#include <cstring>
namespace Frame::Modern {
namespace {
using ScriptFn_t = int(__fastcall *)(void *L);
int CallScript(uintptr_t fn, void *L) {
return reinterpret_cast<ScriptFn_t>(fn)(L);
}
// ---- SetSize / GetSize (Region base — all region types) -------------------
// `region:SetSize(width, height)` → SetWidth + SetHeight.
int __fastcall Script_SetSize(void *L) {
if (!Game::Lua::IsNumber(L, 2) || !Game::Lua::IsNumber(L, 3)) {
Game::Lua::Error(L, "Usage: region:SetSize(width, height)");
return 0;
}
const double h = Game::Lua::ToNumber(L, 3);
Game::Lua::SetTop(L, 2); // (self, width)
CallScript(Offsets::FUN_SCRIPT_REGION_SETWIDTH, L);
Game::Lua::SetTop(L, 1); // (self)
Game::Lua::PushNumber(L, h);
CallScript(Offsets::FUN_SCRIPT_REGION_SETHEIGHT, L);
return 0;
}
// `region:GetSize()` → width, height.
int __fastcall Script_GetSize(void *L) {
Game::Lua::SetTop(L, 1); // (self)
CallScript(Offsets::FUN_SCRIPT_REGION_GETWIDTH, L); // (self, w)
CallScript(Offsets::FUN_SCRIPT_REGION_GETHEIGHT, L); // (self, w, h)
return 2;
}
// ---- SetShown (per-branch Show/Hide delegates) -----------------------------
// `object:SetShown(shown)` — any falsy value hides, truthy shows,
// matching modern semantics.
template <uintptr_t ShowFn, uintptr_t HideFn>
int __fastcall Script_SetShown(void *L) {
const bool shown = Game::Lua::ToBoolean(L, 2) != 0;
Game::Lua::SetTop(L, 1); // (self) — Show/Hide take no args
CallScript(shown ? ShowFn : HideFn, L);
return 0;
}
// ---- SetResizeBounds (Frame) -----------------------------------------------
// `frame:SetResizeBounds(minWidth, minHeight [, maxWidth, maxHeight])` —
// modern rename of the vanilla SetMinResize/SetMaxResize pair.
int __fastcall Script_SetResizeBounds(void *L) {
if (!Game::Lua::IsNumber(L, 2) || !Game::Lua::IsNumber(L, 3)) {
Game::Lua::Error(L, "Usage: frame:SetResizeBounds(minWidth, "
"minHeight [, maxWidth, maxHeight])");
return 0;
}
const bool haveMax = Game::Lua::IsNumber(L, 4) && Game::Lua::IsNumber(L, 5);
const double maxW = haveMax ? Game::Lua::ToNumber(L, 4) : 0.0;
const double maxH = haveMax ? Game::Lua::ToNumber(L, 5) : 0.0;
Game::Lua::SetTop(L, 3); // (self, minW, minH)
CallScript(Offsets::FUN_SCRIPT_FRAME_SETMINRESIZE, L);
if (haveMax) {
Game::Lua::SetTop(L, 1); // (self)
Game::Lua::PushNumber(L, maxW);
Game::Lua::PushNumber(L, maxH);
CallScript(Offsets::FUN_SCRIPT_FRAME_SETMAXRESIZE, L);
}
return 0;
}
// ---- HookScript (Frame) ----------------------------------------------------
// `frame:HookScript(scriptType, handler)` — vanilla has no HookScript at
// all. Chains onto any existing handler: the old handler runs first,
// vanilla-style (no args — it reads the `this`/`event`/`argN` globals like
// every 1.12 handler), then `handler` is invoked MODERN-style with
// positional arguments `(frame, event, arg1..arg9)` for OnEvent and
// `(frame, arg1..arg9)` otherwise — mirroring the compat convention
// modern addon ports expect (and ship themselves when they detect a
// legacy client). The chain is a C closure over (handler, old, isOnEvent).
// Pushes _G[name].
void PushGlobal(void *L, const char *name) {
Game::Lua::PushString(L, name);
Game::Lua::GetTable(L, Game::Lua::GLOBALS_INDEX);
}
// The chained handler the engine actually invokes (with no args, like any
// vanilla script handler). Upvalues: 1 = hook handler, 2 = old handler
// (or nil), 3 = isOnEvent (number). Errors inside lua_call propagate to
// the engine's own protected script dispatch.
int __fastcall HookThunk(void *L) {
if (Game::Lua::Type(L, Game::Lua::UpvalueIndex(2)) ==
Game::Lua::TYPE_FUNCTION) {
Game::Lua::PushValue(L, Game::Lua::UpvalueIndex(2));
Game::Lua::Call(L, 0, 0);
}
const bool isOnEvent =
Game::Lua::ToNumber(L, Game::Lua::UpvalueIndex(3)) != 0;
Game::Lua::PushValue(L, Game::Lua::UpvalueIndex(1));
PushGlobal(L, "this");
if (isOnEvent)
PushGlobal(L, "event");
static const char *const kArgs[] = {"arg1", "arg2", "arg3", "arg4",
"arg5", "arg6", "arg7", "arg8",
"arg9"};
for (const char *a : kArgs)
PushGlobal(L, a);
Game::Lua::Call(L, (isOnEvent ? 2 : 1) + 9, 0);
return 0;
}
int __fastcall Script_HookScript(void *L) {
if (!Game::Lua::IsString(L, 2) ||
Game::Lua::Type(L, 3) != Game::Lua::TYPE_FUNCTION) {
Game::Lua::Error(L, "Usage: frame:HookScript(\"type\", function)");
return 0;
}
const char *scriptName = Game::Lua::ToString(L, 2);
const bool isOnEvent =
scriptName != nullptr && std::strcmp(scriptName, "OnEvent") == 0;
Game::Lua::SetTop(L, 3); // (self, name, handler)
// GetScript reads self/name at 1/2 and pushes the current handler
// (or nil) — extra stack entries above are ignored by its reads.
CallScript(Offsets::FUN_SCRIPT_FRAME_GETSCRIPT, L); // (.., old)
// Closure upvalues, in upvalue order: handler, old, isOnEvent.
Game::Lua::PushValue(L, 3); // handler
Game::Lua::PushValue(L, 4); // old
Game::Lua::PushNumber(L, isOnEvent ? 1.0 : 0.0); // flag
Game::Lua::PushCClosure(L, &HookThunk, 3); // (self,name,handler,old,closure)
Game::Lua::Remove(L, 4); // drop old
Game::Lua::Remove(L, 3); // drop handler → (self,name,closure)
CallScript(Offsets::FUN_SCRIPT_FRAME_SETSCRIPT, L);
return 0;
}
// ---- IsEventRegistered (Frame) ---------------------------------------------
// `frame:IsEventRegistered("event")` — vanilla has RegisterEvent /
// UnregisterEvent but not the query. There's no engine Script_* to
// delegate to, so we replicate the membership check that lives inside
// Frame::RegisterEvent (FUN_00702140): find the event by name in the live
// event table, then walk that entry's subscriber chain for this frame.
// The frame pointer we compare against is the same CFrameScriptObject*
// the engine stores (Game::Lua::ResolveObject resolves self exactly as
// Script_RegisterEvent does).
using SStrCmpI_t = int(__stdcall *)(const char *a, const char *b, int n);
bool FrameHasEvent(void *frame, const char *eventName) {
const uint32_t count = *reinterpret_cast<const uint32_t *>(
static_cast<uintptr_t>(Offsets::VAR_EVENT_TABLE_COUNT));
auto *base = *reinterpret_cast<const uint8_t *const *>(
static_cast<uintptr_t>(Offsets::VAR_EVENT_TABLE_BASE_PTR));
if (count == 0 || base == nullptr)
return false;
auto sstrcmpi = reinterpret_cast<SStrCmpI_t>(
static_cast<uintptr_t>(Offsets::FUN_SSTR_CMP_I));
for (uint32_t i = 0; i < count; ++i) {
const uint8_t *entry = base + i * Offsets::EVENT_ENTRY_STRIDE;
const char *name = *reinterpret_cast<const char *const *>(
entry + Offsets::OFF_EVENT_ENTRY_NAME);
if (name == nullptr)
continue;
if (sstrcmpi(name, eventName, 0x7FFFFFFF) != 0)
continue;
// Names are unique, so this is the one entry — walk its chain.
uintptr_t node = *reinterpret_cast<const uintptr_t *>(
entry + Offsets::OFF_EVENT_ENTRY_HEAD);
while ((node & 1) == 0 && node != 0) {
if (*reinterpret_cast<void *const *>(
node + Offsets::OFF_EVENT_NODE_FRAME) == frame)
return true;
node = *reinterpret_cast<const uintptr_t *>(
node + Offsets::OFF_EVENT_NODE_NEXT);
}
return false;
}
return false;
}
int __fastcall Script_IsEventRegistered(void *L) {
if (!Game::Lua::IsString(L, 2)) {
Game::Lua::Error(L, "Usage: frame:IsEventRegistered(\"event\")");
return 0;
}
void *frame = Game::Lua::ResolveObject(L, 1);
const char *eventName = Game::Lua::ToString(L, 2);
const bool registered =
frame != nullptr && eventName != nullptr && FrameHasEvent(frame, eventName);
Game::Lua::PushBool(L, registered);
return 1;
}
// ---- SetPoint one-arg form (Script_SetPoint co-hook) -----------------------
// Vanilla's Script_SetPoint accepts every modern form except the bare
// one-arg call (`region:SetPoint("RIGHT")`) — a fully-omitted arg 3 is
// LUA_TNONE, which fails its string/table/number/nil validation and
// raises the usage error. Normalize to the vanilla (point, x, y) form
// with zero offsets, which anchors relative to the parent at the same
// point — exactly the modern one-arg semantic. All other call shapes
// pass through untouched.
ScriptFn_t g_origSetPoint = nullptr;
int __fastcall SetPoint_h(void *L) {
if (Game::Lua::GetTop(L) == 2 && Game::Lua::IsString(L, 2)) {
Game::Lua::PushNumber(L, 0.0);
Game::Lua::PushNumber(L, 0.0);
}
return g_origSetPoint(L);
}
const Game::HookAutoRegister _setPointHook{
Offsets::FUN_SCRIPT_REGION_SETPOINT,
reinterpret_cast<void *>(&SetPoint_h),
reinterpret_cast<void **>(&g_origSetPoint)};
// ---- Registration ----------------------------------------------------------
const Game::Lua::FrameMethodEntry g_regionMethods[] = {
{"SetSize", &Script_SetSize},
{"GetSize", &Script_GetSize},
};
const Game::Lua::FrameMethodEntry g_frameMethods[] = {
{"SetShown", &Script_SetShown<Offsets::FUN_SCRIPT_FRAME_SHOW,
Offsets::FUN_SCRIPT_FRAME_HIDE>},
{"SetResizeBounds", &Script_SetResizeBounds},
{"HookScript", &Script_HookScript},
{"IsEventRegistered", &Script_IsEventRegistered},
};
const Game::Lua::FrameMethodEntry g_textureMethods[] = {
{"SetShown", &Script_SetShown<Offsets::FUN_SCRIPT_TEXTURE_SHOW,
Offsets::FUN_SCRIPT_TEXTURE_HIDE>},
};
const Game::Lua::FrameMethodEntry g_fontStringMethods[] = {
{"SetShown", &Script_SetShown<Offsets::FUN_SCRIPT_FONTSTRING_SHOW,
Offsets::FUN_SCRIPT_FONTSTRING_HIDE>},
};
void RegisterLuaFunctions() {
Game::Lua::RegisterFrameMethods(
reinterpret_cast<void *>(Offsets::VAR_REGION_METHOD_REGISTRY),
g_regionMethods,
static_cast<int>(sizeof(g_regionMethods) / sizeof(g_regionMethods[0])));
Game::Lua::RegisterFrameMethods(
reinterpret_cast<void *>(Offsets::VAR_FRAME_METHOD_REGISTRY),
g_frameMethods,
static_cast<int>(sizeof(g_frameMethods) / sizeof(g_frameMethods[0])));
Game::Lua::RegisterFrameMethods(
reinterpret_cast<void *>(Offsets::VAR_TEXTURE_METHOD_REGISTRY),
g_textureMethods,
static_cast<int>(sizeof(g_textureMethods) / sizeof(g_textureMethods[0])));
Game::Lua::RegisterFrameMethods(
reinterpret_cast<void *>(Offsets::VAR_FONTSTRING_METHOD_REGISTRY),
g_fontStringMethods,
static_cast<int>(sizeof(g_fontStringMethods) /
sizeof(g_fontStringMethods[0])));
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace
} // namespace Frame::Modern
+82
View File
@@ -24,6 +24,26 @@
// as the events (single source of truth — events firing and the
// state being readable happen at the same point in the pipeline).
//
// `GetMouseButtonClicked()` — the button name of the most recent
// mouse button message (`"LeftButton"` / … / `"Button5"`), or nil
// when no click is being handled. Modern addons call it inside a
// mouse handler to learn which button drove it; the value is evicted
// shortly after, so — like the real API — it reads nil outside a
// handler rather than returning a stale last-click forever.
//
// Modern WoW backs this with a frame-manager field set right before a
// mouse handler runs and cleared right after (verified in 3.3.5:
// `GetMouseButtonClicked` pushes `[frameMgr + 0x1234]`, nil when that
// field is null). We can't bracket the handler the same way here: the
// `WH_GETMESSAGE` hook sees the button at message-dequeue, but WoW
// dispatches the frame-script mouse handlers *deferred* (during the
// frame update, not the message pump). So instead we capture the
// button on the message and evict it from the per-frame `WorldTick`
// once a couple of quiet ticks pass with no button held — the value
// survives the whole frame its click lands in (so every handler,
// including multiple addons hooking one `OnClick`, sees it) then
// clears. A held button keeps it set so `OnDragStart` still reads it.
//
// Same `WH_GETMESSAGE` thread-message hook pattern as
// [Input::Modifier](Modifier.cpp): we intercept the WM_*BUTTON*
// messages on the engine's main thread *before* dispatch. The hook
@@ -33,6 +53,7 @@
#include "Game.h"
#include "event/Custom.h"
#include "tick/WorldTick.h"
#include <cstdint>
#include <cstring>
@@ -59,6 +80,24 @@ constexpr uint32_t MASK_ANY_BUTTON = 0x1F;
uint32_t g_mouseButtonMask = 0;
// Name of the button from the most recent mouse-button message, or
// nullptr when no click is being handled. Points at one of
// DecodeButton's string literals (stable for the process lifetime),
// so storing the pointer is safe. `lua_pushstring(L, nullptr)`
// tail-jumps to pushnil, so a null here surfaces as nil to the Lua
// caller with no extra branch. Set on each button message; evicted by
// EvictTick (see the header note on GetMouseButtonClicked).
const char *g_lastButton = nullptr;
// Set true whenever g_lastButton is (re)assigned, so EvictTick can
// tell a just-clicked frame from a quiet one. Number of consecutive
// quiet ticks (no activity, nothing held) required before eviction —
// >1 so a handler dispatched a frame after its message still reads
// the button.
bool g_buttonActivity = false;
int g_quietTicks = 0;
constexpr int kQuietTicksToEvict = 2;
HHOOK g_msgHook = nullptr;
// Maps WM_*BUTTON{DOWN,UP} → ("LeftButton"|..., isDown). Returns
@@ -101,6 +140,13 @@ void ProcessMouseMessage(UINT msg, WPARAM wParam) {
if (name == nullptr)
return;
// Capture for GetMouseButtonClicked on both press and release —
// during an OnClick / OnMouseUp handler the "clicked" button is the
// one just released, same as the press for a normal click.
g_lastButton = name;
g_buttonActivity = true;
g_quietTicks = 0;
const int bitIdx = NameToBitIdx(name);
if (bitIdx >= 0) {
const uint32_t bit = 1u << bitIdx;
@@ -136,6 +182,39 @@ int __fastcall Script_IsMouseButtonDown(void *L) {
return 1;
}
int __fastcall Script_GetMouseButtonClicked(void *L) {
// Null → pushnil (the engine's pushstring tail-jumps on NULL), so
// callers outside a mouse handler see nil, matching the modern API.
Game::Lua::PushString(L, g_lastButton);
return 1;
}
// Per-frame eviction of g_lastButton. Runs at the tail of each frame's
// world tick. While a button is held we keep the value (drag handlers
// still read it) and reset the quiet counter. A frame that saw a fresh
// button message consumes its activity flag but doesn't yet evict —
// that one-frame grace lets a handler dispatched the frame after its
// message still read the button. Only after kQuietTicksToEvict ticks
// with nothing held and no activity do we clear, so outside a click
// GetMouseButtonClicked reads nil instead of a stale last-click.
void EvictTick() {
if (g_lastButton == nullptr)
return;
if (g_mouseButtonMask != 0) {
g_quietTicks = 0;
return;
}
if (g_buttonActivity) {
g_buttonActivity = false;
g_quietTicks = 0;
return;
}
if (++g_quietTicks >= kQuietTicksToEvict) {
g_lastButton = nullptr;
g_quietTicks = 0;
}
}
LRESULT CALLBACK GetMsgHook(int code, WPARAM wParam, LPARAM lParam) {
// Only process when the message is being removed from the queue
// (`PM_REMOVE` / `GetMessage`); `PM_NOREMOVE` peeks would deliver
@@ -157,11 +236,14 @@ void InstallHook() {
void RegisterLuaFunctions() {
Game::Lua::RegisterGlobalFunction("IsMouseButtonDown",
&Script_IsMouseButtonDown);
Game::Lua::RegisterGlobalFunction("GetMouseButtonClicked",
&Script_GetMouseButtonClicked);
InstallHook();
}
const Event::Custom::AutoReserve _reserveDown{kDownEvent};
const Event::Custom::AutoReserve _reserveUp{kUpEvent};
const Tick::WorldTick::AutoSubscribe _evict{&EvictTick};
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace
+23 -23
View File
@@ -48,29 +48,6 @@ const uint8_t *FetchItemRecord(uint32_t itemID) {
return fn(cache, itemID, &zeroGuid, nullptr, nullptr, false);
}
// Walks the item's 5 spell slots looking for the first ON_USE
// (trigger=0) entry. Modern `GetItemSpell` returns only this kind;
// passive-on-equip procs (trigger=1), weapon procs (trigger=2),
// soulstones (trigger=4), and recipe-learn entries (trigger=6) all
// stay invisible to the API.
//
// Vanilla server data on Turtle WoW has been observed to put the
// ON_USE entry at slot index 0 for most items, but a few customized
// items use later slots, so we scan all 5.
int FindOnUseSpellIDInRecord(const uint8_t *record) {
auto *spellIDs = reinterpret_cast<const uint32_t *>(
record + Offsets::OFF_ITEMSTATS_SPELL_ID);
auto *triggers = reinterpret_cast<const uint32_t *>(
record + Offsets::OFF_ITEMSTATS_SPELL_TRIGGER);
for (int i = 0; i < Offsets::ITEMSTATS_SPELL_SLOT_COUNT; i++) {
if (spellIDs[i] != 0 &&
triggers[i] == Offsets::ITEM_SPELLTRIGGER_ON_USE) {
return static_cast<int>(spellIDs[i]);
}
}
return 0;
}
const char *SpellNameForID(int spellID) {
const uint8_t *record = ::Spell::Lookup::RecordForID(spellID);
if (record == nullptr)
@@ -110,6 +87,29 @@ int __fastcall Script_GetItemSpell(void *L) {
} // namespace
// Walks the item's 5 spell slots looking for the first ON_USE
// (trigger=0) entry. Modern `GetItemSpell` returns only this kind;
// passive-on-equip procs (trigger=1), weapon procs (trigger=2),
// soulstones (trigger=4), and recipe-learn entries (trigger=6) all
// stay invisible to the API.
//
// Vanilla server data on Turtle WoW has been observed to put the
// ON_USE entry at slot index 0 for most items, but a few customized
// items use later slots, so we scan all 5.
int FindOnUseSpellIDInRecord(const uint8_t *record) {
auto *spellIDs = reinterpret_cast<const uint32_t *>(
record + Offsets::OFF_ITEMSTATS_SPELL_ID);
auto *triggers = reinterpret_cast<const uint32_t *>(
record + Offsets::OFF_ITEMSTATS_SPELL_TRIGGER);
for (int i = 0; i < Offsets::ITEMSTATS_SPELL_SLOT_COUNT; i++) {
if (spellIDs[i] != 0 &&
triggers[i] == Offsets::ITEM_SPELLTRIGGER_ON_USE) {
return static_cast<int>(spellIDs[i]);
}
}
return 0;
}
int OnUseSpellIDForItemID(uint32_t itemID) {
if (itemID == 0)
return 0;
+8
View File
@@ -29,4 +29,12 @@ namespace Item::Spell {
// the same use-spell still register).
int OnUseSpellIDForItemID(uint32_t itemID);
// Walks a cached `ItemStats_C` record's 5 spell slots and returns the
// first ON_USE (trigger=0) spell ID, or 0 if none. Split out so callers
// that already hold the record (e.g. `Item::Usable`, which also reads the
// record's use requirements) don't peek the cache twice or re-derive the
// slot walk. `OnUseSpellIDForItemID` is the peek-by-itemID convenience
// wrapper over this.
int FindOnUseSpellIDInRecord(const uint8_t *record);
} // namespace Item::Spell
+180
View File
@@ -0,0 +1,180 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
// `IsUsableItem(item)` / `C_Item.IsUsableItem(item)` — is the item's
// *on-use* ability usable by the local player right now, returning
// `(usable, noMana)`. This is the item analog of `IsUsableSpell`; it has
// nothing to do with whether the item can be *equipped* (armor
// proficiency / class-restricted gear is not this function's concern —
// that's the tooltip's red requirement lines, evaluated separately).
//
// Semantics (mirrors 3.3.5's `Script_IsUsableItem` structure, rebuilt on
// 1.12 primitives — vanilla ships no `IsUsableItem` and its item-action
// usability path never computed on-use power/noMana):
// 1. Item must have an on-use spell (a `SPELL_TRIGGER[i] == ON_USE(0)`
// slot with a non-zero `SPELL_ID[i]`). No on-use ability → not
// usable (there's nothing to "use").
// 2. The item's own *use* requirements must be met: RequiredLevel, and
// the AllowableClass / AllowableRace masks. These gate activation
// (e.g. a potion below your level, a class-restricted trinket) and
// are use — not equip — requirements, so they belong here.
// 3. The on-use spell must be castable now: delegated to the engine's
// own spell-castability helper `FUN_SPELL_IS_USABLE`, which folds in
// form/stance, mechanic immunities, aura state, and the power check.
// Insufficient power is the only condition that flips `noMana` (and
// leaves `usable` false), exactly like `IsUsableSpell`.
//
// Cache-miss handling matches `C_Item.IsEquippableItem`: a sync peek with
// no load fired, returning `(false, false)` if the item isn't cached yet.
#include "Game.h"
#include "Offsets.h"
#include "item/Arg.h"
#include "item/Spell.h"
#include "spell/Lookup.h"
#include <cstdint>
namespace Item::Usable {
namespace {
using GetItemRecord_t = const uint8_t *(__thiscall *)(void *cache, uint32_t itemID,
const uint64_t *guid, void *callback,
void *userData, int unused);
using ResolveUnitToken_t = void *(__fastcall *)(const char *token);
using SpellIsUsable_t = int(__fastcall *)(const uint8_t *spellRecord, int *outNoMana);
// Same item-cache peek pattern as the other item modules (no shared
// helper — each call site is one line different). Sync, no load fired.
const uint8_t *PeekItemRecord(uint32_t itemID) {
auto fn = reinterpret_cast<GetItemRecord_t>(Offsets::FUN_DBCACHE_ITEMSTATS_GET_RECORD);
auto *cache = reinterpret_cast<void *>(Offsets::VAR_ITEMDB_CACHE);
const uint64_t zeroGuid = 0;
return fn(cache, itemID, &zeroGuid, nullptr, nullptr, 0);
}
const uint8_t *PlayerDescriptor() {
auto resolve = reinterpret_cast<ResolveUnitToken_t>(Offsets::FUN_RESOLVE_UNIT_TOKEN);
auto *player = static_cast<const uint8_t *>(resolve("player"));
if (player == nullptr)
return nullptr;
return *reinterpret_cast<const uint8_t *const *>(
player + Offsets::OFF_UNIT_DESCRIPTOR);
}
// True iff `mask` restricts a 1-based `index` (class/race) the player
// isn't part of. Mask 0 and 0xFFFFFFFF both mean "no restriction".
bool RestrictedOut(uint32_t mask, uint32_t index1Based) {
if (mask == 0 || mask == 0xFFFFFFFF || index1Based == 0)
return false;
return (mask & (1u << (index1Based - 1))) == 0;
}
struct Usability {
bool usable;
bool noMana;
};
Usability ComputeUsability(int itemID) {
Usability r{false, false};
if (itemID <= 0)
return r;
auto *record = PeekItemRecord(static_cast<uint32_t>(itemID));
if (record == nullptr)
return r; // not cached → sync "false", no load fired
const int onUseSpellID = Item::Spell::FindOnUseSpellIDInRecord(record);
if (onUseSpellID == 0)
return r; // no on-use ability → nothing to use
auto *desc = PlayerDescriptor();
if (desc == nullptr)
return r; // pre-login / no player
// Use requirements (NOT equip requirements): level + class/race.
const int playerLevel = *reinterpret_cast<const int *>(
desc + Offsets::OFF_UNIT_FIELD_LEVEL);
const uint32_t reqLevel = *reinterpret_cast<const uint32_t *>(
record + Offsets::OFF_ITEMSTATS_REQUIRED_LEVEL);
if (static_cast<int>(reqLevel) > playerLevel)
return r;
const uint32_t allowClass = *reinterpret_cast<const uint32_t *>(
record + Offsets::OFF_ITEMSTATS_ALLOWABLE_CLASS);
const uint32_t playerClass = *(desc + Offsets::OFF_UNIT_DESCRIPTOR_CLASS_BYTE);
if (RestrictedOut(allowClass, playerClass))
return r;
const uint32_t allowRace = *reinterpret_cast<const uint32_t *>(
record + Offsets::OFF_ITEMSTATS_ALLOWABLE_RACE);
const uint32_t playerRace = *(desc + Offsets::OFF_UNIT_DESCRIPTOR_RACE_BYTE);
if (RestrictedOut(allowRace, playerRace))
return r;
// On-use spell castability + noMana, from the engine's own helper.
// `::Spell::Lookup` (leading `::`) — inside `Item::Usable`, a bare
// `Spell::` binds to the included `Item::Spell` namespace.
auto *spellRec = ::Spell::Lookup::RecordForID(onUseSpellID);
if (spellRec == nullptr)
return r;
auto isUsable = reinterpret_cast<SpellIsUsable_t>(Offsets::FUN_SPELL_IS_USABLE);
int noMana = 0;
const int usable = isUsable(spellRec, &noMana) & 0xFF;
r.usable = usable != 0;
r.noMana = noMana != 0;
return r;
}
// `IsUsableItem(item)` — legacy global. Returns `(usable, noMana)` as
// 1/nil pairs, matching stock 1.12's `IsUsableAction` / our
// `IsUsableSpell` convention.
int __fastcall Script_IsUsableItem(void *L) {
const int itemID = Item::Arg::ResolveItemID(L, 1);
const Usability r = ComputeUsability(itemID);
if (r.usable) {
Game::Lua::PushNumber(L, 1.0);
Game::Lua::PushNil(L);
} else if (r.noMana) {
Game::Lua::PushNil(L);
Game::Lua::PushNumber(L, 1.0);
} else {
Game::Lua::PushNil(L);
Game::Lua::PushNil(L);
}
return 2;
}
// `C_Item.IsUsableItem(item)` — modern table-namespace form. Returns
// proper booleans (`usable`, `noMana`) per the `C_Item.*` convention.
int __fastcall Script_C_Item_IsUsableItem(void *L) {
const int itemID = Item::Arg::ResolveItemID(L, 1);
const Usability r = ComputeUsability(itemID);
Game::Lua::PushBool(L, r.usable);
Game::Lua::PushBool(L, r.noMana);
return 2;
}
} // namespace
static void RegisterLuaFunctions() {
Game::Lua::RegisterGlobalFunction("IsUsableItem", &Script_IsUsableItem);
Game::Lua::RegisterTableFunction("C_Item", "IsUsableItem",
&Script_C_Item_IsUsableItem);
}
static const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace Item::Usable
+559
View File
@@ -0,0 +1,559 @@
// 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) {
if (guid == 0)
return nullptr;
using Lookup_t = const uint8_t *(__thiscall *)(void *cache, uint32_t lo,
uint32_t hi, void *cookie,
void *cb, void *ud, char flag);
auto fn = reinterpret_cast<Lookup_t>(
static_cast<uintptr_t>(Offsets::FUN_PLAYER_INFO_LOOKUP));
uint32_t cookie[2] = {0, 0};
return fn(reinterpret_cast<void *>(
static_cast<uintptr_t>(Offsets::VAR_PLAYER_NAME_CACHE)),
static_cast<uint32_t>(guid), static_cast<uint32_t>(guid >> 32),
cookie, nullptr, nullptr, 0);
}
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
+373
View File
@@ -0,0 +1,373 @@
// 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_LossOfControl` — active loss-of-control effects on the LOCAL PLAYER.
// Vanilla 1.12 has no such aggregation layer (it's a MoP addition), so we
// synthesize it from two sources:
//
// 1. SCHOOL_INTERRUPT — the Counterspell/Kick school lockout. The server
// (Player::ProhibitSpellSchool) delivers it as a single
// SMSG_SPELL_COOLDOWN carrying every spell of the interrupted school,
// all with the same duration. That "player-GUID packet, >=2 spells, one
// uniform duration" shape is produced by nothing else (normal cooldowns
// are single-spell or applied client-side), so co-hooking the cooldown
// handler and matching it yields an authoritative school + duration.
// The interrupting spell isn't in the packet, so spellID is 0 and
// displayText is a fixed "Interrupted" — the mechanically-relevant data
// (school, duration, timing) is exact.
//
// 2. CC debuffs — stun / fear / root / confuse / charm / possess / silence /
// pacify / disarm are real debuff auras. We scan the player's debuffs and
// classify each by its spell's EffectApplyAuraName. Timing is best-effort
// from the Aura::Source cache (nil when the cast wasn't observed — the
// LoC timing fields are nullable, so that's contract-valid).
//
// Placeholders for fields with no vanilla equivalent: priority = 0,
// displayType = 2 (show for full duration), auraInstanceID = nil. iconTexture
// is the spell's icon PATH (vanilla has no FileDataIDs; a path is the
// functional equivalent).
#include "Game.h"
#include "Offsets.h"
#include "aura/Data.h"
#include "aura/Source.h"
#include "dbc/Lookup.h"
#include "event/Custom.h"
#include "net/PacketReader.h"
#include "spell/Lookup.h"
#include "tick/WorldTick.h"
#include "unit/Identity.h"
#include <cstdint>
namespace LossOfControl {
namespace {
using TickMs_t = uint32_t(__fastcall *)();
int NowMs() {
return static_cast<int>(
reinterpret_cast<TickMs_t>(Offsets::FUN_OS_TICKCOUNT_MS)());
}
// Spell.dbc field readers (mirrors the small locals in Spell::Cast).
constexpr int OFF_NAME = 0x1E0; // localized name[9]
constexpr int OFF_ICON_ID = 0x1D4; // -> SpellIcon.dbc
const char *SpellName(const uint8_t *rec) {
const int locale = *reinterpret_cast<int *>(Offsets::VAR_LOCALE_INDEX);
const char *n =
*reinterpret_cast<const char *const *>(rec + OFF_NAME + locale * 4);
return (n != nullptr) ? n : "";
}
const char *SpellIconPath(const uint8_t *rec) {
const int iconID = *reinterpret_cast<const int *>(rec + OFF_ICON_ID);
const uint8_t *iconRec = DBC::Record(Offsets::VAR_SPELL_ICON_RECORDS,
Offsets::VAR_SPELL_ICON_COUNT,
static_cast<uint32_t>(iconID));
if (iconRec == nullptr)
return "";
const char *p = *reinterpret_cast<const char *const *>(iconRec + 0x04);
return (p != nullptr) ? p : "";
}
int SpellSchoolIndex(const uint8_t *rec) {
return *reinterpret_cast<const int *>(rec + Offsets::OFF_SPELL_SCHOOL);
}
// Classify a spell's control-loss type from its EffectApplyAuraName array, or
// null if it applies no control-loss aura. Priority-ordered (strongest control
// first) so a multi-effect spell resolves to one locType, matching retail.
const char *ClassifyControlLoss(const uint8_t *rec) {
const int32_t *aura = reinterpret_cast<const int32_t *>(
rec + Offsets::OFF_SPELL_RECORD_EFFECT_APPLY_AURA_NAME);
auto has = [&](int type) {
for (int i = 0; i < Offsets::SPELL_RECORD_EFFECT_COUNT; ++i)
if (aura[i] == type)
return true;
return false;
};
if (has(Offsets::SPELL_AURA_MOD_POSSESS)) return "POSSESS";
if (has(Offsets::SPELL_AURA_MOD_CHARM)) return "CHARM";
if (has(Offsets::SPELL_AURA_MOD_STUN)) return "STUN";
if (has(Offsets::SPELL_AURA_MOD_FEAR)) return "FEAR";
if (has(Offsets::SPELL_AURA_MOD_CONFUSE)) return "CONFUSE";
if (has(Offsets::SPELL_AURA_MOD_PACIFY_SILENCE)) return "PACIFYSILENCE";
if (has(Offsets::SPELL_AURA_MOD_SILENCE)) return "SILENCE";
if (has(Offsets::SPELL_AURA_MOD_PACIFY)) return "PACIFY";
if (has(Offsets::SPELL_AURA_MOD_ROOT)) return "ROOT";
if (has(Offsets::SPELL_AURA_MOD_DISARM)) return "DISARM";
return nullptr;
}
// ---- School-interrupt lockout state (fed by the SMSG_SPELL_COOLDOWN hook) --
// One active lockout per school index (0..6); active while now < endMs.
// Self-expiring, per-process — a stale entry from before a relog is already
// in the past on the shared uptime clock, so no reset is needed.
struct SchoolLock {
int startMs;
int endMs;
};
SchoolLock g_schoolLock[Offsets::SPELL_SCHOOL_COUNT] = {};
using CooldownHandler_t = int(__stdcall *)(uint32_t *opCode,
Net::CDataStore *packet);
CooldownHandler_t g_origCooldown = nullptr;
int __stdcall Cooldown_h(uint32_t *opCode, Net::CDataStore *packet) {
if (packet != nullptr) {
const uint32_t saved = packet->m_read;
const uint64_t guid = Net::Read<uint64_t>(packet);
if (guid != 0 && guid == Unit::Identity::PlayerGuid()) {
int count = 0;
int firstDur = 0;
bool uniform = true;
int schoolIdx = -1;
while (packet->m_read + 8 <= packet->m_size && count < 256) {
const uint32_t spellID = Net::Read<uint32_t>(packet);
const int dur = static_cast<int>(Net::Read<uint32_t>(packet));
if (count == 0)
firstDur = dur;
else if (dur != firstDur)
uniform = false;
if (schoolIdx < 0) {
const uint8_t *rec =
Spell::Lookup::RecordForID(static_cast<int>(spellID));
if (rec != nullptr) {
const int s = SpellSchoolIndex(rec);
if (s >= 0 && s < Offsets::SPELL_SCHOOL_COUNT)
schoolIdx = s;
}
}
++count;
}
// A uniform-duration batch of >=2 same-school spells is a school
// lockout (ProhibitSpellSchool); single/varied packets are normal
// cooldowns and ignored.
if (count >= 2 && uniform && firstDur > 0 && schoolIdx >= 0) {
const int now = NowMs();
g_schoolLock[schoolIdx].startMs = now;
g_schoolLock[schoolIdx].endMs = now + firstDur;
}
}
packet->m_read = saved; // restore before the engine re-parses
}
return g_origCooldown(opCode, packet);
}
const Game::HookAutoRegister _cooldownHook{
Offsets::FUN_SPELL_COOLDOWN_HANDLER, reinterpret_cast<void *>(&Cooldown_h),
reinterpret_cast<void **>(&g_origCooldown)};
// ---- Aggregated active-effect list ----------------------------------------
struct LocEntry {
const char *locType;
int spellID; // 0 = unknown (school interrupt)
const uint8_t *rec; // spell record for name/icon; null for school interrupt
int schoolMask; // lockoutSchool
int startMs; // 0 = unknown
int endMs; // 0 = unknown / no timing
};
int BuildList(LocEntry *out, int maxOut) {
const int now = NowMs();
int n = 0;
// School-interrupt lockouts first.
for (int s = 0; s < Offsets::SPELL_SCHOOL_COUNT && n < maxOut; ++s) {
if (g_schoolLock[s].endMs != 0 && now < g_schoolLock[s].endMs) {
LocEntry &e = out[n++];
e.locType = "SCHOOL_INTERRUPT";
e.spellID = 0;
e.rec = nullptr;
e.schoolMask = 1 << s;
e.startMs = g_schoolLock[s].startMs;
e.endMs = g_schoolLock[s].endMs;
}
}
// CC debuffs on the player.
const uint8_t *player = Unit::Identity::PlayerObject();
if (player != nullptr) {
const uint64_t playerGuid = Unit::Identity::PlayerGuid();
for (int i = 1; n < maxOut; ++i) {
const int slot = Aura::Data::FindNthSlot(
player, i, Aura::Data::Filter::Harmful, false);
if (slot < 0)
break;
const uint32_t spellID = Aura::Data::ReadSpellID(player, slot);
if (spellID == 0)
continue;
const uint8_t *rec =
Spell::Lookup::RecordForID(static_cast<int>(spellID));
if (rec == nullptr)
continue;
const char *locType = ClassifyControlLoss(rec);
if (locType == nullptr)
continue;
LocEntry &e = out[n++];
e.locType = locType;
e.spellID = static_cast<int>(spellID);
e.rec = rec;
e.schoolMask = 0;
e.startMs = 0;
e.endMs = 0;
uint64_t caster = 0;
uint32_t expMs = 0, durMs = 0;
if (Aura::Source::Get(playerGuid, spellID, &caster, &expMs, &durMs) &&
expMs != 0) {
e.endMs = static_cast<int>(expMs);
if (durMs != 0)
e.startMs = static_cast<int>(expMs - durMs);
}
}
}
return n;
}
// ---- LOSS_OF_CONTROL_ADDED / _UPDATE events (WorldTick poll-and-diff) ------
//
// Effect *expiry* (a school lockout timing out, a CC aura falling off) has no
// engine packet or callback, so a per-frame diff is the only complete signal.
// Firing from the WorldTick tail also keeps the Lua handlers out of the
// mid-packet contexts the state is updated in (the OnTooltipSetItem fire-safety
// lesson). The scan is bounded (16 debuff slots) and early-outs when idle, so
// the per-frame cost is negligible.
constexpr const char *kEventAdded = "LOSS_OF_CONTROL_ADDED";
constexpr const char *kEventUpdate = "LOSS_OF_CONTROL_UPDATE";
// Identity of an active effect for frame-to-frame diffing: a CC is keyed by
// spellID, a school interrupt by its school mask (spellID 0).
struct EffectKey {
int spellID;
int schoolMask;
};
EffectKey g_prev[32];
int g_prevCount = 0;
bool ContainsKey(const EffectKey *list, int count, EffectKey key) {
for (int i = 0; i < count; ++i)
if (list[i].spellID == key.spellID &&
list[i].schoolMask == key.schoolMask)
return true;
return false;
}
void OnWorldTick() {
LocEntry entries[32];
const int n = BuildList(entries, 32);
EffectKey cur[32];
for (int i = 0; i < n; ++i)
cur[i] = EffectKey{entries[i].spellID, entries[i].schoolMask};
bool changed = false;
for (int i = 0; i < n; ++i) {
if (!ContainsKey(g_prev, g_prevCount, cur[i])) {
// A newly-applied effect -> LOSS_OF_CONTROL_ADDED(eventIndex).
Event::Custom::Fire(Event::Custom::Lookup(kEventAdded), "%d", i + 1);
changed = true;
}
}
for (int i = 0; i < g_prevCount && !changed; ++i) {
if (!ContainsKey(cur, n, g_prev[i]))
changed = true; // an effect ended (fell off / lockout expired)
}
// Any add or removal fires one coalesced "re-scan" signal. The payload is
// the affected unit — always "player" here (vanilla only exposes the local
// player's control-loss state), but passing it keeps the event shape
// forward-compatible with per-unit tracking and matches modern WoW, whose
// LOSS_OF_CONTROL_UPDATE carries the unit token.
if (changed)
Event::Custom::Fire(Event::Custom::Lookup(kEventUpdate), "%s", "player");
for (int i = 0; i < n; ++i)
g_prev[i] = cur[i];
g_prevCount = n;
}
static const Event::Custom::AutoReserve _reserveAdded{kEventAdded};
static const Event::Custom::AutoReserve _reserveUpdate{kEventUpdate};
static const Tick::WorldTick::AutoSubscribe _tick{&OnWorldTick};
// `C_LossOfControl.GetActiveLossOfControlDataCount()` -> number.
int __fastcall Script_GetActiveLossOfControlDataCount(void *L) {
LocEntry entries[32];
const int n = BuildList(entries, 32);
Game::Lua::PushNumber(L, static_cast<double>(n));
return 1;
}
// `C_LossOfControl.GetActiveLossOfControlData(index)` -> LossOfControlData, or
// nil if index is out of range.
int __fastcall Script_GetActiveLossOfControlData(void *L) {
if (!Game::Lua::IsNumber(L, 1)) {
Game::Lua::Error(
L, "Usage: C_LossOfControl.GetActiveLossOfControlData(index)");
return 0;
}
const int index = static_cast<int>(Game::Lua::ToNumber(L, 1));
LocEntry entries[32];
const int n = BuildList(entries, 32);
if (index < 1 || index > n)
return 0; // nil
const LocEntry &e = entries[index - 1];
const int now = NowMs();
Game::Lua::NewTable(L);
Game::Lua::SetFieldString(L, "locType", e.locType);
Game::Lua::SetFieldNumber(L, "spellID", static_cast<double>(e.spellID));
Game::Lua::SetFieldString(
L, "displayText", (e.rec != nullptr) ? SpellName(e.rec) : "Interrupted");
Game::Lua::SetFieldString(
L, "iconTexture", (e.rec != nullptr) ? SpellIconPath(e.rec) : "");
// Timing (seconds, GetTime() epoch). Nullable — leave a field unset (nil)
// when we don't know it, per the modern contract.
if (e.endMs != 0 && now < e.endMs) {
Game::Lua::SetFieldNumber(L, "timeRemaining", (e.endMs - now) * 0.001);
if (e.startMs != 0) {
Game::Lua::SetFieldNumber(L, "startTime", e.startMs * 0.001);
Game::Lua::SetFieldNumber(L, "duration",
(e.endMs - e.startMs) * 0.001);
}
}
Game::Lua::SetFieldNumber(L, "lockoutSchool",
static_cast<double>(e.schoolMask));
Game::Lua::SetFieldNumber(L, "priority", 0);
Game::Lua::SetFieldNumber(L, "displayType", 2); // show for full duration
// auraInstanceID: nil (vanilla has no aura instance IDs) — field omitted.
return 1;
}
} // namespace
static void RegisterLuaFunctions() {
Game::Lua::RegisterTableFunction(
"C_LossOfControl", "GetActiveLossOfControlDataCount",
&Script_GetActiveLossOfControlDataCount);
Game::Lua::RegisterTableFunction("C_LossOfControl",
"GetActiveLossOfControlData",
&Script_GetActiveLossOfControlData);
}
static const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace LossOfControl
+500
View File
@@ -0,0 +1,500 @@
// 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_Map.GetMapOverlays([areaID])` — every WorldMapOverlay.dbc entry for
// a zone, regardless of exploration, with the tile grid fully resolved.
//
// The engine's own surface (GetNumMapOverlays / GetMapOverlayInfo) only
// exposes DISCOVERED overlays — the exploration gate is a server-fed
// list of discovered overlay IDs — which is why map addons (pfUI's
// mapreveal, Cartographer …) ship hand-measured overlay tables to draw
// unexplored areas. This reads the DBC directly instead: live data for
// every zone, on any client, no per-server table drift.
//
// With no argument, returns overlays for the world map's CURRENT view
// (mirroring the resolution the engine's GetMapInfo does: continent /
// zone selection → WorldMapArea row). With a numeric `areaID`
// (AreaTable id — the stable zone identity), resolves the WorldMapArea
// row whose areaID column matches.
//
// ## Why the tile grid is resolved here (Octo data quirks)
//
// An overlay's art is `<textureName>1.blp, 2, …` numbered row-major on a
// 256px grid, canonically `ceil(w/256) x ceil(h/256)` tiles with only
// the last column/row allowed partial. On stock 1.12 data that formula
// is always right; on Octo it is not, in three ways (established
// empirically by the InkLab map generator, mirrored here):
//
// 1. FOREIGN TILES: unrelated art continues an overlay's number
// sequence in the same folder (Icepoint Rock's folder carries
// `kaneqnuun` tiles, Hyjal carries Tel Abim art). Anything derived
// from "what files exist" draws duplicate landmass. The overlay
// list must come ONLY from the DBC (which this module guarantees),
// and tiles past the true grid must be dropped.
// 2. SLIVER COLUMNS: the DBC rect can round away a narrow edge column
// (kaneqnuun's real art has a third, 8px-wide column; its DBC `w`
// yields 2 columns). Trusting ceil(w/256) both loses the sliver
// and — worse — shears the row-major layout into diagonal strips.
// The art itself reveals the truth: only a row's LAST tile may be
// narrower than 256, so the first partial-width tile (searched one
// past the DBC's column count) marks the real column count, and
// the first partial-height tile marks the last row.
// 3. UPSCALED RE-EXPORTS: the same rect re-exported at higher
// resolution (Stonetalon's WindshearCrag: 4 tiles for a 1-tile
// rect). All tiles are real and must be kept, drawn scaled to the
// rect. Only distinguishable from quirk 1 when the art has NO
// partial edge anywhere AND the DBC rect is a whole multiple of
// 256 in both dimensions; the grid shape is then recovered from
// the aspect ratio (cols ≈ round(sqrt(n·w/h))).
//
// Tile dimensions come from the BLP headers via the engine's own VFS
// reader (`FUN_FILE_READ`, so patch-MPQ overrides are honored). Both
// BLP1 and BLP2 keep width/height at header offsets +0x0C/+0x10.
// Resolution runs once per overlay per session (cached).
//
// ## HD map patches (uniform tile upscale)
//
// A custom `patch-*.mpq` can replace the overlay art with higher-res tiles
// (every 256px tile re-exported at 256*scale). Because we read the *file*
// dimensions, that upscale must not be assumed to be 256: the resolver
// detects the full-cell size `S` (the largest tile dimension = 256*scale)
// and expresses both the partial-tile threshold and the texcoords relative
// to it. Draw sizes/positions remain in logical map pixels (the patch changes
// texture resolution, not the map canvas), so an interior tile reads texcoord
// 1.0 at any scale and stock data (S == 256) is byte-identical to before.
// The upscale factor need not be integral; only a uniform per-overlay scale
// is assumed.
//
// ## Return shape
//
// Array of overlay tables:
// textureName — bare DBC name ("GOLDSHIRE")
// texturePath — "Interface\WorldMap\<dir>\GOLDSHIRE" (engine format)
// textureWidth / textureHeight — the DBC placement rect (reliable for
// placement; its IMPLIED tile count is what lies)
// offsetX / offsetY — placement on the zone canvas
// mapPointX / mapPointY
// tileCols / tileRows / upscaled — the resolved grid
// tiles — array of ready-to-draw tiles:
// { file — texture path incl. number (SetTexture-ready)
// width, height — draw size in map pixels
// texCoordX, texCoordY — right/bottom texcoords
// offsetX, offsetY } — absolute canvas position
//
// A consumer draws an overlay with a dumb loop: per tile SetTexture,
// SetWidth/Height, SetTexCoord(0, texCoordX, 0, texCoordY), SetPoint.
#include "Game.h"
#include "Offsets.h"
#include "dbc/Lookup.h"
#include <cstdint>
#include <cstdio>
#include <cstring>
#include <cmath>
namespace Map::Overlays {
namespace {
using FileRead_t = int(__stdcall *)(int unused, const char *path, void **outBuf,
unsigned int *outSize, unsigned int extraBytes,
int flag1, int flag2);
using SMemFree_t = void(__stdcall *)(void *ptr, const char *file, int line,
int flags);
constexpr int kMaxTiles = 64;
// Smallest power of two >= v (min 16), matching the engine's own
// WorldMapFrame tile-file-size loop. A tile's content fills content/NextPot
// of its (power-of-two) file — a fraction that's identical whether the file
// is stock or HD-upscaled, so texcoords derived from it are scale-invariant.
int NextPot(int v) {
int p = 16;
while (p < v)
p <<= 1;
return p;
}
// Resolved tile grid for one overlay, cached for the session (DBC and
// MPQ contents are immutable per process).
struct ResolvedOverlay {
uint8_t cols;
uint8_t rows;
uint8_t count; // tiles to draw (foreign tail already dropped)
bool upscaled;
uint16_t cell; // full-cell file size (256 on stock, 256*scale on an HD patch)
uint16_t fileW[kMaxTiles]; // BLP file dimensions per tile
uint16_t fileH[kMaxTiles];
};
ResolvedOverlay **g_cache = nullptr;
int g_cacheSize = 0;
// Reads one BLP's header dimensions through the engine VFS. Returns
// false when the file doesn't exist (end of the tile sequence) or isn't
// a BLP.
bool ReadBlpDims(const char *path, uint16_t *outW, uint16_t *outH) {
auto FileRead = reinterpret_cast<FileRead_t>(Offsets::FUN_FILE_READ);
auto SMemFree = reinterpret_cast<SMemFree_t>(Offsets::FUN_STORM_SMEM_FREE);
void *buf = nullptr;
unsigned int size = 0;
if (FileRead(0, path, &buf, &size, 0, 1, 0) == 0 || buf == nullptr)
return false;
bool ok = false;
const uint8_t *b = static_cast<const uint8_t *>(buf);
if (size >= 0x14 && b[0] == 'B' && b[1] == 'L' && b[2] == 'P') {
const uint32_t w = *reinterpret_cast<const uint32_t *>(b + 0x0C);
const uint32_t h = *reinterpret_cast<const uint32_t *>(b + 0x10);
if (w > 0 && w <= 4096 && h > 0 && h <= 4096) {
*outW = static_cast<uint16_t>(w);
*outH = static_cast<uint16_t>(h);
ok = true;
}
}
SMemFree(buf, __FILE__, __LINE__, 0);
return ok;
}
// The quirk disambiguation (see module comment). `basePath` is the
// numberless texture path; dbcW/dbcH the overlay's DBC rect.
void ResolveTiles(ResolvedOverlay *out, const char *basePath, int dbcW,
int dbcH) {
char path[0x120];
int n = 0;
while (n < kMaxTiles) {
std::snprintf(path, sizeof(path), "%s%d.blp", basePath, n + 1);
if (!ReadBlpDims(path, &out->fileW[n], &out->fileH[n]))
break;
++n;
}
int dbcCols = (dbcW + 255) / 256;
int dbcRows = (dbcH + 255) / 256;
if (dbcCols < 1)
dbcCols = 1;
if (dbcRows < 1)
dbcRows = 1;
// Full-cell file size. On stock data a full (interior) tile is 256px; an
// HD map patch (patch-*.mpq) upscales every tile uniformly, so a full cell
// is 256*scale. Full tiles are the largest; edges/slivers are smaller — so
// the max tile dimension is the full-cell size. Partial-tile detection
// (and the texcoords in PushTiles) must compare against this S, not a
// hardcoded 256, or an HD edge tile (e.g. a 200px edge upscaled to 400px)
// reads as "full" and shears the row-major grid into diagonal strips.
int S = 256;
for (int i = 0; i < n; ++i) {
if (out->fileW[i] > S)
S = out->fileW[i];
if (out->fileH[i] > S)
S = out->fileH[i];
}
out->cell = static_cast<uint16_t>(S);
// Real column count: the first partial-width tile is a row's right
// edge. Search one past dbcCols so a sliver column the DBC width
// rounds off is still found.
int cols = dbcCols;
bool widthEdge = false;
for (int i = 0; i < n && i <= dbcCols; ++i) {
if (out->fileW[i] < S) {
cols = i + 1;
widthEdge = true;
break;
}
}
// Real row count: the row holding the first partial-height tile is
// the last row.
int rows = dbcRows;
bool heightEdge = false;
for (int i = 0; i < n; ++i) {
if (out->fileH[i] < S) {
rows = i / cols + 1;
heightEdge = true;
break;
}
}
int limit = cols * rows;
bool upscaled = false;
// Upscale exception: no partial edge anywhere and a whole-tile DBC
// rect means the extras are higher-res art for the same rect, not
// foreign — keep them all; recover the grid from the aspect ratio.
if (!widthEdge && !heightEdge && dbcW % 256 == 0 && dbcH % 256 == 0 &&
n > limit) {
upscaled = true;
cols = static_cast<int>(std::floor(
std::sqrt(static_cast<double>(n) * dbcW / dbcH) + 0.5));
if (cols < 1)
cols = 1;
if (cols > n)
cols = n;
limit = n;
rows = (n + cols - 1) / cols;
}
if (limit > n)
limit = n;
if (cols < 1)
cols = 1;
if (rows < 1)
rows = 1;
out->cols = static_cast<uint8_t>(cols);
out->rows = static_cast<uint8_t>(rows);
out->count = static_cast<uint8_t>(limit);
out->upscaled = upscaled;
}
const ResolvedOverlay *CachedResolve(int overlayId, const char *basePath,
int dbcW, int dbcH) {
const int count =
*reinterpret_cast<const int *>(Offsets::VAR_WORLDMAP_OVERLAY_COUNT);
if (g_cache == nullptr || g_cacheSize < count + 1) {
auto **fresh = new ResolvedOverlay *[count + 1]();
for (int i = 0; i < g_cacheSize; ++i)
fresh[i] = g_cache[i];
delete[] g_cache;
g_cache = fresh;
g_cacheSize = count + 1;
}
if (overlayId < 0 || overlayId >= g_cacheSize)
return nullptr;
if (g_cache[overlayId] == nullptr) {
auto *r = new ResolvedOverlay();
ResolveTiles(r, basePath, dbcW, dbcH);
g_cache[overlayId] = r;
}
return g_cache[overlayId];
}
// WorldMapArea row id for the world map's current view — the same
// resolution the engine's current-map-name getter (FUN_004a6cf0, behind
// GetMapInfo) performs: continent/zone selection indexes into the
// per-continent data blob; no continent selected falls back to the
// default row global.
int CurrentWorldMapAreaRow() {
const int continent =
*reinterpret_cast<const int *>(Offsets::VAR_WORLDMAP_CONTINENT_INDEX);
if (continent < 0)
return *reinterpret_cast<const int *>(
Offsets::VAR_WORLDMAP_DEFAULT_AREA_ROW);
const uint8_t *blob = *reinterpret_cast<const uint8_t *const *>(
Offsets::VAR_WORLDMAP_CONTINENT_DATA);
if (blob == nullptr)
return -1;
const uint8_t *entry = blob + continent * Offsets::WORLDMAP_CONTINENT_STRIDE;
const int zone =
*reinterpret_cast<const int *>(Offsets::VAR_WORLDMAP_ZONE_INDEX);
if (zone < 0)
return *reinterpret_cast<const int *>(entry + 0x04);
const int *zoneRows = *reinterpret_cast<const int *const *>(entry + 0x10);
return (zoneRows != nullptr) ? zoneRows[zone] : -1;
}
// WorldMapArea row id for an AreaTable areaID. Linear walk — the table
// has ~175 rows; called once per Lua call.
int WorldMapAreaRowForAreaID(uint32_t areaID) {
const int count =
*reinterpret_cast<const int *>(Offsets::VAR_WORLDMAP_AREA_COUNT);
for (int id = 1; id <= count; ++id) {
const uint8_t *rec = DBC::Record(Offsets::VAR_WORLDMAP_AREA_RECORDS,
Offsets::VAR_WORLDMAP_AREA_COUNT,
static_cast<uint32_t>(id));
if (rec != nullptr &&
*reinterpret_cast<const uint32_t *>(rec + Offsets::OFF_WMA_AREA_ID) ==
areaID)
return id;
}
return -1;
}
// Pushes the `tiles` array for one overlay (leaves it on the stack top).
void PushTiles(void *L, const ResolvedOverlay *r, const char *basePath,
int dbcW, int dbcH, int offX, int offY) {
Game::Lua::NewTable(L);
if (r == nullptr || r->count == 0)
return;
const int cols = r->cols;
char file[0x120];
for (int i = 0; i < r->count; ++i) {
const int col = i % cols;
const int row = i / cols;
std::snprintf(file, sizeof(file), "%s%d", basePath, i + 1);
double drawW, drawH, tcX, tcY, posX, posY;
if (r->upscaled) {
// Higher-res re-export of the same rect: tiles split the DBC
// rect evenly, full texcoords.
drawW = static_cast<double>(dbcW) / r->cols;
drawH = static_cast<double>(dbcH) / r->rows;
tcX = 1.0;
tcY = 1.0;
posX = offX + col * drawW;
posY = offY + row * drawH;
} else {
// Canonical 256px logical grid. Draw sizes/positions stay in
// logical map pixels; the texcoord is the content fraction of the
// (possibly HD-upscaled) file. `scale` = file px per logical px
// (1 on stock, 2/4 on an HD patch), so a full interior tile reads
// texCoord 1.0 regardless of file size, and an edge's content
// (remainder * scale) is measured against its actual file width.
const int fileW = r->fileW[i];
const int fileH = r->fileH[i];
const double scale = (r->cell > 0) ? (r->cell / 256.0) : 1.0;
double logicalW = 256.0, logicalH = 256.0;
tcX = 1.0;
tcY = 1.0;
if (col == r->cols - 1) {
const int rem = dbcW - 256 * (r->cols - 1);
if (rem <= 0) {
// Sliver the DBC width rounded away: whole file is content
// (its logical width is the file width de-scaled).
logicalW = fileW / scale;
} else {
// Content fills rem/NextPot(rem) of the POT tile file.
// Scale-invariant, so an HD-upscaled file (POT*scale)
// reads the same fraction — using the raw file width here
// would halve the texcoord on any tile the patch doubled.
logicalW = rem;
tcX = rem / static_cast<double>(NextPot(rem));
}
}
if (row == r->rows - 1) {
const int rem = dbcH - 256 * (r->rows - 1);
if (rem <= 0) {
logicalH = fileH / scale;
} else {
logicalH = rem;
tcY = rem / static_cast<double>(NextPot(rem));
}
}
drawW = logicalW;
drawH = logicalH;
posX = offX + col * 256;
posY = offY + row * 256;
}
Game::Lua::PushNumber(L, static_cast<double>(i + 1));
Game::Lua::NewTable(L);
Game::Lua::SetFieldString(L, "file", file);
Game::Lua::SetFieldNumber(L, "width", drawW);
Game::Lua::SetFieldNumber(L, "height", drawH);
Game::Lua::SetFieldNumber(L, "texCoordX", tcX);
Game::Lua::SetFieldNumber(L, "texCoordY", tcY);
Game::Lua::SetFieldNumber(L, "offsetX", posX);
Game::Lua::SetFieldNumber(L, "offsetY", posY);
Game::Lua::SetTable(L, -3);
}
}
// `C_Map.GetMapOverlays([areaID])` — see the module comment.
int __fastcall Script_GetMapOverlays(void *L) {
int wmaRow = -1;
if (Game::Lua::IsNumber(L, 1)) {
wmaRow = WorldMapAreaRowForAreaID(
static_cast<uint32_t>(Game::Lua::ToNumber(L, 1)));
} else {
wmaRow = CurrentWorldMapAreaRow();
}
Game::Lua::SetTop(L, 0);
Game::Lua::NewTable(L);
if (wmaRow <= 0)
return 1;
const uint8_t *wmaRec = DBC::Record(Offsets::VAR_WORLDMAP_AREA_RECORDS,
Offsets::VAR_WORLDMAP_AREA_COUNT,
static_cast<uint32_t>(wmaRow));
const char *mapDir =
(wmaRec != nullptr)
? *reinterpret_cast<const char *const *>(wmaRec +
Offsets::OFF_WMA_NAME)
: nullptr;
const int count =
*reinterpret_cast<const int *>(Offsets::VAR_WORLDMAP_OVERLAY_COUNT);
int outIdx = 0;
for (int id = 1; id <= count; ++id) {
const uint8_t *rec = DBC::Record(Offsets::VAR_WORLDMAP_OVERLAY_RECORDS,
Offsets::VAR_WORLDMAP_OVERLAY_COUNT,
static_cast<uint32_t>(id));
if (rec == nullptr ||
*reinterpret_cast<const int *>(rec + Offsets::OFF_WMO_WORLDMAP_AREA) !=
wmaRow)
continue;
const char *texName = *reinterpret_cast<const char *const *>(
rec + Offsets::OFF_WMO_TEXTURE_NAME);
if (texName == nullptr || texName[0] == '\0')
continue; // hit-rect-only rows have no drawable texture
// Full path, mirroring the engine's own GetMapOverlayInfo format
// (unresolvable map dir falls back to the "World" folder).
char basePath[0x104];
if (mapDir != nullptr && mapDir[0] != '\0')
std::snprintf(basePath, sizeof(basePath),
"Interface\\WorldMap\\%s\\%s", mapDir, texName);
else
std::snprintf(basePath, sizeof(basePath),
"Interface\\WorldMap\\World\\%s", texName);
const auto fieldInt = [rec](uintptr_t off) {
return *reinterpret_cast<const int32_t *>(rec + off);
};
const int dbcW = fieldInt(Offsets::OFF_WMO_TEXTURE_WIDTH);
const int dbcH = fieldInt(Offsets::OFF_WMO_TEXTURE_HEIGHT);
const int offX = fieldInt(Offsets::OFF_WMO_OFFSET_X);
const int offY = fieldInt(Offsets::OFF_WMO_OFFSET_Y);
const ResolvedOverlay *r = CachedResolve(id, basePath, dbcW, dbcH);
outIdx += 1;
Game::Lua::PushNumber(L, static_cast<double>(outIdx));
Game::Lua::NewTable(L);
Game::Lua::SetFieldString(L, "textureName", texName);
Game::Lua::SetFieldString(L, "texturePath", basePath);
Game::Lua::SetFieldNumber(L, "textureWidth", dbcW);
Game::Lua::SetFieldNumber(L, "textureHeight", dbcH);
Game::Lua::SetFieldNumber(L, "offsetX", offX);
Game::Lua::SetFieldNumber(L, "offsetY", offY);
Game::Lua::SetFieldNumber(
L, "mapPointX", fieldInt(Offsets::OFF_WMO_MAP_POINT_X));
Game::Lua::SetFieldNumber(
L, "mapPointY", fieldInt(Offsets::OFF_WMO_MAP_POINT_Y));
if (r != nullptr) {
Game::Lua::SetFieldNumber(L, "tileCols", r->cols);
Game::Lua::SetFieldNumber(L, "tileRows", r->rows);
Game::Lua::SetFieldBool(L, "upscaled", r->upscaled);
}
Game::Lua::PushString(L, "tiles");
PushTiles(L, r, basePath, dbcW, dbcH, offX, offY);
Game::Lua::SetTable(L, -3);
Game::Lua::SetTable(L, -3);
}
return 1;
}
void RegisterLuaFunctions() {
Game::Lua::RegisterTableFunction("C_Map", "GetMapOverlays",
&Script_GetMapOverlays);
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace
} // namespace Map::Overlays
+312
View File
@@ -0,0 +1,312 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
// `C_PlayerInfo.CanUseItem(itemID)` — true iff the local player meets the
// item's *use/equip* requirements. This is the "is the item red in the
// tooltip" gate, and is distinct from its siblings:
// - `C_Item.IsEquippableItem` — does the item fit *some* slot (static).
// - `IsUsableItem` — is the item's on-use ability castable now.
// - `C_PlayerInfo.CanUseItem` — may the player equip/use it at all.
//
// So this is the answer to "mail chest on a Mage" (proficiency), "level-40
// item on a level-20" (RequiredLevel), and class/race-restricted gear.
//
// Not in vanilla 1.12 nor in our 3.3.5 reference client (the modern API
// landed in 1.14.4 / 3.4.2 / 10.0.5), so there's no cross-binary source;
// the logic mirrors the requirement checks the 1.12 item tooltip builder
// (`FUN_0052B650`) inlines for its red/green line coloring — the same
// `bVar23` "can't use this" flag, minus the display.
//
// Checks (all must pass):
// 1. Proficiency — for weapon/armor the engine keeps a per-item-class
// subclass-proficiency bitmask (VAR_PROFICIENCY_TABLE, fed by
// SMSG_SET_PROFICIENCY). `mask[m_class] & (1 << m_subClass)` must be
// set. Item classes with no proficiency concept store mask 0 and are
// unrestricted (matches the tooltip's `mask != 0` gate).
// 2. RequiredLevel <= player level.
// 3. AllowableClass / AllowableRace masks include the player (0 or all-
// bits = no restriction).
// 4. RequiredSkill — the player must have the item's skill line at an
// effective rank (current + temp bonus) >= RequiredSkillRank (e.g. a
// mount "Requires Riding (150)"). Via FUN_SKILL_LINE_TO_SLOT.
// 5. RequiredSpell — the player must know the item's prerequisite spell,
// or a higher rank of it: a crafting specialization ("Requires
// Armorsmith") or a proficiency spell. Via the spell-knowledge bitmap
// plus the engine's rank-chain walk.
// 6. RequiredHonorRank / RequiredCityRank — PvP-rank gates: the player's
// honor rank (>= required) and earned PvP-medal bitmask.
// 7. RequiredReputation — current standing with the item's faction must
// reach the required reaction band.
//
// Cache-miss handling matches `C_Item.IsEquippableItem`: a synchronous
// peek that returns false without firing a load.
#include "Game.h"
#include "Offsets.h"
#include "item/Arg.h"
#include "spell/Lookup.h"
#include <cstdint>
namespace Player::CanUseItem {
namespace {
using GetItemRecord_t = const uint8_t *(__thiscall *)(void *cache, uint32_t itemID,
const uint64_t *guid, void *callback,
void *userData, int unused);
using ResolveUnitToken_t = void *(__fastcall *)(const char *token);
// `__thiscall(player /*ecx*/, skillLineID) -> slot` (-1 if the player
// doesn't have the skill line). See VAR docs on FUN_SKILL_LINE_TO_SLOT.
using SkillLineToSlot_t = int(__thiscall *)(void *player, uint32_t skillLineID);
// `__fastcall(factionID) -> current total reputation` (base + bonus), 0
// for factions not in the player's list. See FUN_REPUTATION_GET_STANDING.
using RepStanding_t = int(__fastcall *)(int factionID);
// `__thiscall(player /*ecx*/, spellID) -> nonzero` if the player knows a
// higher rank in spellID's chain. See FUN_SPELL_RANK_CHAIN_KNOWN.
using SpellRankChainKnown_t = int(__thiscall *)(void *player, uint32_t spellID);
const uint8_t *PeekItemRecord(uint32_t itemID) {
auto fn = reinterpret_cast<GetItemRecord_t>(Offsets::FUN_DBCACHE_ITEMSTATS_GET_RECORD);
auto *cache = reinterpret_cast<void *>(Offsets::VAR_ITEMDB_CACHE);
const uint64_t zeroGuid = 0;
return fn(cache, itemID, &zeroGuid, nullptr, nullptr, 0);
}
// The local player's CGUnit/CGPlayer object (holds the descriptor at
// +OFF_UNIT_DESCRIPTOR and the CGPlayer sub-struct at +OFF_CGPLAYER_INFO).
const uint8_t *ResolvePlayer() {
auto resolve = reinterpret_cast<ResolveUnitToken_t>(Offsets::FUN_RESOLVE_UNIT_TOKEN);
return static_cast<const uint8_t *>(resolve("player"));
}
// True iff `mask` restricts a 1-based `index` (class/race) the player
// isn't part of. Mask 0 and 0xFFFFFFFF both mean "no restriction".
bool RestrictedOut(uint32_t mask, uint32_t index1Based) {
if (mask == 0 || mask == 0xFFFFFFFF || index1Based == 0)
return false;
return (mask & (1u << ((index1Based - 1) & 31))) == 0;
}
// Per-item-class proficiency bitmask lookup. Returns false only when the
// item's class *has* a proficiency concept (mask != 0) and the item's
// subclass bit is unset.
bool ProficiencyMet(const uint8_t *record) {
const uint32_t itemClass = *reinterpret_cast<const uint32_t *>(
record + Offsets::OFF_ITEMSTATS_CLASS);
const uint32_t subClass = *reinterpret_cast<const uint32_t *>(
record + Offsets::OFF_ITEMSTATS_SUBCLASS);
if (itemClass > 16) // table is 17 entries (m_class 0..16)
return true;
auto *table = reinterpret_cast<const uint32_t *>(
static_cast<uintptr_t>(Offsets::VAR_PROFICIENCY_TABLE));
const uint32_t mask = table[itemClass];
if (mask == 0)
return true; // class has no proficiency concept (consumables, …)
return (mask & (1u << (subClass & 31))) != 0;
}
// RequiredSkill + RequiredSkillRank gate (e.g. a mount "Requires Riding
// (150)", a profession tool "Requires Engineering (200)"). Maps the
// item's SkillLine.dbc row to the player's skill slot; the player must
// have the line at an effective rank (current + temp bonus) >= required.
bool RequiredSkillMet(const uint8_t *player, const uint8_t *record) {
const uint32_t reqSkill = *reinterpret_cast<const uint32_t *>(
record + Offsets::OFF_ITEMSTATS_REQUIRED_SKILL);
if (reqSkill == 0)
return true; // no skill requirement
const uint32_t reqRank = *reinterpret_cast<const uint32_t *>(
record + Offsets::OFF_ITEMSTATS_REQUIRED_SKILL_RANK);
auto toSlot = reinterpret_cast<SkillLineToSlot_t>(Offsets::FUN_SKILL_LINE_TO_SLOT);
const int slot = toSlot(const_cast<uint8_t *>(player), reqSkill);
if (slot < 0)
return false; // player doesn't have the skill line at all
auto *sub = *reinterpret_cast<const uint8_t *const *>(
player + Offsets::OFF_CGPLAYER_INFO);
if (sub == nullptr)
return false;
const uint8_t *rec = sub + Offsets::OFF_SKILL_INFO_TABLE +
slot * Offsets::SKILL_INFO_STRIDE;
uint32_t rank = *reinterpret_cast<const uint16_t *>(rec + Offsets::OFF_SKILL_INFO_CUR);
if (rank != 0) // add the temp bonus only when there's a base value
rank += *reinterpret_cast<const uint16_t *>(rec + Offsets::OFF_SKILL_INFO_BONUS);
return rank >= reqRank;
}
// True if the local player knows `spellID`, via the engine's spell-
// knowledge bitmap (the active-player path of `FUN_0060c740`, read
// directly — no player object needed). Covers trained abilities, talents,
// racials, profession recipes, and crafting specializations (Armorsmith,
// Weaponsmith, the Engineering branches, …), which are all single learned
// spells that land in this bitmap.
bool PlayerKnowsSpell(int spellID) {
if (spellID <= 0)
return false;
auto *bitmap = *reinterpret_cast<const uint32_t *const *>(
static_cast<uintptr_t>(Offsets::VAR_PLAYER_SPELL_BITMAP));
if (bitmap == nullptr)
return false;
const int spellCount = *reinterpret_cast<const int *>(
static_cast<uintptr_t>(Offsets::VAR_SPELL_RECORD_COUNT));
if (spellID > spellCount)
return false;
return (bitmap[spellID >> 5] & (1u << (spellID & 31))) != 0;
}
// RequiredSpell gate — the item's `RequiredSpell` (a Spell.dbc row) is the
// prerequisite the "Requires <spell>" tooltip line reflects: a crafting
// specialization like Armorsmith, or an armor/weapon proficiency spell.
// The player must know it. An unset (0) or invalid (no Spell.dbc record)
// requirement is no gate — matching the tooltip's validity guard, which
// skips the line for a missing row.
//
// Mirrors the tooltip's `bitmap(reqSpell) OR rank-chain(reqSpell)`: met if
// the player knows the exact RequiredSpell, or a higher rank of it (the
// latter covers a ranked RequiredSpell — `FUN_SPELL_RANK_CHAIN_KNOWN`
// walks the forward chain). Item RequiredSpell is a non-ranked
// specialization/proficiency in practice, so the chain walk is
// belt-and-suspenders, but it makes the check exact.
bool RequiredSpellMet(const uint8_t *player, const uint8_t *record) {
const int reqSpell = *reinterpret_cast<const int32_t *>(
record + Offsets::OFF_ITEMSTATS_REQUIRED_SPELL);
if (reqSpell <= 0)
return true; // no requirement
if (::Spell::Lookup::RecordForID(reqSpell) == nullptr)
return true; // invalid Spell.dbc row → tooltip skips the gate
if (PlayerKnowsSpell(reqSpell))
return true; // knows the exact spell
auto chainKnown = reinterpret_cast<SpellRankChainKnown_t>(
Offsets::FUN_SPELL_RANK_CHAIN_KNOWN);
return chainKnown(const_cast<uint8_t *>(player),
static_cast<uint32_t>(reqSpell)) != 0;
}
// RequiredHonorRank — the player's PvP honor rank must be >= the item's.
// `sub` is the CGPlayer +0xE68 sub-struct (may be null pre-world).
bool RequiredHonorRankMet(const uint8_t *sub, const uint8_t *record) {
const uint32_t req = *reinterpret_cast<const uint32_t *>(
record + Offsets::OFF_ITEMSTATS_REQUIRED_HONOR_RANK);
if (req == 0)
return true;
if (sub == nullptr)
return false;
const uint8_t rank = *(sub + Offsets::OFF_CGPLAYER_HONOR_RANK);
return rank >= req;
}
// RequiredCityRank — a PvP-medal bitmask gate: the item's rank R passes
// when the player has earned bit (R-1) in the medal mask.
bool RequiredCityRankMet(const uint8_t *sub, const uint8_t *record) {
const uint32_t req = *reinterpret_cast<const uint32_t *>(
record + Offsets::OFF_ITEMSTATS_REQUIRED_CITY_RANK);
if (req == 0)
return true;
if (sub == nullptr)
return false;
const uint32_t medals = *reinterpret_cast<const uint32_t *>(
sub + Offsets::OFF_CGPLAYER_PVP_MEDALS);
return (medals & (1u << ((req - 1) & 31))) != 0;
}
// RequiredReputation — current standing with the item's faction must
// reach the required reaction band. The band index (0=Hated … 7=Exalted)
// indexes the engine's reaction-min threshold table; the player's total
// rep with the faction must be >= that threshold.
bool RequiredReputationMet(const uint8_t *record) {
const int faction = *reinterpret_cast<const int32_t *>(
record + Offsets::OFF_ITEMSTATS_REQUIRED_FACTION);
if (faction == 0)
return true;
const uint32_t band = *reinterpret_cast<const uint32_t *>(
record + Offsets::OFF_ITEMSTATS_REQUIRED_FACTION_RANK);
if (band > 7) // reaction-min table is 8 bands (0..7); guard OOB
return true;
auto getStanding = reinterpret_cast<RepStanding_t>(Offsets::FUN_REPUTATION_GET_STANDING);
const int current = getStanding(faction);
const int threshold = *reinterpret_cast<const int *>(
static_cast<uintptr_t>(Offsets::VAR_REACTION_MIN_TABLE) + band * 4);
return current >= threshold;
}
bool ComputeCanUse(int itemID) {
if (itemID <= 0)
return false;
auto *record = PeekItemRecord(static_cast<uint32_t>(itemID));
if (record == nullptr)
return false; // not cached → sync false, no load fired
auto *player = ResolvePlayer();
if (player == nullptr)
return false; // pre-login / no player
auto *desc = *reinterpret_cast<const uint8_t *const *>(
player + Offsets::OFF_UNIT_DESCRIPTOR);
if (desc == nullptr)
return false;
if (!ProficiencyMet(record))
return false;
const int playerLevel = *reinterpret_cast<const int *>(
desc + Offsets::OFF_UNIT_FIELD_LEVEL);
const uint32_t reqLevel = *reinterpret_cast<const uint32_t *>(
record + Offsets::OFF_ITEMSTATS_REQUIRED_LEVEL);
if (static_cast<int>(reqLevel) > playerLevel)
return false;
const uint32_t allowClass = *reinterpret_cast<const uint32_t *>(
record + Offsets::OFF_ITEMSTATS_ALLOWABLE_CLASS);
const uint32_t playerClass = *(desc + Offsets::OFF_UNIT_DESCRIPTOR_CLASS_BYTE);
if (RestrictedOut(allowClass, playerClass))
return false;
const uint32_t allowRace = *reinterpret_cast<const uint32_t *>(
record + Offsets::OFF_ITEMSTATS_ALLOWABLE_RACE);
const uint32_t playerRace = *(desc + Offsets::OFF_UNIT_DESCRIPTOR_RACE_BYTE);
if (RestrictedOut(allowRace, playerRace))
return false;
if (!RequiredSkillMet(player, record))
return false;
if (!RequiredSpellMet(player, record))
return false;
// PvP rank + reputation gates read the CGPlayer +0xE68 sub-struct.
auto *sub = *reinterpret_cast<const uint8_t *const *>(
player + Offsets::OFF_CGPLAYER_INFO);
if (!RequiredHonorRankMet(sub, record))
return false;
if (!RequiredCityRankMet(sub, record))
return false;
if (!RequiredReputationMet(record))
return false;
return true;
}
int __fastcall Script_CanUseItem(void *L) {
const int itemID = Item::Arg::ResolveItemID(L, 1);
Game::Lua::PushBool(L, ComputeCanUse(itemID));
return 1;
}
} // namespace
static void RegisterLuaFunctions() {
Game::Lua::RegisterTableFunction("C_PlayerInfo", "CanUseItem", &Script_CanUseItem);
}
static const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace Player::CanUseItem
+117 -22
View File
@@ -23,13 +23,19 @@
// parse callbacks, and the registry handles load/save so neither
// this module nor `Charlist::Order` clobbers the other's lines.
//
// `WTF\Account\<acct>\<realm>\ClassicAPI_NameCache.txt`
// Per-realm cache contents. v2 format. One entry per line:
// `guidHi guidLo classID raceID sex name`
// `WTF\ClassicAPI\<realmList>\<realm>\ClassicAPI_NameCache.txt`
// Per-realm cache contents, SHARED across every account on the
// same server. Keyed by realmlist address (unambiguous server
// identity — can't collide across unrelated private servers) plus
// the realm name, rather than by account, so alts on different
// accounts pool into one file. Not under `Account\`, so we create
// the directory tree ourselves. v4 format. One entry per line:
// `name guid classID raceID sex`
// (fields tab-separated). Loaded once the realm name is
// available post-login. Saved when (a) a recent write hasn't
// been flushed AND (b) at least 30 seconds have elapsed since
// the prior save, OR (c) DLL teardown / `Flush()` is called.
// available post-login (migrating a pre-existing per-account file
// in on first load). Saved when (a) a recent write hasn't been
// flushed AND (b) at least 30 seconds have elapsed since the prior
// save, OR (c) DLL teardown / `Flush()` is called.
//
// Population sources:
// 1. The engine's NameCache writer (`FUN_PLAYER_INFO_CACHE_WRITE`)
@@ -69,6 +75,7 @@
#include <cstdlib>
#include <cstring>
#include <fstream>
#include <istream>
#include <ostream>
#include <sstream>
#include <string>
@@ -139,7 +146,57 @@ const char *ReadRealmName() {
// ----- path resolution -----
// realmList CVar value (the realmlist address, e.g.
// "logon.turtle-wow.org") — the unambiguous server identity. Unlike the
// friendly realm name it can't collide across unrelated private servers,
// so it's the outer key for the shared cache path. Mirrors
// `Security::CredStore`'s realm scoping.
const char *ReadRealmList() {
using GetRealmList_t = const char *(__fastcall *)(int forceRefresh);
return reinterpret_cast<GetRealmList_t>(
static_cast<uintptr_t>(Offsets::FUN_CVAR_REALM_LIST))(0);
}
// Filesystem-safe path component. We create these directories ourselves
// (WoW doesn't), so any character Windows rejects in a path — or that
// could surprise, like the space/apostrophe in "N'Zoth" — becomes '_'.
// Deterministic, so a realm always maps to the same folder.
std::string SanitizeComponent(const char *s) {
std::string out;
for (const char *p = s; p != nullptr && *p; ++p) {
const char c = *p;
const bool safe = (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') ||
(c >= '0' && c <= '9') || c == '.' || c == '-' ||
c == '_';
out.push_back(safe ? c : '_');
}
return out;
}
// Shared, server-specific cache path:
// WTF\ClassicAPI\<realmList>\<realmName>\ClassicAPI_NameCache.txt
// Not under Account\, so alts on different accounts of the same server
// share one pool. Empty (defer) until both realmlist + realm are known.
std::string CachePath() {
const char *realmList = ReadRealmList();
if (realmList == nullptr || realmList[0] == '\0')
return {};
const char *realm = ReadRealmName();
if (realm == nullptr || realm[0] == '\0')
return {};
std::string out = "WTF\\ClassicAPI\\";
out += SanitizeComponent(realmList);
out += '\\';
out += SanitizeComponent(realm);
out += "\\ClassicAPI_NameCache.txt";
return out;
}
// The pre-shared per-account path, used only to migrate an existing
// cache into the shared location on first load. Built exactly as it was
// written (raw account + realm, unsanitized) so it matches the file
// sitting in WoW's own folders.
std::string OldCachePath() {
const char *account = ReadAccountName();
if (account == nullptr || account[0] == '\0')
return {};
@@ -154,6 +211,21 @@ std::string CachePath() {
return out;
}
// Creates every parent directory of `filePath` (mkdir -p). WoW makes
// `WTF\` but not `WTF\ClassicAPI\...`, and ofstream won't create
// directories, so a save into a fresh realm folder would silently fail
// without this. CreateDirectory on an existing dir no-ops.
void EnsureParentDirs(const std::string &filePath) {
for (size_t pos = 0;;) {
const size_t slash = filePath.find('\\', pos);
if (slash == std::string::npos)
break; // remainder is the filename
if (slash > 0)
CreateDirectoryA(filePath.substr(0, slash).c_str(), nullptr);
pos = slash + 1;
}
}
// ----- account settings (registered with Settings::Account) -----
void SettingsReset() {
@@ -259,21 +331,12 @@ bool Upsert(uint64_t guid, const std::string &name,
return changed;
}
void LoadCacheIfNeeded() {
if (g_cacheLoaded)
return;
const std::string path = CachePath();
if (path.empty())
return; // pre-realm; retry on next call (g_cacheLoaded stays false)
g_cacheLoaded = true;
std::ifstream file(path);
if (!file.is_open())
return; // first run for this realm
// v4 format: `name\tguid\tclass\trace\tsex`. Name first since
// it's the primary key; GUID is a peripheral field carried for
// GUID-keyed lookups. Variable-width hex GUID (no leading zeros).
// Parses v4 cache lines (`name\tguid\tclass\trace\tsex`) from `file`
// into the store. Name first since it's the primary key; GUID is a
// peripheral field carried for GUID-keyed lookups. Variable-width hex
// GUID (no leading zeros). Shared by the normal load and the one-time
// migration from the old per-account file.
void LoadFromStream(std::istream &file) {
std::string line;
while (std::getline(file, line)) {
while (!line.empty() && (line.back() == '\r' || line.back() == '\n'))
@@ -301,10 +364,42 @@ void LoadCacheIfNeeded() {
}
}
void LoadCacheIfNeeded() {
if (g_cacheLoaded)
return;
const std::string path = CachePath();
if (path.empty())
return; // pre-realm; retry on next call (g_cacheLoaded stays false)
g_cacheLoaded = true;
std::ifstream file(path);
if (file.is_open()) {
LoadFromStream(file);
return;
}
// No shared file yet. One-time migration: seed from the old
// per-account file if it exists, then mark dirty so the next save
// writes the merged data into the shared path. Only when the shared
// file is absent — once it exists it's authoritative (a second
// account's old-only entries just repopulate via scan / SMSG).
const std::string oldPath = OldCachePath();
if (oldPath.empty())
return;
std::ifstream oldFile(oldPath);
if (oldFile.is_open()) {
LoadFromStream(oldFile);
g_dirty = true;
}
}
void SaveCache() {
const std::string path = CachePath();
if (path.empty())
return;
// `WTF\ClassicAPI\<realmList>\<realm>\` isn't created by WoW — make
// the tree first, else the ofstream open below silently fails.
EnsureParentDirs(path);
const std::string tmp = path + ".tmp";
{
std::ofstream out(tmp, std::ios::binary | std::ios::trunc);
@@ -319,7 +414,7 @@ void SaveCache() {
// sort/scan key when hand-editing. GUID is variable-width
// hex — 1-8 digits for player GUIDs, up to 16 if a non-player
// GUID ever lands here.
out << "# ClassicAPI name cache v4 (per-realm)\n";
out << "# ClassicAPI name cache v4 (per-realm, shared across accounts)\n";
out << "# name\tguid\tclassID\traceID\tsex\n";
for (const auto &kv : g_entries) {
const std::string &name = kv.first;
+48 -27
View File
@@ -17,13 +17,12 @@
//
// Implementation chains the engine's existing primitives:
//
// 1. Initiate the cast the same way `Script_CastSpellByName` does:
// resolve `spellID` (via our `Spell::CastByID` numeric-name hook)
// to a spellbook slot through `FUN_RESOLVE_SPELL_NAME_TO_SLOT`,
// then dispatch through `FUN_SPELL_CAST_DISPATCH`. For a ground-
// target spell, the engine enters placement mode at this point
// (sets `VAR_SPELL_PLACEMENT_STATE`); for a normal cast, it
// fires immediately.
// 1. Initiate the cast: resolve the *exact* `spellID` to its spellbook
// slot (`Spell::Lookup::FindSpellbookSlot` — NOT name resolution, which
// would cast the highest rank) and dispatch through
// `FUN_SPELL_CAST_DISPATCH`. For a ground-target spell, the engine
// enters placement mode at this point (sets `VAR_SPELL_PLACEMENT_STATE`);
// for a normal cast, it fires immediately.
//
// 2. `Spell::AtCursor::Resolve()` then refreshes the cursor's world
// raycast via `FUN_REFRESH_CURSOR_RAYCAST` and commits the
@@ -41,10 +40,10 @@
#include "Game.h"
#include "Offsets.h"
#include "spell/Lookup.h"
#include "spell/MacroPrimarySpell.h"
#include <cstdint>
#include <cstdio>
#include <cstring>
namespace Spell::AtCursor {
@@ -141,9 +140,16 @@ bool Resolve() {
namespace {
using NameToSlot_t = int(__fastcall *)(const char *name, void *out);
using CastDispatch_t = void(__fastcall *)(unsigned slot, int bookType,
unsigned targetGuidLo, float targetGuidHi);
using NameToSlot_t = int(__fastcall *)(const char *name, void *out);
void DispatchSlot(int slot0Based, int bookType) {
auto dispatch = reinterpret_cast<CastDispatch_t>(
Offsets::FUN_SPELL_CAST_DISPATCH);
// (0, 0) GUID = no implicit target; the engine handles placement.
dispatch(static_cast<unsigned>(slot0Based), bookType, 0, 0.0f);
}
} // namespace
@@ -151,39 +157,54 @@ bool DispatchSpellCast(int spellID) {
if (spellID <= 0)
return false;
// Hand the spellID off as a numeric string — our `Spell::CastByID`
// hook on `FUN_RESOLVE_SPELL_NAME_TO_SLOT` translates pure-numeric
// input into the locale-resolved spell name internally, so the
// engine treats it just like `CastSpellByName("Blizzard")`.
char numBuf[16];
std::snprintf(numBuf, sizeof(numBuf), "%d", spellID);
// Resolve the EXACT spellID to its spellbook slot. Every rank is its own
// slot, so we must NOT go through name resolution — that walks to the
// highest rank and would cast Blizzard(Rank 6) for a Rank 1 (spellID 10)
// request. `FindSpellbookSlot` returns a 1-based slot; the dispatcher
// wants the 0-based index into `VAR_PLAYER_SPELLBOOK`.
int bookType = 0;
const int slot1 = Spell::Lookup::FindSpellbookSlot(spellID, &bookType);
if (slot1 <= 0)
return false; // not in the player's spellbook
DispatchSlot(slot1 - 1, bookType);
return true;
}
bool DispatchSpellCastByName(const char *name) {
if (name == nullptr || *name == '\0')
return false;
// The engine's resolver strips a trailing `(Rank N)` and returns the
// 0-based spellbook slot (highest rank when no rank is given). This is the
// by-name path — for an exact rank prefer the numeric spellID overload.
auto nameToSlot = reinterpret_cast<NameToSlot_t>(
Offsets::FUN_RESOLVE_SPELL_NAME_TO_SLOT);
int bookType = 0;
const int slot = nameToSlot(numBuf, &bookType);
const int slot = nameToSlot(name, &bookType);
if (slot < 0)
return false; // not in the player's spellbook
auto dispatch = reinterpret_cast<CastDispatch_t>(
Offsets::FUN_SPELL_CAST_DISPATCH);
dispatch(static_cast<unsigned>(slot), bookType,
0, 0.0f); // no implicit target unit; engine handles placement
DispatchSlot(slot, bookType);
return true;
}
namespace {
int __fastcall Script_C_Spell_CastAtCursor(void *L) {
if (!Game::Lua::IsNumber(L, 1)) {
Game::Lua::PushBool(L, false);
return 1;
// Accept a numeric spellID (exact rank) or a spell name (the engine's
// resolver parses a trailing "(Rank N)"). IsNumber is checked first so a
// numeric string like "10" still takes the exact-spellID path.
bool dispatched = false;
if (Game::Lua::IsNumber(L, 1)) {
dispatched = DispatchSpellCast(static_cast<int>(Game::Lua::ToNumber(L, 1)));
} else if (Game::Lua::IsString(L, 1)) {
dispatched = DispatchSpellCastByName(Game::Lua::ToString(L, 1));
}
const int spellID = static_cast<int>(Game::Lua::ToNumber(L, 1));
// Spell not in the player's spellbook → match `CastSpellByName`'s
// silent failure.
if (!DispatchSpellCast(spellID)) {
// Bad input / spell not in the player's spellbook → match
// `CastSpellByName`'s silent failure.
if (!dispatched) {
Game::Lua::PushBool(L, false);
return 1;
}
+6
View File
@@ -46,6 +46,12 @@ bool CommitAtCoords(const float coords[3]);
// `CommitAtCoords`); for a normal spell it fires immediately. Returns
// true if the cast was dispatched (spell is in the player's
// spellbook), false otherwise.
//
// The numeric form casts the *exact* spellID (a specific rank). The
// name form defers to the engine's own resolver, which parses a
// trailing `(Rank N)` — `"Blizzard"` casts the highest known rank,
// `"Blizzard(Rank 6)"` casts that rank.
bool DispatchSpellCast(int spellID);
bool DispatchSpellCastByName(const char *name);
} // namespace Spell::AtCursor
+11 -3
View File
@@ -43,11 +43,14 @@ namespace Spell::AtUnit {
namespace {
int __fastcall Script_C_Spell_CastAtUnit(void *L) {
if (!Game::Lua::IsNumber(L, 1) || !Game::Lua::IsString(L, 2)) {
// arg1: numeric spellID (exact rank) or spell name ("(Rank N)"-aware).
// arg2: unit token.
const bool byNumber = Game::Lua::IsNumber(L, 1);
if ((!byNumber && !Game::Lua::IsString(L, 1)) ||
!Game::Lua::IsString(L, 2)) {
Game::Lua::PushBool(L, false);
return 1;
}
const int spellID = static_cast<int>(Game::Lua::ToNumber(L, 1));
const char *token = Game::Lua::ToString(L, 2);
// Resolve the unit's position before dispatching — fail fast so a
@@ -61,7 +64,12 @@ int __fastcall Script_C_Spell_CastAtUnit(void *L) {
return 1;
}
if (!Spell::AtCursor::DispatchSpellCast(spellID)) {
const bool dispatched =
byNumber
? Spell::AtCursor::DispatchSpellCast(
static_cast<int>(Game::Lua::ToNumber(L, 1)))
: Spell::AtCursor::DispatchSpellCastByName(Game::Lua::ToString(L, 1));
if (!dispatched) {
Game::Lua::PushBool(L, false);
return 1;
}
+69 -15
View File
@@ -115,8 +115,44 @@ TrackedSpell g_channel{0, 0, 0, 0};
// the WorldTick VAR==0 clear must not touch them.
bool g_castFromServer = false;
// Armed once the broadcast UNIT_FIELD_CHANNEL_SPELL (+0x228) has been seen to
// reflect g_channel's spell — the enable for OnWorldTick's channel-stop poll.
// StampChannel re-arms it (false) on every channel stamp so that field's
// ~1-tick start lag can't false-clear a channel we just stamped.
bool g_channelConfirmed = false;
// Stamp the player's channel and re-arm the stop latch. A channel supersedes
// any in-flight cast. Both channel-stamp sites (SMSG_SPELL_START's instant-
// channel path and the MSG_CHANNEL_START handler) route through here so
// neither can forget to reset the latch.
void StampChannel(int spellID, int startMs, int endMs) {
g_channel = TrackedSpell{spellID, startMs, endMs, 0};
g_channelConfirmed = false;
g_cast.spellID = 0;
}
int NowMs() { return static_cast<int>(reinterpret_cast<TickMs_t>(Offsets::FUN_OS_TICKCOUNT_MS)()); }
// Pushes an engine-ms timestamp to Lua as an UNSIGNED 32-bit value.
//
// `FUN_OS_TICKCOUNT_MS` returns a 64-bit ms tick from one of two backends
// (GetTickCount, or a scaled rdtsc — the latter survives warm reboots, so
// the value can be well past 2^31 even right after a "restart"). We keep
// ticks in a signed int internally, which is fine for the small deltas and
// window compares (they cancel), but a direct int->double push surfaces a
// NEGATIVE startTimeMs/endTimeMs once the tick's high bit is set.
//
// The contract is that these match `GetTime()*1000`. Script_GetTime
// (0x00515EA0) does `(double)(tick & 0xFFFFFFFF) * 0.001` — i.e. it masks
// the tick to the low 32 bits UNSIGNED, then scales. So reinterpreting our
// stored tick as uint32 reproduces GetTime()'s exact value for the whole
// range, including across the ~49.7-day 2^32 wrap (both wrap together).
// Carrying 64-bit precision here would be WRONG: it would diverge from
// GetTime() past 2^32 and break addons' (now-start)/(end-start) math.
void PushMs(void *L, int ms) {
Game::Lua::PushNumber(L, static_cast<double>(static_cast<uint32_t>(ms)));
}
void *Resolve(const char *token) {
return reinterpret_cast<ResolveUnitToken_t>(Offsets::FUN_RESOLVE_UNIT_TOKEN)(token);
}
@@ -184,8 +220,8 @@ int PushCastInfo(void *L, const TrackedSpell &c) {
Game::Lua::PushString(L, name); // 1 name
Game::Lua::PushString(L, name); // 2 displayName
Game::Lua::PushString(L, SpellIconPath(rec)); // 3 textureID (path)
Game::Lua::PushNumber(L, static_cast<double>(c.startMs)); // 4 startTimeMs
Game::Lua::PushNumber(L, static_cast<double>(c.endMs)); // 5 endTimeMs
PushMs(L, c.startMs); // 4 startTimeMs
PushMs(L, c.endMs); // 5 endTimeMs
Game::Lua::PushBool(L, IsTradeskill(rec)); // 6 isTradeskill
Game::Lua::PushNil(L); // 7 castID
Game::Lua::PushBool(L, false); // 8 notInterruptible
@@ -214,8 +250,8 @@ int PushChannelInfo(void *L, int spellID, int startMs, int endMs, bool haveTimes
Game::Lua::PushString(L, name); // 2 text / displayName
Game::Lua::PushString(L, SpellIconPath(rec)); // 3 textureID (path)
if (haveTimes) {
Game::Lua::PushNumber(L, static_cast<double>(startMs)); // 4 startTimeMs
Game::Lua::PushNumber(L, static_cast<double>(endMs)); // 5 endTimeMs
PushMs(L, startMs); // 4 startTimeMs
PushMs(L, endMs); // 5 endTimeMs
} else {
Game::Lua::PushNil(L);
Game::Lua::PushNil(L);
@@ -355,10 +391,8 @@ void HandleSpellStart(uint64_t caster, int spellID, uint32_t castTime) {
// Info) at endMs. (MSG_CHANNEL_START re-stamps moments later with
// the server's duration — see ChannelStart_h.)
const int dur = ChannelDurationMs(spellID);
if (dur > 0) {
g_channel = TrackedSpell{spellID, now, now + dur, 0};
g_cast.spellID = 0; // a channel supersedes any cast
}
if (dur > 0)
StampChannel(spellID, now, now + dur);
return;
}
// channel && castTime > 0 → a CAST-THEN-CHANNEL spell (Mind Control:
@@ -474,9 +508,7 @@ int __stdcall ChannelStart_h(uint32_t *opCode, Net::CDataStore *packet) {
packet->m_read = saved;
if (spellID != 0 && duration > 0) {
const int now = NowMs();
g_channel =
TrackedSpell{spellID, now, now + static_cast<int>(duration), 0};
g_cast.spellID = 0; // the channel supersedes the completed cast
StampChannel(spellID, now, now + static_cast<int>(duration));
}
}
return g_origChannelStart(opCode, packet);
@@ -615,10 +647,32 @@ void OnWorldTick() {
*reinterpret_cast<const int *>(Offsets::VAR_CURRENT_CAST_SPELL) == 0)
g_cast.spellID = 0;
// Channels are stamped from SMSG_SPELL_START (HandleSpellStart) and expire
// on their computed endMs (self-expiry in PushChannelInfo). We don't poll
// the broadcast +0x228 field for the player — it lags the channel start by
// ~1 tick, so the bar would show ~1s late.
// Detect a player CHANNEL that ended before its computed endMs — the
// summon-ritual case (UNIT_FIELD_CHANNEL_SPELL = 698, cleared when the
// summon fills), and equally an interrupted / cancelled / LoS-broken
// channel. Without this, g_channel only clears at endMs (self-expiry in
// PushChannelInfo) or when a later TIMED cast supersedes it — so an early
// end followed by an INSTANT cast (which supersedes nothing) leaves a
// ghost bar counting down to a duration that no longer reflects reality.
//
// We deliberately don't use +0x228 to START the bar — it's a broadcast
// UpdateField that lags the channel start by ~1 tick (HandleSpellStart
// stamps from the packet instead). For the STOP that lag is harmless, but a
// naive "clear when field == 0" would wipe a channel we just stamped during
// the lag window. Guard with a latch: only honor the 0 once the field has
// been seen to reflect our channel spell at least once. A channel too short
// to ever appear in the field just self-expires at endMs.
if (g_channel.spellID != 0) {
const uint8_t *desc = Unit::Identity::PlayerDescriptor();
if (desc != nullptr) {
const int chan = *reinterpret_cast<const int *>(
desc + Offsets::OFF_UNIT_FIELD_CHANNEL_SPELL);
if (chan == g_channel.spellID)
g_channelConfirmed = true;
else if (chan == 0 && g_channelConfirmed)
g_channel.spellID = 0;
}
}
}
static const Tick::WorldTick::AutoSubscribe _tickSub{&OnWorldTick};
+139
View File
@@ -117,6 +117,139 @@ bool SpellHiddenFromAutoLearn(int spellID) {
return (attrEx2 & SPELL_ATTR_EX2_HIDE_FROM_AUTOLEARN) != 0;
}
// ---- Target-level gate for ranked beneficial auras ----------------------
//
// A ranked beneficial aura can't be applied to a target too low for the
// rank — e.g. Divine Spirit Rank 1 (14752) requires the target to be level
// >= 20. Server-enforced via `SpellMgr::SelectAuraRankForLevel`, whose rule
// is: a ranked positive aura is castable when `targetLevel + 10 >=
// spellLevel`, i.e. the minimum target level is `spellLevel - 10`. Below
// it the lowest rank fails (SPELL_FAILED_LOWLEVEL); higher ranks auto-
// downrank. The gate only applies to *ranked* *positive* *auras* — see
// RequiredTargetLevel. Fully derivable from the client's Spell.dbc.
//
// (The explicit MinTargetLevel / MaxTargetLevel fields the server also
// checks are NOT in the 1.12 client's Spell.dbc — server-only columns — so
// the spellLevel rule is the only client-readable target-level mechanism.)
constexpr int OFF_SPELL_MAX_LEVEL = 0x6C; // uint32 (scaling cap level)
constexpr int OFF_SPELL_SPELL_LEVEL = 0x74; // uint32 (rank's effective level)
constexpr int OFF_SPELL_EFFECT = 0xF4; // uint32[3] effect type
constexpr int OFF_SPELL_EFFECT_TARGET_A = 0x148; // uint32[3] implicit target A
constexpr int OFF_SPELL_RANK = 0x204; // char*[9] localized rank text
constexpr uint32_t SPELL_ATTR_PASSIVE = 0x40; // Attributes bit
constexpr uint32_t SPELL_EFFECT_APPLY_AURA = 6;
constexpr uint32_t SPELL_EFFECT_APPLY_AREA_AURA_PARTY = 35;
int LocaleIndex() {
return *reinterpret_cast<const int *>(
static_cast<uintptr_t>(Offsets::VAR_LOCALE_INDEX));
}
// The friendly-target IDs the server's SelectAuraRankForLevel treats as
// "positive" for rank selection: IsExplicitPositiveTarget (21/35/45/57/61)
// plus IsAreaEffectPossitiveTarget (20/30/31/33/34/37/56/61). Narrower than
// a general "is helpful" set — this is the exact gate the rank rule uses
// (self-cast / pet targets don't count).
bool IsPositiveRankTarget(uint32_t t) {
switch (t) {
case 21: // TARGET_UNIT_FRIEND
case 35: // TARGET_UNIT_PARTY
case 45: // TARGET_UNIT_FRIEND_CHAIN_HEAL
case 57: // TARGET_UNIT_RAID
case 61: // TARGET_UNIT_RAID_AND_CLASS
case 20: // TARGET_ENUM_UNITS_PARTY_WITHIN_CASTER_RANGE
case 30: // TARGET_ENUM_UNITS_FRIEND_AOE_AT_SRC_LOC
case 31: // TARGET_ENUM_UNITS_FRIEND_AOE_AT_DEST_LOC
case 33: // TARGET_ENUM_UNITS_PARTY_AOE_AT_SRC_LOC
case 34: // TARGET_ENUM_UNITS_PARTY_AOE_AT_DEST_LOC
case 37: // TARGET_UNIT_FRIEND_AND_PARTY
case 56: // TARGET_ENUM_UNITS_RAID_WITHIN_CASTER_RANGE
return true;
default:
return false;
}
}
// True if the record carries a localized "Rank N" string — the client's
// marker for a spell in a ranked line (single-rank spells leave it empty).
// Proxy for the server's `GetSpellRank(id) != 0`, verified against
// single-rank buffs (Power Infusion / Innervate / Blessing of Kings, all
// empty Rank and un-gated) vs ranked ones (Divine Spirit / Fortitude).
bool HasRankString(const uint8_t *record) {
const char *rank = *reinterpret_cast<const char *const *>(
record + OFF_SPELL_RANK + LocaleIndex() * 4);
return rank != nullptr && *rank != '\0';
}
// Minimum target level required to apply this exact spell (rank), or 0 if
// the spell isn't subject to the ranked-positive-aura target-level rule.
int RequiredTargetLevel(const uint8_t *record) {
const int spellLevel =
*reinterpret_cast<const int *>(record + OFF_SPELL_SPELL_LEVEL);
if (spellLevel <= 10) // targetLevel + 10 >= spellLevel holds for any level
return 0;
const uint32_t attr =
*reinterpret_cast<const uint32_t *>(record + OFF_SPELL_RECORD_ATTRIBUTES);
if (attr & SPELL_ATTR_PASSIVE)
return 0;
if (!HasRankString(record)) // single-rank spell → not gated
return 0;
auto *effect = reinterpret_cast<const uint32_t *>(record + OFF_SPELL_EFFECT);
auto *targetA =
reinterpret_cast<const uint32_t *>(record + OFF_SPELL_EFFECT_TARGET_A);
bool positiveAura = false;
for (int i = 0; i < 3; ++i) {
if ((effect[i] == SPELL_EFFECT_APPLY_AURA &&
IsPositiveRankTarget(targetA[i])) ||
effect[i] == SPELL_EFFECT_APPLY_AREA_AURA_PARTY) {
positiveAura = true;
break;
}
}
if (!positiveAura)
return 0;
return spellLevel - 10;
}
int ArgSpellID(void *L) {
if (!Game::Lua::IsNumber(L, 1))
return 0;
return static_cast<int>(Game::Lua::ToNumber(L, 1));
}
// `C_Spell.GetSpellLevelInfo(spellID)` → spellLevel, baseLevel, maxLevel.
// Raw Spell.dbc level fields: spellLevel (this rank's effective level),
// baseLevel, maxLevel (level at which scaling caps). nil for an unknown
// spellID.
int __fastcall Script_GetSpellLevelInfo(void *L) {
const uint8_t *record = Spell::Lookup::RecordForID(ArgSpellID(L));
if (record == nullptr)
return 0;
Game::Lua::PushNumber(
L, static_cast<double>(
*reinterpret_cast<const int *>(record + OFF_SPELL_SPELL_LEVEL)));
Game::Lua::PushNumber(
L, static_cast<double>(
*reinterpret_cast<const int *>(record + Offsets::OFF_SPELL_RECORD_BASE_LEVEL)));
Game::Lua::PushNumber(
L, static_cast<double>(
*reinterpret_cast<const int *>(record + OFF_SPELL_MAX_LEVEL)));
return 3;
}
// `GetSpellRequiredTargetLevel(spellID)` → the minimum level a target must
// be for this spell (rank) to apply (0 = no restriction). nil for an
// unknown spellID. Ranked beneficial auras only; see RequiredTargetLevel.
int __fastcall Script_GetSpellRequiredTargetLevel(void *L) {
const uint8_t *record = Spell::Lookup::RecordForID(ArgSpellID(L));
if (record == nullptr)
return 0;
Game::Lua::PushNumber(L, static_cast<double>(RequiredTargetLevel(record)));
return 1;
}
// `C_SpellBook.GetSpellLevelLearned(spellID)` — returns the level
// at which a spell becomes available (the `BaseLevel` field in
// Spell.dbc). Direct read off the record — no class/race filtering,
@@ -315,6 +448,12 @@ void RegisterLuaFunctions() {
&Script_GetSpellLevelLearned);
Game::Lua::RegisterTableFunction("C_SpellBook", "GetCurrentLevelSpells",
&Script_GetCurrentLevelSpells);
Game::Lua::RegisterGlobalFunction("GetSpellRequiredTargetLevel",
&Script_GetSpellRequiredTargetLevel);
Game::Lua::RegisterTableFunction("C_Spell", "GetSpellLevelInfo",
&Script_GetSpellLevelInfo);
Game::Lua::RegisterTableFunction("C_Spell", "GetSpellRequiredTargetLevel",
&Script_GetSpellRequiredTargetLevel);
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
+16
View File
@@ -29,6 +29,11 @@ namespace Table::Wipe {
// guarantee holds in 1.12's Lua 5.0 (`luaH_next` at `0x006FA2A0`),
// so the pattern ports verbatim.
//
// One divergence from the 3.3.5 source: 1.12's Lua 5.0 keeps a table's
// `table.insert`/`getn` length out of band from its keys (see the
// `luaL_setn` note below), so after the key-clear loop we must also
// reset that counter — the 5.1 twipe had no such legacy to clear.
//
// Stack discipline matches 3.3.5 to the call:
// 1. `lua_pushnil(L)` — initial "no key" sentinel for next
// 2. `lua_next(L, 1)` — pops key, pushes (newKey, value)
@@ -51,6 +56,17 @@ static int __fastcall Script_table_wipe(void *L) {
Game::Lua::RawSet(L, 1); // t[key] = nil
}
// Reset the Lua 5.0 length counter. In 5.0 a table's `table.insert`/
// `getn` length is stored OUT of band from its keys (in a `t.n`
// field or a weak `sizes[t]` registry entry), so the rawset(nil)
// loop above — which only clears keys — leaves it at its old value.
// Without this, `table.insert` after a wipe appends at
// `getn(t)+1`, past the now-nil slots, and `t[1]` stays nil. 5.1's
// wipe doesn't need this (no getn legacy), which is why the ported
// logic looked complete. `luaL_setn(L, 1, 0)` puts it back to 0 so
// inserts resume at `t[1]`.
Game::Lua::SetN(L, 1, 0);
// The table is still at stack[1] and is the result we return.
Game::Lua::SetTop(L, 1);
return 1;
+139 -21
View File
@@ -126,8 +126,9 @@ int SlotsForInvType(int invType, int *out) {
// English fallback labels, used when the client doesn't define the
// modern `_G[key]` global-string (vanilla 1.12 largely doesn't). Phrasing
// mirrors pfUI's eqcompare so the deltas read consistently on this
// client. Percent stats keep a "%" suffix since the values are native
// vanilla percentages.
// client. Percent stats carry NO "%" here — `IsPercentStat` appends the
// "%" to the value ("-5% Block"), so the unit is correct regardless of
// which label (Blizzard `_SHORT` or this fallback) is used.
struct KeyLabel {
const char *key;
const char *label;
@@ -149,25 +150,46 @@ constexpr KeyLabel kFallbackLabels[] = {
{"RESISTANCE6_NAME", "Arcane Resistance"},
{"ITEM_MOD_ATTACK_POWER_SHORT", "Attack Power"},
{"ITEM_MOD_RANGED_ATTACK_POWER_SHORT", "Ranged Attack Power"},
{"ITEM_MOD_CRIT_MELEE_RATING", "Melee Crit %"},
{"ITEM_MOD_CRIT_RANGED_RATING", "Ranged Crit %"},
{"ITEM_MOD_HIT_MELEE_RATING", "Melee Hit %"},
{"ITEM_MOD_HIT_RANGED_RATING", "Ranged Hit %"},
{"ITEM_MOD_HIT_SPELL_RATING", "Spell Hit %"},
{"ITEM_MOD_CRIT_SPELL_RATING", "Spell Crit %"},
{"ITEM_MOD_CRIT_MELEE_RATING", "Melee Crit"},
{"ITEM_MOD_CRIT_RANGED_RATING", "Ranged Crit"},
{"ITEM_MOD_HIT_MELEE_RATING", "Melee Hit"},
{"ITEM_MOD_HIT_RANGED_RATING", "Ranged Hit"},
{"ITEM_MOD_HIT_SPELL_RATING", "Spell Hit"},
{"ITEM_MOD_CRIT_SPELL_RATING", "Spell Crit"},
{"ITEM_MOD_SPELL_DAMAGE_DONE_SHORT", "Spell Damage"},
{"ITEM_MOD_SPELL_HEALING_DONE_SHORT", "Spell Healing"},
{"ITEM_MOD_MANA_REGENERATION", "Mana Regen"},
{"ITEM_MOD_DEFENSE_SKILL_RATING", "Defense"},
{"ITEM_MOD_DODGE_RATING", "Dodge %"},
{"ITEM_MOD_PARRY_RATING", "Parry %"},
{"ITEM_MOD_BLOCK_RATING", "Block %"},
{"ITEM_MOD_DODGE_RATING", "Dodge"},
{"ITEM_MOD_PARRY_RATING", "Parry"},
{"ITEM_MOD_BLOCK_RATING", "Block"},
{"ITEM_MOD_BLOCK_VALUE", "Block Value"},
{"ITEM_MOD_DAMAGE_PER_SECOND_SHORT", "Damage Per Second"},
{"ITEM_DELTA_DESCRIPTION",
"If you replace this item, the following stat changes will occur:"},
};
// Percent-valued stat keys: the block/dodge/parry *chances* and the
// crit/hit percentages. Vanilla stores these as native percentages (a
// value of 5 means 5%), so the delta must read "-5% Block", not "-5
// Block". The "%" is attached to the NUMBER rather than the label, so it
// stays correct in every locale and requires no `_SHORT` change.
// NB: ITEM_MOD_DEFENSE_SKILL_RATING (skill points) and
// ITEM_MOD_BLOCK_VALUE (flat block amount) are NOT percentages.
bool IsPercentStat(const char *key) {
static const char *const kPercent[] = {
"ITEM_MOD_CRIT_MELEE_RATING", "ITEM_MOD_CRIT_RANGED_RATING",
"ITEM_MOD_CRIT_SPELL_RATING", "ITEM_MOD_HIT_MELEE_RATING",
"ITEM_MOD_HIT_RANGED_RATING", "ITEM_MOD_HIT_SPELL_RATING",
"ITEM_MOD_DODGE_RATING", "ITEM_MOD_PARRY_RATING",
"ITEM_MOD_BLOCK_RATING",
};
for (const char *k : kPercent)
if (std::strcmp(k, key) == 0)
return true;
return false;
}
const char *FallbackLabel(const char *key) {
for (const auto &e : kFallbackLabels)
if (std::strcmp(e.key, key) == 0)
@@ -175,23 +197,112 @@ const char *FallbackLabel(const char *key) {
return key; // last resort — raw key beats crashing
}
// Resolve a display label for `key` into `out`. Prefers the client's
// `_G[key]` when it's a plain, non-empty string (respects localization /
// server-defined labels), read via a direct globals-table access — no
// pcall. Ignores format strings (a "%"-bearing global like "+%d Strength"
// would render garbage), falling back to the built-in English label.
void LabelFor(void *L, const char *key, char *out, int outSize) {
// True if `s` contains a printf conversion (`%d`, `%s`, `%1$s`, …) as
// opposed to a literal `%` used as a unit. A `%` at end-of-string, before
// a space, or escaped as `%%` is treated as literal. Lets locale labels
// like "Dodge %" through while still rejecting a format-string global
// such as "+%d Strength" (which would render garbage via snprintf "%s").
bool HasFormatSpecifier(const char *s) {
for (const char *p = std::strchr(s, '%'); p != nullptr;
p = std::strchr(p + 1, '%')) {
const char n = p[1];
if (n == '%') { // escaped "%%" — skip both, keep scanning
++p;
continue;
}
if (n != '\0' && n != ' ')
return true; // "%d", "%s", "%1$..." → real conversion
}
return false;
}
bool EndsWithShort(const char *key) {
const size_t n = std::strlen(key);
return n >= 6 && std::strcmp(key + n - 6, "_SHORT") == 0;
}
// Raw `_G[key]` string fetch — no format-specifier filtering. Direct
// globals-table access, no pcall. Returns whether a non-empty string was
// copied into `out`.
bool TryGlobalRaw(void *L, const char *key, char *out, int outSize) {
const int saved = Game::Lua::GetTop(L);
Game::Lua::PushString(L, key);
Game::Lua::GetTable(L, Game::Lua::GLOBALS_INDEX);
const char *g = (Game::Lua::Type(L, -1) == Game::Lua::TYPE_STRING)
? Game::Lua::ToString(L, -1)
: nullptr;
if (g != nullptr && *g != '\0' && std::strchr(g, '%') == nullptr)
const bool ok = (g != nullptr && *g != '\0');
if (ok)
std::snprintf(out, outSize, "%s", g);
else
std::snprintf(out, outSize, "%s", FallbackLabel(key));
Game::Lua::SetTop(L, saved);
return ok;
}
// Copies `_G[key]` into `out` iff it's a plain, non-empty, non-format
// string. Returns whether it took.
bool TryGlobalLabel(void *L, const char *key, char *out, int outSize) {
char tmp[192];
if (!TryGlobalRaw(L, key, tmp, sizeof(tmp)) || HasFormatSpecifier(tmp))
return false;
std::snprintf(out, outSize, "%s", tmp);
return true;
}
// Formats a signed value token (e.g. "-5") as a localized percentage via
// the PERCENTAGE_STRING global (typically "%s%%" → "-5%"). The locale
// format is NEVER passed to snprintf — we substitute `num` into its first
// conversion slot (`%s`/`%d`) by hand and collapse `%%` → `%`, so a
// hostile or unexpected format can't misread the argument list. Falls back
// to a plain "%" suffix when the global is absent.
void FormatPercent(void *L, const char *num, char *out, int outSize) {
char fmt[64];
if (!TryGlobalRaw(L, "PERCENTAGE_STRING", fmt, sizeof(fmt))) {
std::snprintf(out, outSize, "%s%%", num);
return;
}
int o = 0;
bool inserted = false;
for (const char *p = fmt; *p != '\0' && o < outSize - 1; ++p) {
if (p[0] == '%' && p[1] == '%') { // literal percent
out[o++] = '%';
++p;
} else if (p[0] == '%' && p[1] != '\0' && !inserted) { // value slot
for (const char *q = num; *q != '\0' && o < outSize - 1; ++q)
out[o++] = *q;
++p; // consume the conversion letter (s / d)
inserted = true;
} else {
out[o++] = *p;
}
}
out[o] = '\0';
}
// Resolve a display label for a stat `key` into `out`, respecting
// localization. The rating/regen tokens GetItemStats keys on
// (`ITEM_MOD_CRIT_MELEE_RATING`, …) carry a "%s" *description* as their
// own global; their `_SHORT` sibling is the compact label a delta wants.
// So for a key that isn't already `_SHORT`, prefer `_G[key.."_SHORT"]`:
// 1. `_G[key.."_SHORT"]` — the compact label (skipped if `key` already
// ends in `_SHORT`, e.g. the primary stats, or has no `_SHORT`
// variant like `RESISTANCE*_NAME` / `CURRENTLY_EQUIPPED`).
// 2. `_G[key]` — the key itself (primary stats resolve here; rating
// tokens only land here if no `_SHORT` global exists).
// 3. Built-in English `FallbackLabel(key)`.
// Preferring `_SHORT` first (rather than only when `_G[key]` fails) means
// a server/addon defining the base rating token as a plain string can't
// shadow the compact `_SHORT` label. StatAccum keys stay the canonical
// GetItemStats tokens; this `_SHORT` mapping is display-only.
void LabelFor(void *L, const char *key, char *out, int outSize) {
if (!EndsWithShort(key)) {
char shortKey[160];
std::snprintf(shortKey, sizeof(shortKey), "%s_SHORT", key);
if (TryGlobalLabel(L, shortKey, out, outSize))
return;
}
if (TryGlobalLabel(L, key, out, outSize))
return;
std::snprintf(out, outSize, "%s", FallbackLabel(key));
}
// Delta colors — the canonical GlobalColor.dbc tags modern WoW uses for
@@ -237,7 +348,14 @@ void RenderDeltas(void *self, void *L, const Accum *acc, double dpsDelta) {
if (v == 0)
continue;
LabelFor(L, acc[i].key, label, sizeof(label));
std::snprintf(line, sizeof(line), "%s%ld %s", v > 0 ? "+" : "", v, label);
char num[48];
std::snprintf(num, sizeof(num), "%s%ld", v > 0 ? "+" : "", v);
if (IsPercentStat(acc[i].key)) {
char pct[64];
FormatPercent(L, num, pct, sizeof(pct));
std::snprintf(num, sizeof(num), "%s", pct);
}
std::snprintf(line, sizeof(line), "%s %s", num, label);
AddColoredLine(self, line, v > 0 ? kGain : kLoss);
}
}
+449
View File
@@ -0,0 +1,449 @@
// 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/>.
// Trade-skill *list* links (`|Htrade:...|h`) — the shareable "here's my
// profession and which recipes I know" link. Vanilla 1.12 has NO such link
// type (the engine's SetHyperlink parser only knows `item:` and `enchant:`);
// 2.x introduced it. We backport the whole thing: the link is built entirely
// in C here; only the click-to-display viewer lives in the embedded addon
// (Util/TradeSkillLink.lua), since that's UI.
//
// Wire format (a ClassicAPI-private shape — nothing native emits/reads it, so
// we're free to define it; modeled on the 2.x/3.3.5 `trade:` link, with the
// linker's name in the slot 2.x used for the GUID):
//
// |cffffd000|Htrade:<skillLineID>:<curRank>:<maxRank>:<linkerName>:<base64 bits>|h[Name]|h|r
//
// - skillLineID — SkillLine.dbc row (e.g. 164 = Blacksmithing)
// - curRank/maxRank — the linker's skill level (cosmetic; viewer title)
// - linkerName — the linking character's name
// - base64 bits — a bitfield over the skill line's *canonical recipe list*:
// one bit per possible recipe, set = the linker knows it.
//
// The canonical recipe list is every SkillLineAbility.dbc row for the skill
// line whose spell actually produces a craft (see IsCraftRecipe — an item,
// enchant, or Turtle LEARN_SPELL recipe; this excludes profession abilities
// like Disenchant), in ascending record-ID order. That ordering is
// deterministic and identical on every client with the same DBC, so builder
// and reader agree on the bit-index space without any handshake. "Which
// recipes does the linker know" comes from the player-spell bitmap
// (VAR_PLAYER_SPELL_BITMAP — the same store IsPlayerSpell reads, which covers
// profession recipes), NOT from the trade-skill window's server list.
//
// The TradeSkill and Craft windows are separate frames with separate storage
// and can both be open at once, so each gets its own builder (matching
// WotLK) — each reads only its own window's open-state global (both zeroed by
// the engine on close), so "both open" is unambiguous and neither guesses:
//
// - `C_TradeSkillUI.GetTradeSkillListLink()` — the TradeSkill window
// - `C_TradeSkillUI.GetCraftListLink()` — the Craft window (Enchanting)
// - `C_TradeSkillUI.GetTradeSkillListRecipes(skillLineID, bits)` — decoder
// for the viewer.
#include "Game.h"
#include "Offsets.h"
#include "dbc/Lookup.h"
#include "spell/Lookup.h"
#include "unit/Identity.h"
#include <cstdint>
#include <cstdio>
#include <cstring>
namespace TradeSkill::Link {
namespace {
// 6 recipes per character, LSB-first (mirrors 3.x's encoder).
constexpr char kBase64[] =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
constexpr int kRecipesPerChar = 6;
constexpr int kMaxRecipes = 1024; // largest vanilla skill line is ~340
constexpr int kBitsBufSize = kMaxRecipes / kRecipesPerChar + 2;
int Base64Value(char c) {
for (int i = 0; i < 64; ++i)
if (kBase64[i] == c)
return i;
return -1;
}
// A spell is a craftable recipe (vs. a profession ability like Disenchant, or
// a rank-up / skill-grant spell) iff one of its effects produces a craft: an
// item (CREATE_ITEM), an enchant (ENCHANT_ITEM / _TEMPORARY), or Turtle's
// custom recipes (LEARN_SPELL). Filtering on the skill-up threshold alone
// wrongly kept Disenchant (which carries thresholds but isn't a recipe).
bool IsCraftRecipe(int spellID) {
const uint8_t *rec = Spell::Lookup::RecordForID(spellID);
if (rec == nullptr)
return false;
const int32_t *eff = reinterpret_cast<const int32_t *>(
rec + Offsets::OFF_SPELL_RECORD_EFFECT);
for (int i = 0; i < Offsets::SPELL_RECORD_EFFECT_COUNT; ++i) {
const int e = eff[i];
if (e == Offsets::SPELL_EFFECT_CREATE_ITEM ||
e == Offsets::SPELL_EFFECT_LEARN_SPELL ||
e == Offsets::SPELL_EFFECT_ENCHANT_ITEM ||
e == Offsets::SPELL_EFFECT_ENCHANT_ITEM_TEMPORARY)
return true;
}
return false;
}
// Fills `outSpell` with the recipe spellIDs for `skillLineID`, in canonical
// (ascending record-ID) order, and — when non-null — `outGrey`/`outGreen` with
// the skill levels the recipe turns grey / green at (its difficulty tiers; the
// required-skill field 7 is uselessly 1 for nearly every trainer-taught
// recipe). Returns the count (capped at `maxOut`). Same walk builder and reader
// use, so the bit-index space is identical.
int BuildRecipeList(uint32_t skillLineID, int *outSpell, int *outGrey,
int *outGreen, int maxOut) {
auto *const *records = *reinterpret_cast<const uint8_t *const *const *>(
static_cast<uintptr_t>(Offsets::VAR_SKILL_LINE_ABILITY_RECORDS));
const int count = *reinterpret_cast<const int *>(
static_cast<uintptr_t>(Offsets::VAR_SKILL_LINE_ABILITY_COUNT));
if (records == nullptr || count <= 0)
return 0;
int n = 0;
for (int i = 1; i <= count && n < maxOut; ++i) {
const uint8_t *rec = records[i];
if (rec == nullptr)
continue;
if (static_cast<uint32_t>(*reinterpret_cast<const int *>(
rec + Offsets::OFF_SLA_SKILL_ID)) != skillLineID)
continue;
const int spellID =
*reinterpret_cast<const int *>(rec + Offsets::OFF_SLA_SPELL_ID);
if (!IsCraftRecipe(spellID))
continue;
if (outGrey != nullptr)
outGrey[n] = *reinterpret_cast<const int *>(
rec + Offsets::OFF_SLA_TRIVIAL_SKILL_HIGH);
if (outGreen != nullptr)
outGreen[n] = *reinterpret_cast<const int *>(
rec + Offsets::OFF_SLA_TRIVIAL_SKILL_LOW);
outSpell[n++] = spellID;
}
return n;
}
// Same bit the engine (and our IsPlayerSpell) reads — covers profession
// recipes, not just trained abilities.
bool IsKnownSpell(int spellID) {
if (spellID <= 0)
return false;
auto *bitmap = *reinterpret_cast<const uint32_t *const *>(
static_cast<uintptr_t>(Offsets::VAR_PLAYER_SPELL_BITMAP));
if (bitmap == nullptr)
return false;
return (bitmap[static_cast<uint32_t>(spellID) >> 5] &
(1u << (spellID & 31))) != 0;
}
// Skill line (SkillLine.dbc row) of the first SLA row that teaches `spellID`,
// or 0 if none. Used to identify the Craft window's profession, which — unlike
// the TradeSkill window — has no skill-line global (the engine derives it from
// the recipe, see `GetCraftDisplaySkillLine` at `0x004F6CB0`).
uint32_t SkillForSpell(int spellID) {
if (spellID <= 0)
return 0;
auto *const *records = *reinterpret_cast<const uint8_t *const *const *>(
static_cast<uintptr_t>(Offsets::VAR_SKILL_LINE_ABILITY_RECORDS));
const int count = *reinterpret_cast<const int *>(
static_cast<uintptr_t>(Offsets::VAR_SKILL_LINE_ABILITY_COUNT));
if (records == nullptr || count <= 0)
return 0;
for (int i = 1; i <= count; ++i) {
const uint8_t *rec = records[i];
if (rec != nullptr &&
*reinterpret_cast<const int *>(rec + Offsets::OFF_SLA_SPELL_ID) ==
spellID)
return static_cast<uint32_t>(
*reinterpret_cast<const int *>(rec + Offsets::OFF_SLA_SKILL_ID));
}
return 0;
}
// Skill line of the open Craft window (Enchanting, pet training) — derived
// from its first real recipe entry, mirroring the engine. 0 if none open.
uint32_t CraftSkillLine() {
const int count = *reinterpret_cast<const int *>(
static_cast<uintptr_t>(Offsets::VAR_CRAFT_COUNT));
if (count <= 0)
return 0;
const int cap = (count < kMaxRecipes) ? count : kMaxRecipes;
for (int slot = 0; slot < cap; ++slot) {
const int spellID = Spell::Lookup::RecipeSlotSpellID(
Offsets::VAR_CRAFT_ENTRIES, Offsets::VAR_CRAFT_COUNT, slot);
if (spellID > 0) {
const uint32_t skill = SkillForSpell(spellID);
if (skill != 0)
return skill;
}
}
return 0;
}
// Encodes the local player's known-recipe bitfield for `skillLineID` into
// `out` (must hold kBitsBufSize). Returns the recipe count, or -1 if the skill
// line has no recipes.
int EncodeKnownBits(uint32_t skillLineID, char *out) {
int spells[kMaxRecipes];
const int n = BuildRecipeList(skillLineID, spells, nullptr, nullptr,
kMaxRecipes);
if (n <= 0)
return -1;
int nb = 0;
unsigned acc = 0;
int used = 0;
for (int i = 0; i < n; ++i) {
if (IsKnownSpell(spells[i]))
acc |= (1u << used);
if (++used == kRecipesPerChar) {
out[nb++] = kBase64[acc];
acc = 0;
used = 0;
}
}
if (used != 0)
out[nb++] = kBase64[acc];
out[nb] = '\0';
return n;
}
// Current / max skill rank for the local player in `skillLine`, read from the
// CGPlayer `+0xE68` sub-struct exactly as the engine's own accessors do (see
// the FUN_SKILL_LINE_TO_SLOT block in Offsets.h). Leaves 0/0 when the player
// isn't resolvable or doesn't have the skill.
void ReadPlayerSkillRank(int skillLine, int *outCur, int *outMax) {
*outCur = 0;
*outMax = 0;
const uint8_t *player = Unit::Identity::PlayerObject();
if (player == nullptr)
return;
using SlotFn = int(__thiscall *)(const void *, int);
const int slot = reinterpret_cast<SlotFn>(
Offsets::FUN_SKILL_LINE_TO_SLOT)(player, skillLine);
if (slot < 0)
return;
const uint8_t *base = *reinterpret_cast<const uint8_t *const *>(
player + Offsets::OFF_CGPLAYER_INFO);
if (base == nullptr)
return;
const uint8_t *rec =
base + Offsets::OFF_SKILL_INFO_TABLE + slot * Offsets::SKILL_INFO_STRIDE;
uint32_t cur = *reinterpret_cast<const uint16_t *>(rec + Offsets::OFF_SKILL_INFO_CUR);
uint32_t mx = *reinterpret_cast<const uint16_t *>(rec + Offsets::OFF_SKILL_INFO_MAX);
const uint32_t bonus =
*reinterpret_cast<const uint16_t *>(rec + Offsets::OFF_SKILL_INFO_BONUS);
if (cur != 0)
cur += bonus;
if (mx != 0)
mx += bonus;
*outCur = static_cast<int>(cur);
*outMax = static_cast<int>(mx);
}
// The local player's character name via the engine's object name getter.
const char *PlayerCharacterName() {
const uint8_t *player = Unit::Identity::PlayerObject();
if (player == nullptr)
return "";
using GetName_t = const char *(__thiscall *)(const void *, int *);
const char *name = reinterpret_cast<GetName_t>(
Offsets::FUN_OBJECT_GET_NAME)(player, nullptr);
return (name != nullptr) ? name : "";
}
// Builds the full `|Htrade:...|h` link for whichever window `useCraft`
// selects. Pushes the link string (returns 1), or nothing (returns 0 → nil)
// when that window is closed or has no recipes.
int BuildLink(void *L, bool useCraft) {
int skillLine;
if (useCraft) {
// Craft-window open-state flag; zeroed by the engine on close.
if (*reinterpret_cast<const int *>(
static_cast<uintptr_t>(Offsets::VAR_CRAFT_WINDOW_STATE)) == 0)
return 0;
skillLine = static_cast<int>(CraftSkillLine());
} else {
// TradeSkill skill-line global; zeroed by the engine on close.
skillLine = *reinterpret_cast<const int *>(
static_cast<uintptr_t>(Offsets::VAR_CURRENT_TRADESKILL_LINE));
}
if (skillLine <= 0)
return 0;
char bits[kBitsBufSize];
if (EncodeKnownBits(static_cast<uint32_t>(skillLine), bits) < 0)
return 0;
const char *prof = DBC::LocalizedField(
Offsets::VAR_SKILL_LINE_RECORDS, Offsets::VAR_SKILL_LINE_COUNT,
static_cast<uint32_t>(skillLine), Offsets::OFF_SKILL_LINE_NAME);
if (prof == nullptr || *prof == '\0')
return 0;
int cur = 0, mx = 0;
ReadPlayerSkillRank(skillLine, &cur, &mx);
const char *who = PlayerCharacterName();
char link[512];
std::snprintf(link, sizeof link,
"|cffffd000|Htrade:%d:%d:%d:%s:%s|h[%s]|h|r", skillLine, cur,
mx, who, bits, prof);
Game::Lua::PushString(L, link);
return 1;
}
// `C_TradeSkillUI.GetTradeSkillListLink()` -> link for the open TradeSkill
// window, or nil.
int __fastcall Script_GetTradeSkillListLink(void *L) {
return BuildLink(L, /*useCraft=*/false);
}
// `C_TradeSkillUI.GetCraftListLink()` -> link for the open Craft window, or nil.
int __fastcall Script_GetCraftListLink(void *L) {
return BuildLink(L, /*useCraft=*/true);
}
// `C_TradeSkillUI.GetTradeSkillListRecipes(skillLineID, bits)`
// -> { { spellID, isKnown, trivialLevel, greenLevel, createdItem,
// numMade, reagents = { { itemID, count }, ... } }, ... }
// `trivialLevel` / `greenLevel` are the skill levels the recipe turns
// grey / green at — its difficulty tiers, for sorting and colouring.
// `createdItem` is the crafted item's ID (0 for enchants) — the viewer
// derives its subclass filter from it. `numMade` is how many the recipe
// yields (CREATE_ITEM base points + dice; 0 for enchants). `reagents` lists
// each consumed item + required count in DBC slot order (stopping at the
// first empty slot, the engine's own convention), for the detail pane.
//
// Rebuilds the canonical recipe list for `skillLineID` and decodes `bits`
// against it. Recipes past the end of `bits` (shorter/garbled string) decode
// as not-known rather than erroring. The Lua caller (the viewer) turns each
// spellID into a name/icon via GetSpellInfo and each reagent itemID into
// name/texture/on-hand-count via GetItemInfo / GetItemCount.
//
// The real 3.3.5 reagent API (GetTradeSkillReagentInfo etc.) can't serve this:
// it's indexed by position in the player's *own* open trade window, so it
// can't describe a linked recipe from someone else's profession. We read the
// reagents straight from Spell.dbc instead.
int __fastcall Script_GetTradeSkillListRecipes(void *L) {
if (!Game::Lua::IsNumber(L, 1) || !Game::Lua::IsString(L, 2)) {
Game::Lua::Error(
L, "Usage: C_TradeSkillUI.GetTradeSkillListRecipes(skillLineID, bits)");
return 0;
}
const uint32_t skillLine =
static_cast<uint32_t>(Game::Lua::ToNumber(L, 1));
const char *bits = Game::Lua::ToString(L, 2);
const int blen = (bits != nullptr) ? static_cast<int>(std::strlen(bits)) : 0;
int spells[kMaxRecipes];
int greys[kMaxRecipes];
int greens[kMaxRecipes];
const int n = BuildRecipeList(skillLine, spells, greys, greens, kMaxRecipes);
Game::Lua::NewTable(L);
for (int i = 0; i < n; ++i) {
const int ci = i / kRecipesPerChar;
const int bi = i % kRecipesPerChar;
bool known = false;
if (ci < blen) {
const int v = Base64Value(bits[ci]);
if (v >= 0)
known = (v & (1 << bi)) != 0;
}
// The recipe's crafted item (0 for enchants) — the viewer groups the
// subclass filter by this item's subclass — plus its yield count.
const uint8_t *srec = Spell::Lookup::RecordForID(spells[i]);
int createdItem = 0;
int numMade = 0;
if (srec != nullptr) {
createdItem = *reinterpret_cast<const int *>(
srec + Offsets::OFF_SPELL_RECORD_CREATED_ITEM);
// Yield: the CREATE_ITEM effect's magnitude (base points + dice),
// mirroring the engine's item-creation math. 0 for enchants.
const int32_t *eff = reinterpret_cast<const int32_t *>(
srec + Offsets::OFF_SPELL_RECORD_EFFECT);
const int32_t *basePts = reinterpret_cast<const int32_t *>(
srec + Offsets::OFF_SPELL_RECORD_EFFECT_BASE_POINTS);
const int32_t *baseDice = reinterpret_cast<const int32_t *>(
srec + Offsets::OFF_SPELL_RECORD_EFFECT_BASE_DICE);
for (int e = 0; e < Offsets::SPELL_RECORD_EFFECT_COUNT; ++e) {
if (eff[e] == Offsets::SPELL_EFFECT_CREATE_ITEM) {
numMade = basePts[e] + baseDice[e];
break;
}
}
}
Game::Lua::PushNumber(L, static_cast<double>(i + 1));
Game::Lua::NewTable(L);
Game::Lua::SetFieldNumber(L, "spellID", static_cast<double>(spells[i]));
Game::Lua::SetFieldBool(L, "isKnown", known);
Game::Lua::SetFieldNumber(L, "trivialLevel",
static_cast<double>(greys[i]));
Game::Lua::SetFieldNumber(L, "greenLevel",
static_cast<double>(greens[i]));
Game::Lua::SetFieldNumber(L, "createdItem",
static_cast<double>(createdItem));
Game::Lua::SetFieldNumber(L, "numMade", static_cast<double>(numMade));
// reagents = { {itemID, count}, ... } — nested array under "reagents"
// (no SetField helper takes a table value, so push key + build + SetTable).
Game::Lua::PushString(L, "reagents");
Game::Lua::NewTable(L);
if (srec != nullptr) {
const int32_t *rids = reinterpret_cast<const int32_t *>(
srec + Offsets::OFF_SPELL_REAGENT_ID);
const int32_t *rcounts = reinterpret_cast<const int32_t *>(
srec + Offsets::OFF_SPELL_REAGENT_COUNT);
int outIdx = 0;
for (int r = 0; r < Offsets::SPELL_MAX_REAGENTS; ++r) {
if (rids[r] == 0)
break; // first empty slot ends the list
Game::Lua::PushNumber(L, static_cast<double>(++outIdx));
Game::Lua::NewTable(L);
Game::Lua::SetFieldNumber(L, "itemID",
static_cast<double>(rids[r]));
Game::Lua::SetFieldNumber(L, "count",
static_cast<double>(rcounts[r]));
Game::Lua::SetTable(L, -3); // reagents[outIdx] = {itemID,count}
}
}
Game::Lua::SetTable(L, -3); // entry.reagents = { ... }
Game::Lua::SetTable(L, -3);
}
return 1;
}
void RegisterLuaFunctions() {
Game::Lua::RegisterTableFunction("C_TradeSkillUI", "GetTradeSkillListLink",
&Script_GetTradeSkillListLink);
Game::Lua::RegisterTableFunction("C_TradeSkillUI", "GetCraftListLink",
&Script_GetCraftListLink);
Game::Lua::RegisterTableFunction("C_TradeSkillUI",
"GetTradeSkillListRecipes",
&Script_GetTradeSkillListRecipes);
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace
} // namespace TradeSkill::Link
+31
View File
@@ -96,6 +96,37 @@ uint64_t PlayerGuid() {
return g_charSelectGuid; // early-login fallback (0 until char-select)
}
const uint8_t *PlayerObject() {
// Resolve the player's CGObject straight from the object manager by GUID —
// one hash lookup, no token-string parsing, and non-throwing: the resolver
// returns null when the GUID isn't loaded (pre-world) instead of raising,
// so this is safe from any context (world tick, packet send hook, Lua
// callback). The GUID comes from PlayerGuid() — 0 before char-select, which
// short-circuits to null here. NOT VAR_LOCAL_PLAYER_PTR: its +0xC0 is only
// the GUID, not the canonical CGPlayer whose descriptor is readable.
const uint64_t guid = PlayerGuid();
if (guid == 0)
return nullptr;
using ResolveByGuid_t = void *(__fastcall *)(uint32_t typeMask,
const char *debugName,
uint32_t guidLo, uint32_t guidHi,
int line);
auto resolve =
reinterpret_cast<ResolveByGuid_t>(Offsets::FUN_OBJECT_RESOLVE_BY_GUID);
return static_cast<const uint8_t *>(
resolve(Offsets::OBJ_TYPE_PLAYER, "ClassicAPI",
static_cast<uint32_t>(guid), static_cast<uint32_t>(guid >> 32),
/*line*/ 0));
}
const uint8_t *PlayerDescriptor() {
const uint8_t *player = PlayerObject();
if (player == nullptr)
return nullptr;
return *reinterpret_cast<const uint8_t *const *>(
player + Offsets::OFF_UNIT_DESCRIPTOR);
}
uint64_t GuidForObject(const void *unitObject) {
if (unitObject == nullptr)
return 0;
+16
View File
@@ -28,6 +28,22 @@ namespace Unit::Identity {
// re-dereferencing the globals inline.
uint64_t PlayerGuid();
// The local player's in-world object pointer, resolved straight from the
// object manager by GUID (`FUN_OBJECT_RESOLVE_BY_GUID`) — the fastest path we
// have (one hash lookup, no token-string work) and naturally non-throwing:
// null before the player object exists (pre-world) or before char-select,
// never a raise. Safe from any context — Lua callback, world tick, packet
// hook. Deliberately NOT `VAR_LOCAL_PLAYER_PTR`: that global carries the
// player GUID at +0xC0 but is NOT the CGPlayer whose descriptor and
// CGPlayer-local fields are readable (see Offsets.h).
const uint8_t *PlayerObject();
// The local player's `m_objectFields` descriptor (`PlayerObject() +
// OFF_UNIT_DESCRIPTOR`), or null. This is the broadcast UpdateField block
// (health / power / flags / UNIT_FIELD_CHANNEL_SPELL / …). Prefer this over
// re-deriving the player object + descriptor offset in each module.
const uint8_t *PlayerDescriptor();
// The 64-bit GUID of a resolved CGUnit/CGObject pointer, read through the
// pointer at `+0x08` to an instance block whose first 8 bytes are the GUID
// (verified in `Script_GetInventoryItemLink`; see `OFF_UNIT_GUID_PTR`).
+177
View File
@@ -0,0 +1,177 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
// Pet / minion classification of a unit:
// - `UnitIsMinion(unit)` — modern backport: is the unit a
// minion of a player (pet, guardian, totem, charmed creature)?
// - `UnitIsOtherPlayersPet(unit)` — the real API: is the unit a pet
// controlled by a player OTHER than you?
//
// Neither exists in vanilla 1.12 nor in our 3.3.5 reference client
// (`UnitIsMinion` is a 12.0.0/5.5.4 addition; `UnitIsOtherPlayersPet`
// is 5.0.4/1.13.2), so both are derived from vanilla primitives:
// * `Unit::Flags::IsPlayerControlled` — UNIT_FLAG_PLAYER_CONTROLLED,
// set on players and every unit a player controls (pets, guardians,
// totems, charmed mobs).
// * The unit's GUID type — vanilla packs the entity type in the GUID's
// high bits (`Guid::Classify`): players `0x0000`, pets `0xF140`,
// creatures `0xF130`. A pet-type GUID is intrinsically a player's pet.
//
// `UnitIsMinion` = owned by a player (its SummonedBy/CreatedBy/CharmedBy
// GUID resolves to a player), OR player-controlled-and-not-a-player as a
// fallback. Owner-based is primary because the player-controlled flag
// alone misses summoned guardians — units shown as "X's Minion" that carry
// an owner GUID but not the flag (a real hunter/warlock "X's Pet" has
// both). The union keeps flagged pets working regardless of the owner
// fields.
//
// `UnitIsOtherPlayersPet` is **owner-based**, matching 5.4.8's real
// `Script_UnitIsOtherPlayersPet` (`0x0089FD70`): resolve the unit, read
// its owner GUID (SummonedBy / CreatedBy / CharmedBy), and return true iff
// that owner is a *player* other than you. Owner-based (not a pet-GUID
// heuristic) so it covers pets, guardians, totems, and charmed units of
// other players alike, and excludes *all* of your own minions (owner ==
// you), not just the active pet. The owner-field offsets are byte-verified
// / adjacency-derived — see `OFF_UNIT_FIELD_CHARMEDBY`.
#include "Game.h"
#include "Offsets.h"
#include "guid/Guid.h"
#include "unit/Flags.h"
#include "unit/Identity.h"
#include <cstdint>
namespace Unit::Pet {
namespace {
using ResolveUnitToken_t = void *(__fastcall *)(const char *token);
// `int __fastcall(unit)` → 1 for a controllable pet, non-1 otherwise.
// The engine's pet-vs-minion title discriminator. See
// FUN_UNIT_PET_MINION_CLASS.
using PetMinionClass_t = int(__fastcall *)(const void *unit);
const uint8_t *ResolveUnit(void *L) {
if (!Game::Lua::IsString(L, 1))
return nullptr;
const char *token = Game::Lua::ToString(L, 1);
if (token == nullptr)
return nullptr;
auto resolve = reinterpret_cast<ResolveUnitToken_t>(Offsets::FUN_RESOLVE_UNIT_TOKEN);
return static_cast<const uint8_t *>(resolve(token));
}
// The unit's owner/controller GUID: CharmedBy, else CreatedBy (the two
// fields the engine itself uses for ownership). 0 for players, world
// creatures, and anything unowned.
uint64_t OwnerGuid(const uint8_t *unit) {
auto *desc = *reinterpret_cast<const uint8_t *const *>(
unit + Offsets::OFF_UNIT_DESCRIPTOR);
if (desc == nullptr)
return 0;
const int owners[] = {
Offsets::OFF_UNIT_FIELD_CHARMEDBY,
Offsets::OFF_UNIT_FIELD_CREATEDBY,
};
for (int off : owners) {
const uint64_t g = *reinterpret_cast<const uint64_t *>(desc + off);
if (g != 0)
return g;
}
return 0;
}
// `UnitIsMinion(unit)` — true iff the unit is a player's minion (pet,
// guardian, totem, or charmed creature): player-controlled but not itself
// a player. NPCs, world creatures, and players return false. A bad or
// unresolved unit token returns false (no error), matching the tolerant
// modern predicate convention.
int __fastcall Script_UnitIsMinion(void *L) {
const uint8_t *unit = ResolveUnit(L);
if (unit == nullptr) {
Game::Lua::PushBool(L, 0);
return 1;
}
// Primary: owned by a player (pet, guardian, totem, charmed).
bool minion = Guid::IsPlayer(OwnerGuid(unit));
// Fallback: a player-controlled non-player with no owner GUID.
if (!minion && Unit::Flags::IsPlayerControlled(unit))
minion = !Guid::IsPlayer(Unit::Identity::GuidForObject(unit));
Game::Lua::PushBool(L, minion);
return 1;
}
// `UnitIsPet(unit)` — true iff the unit is a **pet** of a player (as
// opposed to a guardian/totem "minion"): owned by a player AND the
// engine's pet/minion classifier `FUN_UNIT_PET_MINION_CLASS` returns 1.
// That classifier — reading the creature *family*, not the GUID or the
// player-controlled flag — is exactly what the engine uses to title a unit
// "X's Pet" vs "X's Minion". On 1.12/Turtle the GUID can't be used (pets
// and guardians share the `0xF14…` prefix) and the PLAYER_CONTROLLED flag
// is unreliable (a "Minion" can carry it), so the family classifier is the
// only correct signal. The owner-is-player gate also rules out wild
// tameable beasts of the same family (which aren't anyone's pet).
// `UnitIsMinion` is the broad umbrella (pet + guardian + totem); this is
// the pet subset. A ClassicAPI extension — not a stock WoW global.
int __fastcall Script_UnitIsPet(void *L) {
const uint8_t *unit = ResolveUnit(L);
if (unit == nullptr) {
Game::Lua::PushBool(L, 0);
return 1;
}
// Owned by a player (rules out wild same-family beasts), AND the
// engine's own pet/minion classifier says PET (1). The classifier —
// not the GUID, not the player-controlled flag — is what the engine
// uses to title "X's Pet" vs "X's Minion".
bool pet = false;
if (Guid::IsPlayer(OwnerGuid(unit))) {
auto classify = reinterpret_cast<PetMinionClass_t>(
Offsets::FUN_UNIT_PET_MINION_CLASS);
pet = (classify(unit) == 1);
}
Game::Lua::PushBool(L, pet);
return 1;
}
// `UnitIsOtherPlayersPet(unit)` — true iff the unit is a minion (pet,
// guardian, totem, charmed) whose owner is a player other than you.
// Owner-based, mirroring 5.4.8: read the unit's owner GUID, require it to
// be a player, and require it to differ from the local player. World
// creatures and players (no owner) return false; your own minions (owner
// == you) return false.
int __fastcall Script_UnitIsOtherPlayersPet(void *L) {
const uint8_t *unit = ResolveUnit(L);
if (unit == nullptr) {
Game::Lua::PushBool(L, 0);
return 1;
}
const uint64_t owner = OwnerGuid(unit);
const bool otherPlayers = Guid::IsPlayer(owner) &&
owner != Unit::Identity::PlayerGuid();
Game::Lua::PushBool(L, otherPlayers);
return 1;
}
} // namespace
static void RegisterLuaFunctions() {
Game::Lua::RegisterGlobalFunction("UnitIsMinion", &Script_UnitIsMinion);
Game::Lua::RegisterGlobalFunction("UnitIsPet", &Script_UnitIsPet);
Game::Lua::RegisterGlobalFunction("UnitIsOtherPlayersPet",
&Script_UnitIsOtherPlayersPet);
}
static const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace Unit::Pet
+62 -14
View File
@@ -8,11 +8,21 @@
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
// `UnitInRange(unit) → (inRange, checkedRange)` — 40-yard healing-
// range check, modern's `UnitInRange` shape. Vanilla 1.12 doesn't
// ship the function but has all the underlying machinery — the
// same `GetPosition` virtual (vtable slot 5) `CheckInteractDistance`
// uses, applied with a fixed 40-yard threshold.
// Player↔unit proximity, modern's shape. Vanilla 1.12 ships neither
// function but has all the underlying machinery — the object's
// `GetPosition` virtual (vtable slot 5) `CheckInteractDistance` uses:
//
// `UnitInRange(unit) → (inRange, checkedRange)` — 40-yard healing-
// range check (fixed threshold on the squared distance).
// `UnitDistanceSquared(unit) → (distanceSquared, checkedPosition)` —
// the raw squared world distance player→unit. Squared because
// nearly every consumer compares against a threshold (`distSq <=
// range*range`) or ranks by nearest, neither of which needs the
// sqrt; that's why retail exposes only the squared form (added
// 5.0.4). Center-to-center — NOT reach-aware (that's UnitXP_SP3's
// niche); equivalent to computing it from SuperWoW `UnitPosition`,
// but self-contained (reads the position in C, no sibling-DLL
// dependency, no clash with SuperWoW's own `UnitPosition`).
#include "Game.h"
#include "unit/Position.h"
@@ -26,6 +36,24 @@ namespace {
constexpr float kRangeYards = 40.0f;
constexpr float kRangeYardsSq = kRangeYards * kRangeYards;
// Squared world distance from the player to `token`'s unit. Returns true
// and writes *outSq on success; false when either position is unavailable
// (unresolved / absent token, or an object with no known position yet —
// e.g. a party member outside the client's sync range). This false is
// exactly the `checkedPosition = false` case retail flags.
bool PlayerToUnitDistSq(const char *token, float *outSq) {
float unitPos[3] = {};
float playerPos[3] = {};
if (!Unit::Position::ReadToken(token, unitPos) ||
!Unit::Position::ReadToken("player", playerPos))
return false;
const float dx = unitPos[0] - playerPos[0];
const float dy = unitPos[1] - playerPos[1];
const float dz = unitPos[2] - playerPos[2];
*outSq = dx * dx + dy * dy + dz * dz;
return true;
}
int __fastcall Script_UnitInRange(void *L) {
if (!Game::Lua::IsString(L, 1)) {
Game::Lua::Error(L, "Usage: UnitInRange(\"unit\")");
@@ -44,27 +72,47 @@ int __fastcall Script_UnitInRange(void *L) {
return 2;
}
float unitPos[3] = {};
float playerPos[3] = {};
if (!Unit::Position::ReadToken(token, unitPos) ||
!Unit::Position::ReadToken("player", playerPos)) {
float distSq = 0.0f;
if (!PlayerToUnitDistSq(token, &distSq)) {
Game::Lua::PushBool(L, false);
Game::Lua::PushBool(L, false);
return 2;
}
const float dx = unitPos[0] - playerPos[0];
const float dy = unitPos[1] - playerPos[1];
const float dz = unitPos[2] - playerPos[2];
const float distSq = dx * dx + dy * dy + dz * dz;
Game::Lua::PushBool(L, distSq <= kRangeYardsSq);
Game::Lua::PushBool(L, true);
return 2;
}
// `UnitDistanceSquared("unit") → (distanceSquared, checkedPosition)`.
// Unlike UnitInRange there's no self-quirk: `UnitDistanceSquared("player")`
// is a legitimate `(0, true)`. On a position miss returns `(0, false)` —
// matching retail's "always a number" shape; consumers must branch on
// `checkedPosition`, since a real `0` (self, or exactly co-located units)
// is indistinguishable from the miss placeholder by value alone.
int __fastcall Script_UnitDistanceSquared(void *L) {
if (!Game::Lua::IsString(L, 1)) {
Game::Lua::Error(L, "Usage: UnitDistanceSquared(\"unit\")");
return 0;
}
const char *token = Game::Lua::ToString(L, 1);
float distSq = 0.0f;
if (!PlayerToUnitDistSq(token, &distSq)) {
Game::Lua::PushNumber(L, 0.0);
Game::Lua::PushBool(L, false);
return 2;
}
Game::Lua::PushNumber(L, static_cast<double>(distSq));
Game::Lua::PushBool(L, true);
return 2;
}
void RegisterLuaFunctions() {
Game::Lua::RegisterGlobalFunction("UnitInRange", &Script_UnitInRange);
Game::Lua::RegisterGlobalFunction("UnitDistanceSquared",
&Script_UnitDistanceSquared);
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
+55 -51
View File
@@ -21,6 +21,7 @@
#include "Offsets.h"
#include "aura/Data.h"
#include "spell/Lookup.h"
#include "unit/Identity.h"
#include <cstdint>
@@ -28,24 +29,10 @@ namespace Unit::State {
namespace {
using ResolveUnitToken_t = void *(__fastcall *)(const char *token);
using CancelAuraSend_t = void(__fastcall *)(int spellID);
const uint8_t *Player() {
auto fn = reinterpret_cast<ResolveUnitToken_t>(Offsets::FUN_RESOLVE_UNIT_TOKEN);
return static_cast<const uint8_t *>(fn("player"));
}
const uint8_t *PlayerDescriptor() {
auto *player = Player();
if (player == nullptr)
return nullptr;
return *reinterpret_cast<const uint8_t *const *>(
player + Offsets::OFF_UNIT_DESCRIPTOR);
}
uint32_t PlayerMovementFlags() {
auto *player = Player();
auto *player = Unit::Identity::PlayerObject();
if (player == nullptr)
return 0;
return *reinterpret_cast<const uint32_t *>(
@@ -59,11 +46,39 @@ uint32_t MountDisplayID(const uint8_t *desc) {
desc + Offsets::OFF_UNIT_FIELD_MOUNTDISPLAYID);
}
// Scans the player's helpful-aura (buff) slots for the first spell whose
// Spell.dbc record lists `auraEffect` (a `SPELL_AURA_*` code) in any of
// its three effect slots, and returns that spellID (0 if none). Used by
// `Dismount` to find the active mount aura to cancel — the engine's
// `CMSG_CANCEL_AURA` needs the spellID, not a slot index.
uint32_t FindPlayerBuffWithEffect(int auraEffect) {
auto *player = Unit::Identity::PlayerObject();
if (player == nullptr)
return 0;
for (int slot = 0; slot < Offsets::UNIT_AURA_BUFF_COUNT; ++slot) {
if (!Aura::Data::IsSlotPopulated(player, slot))
continue;
const uint32_t spellID = Aura::Data::ReadSpellID(player, slot);
const uint8_t *record = Spell::Lookup::RecordForID(static_cast<int>(spellID));
if (record == nullptr)
continue;
auto *auraTypes = reinterpret_cast<const int32_t *>(
record + Offsets::OFF_SPELL_RECORD_EFFECT_APPLY_AURA_NAME);
for (int eff = 0; eff < Offsets::SPELL_RECORD_EFFECT_COUNT; ++eff) {
if (auraTypes[eff] == auraEffect)
return spellID;
}
}
return 0;
}
// `IsMounted()` — true iff the player is currently mounted. Reads
// `UNIT_FIELD_MOUNTDISPLAYID` from the descriptor; non-zero means
// the engine is rendering a mount under the player.
int __fastcall Script_IsMounted(void *L) {
auto *desc = PlayerDescriptor();
auto *desc = Unit::Identity::PlayerDescriptor();
Game::Lua::PushBool(L, MountDisplayID(desc) != 0);
return 1;
}
@@ -80,50 +95,39 @@ int __fastcall Script_IsMounted(void *L) {
// No-op if the player isn't mounted (early-outs on the
// `UNIT_FIELD_MOUNTDISPLAYID` check before walking auras).
int __fastcall Script_Dismount(void *L) {
auto *desc = PlayerDescriptor();
auto *desc = Unit::Identity::PlayerDescriptor();
if (MountDisplayID(desc) == 0)
return 0;
auto *player = Player();
if (player == nullptr)
return 0;
for (int slot = 0; slot < Offsets::UNIT_AURA_BUFF_COUNT; ++slot) {
if (!Aura::Data::IsSlotPopulated(player, slot))
continue;
const uint32_t spellID = Aura::Data::ReadSpellID(player, slot);
const uint8_t *record = Spell::Lookup::RecordForID(static_cast<int>(spellID));
if (record == nullptr)
continue;
auto *auraTypes = reinterpret_cast<const int32_t *>(
record + Offsets::OFF_SPELL_RECORD_EFFECT_APPLY_AURA_NAME);
for (int eff = 0; eff < Offsets::SPELL_RECORD_EFFECT_COUNT; ++eff) {
if (auraTypes[eff] != Offsets::SPELL_AURA_MOUNTED)
continue;
auto fn = reinterpret_cast<CancelAuraSend_t>(
static_cast<uintptr_t>(Offsets::FUN_CANCEL_AURA_SEND));
fn(static_cast<int>(spellID));
return 0;
}
const uint32_t spellID = FindPlayerBuffWithEffect(Offsets::SPELL_AURA_MOUNTED);
if (spellID != 0) {
auto fn = reinterpret_cast<CancelAuraSend_t>(
static_cast<uintptr_t>(Offsets::FUN_CANCEL_AURA_SEND));
fn(static_cast<int>(spellID));
}
return 0;
}
// `IsStealthed()` — true iff the player is currently stealthed
// (Rogue Stealth / Druid Prowl). Checks `PLAYER_FIELD_VIS_BYTES`
// bit 0x02. Mount also sets that bit, so we AND-gate with
// `mountDisplayID == 0` to disambiguate. See the comment on
// `OFF_PLAYER_FIELD_VIS_BYTES` in Offsets.h for the full bit map
// and known limitations (untested for shapeshift forms).
// (Rogue Stealth / Druid Prowl / Night Elf Shadowmeld / etc.). Reads
// the sneak flag in `UNIT_FIELD_BYTES_1` byte 3 (mask
// `UNIT_BYTES_1_STEALTH_FLAG`) — the same broadcast bit the client's
// render path uses to draw a stealthed unit semi-transparent.
//
// Derived via `_classicapi_DiffAll`: a Prowl toggle from a Cat-Form
// baseline flips byte 3 `0x00 -> 0x02` and touches nothing else but
// aura-array slot metadata, so this is the one standalone descriptor
// bit that means "stealthed" (the earlier `0x17C & 0x02` read was
// actually AURALEVELS and only worked by coincidence). Being a unit
// anim-tier flag rather than a class field, it covers every stealth
// source and rank without a spellID list or an aura scan.
int __fastcall Script_IsStealthed(void *L) {
auto *desc = PlayerDescriptor();
auto *desc = Unit::Identity::PlayerDescriptor();
bool stealthed = false;
if (desc != nullptr) {
const uint32_t visBytes = *reinterpret_cast<const uint32_t *>(
desc + Offsets::OFF_PLAYER_FIELD_VIS_BYTES);
stealthed = (visBytes & Offsets::PLAYER_VIS_BIT_STEALTH) != 0
&& MountDisplayID(desc) == 0;
const uint32_t bytes1 = *reinterpret_cast<const uint32_t *>(
desc + Offsets::OFF_UNIT_FIELD_BYTES_1);
stealthed = (bytes1 & Offsets::UNIT_BYTES_1_STEALTH_FLAG) != 0;
}
Game::Lua::PushBool(L, stealthed);
return 1;
@@ -177,12 +181,12 @@ int __fastcall Script_IsSwimming(void *L) {
// vanilla's summoning ritual has no UI surface, which is what
// motivates this function.
int __fastcall Script_IsAssistingRitual(void *L) {
auto *player = Player();
auto *player = Unit::Identity::PlayerObject();
if (player == nullptr) {
Game::Lua::PushBool(L, 0);
return 1;
}
auto *desc = PlayerDescriptor();
auto *desc = Unit::Identity::PlayerDescriptor();
if (desc == nullptr) {
Game::Lua::PushBoolean(L, 0);
return 1;
+232
View File
@@ -0,0 +1,232 @@
// 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_XMLUtil.GetTemplates()` — enumerate every registered virtual XML frame
// template as `{ name = <string>, type = <frameType> }`.
//
// The engine keeps virtual templates (the `<Frame virtual="true">` etc. that
// `inherits=` targets) in a Storm hash table. The XML parser (FUN_006ede10)
// registers each one via FUN_006ee500, and the inherits resolver reads the
// same table via FUN_006ee6f0 — so it's the authoritative template store, and
// walking it yields exactly what modern `C_XMLUtil.GetTemplates` returns.
// Fonts are a separate registry and are (correctly) not included; non-virtual
// named frames take a different instantiate path and never enter this table.
//
// Table shape (see Offsets.h): a POINTER to a bucket array at
// VAR_XML_TEMPLATE_TABLE, a bucket-count mask at VAR_XML_TEMPLATE_MASK
// (0xFFFFFFFF until the first template registers). Each bucket is a Storm
// intrusive list; each node carries the template name (+0x14) and the parsed
// definition node (+0x18), whose element tag string (+0x8) is the frame type.
// Contents reflect the currently-loaded XML and are rebuilt on `/reload`.
#include "Game.h"
#include "Offsets.h"
#include <cstdint>
#include <cstdlib>
namespace Xml::Templates {
namespace {
// ---- Node-DOM helpers (shared by GetTemplateInfo / DoesTemplateExist) -----
using TemplateLookup_t = const uint8_t *(__fastcall *)(const char *name);
using GetAttr_t = const char *(__thiscall *)(const void *node, const char *name);
using SStrCmpI_t = int(__stdcall *)(const char *a, const char *b, int n);
// The engine's by-name template lookup (FUN_006ee6f0) — the same path
// `inherits=` resolves through. Returns the parsed definition node, or null
// if no template of that name is registered (case-insensitive, hashed).
const uint8_t *LookupTemplate(const char *name) {
return reinterpret_cast<TemplateLookup_t>(
Offsets::FUN_XML_TEMPLATE_LOOKUP)(name);
}
// Value of `node`'s attribute `name`, or null if absent.
const char *Attr(const uint8_t *node, const char *name) {
return reinterpret_cast<GetAttr_t>(Offsets::FUN_XML_NODE_GET_ATTRIBUTE)(
node, name);
}
const uint8_t *FirstChild(const uint8_t *node) {
return *reinterpret_cast<const uint8_t *const *>(
node + Offsets::OFF_XML_NODE_CHILD);
}
const uint8_t *NextSibling(const uint8_t *node) {
return *reinterpret_cast<const uint8_t *const *>(
node + Offsets::OFF_XML_NODE_SIBLING);
}
// Case-insensitive tag match, using the engine's own comparator (the same one
// FUN_006f1eb0 uses to recognize "AbsDimension" etc.).
bool TagEquals(const uint8_t *node, const char *tag) {
const char *t = *reinterpret_cast<const char *const *>(
node + Offsets::OFF_XML_NODE_TAG);
if (t == nullptr)
return false;
return reinterpret_cast<SStrCmpI_t>(Offsets::FUN_SSTR_CMP_I)(
t, tag, 0x7FFFFFFF) == 0;
}
const uint8_t *FindChild(const uint8_t *node, const char *tag) {
for (const uint8_t *c = FirstChild(node); c != nullptr; c = NextSibling(c))
if (TagEquals(c, tag))
return c;
return nullptr;
}
// The statically-declared width/height from a `<Size>` child (0 if none).
// Handles the inline `<Size x= y=>` form and the `<Size><AbsDimension x= y=>`
// (or RelDimension) child form. Returns the RAW values from the XML — retail's
// GetTemplateInfo reports the declared value, so unlike the engine's own Size
// parser (FUN_006f1eb0) we deliberately skip the UI-scale conversion.
void ReadSize(const uint8_t *node, double *outW, double *outH) {
*outW = 0.0;
*outH = 0.0;
const uint8_t *size = FindChild(node, "Size");
if (size == nullptr)
return;
const char *sx = Attr(size, "x");
const char *sy = Attr(size, "y");
const uint8_t *dim = FirstChild(size);
if (dim != nullptr &&
(TagEquals(dim, "AbsDimension") || TagEquals(dim, "RelDimension"))) {
const char *dx = Attr(dim, "x");
const char *dy = Attr(dim, "y");
if (dx != nullptr && *dx != '\0')
sx = dx;
if (dy != nullptr && *dy != '\0')
sy = dy;
}
if (sx != nullptr && *sx != '\0')
*outW = std::strtod(sx, nullptr);
if (sy != nullptr && *sy != '\0')
*outH = std::strtod(sy, nullptr);
}
// `C_XMLUtil.GetTemplates()` -> XMLTemplateListInfo[] of { name, type }.
int __fastcall Script_GetTemplates(void *L) {
Game::Lua::NewTable(L); // the result array
const uint32_t mask =
*reinterpret_cast<const uint32_t *>(Offsets::VAR_XML_TEMPLATE_MASK);
if (mask == 0xFFFFFFFFu)
return 1; // no template has ever registered — empty array
const uint8_t *base = *reinterpret_cast<const uint8_t *const *>(
Offsets::VAR_XML_TEMPLATE_TABLE);
if (base == nullptr)
return 1;
int outIdx = 0;
for (uint32_t b = 0; b <= mask; ++b) {
const uint8_t *bucket = base + b * Offsets::XML_TEMPLATE_BUCKET_STRIDE;
const int linkOff = *reinterpret_cast<const int *>(
bucket + Offsets::OFF_XML_TEMPLATE_BUCKET_LINKOFF);
const uint8_t *node = *reinterpret_cast<const uint8_t *const *>(
bucket + Offsets::OFF_XML_TEMPLATE_BUCKET_HEAD);
// Walk the intrusive chain; the tail sentinel has its low bit set
// (mirrors the engine's own traversal in FUN_006ee6f0).
while (node != nullptr && (reinterpret_cast<uintptr_t>(node) & 1) == 0) {
const char *name = *reinterpret_cast<const char *const *>(
node + Offsets::OFF_XML_TEMPLATE_NODE_NAME);
const uint8_t *def = *reinterpret_cast<const uint8_t *const *>(
node + Offsets::OFF_XML_TEMPLATE_NODE_DEF);
const char *type =
(def != nullptr)
? *reinterpret_cast<const char *const *>(
def + Offsets::OFF_XML_NODE_TAG)
: nullptr;
++outIdx;
Game::Lua::PushNumber(L, static_cast<double>(outIdx));
Game::Lua::NewTable(L);
Game::Lua::SetFieldString(L, "name", name != nullptr ? name : "");
Game::Lua::SetFieldString(L, "type", type != nullptr ? type : "");
Game::Lua::SetTable(L, -3);
node = *reinterpret_cast<const uint8_t *const *>(node + linkOff + 4);
}
}
return 1;
}
// `C_XMLUtil.GetTemplateInfo(name)` -> XMLTemplateInfo table, or nil if the
// template doesn't exist.
int __fastcall Script_GetTemplateInfo(void *L) {
if (!Game::Lua::IsString(L, 1)) {
Game::Lua::Error(L, "Usage: C_XMLUtil.GetTemplateInfo(\"name\")");
return 0;
}
const char *name = Game::Lua::ToString(L, 1);
const uint8_t *node = (name != nullptr) ? LookupTemplate(name) : nullptr;
if (node == nullptr)
return 0; // nil — no such template
Game::Lua::NewTable(L); // XMLTemplateInfo
const char *type = *reinterpret_cast<const char *const *>(
node + Offsets::OFF_XML_NODE_TAG);
Game::Lua::SetFieldString(L, "type", type != nullptr ? type : "");
double w = 0.0, h = 0.0;
ReadSize(node, &w, &h);
Game::Lua::SetFieldNumber(L, "width", w);
Game::Lua::SetFieldNumber(L, "height", h);
// keyValues: vanilla's XML schema has no <KeyValues> element, so this is
// always an empty table (parity with retail's "empty if none defined").
Game::Lua::PushString(L, "keyValues");
Game::Lua::NewTable(L);
Game::Lua::SetTable(L, -3);
// inherits: the comma-delimited `inherits=` attribute, or nil (field left
// unset) when the template inherits nothing.
const char *inherits = Attr(node, "inherits");
if (inherits != nullptr && *inherits != '\0')
Game::Lua::SetFieldString(L, "inherits", inherits);
// sourceLocation is intentionally omitted (nil): vanilla records no
// file/line for XML nodes — a 10.2.0 addition gated behind the
// enableSourceLocationLookup CVar even on retail.
return 1;
}
// `C_XMLUtil.DoesTemplateExist(name)` -> bool.
int __fastcall Script_DoesTemplateExist(void *L) {
if (!Game::Lua::IsString(L, 1)) {
Game::Lua::Error(L, "Usage: C_XMLUtil.DoesTemplateExist(\"name\")");
return 0;
}
const char *name = Game::Lua::ToString(L, 1);
const uint8_t *node = (name != nullptr) ? LookupTemplate(name) : nullptr;
Game::Lua::PushBool(L, node != nullptr);
return 1;
}
} // namespace
static void RegisterLuaFunctions() {
Game::Lua::RegisterTableFunction("C_XMLUtil", "GetTemplates",
&Script_GetTemplates);
Game::Lua::RegisterTableFunction("C_XMLUtil", "GetTemplateInfo",
&Script_GetTemplateInfo);
Game::Lua::RegisterTableFunction("C_XMLUtil", "DoesTemplateExist",
&Script_DoesTemplateExist);
}
static const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace Xml::Templates