Compare commits

...
49 Commits
Author SHA1 Message Date
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
Brues 07a4cbde43 Evict remote casts via CGUnit_C::ClearCastingSpell choke point
Turtle's core doesn't broadcast SMSG_SPELL_FAILURE or
SMSG_SPELL_FAILED_OTHER on interrupt (verified by logging both
handlers through live tests), so the SPELL_FAILED_OTHER co-hook never
fired and interrupted enemy casts (Kick / Earth Shock / Counterspell)
lingered as ghost bars. What actually stops the cast visual on Turtle
is SuperWoW calling CGUnit_C::ClearCastingSpell (0x0060D040) directly.

Co-hook that choke point instead: every unit-stopped-casting path
funnels through it (failure packets, SPELL_GO completion, movement,
death, animation events, local cancel), gated on the unit's live
current-cast field at +0xC8C, so eviction is independent of which
signal the server sends. Both failure-packet handlers stay co-hooked
as backstops for servers that do broadcast them. Verified in-game:
all three interrupts evict; non-interrupting damage doesn't
false-evict.

Also confirmed via a whole-protocol sniff on the NetClient message
dispatch (FUN_NET_MESSAGE_DISPATCH = 0x00537AA0, documented for
future use): SMSG_SPELL_DELAYED goes only to the affected caster --
observers receive no packet of any kind for pushback, so remote bars
can't stretch. Documented as an inherent 1.12 protocol limitation.
2026-07-05 01:57:18 -05:00
Brues 017dc5b6f1 Add UPDATE_INVENTORY_DURABILITY (TODO #89)
Fires (no payload) when any equipped item's durability changes -
combat damage, death, repairs - via a co-hook on the engine's
inventory-alerts recompute (FUN_004C7EE0, the routine behind vanilla's
UPDATE_INVENTORY_ALERTS). The engine calls it whenever an owned item's
fields change (the item post-update handler FUN_005D9A90 routes every
item values-update through it) plus equip/enter-world paths, so it is
the engine's own durability-may-have-changed signal. Post-original we
diff a per-slot {itemGuid, durability} snapshot and fire once per
recompute with a real change - a death dinging all armor produces one
event, not nineteen.

The snapshot seeds silently on the first recompute of the process and
is deliberately NOT re-seeded per enter-world: death/release/graveyard
teleports re-run the setup, and a per-setup reset swallowed the
death's own durability loss (verified in-game; repairs fired, deaths
didn't until this was fixed).

Dead end, kept documented at OFF_DESC_ITEM_DURABILITY: bank-1 (ITEM
field) descriptor observers register without error but are never
invoked - item values-updates demonstrably arrive and player-bank
observers fire in the same session; registration arithmetic fully
verified (bank base FUN_00465690(1)=0x18, bank walk item 0->1->2).
Prime suspect is the registrar's internal wrapper lookup
(FUN_00464890, the observer registry, distinct from the object
manager) silently no-op'ing for item objects.

Also in Player::Equipment: resolve the player via the non-throwing
GUID resolver (FUN_00468460, what the engine's own bag observer uses)
instead of FUN_RESOLVE_UNIT_TOKEN, which raises a Lua error when the
token doesn't resolve - unsafe inside SMSG_UPDATE_OBJECT processing
where the player can be mid-create.
2026-07-05 00:23:01 -05:00
Brues ddcb6ba9f0 Add C_QuestLog.GetNumQuestObjectives(questID)
The quest's objective count — the length GetQuestDetails(questID)
.requirements would have — read straight from the cache block with no
Lua tables or strings materialized. nil when the quest isn't cached
(same contract as GetQuestDetails).

Exists because materializing full detail tables in a login-path loop
is expensive at warm cache: GetQuestDetails copies description /
objectives / completion text into Lua strings (~5 KB per quest).
TurtleGuide's guide-load OIDX validation did exactly that for every
tagged step — fine on the first (cold-cache) session, but once the
quest cache was warm from questcache.wdb every subsequent login
exhausted vanilla's fixed Lua memory pool and crashed the client in
lmemPool.cpp:53. With this accessor the same validation costs a few
dozen byte reads regardless of cache temperature.
2026-07-04 20:02:25 -05:00
Brues 17cf323a52 Add PLAYER_EQUIPMENT_CHANGED(equipmentSlot, hasCurrent) (TODO #88)
The earlier investigation tried to hook a firer of
UNIT_INVENTORY_CHANGED; none of the three known 0xBA fire sites
trigger on equip because equipment descriptor writes are silent — the
engine registers descriptor-field observers (the __AUCMirrorHandler__
system: registrar 0x00467E70, dispatcher 0x00465570 with per-node
mirror + memcmp change detection) for the bag/backpack/bank/keyring
GUID ranges but never for equipment 0x4A8..0x538.

So register our own: co-hook the engine's inventory observer setup
(FUN_004F8CC0, once per enter-world) and, post-original, register one
observer per equipment GUID field through the engine's registrar —
same bank/size/GUID shape as the engine's bag observers, engine-
managed lifetime, no polling, no hot-function hook. The dispatcher
invokes us only on a real change.

Callback ABI verified from the dispatcher call site at 0x004655DC and
the engine callback FUN_004F8DB0's RET 0x10:
  __fastcall(fieldOffset /*ecx*/, size /*edx*/, guidLo, guidHi,
             oldValue*, userArg) -> ret 0x10

equipmentSlot is the 1-based Lua inventory slot (1 head .. 19 tabard);
hasCurrent (1/nil) is read from the player invMgr GUID array — the
same post-change truth the engine's bag observer reads.
2026-07-04 17:01:01 -05:00
Brues a0b7d03c28 C_GossipInfo: session gating + quest-greeting support
Vanilla has two disjoint questgiver paths. Gossip NPCs send
SMSG_GOSSIP_MESSAGE into the gossip arrays this namespace reads; but
quest-only NPCs send SMSG_QUESTGIVER_QUEST_LIST (the QUEST_GREETING
event / QuestFrameGreetingPanel), whose data lands in entirely
separate storage C_GossipInfo never read — every getter returned
empty for those NPCs. GetText was also ungated: the gossip text
buffer persists after close, so it served the previous gossip NPC's
greeting.

- Gate all gossip reads on the live gossip NPC GUID (0x00BC3F58,
  cleared by the close worker at 0x004E1FA0 before GOSSIP_CLOSED
  fires). Stale text/options/quests are no longer visible, and
  selectors can't send a CMSG from stale slots.
- Serve the quest-greeting session when one is live instead: the
  questgiver GUID (0x00BE0810) must match the greeting-fill GUID
  (0x00C4D740) so leftovers can't leak when a quest session starts
  from a gossip-menu click. GetText returns the greeting text;
  Get{Available,Active}Quests / GetNum* read the greeting arrays
  (32 x 0x4C entries {questID, level, title}) with isComplete
  recovered from the packet handler's scratch status array
  (status 4 = turn-in-ready); Select{Available,Active}Quest route to
  the engine's greeting selectors (0x005012A0 / 0x00501320).
- Neither session live: empty tables, 0 counts, empty text.

GetOptions/GetNumOptions stay gossip-only — greetings have no
options. Retail keeps the two APIs disjoint; merging is deliberate so
modern-addon ports work on greeting-path NPCs (common on 1.12
servers).
2026-07-04 14:27:06 -05:00
Brues 15cae8a474 Add QUEST_REMOVED event; refactor quest log hook
Introduce QUEST_REMOVED (fires with questID) and document it in API.md and README. Rename Quest::Accepted → Quest::LogEvents and update the quest-log rebuild hook to compute pre/post deltas for both QUEST_ACCEPTED (questLogIndex, questID) and QUEST_REMOVED (questID). Add suppression rules to avoid firing on login/resync bulk changes. Update Offsets.h commentary to reflect the new LogEvents module and its responsibilities.
2026-07-04 12:04:27 -05:00
Brues 49865e9b9e Fix cast-then-channel spells showing the channel bar at cast start
Mind Control is a 3 s cast on a spell flagged CHANNELED with a 60 s
duration. HandleSpellStart branched on the channel attribute alone, so
at cast start it stamped g_channel with the 60 s duration and killed
the client hook's correct 3 s cast stamp — the bar showed a premature
60 s countdown (pfUI #9). Same story remotely: an observed MC stored a
60 s channel entry instead of the 3 s cast.

- Treat a channeled spell with castTime > 0 as its CAST phase in
  HandleSpellStart (player and remote paths); instant channels keep
  the immediate stamp.
- Co-hook the MSG_CHANNEL_START handler (0x006E7550, layout from
  nampower's SpellChannelStartHandlerHook: spellId u32, duration u32).
  The server sends it to the caster the moment the channel begins —
  after MC's cast completes — with the server-authoritative duration.
  Stamp g_channel there, before the original fires
  SPELLCAST_CHANNEL_START, so pollers see fresh data. For instant
  channels it re-stamps what the SpellStart path just wrote.

Verified: MC shows 3 s cast then 60 s channel; Mind Flay-style instant
channels unaffected.
2026-07-03 21:36:42 -05:00
Brues 98d4504b6f CBOR encode: keep encoding through OutOfMemory so the resize tally is complete
tinycbor's grow-and-retry contract: on buffer overflow the encoder
switches to counting mode and encoding must CONTINUE for
cbor_encoder_get_extra_bytes_needed() to be accurate — a nested
container's tally only reaches the parent via close_container. The old
code returned false on the first CborErrorOutOfMemory, so a mid-
container overflow could report extra == 0 and be misread as a genuine
"value not representable" failure (returning nil), or undercount and
churn through multiple resize passes.

Add EncOk() (NoError or OutOfMemory = keep going) around every encode
call; the retry loop now distinguishes real failures (return nil) from
overflow (single complete-tally resize, retry).
2026-07-03 20:48:39 -05:00
Brues 597530d558 Evict interrupted casts via SMSG_SPELL_FAILED_OTHER (TODO #98)
Remote casts cached from SMSG_SPELL_START were expired only by their
computed end time — an interrupted remote cast lingered as a ghost bar
for every UnitCastingInfo consumer (pfUI #11). The interrupt signal
does exist at the packet layer: the server broadcasts
SMSG_SPELL_FAILED_OTHER to observers whenever a started cast aborts
(interrupt, death, movement, fizzle).

Co-hook its handler (0x006E8E40, same __stdcall shape as
FUN_SPELL_DELAYED; address + layout mirrored from nampower's
SpellFailedOtherHandlerHook — body is plain casterGuid(u64),
spellId(u32)) and:

- evict the matching g_remoteCasts entry (guid + spellID), so remote
  cast/channel bars drop the same tick the cast dies;
- clear the player's own g_cast on a guid match — covers the
  server-stamped chained recast that the WorldTick VAR==0 poll can't
  clear (self-receipt is server-dependent; the endMs self-expiry
  backstop remains if it never fires).

Update the stale 'no per-unit interrupt signal in 1.12' notes in
Cast.cpp and docs/API.md.
2026-07-03 20:24:14 -05:00
Brues 2165a8e986 Restore TTS with VanillaTTS coexistence
Re-add the C_VoiceChat / C_TTSSettings SAPI module removed in 6f543ec,
gated so it coexists with the standalone VanillaTTS DLL: when
VanillaTTS.dll is loaded in the process, the module stands down
entirely — no Lua functions, no ttsVoice/ttsSpeed/ttsVolume cvars, no
VOICE_CHAT_TTS_* event reservations, no SAPI init. Without the gate,
each DLL would claim its own event-table slot for the same event names
(listeners bind to one slot while the other DLL fires the other) and
the cvar registrar would replace the first DLL's clamp callbacks.

The check (GetModuleHandleA at RegisterLuaFunctions time) is
load-order-independent — VanillaFixes loads every DLL at process
attach, long before FUN_LOAD_SCRIPT_FUNCTIONS fires. The event
AutoReserves move from file scope into the gated registration as
function-local statics so a stand-down leaves no reservations behind.

Restore the ole32 link, the README VoiceChat/API rows + event table,
and the docs/API.md VoiceChat section (with a coexistence note).
2026-07-03 19:00:17 -05:00
Brues cdb515e0c3 Remove third compare tooltip support
Drop legacy third shopping tooltip handling from GameTooltip_ShowCompareItem. Unpack only two shoppingTooltips, remove unused item3/shoppingTooltip3 variables and commented code, and simplify anchor/owner logic for the two-compare layout. GAP/backdrop spacing calculation is preserved.
2026-07-03 02:27:11 -05:00
Brues d55613e88c Clear the tooltip item GUID on every clear (fix stale GetItem)
GameTooltip:GetItem() returned the previous item after SetUnitAura /
SetEquipmentSet (and any tooltip that shows no item). The engine's per-tooltip
clear FUN_00530050 zeroes the unit / gameobject / itemID / spell identity fields
but leaves the item GUID (+0x380/+0x384) intact -- the field CGItem tooltips
(SetBagItem/SetInventoryItem) set and that Item::TooltipItem::CurrentID resolves
when the itemID field is 0. So a stale GUID lingered across a switch to non-item
content and GetItem/HasItem/IsEquippedItem resolved the old item.

Co-hook FUN_00530050 and zero +0x380/+0x384 after the original. All 14
Set*/builder paths route through this clear (verified via xrefs; the aura
builder FUN_0052f880 and the item builder FUN_0052b650 both call it), and the
item builder calls it before writing the GUID -- so every fresh tooltip resets
the item GUID and only a real item build re-sets it. The hook runs no Lua, just
zeroes 8 bytes; the CurrentID unit/GO/spell gating is now belt-and-suspenders.
Verified in-game: bag/equipped items still resolve; SetUnitAura / SetEquipmentSet
no longer leak the previous item.
2026-07-03 02:24:50 -05:00
Brues 3c0bf3abed OnTooltipSetItem: fire via the inner invoker + restore the Lua stack
The initial fire used FUN_00702690, whose exec-context mutation (DAT_00ceeac0)
is only valid from the engine's top-level frame-script dispatch; firing it from
inside the item builder (mid-Script_Set*, and in the wild under a stack of addon
Set* wrappers) raised through the outer wrappers. Two fixes make the fire safe
for normal handlers:

- Call the inner invoker FUN_00704d50(handler, tooltip) directly. It binds the
  global `this` = tooltip and runs the handler under its own protected pcall
  (0 args), self-contained and context-independent.
- Snapshot lua_gettop before the fire and lua_settop back after, so the still-
  running outer C code sees exactly the stack it left. Without this the invoker
  left the stack shifted and the outer code / other DLLs' hooks indexed a stale
  slot -> ACCESS_VIOLATION.

Verified in-game: SetScript / HookScript / GetScript all work, `this` is the
tooltip (a1 is nil -- vanilla convention, not modern `self`), and no-op /
print / AddLine handlers fire cleanly on bag / equipped / merchant / auction.

Docs: correct the handler example to use `this` (not a `self` arg), and add the
caveat that re-entrant tooltip rebuilds from the handler (ShowCompareItem, Set*
on another tooltip) can crash in a hook-heavy client and must be deferred
(C_Timer.After(0, ...)) -- the event fires mid-build.
2026-07-02 23:18:17 -05:00
Brues 3179d1741e Add OnTooltipSetItem frame script
Backports the modern OnTooltipSetItem tooltip script to 1.12 as a real
frame script, settable via the standard SetScript / GetScript / HookScript
and firing whenever a tooltip's item is set (with the tooltip as self). Lets
addons annotate item tooltips by hooking one script instead of wrapping every
Set* method -- the destructive wrap-and-rebuild pattern (AtlasLoot's
AddSourceLine) that clobbers the tooltip's item linkage.

Fully native, two Game::HookAutoRegister co-hooks in Tooltip::SetItemEvent:

- Resolver (FUN_005295d0, the CGGameTooltip vtable script-name -> slot
  vmethod): for "OnTooltipSetItem" (a name the engine doesn't know) return a
  pointer to a C-side per-tooltip 8-byte {handler, context} cell. The tooltip
  object (alloc size 0x460) has no free slot, and the resolver's only contract
  is "return where the handler lives", so external storage behaves exactly
  like the engine's own slots -- proven by OnTooltipCleared (+0x44c).
- Item builder (FUN_0052B650, the shared funnel for SetBagItem /
  SetInventoryItem / SetHyperlink / SetMerchantItem / SetAuctionItem / ...):
  after the build, fire the handler via FUN_00702690 (the no-arg runner the
  clear uses for OnTooltipCleared). Recursion-guarded, and suppressed via
  Tooltip::SetItemEvent::Suppressor during Tooltip::Compare's internal
  equipped-item build so a handler doesn't run mid line-shift.

Item only for now; OnTooltipSetSpell / OnTooltipSetUnit follow the same
pattern with their own builders.
2026-07-02 19:41:29 -05:00
Brues 63c003db26 Add GameTooltip:IsEquippedItem; share tooltip-item resolver; fix GetItem for CGItem/auction tooltips
Introduce Item::TooltipItem::CurrentID -- the single "what item is this
tooltip showing" resolver. It trusts the link-path itemID (+0x398), and
otherwise resolves the CGItem GUID (+0x380) but only when no unit /
gameobject / spell id is set, since the clear does not zero the item GUID
and it goes stale after the tooltip moves on.

- GameTooltip:GetItem / HasItem now route through CurrentID: fixes GetItem
  returning nil for CGItem tooltips (bag / equipped / merchant / loot) and
  HasItem false-positiving on a unit tooltip after an item hover.
- Add GameTooltip:IsEquippedItem() (3.3.5 backport, registry index 63),
  resolving the displayed item via CurrentID + the equipment-slot walk.
- SetHyperlinkCompareItem: the comparisonTooltip (4th arg) fallback uses
  CurrentID; stat deltas now show by default and hide only for an explicit
  false / 0 (was: show only for a truthy shift).
- GetItem round-trips the random-suffix id in the link for auction/compare
  items (no CGItem instance) via the compare descriptor.
- FrameXML GameTooltip_ShowCompareItem falls back to self.compareLink, so a
  caller can hand it an explicit link when the tooltip's item state was
  rebuilt out from under it (e.g. an AH addon appending source lines).
- Offsets: compare-descriptor + FontString offsets; drop the duplicate
  tooltip item-id/guid offsets in favor of the canonical OFF_TOOLTIP_*.
2026-07-02 19:09:03 -05:00
Brues d01e51d862 Add GameTooltip:SetHyperlinkCompareItem (item comparison tooltips)
Backports the 3.3.5+ shopping-tooltip method, fully native (no lua_pcall):
fills a tooltip with the item currently equipped in the given item's slot
— a grey "Currently Equipped" header, the equipped item's tooltip, then a
green/red per-stat delta breakdown gated on shiftButton.

- Extract the stat engine (base record + on-equip-spell auras + random
  suffix) out of item/Stats.cpp into the shared Item::StatAccum, reused by
  C_Item.GetItemStats/GetItemStatDelta and the compare deltas.
- Build via the engine's own item-tooltip builder; prepend the grey header
  by shifting the line FontStrings, replicating the show/hide protocol,
  keying on the visible flag (no stale text) and resetting cell color.
- Delta colors sourced from ColorData.h via a new constexpr ByTag helper.
- offset selects the slot for two-slot items (rings/trinkets/1H weapons);
  shiftButton gates the deltas (true/1 vs false/0/nil); a comparisonTooltip
  4th arg lets it run link-free, 1:1 with the retail call.
- FrameXML GameTooltip_ShowCompareItem consumer with a dynamic,
  backdrop-derived anchor gap.
2026-07-02 13:41:10 -05:00
Brues 92d868424c Lift GameTooltip's 30-line cap (pure C++)
Vanilla GameTooltip:AddLine silently drops any line past the 30
TextLeftN/TextRightN FontStrings the FrameXML template declares, so
stat-heavy tooltips (pfUI eqcompare, AtlasLoot) lose their tail.

Tooltip::LinePool co-hooks the tooltip's XML-OnLoad region scan
(FUN_00529650, CGameTooltip vtable slot 9) via HookAutoRegister. After
the engine sets up the template's 30 lines it creates TextLeft/Right
31..60 in C++ (alloc from the CSimpleFontString pool -> ctor(parent) ->
name -> font copied from the previous line -> anchor) and appends each
pair to the tooltip's three line arrays, bumping numLinesAllocated. Runs
once per GameTooltip-family frame at creation and per /reload, so it
auto-covers GameTooltip, ShoppingTooltip1/2, ItemRefTooltip, AtlasLoot,
WorldMapTooltip, etc. No addon Lua and no per-line AddLine hook.

SetPoint stores offsets in internal coordinates, not pixels, so the
anchor offsets are scaled the way Script_SetPoint does
(pixel * [0x00832A44] / ([0x00832A4C] * 1024)) — without that a -2px gap
rendered ~3950px off.

Adds the tooltip line-pool, FontString-creation, and UI-scale offsets to
Offsets.h; documents the behavior in the README and marks TODO #97 done.
2026-07-02 00:08:30 -05:00
Brues eb4c42b1e0 Update TODO.md 2026-07-01 22:28:37 -05:00
Brues 2968f51661 GetItemStats: cover Turtle equip-spell stats; exact effect value
Cross-checked against Turtle's server source (patch_1172): ItemModType
is unchanged vanilla (0-7) and ApplyItemEquipSpell applies only
ON_EQUIP, confirming special stats are all equip-spell auras.

- Value now uses the engine's CalculateSimpleValue = EffectBasePoints +
  EffectBaseDice (new OFF_SPELL_RECORD_EFFECT_BASE_DICE = 0x10C) instead
  of a hardcoded +1; equal for the fixed-die stat auras but exact.
- Decode the additional stat auras Turtle puts on custom gear: dodge %
  (49), parry % (47), block chance % (51), spell crit % (57/71), and
  flat health (34) / mana (35) folded into the existing keys. Adds shield
  block value from the record's m_block. New keys ITEM_MOD_DODGE_RATING,
  ITEM_MOD_PARRY_RATING, ITEM_MOD_BLOCK_RATING, ITEM_MOD_CRIT_SPELL_RATING,
  ITEM_MOD_BLOCK_VALUE (percent stats keep vanilla-native values).

Documents the expanded key set in docs/API.md.
2026-07-01 22:27:42 -05:00
44 changed files with 8122 additions and 652 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
-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
--
+89
View File
@@ -1,3 +1,92 @@
function GameTooltip_Hide()
GameTooltip:Hide()
end
GameTooltip.shoppingTooltips = GameTooltip.shoppingTooltips or { ShoppingTooltip1, ShoppingTooltip2 }
function GameTooltip_ShowCompareItem(self, shift)
if ( not self ) then
self = GameTooltip;
end
local item, link = self:GetItem();
link = link or self.compareLink
if ( not link ) then
return;
end
local shoppingTooltip1, shoppingTooltip2 = unpack(self.shoppingTooltips or { ShoppingTooltip1, ShoppingTooltip2 });
local SEPARATION = 6;
local backdrop = shoppingTooltip1.GetBackdrop and shoppingTooltip1:GetBackdrop();
local GAP = SEPARATION + ((type(backdrop) == "table" and backdrop.edgeSize) or 0);
local item1 = nil;
local item2 = nil;
local side = "left";
if ( shoppingTooltip1:SetHyperlinkCompareItem(link, 1, shift, self) ) then
item1 = true;
end
if ( shoppingTooltip2:SetHyperlinkCompareItem(link, 2, shift, self) ) then
item2 = true;
end
-- find correct side
local rightDist = 0;
local leftPos = self:GetLeft();
local rightPos = self:GetRight();
if ( not rightPos ) then
rightPos = 0;
end
if ( not leftPos ) then
leftPos = 0;
end
rightDist = GetScreenWidth() - rightPos;
if (leftPos and (rightDist < leftPos)) then
side = "left";
else
side = "right";
end
-- see if we should slide the tooltip
if ( self:GetAnchorType() and self:GetAnchorType() ~= "ANCHOR_PRESERVE" ) then
local totalWidth = 0;
if ( item1 ) then
totalWidth = totalWidth + shoppingTooltip1:GetWidth();
end
if ( item2 ) then
totalWidth = totalWidth + shoppingTooltip2:GetWidth();
end
if ( (side == "left") and (totalWidth > leftPos) ) then
self:SetAnchorType(self:GetAnchorType(), (totalWidth - leftPos), 0);
elseif ( (side == "right") and (rightPos + totalWidth) > GetScreenWidth() ) then
self:SetAnchorType(self:GetAnchorType(), -((rightPos + totalWidth) - GetScreenWidth()), 0);
end
end
if ( item1 ) then
shoppingTooltip1:SetOwner(self, "ANCHOR_NONE");
shoppingTooltip1:ClearAllPoints();
if ( side and side == "left" ) then
shoppingTooltip1:SetPoint("TOPRIGHT", self, "TOPLEFT", -GAP, -10);
else
shoppingTooltip1:SetPoint("TOPLEFT", self, "TOPRIGHT", GAP, -10);
end
shoppingTooltip1:SetHyperlinkCompareItem(link, 1, shift, self);
shoppingTooltip1:Show();
if ( item2 ) then
shoppingTooltip2:SetOwner(shoppingTooltip1, "ANCHOR_NONE");
shoppingTooltip2:ClearAllPoints();
if ( side and side == "left" ) then
shoppingTooltip2:SetPoint("TOPRIGHT", shoppingTooltip1, "TOPLEFT", -GAP, 0);
else
shoppingTooltip2:SetPoint("TOPLEFT", shoppingTooltip1, "TOPRIGHT", GAP, 0);
end
shoppingTooltip2:SetHyperlinkCompareItem(link, 2, shift, self);
shoppingTooltip2:Show();
end
end
end
@@ -0,0 +1,698 @@
-- 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 = { 379, -113 },
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 = { 28, -237 },
exit = { "CENTER", "TOPLEFT", 305, -422 },
};
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();
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
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, "FauxScrollFrameTemplate");
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
local NUM_REAGENTS = 8;
local prodIcon = CreateFrame("Button", nil, f);
prodIcon:SetSize(37, 37); -- TradeSkillSkillIcon is 37x37
prodIcon:SetPoint("TOPLEFT", LO.detail[1], LO.detail[2]);
prodIcon:SetNormalTexture("Interface\\Icons\\INV_Misc_QuestionMark");
local hdrLeft = prodIcon:CreateTexture(nil, "BACKGROUND");
hdrLeft:SetTexture("Interface\\ClassTrainerFrame\\UI-ClassTrainer-DetailHeaderLeft");
hdrLeft:SetSize(256, 64); -- Blizzard's native fill width (+64 cap = 320 total)
hdrLeft:SetPoint("TOPLEFT", prodIcon, "TOPLEFT", -8, 6);
local hdrRight = prodIcon:CreateTexture(nil, "BACKGROUND");
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 = prodIcon:CreateFontString(nil, "ARTWORK", "GameFontNormal");
prodName:SetPoint("LEFT", prodIcon, "RIGHT", 5, 0);
prodName:SetWidth(240);
prodName:SetJustifyH("LEFT");
prodName:SetJustifyV("MIDDLE");
f.prodName = prodName;
local prodDesc = f:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall");
prodDesc:SetPoint("TOPLEFT", prodIcon, "BOTTOMLEFT", 0, -6);
prodDesc:SetWidth(280);
prodDesc:SetJustifyH("LEFT");
prodDesc:SetJustifyV("TOP");
f.prodDesc = prodDesc;
local reagentLabel = f:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall");
reagentLabel:SetPoint("TOPLEFT", prodDesc, "BOTTOMLEFT", 0, -8);
reagentLabel:SetText(SPELL_REAGENTS or "Reagents:");
f.reagentLabel = reagentLabel;
f.reagents = {};
for i = 1, NUM_REAGENTS do
local rowN = math.floor((i - 1) / 2);
local col = (i - 1) - rowN * 2;
local btn = CreateFrame("Button", nil, f);
btn:SetSize(39, 39);
btn:SetPoint("TOPLEFT", reagentLabel, "BOTTOMLEFT",
col * 147 - 5, -3 - rowN * 43);
btn:SetNormalTexture("Interface\\Icons\\INV_Misc_QuestionMark");
local plate = btn:CreateTexture(nil, "BACKGROUND");
plate:SetTexture("Interface\\QuestFrame\\UI-QuestItemNameFrame");
plate:SetSize(128, 64);
plate:SetPoint("LEFT", btn, "RIGHT", -10, 0);
local cnt = btn:CreateFontString(nil, "ARTWORK", "NumberFontNormal");
cnt:SetPoint("BOTTOMRIGHT", -4, 1);
btn.count = cnt;
local nm = btn:CreateFontString(nil, "ARTWORK", "GameFontHighlight");
nm:SetPoint("LEFT", plate, "LEFT", 15, 0);
nm:SetSize(90, 36);
nm: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);
btn:Hide();
f.reagents[i] = btn;
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
if created > 0 then
self.prodDesc:SetText("");
else
self.prodDesc:SetText(C_Spell.GetSpellDescription(entry.spellID) or "");
end
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();
local reagents = entry.reagents or {};
for i = 1, NUM_REAGENTS do
local btn = self.reagents[i];
local r = reagents[i];
if r then
local name, tex, link = itemInfo(r.itemID);
btn:SetNormalTexture(tex or
"Interface\\Icons\\INV_Misc_QuestionMark");
btn.itemLink = link;
btn.nameText:SetText(name or ("item:" .. r.itemID));
local need = r.count or 1;
local have = C_Item.GetItemCount(r.itemID) or 0;
if have >= need then
btn:GetNormalTexture():SetVertexColor(1, 1, 1);
btn.nameText:SetTextColor(HIGHLIGHT_FONT_COLOR:GetRGB());
else
btn:GetNormalTexture():SetVertexColor(0.5, 0.5, 0.5);
btn.nameText:SetTextColor(GRAY_FONT_COLOR:GetRGB());
end
local haveText = (have >= 100) and "*" or tostring(have);
btn.count:SetText(haveText .. " /" .. need);
btn:Show();
else
btn.itemLink = nil;
btn:Hide();
end
end
end
prodIcon:SetScript("OnEnter", function()
GameTooltip:SetOwner(this, "ANCHOR_RIGHT");
if this.itemLink then
GameTooltip:SetHyperlink(this.itemLink);
elseif this.spellID then
GameTooltip:SetSpellByID(this.spellID);
end
GameTooltip:Show();
end);
prodIcon:SetScript("OnLeave", GameTooltip_Hide);
function f:SelectRecipe(entry)
self.selectedSpellID = entry.spellID;
self.selectedEntry = entry;
self:RenderDetail(entry);
self:Refresh();
end
function f:ClearDetail()
self.selectedSpellID = nil;
self.selectedEntry = nil;
self.prodIcon:Hide();
self.prodName:SetText("");
self.prodDesc:SetText("");
for i = 1, NUM_REAGENTS do
self.reagents[i]:Hide();
end
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 btn = CreateFrame("Button", btnName, parent);
btn:SetSize(16, 16);
btn:SetPoint("LEFT", _G[parentName .. "TitleText"], "RIGHT", 4, 0);
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);
+2
View File
@@ -6,6 +6,7 @@ FOCUSTARGET = "Focus Target";
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:";
ITEMS_EQUIPPED = "%d |4item:items; equipped";
ITEMS_IN_INVENTORY = "%d |4item:items; in inventory";
ITEMS_NOT_IN_INVENTORY = "%d |4item:items; missing";
@@ -23,3 +24,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.";
+4 -1
View File
@@ -99,7 +99,10 @@ target_compile_definitions(
${PROJECT_NAME}
PRIVATE CLASSICAPI_VERSION_VALUE=${CLASSICAPI_VERSION_VALUE})
target_link_libraries(${PROJECT_NAME} PRIVATE minhook advapi32)
# ole32: COM (CoInitializeEx / CoCreateInstance / CoTaskMemFree) for the
# SAPI text-to-speech backend in src/voicechat/Tts.cpp. The SAPI GUIDs are
# instantiated in that TU via <initguid.h>, so no sapi.lib is needed.
target_link_libraries(${PROJECT_NAME} PRIVATE minhook advapi32 ole32)
target_include_directories(
${PROJECT_NAME} PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/src
+26 -4
View File
@@ -33,22 +33,24 @@ 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:SetEquipmentSet`, `GameTooltip:SetInventoryItemByID`, `GameTooltip:SetItemByGUID`, `GameTooltip:SetItemByID`, `GameTooltip:SetSpellByID`, `GameTooltip:SetTalentByID`, `GameTooltip:SetUnitAura` |
| [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` |
| [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` |
| [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` |
| [Quest](docs/API.md#quest) | `C_QuestLog.GetQuestDetails`, `C_QuestLog.GetQuestIDForLogIndex`, `C_QuestLog.GetTitleForQuestID`, `C_QuestLog.IsOnQuest`, `C_QuestLog.IsQuestDataCachedByID`, `C_QuestLog.IsUnitOnQuest`, `C_QuestLog.RequestLoadQuestByID`, `GetQuestLogLeaderBoardID` |
| [Quest](docs/API.md#quest) | `C_QuestLog.GetNumQuestObjectives`, `C_QuestLog.GetQuestDetails`, `C_QuestLog.GetQuestIDForLogIndex`, `C_QuestLog.GetTitleForQuestID`, `C_QuestLog.IsOnQuest`, `C_QuestLog.IsQuestDataCachedByID`, `C_QuestLog.IsUnitOnQuest`, `C_QuestLog.RequestLoadQuestByID`, `GetQuestLogLeaderBoardID` |
| [Screen](docs/API.md#screen) | `GetPhysicalScreenSize` |
| [Spell](docs/API.md#spell) | `C_Spell.CancelSpellByID`, `C_Spell.CastAtCursor`, `C_Spell.CastAtUnit`, `C_Spell.CastingInfo`, `C_Spell.ChannelInfo`, `C_Spell.DoesSpellExist`, `C_Spell.GetSchoolString`, `C_Spell.GetSpellCooldown`, `C_Spell.GetSpellDescription`, `C_Spell.GetSpellInfo`, `C_Spell.GetSpellLink`, `C_Spell.GetSpellMechanicByID`, `C_Spell.GetSpellName`, `C_Spell.GetSpellPowerCost`, `C_Spell.GetSpellRadius`, `C_Spell.GetSpellReagents`, `C_Spell.GetSpellSubtext`, `C_Spell.GetSpellTexture`, `C_Spell.IsAutoAttackSpell`, `C_Spell.IsCurrentSpell`, `C_Spell.IsRangedAutoAttackSpell`, `C_Spell.IsSelfBuff`, `C_Spell.IsSpellHarmful`, `C_Spell.IsSpellHelpful`, `C_Spell.IsSpellPassive`, `C_Spell.IsSpellUsable`, `C_Spell.SpellHasRange`, `C_Spell.UnitCastingInfo`, `C_Spell.UnitChannelInfo`, `CancelSpellByName`, `CastSpellNoToggle`, `GetCraftSpellID`, `GetSpellInfo`, `GetSpellLink`, `GetSpellRadius`, `GetSpellSchool`, `IsHarmfulSpell`, `IsHelpfulSpell`, `IsPassiveSpell`, `IsPlayerSpell`, `IsSpellKnown`, `IsUsableSpell`, `SpellHasRange` |
| [SpellBook](docs/API.md#spellbook) | `C_SpellBook.GetCurrentLevelSpells`, `C_SpellBook.GetSpellLevelLearned`, `C_SpellBook.GetSpellSkillLine`, `C_SpellBook.IsAutoAttackSpellBookItem`, `C_SpellBook.IsRangedAutoAttackSpellBookItem`, `FindSpellBookSlotByID` |
@@ -56,9 +58,12 @@ Full per-function reference: **[docs/API.md](docs/API.md)**.
| [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` |
| [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,10 +122,13 @@ when launching with `-console`), not as Lua functions. See the
| `HEARTHSTONE_BOUND` | *(none)* |
| `ITEM_DATA_LOAD_RESULT` | `itemID, success` |
| `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") |
| `NAME_PLATE_UNIT_REMOVED` | `unitToken` ("nameplateN") |
| `PLAYER_EQUIPMENT_CHANGED` | `equipmentSlot, hasCurrent` |
| `PLAYER_FOCUS_CHANGED` | *(none)* |
| `PLAYER_STARTED_LOOKING` | *(none)* |
| `PLAYER_STOPPED_LOOKING` | *(none)* |
@@ -130,8 +138,14 @@ when launching with `-console`), not as Lua functions. See the
| `PLAYER_STOPPED_TURNING` | *(none)* |
| `QUEST_ACCEPTED` | `questLogIndex, questID` |
| `QUEST_DATA_LOAD_RESULT` | `questID, success` |
| `QUEST_REMOVED` | `questID` |
| `QUEST_TURNED_IN` | `questID, xpReward, moneyReward` |
| `UPDATE_INVENTORY_DURABILITY` | *(none)* |
| `UPDATE_SHAPESHIFT_FORM` | *(none)* |
| `VOICE_CHAT_TTS_PLAYBACK_STARTED` | `numConsumers, utteranceID, durationMS, destination` |
| `VOICE_CHAT_TTS_PLAYBACK_FINISHED` | `numConsumers, utteranceID, destination` |
| `VOICE_CHAT_TTS_PLAYBACK_FAILED` | `status, utteranceID, destination` |
| `VOICE_CHAT_TTS_VOICES_UPDATE` | *(none)* |
### Globals
@@ -165,6 +179,14 @@ native targeting bindings instead of orphaned at the bottom.
See [Focus / Bindings](docs/API.md#bindings-focustarget--targetfocus) for the implementation note.
### Behaviors
Transparent engine tweaks — no API to call, they just fix a vanilla limitation:
| Tweak | Effect |
|-------|--------|
| Tooltip line cap | Lifts `GameTooltip`'s hard 30-line limit to 60 for every `GameTooltipTemplate` frame (`GameTooltip`, `ShoppingTooltip1/2`, `ItemRefTooltip`, AtlasLoot, …). Stat-heavy tooltips and comparison blocks (e.g. pfUI's eqcompare) no longer have their extra lines silently dropped. Done in pure C++ by growing the engine's FontString pool at tooltip-creation time. |
## Installation
Download the prebuilt `ClassicAPI.dll` from the
+316 -2
View File
@@ -2725,7 +2725,26 @@ matching modern's 3-tuple. `CastSpellNoToggle("<name>")` macros are
also recognized because `Spell::CastNoToggle` already hooks the same
parser to register that pattern.
## 88. `PLAYER_EQUIPMENT_CHANGED(equipmentSlot, hasCurrent)` event
## ~~88. `PLAYER_EQUIPMENT_CHANGED(equipmentSlot, hasCurrent)` event~~ — DONE
**Resolution (2026-07-04, src/player/Equipment.cpp):** the 2026-05-22
investigation kept trying to hook a FIRER of UNIT_INVENTORY_CHANGED.
The winning move was the opposite: the engine has a generic
descriptor-field observer system (the "__AUCMirrorHandler__" nodes —
registrar `FUN_00467E70`, dispatcher `FUN_00465570` with per-node
mirror + memcmp change detection). `FUN_004F8CC0` registers observers
for the bag/backpack/bank/keyring GUID ranges but never for equipment
`0x4A8..0x538` — so we co-hook it and REGISTER OUR OWN observer per
equipment field, mirroring the engine's registration exactly (bank 4,
size 8, player GUID). Callback ABI verified from the dispatcher call
site at `0x004655DC` + `FUN_004F8DB0`'s `RET 0x10`:
`__fastcall(fieldOffset /*ecx*/, size /*edx*/, guidLo, guidHi,
oldValue*, userArg)`. hasCurrent read from the invMgr GUID array (the
same "current truth" the engine's bag callback reads). No polling, no
hot hook, engine-managed lifetime (setup re-runs each enter-world).
Offsets documented at `FUN_DESC_OBSERVER_REGISTER` in Offsets.h.
Original notes below kept for the investigation trail.
WotLK-era event that fires every time a paperdoll slot changes
(equip, unequip, swap). Heavily used by gear-tracking,
@@ -2828,7 +2847,33 @@ Same diff feeds `EQUIPMENT_SETS_CHANGED` /
module — currently those fire only on user-side mutations, not
on inventory changes that affect a set's "equipped count".
## 89. `UPDATE_INVENTORY_DURABILITY` event
## ~~89. `UPDATE_INVENTORY_DURABILITY` event~~ — DONE
**Resolution (2026-07-04, src/player/Equipment.cpp):** co-hook the
engine's inventory-alerts recompute `FUN_004C7EE0` — the routine that
walks equipment slots, reads each item's durability/broken state from
the descriptor, and unconditionally fires vanilla's
`UPDATE_INVENTORY_ALERTS` (event 501). The engine calls it whenever an
owned item's fields change (the item post-update handler `FUN_005D9A90`
routes every item values-update through it, alongside
UNIT_INVENTORY_CHANGED and the bag-update chain) plus equip and
enter-world paths — i.e., it IS the engine's own "durability may have
changed" signal. Post-original we diff a per-slot {guid, durability}
snapshot (seeded silently on the first recompute per enter-world) and
fire once per recompute with a real change. No polling, no hot hook.
**Dead end tried first:** bank-1 (ITEM field) descriptor observers per
equipped item, via the TODO #88 observer system. They register without
error but are NEVER invoked — item values-updates demonstrably arrive
(probed the values pre-pass `FUN_00465330`) and player-bank observers
fire in the same session; registration arithmetic fully verified (bank
base `FUN_00465690(1)=0x18`, bank walk item 0→1→2, anchors match
dispatch). Prime suspect: the registrar's internal wrapper lookup
(`FUN_00464890`, the observer registry — NOT the object manager)
silently no-ops for item objects. See the DEAD END note at
`OFF_DESC_ITEM_DURABILITY` in Offsets.h.
Original notes below kept for the investigation trail.
Fires when any equipped item's durability changes — picks up
combat damage, repairs, item destruction. Used by Repair-O-Matic
@@ -3301,3 +3346,272 @@ the matching read. Both should be straightforward once the field
offset is identified — 3.3.5's `Script_*` equivalents are a good
starting point for what the parameter shape and bounds-clamp
behavior should be.
## ~~97. Lift GameTooltip's 30-line cap~~ — DONE
Shipped as [src/tooltip/LinePool.cpp](src/tooltip/LinePool.cpp): pure C++,
no addon Lua. Co-hooks the XML-OnLoad region scan (FUN_00529650, vtable
slot 9) via `Game::HookAutoRegister`; after the engine sets up the
template's 30 lines it creates `TextLeft/Right 31..60` in C++ (alloc →
ctor → name → font-copied-from-prev → anchor, offsets scaled pixel →
internal like Script_SetPoint) and appends them to the tooltip's three
line arrays, bumping `numLinesAllocated`. Runs once per GameTooltip-family
frame at creation + per `/reload`, so it auto-covers GameTooltip,
ShoppingTooltip1/2, ItemRefTooltip, AtlasLootTooltip, WorldMapTooltip, …
Verified in-game: AtlasLoot tooltip rendered a 31st line correctly. Cap is
`kMaxLines = 60`. The design notes below record the RE trail; Approach A
(hook AddLine) / Approach B (addon Lua) were both explored and dropped in
favor of the scan-hook.
Vanilla 1.12's `GameTooltip:AddLine` silently drops any line past the
pool of `TextLeftN`/`TextRightN` FontStrings the XML template
pre-declares (30). Modern (3.3.5+) engines grow the pool on demand.
Addons that append comparison summaries, item-info footers, or aura
details to a full item tooltip get their tail chopped. Practical
example — pfUI's eqcompare block (base-stat deltas inline + extended
stats appended at the bottom) hits the cap on stat-heavy items where
the vendor tooltip already has ~22 lines and the compare block adds
another 10; extended-stat rows past line 30 disappear silently.
Also affects `ShoppingTooltip1`/`ShoppingTooltip2` (same
`GameTooltipTemplate`).
### Root cause (verified in Ghidra on the 1.12 Octo binary)
`AddLine` internal impl at `FUN_00530270` — the shared body called
from both `Script_GameTooltipAddLine` (`FUN_00531630`, method ptr
stored in the GameTooltip method table alongside the `"AddLine"`
string at `0x00854560`) and `Script_GameTooltipAddDoubleLine`:
```c
void FUN_00530270(int this, char *leftText, char *rightText,
colorLeft, colorRight, wrapFlag) {
if ((*(int *)(this + 0x31c) != *(int *)(this + 0x320)) && anyHasText) {
// set leftText on textLeft[numLines_used],
// set rightText on textRight[numLines_used],
// wrapFlag[numLines_used] = wrapFlag,
// ++numLines_used
}
// else: silently drop the line
}
```
`0x320 == 800` decimal, matches decompile. Pool exhausted ⇒ nothing
happens.
### CGameTooltip struct offsets (1.12)
Derived from `FUN_00530270` and the pool-init at `FUN_00529650`:
| Offset | Field |
|--------|----------------------------------------------------|
| `+0x31c` | `numLines` — current write index (0-based) |
| `+0x320` | `numLinesAllocated` — pool size (30 default) |
| `+0x328..+0x32c` | `textLeft[]` — array of `CSimpleFontString *`, count `+0x320` |
| `+0x334..+0x338` | `textRight[]` — same, symmetric right column |
| `+0x340..+0x344` | `wrapFlag[]` — per-line wrap bool |
Actual layout of the array slots: `+0x328` is the first ptr in an
external heap block whose base is `param_1[0xcb]` = `+0x32c`. The
init function reallocates all three arrays whenever `numLinesAllocated`
would change — see `FUN_00536c80` (the reallocator) and `FUN_004368c0`
(a matching alloc for the wrap-flag array of a different element size).
### The pool-init function
`FUN_00529650` — walks `%sTextLeftN` / `%sTextRightN` FontString names
from the FrameXML-declared pool, counts them, reallocates the three
arrays, stores the pointers. Called via vtable slot at `0x00808f84`
(only xref) — likely `SetOwner` or an equivalent virtual reset method
runs it. It's the natural growth path — if we can force it to see a
larger pool of Lua-visible FontStrings and trigger it, the arrays
resize themselves.
### The 3.3.5 reference (already REd)
3.3.5's `GameTooltip::AddLine` at `0x0061fec0` has the grow-on-demand
branch we want to backport. Shape:
```c
if (currentLine == numLinesAllocated - 1) {
format(buf, 256, "%sTextLeft%d", tooltipName, currentLine + 1);
auto *fs = SimpleFontString::create(this, /*layer*/2, /*subLayer*/1);
fs->setName(buf);
fs->setFontObject(prevLeft->fontObject);
fs->anchor(TOPLEFT, prevLeft, BOTTOMLEFT, 0.0, calculatedYOffset);
// ... mirror for TextRight ...
// ... append fs to textLeft[]/textRight[] at [numLinesAllocated],
// then ++numLinesAllocated.
}
// fall through to the existing "set text on line[currentLine]" path
```
3.3.5 helpers (offsets 3.3.5-specific — 1.12 equivalents need to be
re-derived):
- `FUN_00485240` — SimpleFontString constructor `(parent, layer, sublayer)`
- `FUN_0048b6c0` — set-name helper (registers under global `_G[name]`)
- `FUN_0048a260` — anchor helper `(this, anchorPoint, relativeTo, relativePoint, xoff, yoff)`
Struct offsets in 3.3.5 were `+0x2a4` (numLines) / `+0x2a8` (numAlloc) /
`+0x2b4` (textLeft) / `+0x2c0` (textRight) / `+0x2cc` (wrapFlag). All
shifted vs. 1.12 (see above), unsurprising given TBC/WotLK bloat.
### Implementation plan
**Approach A — pre-hook AddLine with a grow-if-needed prelude
(preferred)**:
1. Locate the 1.12 equivalents of the three 3.3.5 helpers. Trace them
from existing `CSimpleFontString`-creating sites in the engine —
the CreateFontString API dispatch is a good starting point (it's
Lua-visible and calls the same constructor).
2. Locate the array reallocators used by `FUN_00529650`
(`FUN_00536c80` for the two pointer arrays, `FUN_004368c0` for the
wrap-flag byte array).
3. MinHook `FUN_00530270`. Pre-hook body:
- If `numLines != numLinesAllocated`, call original — done.
- Otherwise: realloc the three arrays to `numLinesAllocated + 1`,
create two new `CSimpleFontString` (left + right), name them
`"%sTextLeft%d"` / `"%sTextRight%d"` with `tooltip->GetName()` and
the new index, anchor each below the prior-slot FontString on the
same side, use the prior FontString's `fontObject` (`+0xd8` in the
3.3.5 layout — confirm on 1.12) as the default, write the pointers
into `textLeft[numLinesAllocated]` / `textRight[numLinesAllocated]`,
zero-init `wrapFlag[numLinesAllocated]`, and bump
`numLinesAllocated`.
- Then tail-call the original — it now succeeds normally.
**Approach B — pre-declare a wider pool via Lua** (fallback if the
constructor helpers turn out to be too tangled):
1. In `!!!ClassicAPI` addon, at load time, call
`GameTooltip:CreateFontString("GameTooltipTextLeft" .. i, "ARTWORK",
"GameTooltipText")` for `i = 31..60`, plus `TextRightN`, plus set
anchors matching the pattern in the template.
2. Force the engine's pool-init to re-scan. If we can't reach the
vtable trigger cleanly, expose a small C entrypoint that walks the
pool the same way `FUN_00529650` does — takes `tooltip` as arg,
reallocates arrays, refills.
Approach B is less code but ties the cap to a fixed number (60 lines
is enough for pfUI's use case but arbitrary). Approach A grows on
demand like retail and has no ceiling.
### Testing
- Hover a random-suffix weapon with several equip-spell bonuses (Krol
Blade, Ironfoe, any ZG/AQ trinket) — tooltip should render >30 lines
without truncation.
- Hover an item that produces a 40+ line tooltip when pfUI's eqcompare
block is enabled (Priest Judgement Set chest against another
Judgement chest: item tooltip ~22 lines + compare block ~10 lines).
- Verify no crash / no visual glitches on:
- ShoppingTooltip1/2 (same class, same `GameTooltipTemplate`)
- Rapid re-hovers (create/destroy pressure on the added FontStrings)
- Item tooltips that stay under 30 lines (pre-existing behavior unchanged)
### Risks
- If the array reallocation logic in `FUN_00536c80` doesn't null-out
the tail on grow, `textLeft[N]`/`textRight[N]` might contain
garbage for a frame — anchor before write, or memset to zero.
- FontString lifetime: 3.3.5 leaks the extras for the tooltip
lifetime (they stay hidden when unused). GameTooltip is a
singleton, so leak is bounded. Fine.
- Interaction with `pfUI`'s existing tooltip skinning (frame borders,
status bar reposition, etc.): the layer we're modifying is beneath
the skinning; shouldn't touch anything visible unless anchor math
is wrong.
### Related
If we ever consider dropping the whole "GameTooltipTemplate declares
30 FontStrings in XML" scheme and instead having the engine start
with 0 and grow entirely on demand — that would be even cleaner, but
requires patching every consumer of the initial pool (there aren't
many, but they need audit). Filed for future if the incremental grow
turns out to be fragile.
### Investigation update (resolved unknowns)
Traced the pool machinery end-to-end. The open questions above are now
answered, and they point away from both the AddLine hook (Approach A)
and the "trigger the engine's scan" idea — a **hybrid** is cleaner than
either.
**The scan is virtual slot 9, run only at XML-template instantiation.**
`FUN_00529650` is slot 9 (`vtable+0x24`) of the CGameTooltip vtable
(base `0x00808f60`; the null at `0x00808f5c` is the boundary, and slot 2
`0x005368d0` is the `tooltip:method()` dispatcher — confirms the class).
The two vtable-base xrefs are the constructors `FUN_00529240` /
`FUN_00529480`. The scan's prologue calls
`FUN_0076a2f0(node) → FUN_0076a060(node)`, which reads the XML
`"inherits"` attribute and child `"Frames"` — i.e. the scan is the
frame's **XML OnLoad / region-setup** virtual. It runs once when
`GameTooltip` is built from `GameTooltipTemplate` (and again per
`/reload`), **not** on `SetOwner`/show. So we can't cheaply re-trigger
it — the `node` arg is a live XML-parse handle we don't have, and
calling it with a bogus node runs the inherits/Frames path on garbage.
**The scan finds lines by `_G[name]`, so Lua-created FontStrings count.**
`numLinesAllocated` (`+0x320`) is literally "how many contiguous
`%sTextLeftN` **and** `%sTextRightN` exist by name": pass 0 counts (stop
at the first missing index), pass 1 reallocs the three arrays to that
count and fills the pointers. The name lookup `FUN_0076c760` does
`lua_pushstring(name); gettable(_G); if type==TABLE: FrameScript_GetObject`
— a plain `_G[name]` resolve, so a FontString made with
`GameTooltip:CreateFontString("GameTooltipTextLeft31", …)` **is**
visible to it. (Names must be contiguous and exist in *both* columns.)
**The array reallocators are simple and callable from C.**
`FUN_00536c80` (pointer arrays) and `FUN_004368c0` (wrap-flag array) are
the same routine with different SMem tags:
`__thiscall(descriptor, newCount)` where the descriptor is
`{ count@+0x0, cap@+0x4, data@+0x8 }` — reallocs `data` to `newCount*4`
and copies the old contents. In the tooltip the three descriptors are:
| Descriptor `this` | count / cap / data | array |
|-------------------|--------------------|-------|
| `tooltip + 0x324` | `+0x324 / +0x328 / +0x32c` | `textLeft[]` (`CSimpleFontString*`) |
| `tooltip + 0x330` | `+0x330 / +0x334 / +0x338` | `textRight[]` |
| `tooltip + 0x33c` | `+0x33c / +0x340 / +0x344` | `wrapFlag[]` (int, 4-byte) |
`numLinesAllocated` (`+0x320`) is a separate field the scan sets equal
to the count; AddLine (`FUN_00530270`) gates on
`numLines(+0x31c) != numLinesAllocated(+0x320)`.
**Recommended approach — Lua creates FontStrings, C grows the arrays
(no hook, no XML re-run).** Replicate the scan's pass 1 directly:
1. **Lua (`!!!ClassicAPI`), at load / `PLAYER_LOGIN`:** for each frame on
`GameTooltipTemplate` (`GameTooltip`, `ShoppingTooltip1/2`,
`ItemRefTooltip`, …), create `<name>TextLeft31..N` and
`<name>TextRight31..N` via `frame:CreateFontString(nm, "ARTWORK",
"GameTooltipText")`, each anchored `TOPLEFT`/`TOPRIGHT` to the prior
line's `BOTTOMLEFT`/`BOTTOMRIGHT` (mirror the template's chain).
This avoids re-deriving the 1.12 FontString ctor / setName / anchor
helpers that Approach A needed — Lua already wraps them.
2. **C entrypoint** `GrowTooltipPool(tooltipObj, newCount)` (expose as a
Lua global, called once per frame right after step 1):
- `FUN_00536c80(tooltip+0x324, newCount)`; set `+0x328 = newCount`.
- `FUN_00536c80(tooltip+0x330, newCount)`; set `+0x334 = newCount`.
- `FUN_004368c0(tooltip+0x33c, newCount)`; set `+0x340 = newCount`.
- for `i` in `[oldAlloc, newCount)`: resolve `<name>TextLeftN`/
`RightN` via the `_G[name]` dance (same as `FUN_0076c760`), and if
either is missing stop at `i`; else write the two pointers into the
new `textLeft`/`textRight` data slots and zero `wrapFlag[i]`.
- set `numLinesAllocated (+0x320) = i`.
Resolve `tooltipObj` from the Lua frame the same way our other
tooltip methods do (`FrameScript_GetObject`).
Runs once per frame per session (re-runs on `/reload`, which recreates
the frames → scan resets to 30 → our addon re-creates + re-grows). No
per-line hook (dodges the MinHook-collision risk on a moderately hot
function), no XML-node fabrication.
Remaining risk to validate in-game: whether AddLine's downstream layout
/ `Show` sizing walks `numLines` FontStrings by their anchors (expected)
vs. any hidden fixed-30 assumption elsewhere; and that the Lua-created
lines inherit height/spacing identical to the template's 1..30 so the
tooltip grows seamlessly. Both are observable on the first >30-line
hover.
+859 -33
View File
File diff suppressed because it is too large Load Diff
+689 -27
View File
@@ -87,6 +87,121 @@ enum Offsets {
FUN_REGISTER_FRAME_METHODS = 0x00701D80,
VAR_GAMETOOLTIP_METHOD_REGISTRY = 0x00C0CF20,
// CGameTooltip line pool (see TODO #97). The engine caps AddLine
// (FUN_00530270) at `numLinesAllocated`, which the XML-OnLoad scan
// (FUN_00529650, vtable slot 9) sets to the count of contiguous
// `<name>TextLeftN`/`TextRightN` FontStrings the template declares
// (30). Tooltip::LinePool grows it: it appends its own C++-created
// FontStrings to the three parallel arrays and bumps this count. Each
// array is a {count@+0x0, cap@+0x4, data@+0x8} descriptor; the
// reallocators realloc `data` to newCount*4 and copy `cap` old elements.
VAR_GAMETOOLTIP_VTABLE = 0x00808F60, // type guard for a resolved tooltip object
OFF_GAMETOOLTIP_NUM_LINES_ALLOC = 0x320, // int — pool size AddLine gates on
OFF_GAMETOOLTIP_TEXTLEFT_DESC = 0x324, // {count,cap,data}; data (+0x8) = CSimpleFontString*[]
OFF_GAMETOOLTIP_TEXTRIGHT_DESC = 0x330, // symmetric right column
OFF_GAMETOOLTIP_WRAPFLAG_DESC = 0x33C, // per-line wrap int[]
FUN_TOOLTIP_REALLOC_PTR_ARRAY = 0x00536C80, // __thiscall(desc, newCount) — pointer arrays
FUN_TOOLTIP_REALLOC_INT_ARRAY = 0x004368C0, // __thiscall(desc, newCount) — wrap-flag array
// GameTooltip:SetHyperlinkCompareItem (Tooltip::Compare) — builds the
// equipped item's tooltip natively (no Lua roundtrip) and appends
// colored stat deltas. FUN_GAMETOOLTIP_BUILD_ITEM is the engine's own
// item-tooltip builder that SetInventoryItem/SetHyperlink call:
// __thiscall(self, itemID, guid*, guid*, a4, a5, a6, headerFlag, a8, a9)
// Proven arg shape from FUN_005353b0/FUN_00535700 (all flags 0 → a clean
// item tooltip). The builder's own headerFlag (param_7) path merges the
// "Currently Equipped" text into the name line and force-wraps it, so we
// build clean (flag 0) and prepend the grey header ourselves by shifting
// the line FontStrings — see FUN_FONTSTRING_SET_* / OFF_FONTSTRING_*.
// FUN_GAMETOOLTIP_ADD_LINE is the raw add-line body (see LinePool notes);
// its color args are pointers to a 4-byte packed color {b,g,r,a} — the
// Lua AddLine handler (FUN_00531630) builds it as 0xFF<rr><gg><bb>, i.e.
// uint32 `0xFF000000 | r<<16 | g<<8 | b` in little-endian memory.
FUN_GAMETOOLTIP_BUILD_ITEM = 0x0052B650,
// OnTooltipSetItem support. FUN_GAMETOOLTIP_SCRIPT_RESOLVER is the
// CGGameTooltip vtable method (vtable 0x00808F60) that maps a script name
// to its handler slot; __thiscall(self, const char *name) -> int* slot,
// 0 if the name isn't a tooltip script. It first delegates to the base
// frame resolver, then checks OnTooltipSetDefaultAnchor(+0x444) /
// OnTooltipCleared(+0x44c) / OnTooltipAddMoney(+0x454) — each an 8-byte
// {handler, context} slot. Vanilla has no OnTooltipSetItem, and the object
// (alloc size 0x460) has no free 8-byte slot, so we co-hook this to hand
// out a C-side per-tooltip cell for that name. FUN_FRAME_INVOKE_SCRIPT is
// the engine's real script invoker __fastcall(handler /*ecx = slot[0]*/,
// frame /*edx*/): it binds the global `this` = frame and runs the handler
// under its own protected lua_pcall (0 args). We call it directly from the
// item-builder co-hook to fire OnTooltipSetItem.
// NOTE: the clear fires OnTooltipCleared via the wrapper FUN_00702690
// (self, &slot), which additionally stamps the global exec-context
// (DAT_00ceeac0) from slot[1]. That context mutation is only valid from
// the engine's top-level frame-script dispatch — firing it from deep in a
// nested Lua->C->Lua stack (e.g. an addon's SetAuctionItem wrapper)
// corrupts the outer script's context and faults. So we skip the wrapper
// and call the inner invoker, which is self-contained.
FUN_GAMETOOLTIP_SCRIPT_RESOLVER = 0x005295D0,
FUN_FRAME_INVOKE_SCRIPT = 0x00704D50,
FUN_GAMETOOLTIP_ADD_LINE = 0x00530270, // __thiscall(self, left, right, lColorBGRA*, rColorBGRA*, wrap)
OFF_GAMETOOLTIP_NUM_LINES = 0x31C, // int — live line count (AddLine index; +0x320 is the cap)
// The displayed item's identity is read via the existing
// OFF_TOOLTIP_ITEM_ID (+0x398) / OFF_TOOLTIP_ITEM_GUID_LO (+0x380) —
// link paths set the itemID, CGItem paths (SetInventoryItem/…) set only
// the GUID (see Tooltip::Compare::TooltipItemID and item/Tooltip.cpp).
// Line-shift primitives for prepending the grey "Currently Equipped"
// header. The per-line CSimpleFontString objects live in the parallel
// arrays at OFF_GAMETOOLTIP_TEXTLEFT/RIGHT_DESC (+8 = data). Each stores
// its text buffer pointer at +0xF0 and its 4-byte {b,g,r,a} color behind
// the pointer at +0xB8 (both verified from the setters below). We read a
// line's text/color and re-apply them one slot down, then set line 0.
FUN_FONTSTRING_SET_TEXT = 0x00771D80, // __thiscall(fs, text, flag=0)
FUN_FONTSTRING_SET_COLOR = 0x0077F750, // __thiscall(fs, colorBGRA*)
OFF_FONTSTRING_TEXT = 0xF0, // char* — current text buffer
OFF_FONTSTRING_COLOR_PTR = 0xB8, // ptr → 4-byte {b,g,r,a} color storage
// A line's FontString is only positioned once shown: set the desired
// flag at +0xC4 then call SHOW (FUN_0077fcb0) / HIDE (FUN_0077fc60) —
// the exact pair AddLine (FUN_00530270) and the clear use. Moving text
// into a previously-empty (hidden) cell without this leaves it
// unpositioned, so the line-shift must replicate it.
FUN_FONTSTRING_SHOW = 0x0077FCB0, // __fastcall(fs) — realizes +0xC4
FUN_FONTSTRING_HIDE = 0x0077FC60, // __fastcall(fs)
OFF_FONTSTRING_SHOWN_FLAG = 0xC4, // int — desired-shown flag
// The per-tooltip clear only HIDES line FontStrings (sets +0xC8 = 0)
// and clears the left text; right-column text buffers are left intact.
// So a shift must decide a cell's content by its actually-shown flag
// (+0xC8), not by whether +0xF0 still holds (stale) text.
OFF_FONTSTRING_VISIBLE = 0xC8, // int — actually-shown flag
// FontString creation primitives used by Tooltip::LinePool to build the
// extra lines entirely in C++ (no addon Lua). Calling conventions and
// offsets mirrored from Script_CreateFontString (0x00773C30),
// Script_SetFontObject (0x0079D1A0 → 0x00770C60), and Script_SetPoint
// (0x007A2540 → 0x00767C70) — see TODO #97's investigation update.
FUN_GAMETOOLTIP_SETUP_LINES = 0x00529650, // vtable slot 9 — the pool scan we co-hook
VAR_SIMPLEFONTSTRING_POOL = 0x00CF4D10, // CSimpleFontString free-list pool (allocator `this`)
VAR_SIMPLEFONTSTRING_CLASS_TAG = 0x00846544, // ".?AVCSimpleFontString@@" (alloc debug tag)
FUN_REGION_POOL_ALLOC = 0x00760450, // __thiscall(pool, zeroInit=0, tag, line=-2) -> raw mem
FUN_SIMPLEFONTSTRING_CTOR = 0x00770D30, // __thiscall(mem, parent, layer, sublayer) -> fs
FUN_REGION_SET_NAME = 0x0076C650, // __thiscall(region, name) — registers _G[name]
FUN_FONTSTRING_SET_FONT = 0x00770C60, // __thiscall(fs + OFF_FONTSTRING_FONT_HOLDER, fontObject)
FUN_REGION_SET_POINT = 0x00767C70, // __thiscall(region+OFF_REGION_ANCHOR, point, relAnchor, relPoint, x, y, flag=1)
OFF_FONTSTRING_FONT_HOLDER = 0xCC, // font-reference sub-object (SetFont `this`)
OFF_FONTSTRING_FONT_OBJECT = 0xD0, // font object pointer (holder + 4)
OFF_REGION_ANCHOR = 0x24, // LayoutFrame anchor sub-object (SetPoint `this` / relativeTo base)
DRAWLAYER_ARTWORK = 2,
FRAMEPOINT_TOPLEFT = 0,
FRAMEPOINT_LEFT = 3,
FRAMEPOINT_RIGHT = 5,
FRAMEPOINT_BOTTOMLEFT = 6,
// FUN_REGION_SET_POINT stores offsets in *internal* coordinates, not
// pixels. Script_SetPoint converts: `internal = pixel * [0x00832A44] /
// ([0x00832A4C] * 1024)` (FUN_0041ae60's factor × input ÷
// (FUN_0041ad70's return × DAT_007ffd68)). Both globals are runtime
// UI-scale floats; passing raw pixels makes offsets ~1000× too large.
VAR_UI_COORD_SCALE_MUL = 0x00832A44, // float numerator
VAR_UI_COORD_SCALE_DIV = 0x00832A4C, // float denominator base
UI_COORD_SCALE_UNIT = 1024, // DAT_007ffd68
// "Currently displayed thing" state fields on a GameTooltip frame
// instance. Each Set* path writes one of these (and zero or two
// others), and the per-tooltip Clear at FUN_00530050 zeroes all of
@@ -106,11 +221,32 @@ enum Offsets {
// (gameobject GUID) at 0x0052AA52 / 0x0052AA59.
// Only call site is the in-world hover handler
// FUN_00492890; no Lua `SetGameObject` method.
// - Clear (0x00530050) zeroes all of them.
OFF_TOOLTIP_ITEM_GUID_LO = 0x380, // 0 for SetItemByID (no CGItem)
// - Clear (FUN_GAMETOOLTIP_CLEAR) zeroes unit(+0x368)/GO(+0x370)/
// itemID(+0x398)/spell(+0x39c) — but NOT the item GUID +0x380/+0x384.
// That omission means the item GUID goes stale across a switch to a
// tooltip that shows no item (SetUnitAura, SetEquipmentSet, …), so
// GameTooltip:GetItem would resolve the *previous* item. We co-hook
// the clear (Tooltip::ClearItemGuid) to zero +0x380/+0x384 too, so
// every fresh tooltip resets it and only the item builder re-sets it.
// All 14 Set*/builder paths route through this clear, so the co-hook
// covers them uniformly. __fastcall(self).
FUN_GAMETOOLTIP_CLEAR = 0x00530050,
OFF_TOOLTIP_ITEM_GUID_LO = 0x380, // 0 for SetItemByID (no CGItem); stale-safe via the clear co-hook
OFF_TOOLTIP_ITEM_GUID_HI = 0x384,
OFF_TOOLTIP_ITEM_ID = 0x398,
OFF_TOOLTIP_SPELL_ID = 0x39C,
// Auction/compare descriptor. Script_GameTooltip_SetAuctionItem
// (0x00535810) has no CGItem instance, so it stashes the listing's
// random-property (suffix) id at tooltip+0x424 and marks the
// compare-descriptor valid by writing the builder's compare flag to
// this[0x110] (tooltip+0x440) = 1 (SetAuctionItem passes param_6=1;
// every other Set* path passes 0, and the builder writes it on each
// param_7==0 build — so +0x440 is a reliable per-build gate, not
// stale). This is 1.12's analog of 3.3.5's `tooltip[0x130]`-gated
// `piVar5[0x2d]` random-property read in GameTooltip:GetItem. GetItem
// reads +0x424 (only when +0x440 is set) to put the suffix in the link.
OFF_TOOLTIP_COMPARE_FLAG = 0x440,
OFF_TOOLTIP_COMPARE_SUFFIX = 0x424,
// Unit GUID written by the inner unit-tooltip builder
// (FUN_00529FE0) when `tooltip:SetUnit(token)` resolves the token
// to a non-zero GUID. Cleared by the same `FUN_00530050` clear
@@ -406,7 +542,99 @@ enum Offsets {
// addon's CHAT_MSG_* OnEvent.
FUN_CHAT_DISPATCH = 0x0049A870,
// Per-player inventory manager lives at this offset on the player object.
// +0x00 = u32 slot count (OFF_INVMGR_SLOT_COUNT), +0x04 = u64* GUID
// array (see OFF_INVMGR_GUID_ARRAY below for the slot-range map).
OFF_PLAYER_INVENTORY_MANAGER = 0x1D38,
OFF_INVMGR_SLOT_COUNT = 0x00,
// --- Descriptor-field observer system --------------------------------
// The engine's generic "watch a descriptor field range for changes"
// mechanism (nodes tagged "__AUCMirrorHandler__", 0x30 bytes). The
// registrar attaches a node to the object's per-field observer anchors;
// each node keeps a private MIRROR of the watched bytes, seeded from
// the live value at registration. During SMSG_UPDATE_OBJECT processing
// the dispatcher FUN_00465570 memcmps live vs mirror and invokes the
// callback only on a real change (the mirror is then re-synced by
// FUN_004667A0). Firing engine events from the callback is sanctioned —
// the engine's own bag observer does exactly that.
//
// Registrar — __fastcall(int bank /*ecx*/, uint32_t fieldOffset /*edx*/,
// uint32_t guidLo, uint32_t guidHi, int size, const void *callback,
// void *userArg1, void *userArg2). `guid` selects the watched OBJECT
// (player, item, container, …); `bank` selects its field bank
// (1 = ITEM, 2 = CONTAINER, 4 = PLAYER — the engine's per-bag
// manager FUN_004F91A0 registers bank-2 observers on each equipped
// bag's CGContainer); `fieldOffset` is BANK-relative (container
// SLOT_1 = 8, not its descriptor-absolute 0x20). The engine's
// inventory setup FUN_004F8CC0 registers the player's bag/backpack/
// bank/keyring GUID ranges this way — but NOT equipment.
FUN_DESC_OBSERVER_REGISTER = 0x00467E70,
// Unregister — __fastcall(int bank /*ecx*/, uint32_t fieldOffset
// /*edx*/, uint32_t guidLo, uint32_t guidHi, const void *callback,
// void *userArg1). Removes the node matching (callback, userArg1)
// from that object+field's observer list; harmless no-op when
// nothing matches. What FUN_004F91A0 calls for an unequipped bag.
FUN_DESC_OBSERVER_UNREGISTER = 0x00467FB0,
// Observer callback ABI (verified from the dispatcher's call site at
// 0x004655DC + the engine callback FUN_004F8DB0's RET 0x10):
// int __fastcall cb(uint32_t fieldOffset /*ecx, as registered*/,
// uint32_t size /*edx*/,
// uint32_t guidLo, uint32_t guidHi,
// const uint32_t *oldValue /*the node's mirror*/,
// void *userArg1) → return 1, callee cleans 0x10.
// The live (new) value is NOT passed — read it from the object (the
// engine's bag callback reads the invMgr GUID array).
// The engine's inventory observer setup — registers the bag-slot
// GUID-field observers above, once per enter-world (sole caller is the
// enter-world initializer FUN_004908C0, latched by DAT_00B4B424).
// Player::Equipment co-hooks it to register the equipment-slot
// observers the engine never installs; the co-hook inherits the exact
// engine timing + lifetime (nodes die with the player object).
FUN_INV_OBSERVER_SETUP = 0x004F8CC0,
// PLAYER_FIELD_INV_SLOT_HEAD — first of the 19 equipment-slot u64 GUID
// fields in the player descriptor (0x4A8..0x538, stride 8; 0x540 is
// the first equipped-bag slot). 0-based slot = (offset - 0x4A8) >> 3;
// Lua inventory slot (GetInventoryItemLink etc.) = that + 1.
OFF_DESC_PLAYER_EQUIP_FIRST = 0x4A8,
DESC_PLAYER_EQUIP_SLOTS = 19,
DESC_OBSERVER_BANK_PLAYER = 4,
// ITEM_FIELD_DURABILITY, ITEM-bank-relative: descriptor-absolute
// +0xA0 (field index 0x28, which counts the 6 OBJECT fields) minus
// the 0x18-byte OBJECT prefix. u32.
//
// DEAD END — bank-1 (ITEM field) observers register without error but
// are NEVER invoked: item values-updates demonstrably arrive
// (verified by probing the values pre-pass FUN_00465330) and
// player-bank observers fire in the same session, yet item-bank
// callbacks stay silent. Registration arithmetic was fully verified
// (bank base FUN_00465690(1)=0x18, next-bank walk FUN_004656E0
// item: 0→1→2, anchors match dispatch), so the suspect is the
// registrar's internal wrapper lookup (FUN_00464890 — the observer
// registry, NOT the object manager) silently no-op'ing for item
// objects. UPDATE_INVENTORY_DURABILITY is instead backed by
// FUN_INVENTORY_ALERTS_RECOMPUTE below. Constants kept for the
// investigation trail.
DESC_OBSERVER_BANK_ITEM = 1,
OFF_DESC_ITEM_DURABILITY = 0x88,
// The engine's inventory-alerts recompute — walks the equipment
// slots, reads each item's ITEM_FIELD_DURABILITY / MAXDURABILITY /
// broken flag from the descriptor, fills the per-slot alert table at
// [DAT_00B71F60], and UNCONDITIONALLY fires UPDATE_INVENTORY_ALERTS
// (event 501) at the end. The engine calls it whenever an owned
// item's fields change (the item post-update handler FUN_005D9A90 —
// which also fires UNIT_INVENTORY_CHANGED and the bag-update chain —
// routes every item values-update through it) plus equip and
// enter-world paths. That makes it the engine's own "durability may
// have changed" signal: Player::Equipment co-hooks it and diffs a
// {guid, durability} snapshot per slot to back
// UPDATE_INVENTORY_DURABILITY. void __fastcall(), no args.
FUN_INVENTORY_ALERTS_RECOMPUTE = 0x004C7EE0,
// ItemMgr::GetItemBySlot — __thiscall(this, slot) → CGItem* (NULL if empty).
// Slot is the engine's linearized slot index, not bagID/slot tuple.
FUN_ITEMMGR_GET_ITEM_BY_SLOT = 0x006228A0,
@@ -734,12 +962,231 @@ enum Offsets {
// `SMSG_SPELL_DELAYED` handler — `int __stdcall(uint32_t *opCode,
// CDataStore *packet)`. Body: `guid(u64), delayMs(u32)`. The server
// only sends this to the affected caster, so in practice it's always
// the local player's cast pushback (taking damage mid-cast). `Spell::
// Cast` co-hooks it to extend the tracked cast's end time so the bar
// reflects pushback. Mirrored from nampower's SpellDelayedHook.
// only sends this to the affected caster — empirically confirmed on
// Turtle (melee-pushback test: only the victim's client received it) —
// so remote units' pushback is NOT recoverable client-side; only the
// local player's cast pushback is trackable. `Spell::Cast` co-hooks it
// to extend the tracked cast's end time so the bar reflects pushback.
// Mirrored from nampower's SpellDelayedHook.
FUN_SPELL_DELAYED = 0x006E74F0,
// `MSG_CHANNEL_START` handler — `int __stdcall(uint32_t *opCode,
// CDataStore *packet)`. Body: `spellId(u32), durationMs(u32)`. Sent
// only to the caster when their channel begins, with the server-
// authoritative duration. `Spell::Cast` co-hooks it to stamp the
// player's channel — required for cast-then-channel spells (Mind
// Control: 3 s cast then 60 s channel), whose SMSG_SPELL_START only
// covers the cast phase. Address + layout mirrored from nampower's
// SpellChannelStartHandlerHook (they hook it too — accepted co-hook
// collision, per-channel-start frequency).
FUN_SPELL_CHANNEL_START = 0x006E7550,
// `SMSG_SPELL_FAILED_OTHER` handler — `int __stdcall(uint32_t *opCode,
// CDataStore *packet)`, same shape as FUN_SPELL_DELAYED. Body:
// `casterGuid(u64, plain), spellId(u32)`. In (v)mangos cores this is
// broadcast to observers when a started cast aborts (interrupt, death,
// movement, fizzle) — but Turtle's core does NOT send it (verified
// in-game: interrupts arrive with neither this nor SMSG_SPELL_FAILURE;
// observers learn of the abort via SuperWoW instead — see
// FUN_UNIT_CLEAR_CASTING_SPELL). `Spell::Cast` co-hooks it anyway as a
// backstop for servers that do send it, invalidating the matching
// remote-cast cache entry (and the player's own server-stamped chained
// recast). Address + packet layout mirrored from nampower's
// SpellFailedOtherHandlerHook.
FUN_SPELL_FAILED_OTHER = 0x006E8E40,
// `SMSG_SPELL_FAILURE` handler — same `int __stdcall(uint32_t *opCode,
// CDataStore *packet)` shape. Body: `casterGuid(u64, plain),
// spellId(u32), result(u8)` — a superset of FAILED_OTHER's with a
// trailing result byte. Decompiled at 0x006E8D80: resolves the caster
// unit (FUN_00468460 typemask 8) and stops its cast visual via the SAME
// unit methods FAILED_OTHER's handler calls (FUN_0060d040 +
// FUN_00614150), plus extra channel/visual cleanup. Servers derived
// from (v)mangos broadcast BOTH packets from Spell::SendInterrupted,
// but which one a given core actually emits per abort path varies —
// `Spell::Cast` co-hooks this one alongside FAILED_OTHER so a remote
// interrupt evicts the cast cache regardless of which sibling arrives.
// nampower calls this SpellFailedHandler (offsets.hpp) but doesn't
// hook it.
FUN_SPELL_FAILURE = 0x006E8D80,
// `CGUnit_C::ClearCastingSpell` — `__thiscall void(CGUnit *unit,
// int spellID, char notify, char cleanup)` (nampower names the offset
// but never hooks it). THE engine choke point for "this unit stopped
// casting": gated on `spellID == [unit+0xC8C]` (the unit's live
// current-cast spellID, set by SMSG_SPELL_START processing), it zeros
// that field and clears the casting flag (bit 0x400 at [unit+0xD58]).
// Ten callers converge here: both failure-packet handlers, the
// SPELL_GO handler (natural completion), the movement handler
// (0x006018f0 — moving units aren't casting), the health-diff handler
// (0x006046f0, death), the unit animation-event dispatcher
// (0x0060e0a0), the local cancel path (0x006e7040, sends
// CMSG_CANCEL_CAST), and Spell_C_SpellFailed. Hooking THIS instead of
// individual packets makes remote-cast eviction independent of which
// packet the server actually sends — load-bearing on Turtle, whose
// core does NOT broadcast SMSG_SPELL_FAILURE / SMSG_SPELL_FAILED_OTHER
// on interrupt (verified: neither handler fires while the victim's
// cast visual demonstrably stops).
FUN_UNIT_CLEAR_CASTING_SPELL = 0x0060D040,
// CGUnit field: the unit's current-cast spellID (0 = not casting).
// The regular-cast analog of OFF_UNIT_FIELD_CHANNEL_SPELL — but a
// plain CGUnit member, NOT a descriptor UpdateField (client-derived
// from SMSG_SPELL_START, not server-broadcast state).
OFF_UNIT_CAST_SPELL = 0xC8C,
// NetClient message dispatch — `__thiscall void(void *conn, uint32_t
// arg, CDataStore *packet)`. Reads the u16 opcode off the packet
// cursor, then invokes `[conn + opcode*4 + 0x74]` (the handler table
// FUN_005ab650 / FUN_00537a60 register into; per-handler userParam
// parallel table at +0xD64). EVERY server message the client processes
// funnels through here (the raw-ingress sibling at 0x00537B10 only
// special-cases 0x1DD compressed-moves before queueing into this).
// Useful as a temporary whole-protocol sniff point: co-hook, peek the
// 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,
@@ -947,6 +1394,7 @@ enum Offsets {
OBJ_TYPE_ITEM = 2,
OBJ_TYPE_CONTAINER = 4,
OBJ_TYPE_UNIT = 8,
OBJ_TYPE_PLAYER = 0x10, // what the engine's bag observer FUN_004F8DB0 passes
OBJ_TYPE_GAMEOBJECT = 0x20, // 1<<5; passed by FUN_0052AA20 (hover-tooltip populator).
// `CGObject::GetName` — returns the display-name `const char *` for
@@ -1045,6 +1493,13 @@ enum Offsets {
// descriptor bytes for a worn-and-bound item.
OFF_DESCRIPTOR_FLAGS = 0x3C,
ITEM_FLAG_SOULBOUND = 0x01,
// Within the descriptor, the item's random-property index
// (ITEM_FIELD_RANDOM_PROPERTIES_ID) is at +0x98 — this is the live
// instance's suffix, indexing ItemRandomProperties.dbc the same way
// StatAccum::ApplyRandomSuffix expects. Derived from the item-tooltip
// builder's `[descriptor + 0x26*4]` read (FUN_0052B650 @ ~0x52b7xx),
// which feeds it straight into `ItemRandomProperties[idx]`.
OFF_DESCRIPTOR_RANDOM_PROPERTY = 0x98,
// Bit 2 — instance is "unlocked", i.e. the lock referenced by
// `ItemStats.LockID` has been opened (rogue Pick Lock, key item,
// etc.). For unlockable items, the engine gates the
@@ -1729,6 +2184,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
@@ -1761,6 +2225,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
@@ -1797,7 +2279,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
@@ -2275,10 +2768,10 @@ enum Offsets {
// walks the player's slot data, repopulates `VAR_QUEST_LOG_ENTRIES`
// / `VAR_QUEST_LOG_ENTRY_COUNT`, then fires QUEST_LOG_UPDATE.
//
// Hooked by `Quest::Accepted` to snapshot the log pre-/post-rebuild
// and fire `QUEST_ACCEPTED(logIndex, questID)` for the diff. Single
// quiet target in the `0x004DExxx` quest region — no known DLL
// collisions.
// Hooked by `Quest::LogEvents` to snapshot the log pre-/post-rebuild
// and fire `QUEST_ACCEPTED(logIndex, questID)` / `QUEST_REMOVED(questID)`
// for the diff. Single quiet target in the `0x004DExxx` quest region —
// no known DLL collisions.
FUN_QUEST_LOG_REBUILD = 0x004DE510,
// Player and pet spellbooks — flat int32 arrays indexed by 0-based slot.
@@ -2707,6 +3200,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
@@ -2969,6 +3463,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
@@ -3397,23 +3900,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
@@ -4078,6 +4573,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`
@@ -4102,6 +4610,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
@@ -4111,6 +4631,13 @@ enum Offsets {
// stat auras. Parallel [3] array to APPLY_AURA_NAME / MISC_VALUE.
OFF_SPELL_RECORD_EFFECT_BASE_POINTS = 0x130, // int32[3]
// EffectBaseDice[3] — added to EffectBasePoints for the effect's fixed
// magnitude (mangos `CalculateSimpleValue = BasePoints + BaseDice`).
// For every item-stat aura BaseDice == 1, so value = BasePoints + 1;
// reading it directly matches the engine exactly. Verified at field 67
// (Effect@61, DieSides@64, BaseDice@67, BasePoints@76) against 7468.
OFF_SPELL_RECORD_EFFECT_BASE_DICE = 0x10C, // int32[3]
// Per-effect EffectRadiusIndex[3] → SpellRadius.dbc. Verified by
// FUN_006e6350, which reads spellRec[+0x160] (effect 0) and
// spellRec[+0x164] (effect 1) as radius indices. 0 = effect has no
@@ -4135,9 +4662,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:
@@ -4187,6 +4738,80 @@ enum Offsets {
FUN_GOSSIP_SELECT_ACTIVE_QUEST = 0x004E2600,
FUN_SCRIPT_CLOSE_GOSSIP = 0x004E2B20,
// Current gossip NPC GUID (u64). Set by the SMSG_GOSSIP_MESSAGE
// handler, cleared by the gossip-close worker at 0x004E1FA0 (which
// then fires GOSSIP_CLOSED, event 0x12B). Non-zero = a gossip
// session is live — the gate that keeps `C_GossipInfo` from serving
// the previous NPC's stale arrays/text after close.
VAR_GOSSIP_NPC_GUID = 0x00BC3F58,
// --- Quest-greeting state (SMSG_QUESTGIVER_QUEST_LIST) -------------
// Vanilla's SECOND questgiver path: NPCs without a gossip menu send
// SMSG_QUESTGIVER_QUEST_LIST, whose handler (FUN_005dbb10) fills a
// storage entirely separate from the gossip arrays and fires
// QUEST_GREETING (event 300 = 0x12C) — the QuestFrameGreetingPanel
// ("Current Quests" / "Available Quests"). Vanilla Lua reads it via
// GetGreetingText / Get{Available,Active}Title / etc.; C_GossipInfo
// merges it in so modern-addon ports work on greeting NPCs too.
//
// Shared quest-session state, written by the panel-begin helper
// FUN_00500bd0(panelType, textID, guidLo, guidHi, ...):
// VAR_QUESTGIVER_GUID — u64, the current questgiver. Cleared by
// the session-close worker FUN_00501130 (fires QUEST_FINISHED,
// event 0x130). Non-zero = quest session live.
// VAR_QUEST_PANEL_TYPE — i32 panel: 0 greeting, 1 detail,
// 2 progress, 3 reward. begin(0) also zeroes both greeting
// arrays + counts + the text buffers.
VAR_QUESTGIVER_GUID = 0x00BE0810,
VAR_QUEST_PANEL_TYPE = 0x00BE0818,
// GUID the greeting arrays were filled for — written ONLY by the
// SMSG_QUESTGIVER_QUEST_LIST handler. The greeting arrays are valid
// iff this equals VAR_QUESTGIVER_GUID (and that is non-zero);
// otherwise they're leftovers from an older NPC (e.g. the current
// quest session came from a gossip-menu quest click, which begins
// at panel 1 without ever clearing the greeting arrays).
VAR_GREETING_NPC_GUID = 0x00C4D740,
// Greeting text (0x200 inline, $N/$C-expanded) — what vanilla's
// Script_GetGreetingText pushes.
VAR_QUEST_GREETING_TEXT = 0x00BDE068,
// The two greeting quest arrays: 32 entries, stride 0x4C =
// { u32 questID @ +0, i32 level @ +4, char title[0x40] @ +8,
// u32 extra @ +0x48 (available: status==0 flag; active: always
// 0 — the appender FUN_00500e90 never stores the status, see
// the scratch arrays below for isComplete) }.
// Appenders: FUN_00500e40 (available), FUN_00500e90 (active).
// Readers: Script_Get{Available,Active}{Title,Level} @ 0x00501AC0/
// 0x00501B30/0x00501BA0/0x00501C20.
VAR_GREETING_AVAILABLE_ENTRIES = 0x00BDFE60,
VAR_GREETING_ACTIVE_ENTRIES = 0x00BDE690,
GREETING_QUESTS_STRIDE = 0x4C,
GREETING_QUESTS_MAX = 32,
OFF_GREETING_QUEST_ID = 0x000,
OFF_GREETING_QUEST_LEVEL = 0x004,
OFF_GREETING_QUEST_TITLE = 0x008,
VAR_GREETING_AVAILABLE_COUNT = 0x00BE0834,
VAR_GREETING_ACTIVE_COUNT = 0x00BE0838,
// Scratch arrays the SMSG_QUESTGIVER_QUEST_LIST handler fills in
// packet order (32 × u32 each, zeroed per packet): questIDs and the
// per-quest dialog status. Status 3|4 = active (same sentinel as
// the gossip quest rows), 4 = complete/turn-in-ready. The canonical
// greeting arrays drop the status, so `isComplete` for an active
// greeting quest is recovered by questID lookup here.
VAR_GREETING_SCRATCH_IDS = 0x00C4D2B0,
VAR_GREETING_SCRATCH_STATUS = 0x00C4D340,
// Engine greeting-quest selectors — the workers vanilla's
// Script_Select{Available,Active}Quest call: `__fastcall(int
// idx0Based)`, index straight into the corresponding greeting
// array. They validate VAR_QUESTGIVER_GUID internally and send
// CMSG_QUESTGIVER_{QUERY_QUEST,COMPLETE_QUEST}.
FUN_GREETING_SELECT_AVAILABLE_QUEST = 0x005012A0,
FUN_GREETING_SELECT_ACTIVE_QUEST = 0x00501320,
// CDataStore primitive readers — `__thiscall void(this, void *out)`.
// Read at the current cursor (`+0x14`) and advance it by the value
// size. We call these directly from `Unit::MirrorTimer` to peek
@@ -4356,4 +4981,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,
};
+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
+33 -4
View File
@@ -492,6 +492,25 @@ void PushFromCache(void *L, const uint8_t *unit,
c.casterGuid);
}
// 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 +534,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 +559,8 @@ bool PushNthCacheFallback(void *L, const uint8_t *unit, int oneBasedIndex,
Filter filter, bool playerOnly) {
if (unit == nullptr)
return false;
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 +589,8 @@ void AppendCacheFallbacks(void *L, const uint8_t *unit, Filter filter,
bool playerOnly, int outerIdx, int &nextKey) {
if (unit == nullptr)
return;
if (DescriptorHasVisibleAura(unit))
return; // descriptor authoritative — see DescriptorHasVisibleAura
const uint64_t guid = UnitGuid(unit);
if (guid == 0)
return;
+10 -1
View File
@@ -15,6 +15,7 @@
#include "Source.h"
#include "ComboDuration.h"
#include "Data.h"
#include "Game.h"
#include "Offsets.h"
@@ -203,7 +204,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) {
+29 -26
View File
@@ -30,6 +30,16 @@ constexpr int kMaxDepth = 64;
// ---------------- encode ----------------
// tinycbor's grow-and-retry contract: on buffer overflow the encoder
// switches to counting mode (end = NULL, accumulates bytes_needed) and
// encoding must CONTINUE so the final tally is complete — a nested
// container's tally only reaches the parent via close_container. So
// CborErrorOutOfMemory is not a failure here; the caller reads
// cbor_encoder_get_extra_bytes_needed() afterwards, resizes, retries.
bool EncOk(CborError err) {
return err == CborNoError || err == CborErrorOutOfMemory;
}
bool EncodeValue(void *L, int idx, CborEncoder *enc, int depth);
bool IsLuaArray(void *L, int idx, int &outLen) {
@@ -73,8 +83,8 @@ bool EncodeTable(void *L, int idx, CborEncoder *enc, int depth) {
int arrayLen = 0;
if (IsLuaArray(L, idx, arrayLen)) {
CborEncoder arr;
if (cbor_encoder_create_array(enc, &arr,
static_cast<size_t>(arrayLen)) != CborNoError)
if (!EncOk(cbor_encoder_create_array(enc, &arr,
static_cast<size_t>(arrayLen))))
return false;
for (int i = 1; i <= arrayLen; ++i) {
Game::Lua::PushNumber(L, static_cast<double>(i));
@@ -85,12 +95,12 @@ bool EncodeTable(void *L, int idx, CborEncoder *enc, int depth) {
}
Game::Lua::SetTop(L, -2);
}
return cbor_encoder_close_container(enc, &arr) == CborNoError;
return EncOk(cbor_encoder_close_container(enc, &arr));
}
const int pairs = CountTablePairs(L, idx);
CborEncoder map;
if (cbor_encoder_create_map(enc, &map, static_cast<size_t>(pairs)) != CborNoError)
if (!EncOk(cbor_encoder_create_map(enc, &map, static_cast<size_t>(pairs))))
return false;
Game::Lua::PushNil(L);
while (Game::Lua::Next(L, idx) != 0) {
@@ -101,16 +111,16 @@ bool EncodeTable(void *L, int idx, CborEncoder *enc, int depth) {
}
Game::Lua::SetTop(L, -2);
}
return cbor_encoder_close_container(enc, &map) == CborNoError;
return EncOk(cbor_encoder_close_container(enc, &map));
}
bool EncodeValue(void *L, int idx, CborEncoder *enc, int depth) {
switch (Game::Lua::Type(L, idx)) {
case Game::Lua::TYPE_NIL:
return cbor_encode_null(enc) == CborNoError;
return EncOk(cbor_encode_null(enc));
case Game::Lua::TYPE_BOOLEAN:
return cbor_encode_boolean(enc,
Game::Lua::ToBoolean(L, idx) != 0) == CborNoError;
return EncOk(cbor_encode_boolean(enc,
Game::Lua::ToBoolean(L, idx) != 0));
case Game::Lua::TYPE_NUMBER: {
const double n = Game::Lua::ToNumber(L, idx);
// Whole numbers in [-2^53, 2^53] go out as integers so
@@ -118,17 +128,17 @@ bool EncodeValue(void *L, int idx, CborEncoder *enc, int depth) {
if (std::isfinite(n) && n == std::floor(n) &&
n >= -9007199254740992.0 && n <= 9007199254740992.0) {
if (n >= 0.0)
return cbor_encode_uint(enc,
static_cast<uint64_t>(n)) == CborNoError;
return cbor_encode_negative_int(enc,
static_cast<uint64_t>(-n)) == CborNoError;
return EncOk(cbor_encode_uint(enc,
static_cast<uint64_t>(n)));
return EncOk(cbor_encode_negative_int(enc,
static_cast<uint64_t>(-n)));
}
return cbor_encode_double(enc, n) == CborNoError;
return EncOk(cbor_encode_double(enc, n));
}
case Game::Lua::TYPE_STRING: {
const char *s = Game::Lua::ToString(L, idx);
const unsigned int n = Game::Lua::StrLen(L, idx);
return cbor_encode_text_string(enc, s, n) == CborNoError;
return EncOk(cbor_encode_text_string(enc, s, n));
}
case Game::Lua::TYPE_TABLE:
return EncodeTable(L, idx, enc, depth);
@@ -143,22 +153,15 @@ int __fastcall Script_SerializeCBOR(void *L) {
return 0;
}
// tinycbor encodes into a fixed-size buffer; if it overflows, it
// tracks the extra bytes needed so we can resize and retry.
// tinycbor encodes into a fixed-size buffer; on overflow it keeps
// encoding in counting mode (EncOk treats OutOfMemory as success) so
// the extra-bytes tally is complete, then we resize and retry once.
std::vector<uint8_t> buf(256);
for (;;) {
CborEncoder enc;
cbor_encoder_init(&enc, buf.data(), buf.size(), 0);
if (!EncodeValue(L, 1, &enc, 0)) {
// If it was a buffer-size issue we'll catch it via the
// extra-bytes-needed check below; otherwise this is a
// genuine "value not representable" failure.
const size_t extra = cbor_encoder_get_extra_bytes_needed(&enc);
if (extra == 0)
return 0;
buf.resize(buf.size() + extra);
continue;
}
if (!EncodeValue(L, 1, &enc, 0))
return 0; // genuine "value not representable" failure
const size_t extra = cbor_encoder_get_extra_bytes_needed(&enc);
if (extra > 0) {
buf.resize(buf.size() + extra);
+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
+159 -11
View File
@@ -22,6 +22,24 @@
// SMSG_GOSSIP_MESSAGE handler at `0x004E26E0` and cleared each open.
// We read the same shape the engine's own `Script_GetGossip*` reads.
//
// Vanilla has a SECOND questgiver path this namespace also covers:
// NPCs without a gossip menu send SMSG_QUESTGIVER_QUEST_LIST instead —
// the QUEST_GREETING event and the QuestFrameGreetingPanel ("Current
// Quests" / "Available Quests") — whose data lands in a storage
// entirely separate from the gossip arrays (see the VAR_GREETING_*
// block in Offsets.h). Retail keeps the two APIs disjoint, but for
// backporting it's far more useful for `C_GossipInfo` to serve
// whichever questgiver session is actually live:
// - gossip session live (VAR_GOSSIP_NPC_GUID != 0) → gossip arrays
// - greeting session live (VAR_QUESTGIVER_GUID != 0
// and == VAR_GREETING_NPC_GUID) → greeting arrays
// - neither → empty / ""
// The GUID gates double as staleness protection: both storages persist
// after their frames close (the engine never clears them until the
// next open), so ungated reads would report the previous NPC.
// `GetOptions`/`GetNumOptions` stay gossip-only — greetings have no
// options.
//
// Missing modern fields — vanilla 1.12's server simply never sends
// them, so there's nothing to surface:
// - `rewards`, `spellID` (post-vanilla; merged in with the
@@ -46,6 +64,57 @@ namespace {
using EngineScriptFn = int(__fastcall *)(void *L);
uint64_t ReadU64(uintptr_t va) {
return *reinterpret_cast<const uint64_t *>(va);
}
// A gossip session is live from SMSG_GOSSIP_MESSAGE until the close
// worker (0x004E1FA0) zeroes the GUID and fires GOSSIP_CLOSED.
bool GossipSessionActive() {
return ReadU64(Offsets::VAR_GOSSIP_NPC_GUID) != 0;
}
// A quest-greeting session is live while the quest-session GUID is set
// AND the greeting arrays were filled for that same NPC. The second
// check matters because a gossip-menu quest click opens a quest session
// at panel 1 without ever touching the greeting arrays — without it,
// leftovers from an older greeting NPC would leak through.
bool GreetingSessionActive() {
const uint64_t giver = ReadU64(Offsets::VAR_QUESTGIVER_GUID);
return giver != 0 && giver == ReadU64(Offsets::VAR_GREETING_NPC_GUID);
}
const uint8_t *GreetingEntry(bool active, int slot) {
const uintptr_t base = active ? Offsets::VAR_GREETING_ACTIVE_ENTRIES
: Offsets::VAR_GREETING_AVAILABLE_ENTRIES;
return reinterpret_cast<const uint8_t *>(
base + static_cast<uintptr_t>(slot) * Offsets::GREETING_QUESTS_STRIDE);
}
int GreetingCount(bool active) {
const int count = *reinterpret_cast<const int *>(
active ? Offsets::VAR_GREETING_ACTIVE_COUNT
: Offsets::VAR_GREETING_AVAILABLE_COUNT);
return (count < 0) ? 0
: (count > Offsets::GREETING_QUESTS_MAX) ? Offsets::GREETING_QUESTS_MAX
: count;
}
// The greeting arrays drop the per-quest dialog status, but the packet
// handler's scratch arrays (packet order, zeroed per packet) keep it —
// status 4 = complete/turn-in-ready, same sentinel as the gossip rows.
bool GreetingQuestIsComplete(uint32_t questID) {
const auto *ids = reinterpret_cast<const uint32_t *>(
Offsets::VAR_GREETING_SCRATCH_IDS);
const auto *status = reinterpret_cast<const uint32_t *>(
Offsets::VAR_GREETING_SCRATCH_STATUS);
for (int i = 0; i < Offsets::GREETING_QUESTS_MAX; ++i) {
if (ids[i] == questID)
return status[i] == 4;
}
return false;
}
const uint8_t *OptionEntry(int slot) {
return reinterpret_cast<const uint8_t *>(
static_cast<uintptr_t>(Offsets::VAR_GOSSIP_OPTIONS) +
@@ -79,14 +148,21 @@ bool IsActiveQuest(uint32_t status) {
return status == 3 || status == 4;
}
// `C_GossipInfo.GetText()` — pushes the greeting string the engine
// resolved for the gossip-giver's NPC_TEXT.dbc entry. Mirrors what
// `Script_GetGossipText` does (one inline `lua_pushstring` of the
// fixed buffer at `VAR_GOSSIP_GREETING_TEXT`).
// `C_GossipInfo.GetText()` — the greeting string of whichever
// questgiver session is live: the gossip buffer (what
// `Script_GetGossipText` pushes) for a gossip session, the quest
// greeting buffer (what `Script_GetGreetingText` pushes) for a
// greeting session, "" when neither is open. Both buffers persist
// after close, so the gates keep stale text from the previous NPC
// out.
int __fastcall Script_C_GossipInfo_GetText(void *L) {
const char *text = reinterpret_cast<const char *>(
static_cast<uintptr_t>(Offsets::VAR_GOSSIP_GREETING_TEXT));
Game::Lua::PushString(L, text);
uintptr_t buf = 0;
if (GossipSessionActive())
buf = Offsets::VAR_GOSSIP_GREETING_TEXT;
else if (GreetingSessionActive())
buf = Offsets::VAR_QUEST_GREETING_TEXT;
Game::Lua::PushString(
L, (buf != 0) ? reinterpret_cast<const char *>(buf) : "");
return 1;
}
@@ -108,6 +184,8 @@ int __fastcall Script_C_GossipInfo_GetText(void *L) {
int __fastcall Script_C_GossipInfo_GetOptions(void *L) {
Game::Lua::SetTop(L, 0);
Game::Lua::NewTable(L);
if (!GossipSessionActive())
return 1; // greeting sessions have no options; stale data stays hidden
int outIdx = 0;
for (int slot = 0; slot < Offsets::GOSSIP_OPTIONS_MAX; ++slot) {
@@ -138,6 +216,36 @@ int __fastcall Script_C_GossipInfo_GetOptions(void *L) {
return 1;
}
// Greeting-session variant: walks the pre-split greeting array for the
// wanted side. Same table shape as the gossip rows (questID / title /
// questLevel / isComplete) so consumers can't tell the sources apart.
int PushGreetingQuestList(void *L, bool wantActive) {
const int count = GreetingCount(wantActive);
for (int slot = 0; slot < count; ++slot) {
const uint8_t *entry = GreetingEntry(wantActive, slot);
const uint32_t questID = *reinterpret_cast<const uint32_t *>(
entry + Offsets::OFF_GREETING_QUEST_ID);
Game::Lua::PushNumber(L, static_cast<double>(slot + 1));
Game::Lua::NewTable(L);
Game::Lua::SetFieldNumber(L, "questID",
static_cast<double>(questID));
Game::Lua::SetFieldString(L, "title", reinterpret_cast<const char *>(
entry + Offsets::OFF_GREETING_QUEST_TITLE));
const int32_t level = *reinterpret_cast<const int32_t *>(
entry + Offsets::OFF_GREETING_QUEST_LEVEL);
Game::Lua::SetFieldNumber(L, "questLevel",
static_cast<double>(level));
if (wantActive)
Game::Lua::SetFieldBool(L, "isComplete",
GreetingQuestIsComplete(questID));
Game::Lua::SetTable(L, -3);
}
return 1;
}
// Shared body for `GetAvailableQuests` / `GetActiveQuests`. Both walk
// the same backing array and split by the `status` field — `wantActive`
// inverts the filter.
@@ -145,6 +253,12 @@ int PushQuestList(void *L, bool wantActive) {
Game::Lua::SetTop(L, 0);
Game::Lua::NewTable(L);
if (!GossipSessionActive()) {
if (GreetingSessionActive())
return PushGreetingQuestList(L, wantActive);
return 1; // no questgiver session — empty table, never stale data
}
int outIdx = 0;
for (int slot = 0; slot < Offsets::GOSSIP_QUESTS_MAX; ++slot) {
const uint8_t *entry = QuestEntry(slot);
@@ -189,15 +303,19 @@ int __fastcall Script_C_GossipInfo_GetActiveQuests(void *L) {
int __fastcall Script_C_GossipInfo_GetNumOptions(void *L) {
int count = 0;
for (int slot = 0; slot < Offsets::GOSSIP_OPTIONS_MAX; ++slot) {
if (OptionIndex(OptionEntry(slot)) >= 0)
count += 1;
if (GossipSessionActive()) {
for (int slot = 0; slot < Offsets::GOSSIP_OPTIONS_MAX; ++slot) {
if (OptionIndex(OptionEntry(slot)) >= 0)
count += 1;
}
}
Game::Lua::PushNumber(L, static_cast<double>(count));
return 1;
}
int CountQuests(bool wantActive) {
if (!GossipSessionActive())
return GreetingSessionActive() ? GreetingCount(wantActive) : 0;
int count = 0;
for (int slot = 0; slot < Offsets::GOSSIP_QUESTS_MAX; ++slot) {
const uint8_t *entry = QuestEntry(slot);
@@ -247,6 +365,17 @@ void EngineSelectActiveQuest(int idx0Based) {
fn(idx0Based);
}
// Greeting-session selectors — the workers vanilla's
// Script_Select{Available,Active}Quest call. Index straight into the
// corresponding greeting array (they validate the questgiver GUID
// internally before sending the CMSG).
void EngineSelectGreetingQuest(bool wantActive, int idx0Based) {
auto fn = reinterpret_cast<SelectQuest_t>(
wantActive ? Offsets::FUN_GREETING_SELECT_ACTIVE_QUEST
: Offsets::FUN_GREETING_SELECT_AVAILABLE_QUEST);
fn(idx0Based);
}
// `C_GossipInfo.SelectOption(gossipOptionID[, text])` — wraps vanilla's
// `SelectGossipOption(slotIndex)`. `text` is the boxCoded password,
// passed through to the engine helper which gates on the
@@ -260,6 +389,8 @@ int __fastcall Script_C_GossipInfo_SelectOption(void *L) {
const int target = static_cast<int>(Game::Lua::ToNumber(L, 1));
const char *password =
Game::Lua::IsString(L, 2) ? Game::Lua::ToString(L, 2) : nullptr;
if (!GossipSessionActive())
return 0; // stale option slots must not send a CMSG
for (int slot = 0; slot < Offsets::GOSSIP_OPTIONS_MAX; ++slot) {
const uint8_t *entry = OptionEntry(slot);
@@ -284,7 +415,8 @@ int __fastcall Script_C_GossipInfo_SelectOptionByIndex(void *L) {
return 0;
}
const int slot0Based = static_cast<int>(Game::Lua::ToNumber(L, 1)) - 1;
if (slot0Based < 0 || slot0Based >= Offsets::GOSSIP_OPTIONS_MAX)
if (slot0Based < 0 || slot0Based >= Offsets::GOSSIP_OPTIONS_MAX ||
!GossipSessionActive())
return 0;
EngineSelectOption(slot0Based, nullptr);
return 0;
@@ -301,6 +433,22 @@ int SelectQuestByID(void *L, bool wantActive, const char *errUsage) {
}
const uint32_t target = static_cast<uint32_t>(Game::Lua::ToNumber(L, 1));
if (!GossipSessionActive()) {
if (!GreetingSessionActive())
return 0;
const int count = GreetingCount(wantActive);
for (int slot = 0; slot < count; ++slot) {
const uint32_t questID = *reinterpret_cast<const uint32_t *>(
GreetingEntry(wantActive, slot) +
Offsets::OFF_GREETING_QUEST_ID);
if (questID == target) {
EngineSelectGreetingQuest(wantActive, slot);
return 0;
}
}
return 0;
}
int idx = 0;
for (int slot = 0; slot < Offsets::GOSSIP_QUESTS_MAX; ++slot) {
const uint8_t *entry = QuestEntry(slot);
+401
View File
@@ -0,0 +1,401 @@
// 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/>.
// Shared item-stat accumulation engine — see [src/item/StatAccum.h] for
// the public surface. This file holds the value derivation:
//
// Three sources feed the accumulator:
// 1. The base `ItemStats_C` record — the stored attributes
// (Str/Agi/Sta/Int/Spi, mana/health-on-equip), armor, six resists,
// and (for weapons) DPS derived from the damage/delay fields.
// 2. The item's on-equip spells (`m_spellTrigger == 1`) — vanilla
// (and Turtle's custom gear) store "special" bonuses (crit, hit,
// attack power, spell power/healing, mp5, defense, dodge, parry,
// block, spell crit, flat health/mana, …) as an equip spell whose
// aura effect carries the value, not as a stat slot. We decode those
// auras (the aura → key map is in AddSpellStatAuras).
// 3. The link's random suffix (`item:id:enchant:SUFFIX:unique`) —
// ItemRandomProperties.dbc → SpellItemEnchantment.dbc, whose stat
// suffixes are themselves equip spells (enchant type 3) and whose
// armor suffixes are direct resistance enchants (type 4).
//
// **Values are vanilla-native.** The percent-based stats (crit / hit /
// defense) are reported as the raw vanilla percentage under the modern
// rating key — e.g. Krol Blade's "+1% crit" is
// `ITEM_MOD_CRIT_MELEE_RATING = 1`, NOT the level-scaled rating (13) that
// TBC/Era would show. Vanilla has no rating conversion (it's
// level-dependent and doesn't exist in this client), so a native percent
// is the only honest, level-independent value.
//
// The aura → key map (below) covers the common gear stats. Rarer auras
// (percent attack power, weapon-skill-per-line, damage-taken, procs) are
// intentionally skipped — they either lack a clean single key or aren't
// part of the stat set.
#include "item/StatAccum.h"
#include "Game.h"
#include "Offsets.h"
#include "dbc/Lookup.h"
#include <cstdlib>
#include <cstring>
namespace Item::StatAccum {
namespace {
using GetItemRecord_t = const uint8_t *(__thiscall *)(void *cache, uint32_t itemID,
const uint64_t *guid, void *callback,
void *userData, bool requestIfMissing);
// Base-record m_statType uses the ItemModType enum: 0=mana, 1=health,
// 3=agility, 4=strength, 5=intellect, 6=spirit, 7=stamina (2 is
// deprecated). Returns nullptr for types with no vanilla key.
const char *StatKeyForItemModType(uint32_t statType) {
switch (statType) {
case 0: return "ITEM_MOD_MANA_SHORT";
case 1: return "ITEM_MOD_HEALTH_SHORT";
case 3: return "ITEM_MOD_AGILITY_SHORT";
case 4: return "ITEM_MOD_STRENGTH_SHORT";
case 5: return "ITEM_MOD_INTELLECT_SHORT";
case 6: return "ITEM_MOD_SPIRIT_SHORT";
case 7: return "ITEM_MOD_STAMINA_SHORT";
default: return nullptr;
}
}
// SPELL_AURA_MOD_STAT (29) misc uses the UnitStat enum instead: 0=Str,
// 1=Agi, 2=Sta, 3=Int, 4=Spi (-1 = all stats, not a single key).
// Verified against the "Increased <stat> 01" random-property spells.
const char *StatKeyForUnitStat(int stat) {
switch (stat) {
case 0: return "ITEM_MOD_STRENGTH_SHORT";
case 1: return "ITEM_MOD_AGILITY_SHORT";
case 2: return "ITEM_MOD_STAMINA_SHORT";
case 3: return "ITEM_MOD_INTELLECT_SHORT";
case 4: return "ITEM_MOD_SPIRIT_SHORT";
default: return nullptr;
}
}
// Resistance school index → key. 0 = armor (physical), 1..6 = the magic
// schools in Holy/Fire/Nature/Frost/Shadow/Arcane order.
const char *ResistKey(int school) {
static const char *const kKeys[7] = {
"RESISTANCE0_NAME", "RESISTANCE1_NAME", "RESISTANCE2_NAME",
"RESISTANCE3_NAME", "RESISTANCE4_NAME", "RESISTANCE5_NAME",
"RESISTANCE6_NAME",
};
return (school >= 0 && school <= 6) ? kKeys[school] : nullptr;
}
// Keys in this list are the ones AddByKey ever writes: the base stats +
// armor + six resistances, plus the equip-spell "special" stats. The
// equip-spell keys past the resistances are best-effort names verified
// against a 1.15.x client (crit melee/ranged are confirmed from a TBC
// GetItemStats dump); adjust here if the live client differs.
constexpr const char *kKeys[] = {
"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",
"RESISTANCE0_NAME",
"RESISTANCE1_NAME",
"RESISTANCE2_NAME",
"RESISTANCE3_NAME",
"RESISTANCE4_NAME",
"RESISTANCE5_NAME",
"RESISTANCE6_NAME",
"ITEM_MOD_ATTACK_POWER_SHORT",
"ITEM_MOD_RANGED_ATTACK_POWER_SHORT",
"ITEM_MOD_CRIT_MELEE_RATING",
"ITEM_MOD_CRIT_RANGED_RATING",
"ITEM_MOD_HIT_MELEE_RATING",
"ITEM_MOD_HIT_RANGED_RATING",
"ITEM_MOD_HIT_SPELL_RATING",
"ITEM_MOD_CRIT_SPELL_RATING",
"ITEM_MOD_SPELL_DAMAGE_DONE_SHORT",
"ITEM_MOD_SPELL_HEALING_DONE_SHORT",
"ITEM_MOD_MANA_REGENERATION",
"ITEM_MOD_DEFENSE_SKILL_RATING",
"ITEM_MOD_DODGE_RATING",
"ITEM_MOD_PARRY_RATING",
"ITEM_MOD_BLOCK_RATING",
"ITEM_MOD_BLOCK_VALUE",
};
static_assert(sizeof(kKeys) / sizeof(kKeys[0]) == kCount,
"kCount must match the kKeys list");
long U32(const uint8_t *record, int offset) {
return static_cast<long>(*reinterpret_cast<const uint32_t *>(record + offset));
}
// Folds a spell's stat/resistance auras into `acc`. Used for both the
// item's own on-equip spells and random-suffix equip enchants. The value
// is the fixed-die `EffectBasePoints + EffectBaseDice` (mangos
// CalculateSimpleValue). Percent stats keep their vanilla percentage.
// Auras with no clean stat key are ignored (procs, dummies,
// weapon-skill-per-line, damage-taken, etc.).
void AddSpellStatAuras(Accum *acc, int spellID, long sign) {
if (spellID <= 0)
return;
const uint8_t *sp = DBC::Record(Offsets::VAR_SPELL_RECORDS,
Offsets::VAR_SPELL_RECORD_COUNT,
static_cast<uint32_t>(spellID));
if (sp == nullptr)
return;
auto aura = reinterpret_cast<const int32_t *>(
sp + Offsets::OFF_SPELL_RECORD_EFFECT_APPLY_AURA_NAME);
auto misc = reinterpret_cast<const int32_t *>(
sp + Offsets::OFF_SPELL_RECORD_EFFECT_MISC_VALUE);
auto base = reinterpret_cast<const int32_t *>(
sp + Offsets::OFF_SPELL_RECORD_EFFECT_BASE_POINTS);
auto dice = reinterpret_cast<const int32_t *>(
sp + Offsets::OFF_SPELL_RECORD_EFFECT_BASE_DICE);
// Vanilla "+N Attack Power" spells carry a melee (99) AND a ranged
// (124) aura with the same value; the generic melee key subsumes the
// ranged one, so we emit ranged only for ranged-only spells (scopes).
bool hasMeleeAP = false;
for (int i = 0; i < Offsets::SPELL_RECORD_EFFECT_COUNT; ++i)
if (aura[i] == 99)
hasMeleeAP = true;
for (int i = 0; i < Offsets::SPELL_RECORD_EFFECT_COUNT; ++i) {
const long v = static_cast<long>(base[i]) + static_cast<long>(dice[i]);
switch (aura[i]) {
case 29: // SPELL_AURA_MOD_STAT — misc = UnitStat index
AddByKey(acc, StatKeyForUnitStat(misc[i]), sign * v);
break;
case 34: // SPELL_AURA_MOD_INCREASE_HEALTH — flat +health
AddByKey(acc, "ITEM_MOD_HEALTH_SHORT", sign * v);
break;
case 35: // SPELL_AURA_MOD_INCREASE_ENERGY — misc = power type
if (misc[i] == 0) // mana
AddByKey(acc, "ITEM_MOD_MANA_SHORT", sign * v);
break;
case 22: // SPELL_AURA_MOD_RESISTANCE — misc = school bitmask
for (int school = 0; school <= 6; ++school)
if (misc[i] & (1 << school))
AddByKey(acc, ResistKey(school), sign * v);
break;
case 99: // SPELL_AURA_MOD_ATTACK_POWER
AddByKey(acc, "ITEM_MOD_ATTACK_POWER_SHORT", sign * v);
break;
case 124: // SPELL_AURA_MOD_RANGED_ATTACK_POWER
if (!hasMeleeAP)
AddByKey(acc, "ITEM_MOD_RANGED_ATTACK_POWER_SHORT", sign * v);
break;
case 52: // weapon crit % — TBC surfaces it as melee AND ranged
AddByKey(acc, "ITEM_MOD_CRIT_MELEE_RATING", sign * v);
AddByKey(acc, "ITEM_MOD_CRIT_RANGED_RATING", sign * v);
break;
case 54: // melee/ranged hit %
AddByKey(acc, "ITEM_MOD_HIT_MELEE_RATING", sign * v);
AddByKey(acc, "ITEM_MOD_HIT_RANGED_RATING", sign * v);
break;
case 55: // spell hit %
AddByKey(acc, "ITEM_MOD_HIT_SPELL_RATING", sign * v);
break;
case 57: // SPELL_AURA_MOD_SPELL_CRIT_CHANCE
case 71: // ..._SCHOOL (misc = school mask) — same stat
AddByKey(acc, "ITEM_MOD_CRIT_SPELL_RATING", sign * v);
break;
case 47: // SPELL_AURA_MOD_PARRY_PERCENT
AddByKey(acc, "ITEM_MOD_PARRY_RATING", sign * v);
break;
case 49: // SPELL_AURA_MOD_DODGE_PERCENT
AddByKey(acc, "ITEM_MOD_DODGE_RATING", sign * v);
break;
case 51: // SPELL_AURA_MOD_BLOCK_PERCENT (block chance)
AddByKey(acc, "ITEM_MOD_BLOCK_RATING", sign * v);
break;
case 13: // SPELL_AURA_MOD_DAMAGE_DONE — misc = school mask
if (misc[i] & 0x7E) // any magic school (bits 1..6)
AddByKey(acc, "ITEM_MOD_SPELL_DAMAGE_DONE_SHORT", sign * v);
break;
case 135: // SPELL_AURA_MOD_HEALING_DONE
AddByKey(acc, "ITEM_MOD_SPELL_HEALING_DONE_SHORT", sign * v);
break;
case 85: // SPELL_AURA_MOD_POWER_REGEN (mp5) — misc = power type
if (misc[i] == 0) // mana
AddByKey(acc, "ITEM_MOD_MANA_REGENERATION", sign * v);
break;
case 30: // SPELL_AURA_MOD_SKILL — misc = skill line (95 = Defense)
if (misc[i] == 95)
AddByKey(acc, "ITEM_MOD_DEFENSE_SKILL_RATING", sign * v);
break;
default:
break;
}
}
}
// Decodes the item's own on-equip spells (SpellTrigger == 1) into `acc`.
// On-use (0) and chance-on-hit (2) spells are skipped — they aren't
// passive stats. This is what surfaces crit / AP / spell power / hit /
// mp5 / defense on base items (vanilla stores them as equip spells, not
// stat slots).
void AddEquipSpellStats(Accum *acc, const uint8_t *record, long sign) {
auto ids = 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 (ids[i] != 0 && triggers[i] == 1) // ON_EQUIP
AddSpellStatAuras(acc, static_cast<int>(ids[i]), sign);
}
} // namespace
void Init(Accum *acc) {
for (int i = 0; i < kCount; ++i) {
acc[i].key = kKeys[i];
acc[i].value = 0;
}
}
void AddByKey(Accum *acc, const char *key, long delta) {
if (key == nullptr || delta == 0)
return;
for (int i = 0; i < kCount; ++i)
if (std::strcmp(acc[i].key, key) == 0) {
acc[i].value += delta;
return;
}
}
const uint8_t *FetchRecord(uint32_t itemID) {
auto fn = reinterpret_cast<GetItemRecord_t>(Offsets::FUN_DBCACHE_ITEMSTATS_GET_RECORD);
auto *cache = reinterpret_cast<void *>(Offsets::VAR_ITEMDB_CACHE);
const uint64_t zeroGuid = 0;
return fn(cache, itemID, &zeroGuid, nullptr, nullptr, false);
}
double ComputeDPS(const uint8_t *record) {
if (record == nullptr)
return 0.0;
const uint32_t delayMs = *reinterpret_cast<const uint32_t *>(
record + Offsets::OFF_ITEMSTATS_DELAY);
if (delayMs == 0)
return 0.0;
auto *dmgMin = reinterpret_cast<const float *>(
record + Offsets::OFF_ITEMSTATS_DAMAGE_MIN);
auto *dmgMax = reinterpret_cast<const float *>(
record + Offsets::OFF_ITEMSTATS_DAMAGE_MAX);
double avgTotal = 0.0;
for (int i = 0; i < Offsets::ITEMSTATS_DAMAGE_SLOT_COUNT; ++i)
avgTotal += (static_cast<double>(dmgMin[i]) +
static_cast<double>(dmgMax[i])) * 0.5;
if (avgTotal <= 0.0)
return 0.0;
return avgTotal / (static_cast<double>(delayMs) / 1000.0);
}
void ApplyRandomSuffix(Accum *acc, int suffixID, long sign) {
if (suffixID <= 0)
return;
const uint8_t *rp = DBC::Record(Offsets::VAR_ITEMRANDOMPROP_RECORDS,
Offsets::VAR_ITEMRANDOMPROP_COUNT,
static_cast<uint32_t>(suffixID));
if (rp == nullptr)
return;
auto enchants = reinterpret_cast<const uint32_t *>(
rp + Offsets::OFF_ITEMRANDOMPROP_ENCHANT);
for (int s = 0; s < Offsets::ITEMRANDOMPROP_ENCHANT_SLOT_COUNT; ++s) {
const uint32_t enchantID = enchants[s];
if (enchantID == 0)
continue;
const uint8_t *en = DBC::Record(Offsets::VAR_SPELLITEMENCHANT_RECORDS,
Offsets::VAR_SPELLITEMENCHANT_COUNT, enchantID);
if (en == nullptr)
continue;
auto type = reinterpret_cast<const int32_t *>(en + Offsets::OFF_SPELLITEMENCHANT_TYPE);
auto amount = reinterpret_cast<const int32_t *>(en + Offsets::OFF_SPELLITEMENCHANT_AMOUNT);
auto arg = reinterpret_cast<const int32_t *>(en + Offsets::OFF_SPELLITEMENCHANT_ARG);
for (int i = 0; i < 3; ++i) { // SpellItemEnchantment has 3 effect slots
switch (type[i]) {
case 3: // equip spell — stat/resist value lives in the spell
AddSpellStatAuras(acc, arg[i], sign);
break;
case 4: // resistance — arg = school index (0 = armor)
AddByKey(acc, ResistKey(arg[i]), sign * static_cast<long>(amount[i]));
break;
case 5: // direct stat — arg = ItemModType, amount = value
AddByKey(acc, StatKeyForItemModType(static_cast<uint32_t>(arg[i])),
sign * static_cast<long>(amount[i]));
break;
default: // 1 proc / 2 weapon-damage / 6 totem / 7 use spell
break;
}
}
}
}
void AccumulateRecord(Accum *acc, const uint8_t *record, long sign) {
if (record == nullptr)
return;
auto *types = reinterpret_cast<const uint32_t *>(
record + Offsets::OFF_ITEMSTATS_STAT_TYPE);
auto *values = reinterpret_cast<const int32_t *>(
record + Offsets::OFF_ITEMSTATS_STAT_VALUE);
for (int i = 0; i < Offsets::ITEMSTATS_STAT_SLOT_COUNT; ++i)
AddByKey(acc, StatKeyForItemModType(types[i]),
sign * static_cast<long>(values[i]));
AddByKey(acc, "RESISTANCE0_NAME", sign * U32(record, Offsets::OFF_ITEMSTATS_ARMOR));
AddByKey(acc, "RESISTANCE1_NAME", sign * U32(record, Offsets::OFF_ITEMSTATS_RES_HOLY));
AddByKey(acc, "RESISTANCE2_NAME", sign * U32(record, Offsets::OFF_ITEMSTATS_RES_FIRE));
AddByKey(acc, "RESISTANCE3_NAME", sign * U32(record, Offsets::OFF_ITEMSTATS_RES_NATURE));
AddByKey(acc, "RESISTANCE4_NAME", sign * U32(record, Offsets::OFF_ITEMSTATS_RES_FROST));
AddByKey(acc, "RESISTANCE5_NAME", sign * U32(record, Offsets::OFF_ITEMSTATS_RES_SHADOW));
AddByKey(acc, "RESISTANCE6_NAME", sign * U32(record, Offsets::OFF_ITEMSTATS_RES_ARCANE));
// Shield block value is a stored field, not a stat slot or aura.
AddByKey(acc, "ITEM_MOD_BLOCK_VALUE", sign * U32(record, Offsets::OFF_ITEMSTATS_BLOCK));
AddEquipSpellStats(acc, record, sign);
}
int ParseRandomSuffixFromLink(void *L, int idx) {
if (Game::Lua::Type(L, idx) != Game::Lua::TYPE_STRING)
return 0;
const char *s = Game::Lua::ToString(L, idx);
if (s == nullptr)
return 0;
const char *m = std::strstr(s, "item:");
if (m == nullptr)
return 0;
m += 5; // start of field 0 (itemID)
// Advance to the start of field 2 (past two ':' separators), stopping
// at the end of the hyperlink payload if the link is too short.
int colons = 0;
while (*m != '\0' && *m != '|') {
if (*m == ':') {
if (++colons == 2) {
++m;
break;
}
}
++m;
}
if (colons < 2)
return 0;
return std::atoi(m);
}
} // namespace Item::StatAccum
+77
View File
@@ -0,0 +1,77 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
#pragma once
#include <cstdint>
// Shared item-stat accumulation engine. Reads an item's stats (base
// record + on-equip spell auras + random-suffix enchants) into a
// fixed key→value table keyed by FrameXML global-string names
// (`ITEM_MOD_*`, `RESISTANCE*_NAME`). Two consumers:
// - `Item::Stats` — the `C_Item.GetItemStats` / `GetItemStatDelta`
// Lua bindings, which turn an `Accum` into a Lua table.
// - `Tooltip::Compare` — `GameTooltip:SetHyperlinkCompareItem`, which
// diffs a hovered item against the equipped item and renders the
// per-stat delta as colored tooltip lines.
//
// The full rationale for each stat source and the aura→key map lives in
// [src/item/StatAccum.cpp]; see also the "Item lookups" notes in
// CLAUDE.md for the `ItemStats_C` record shape.
namespace Item::StatAccum {
// One key→value pair. `key` is a FrameXML global-string name; `value`
// is the accumulated magnitude (native vanilla units — percents stay
// percents, not ratings).
struct Accum {
const char *key;
long value;
};
// Number of distinct keys an item can touch (base stats + armor + six
// resistances + the equip-spell "special" stats). Compile-time so
// callers can stack-allocate `Accum acc[kCount]`.
inline constexpr int kCount = 30;
// Fill `acc` (kCount entries) with the canonical key list and zero
// values. Call once before accumulating.
void Init(Accum *acc);
// Add `delta` to the entry matching `key` (no-op for an unknown key or
// a zero delta). Linear scan — the table is small and fixed.
void AddByKey(Accum *acc, const char *key, long delta);
// Passive cache peek for an item's `ItemStats_C` record. Does NOT warm
// the cache (returns nullptr for an uncached item) — see the item-cache
// race note in CLAUDE.md.
const uint8_t *FetchRecord(uint32_t itemID);
// Fold an item record's base stats + on-equip-spell stats into `acc`,
// scaled by `sign` (+1 to add, -1 to subtract for a delta). No-op for a
// null record.
void AccumulateRecord(Accum *acc, const uint8_t *record, long sign);
// Fold an item link's random-suffix bonus (ItemRandomProperties.dbc →
// SpellItemEnchantment.dbc) into `acc`, scaled by `sign`. No-op for
// suffixID <= 0.
void ApplyRandomSuffix(Accum *acc, int suffixID, long sign);
// Weapon DPS = average damage / swing time, or 0 for non-weapons.
double ComputeDPS(const uint8_t *record);
// Parse the random-property/suffix ID from the item link at 1-based Lua
// stack `idx` (`item:id:enchant:SUFFIX:unique`). 0 for a non-string
// arg, a bare `item:N`, or no `item:` payload.
int ParseRandomSuffixFromLink(void *L, int idx);
} // namespace Item::StatAccum
+25 -386
View File
@@ -17,383 +17,22 @@
// uses (so `_G[key]` yields the display label). `GetItemStatDelta` is the
// per-stat difference of two items.
//
// Three sources feed the table:
// 1. The base `ItemStats_C` record — the stored attributes
// (Str/Agi/Sta/Int/Spi, mana/health-on-equip), armor, six resists,
// and (for weapons) DPS derived from the damage/delay fields.
// 2. The item's on-equip spells (`m_spellTrigger == 1`) — vanilla
// stores "special" bonuses (crit, attack power, spell power, hit,
// mp5, defense, …) as an equip spell whose aura effect carries the
// value, not as a stat slot. We decode those auras.
// 3. The link's random suffix (`item:id:enchant:SUFFIX:unique`) —
// ItemRandomProperties.dbc → SpellItemEnchantment.dbc, whose stat
// suffixes are themselves equip spells (enchant type 3) and whose
// armor suffixes are direct resistance enchants (type 4).
//
// **Values are vanilla-native.** The percent-based stats (crit / hit /
// defense) are reported as the raw vanilla percentage under the modern
// rating key — e.g. Krol Blade's "+1% crit" is
// `ITEM_MOD_CRIT_MELEE_RATING = 1`, NOT the level-scaled rating (13) that
// TBC/Era would show. Vanilla has no rating conversion (it's
// level-dependent and doesn't exist in this client), so a native percent
// is the only honest, level-independent value.
//
// The aura → key map (below) covers the common gear stats. Rarer auras
// (percent attack power, weapon-skill-per-line, damage-taken, procs) are
// intentionally skipped — they either lack a clean single key or aren't
// part of the stat set.
// The stat derivation (base record + on-equip spell auras + random
// suffix) lives in the shared `Item::StatAccum` engine
// ([src/item/StatAccum.cpp]); this file is just the Lua bindings that
// turn an accumulator into a returned table.
#include "Game.h"
#include "Offsets.h"
#include "dbc/Lookup.h"
#include "item/Arg.h"
#include "item/StatAccum.h"
#include <cstdint>
#include <cstdlib>
#include <cstring>
namespace Item::Stats {
namespace {
using GetItemRecord_t = const uint8_t *(__thiscall *)(void *cache, uint32_t itemID,
const uint64_t *guid, void *callback,
void *userData, bool requestIfMissing);
// Passive peek — same contract as GetItemData / GetItemInfoInstant. Does
// not warm the cache (callback = nullptr, requestIfMissing = false), so
// an uncached item reads back as nullptr rather than orphaning an entry
// (see the item-cache race note in CLAUDE.md).
const uint8_t *FetchItemRecord(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, false);
}
// Base-record m_statType uses the ItemModType enum: 0=mana, 1=health,
// 3=agility, 4=strength, 5=intellect, 6=spirit, 7=stamina (2 is
// deprecated). Returns nullptr for types with no vanilla key.
const char *StatKeyForItemModType(uint32_t statType) {
switch (statType) {
case 0: return "ITEM_MOD_MANA_SHORT";
case 1: return "ITEM_MOD_HEALTH_SHORT";
case 3: return "ITEM_MOD_AGILITY_SHORT";
case 4: return "ITEM_MOD_STRENGTH_SHORT";
case 5: return "ITEM_MOD_INTELLECT_SHORT";
case 6: return "ITEM_MOD_SPIRIT_SHORT";
case 7: return "ITEM_MOD_STAMINA_SHORT";
default: return nullptr;
}
}
// SPELL_AURA_MOD_STAT (29) misc uses the UnitStat enum instead: 0=Str,
// 1=Agi, 2=Sta, 3=Int, 4=Spi (-1 = all stats, not a single key).
// Verified against the "Increased <stat> 01" random-property spells.
const char *StatKeyForUnitStat(int stat) {
switch (stat) {
case 0: return "ITEM_MOD_STRENGTH_SHORT";
case 1: return "ITEM_MOD_AGILITY_SHORT";
case 2: return "ITEM_MOD_STAMINA_SHORT";
case 3: return "ITEM_MOD_INTELLECT_SHORT";
case 4: return "ITEM_MOD_SPIRIT_SHORT";
default: return nullptr;
}
}
// Resistance school index → key. 0 = armor (physical), 1..6 = the magic
// schools in Holy/Fire/Nature/Frost/Shadow/Arcane order.
const char *ResistKey(int school) {
static const char *const kKeys[7] = {
"RESISTANCE0_NAME", "RESISTANCE1_NAME", "RESISTANCE2_NAME",
"RESISTANCE3_NAME", "RESISTANCE4_NAME", "RESISTANCE5_NAME",
"RESISTANCE6_NAME",
};
return (school >= 0 && school <= 6) ? kKeys[school] : nullptr;
}
// Accumulator over every key an item can touch: the base stats + armor +
// six resistances, plus the equip-spell "special" stats. Fixed and
// small — linear scan by key is fine.
struct Accum {
const char *key;
long value;
};
// Keys in this list are the ones AddByKey / Accumulate ever write. The
// equip-spell keys past the resistances are best-effort names to verify
// against a 1.15.x client (crit melee/ranged are confirmed from a TBC
// GetItemStats dump); adjust here if the live client differs.
constexpr const char *kKeys[] = {
"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",
"RESISTANCE0_NAME",
"RESISTANCE1_NAME",
"RESISTANCE2_NAME",
"RESISTANCE3_NAME",
"RESISTANCE4_NAME",
"RESISTANCE5_NAME",
"RESISTANCE6_NAME",
"ITEM_MOD_ATTACK_POWER_SHORT",
"ITEM_MOD_RANGED_ATTACK_POWER_SHORT",
"ITEM_MOD_CRIT_MELEE_RATING",
"ITEM_MOD_CRIT_RANGED_RATING",
"ITEM_MOD_HIT_MELEE_RATING",
"ITEM_MOD_HIT_RANGED_RATING",
"ITEM_MOD_HIT_SPELL_RATING",
"ITEM_MOD_SPELL_DAMAGE_DONE_SHORT",
"ITEM_MOD_SPELL_HEALING_DONE_SHORT",
"ITEM_MOD_MANA_REGENERATION",
"ITEM_MOD_DEFENSE_SKILL_RATING",
};
constexpr int kAccumCount = static_cast<int>(sizeof(kKeys) / sizeof(kKeys[0]));
void InitAccum(Accum *acc) {
for (int i = 0; i < kAccumCount; ++i) {
acc[i].key = kKeys[i];
acc[i].value = 0;
}
}
void AddByKey(Accum *acc, const char *key, long delta) {
if (key == nullptr || delta == 0)
return;
for (int i = 0; i < kAccumCount; ++i)
if (std::strcmp(acc[i].key, key) == 0) {
acc[i].value += delta;
return;
}
}
long U32(const uint8_t *record, int offset) {
return static_cast<long>(*reinterpret_cast<const uint32_t *>(record + offset));
}
// Weapon DPS = average damage across all damage slots / swing time. The
// record stores min/max as floats per slot (vanilla weapons use slot 0,
// occasionally a second for a split physical/elemental line) and m_delay
// in milliseconds. Returns 0 for non-weapons (no delay or no damage),
// which the callers treat as "omit the key" — mirrors 3.3.5's range
// guard on `ITEM_MOD_DAMAGE_PER_SECOND_SHORT`.
double ComputeDPS(const uint8_t *record) {
if (record == nullptr)
return 0.0;
const uint32_t delayMs = *reinterpret_cast<const uint32_t *>(
record + Offsets::OFF_ITEMSTATS_DELAY);
if (delayMs == 0)
return 0.0;
auto *dmgMin = reinterpret_cast<const float *>(
record + Offsets::OFF_ITEMSTATS_DAMAGE_MIN);
auto *dmgMax = reinterpret_cast<const float *>(
record + Offsets::OFF_ITEMSTATS_DAMAGE_MAX);
double avgTotal = 0.0;
for (int i = 0; i < Offsets::ITEMSTATS_DAMAGE_SLOT_COUNT; ++i)
avgTotal += (static_cast<double>(dmgMin[i]) +
static_cast<double>(dmgMax[i])) * 0.5;
if (avgTotal <= 0.0)
return 0.0;
return avgTotal / (static_cast<double>(delayMs) / 1000.0);
}
// Folds a spell's stat/resistance auras into `acc`. Used for both the
// item's own on-equip spells and random-suffix equip enchants. The value
// is the fixed-die `EffectBasePoints + 1` (all item-stat spells store a
// single fixed magnitude that way). Percent stats keep their vanilla
// percentage. Auras with no clean stat key are ignored (procs, dummies,
// weapon-skill-per-line, damage-taken, etc.).
void AddSpellStatAuras(Accum *acc, int spellID, long sign) {
if (spellID <= 0)
return;
const uint8_t *sp = DBC::Record(Offsets::VAR_SPELL_RECORDS,
Offsets::VAR_SPELL_RECORD_COUNT,
static_cast<uint32_t>(spellID));
if (sp == nullptr)
return;
auto aura = reinterpret_cast<const int32_t *>(
sp + Offsets::OFF_SPELL_RECORD_EFFECT_APPLY_AURA_NAME);
auto misc = reinterpret_cast<const int32_t *>(
sp + Offsets::OFF_SPELL_RECORD_EFFECT_MISC_VALUE);
auto base = reinterpret_cast<const int32_t *>(
sp + Offsets::OFF_SPELL_RECORD_EFFECT_BASE_POINTS);
// Vanilla "+N Attack Power" spells carry a melee (99) AND a ranged
// (124) aura with the same value; the generic melee key subsumes the
// ranged one, so we emit ranged only for ranged-only spells (scopes).
bool hasMeleeAP = false;
for (int i = 0; i < Offsets::SPELL_RECORD_EFFECT_COUNT; ++i)
if (aura[i] == 99)
hasMeleeAP = true;
for (int i = 0; i < Offsets::SPELL_RECORD_EFFECT_COUNT; ++i) {
const long v = static_cast<long>(base[i]) + 1;
switch (aura[i]) {
case 29: // SPELL_AURA_MOD_STAT — misc = UnitStat index
AddByKey(acc, StatKeyForUnitStat(misc[i]), sign * v);
break;
case 22: // SPELL_AURA_MOD_RESISTANCE — misc = school bitmask
for (int school = 0; school <= 6; ++school)
if (misc[i] & (1 << school))
AddByKey(acc, ResistKey(school), sign * v);
break;
case 99: // SPELL_AURA_MOD_ATTACK_POWER
AddByKey(acc, "ITEM_MOD_ATTACK_POWER_SHORT", sign * v);
break;
case 124: // SPELL_AURA_MOD_RANGED_ATTACK_POWER
if (!hasMeleeAP)
AddByKey(acc, "ITEM_MOD_RANGED_ATTACK_POWER_SHORT", sign * v);
break;
case 52: // weapon crit % — TBC surfaces it as melee AND ranged
AddByKey(acc, "ITEM_MOD_CRIT_MELEE_RATING", sign * v);
AddByKey(acc, "ITEM_MOD_CRIT_RANGED_RATING", sign * v);
break;
case 54: // melee/ranged hit %
AddByKey(acc, "ITEM_MOD_HIT_MELEE_RATING", sign * v);
AddByKey(acc, "ITEM_MOD_HIT_RANGED_RATING", sign * v);
break;
case 55: // spell hit %
AddByKey(acc, "ITEM_MOD_HIT_SPELL_RATING", sign * v);
break;
case 13: // SPELL_AURA_MOD_DAMAGE_DONE — misc = school mask
if (misc[i] & 0x7E) // any magic school (bits 1..6)
AddByKey(acc, "ITEM_MOD_SPELL_DAMAGE_DONE_SHORT", sign * v);
break;
case 135: // SPELL_AURA_MOD_HEALING_DONE
AddByKey(acc, "ITEM_MOD_SPELL_HEALING_DONE_SHORT", sign * v);
break;
case 85: // SPELL_AURA_MOD_POWER_REGEN (mp5) — misc = power type
if (misc[i] == 0) // mana
AddByKey(acc, "ITEM_MOD_MANA_REGENERATION", sign * v);
break;
case 30: // SPELL_AURA_MOD_SKILL — misc = skill line (95 = Defense)
if (misc[i] == 95)
AddByKey(acc, "ITEM_MOD_DEFENSE_SKILL_RATING", sign * v);
break;
default:
break;
}
}
}
// Decodes the item's own on-equip spells (SpellTrigger == 1) into `acc`.
// On-use (0) and chance-on-hit (2) spells are skipped — they aren't
// passive stats. This is what surfaces crit / AP / spell power / hit /
// mp5 / defense on base items (vanilla stores them as equip spells, not
// stat slots).
void AddEquipSpellStats(Accum *acc, const uint8_t *record, long sign) {
auto ids = 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 (ids[i] != 0 && triggers[i] == 1) // ON_EQUIP
AddSpellStatAuras(acc, static_cast<int>(ids[i]), sign);
}
// Applies the item's random-suffix bonus (link field 2 → suffixID) to
// `acc`. Walks ItemRandomProperties.dbc → its SpellItemEnchantment IDs;
// type 3 enchants defer to the equip spell's auras, type 4 add a direct
// resistance, type 5 a direct stat. No-op for suffixID <= 0.
void ApplyRandomSuffix(Accum *acc, int suffixID, long sign) {
if (suffixID <= 0)
return;
const uint8_t *rp = DBC::Record(Offsets::VAR_ITEMRANDOMPROP_RECORDS,
Offsets::VAR_ITEMRANDOMPROP_COUNT,
static_cast<uint32_t>(suffixID));
if (rp == nullptr)
return;
auto enchants = reinterpret_cast<const uint32_t *>(
rp + Offsets::OFF_ITEMRANDOMPROP_ENCHANT);
for (int s = 0; s < Offsets::ITEMRANDOMPROP_ENCHANT_SLOT_COUNT; ++s) {
const uint32_t enchantID = enchants[s];
if (enchantID == 0)
continue;
const uint8_t *en = DBC::Record(Offsets::VAR_SPELLITEMENCHANT_RECORDS,
Offsets::VAR_SPELLITEMENCHANT_COUNT, enchantID);
if (en == nullptr)
continue;
auto type = reinterpret_cast<const int32_t *>(en + Offsets::OFF_SPELLITEMENCHANT_TYPE);
auto amount = reinterpret_cast<const int32_t *>(en + Offsets::OFF_SPELLITEMENCHANT_AMOUNT);
auto arg = reinterpret_cast<const int32_t *>(en + Offsets::OFF_SPELLITEMENCHANT_ARG);
for (int i = 0; i < 3; ++i) { // SpellItemEnchantment has 3 effect slots
switch (type[i]) {
case 3: // equip spell — stat/resist value lives in the spell
AddSpellStatAuras(acc, arg[i], sign);
break;
case 4: // resistance — arg = school index (0 = armor)
AddByKey(acc, ResistKey(arg[i]), sign * static_cast<long>(amount[i]));
break;
case 5: // direct stat — arg = ItemModType, amount = value
AddByKey(acc, StatKeyForItemModType(static_cast<uint32_t>(arg[i])),
sign * static_cast<long>(amount[i]));
break;
default: // 1 proc / 2 weapon-damage / 6 totem / 7 use spell
break;
}
}
}
}
// Adds item `record`'s base-record stats + on-equip-spell stats to `acc`
// scaled by `sign` (+1 to add, -1 to subtract — the delta path adds
// item2 then subtracts item1).
void Accumulate(Accum *acc, const uint8_t *record, long sign) {
if (record == nullptr)
return;
auto *types = reinterpret_cast<const uint32_t *>(
record + Offsets::OFF_ITEMSTATS_STAT_TYPE);
auto *values = reinterpret_cast<const int32_t *>(
record + Offsets::OFF_ITEMSTATS_STAT_VALUE);
for (int i = 0; i < Offsets::ITEMSTATS_STAT_SLOT_COUNT; ++i)
AddByKey(acc, StatKeyForItemModType(types[i]),
sign * static_cast<long>(values[i]));
AddByKey(acc, "RESISTANCE0_NAME", sign * U32(record, Offsets::OFF_ITEMSTATS_ARMOR));
AddByKey(acc, "RESISTANCE1_NAME", sign * U32(record, Offsets::OFF_ITEMSTATS_RES_HOLY));
AddByKey(acc, "RESISTANCE2_NAME", sign * U32(record, Offsets::OFF_ITEMSTATS_RES_FIRE));
AddByKey(acc, "RESISTANCE3_NAME", sign * U32(record, Offsets::OFF_ITEMSTATS_RES_NATURE));
AddByKey(acc, "RESISTANCE4_NAME", sign * U32(record, Offsets::OFF_ITEMSTATS_RES_FROST));
AddByKey(acc, "RESISTANCE5_NAME", sign * U32(record, Offsets::OFF_ITEMSTATS_RES_SHADOW));
AddByKey(acc, "RESISTANCE6_NAME", sign * U32(record, Offsets::OFF_ITEMSTATS_RES_ARCANE));
AddEquipSpellStats(acc, record, sign);
}
// Parses the random-property/suffix ID from an item link's third field
// (`item:id:enchant:SUFFIX:unique`). Returns 0 for a non-string arg, a
// link with no `item:` payload, or a short link that omits the field
// (bare `item:N`, `item:N:E`). The engine's own link format is the
// four-field `item:%d:%d:%d:%d`, so the suffix is always field index 2.
int ParseRandomSuffix(void *L, int idx) {
if (Game::Lua::Type(L, idx) != Game::Lua::TYPE_STRING)
return 0;
const char *s = Game::Lua::ToString(L, idx);
if (s == nullptr)
return 0;
const char *m = std::strstr(s, "item:");
if (m == nullptr)
return 0;
m += 5; // start of field 0 (itemID)
// Advance to the start of field 2 (past two ':' separators), stopping
// at the end of the hyperlink payload if the link is too short.
int colons = 0;
while (*m != '\0' && *m != '|') {
if (*m == ':') {
if (++colons == 2) {
++m;
break;
}
}
++m;
}
if (colons < 2)
return 0;
return std::atoi(m);
}
using StatAccum::Accum;
// Pushes a fresh Lua table with each non-zero accumulator entry as
// `t[key] = value`, plus the weapon DPS under
@@ -405,7 +44,7 @@ int ParseRandomSuffix(void *L, int idx) {
// noise producing a spurious near-zero delta.
void PushAccumTable(void *L, const Accum *acc, double dps) {
Game::Lua::NewTable(L);
for (int i = 0; i < kAccumCount; ++i)
for (int i = 0; i < StatAccum::kCount; ++i)
if (acc[i].value != 0)
Game::Lua::SetFieldNumber(L, acc[i].key,
static_cast<double>(acc[i].value));
@@ -430,17 +69,17 @@ int __fastcall Script_GetItemStats(void *L) {
Game::Lua::Error(L, "Usage: C_Item.GetItemStats(itemLink)");
return 0;
}
const int suffixID = ParseRandomSuffix(L, 1);
const uint8_t *record = FetchItemRecord(static_cast<uint32_t>(itemID));
const int suffixID = StatAccum::ParseRandomSuffixFromLink(L, 1);
const uint8_t *record = StatAccum::FetchRecord(static_cast<uint32_t>(itemID));
if (record == nullptr) {
Game::Lua::PushNil(L);
return 1;
}
Accum acc[kAccumCount];
InitAccum(acc);
Accumulate(acc, record, +1);
ApplyRandomSuffix(acc, suffixID, +1);
PushAccumTable(L, acc, ComputeDPS(record));
Accum acc[StatAccum::kCount];
StatAccum::Init(acc);
StatAccum::AccumulateRecord(acc, record, +1);
StatAccum::ApplyRandomSuffix(acc, suffixID, +1);
PushAccumTable(L, acc, StatAccum::ComputeDPS(record));
return 1;
}
@@ -458,21 +97,21 @@ int __fastcall Script_GetItemStatDelta(void *L) {
L, "Usage: C_Item.GetItemStatDelta(itemLink1, itemLink2)");
return 0;
}
const int suffix1 = ParseRandomSuffix(L, 1);
const int suffix2 = ParseRandomSuffix(L, 2);
const uint8_t *r1 = FetchItemRecord(static_cast<uint32_t>(id1));
const uint8_t *r2 = FetchItemRecord(static_cast<uint32_t>(id2));
const int suffix1 = StatAccum::ParseRandomSuffixFromLink(L, 1);
const int suffix2 = StatAccum::ParseRandomSuffixFromLink(L, 2);
const uint8_t *r1 = StatAccum::FetchRecord(static_cast<uint32_t>(id1));
const uint8_t *r2 = StatAccum::FetchRecord(static_cast<uint32_t>(id2));
if (r1 == nullptr || r2 == nullptr) {
Game::Lua::PushNil(L);
return 1;
}
Accum acc[kAccumCount];
InitAccum(acc);
Accumulate(acc, r2, +1);
ApplyRandomSuffix(acc, suffix2, +1);
Accumulate(acc, r1, -1);
ApplyRandomSuffix(acc, suffix1, -1);
PushAccumTable(L, acc, ComputeDPS(r2) - ComputeDPS(r1));
Accum acc[StatAccum::kCount];
StatAccum::Init(acc);
StatAccum::AccumulateRecord(acc, r2, +1);
StatAccum::ApplyRandomSuffix(acc, suffix2, +1);
StatAccum::AccumulateRecord(acc, r1, -1);
StatAccum::ApplyRandomSuffix(acc, suffix1, -1);
PushAccumTable(L, acc, StatAccum::ComputeDPS(r2) - StatAccum::ComputeDPS(r1));
return 1;
}
+31 -22
View File
@@ -18,6 +18,7 @@
#include "item/Link.h"
#include "item/Location.h"
#include "item/QualityColor.h"
#include "item/TooltipItem.h"
#include <cstdint>
#include <cstdio>
@@ -170,14 +171,15 @@ static void *ResolveItemByGuid(uint32_t guidLo, uint32_t guidHi) {
// expects addons to parse the itemID out of the link. Returning it
// directly saves callers a gsub round.
//
// BuildItemTooltip stores two fields per Set* call:
// - tooltip+0x398 → itemID (always populated for any item path)
// - tooltip+0x380/+0x384 → item GUID (populated only when there's a
// real CGItem — SetBagItem,
// SetInventoryItem, SetLootItem,
// SetMerchantItem, etc. Zero for
// SetItemByID / SetHyperlink
// which have no instance.)
// BuildItemTooltip stores the displayed item on the tooltip per Set*
// call — but only ONE of two fields depending on the path:
// - tooltip+0x398 → itemID (link paths with no instance: SetItemByID,
// SetHyperlink)
// - tooltip+0x380/+0x384 → item GUID (CGItem paths: SetBagItem,
// SetInventoryItem, SetLootItem,
// SetMerchantItem, …) — these leave the itemID
// field 0, so when +0x398 is 0 we recover the
// itemID from the GUID's live CGItem.
// Both are zeroed by the per-tooltip Clear at FUN_00530050.
//
// Two paths for the link:
@@ -203,14 +205,15 @@ static int __fastcall Script_GameTooltipGetItem(void *L) {
return 0;
auto *base = static_cast<const uint8_t *>(tooltipObj);
const int itemID = *reinterpret_cast<const int *>(base + Offsets::OFF_TOOLTIP_ITEM_ID);
// Resolve the currently-displayed item (link-path itemID, or the
// CGItem-path GUID when no unit/GO/spell is shown) — see
// Item::TooltipItem::CurrentID for the stale-GUID guarding.
uint64_t itemGuid = 0;
const int itemID = Item::TooltipItem::CurrentID(base, &itemGuid);
if (itemID <= 0)
return 0;
const uint32_t guidLo = *reinterpret_cast<const uint32_t *>(
base + Offsets::OFF_TOOLTIP_ITEM_GUID_LO);
const uint32_t guidHi = *reinterpret_cast<const uint32_t *>(
base + Offsets::OFF_TOOLTIP_ITEM_GUID_HI);
const uint32_t guidLo = static_cast<uint32_t>(itemGuid);
const uint32_t guidHi = static_cast<uint32_t>(itemGuid >> 32);
if (guidLo != 0 || guidHi != 0) {
if (void *cgItem = ResolveItemByGuid(guidLo, guidHi)) {
@@ -243,9 +246,13 @@ static int __fastcall Script_GameTooltipGetItem(void *L) {
// than nothing when we have an itemID.
}
// No-GUID path (SetItemByID, SetHyperlink for an item:N link with
// no per-instance data): build the basic colored link from cached
// itemID + quality + name.
// No-GUID path (SetItemByID, SetHyperlink, SetAuctionItem — items with
// no CGItem instance): build the colored link from cached itemID +
// quality + name. Auction/compare items have no GUID but do carry a
// random-property (suffix) id in the compare descriptor at
// OFF_TOOLTIP_COMPARE_SUFFIX — include it (in the link's suffix field)
// when the compare-descriptor flag is set, so the link round-trips the
// suffix for stat comparison. Matches 3.3.5's auction GetItem.
const uint8_t *record = PeekItemRecord(static_cast<uint32_t>(itemID));
if (record == nullptr) {
Item::Data::WarmCache(static_cast<uint32_t>(itemID));
@@ -258,11 +265,15 @@ static int __fastcall Script_GameTooltipGetItem(void *L) {
const uint32_t quality = *reinterpret_cast<const uint32_t *>(
record + Offsets::OFF_ITEMSTATS_QUALITY);
int suffix = 0;
if (*reinterpret_cast<const int *>(base + Offsets::OFF_TOOLTIP_COMPARE_FLAG) != 0)
suffix = *reinterpret_cast<const int *>(base + Offsets::OFF_TOOLTIP_COMPARE_SUFFIX);
char link[256];
std::snprintf(link, sizeof(link),
"%s|Hitem:%d:0:0:0:0:0:0:0|h[%s]|h|r",
"%s|Hitem:%d:0:%d:0:0:0:0:0|h[%s]|h|r",
Item::QualityColor::Prefix(static_cast<int>(quality)),
itemID, name);
itemID, suffix, name);
Game::Lua::PushString(L, name);
Game::Lua::PushString(L, link);
@@ -285,9 +296,7 @@ static int __fastcall Script_GameTooltipHasItem(void *L) {
Game::Lua::PushBool(L, 0);
return 1;
}
const int itemID = *reinterpret_cast<const int *>(
static_cast<const uint8_t *>(tooltipObj) + Offsets::OFF_TOOLTIP_ITEM_ID);
Game::Lua::PushBoolean(L, itemID > 0);
Game::Lua::PushBoolean(L, Item::TooltipItem::CurrentID(tooltipObj, nullptr) > 0);
return 1;
}
+65
View File
@@ -0,0 +1,65 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
#include "item/TooltipItem.h"
#include "Offsets.h"
#include "item/ID.h"
#include "item/Location.h"
namespace Item::TooltipItem {
int CurrentID(const void *tooltipObj, uint64_t *outGuid) {
if (outGuid != nullptr)
*outGuid = 0;
if (tooltipObj == nullptr)
return 0;
auto *c = static_cast<const uint8_t *>(tooltipObj);
const uint64_t itemGuid =
*reinterpret_cast<const uint64_t *>(c + Offsets::OFF_TOOLTIP_ITEM_GUID_LO);
// Link item: the itemID field is authoritative (and reliably cleared).
const int linkItemID = *reinterpret_cast<const int *>(c + Offsets::OFF_TOOLTIP_ITEM_ID);
if (linkItemID > 0) {
if (outGuid != nullptr)
*outGuid = itemGuid;
return linkItemID;
}
// CGItem item: only the GUID is stored, and +0x380 isn't zeroed by the
// clear — so it lingers after the tooltip switches to a unit / GO /
// spell. Each of those sets its own (cleared-then-written) id, so trust
// the item GUID only when none of them is currently shown, then resolve
// it to the itemID (this also yields the dressed link for callers).
if (itemGuid == 0)
return 0;
if (*reinterpret_cast<const uint64_t *>(c + Offsets::OFF_TOOLTIP_UNIT_GUID_LO) != 0)
return 0;
if (*reinterpret_cast<const uint64_t *>(c + Offsets::OFF_TOOLTIP_GAMEOBJECT_GUID_LO) != 0)
return 0;
if (*reinterpret_cast<const int *>(c + Offsets::OFF_TOOLTIP_SPELL_ID) > 0)
return 0;
const uint8_t *cg = Item::Location::ResolveByGUID(itemGuid);
if (cg == nullptr)
return 0;
const int id = Item::ID::FromCGItem(cg);
if (id <= 0)
return 0;
if (outGuid != nullptr)
*outGuid = itemGuid;
return id;
}
} // namespace Item::TooltipItem
+41
View File
@@ -0,0 +1,41 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
#pragma once
#include <cstdint>
namespace Item::TooltipItem {
// The itemID a tooltip is currently displaying, or 0 when it isn't
// showing an item. Two storage paths:
// - link paths (SetItemByID / SetHyperlink) set the itemID at
// OFF_TOOLTIP_ITEM_ID (+0x398), which the per-tooltip clear zeroes;
// - CGItem paths (SetInventoryItem / SetBagItem / SetItemByGUID) set the
// instance GUID at OFF_TOOLTIP_ITEM_GUID_LO (+0x380), which the clear
// does NOT zero — so it goes stale once the tooltip moves on to a unit
// / gameobject / spell.
// We trust the itemID directly, and otherwise resolve the GUID — but only
// when no unit / gameobject / spell id is currently set (each of those IS
// cleared-and-set on show, so their presence means the lingering item GUID
// isn't the live content). `outGuid` (optional) receives the item GUID (0
// when none), which lets callers build the dressed (suffixed) link.
// Returns 0 for a null tooltip or any non-item tooltip.
//
// Note: auction tooltips (SetAuctionItem) stash the itemID in a separate
// descriptor (+0x3D0) with no CGItem/GUID, so no dressed link is
// recoverable from them — we deliberately don't surface those here rather
// than return a suffix-stripped link.
int CurrentID(const void *tooltipObj, uint64_t *outGuid);
} // namespace Item::TooltipItem
+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
+218
View File
@@ -0,0 +1,218 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
// `PLAYER_EQUIPMENT_CHANGED(equipmentSlot, hasCurrent)` — the WotLK-era
// per-slot paperdoll event. Vanilla only fires UNIT_INVENTORY_CHANGED for
// the player, which doesn't say WHICH slot changed; gear trackers and stat
// sheets built against 3.3+ gate their refresh on this event.
//
// Fully event-driven via the engine's own descriptor-field observer system
// (see the FUN_DESC_OBSERVER_REGISTER block in Offsets.h). The engine's
// inventory setup (FUN_INV_OBSERVER_SETUP, once per enter-world) registers
// change observers for the bag/backpack/bank/keyring GUID fields of the
// player descriptor — but never for the 19 equipment slots (0x4A8..0x538).
// We co-hook that setup and register our own observer per equipment field,
// mirroring the engine's registration shape exactly (bank 4, size 8). The
// dispatcher invokes us only when a slot's item GUID actually changed
// (per-node mirror memcmp), so there's no polling and no diff bookkeeping
// on our side, and the nodes share the engine observers' lifetime (they
// die with the player object; the setup re-runs each enter-world).
//
// `equipmentSlot` is the 1-based Lua inventory slot (1 = head … 19 =
// tabard), matching modern. `hasCurrent` is 1 when the slot now holds an
// item and nil when it's empty (the engine dispatcher has no `%b`; 1/nil
// keeps the idiomatic `if hasCurrent then` working — see FireIdSuccess).
//
// Also `UPDATE_INVENTORY_DURABILITY` (no payload) — fires when any
// equipped item's durability changes (combat damage, death, repairs) —
// via a co-hook on the engine's inventory-alerts recompute
// (FUN_INVENTORY_ALERTS_RECOMPUTE): the engine itself calls that whenever
// an owned item's fields change (its item post-update handler FUN_005D9A90
// routes every item values-update through it) and on equip/enter-world
// paths, so it IS the engine's own "durability may have changed" signal.
// Post-original we diff a per-slot {itemGuid, durability} snapshot and
// fire only on a real change. NOT done with bank-1 (ITEM field) descriptor
// observers: those register without error but are never invoked — the
// registrar's internal wrapper lookup (FUN_00464890, the observer
// registry, distinct from the object manager) silently no-ops for item
// objects, verified empirically (item values-updates arrive and the
// player-bank observers fire, item-bank ones never do).
#include "Game.h"
#include "Offsets.h"
#include "equipmentset/Locations.h"
#include "event/Custom.h"
#include "unit/Identity.h"
#include <cstdint>
namespace Player::Equipment {
namespace {
constexpr const char *kEvtEquipmentChanged = "PLAYER_EQUIPMENT_CHANGED";
const Event::Custom::AutoReserve _r{kEvtEquipmentChanged};
constexpr const char *kEvtDurability = "UPDATE_INVENTORY_DURABILITY";
const Event::Custom::AutoReserve _r2{kEvtDurability};
// Whether the slot currently holds an item — read from the player's
// inventory-manager GUID array, the same source the engine's own bag
// observer (FUN_004F8DB0) treats as the post-change truth when its field
// observer fires. The player is resolved from the GUID the observer
// callback receives, via the NON-THROWING object resolver — the same call
// FUN_004F8DB0 makes. NOT via FUN_RESOLVE_UNIT_TOKEN("player"): that's the
// assert-frame helper that raises a Lua error when the token doesn't
// resolve, and this runs inside SMSG_UPDATE_OBJECT processing, where the
// player object can be mid-create and momentarily unresolvable — a Lua
// error thrown from there unwinds through raw engine code.
bool SlotHasItem(uint32_t guidLo, uint32_t guidHi, int slot0Based) {
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);
auto *player = static_cast<const uint8_t *>(resolve(
Offsets::OBJ_TYPE_PLAYER, "ClassicAPI", guidLo, guidHi, 0x172));
if (player == nullptr)
return false;
const uint8_t *invMgr = player + Offsets::OFF_PLAYER_INVENTORY_MANAGER;
const uint32_t count = *reinterpret_cast<const uint32_t *>(
invMgr + Offsets::OFF_INVMGR_SLOT_COUNT);
if (static_cast<uint32_t>(slot0Based) >= count)
return false;
const uint64_t *guids = *reinterpret_cast<const uint64_t *const *>(
invMgr + Offsets::OFF_INVMGR_GUID_ARRAY);
return guids != nullptr && guids[slot0Based] != 0;
}
// --- PLAYER_EQUIPMENT_CHANGED (player-descriptor observers) -------------
// Descriptor-field observer callback — ABI per the Offsets.h note:
// __fastcall(fieldOffset /*ecx*/, size /*edx*/, guidLo, guidHi, oldValue*,
// userArg), return 1, callee cleans 0x10. Invoked by the dispatcher only
// when the watched equipment GUID field actually changed.
int __fastcall EquipSlotChanged_cb(uint32_t fieldOffset, uint32_t /*size*/,
uint32_t guidLo, uint32_t guidHi,
const uint32_t * /*oldValue*/,
void * /*userArg*/) {
const int slot0 =
static_cast<int>(fieldOffset - Offsets::OFF_DESC_PLAYER_EQUIP_FIRST) >> 3;
if (slot0 >= 0 && slot0 < Offsets::DESC_PLAYER_EQUIP_SLOTS) {
Event::Custom::FireIdSuccess(Event::Custom::Lookup(kEvtEquipmentChanged),
slot0 + 1,
SlotHasItem(guidLo, guidHi, slot0));
}
return 1;
}
// Co-hook on the engine's inventory observer setup: after it registers the
// bag-range observers, register ours for the equipment range it skips.
// Same registrar, same shape — only the field offsets differ.
using RegisterObserver_t = void(__fastcall *)(
int bank, uint32_t fieldOffset, uint32_t guidLo, uint32_t guidHi, int size,
const void *callback, void *userArg1, void *userArg2);
using ObserverSetup_t = void(__fastcall *)();
ObserverSetup_t g_origSetup = nullptr;
// One-time (per process) seed latch for the durability diff below — the
// very first recompute seeds the snapshot silently instead of firing for
// the initial gear population. Deliberately NOT reset per enter-world:
// death/release/graveyard teleports re-run the setup, and a per-setup
// reset made the post-teleport recompute re-seed silently — swallowing
// the death's own durability loss (verified in-game: repairs fired,
// deaths didn't). Keeping the snapshot across loading screens means
// changes that land around them diff normally; the cost is at most one
// spurious fire on relog to a different character, which matches
// modern's login-time fire anyway.
bool g_durSnapValid = false;
void __fastcall ObserverSetup_h() {
g_origSetup();
const uint64_t guid = Unit::Identity::PlayerGuid();
if (guid == 0)
return;
auto reg = reinterpret_cast<RegisterObserver_t>(
Offsets::FUN_DESC_OBSERVER_REGISTER);
for (int slot = 0; slot < Offsets::DESC_PLAYER_EQUIP_SLOTS; ++slot) {
reg(Offsets::DESC_OBSERVER_BANK_PLAYER,
Offsets::OFF_DESC_PLAYER_EQUIP_FIRST + slot * 8,
static_cast<uint32_t>(guid), static_cast<uint32_t>(guid >> 32),
/*size*/ 8, reinterpret_cast<const void *>(&EquipSlotChanged_cb),
nullptr, nullptr);
}
}
static const Game::HookAutoRegister _setupHook{
Offsets::FUN_INV_OBSERVER_SETUP,
reinterpret_cast<void *>(&ObserverSetup_h),
reinterpret_cast<void **>(&g_origSetup)};
// --- UPDATE_INVENTORY_DURABILITY (alerts-recompute co-hook) --------------
struct SlotDurability {
uint64_t guid; // equipped item, 0 = empty slot
uint32_t durability; // ITEM_FIELD_DURABILITY, 0 when empty/unresolved
};
SlotDurability g_durSnap[EquipmentSet::SLOT_COUNT];
uint32_t ItemDurability(uint64_t guid) {
if (guid == 0)
return 0;
const uint8_t *item = EquipmentSet::Locations::ResolveItemByGUID(guid);
if (item == nullptr)
return 0;
const uint8_t *descriptor = *reinterpret_cast<const uint8_t *const *>(
item + Offsets::OFF_ITEM_DESCRIPTOR);
if (descriptor == nullptr)
return 0;
return *reinterpret_cast<const uint32_t *>(
descriptor + Offsets::OFF_DESCRIPTOR_DURABILITY);
}
using AlertsRecompute_t = void(__fastcall *)();
AlertsRecompute_t g_origAlertsRecompute = nullptr;
void __fastcall AlertsRecompute_h() {
g_origAlertsRecompute();
uint64_t paperdoll[EquipmentSet::SLOT_COUNT];
EquipmentSet::Locations::SnapshotPaperdoll(paperdoll);
bool changed = false;
for (int i = 0; i < EquipmentSet::SLOT_COUNT; ++i) {
const SlotDurability cur{paperdoll[i], ItemDurability(paperdoll[i])};
if (g_durSnap[i].guid != cur.guid ||
g_durSnap[i].durability != cur.durability) {
changed = true;
g_durSnap[i] = cur;
}
}
if (!g_durSnapValid) {
g_durSnapValid = true; // first recompute of the session seeds only
return;
}
if (changed)
Event::Custom::Fire(Event::Custom::Lookup(kEvtDurability), "");
}
static const Game::HookAutoRegister _alertsHook{
Offsets::FUN_INVENTORY_ALERTS_RECOMPUTE,
reinterpret_cast<void *>(&AlertsRecompute_h),
reinterpret_cast<void **>(&g_origAlertsRecompute)};
} // namespace
} // namespace Player::Equipment
+39
View File
@@ -134,6 +134,43 @@ void PushRequirements(void *L, const uint8_t *data) {
}
}
// `C_QuestLog.GetNumQuestObjectives(questID)` — the quest's objective
// count (the length `GetQuestDetails(questID).requirements` would have),
// read straight from the cache block with zero Lua table/string
// materialization. Exists so per-step validation loops (guide addons
// bounds-checking objective indices at load time) stay O(1)-cheap even
// with a warm quest cache — materializing full detail tables for hundreds
// of quests inside a login-path loop can exhaust vanilla's fixed Lua
// memory pool. Returns nil when the quest isn't cached (same contract as
// GetQuestDetails).
int __fastcall Script_GetNumQuestObjectives(void *L) {
if (!Game::Lua::IsNumber(L, 1)) {
Game::Lua::Error(L,
"Usage: C_QuestLog.GetNumQuestObjectives(questID)");
return 0;
}
const int questID = static_cast<int>(Game::Lua::ToNumber(L, 1));
if (questID <= 0)
return 0;
const uint8_t *data = Quest::Cache::Peek(static_cast<uint32_t>(questID));
if (data == nullptr)
return 0;
int count = 0;
auto *npcOrGo = reinterpret_cast<const int32_t *>(
data + Quest::Cache::OFF_REQUIRED_NPC_OR_GO);
auto *items = reinterpret_cast<const uint32_t *>(
data + Quest::Cache::OFF_REQUIRED_ITEM);
for (int i = 0; i < Quest::Cache::OBJECTIVE_COUNT; ++i) {
if (npcOrGo[i] != 0)
++count;
if (items[i] != 0)
++count;
}
Game::Lua::PushNumber(L, static_cast<double>(count));
return 1;
}
int __fastcall Script_GetQuestDetails(void *L) {
if (!Game::Lua::IsNumber(L, 1)) {
Game::Lua::Error(L, "Usage: C_QuestLog.GetQuestDetails(questID)");
@@ -245,6 +282,8 @@ int __fastcall Script_GetQuestDetails(void *L) {
void RegisterLuaFunctions() {
Game::Lua::RegisterTableFunction("C_QuestLog", "GetQuestDetails",
&Script_GetQuestDetails);
Game::Lua::RegisterTableFunction("C_QuestLog", "GetNumQuestObjectives",
&Script_GetNumQuestObjectives);
}
} // namespace
@@ -11,18 +11,29 @@
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
// `QUEST_ACCEPTED(questLogIndex, questID)` event — fires once per
// new quest entering the local quest log. Polyfills modern WoW's
// event of the same name (added in 3.1.0). Matches the Cata/WotLK
// signature: `(questLogIndex, questID)`, 1-based log index.
// Quest log delta events — polyfills two modern WoW events from one
// pre-/post-rebuild diff:
//
// `QUEST_ACCEPTED(questLogIndex, questID)` — fires once per new quest
// entering the local quest log. Matches the Cata/WotLK signature,
// 1-based log index (event added in 3.1.0).
//
// `QUEST_REMOVED(questID)` — fires once per quest leaving the local
// quest log, for both turn-ins and abandons (event added in 8.0.1;
// Classic-era signature is the bare questID). For turn-ins this fires
// *after* `QUEST_TURNED_IN`, matching retail ordering: the 0x191
// complete packet doesn't touch the log; the removal arrives in the
// follow-up quest-log update packets, whose rebuild we diff here.
// Observed turn-in sequence: QUEST_TURNED_IN → UNIT_QUEST_LOG_CHANGED
// → QUEST_LOG_UPDATE (fired inside the rebuild) → QUEST_REMOVED.
//
// Hook target: `FUN_QUEST_LOG_REBUILD` (`0x004DE510`) — the single
// chokepoint the engine uses to refresh the Lua-visible quest log
// from the player's authoritative slot data at `[CGPlayer + 0xE68 +
// 0x28]`. Every state change that adds, removes, or updates a quest
// flows through here. By snapshotting the log before and after the
// original runs, we can compute the delta and fire QUEST_ACCEPTED
// only for genuine additions.
// original runs, we can compute the delta and fire only for genuine
// additions / removals.
//
// Login suppression: the same function handles the initial bulk-sync
// at character entry, which appears as a single rebuild call adding
@@ -30,7 +41,10 @@
// added in a single rebuild (which can only happen on login / reload
// resync — human input speed can't accept multiple quests in the
// same engine tick). A brand-new character's first accept is
// `0 → 1` and fires correctly.
// `0 → 1` and fires correctly. Removals apply the same rule (a user
// action removes at most one quest per rebuild) and are additionally
// suppressed when the rebuild also added multiple quests — a
// mixed-delta rebuild is a cross-character resync, not gameplay.
#include "Game.h"
#include "Offsets.h"
@@ -39,13 +53,15 @@
#include <cstdint>
#include <unordered_set>
namespace Quest::Accepted {
namespace Quest::LogEvents {
namespace {
constexpr const char *kEventName = "QUEST_ACCEPTED";
constexpr const char *kAcceptedEventName = "QUEST_ACCEPTED";
constexpr const char *kRemovedEventName = "QUEST_REMOVED";
const Event::Custom::AutoReserve _reserve{kEventName};
const Event::Custom::AutoReserve _reserveAccepted{kAcceptedEventName};
const Event::Custom::AutoReserve _reserveRemoved{kRemovedEventName};
// Walk the live quest log and collect questIDs of real (non-header)
// entries. Headers are 16-byte rows with a non-NULL pointer at +0x8;
@@ -80,7 +96,7 @@ QuestLogRebuild_t QuestLogRebuild_o = nullptr;
void __fastcall QuestLogRebuild_h(int param_1) {
// `param_1 == 0` is the engine's "no-op rebuild" signal — just
// refires QUEST_LOG_UPDATE without touching the entry array.
// No diff possible, no accept happening, pass through.
// No diff possible, no accept/remove happening, pass through.
if (param_1 == 0) {
QuestLogRebuild_o(param_1);
return;
@@ -93,14 +109,17 @@ void __fastcall QuestLogRebuild_h(int param_1) {
// Walk the post-state in index order so the fired event's
// `questLogIndex` matches the 1-based slot the engine just
// assigned. Skip questIDs already in `pre`.
// assigned. Collect the full post-set on the way for the
// removal diff.
const int count = *reinterpret_cast<const int *>(
static_cast<uintptr_t>(Offsets::VAR_QUEST_LOG_ENTRY_COUNT));
if (count <= 0)
return;
const auto *entries = reinterpret_cast<const uint8_t *>(
static_cast<uintptr_t>(Offsets::VAR_QUEST_LOG_ENTRIES));
std::unordered_set<int> post;
if (count > 0)
post.reserve(static_cast<size_t>(count));
int newCount = 0;
int firstNewIdx = 0;
int firstNewID = 0;
@@ -114,6 +133,7 @@ void __fastcall QuestLogRebuild_h(int param_1) {
entry + Offsets::OFF_QUEST_LOG_ENTRY_QUEST_ID);
if (questID <= 0)
continue;
post.insert(questID);
if (pre.count(questID) != 0)
continue;
++newCount;
@@ -123,14 +143,35 @@ void __fastcall QuestLogRebuild_h(int param_1) {
}
}
int removedCount = 0;
int firstRemovedID = 0;
for (const int questID : pre) {
if (post.count(questID) != 0)
continue;
++removedCount;
if (removedCount == 1)
firstRemovedID = questID;
}
// Single addition → user-driven accept. Multiple → bulk resync
// (login or post-reload). Modern WoW's QUEST_ACCEPTED doesn't
// fire on initial sync either; we match that.
if (newCount == 1) {
const int slot = Event::Custom::Lookup(kEventName);
const int slot = Event::Custom::Lookup(kAcceptedEventName);
if (slot >= 0)
Event::Custom::Fire(slot, "%d%d", firstNewIdx, firstNewID);
}
// Single removal → user-driven turn-in or abandon. The extra
// `newCount <= 1` gate rejects the one resync shape the removal
// count alone can't: switching to a character whose log shares
// all but one of the previous character's quests (1 removal, N
// additions in the same rebuild).
if (removedCount == 1 && newCount <= 1) {
const int slot = Event::Custom::Lookup(kRemovedEventName);
if (slot >= 0)
Event::Custom::Fire(slot, "%d", firstRemovedID);
}
}
static const Game::HookAutoRegister _hookreg{
@@ -138,4 +179,4 @@ static const Game::HookAutoRegister _hookreg{
reinterpret_cast<void *>(&QuestLogRebuild_h),
reinterpret_cast<void **>(&QuestLogRebuild_o)};
} // namespace Quest::Accepted
} // namespace Quest::LogEvents
+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;
}
+254 -30
View File
@@ -32,26 +32,37 @@
// time), dedup'd against a just-made client stamp so a normal cast's
// confirming packet doesn't restart its bar. `g_castFromServer = true`;
// since the global was never set for these, the WorldTick VAR==0 clear
// must skip them — they expire on their computed endMs (and pfUI clears
// on nampower's SPELL_FAILED_SELF for interrupts).
// - channel: the player's broadcast `UNIT_FIELD_CHANNEL_SPELL`
// (descriptor +0x228), polled on `WorldTick`. endMs = now + effective
// channel duration.
// must skip them — they expire on their computed endMs, or earlier via
// the abort co-hooks (ClearCastingSpell / the failure packets) when the
// engine learns the cast died.
// - channel: stamped from `SMSG_SPELL_START` at packet receipt (instant
// channels; engine-computed duration) and from `MSG_CHANNEL_START`
// (server duration — and the ONLY start signal for cast-then-channel
// spells like Mind Control, whose SpellStart covers just the cast
// phase). The broadcast +0x228 field is NOT polled for the player —
// it lags the channel start by ~1 tick.
// `now` is the engine ms clock (`FUN_OS_TICKCOUNT_MS`), the same source
// Lua's `GetTime()` scales by 0.001 — so startMs/endMs are directly
// comparable to `GetTime()*1000` in addon progress math.
//
// Other units have no engine-stored cast record, so we capture their casts
// from the `SMSG_SPELL_START` packet (the only place the client sees
// another unit's cast + a server-authoritative cast time) into a per-caster
// cache — see the SpellStart co-hook below. Scope:
// Other units have no engine-stored cast TIMES (the engine does keep the
// current-cast spellID at [unit+0xC8C], but nothing temporal), so we
// capture their casts from the `SMSG_SPELL_START` packet (the only place
// the client sees another unit's cast + a server-authoritative cast time)
// into a per-caster cache — see the SpellStart co-hook below. Scope:
// - Player casts/channels: full timing (self-tracked, exact engine math).
// - Other units' regular CASTS: full timing while within the cast window,
// from the SpellStart cache. No clean per-unit interrupt signal exists
// in 1.12, so an interrupted remote cast lingers until its computed end.
// from the SpellStart cache. Aborts (interrupt, death, movement,
// cancel) evict the entry the moment the engine stops the unit's cast
// — the CGUnit_C::ClearCastingSpell co-hook below, backstopped by
// co-hooks on both failure packets — so no lingering ghost bar.
// - Other units' CHANNELS: the broadcast +0x228 field gates "channeling
// now"; the SpellStart cache adds real start/end times when we observed
// the channel begin (else spellID/name/texture with nil times).
// - Other units' PUSHBACK is invisible: SMSG_SPELL_DELAYED goes only to
// the affected caster (empirically confirmed — during a melee-pushback
// test only the victim's client received it), so a remote bar can't
// stretch. Inherent 1.12 protocol gap, not recoverable client-side.
//
// Modern-signature fields vanilla can't fill are structurally-correct
// placeholders: castID / castBarID = nil, notInterruptible = false,
@@ -104,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);
}
@@ -173,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
@@ -203,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);
@@ -332,7 +379,7 @@ void HandleSpellStart(uint64_t caster, int spellID, uint32_t castTime) {
const bool channel = IsChannelSpell(rec);
if (caster == Unit::Identity::PlayerGuid()) {
if (channel) {
if (channel && castTime == 0) {
// Player channel start. The broadcast +0x228 field that signals
// "channeling" is server-pushed and lands ~1 tick (~1s) after the
// channel actually begins, so a +0x228 poll shows the bar far too
@@ -341,14 +388,19 @@ void HandleSpellStart(uint64_t caster, int spellID, uint32_t castTime) {
// now with the engine-computed duration. We run before the
// original handler that fires SPELLCAST_CHANNEL_START, so the data
// is fresh when addons poll. Cleared by self-expiry (PushChannel-
// Info) at endMs.
// 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:
// 3 s cast, then a 60 s channel). This packet is the cast phase —
// the channel begins only when the cast completes, signaled by
// MSG_CHANNEL_START (ChannelStart_h). Fall through to regular cast
// handling so the bar shows the 3 s cast, not a premature 60 s
// channel (pfUI #9).
if (castTime == 0)
return; // instant — no cast bar
// Backstop for chained same-spell recasts: the engine short-circuits
@@ -367,9 +419,14 @@ void HandleSpellStart(uint64_t caster, int spellID, uint32_t castTime) {
g_channel.spellID = 0; // a cast supersedes any channel
return;
}
const int endMs = channel ? now + RemoteChannelDurationMs(rec)
: now + static_cast<int>(castTime);
StoreRemoteCast(caster, spellID, now, endMs, channel);
// A channeled spell with a cast time (Mind Control) is in its CAST phase
// here — store it as a regular cast so observers see the 3 s bar. The
// remote channel phase has no packet (MSG_CHANNEL_START is caster-only);
// it surfaces through the unit's broadcast +0x228 field as usual.
const bool instantChannel = channel && castTime == 0;
const int endMs = instantChannel ? now + RemoteChannelDurationMs(rec)
: now + static_cast<int>(castTime);
StoreRemoteCast(caster, spellID, now, endMs, instantChannel);
}
// Co-hook on the SMSG_SPELL_START handler. Gated on opCode 0x131 so every
@@ -427,6 +484,151 @@ const Game::HookAutoRegister _spellDelayedHook{
Offsets::FUN_SPELL_DELAYED, reinterpret_cast<void *>(&SpellDelayed_h),
reinterpret_cast<void **>(&g_origSpellDelayed)};
// Co-hook on the MSG_CHANNEL_START handler — the server tells the caster a
// channel has begun, with the server-authoritative (modifier-applied)
// duration. Sent only to the caster, so it's always the local player. This
// is what makes CAST-THEN-CHANNEL spells (Mind Control: 3 s cast, then a
// 60 s channel) show both phases: their SMSG_SPELL_START carries the cast
// (castTime > 0, handled as a regular cast in HandleSpellStart) and only
// this packet marks the channel actually starting. For instant channels it
// re-stamps what the SpellStart path just wrote (same moment, server
// duration) — harmless. We stamp before the original runs so the data is
// fresh when the engine fires SPELLCAST_CHANNEL_START. Packet layout
// mirrored from nampower's SpellChannelStartHandlerHook:
// spellId(u32), durationMs(u32).
using ChannelStart_t = int(__stdcall *)(uint32_t *opCode,
Net::CDataStore *packet);
ChannelStart_t g_origChannelStart = nullptr;
int __stdcall ChannelStart_h(uint32_t *opCode, Net::CDataStore *packet) {
if (packet != nullptr) {
const uint32_t saved = packet->m_read;
const int spellID = static_cast<int>(Net::Read<uint32_t>(packet));
const uint32_t duration = Net::Read<uint32_t>(packet);
packet->m_read = saved;
if (spellID != 0 && duration > 0) {
const int now = NowMs();
StampChannel(spellID, now, now + static_cast<int>(duration));
}
}
return g_origChannelStart(opCode, packet);
}
const Game::HookAutoRegister _channelStartHook{
Offsets::FUN_SPELL_CHANNEL_START,
reinterpret_cast<void *>(&ChannelStart_h),
reinterpret_cast<void **>(&g_origChannelStart)};
// Shared abort handling for the two sibling failure packets. Servers
// derived from (v)mangos broadcast BOTH `SMSG_SPELL_FAILURE` and
// `SMSG_SPELL_FAILED_OTHER` from Spell::SendInterrupted, but which one a
// given core emits per abort path varies — so both handlers are co-hooked
// and both feed this. Idempotent, so the both-arrive case is harmless.
void HandleCastAborted(uint64_t guid, int spellID) {
if (guid == 0 || spellID == 0)
return;
// Remote units: invalidate the cached cast/channel so
// UnitCastingInfo/UnitChannelInfo stop reporting it this tick.
for (auto &e : g_remoteCasts) {
if (e.used && e.casterGuid == guid && e.spellID == spellID) {
e.used = false;
break;
}
}
// Local player: a server-stamped chained recast (g_castFromServer)
// never set VAR_CURRENT_CAST_SPELL, so the WorldTick VAR==0 poll can't
// clear it on interrupt — this packet is the only signal. Whether the
// caster receives their own broadcast is server-dependent; if it never
// fires, the endMs self-expiry backstop still applies.
if (guid == Unit::Identity::PlayerGuid() && g_cast.spellID == spellID)
g_cast.spellID = 0;
}
// Co-hook on the SMSG_SPELL_FAILED_OTHER handler — the server broadcasts it
// to observers whenever a started cast aborts (interrupt, death, movement,
// fizzle). It's what lets us drop a remote cast the moment it dies instead
// of letting the ghost bar run to its computed end (the documented
// remote-unit limitation above). Packet body is plain (not packed):
// casterGuid(u64), spellId(u32).
using SpellFailedOther_t = int(__stdcall *)(uint32_t *opCode,
Net::CDataStore *packet);
SpellFailedOther_t g_origSpellFailedOther = nullptr;
int __stdcall SpellFailedOther_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);
const int spellID = static_cast<int>(Net::Read<uint32_t>(packet));
packet->m_read = saved;
HandleCastAborted(guid, spellID);
}
return g_origSpellFailedOther(opCode, packet);
}
const Game::HookAutoRegister _spellFailedOtherHook{
Offsets::FUN_SPELL_FAILED_OTHER,
reinterpret_cast<void *>(&SpellFailedOther_h),
reinterpret_cast<void **>(&g_origSpellFailedOther)};
// Co-hook on the SMSG_SPELL_FAILURE handler — FAILED_OTHER's sibling, with
// a trailing result(u8) after the same casterGuid(u64), spellId(u32) head
// (we don't read the result). This is the packet that actually stops the
// caster's cast VISUAL in the engine's own handler, so it demonstrably
// arrives for interrupts; hooking both siblings makes the eviction
// independent of which one the server chose to send.
using SpellFailure_t = int(__stdcall *)(uint32_t *opCode,
Net::CDataStore *packet);
SpellFailure_t g_origSpellFailure = nullptr;
int __stdcall SpellFailure_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);
const int spellID = static_cast<int>(Net::Read<uint32_t>(packet));
packet->m_read = saved;
HandleCastAborted(guid, spellID);
}
return g_origSpellFailure(opCode, packet);
}
const Game::HookAutoRegister _spellFailureHook{
Offsets::FUN_SPELL_FAILURE, reinterpret_cast<void *>(&SpellFailure_h),
reinterpret_cast<void **>(&g_origSpellFailure)};
// Co-hook on CGUnit_C::ClearCastingSpell — the engine choke point every
// "unit stopped casting" path funnels through (failure packets, SPELL_GO,
// movement, death, animation events, local cancel — see Offsets.h). The
// original is gated on `spellID == [unit+0xC8C]` (the unit's live
// current-cast field), so a real stop only happens when the gate passes;
// we mirror the gate and evict then. This is the PRIMARY interrupt signal
// on Turtle: its core doesn't broadcast either failure packet — interrupt
// propagation to observers happens via SuperWoW, whose code calls this
// method directly (verified by return-address tracing: interrupted remote
// casts clear with a caller inside a sibling DLL, natural completions
// clear from the SPELL_GO handler). Verified in-game: Kick / Earth Shock /
// Counterspell each evict the victim's cast, while non-interrupting damage
// (pushback flinch) does NOT route through here — no false eviction.
using ClearCastingSpell_t = void(__fastcall *)(void *unit, void *edx,
int spellID, char notify,
char cleanup);
ClearCastingSpell_t g_origClearCastingSpell = nullptr;
void __fastcall ClearCastingSpell_h(void *unit, void *edx, int spellID,
char notify, char cleanup) {
if (unit != nullptr && spellID != 0) {
const int current = *reinterpret_cast<const int *>(
static_cast<const uint8_t *>(unit) + Offsets::OFF_UNIT_CAST_SPELL);
if (current != 0 && current == spellID)
HandleCastAborted(Unit::Identity::GuidForObject(unit), spellID);
}
g_origClearCastingSpell(unit, edx, spellID, notify, cleanup);
}
const Game::HookAutoRegister _clearCastingHook{
Offsets::FUN_UNIT_CLEAR_CASTING_SPELL,
reinterpret_cast<void *>(&ClearCastingSpell_h),
reinterpret_cast<void **>(&g_origClearCastingSpell)};
} // namespace
// Per-frame upkeep for the player. The regular cast is stamped by the
@@ -445,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};
@@ -458,8 +682,8 @@ static int __fastcall Script_CastingInfo(void *L) { return PushCastInfo(L, g_cas
// `UnitCastingInfo(unit)` — local player from self-tracking; other units
// from the SMSG_SPELL_START cache (regular casts still within their cast
// window). An interrupted remote cast lingers until its computed end time
// (no clean per-unit interrupt signal in 1.12) — acceptable best-effort.
// window; aborted casts are evicted by the ClearCastingSpell / failure-
// packet co-hooks).
static int __fastcall Script_UnitCastingInfo(void *L) {
if (!Game::Lua::IsString(L, 1)) {
Game::Lua::Error(L, "Usage: C_Spell.UnitCastingInfo(\"unit\")");
+61
View File
@@ -0,0 +1,61 @@
// 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/>.
// Completes the per-tooltip clear so the item GUID doesn't go stale.
//
// The engine's tooltip clear FUN_GAMETOOLTIP_CLEAR (FUN_00530050) zeroes the
// unit (+0x368), gameobject (+0x370), itemID (+0x398) and spell (+0x39c)
// identity fields — but NOT the item GUID (+0x380/+0x384) that the item builder
// writes for CGItem tooltips (SetBagItem / SetInventoryItem / …). The engine
// never reads that GUID as "current item" (the builder always overwrites it
// before use), so leaving it stale is harmless to the engine — but
// GameTooltip:GetItem / HasItem / IsEquippedItem read it (via
// Item::TooltipItem::CurrentID's GUID fallback, which exists because CGItem
// tooltips leave the itemID field 0). So after the tooltip switches to content
// that sets no item — SetUnitAura (→ FUN_0052f880), SetEquipmentSet (→ SetText),
// etc. — the stale GUID made GetItem resolve the *previous* item.
//
// All 14 Set*/builder paths route through this clear (verified via its xrefs),
// and the item builder calls it *before* writing the GUID, so co-hooking the
// clear to also zero +0x380/+0x384 fixes every case uniformly: a fresh tooltip
// always resets the item GUID, and only a real item build re-sets it. The hook
// runs no Lua and touches nothing but those 8 bytes after the original clear —
// none of the fragility of the OnTooltipSetItem fire.
#include "Game.h"
#include "Offsets.h"
#include <cstdint>
namespace Tooltip::ClearItemGuid {
namespace {
using Clear_t = void(__fastcall *)(void *self);
Clear_t g_clearOriginal = nullptr;
void __fastcall Clear_h(void *self) {
g_clearOriginal(self);
if (self != nullptr)
*reinterpret_cast<uint64_t *>(static_cast<uint8_t *>(self) +
Offsets::OFF_TOOLTIP_ITEM_GUID_LO) = 0;
}
static const Game::HookAutoRegister _hook{
Offsets::FUN_GAMETOOLTIP_CLEAR,
reinterpret_cast<void *>(&Clear_h),
reinterpret_cast<void **>(&g_clearOriginal)};
} // namespace
} // namespace Tooltip::ClearItemGuid
+552
View File
@@ -0,0 +1,552 @@
// 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/>.
// `GameTooltip:SetHyperlinkCompareItem("itemLink" [, offset, shiftButton, comparisonTooltip])`
// — a backport of the 3.3.5+ tooltip method that powers item-comparison
// ("shopping") tooltips. The compared item comes from `itemLink` when
// given; otherwise it's taken from the item the `comparisonTooltip` (4th
// positional arg — the tooltip FrameXML's GameTooltip_ShowCompareItem
// passes) is displaying, so the method works purely tooltip-to-tooltip
// with no link, matching the retail call. Called on a tooltip frame (typically
// ShoppingTooltip1/2, but works on any GameTooltip-type frame), it:
//
// 1. Parses the hovered item link and finds which equipment slot(s)
// that item would occupy (via its inventoryType).
// 2. Picks the slot indicated by `offset` (1-based; two-slot items —
// rings, trinkets, one-hand weapons — expose slots at offset 1 and
// 2, so a caller iterates offset = 1 .. returnValue).
// 3. Fills the tooltip with the item currently EQUIPPED in that slot,
// led by a grey "Currently Equipped" header — done by calling the
// engine's own item-tooltip builder (FUN_GAMETOOLTIP_BUILD_ITEM) in
// its header mode. No Lua roundtrip.
// 4. Appends colored per-stat delta lines — yellow ITEM_DELTA_DESCRIPTION
// header, then green for a gain / red for a loss — showing how the
// hovered item compares to the equipped one. The delta is
// `hovered − equipped`, computed by the shared `Item::StatAccum`
// engine (the same one behind `C_Item.GetItemStatDelta`); the lines
// are added with the engine's raw add-line (FUN_GAMETOOLTIP_ADD_LINE).
//
// Returns the number of comparison slots for the hovered item (0 if it
// isn't equippable, isn't cached yet, the tooltip object can't be
// resolved, or nothing is equipped in the chosen slot) — a ClassicAPI
// extension over the retail signature, so a caller knows whether a second
// offset is worth querying.
//
// Everything is done with direct engine calls (the builder, clear, and
// add-line functions the engine uses itself) — no `lua_pcall` into
// registered Lua methods. The tooltip's final size/show is left to the
// caller (matching retail `SetHyperlinkCompareItem`, which populates but
// doesn't show). The 60-line pool from Tooltip::LinePool keeps the
// appended deltas under the AddLine cap.
//
// `shiftButton`: gates the stat-change breakdown. Deltas show by default
// (`true`, `1`, `nil`, or omitted) and are hidden only for an explicit
// `false` or `0` — just the grey header + the equipped item's tooltip.
// Both the boolean and the vanilla 1/nil convention are accepted —
// mirroring FrameXML's GameTooltip_ShowCompareItem.
#include "Game.h"
#include "Offsets.h"
#include "item/Arg.h"
#include "item/ID.h"
#include "item/Location.h"
#include "item/StatAccum.h"
#include "item/TooltipItem.h"
#include "tooltip/SetItemEvent.h"
#include "ui/ColorData.h"
#include <cstdint>
#include <cstdio>
#include <cstring>
namespace Tooltip::Compare {
namespace {
using Item::StatAccum::Accum;
// The engine's item-tooltip builder. __thiscall(self = tooltip object).
// With headerFlag != 0 it skips its leading clear and emits the grey
// "Currently Equipped" header; the item itself is looked up from itemID +
// guid regardless. See OFF/FUN notes in Offsets.h.
using BuildItemTooltip_t = void(__thiscall *)(void *self, uint32_t itemID, const void *guid,
const void *guid2, int a4, int a5, int a6,
int headerFlag, int a8, int a9);
// Raw add-line. Color args are pointers to a 4-byte packed {b,g,r,a}.
using AddLine_t = void(__thiscall *)(void *self, const char *left, const char *right,
const void *leftColor, const void *rightColor, int wrap);
using SetLineText_t = void(__thiscall *)(void *fontString, const char *text, int flag);
using SetLineColor_t = void(__thiscall *)(void *fontString, const void *colorBGRA);
// inventoryType (ItemStats_C m_inventoryType) → equipment slot ID(s).
// Slot IDs are the engine's canonical INVSLOT values (1 = Head … 19 =
// Tabard). Two-slot types (finger, trinket, one-hand weapon) fill both
// entries and return 2. Returns 0 for non-equippable types (bag, ammo,
// quiver, non-equip). `out` must hold 2 ints.
int SlotsForInvType(int invType, int *out) {
switch (invType) {
case 1: out[0] = 1; return 1; // HEAD
case 2: out[0] = 2; return 1; // NECK
case 3: out[0] = 3; return 1; // SHOULDER
case 4: out[0] = 4; return 1; // BODY (shirt)
case 5: case 20: out[0] = 5; return 1; // CHEST / ROBE
case 6: out[0] = 6; return 1; // WAIST
case 7: out[0] = 7; return 1; // LEGS
case 8: out[0] = 8; return 1; // FEET
case 9: out[0] = 9; return 1; // WRIST
case 10: out[0] = 10; return 1; // HANDS
case 11: out[0] = 11; out[1] = 12; return 2; // FINGER
case 12: out[0] = 13; out[1] = 14; return 2; // TRINKET
case 16: out[0] = 15; return 1; // CLOAK (back)
case 13: out[0] = 16; out[1] = 17; return 2; // ONE-HAND WEAPON (main/off)
case 17: out[0] = 16; return 1; // TWO-HAND WEAPON
case 21: out[0] = 16; return 1; // WEAPONMAINHAND
case 14: out[0] = 17; return 1; // SHIELD (off hand)
case 22: out[0] = 17; return 1; // WEAPONOFFHAND
case 23: out[0] = 17; return 1; // HOLDABLE (off hand)
case 15: out[0] = 18; return 1; // RANGED
case 25: out[0] = 18; return 1; // THROWN
case 26: out[0] = 18; return 1; // RANGEDRIGHT (wands)
case 28: out[0] = 18; return 1; // RELIC
case 19: out[0] = 19; return 1; // TABARD
default: return 0; // 0 non-equip, 18 bag, 24 ammo, 27 quiver
}
}
// 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.
struct KeyLabel {
const char *key;
const char *label;
};
constexpr KeyLabel kFallbackLabels[] = {
{"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"},
{"RESISTANCE0_NAME", "Armor"},
{"RESISTANCE1_NAME", "Holy Resistance"},
{"RESISTANCE2_NAME", "Fire Resistance"},
{"RESISTANCE3_NAME", "Nature Resistance"},
{"RESISTANCE4_NAME", "Frost Resistance"},
{"RESISTANCE5_NAME", "Shadow Resistance"},
{"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_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_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:"},
};
const char *FallbackLabel(const char *key) {
for (const auto &e : kFallbackLabels)
if (std::strcmp(e.key, key) == 0)
return e.label;
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) {
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)
std::snprintf(out, outSize, "%s", g);
else
std::snprintf(out, outSize, "%s", FallbackLabel(key));
Game::Lua::SetTop(L, saved);
}
// Delta colors — the canonical GlobalColor.dbc tags modern WoW uses for
// item comparison, pulled from UI::ColorData (its packed argb is already
// the {b,g,r,a} order the tooltip line-color setter wants).
constexpr uint32_t kGain = UI::ColorData::ByTag("INCREASE_STAT_COLOR"); // green
constexpr uint32_t kLoss = UI::ColorData::ByTag("DECREASE_STAT_COLOR"); // red
constexpr uint32_t kHeader = UI::ColorData::ByTag("NORMAL_FONT_COLOR"); // header yellow
// self:AddLine equivalent — left-aligned colored line via the engine's
// raw add-line. `color` must outlive the call (we pass its address).
void AddColoredLine(void *self, const char *text, const uint32_t &color) {
auto add = reinterpret_cast<AddLine_t>(Offsets::FUN_GAMETOOLTIP_ADD_LINE);
add(self, text, nullptr, &color, nullptr, 0);
}
// Appends the yellow ITEM_DELTA_DESCRIPTION header + one line per non-zero
// stat and a DPS line. No-op when nothing differs (identical items /
// self-compare add no delta section).
void RenderDeltas(void *self, void *L, const Accum *acc, double dpsDelta) {
const bool hasDps = (dpsDelta > 1e-4 || dpsDelta < -1e-4);
bool hasAny = hasDps;
for (int i = 0; !hasAny && i < Item::StatAccum::kCount; ++i)
hasAny = (acc[i].value != 0);
if (!hasAny)
return;
char label[192];
char line[256];
LabelFor(L, "ITEM_DELTA_DESCRIPTION", label, sizeof(label));
AddColoredLine(self, label, kHeader);
if (hasDps) {
LabelFor(L, "ITEM_MOD_DAMAGE_PER_SECOND_SHORT", label, sizeof(label));
std::snprintf(line, sizeof(line), "%s%.1f %s", dpsDelta > 0 ? "+" : "",
dpsDelta, label);
AddColoredLine(self, line, dpsDelta > 0 ? kGain : kLoss);
}
for (int i = 0; i < Item::StatAccum::kCount; ++i) {
const long v = acc[i].value;
if (v == 0)
continue;
LabelFor(L, acc[i].key, label, sizeof(label));
std::snprintf(line, sizeof(line), "%s%ld %s", v > 0 ? "+" : "", v, label);
AddColoredLine(self, line, v > 0 ? kGain : kLoss);
}
}
// --- "Currently Equipped" header ---------------------------------------
//
// The engine's builder can emit this header itself (FUN_0052B650 param_7),
// but that path merges it into the item's name line and force-wraps it.
// So we build the item cleanly and prepend the header the way vanilla
// addons (pfUI eqcompare) do — shift every built line's text+color down
// one slot and claim line 0 — but entirely in C via the engine's line
// FontStrings, no Lua method calls.
const char *FSText(const void *fs) {
if (fs == nullptr)
return nullptr;
return *reinterpret_cast<const char *const *>(
static_cast<const uint8_t *>(fs) + Offsets::OFF_FONTSTRING_TEXT);
}
// Whether a line's FontString is actually shown. The tooltip clear hides
// unused cells but keeps their (now stale) text, so this — not FSText —
// is what decides whether a cell carries live content to shift.
bool FSVisible(const void *fs) {
if (fs == nullptr)
return false;
return *reinterpret_cast<const int *>(
static_cast<const uint8_t *>(fs) + Offsets::OFF_FONTSTRING_VISIBLE) != 0;
}
// Reads a FontString's current color into `*out` (4-byte {b,g,r,a}).
// Returns false when the color storage isn't set, so the caller leaves
// the destination's existing color alone.
bool FSColor(const void *fs, uint32_t *out) {
if (fs == nullptr)
return false;
auto *slot = *reinterpret_cast<const uint32_t *const *>(
static_cast<const uint8_t *>(fs) + Offsets::OFF_FONTSTRING_COLOR_PTR);
if (slot == nullptr)
return false;
*out = *slot;
return true;
}
void FSSetText(void *fs, const char *text) {
if (fs != nullptr)
reinterpret_cast<SetLineText_t>(Offsets::FUN_FONTSTRING_SET_TEXT)(fs, text, 0);
}
void FSSetColor(void *fs, const uint32_t &color) {
if (fs != nullptr)
reinterpret_cast<SetLineColor_t>(Offsets::FUN_FONTSTRING_SET_COLOR)(fs, &color);
}
using ShowHideFS_t = void(__fastcall *)(void *fs);
// Realize a FontString's shown state the way the engine's AddLine/clear
// pair do: set the desired-shown flag, then call SHOW or HIDE so the
// FontString gets positioned (or dropped from) the layout. Without this a
// cell moved into a previously-hidden slot never anchors.
void FSSetShown(void *fs, bool shown) {
if (fs == nullptr)
return;
*reinterpret_cast<int *>(static_cast<uint8_t *>(fs) +
Offsets::OFF_FONTSTRING_SHOWN_FLAG) = shown ? 1 : 0;
reinterpret_cast<ShowHideFS_t>(
shown ? Offsets::FUN_FONTSTRING_SHOW : Offsets::FUN_FONTSTRING_HIDE)(fs);
}
// Copies `src`'s content (text + color) onto `dst`, replicating the shown
// state so `dst` positions correctly; hides `dst` when `src` isn't a live
// (shown) cell. Keyed on visibility, not text, so stale text the clear
// left behind in a hidden cell isn't resurrected. One column of the shift.
void CopyLineCell(void *dst, const void *src) {
if (dst == nullptr)
return;
if (FSVisible(src)) {
const char *text = FSText(src);
FSSetText(dst, text != nullptr ? text : "");
// Always set a color: source's if it has one, else white — so a
// stale color left on `dst` by a previous delta line is reset
// (lines the builder leaves at the default have no readable color).
uint32_t color = UI::ColorData::ByTag("WHITE_FONT_COLOR");
FSColor(src, &color);
FSSetColor(dst, color);
FSSetShown(dst, true);
} else {
FSSetText(dst, "");
FSSetShown(dst, false);
}
}
void PrependCurrentlyEquipped(void *self, void *L) {
auto *t = static_cast<uint8_t *>(self);
int *numLines = reinterpret_cast<int *>(t + Offsets::OFF_GAMETOOLTIP_NUM_LINES);
const int allocated =
*reinterpret_cast<const int *>(t + Offsets::OFF_GAMETOOLTIP_NUM_LINES_ALLOC);
const int n = *numLines;
if (n <= 0 || n + 1 > allocated)
return; // nothing built, or pool full (LinePool keeps 60, so rare)
auto **leftArr = *reinterpret_cast<void ***>(
t + Offsets::OFF_GAMETOOLTIP_TEXTLEFT_DESC + 8);
auto **rightArr = *reinterpret_cast<void ***>(
t + Offsets::OFF_GAMETOOLTIP_TEXTRIGHT_DESC + 8);
auto *wrapArr = *reinterpret_cast<int **>(
t + Offsets::OFF_GAMETOOLTIP_WRAPFLAG_DESC + 8);
if (leftArr == nullptr || rightArr == nullptr || wrapArr == nullptr)
return;
// Shift lines [0..n-1] down to [1..n]. High-to-low so each source is
// read before the next iteration overwrites it.
for (int i = n - 1; i >= 0; --i) {
CopyLineCell(leftArr[i + 1], leftArr[i]);
CopyLineCell(rightArr[i + 1], rightArr[i]);
wrapArr[i + 1] = wrapArr[i];
}
char header[128];
LabelFor(L, "CURRENTLY_EQUIPPED", header, sizeof(header)); // native 1.12 string
constexpr uint32_t grey = UI::ColorData::ByTag("GRAY_FONT_COLOR");
FSSetText(leftArr[0], header);
FSSetColor(leftArr[0], grey);
FSSetShown(leftArr[0], true);
FSSetText(rightArr[0], "");
FSSetShown(rightArr[0], false);
wrapArr[0] = 0;
*numLines = n + 1;
}
// Reads the live random-property (suffix) index off an equipped CGItem's
// descriptor — the same index ItemRandomProperties.dbc / ApplyRandomSuffix
// use. 0 for a plain item or a null descriptor.
int EquippedSuffix(const uint8_t *item) {
auto *desc = *reinterpret_cast<const uint8_t *const *>(
item + Offsets::OFF_ITEM_DESCRIPTOR);
if (desc == nullptr)
return 0;
return static_cast<int>(*reinterpret_cast<const uint32_t *>(
desc + Offsets::OFF_DESCRIPTOR_RANDOM_PROPERTY));
}
uint64_t EquippedGUID(const uint8_t *item) {
auto *instance = *reinterpret_cast<const uint8_t *const *>(
item + Offsets::OFF_ITEM_INSTANCE_BLOCK);
if (instance == nullptr)
return 0;
return *reinterpret_cast<const uint64_t *>(
instance + Offsets::OFF_INSTANCE_BLOCK_GUID);
}
int __fastcall Script_SetHyperlinkCompareItem(void *L) {
if (Game::Lua::Type(L, 1) != Game::Lua::TYPE_TABLE) {
Game::Lua::Error(
L, "Usage: GameTooltip:SetHyperlinkCompareItem(\"itemLink\" [, offset, shiftButton, comparisonTooltip])");
return 0;
}
const int argc = Game::Lua::GetTop(L);
auto bail = [&](int n) -> int {
Game::Lua::SetTop(L, argc);
Game::Lua::PushNumber(L, static_cast<double>(n));
return 1;
};
// Compared ("hovered") item: prefer the explicit link (arg 2). When
// it's absent, fall back to whatever the comparisonTooltip (arg 5) is
// displaying — the builder stashes that tooltip's itemID/GUID, so we
// recover the itemID directly and the suffix via the GUID's live
// CGItem. This makes the method 1:1 with the retail call, which can be
// driven purely tooltip-to-tooltip.
int hoveredID = Item::Arg::ResolveItemID(L, 2);
int hoveredSuffix = Item::StatAccum::ParseRandomSuffixFromLink(L, 2);
if (hoveredID <= 0 && Game::Lua::Type(L, 5) == Game::Lua::TYPE_TABLE) {
if (void *cmp = Game::Lua::ResolveObject(L, 5)) {
uint64_t g = 0;
hoveredID = Item::TooltipItem::CurrentID(cmp, &g);
if (g != 0) {
const uint8_t *cg = Item::Location::ResolveByGUID(g);
if (cg != nullptr)
hoveredSuffix = EquippedSuffix(cg);
}
}
}
const uint8_t *hoveredRec =
hoveredID > 0 ? Item::StatAccum::FetchRecord(static_cast<uint32_t>(hoveredID)) : nullptr;
if (hoveredRec == nullptr)
return bail(0); // no link and no comparisonTooltip item, or not cached yet
const int invType = static_cast<int>(*reinterpret_cast<const uint32_t *>(
hoveredRec + Offsets::OFF_ITEMSTATS_INVENTORY_TYPE));
int slots[2] = {0, 0};
const int nSlots = SlotsForInvType(invType, slots);
if (nSlots == 0)
return bail(0); // non-equippable
int offset = (Game::Lua::Type(L, 3) == Game::Lua::TYPE_NUMBER)
? static_cast<int>(Game::Lua::ToNumber(L, 3))
: 1;
if (offset < 1)
offset = 1;
if (offset > nSlots)
return bail(nSlots);
const int slotID = slots[offset - 1];
// shiftButton: gates the stat-change breakdown. Deltas show by default
// — for `true`, `1`, `nil`, or omitted — and are hidden ONLY for an
// explicit `false` or `0`. Accepting both the boolean and the vanilla
// 1/nil convention matches how callers (and FrameXML's
// GameTooltip_ShowCompareItem) forward the modified-click state.
bool showDeltas = true;
const int shiftType = Game::Lua::Type(L, 4);
if (shiftType == Game::Lua::TYPE_BOOLEAN)
showDeltas = Game::Lua::ToBoolean(L, 4) != 0;
else if (shiftType == Game::Lua::TYPE_NUMBER)
showDeltas = Game::Lua::ToNumber(L, 4) != 0.0;
// What's equipped there? Nothing → no comparison; leave the tooltip
// as the caller had it.
const uint8_t *eqItem = Item::Location::ResolveEquipmentSlot(slotID);
if (eqItem == nullptr)
return bail(nSlots);
const int equippedID = Item::ID::FromCGItem(eqItem);
if (equippedID <= 0)
return bail(nSlots);
const int equippedSuffix = EquippedSuffix(eqItem);
uint64_t equippedGUID = EquippedGUID(eqItem);
// The tooltip's underlying CFrameScriptObject — the `this` for the
// engine's builder / add-line thiscalls.
void *self = Game::Lua::ResolveObject(L, 1);
if (self == nullptr)
return bail(0);
// Build the equipped item into the tooltip via the engine's own
// item-tooltip builder (the call SetInventoryItem/SetHyperlink use),
// proven arg shape from FUN_005353b0/FUN_00535700 — all flags 0. This
// clears + fills the item cleanly (no wrap). The grey "Currently
// Equipped" header is prepended afterwards. Suppress OnTooltipSetItem for
// this internal build so a handler doesn't run mid-way through the
// line-shift below (it fires for the *hovered* item's real Set* instead).
{
Tooltip::SetItemEvent::Suppressor noEvent;
reinterpret_cast<BuildItemTooltip_t>(Offsets::FUN_GAMETOOLTIP_BUILD_ITEM)(
self, static_cast<uint32_t>(equippedID), &equippedGUID, &equippedGUID, 0, 0, 0, 0, 0, 0);
}
PrependCurrentlyEquipped(self, L);
// Delta = hovered − equipped, then render (unless suppressed).
if (showDeltas) {
Accum acc[Item::StatAccum::kCount];
Item::StatAccum::Init(acc);
Item::StatAccum::AccumulateRecord(acc, hoveredRec, +1);
Item::StatAccum::ApplyRandomSuffix(acc, hoveredSuffix, +1);
const uint8_t *equippedRec =
Item::StatAccum::FetchRecord(static_cast<uint32_t>(equippedID));
if (equippedRec != nullptr) {
Item::StatAccum::AccumulateRecord(acc, equippedRec, -1);
Item::StatAccum::ApplyRandomSuffix(acc, equippedSuffix, -1);
}
const double dpsDelta = Item::StatAccum::ComputeDPS(hoveredRec) -
(equippedRec ? Item::StatAccum::ComputeDPS(equippedRec) : 0.0);
RenderDeltas(self, L, acc, dpsDelta);
}
return bail(nSlots);
}
// `GameTooltip:IsEquippedItem()` → bool. True when the item the tooltip is
// currently displaying is equipped in a character-pane slot. The displayed
// item is resolved by Item::TooltipItem::CurrentID (which guards the stale
// item-GUID so unit / spell / gameobject tooltips read 0 and never
// false-positive). Backports the 3.3.5 GameTooltip method (registry
// index 63); the equipment walk mirrors C_Item.IsEquippedItem's itemID
// path.
int __fastcall Script_IsEquippedItem(void *L) {
if (Game::Lua::Type(L, 1) != Game::Lua::TYPE_TABLE) {
Game::Lua::Error(L, "Usage: GameTooltip:IsEquippedItem()");
return 0;
}
void *self = Game::Lua::ResolveObject(L, 1);
const int itemID = self != nullptr ? Item::TooltipItem::CurrentID(self, nullptr) : 0;
bool equipped = false;
for (int slot = Offsets::EQUIPMENT_SLOT_FIRST;
itemID > 0 && slot <= Offsets::EQUIPMENT_SLOT_LAST; ++slot) {
const uint8_t *it = Item::Location::ResolveEquipmentSlot(slot);
if (it != nullptr && Item::ID::FromCGItem(it) == itemID) {
equipped = true;
break;
}
}
Game::Lua::PushBool(L, equipped);
return 1;
}
const Game::Lua::FrameMethodEntry g_methods[] = {
{"SetHyperlinkCompareItem", &Script_SetHyperlinkCompareItem},
{"IsEquippedItem", &Script_IsEquippedItem},
};
void RegisterLuaFunctions() {
Game::Lua::RegisterFrameMethods(
reinterpret_cast<void *>(Offsets::VAR_GAMETOOLTIP_METHOD_REGISTRY),
g_methods,
static_cast<int>(sizeof(g_methods) / sizeof(g_methods[0])));
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace
} // namespace Tooltip::Compare
+222
View File
@@ -0,0 +1,222 @@
// 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/>.
// Lift GameTooltip's 30-line cap (TODO #97) — pure C++, no addon Lua.
//
// Vanilla `GameTooltip:AddLine` (FUN_00530270) silently drops any line
// past `numLinesAllocated`, and that count is just "how many contiguous
// `<name>TextLeftN`/`TextRightN` FontStrings the FrameXML template
// declared" — established once by the tooltip's XML-OnLoad region scan
// (FUN_00529650, vtable slot 9). Stat-heavy tooltips (pfUI's eqcompare
// block) lose their tail past line 30.
//
// We grow the pool the same way 3.3.5's AddLine grows on demand, but at
// scan time instead of per line: co-hook the scan, and after the engine
// has set up the template's 30 lines, create `TextLeft/Right 31..60` in
// C++ (mirroring Script_CreateFontString + SetFontObject + SetPoint) and
// append them to the tooltip's three line arrays. This runs once per
// GameTooltip-family frame at creation (and per `/reload`), so it
// auto-covers GameTooltip, ShoppingTooltip1/2, ItemRefTooltip,
// WorldMapTooltip, … with no frame list and no `AddLine` hook.
//
// Everything the grow needs — parent frame, the previous line (for its
// anchor target + font), and the array data pointers — comes straight
// from the just-populated tooltip, so there's no `_G` name lookup. The
// new lines are still *named* (`FUN_REGION_SET_NAME`) so addons that read
// `_G[name.."TextLeft"..i]` keep working and a re-run of the scan
// recounts them idempotently. See TODO #97 for the full RE trail.
#include "Game.h"
#include "Offsets.h"
#include <cstdint>
#include <cstdio>
namespace Tooltip::LinePool {
namespace {
// Grow every GameTooltip-family frame to this many lines. pfUI's worst
// case is ~32; 60 leaves headroom without an unbounded pool.
constexpr uint32_t kMaxLines = 60;
// --- engine entry points (all __thiscall; see Offsets.h) -------------------
using GetName_t = const char *(__thiscall *)(void *self);
using PoolAlloc_t = void *(__thiscall *)(void *pool, int zeroInit, const char *tag, int line);
using FontStringCtor_t = void *(__thiscall *)(void *mem, void *parent, int layer, int sublayer);
using SetName_t = void(__thiscall *)(void *region, const char *name);
using SetFont_t = void(__thiscall *)(void *fontHolder, void *fontObject);
using SetPoint_t = void(__thiscall *)(void *anchor, int point, void *relAnchor, int relPoint,
float x, float y, int flag);
using ReallocArray_t = void(__thiscall *)(void *descriptor, uint32_t newCount);
// The scan we co-hook: __thiscall(tooltip, node, flag). Hooked via the
// standard __fastcall(ecx, edx, …) shim — __thiscall passes `this` in ECX
// and the rest on the stack, so the trailing stack args line up and the
// phantom `edx` is harmless.
using Scan_t = void(__fastcall *)(void *tooltip, void *edx, void *node, int flag);
Scan_t g_scanOriginal = nullptr;
uint8_t *DescData(uint8_t *tooltip, int descOffset) {
return *reinterpret_cast<uint8_t **>(tooltip + descOffset + 8);
}
// FUN_REGION_SET_POINT stores offsets in internal coordinates, so a pixel
// offset must be scaled the way Script_SetPoint does before being passed —
// otherwise a -2px gap lands ~1000px off. Returns 0 if the scale globals
// aren't populated yet (degrades to a 0-offset anchor, not a wild one).
float PixelToInternal(float pixels) {
const float mul = *reinterpret_cast<const float *>(Offsets::VAR_UI_COORD_SCALE_MUL);
const float divBase = *reinterpret_cast<const float *>(Offsets::VAR_UI_COORD_SCALE_DIV);
const float denom = divBase * static_cast<float>(Offsets::UI_COORD_SCALE_UNIT);
if (denom == 0.0f)
return 0.0f;
return pixels * mul / denom;
}
// Reads a FontString's current font object (holder+4). Used to copy the
// previous line's font onto a new one so it renders identically.
void *FontObjectOf(void *fontString) {
if (fontString == nullptr)
return nullptr;
return *reinterpret_cast<void **>(reinterpret_cast<uint8_t *>(fontString) +
Offsets::OFF_FONTSTRING_FONT_OBJECT);
}
// Builds one CSimpleFontString: alloc → ctor(parent) → name → font (copied
// from `fontSource`) → anchor. Mirrors Script_CreateFontString's core plus
// the SetFontObject / SetPoint the Lua caller would do. Returns nullptr on
// allocation failure.
void *CreateLine(void *parent, const char *name, void *fontSource, int point,
void *relRegion, int relPoint, float x, float y) {
auto alloc = reinterpret_cast<PoolAlloc_t>(Offsets::FUN_REGION_POOL_ALLOC);
void *mem = alloc(reinterpret_cast<void *>(Offsets::VAR_SIMPLEFONTSTRING_POOL), 0,
reinterpret_cast<const char *>(Offsets::VAR_SIMPLEFONTSTRING_CLASS_TAG), -2);
if (mem == nullptr)
return nullptr;
auto ctor = reinterpret_cast<FontStringCtor_t>(Offsets::FUN_SIMPLEFONTSTRING_CTOR);
void *fs = ctor(mem, parent, Offsets::DRAWLAYER_ARTWORK, 1);
if (fs == nullptr)
return nullptr;
auto *fsBytes = reinterpret_cast<uint8_t *>(fs);
auto setName = reinterpret_cast<SetName_t>(Offsets::FUN_REGION_SET_NAME);
setName(fs, name);
void *fontObject = FontObjectOf(fontSource);
if (fontObject != nullptr) {
auto setFont = reinterpret_cast<SetFont_t>(Offsets::FUN_FONTSTRING_SET_FONT);
setFont(fsBytes + Offsets::OFF_FONTSTRING_FONT_HOLDER, fontObject);
}
if (relRegion != nullptr) {
auto setPoint = reinterpret_cast<SetPoint_t>(Offsets::FUN_REGION_SET_POINT);
setPoint(fsBytes + Offsets::OFF_REGION_ANCHOR, point,
reinterpret_cast<uint8_t *>(relRegion) + Offsets::OFF_REGION_ANCHOR,
relPoint, PixelToInternal(x), PixelToInternal(y), 1);
}
return fs;
}
// Appends one (left, right) line pair to the tooltip's three parallel
// arrays and bumps `numLinesAllocated` — the C++ equivalent of the
// engine's own FUN_0061fe30. Reallocs each array via its {count,cap,data}
// descriptor (the reallocator preserves `cap` old elements; we set the new
// cap to match, as the scan does).
void AppendLine(uint8_t *tooltip, void *left, void *right) {
auto reallocPtr = reinterpret_cast<ReallocArray_t>(Offsets::FUN_TOOLTIP_REALLOC_PTR_ARRAY);
auto reallocInt = reinterpret_cast<ReallocArray_t>(Offsets::FUN_TOOLTIP_REALLOC_INT_ARRAY);
const uint32_t index = *reinterpret_cast<uint32_t *>(
tooltip + Offsets::OFF_GAMETOOLTIP_NUM_LINES_ALLOC);
const uint32_t newCount = index + 1;
reallocPtr(tooltip + Offsets::OFF_GAMETOOLTIP_TEXTLEFT_DESC, newCount);
*reinterpret_cast<uint32_t *>(tooltip + Offsets::OFF_GAMETOOLTIP_TEXTLEFT_DESC + 4) = newCount;
reallocPtr(tooltip + Offsets::OFF_GAMETOOLTIP_TEXTRIGHT_DESC, newCount);
*reinterpret_cast<uint32_t *>(tooltip + Offsets::OFF_GAMETOOLTIP_TEXTRIGHT_DESC + 4) = newCount;
reallocInt(tooltip + Offsets::OFF_GAMETOOLTIP_WRAPFLAG_DESC, newCount);
*reinterpret_cast<uint32_t *>(tooltip + Offsets::OFF_GAMETOOLTIP_WRAPFLAG_DESC + 4) = newCount;
reinterpret_cast<void **>(DescData(tooltip, Offsets::OFF_GAMETOOLTIP_TEXTLEFT_DESC))[index] = left;
reinterpret_cast<void **>(DescData(tooltip, Offsets::OFF_GAMETOOLTIP_TEXTRIGHT_DESC))[index] = right;
reinterpret_cast<uint32_t *>(DescData(tooltip, Offsets::OFF_GAMETOOLTIP_WRAPFLAG_DESC))[index] = 0;
*reinterpret_cast<uint32_t *>(tooltip + Offsets::OFF_GAMETOOLTIP_NUM_LINES_ALLOC) = newCount;
}
// Grows `tooltip`'s line pool up to kMaxLines, creating + appending the
// missing `TextLeftN`/`TextRightN` pairs. No-op if the pool is empty
// (scan didn't populate) or already at/above the target.
void GrowPool(uint8_t *tooltip) {
uint32_t count = *reinterpret_cast<uint32_t *>(
tooltip + Offsets::OFF_GAMETOOLTIP_NUM_LINES_ALLOC);
if (count == 0 || count >= kMaxLines)
return;
auto vtable = *reinterpret_cast<void ***>(tooltip);
auto getName = reinterpret_cast<GetName_t>(vtable[1]); // slot 1 = GetName
const char *baseName = getName(tooltip);
if (baseName == nullptr || baseName[0] == '\0')
return;
for (uint32_t index = count; index < kMaxLines; ++index) {
// Previous line supplies both the anchor target and the font.
void *prevLeft = reinterpret_cast<void **>(
DescData(tooltip, Offsets::OFF_GAMETOOLTIP_TEXTLEFT_DESC))[index - 1];
void *prevRight = reinterpret_cast<void **>(
DescData(tooltip, Offsets::OFF_GAMETOOLTIP_TEXTRIGHT_DESC))[index - 1];
if (prevLeft == nullptr || prevRight == nullptr)
break;
char nameLeft[96];
char nameRight[96];
std::snprintf(nameLeft, sizeof nameLeft, "%sTextLeft%u", baseName, index + 1);
std::snprintf(nameRight, sizeof nameRight, "%sTextRight%u", baseName, index + 1);
// Left: TOPLEFT anchored to the previous left line's BOTTOMLEFT at
// (0, -2) — the template's exact chain. Right: RIGHT anchored to
// this line's own left LEFT at (40, 0).
void *left = CreateLine(tooltip, nameLeft, prevLeft, Offsets::FRAMEPOINT_TOPLEFT,
prevLeft, Offsets::FRAMEPOINT_BOTTOMLEFT, 0.0f, -2.0f);
if (left == nullptr)
break;
void *right = CreateLine(tooltip, nameRight, prevRight, Offsets::FRAMEPOINT_RIGHT,
left, Offsets::FRAMEPOINT_LEFT, 40.0f, 0.0f);
if (right == nullptr)
break;
AppendLine(tooltip, left, right);
}
}
// Co-hook of the tooltip's XML-OnLoad region scan. Runs the original
// (which counts the template's 30 lines and sizes the arrays), then grows
// the pool. Guarded on the CGameTooltip vtable so we never touch a
// non-tooltip object.
void __fastcall Scan_h(void *tooltip, void *edx, void *node, int flag) {
g_scanOriginal(tooltip, edx, node, flag);
if (tooltip != nullptr &&
*reinterpret_cast<uintptr_t *>(tooltip) == Offsets::VAR_GAMETOOLTIP_VTABLE)
GrowPool(reinterpret_cast<uint8_t *>(tooltip));
}
static const Game::HookAutoRegister _hookreg{
Offsets::FUN_GAMETOOLTIP_SETUP_LINES,
reinterpret_cast<void *>(&Scan_h),
reinterpret_cast<void **>(&g_scanOriginal)};
} // namespace
} // namespace Tooltip::LinePool
+168
View File
@@ -0,0 +1,168 @@
// 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/>.
// `GameTooltip:HookScript("OnTooltipSetItem", fn)` — backport of the modern
// tooltip script that fires whenever a tooltip's item is set, so addons can
// annotate item tooltips by hooking one script instead of wrapping every
// Set* method (the pattern AtlasLoot resorts to in vanilla, rebuilding the
// tooltip and clobbering its item linkage). Fully native — a real frame
// script, settable via the standard SetScript / GetScript / HookScript.
//
// 1.12 already has the machinery for tooltip scripts, just not this one:
//
// - The CGGameTooltip vtable resolver FUN_GAMETOOLTIP_SCRIPT_RESOLVER maps a
// script name to its handler slot on the tooltip (OnTooltipCleared etc.,
// each an 8-byte {handler, context} pair). SetScript/GetScript/HookScript
// all go through it. We co-hook it: for "OnTooltipSetItem" (a name the
// engine doesn't know) we hand back a pointer to a C-side per-tooltip cell
// — the tooltip object (alloc size 0x460) has no free 8-byte slot, and the
// resolver's only contract is "return the address the script lives at", so
// external storage works exactly like an in-object slot.
//
// - Every item setter (SetHyperlink item:, SetBagItem, SetInventoryItem,
// SetMerchantItem, SetAuctionItem, …) funnels through the shared item
// builder FUN_GAMETOOLTIP_BUILD_ITEM. We co-hook it and, after the build,
// fire the handler via FUN_FRAME_RUN_SCRIPT (the same no-arg runner the
// clear uses for OnTooltipCleared) with the tooltip as `self`.
//
// Suppressed during our own Tooltip::Compare equipped-item build (see
// Suppressor), and guarded against a handler that re-enters the builder.
#include "SetItemEvent.h"
#include "Game.h"
#include "Offsets.h"
#include <cstdint>
namespace Tooltip::SetItemEvent {
namespace {
// Per-tooltip 8-byte handler slot {handler, context}. Tooltips are few and
// permanent (GameTooltip, ItemRefTooltip, ShoppingTooltip1/2, AtlasLootTooltip,
// WorldMapTooltip, plus the odd addon tooltip), so a small fixed table with a
// linear scan is plenty and gives each cell a stable address to hand out.
struct Cell {
void *tooltip;
uint32_t slot[2]; // [0] = handler, [1] = exec context (written by SetScript)
};
constexpr int kMaxTooltips = 32;
Cell g_cells[kMaxTooltips];
int g_cellCount = 0;
uint32_t *CellFor(void *tooltip, bool create) {
for (int i = 0; i < g_cellCount; ++i)
if (g_cells[i].tooltip == tooltip)
return g_cells[i].slot;
if (!create || g_cellCount >= kMaxTooltips)
return nullptr;
Cell &c = g_cells[g_cellCount++];
c.tooltip = tooltip;
c.slot[0] = 0;
c.slot[1] = 0;
return c.slot;
}
bool EqualsIgnoreCase(const char *s, const char *literal) {
if (s == nullptr)
return false;
for (;; ++s, ++literal) {
unsigned char a = static_cast<unsigned char>(*s);
const unsigned char b = static_cast<unsigned char>(*literal);
if (a >= 'A' && a <= 'Z')
a = static_cast<unsigned char>(a + 32);
if (a != b)
return false;
if (b == 0)
return true;
}
}
constexpr char kScriptNameLower[] = "ontooltipsetitem"; // matched case-insensitively
// --- Suppression / recursion state -------------------------------------
int g_suppress = 0; // > 0 while our own compare build runs
int g_firing = 0; // recursion guard around the handler fire
// --- Resolver co-hook (CGGameTooltip vtable script-name → slot) ---------
using Resolver_t = int(__fastcall *)(void *self, void *edx, const char *name);
Resolver_t g_resolverOriginal = nullptr;
int __fastcall Resolver_h(void *self, void *edx, const char *name) {
const int engineSlot = g_resolverOriginal(self, edx, name);
if (engineSlot != 0) // a base-frame or existing tooltip script — leave it
return engineSlot;
if (EqualsIgnoreCase(name, kScriptNameLower))
return reinterpret_cast<int>(CellFor(self, /*create*/ true));
return 0;
}
// --- Item-builder co-hook (fires the handler after the item is set) -----
using Build_t = void(__fastcall *)(void *self, void *edx, uint32_t itemID, const void *guid,
const void *guid2, int a4, int a5, int a6, int headerFlag,
int a8, int a9);
Build_t g_buildOriginal = nullptr;
// The engine's real script invoker: binds the global `this` = frame and runs
// the handler under its own protected lua_pcall. We call it directly rather
// than the FUN_00702690 wrapper, whose exec-context mutation is unsafe from the
// deep nested Lua->C->Lua stack our co-hook fires within — see Offsets.h.
using InvokeScript_t = void(__fastcall *)(uint32_t handler, void *frame);
void __fastcall Build_h(void *self, void *edx, uint32_t itemID, const void *guid,
const void *guid2, int a4, int a5, int a6, int headerFlag, int a8,
int a9) {
g_buildOriginal(self, edx, itemID, guid, guid2, a4, a5, a6, headerFlag, a8, a9);
if (g_suppress > 0 || g_firing > 0)
return;
uint32_t *slot = CellFor(self, /*create*/ false);
if (slot == nullptr || slot[0] == 0)
return;
// We fire from inside the item builder, which is itself mid-execution of
// the engine's Script_Set* (and, in the wild, nested under addon Set*
// wrappers + several other DLLs' hooks). The invoker pushes onto the Lua
// stack and does NOT restore the top to where the outer, still-running C
// code left it — harmless from the engine's top-level dispatch, but from
// here it shifts the stack and the outer code (and other DLLs' hooks) then
// index garbage → deref → crash. Snapshot and restore the stack top around
// the fire so the outer code sees exactly the stack it left.
void *L = Game::Lua::State();
const int savedTop = (L != nullptr) ? Game::Lua::GetTop(L) : 0;
++g_firing;
reinterpret_cast<InvokeScript_t>(Offsets::FUN_FRAME_INVOKE_SCRIPT)(slot[0], self);
--g_firing;
if (L != nullptr)
Game::Lua::SetTop(L, savedTop);
}
static const Game::HookAutoRegister _resolverHook{
Offsets::FUN_GAMETOOLTIP_SCRIPT_RESOLVER,
reinterpret_cast<void *>(&Resolver_h),
reinterpret_cast<void **>(&g_resolverOriginal)};
static const Game::HookAutoRegister _buildHook{
Offsets::FUN_GAMETOOLTIP_BUILD_ITEM,
reinterpret_cast<void *>(&Build_h),
reinterpret_cast<void **>(&g_buildOriginal)};
} // namespace
Suppressor::Suppressor() { ++g_suppress; }
Suppressor::~Suppressor() {
if (g_suppress > 0)
--g_suppress;
}
} // namespace Tooltip::SetItemEvent
+30
View File
@@ -0,0 +1,30 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
#pragma once
namespace Tooltip::SetItemEvent {
// RAII guard that suppresses OnTooltipSetItem firing for the duration of an
// engine item-build we invoke *internally* (Tooltip::Compare building the
// equipped item into a shopping tooltip). Without it, a handler would run in
// the middle of our line-shift / delta rendering and see a half-built tooltip.
// Nestable (ref-counted).
struct Suppressor {
Suppressor();
~Suppressor();
Suppressor(const Suppressor &) = delete;
Suppressor &operator=(const Suppressor &) = delete;
};
} // namespace Tooltip::SetItemEvent
+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
+21
View File
@@ -242,4 +242,25 @@ constexpr Entry kColors[] = {
constexpr int kColorCount = sizeof(kColors) / sizeof(kColors[0]);
// Looks up a color by `baseTag` and returns it as a packed 32-bit value.
// The DBC `argb` column decodes as `0xAARRGGBB` (see the byte-split note
// above) — which is also exactly the little-endian {b,g,r,a} byte order
// the engine's tooltip line-color setter (FUN_0077f750) reads, so the
// returned value can be handed straight to a colored AddLine. Returns
// `fallback` (default opaque white) when the tag isn't present. constexpr
// so callers can bind named color constants at compile time.
constexpr uint32_t ByTag(const char *tag, uint32_t fallback = 0xFFFFFFFFu) {
for (const auto &e : kColors) {
const char *a = e.baseTag;
const char *b = tag;
while (*a != '\0' && *a == *b) {
++a;
++b;
}
if (*a == '\0' && *b == '\0')
return static_cast<uint32_t>(e.argb);
}
return fallback;
}
} // namespace UI::ColorData
+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`).
+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;
+594
View File
@@ -0,0 +1,594 @@
// 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/>.
// Text-to-speech — a Windows SAPI backend behind modern WoW's C_VoiceChat /
// C_TTSSettings Lua surface. Ported from awesome_wotlk's VoiceChat.cpp (the
// 3.3.5 implementation); the SAPI core is client-version-agnostic, so only
// the three integration seams are ClassicAPI-native:
// - namespaces → Game::Lua::RegisterTableFunction
// - events → Event::Custom (AutoReserve + Fire)
// - settings → CVar::Factory (ttsVoice / ttsSpeed / ttsVolume, persisted
// to Config.wtf, clamped via change callbacks)
//
// Lua surface:
// C_VoiceChat
// GetTtsVoices() -> { {voiceID=, name=}, ... }
// GetRemoteTtsVoices() -> same (vanilla has no real voice chat)
// SpeakText(voiceID, text[, destination[, rate[, volume]]])
// StopSpeakingText()
// C_TTSSettings
// GetSpeechRate() / GetSpeechVolume() / GetSpeechVoiceID()
// GetVoiceOptionName()
// SetDefaultSettings()
// SetSpeechRate(v) / SetSpeechVolume(v)
// SetVoiceOption(id) / SetVoiceOptionByName(name)
// RefreshVoices()
//
// Events: VOICE_CHAT_TTS_PLAYBACK_STARTED/FINISHED/FAILED,
// VOICE_CHAT_TTS_SPEAK_TEXT_UPDATE (reserved, unused),
// VOICE_CHAT_TTS_VOICES_UPDATE.
//
// Threading: SAPI is created on the main (game) thread under an STA, and
// SetNotifyCallbackFunction marshals the notify back to that thread's message
// pump (WoW pumps messages every frame), so OnSpeechNotify runs on the main
// thread and may touch the Lua event dispatcher safely.
//
// Coexistence: the same TTS surface also ships as the standalone VanillaTTS
// DLL (which adds a pluggable backend — software synth under Wine). When
// that DLL is injected alongside us, this module stands down entirely — see
// StandaloneTtsPresent below.
#include "Game.h"
#include "cvar/Factory.h"
#include "event/Custom.h"
#include <initguid.h> // instantiate the SAPI GUIDs in this TU (no sapi.lib)
#include <sapi.h>
#include <windows.h>
#include <algorithm>
#include <cstdint>
#include <cstdio>
#include <cstdlib>
#include <cwctype>
#include <mutex>
#include <string>
#include <unordered_map>
#include <vector>
namespace VoiceChat::Tts {
namespace {
// ---------------------------------------------------------------------------
// Standalone-DLL coexistence
// ---------------------------------------------------------------------------
// The dedicated VanillaTTS DLL provides this exact Lua surface (same
// C_VoiceChat / C_TTSSettings functions, same VOICE_CHAT_TTS_* events, same
// ttsVoice/ttsSpeed/ttsVolume cvars) with a more capable backend. If both
// registered, the damage wouldn't be limited to "last one wins":
// - Each DLL claims its OWN event-table slot for the same event name, so
// the table ends up with duplicate-name entries. frame:RegisterEvent
// binds listeners to whichever slot the engine's scan finds first, while
// the other DLL fires the other slot — events silently lost depending on
// load order.
// - The cvar registrar dedups by name but REPLACES the change callback on
// re-register, so one DLL's clamp callbacks (and userData) go dead.
// - Both would init COM/SAPI and answer SpeakText → double playback risk.
// So when VanillaTTS.dll is in the process, this module registers nothing —
// no Lua functions, no cvars, no event reservations, no SAPI init.
//
// The check runs at RegisterLuaFunctions time (first login), which is
// load-order-independent: VanillaFixes loads every DLL at process attach,
// long before FUN_LOAD_SCRIPT_FUNCTIONS first fires. Latched once — modules
// can't appear or vanish mid-session.
bool StandaloneTtsPresent() {
static const bool present = GetModuleHandleA("VanillaTTS.dll") != nullptr;
return present;
}
// ---------------------------------------------------------------------------
// Event names — reserved lazily in RegisterLuaFunctions (NOT via file-scope
// AutoReserve statics, which would run unconditionally at DLL load and claim
// slots even when we're standing down for VanillaTTS).
// ---------------------------------------------------------------------------
constexpr const char *kEvtPlaybackFailed = "VOICE_CHAT_TTS_PLAYBACK_FAILED";
constexpr const char *kEvtPlaybackFinished = "VOICE_CHAT_TTS_PLAYBACK_FINISHED";
constexpr const char *kEvtPlaybackStarted = "VOICE_CHAT_TTS_PLAYBACK_STARTED";
constexpr const char *kEvtSpeakTextUpdate = "VOICE_CHAT_TTS_SPEAK_TEXT_UPDATE";
constexpr const char *kEvtVoicesUpdate = "VOICE_CHAT_TTS_VOICES_UPDATE";
// ---------------------------------------------------------------------------
// CVar names + handles (registered in RegisterLuaFunctions)
// ---------------------------------------------------------------------------
constexpr const char *kCVarVoice = "ttsVoice";
constexpr const char *kCVarSpeed = "ttsSpeed";
constexpr const char *kCVarVolume = "ttsVolume";
CVar::Factory::Handle g_cvarVoice = nullptr;
CVar::Factory::Handle g_cvarSpeed = nullptr;
CVar::Factory::Handle g_cvarVolume = nullptr;
// Destination constants (kept for API parity; no special handling — SAPI
// queues FIFO either way).
enum : int {
DEST_LOCAL_PLAYBACK = 1,
DEST_QUEUED_LOCAL_PLAYBACK = 4,
};
int ClampDestination(int d) {
return d == DEST_QUEUED_LOCAL_PLAYBACK ? DEST_QUEUED_LOCAL_PLAYBACK
: DEST_LOCAL_PLAYBACK;
}
// ---------------------------------------------------------------------------
// SAPI state
// ---------------------------------------------------------------------------
ISpVoice *g_pVoice = nullptr;
bool g_comInit = false;
int g_nextUtteranceID = 1;
int NextUtteranceID() {
if (g_nextUtteranceID >= 0x7fffffff)
g_nextUtteranceID = 1;
return g_nextUtteranceID++;
}
struct UtteranceMeta {
int id;
int destination;
bool startedEmitted;
};
std::mutex g_streamMx;
std::unordered_map<ULONG, UtteranceMeta> g_streamMap;
struct VoiceEntry {
int voiceID;
std::wstring name;
};
std::vector<VoiceEntry> g_cachedVoices;
// ---------------------------------------------------------------------------
// UTF-8 / wide conversion
// ---------------------------------------------------------------------------
std::string WideToUtf8(const std::wstring &w) {
if (w.empty())
return {};
int need = WideCharToMultiByte(CP_UTF8, 0, w.c_str(), -1, nullptr, 0, nullptr, nullptr);
if (need <= 1)
return {};
std::string out(static_cast<size_t>(need - 1), '\0');
WideCharToMultiByte(CP_UTF8, 0, w.c_str(), -1, &out[0], need - 1, nullptr, nullptr);
return out;
}
std::wstring Utf8ToWide(const char *s) {
if (s == nullptr || *s == '\0')
return {};
int need = MultiByteToWideChar(CP_UTF8, 0, s, -1, nullptr, 0);
if (need <= 1)
return {};
std::wstring out(static_cast<size_t>(need - 1), L'\0');
MultiByteToWideChar(CP_UTF8, 0, s, -1, &out[0], need);
return out;
}
// ---------------------------------------------------------------------------
// Event fire helpers (engine dispatcher via Event::Custom)
// ---------------------------------------------------------------------------
void FireVoicesUpdate() {
Event::Custom::Fire(Event::Custom::Lookup(kEvtVoicesUpdate), "");
}
void FirePlaybackStarted(int numConsumers, int utteranceID, int durationMS, int dest) {
Event::Custom::Fire(Event::Custom::Lookup(kEvtPlaybackStarted), "%d%d%d%d",
numConsumers, utteranceID, durationMS, dest);
}
void FirePlaybackFinished(int numConsumers, int utteranceID, int dest) {
Event::Custom::Fire(Event::Custom::Lookup(kEvtPlaybackFinished), "%d%d%d",
numConsumers, utteranceID, dest);
}
void FirePlaybackFailed(const char *status, int utteranceID, int dest) {
Event::Custom::Fire(Event::Custom::Lookup(kEvtPlaybackFailed), "%s%d%d",
status, utteranceID, dest);
}
// ---------------------------------------------------------------------------
// COM / SAPI lifecycle
// ---------------------------------------------------------------------------
void InitCom() {
if (g_comInit)
return;
HRESULT hr = CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED);
// S_FALSE = already initialized on this thread; treat as success.
if (SUCCEEDED(hr) || hr == RPC_E_CHANGED_MODE)
g_comInit = true;
}
void __stdcall OnSpeechNotify(WPARAM, LPARAM) {
if (g_pVoice == nullptr)
return;
SPEVENT ev = {};
ULONG fetched = 0;
while (SUCCEEDED(g_pVoice->GetEvents(1, &ev, &fetched)) && fetched == 1) {
if (ev.eEventId == SPEI_START_INPUT_STREAM) {
std::lock_guard<std::mutex> lock(g_streamMx);
auto it = g_streamMap.find(ev.ulStreamNum);
if (it != g_streamMap.end() && !it->second.startedEmitted) {
FirePlaybackStarted(1, it->second.id, /*durationMS*/ 0,
it->second.destination);
it->second.startedEmitted = true;
}
} else if (ev.eEventId == SPEI_END_INPUT_STREAM) {
UtteranceMeta meta{0, DEST_LOCAL_PLAYBACK, false};
{
std::lock_guard<std::mutex> lock(g_streamMx);
auto it = g_streamMap.find(ev.ulStreamNum);
if (it != g_streamMap.end()) {
meta = it->second;
g_streamMap.erase(it);
}
}
FirePlaybackFinished(1, meta.id, meta.destination);
}
// Only START/END stream events are requested; neither carries an
// allocated lParam, so no SpClearEvent (sphelper) is needed.
ev = SPEVENT{};
}
}
void InitVoice() {
if (g_pVoice != nullptr)
return;
InitCom();
HRESULT hr = CoCreateInstance(CLSID_SpVoice, nullptr, CLSCTX_ALL,
IID_ISpVoice, reinterpret_cast<void **>(&g_pVoice));
if (FAILED(hr) || g_pVoice == nullptr) {
g_pVoice = nullptr;
return;
}
g_pVoice->SetNotifyCallbackFunction(&OnSpeechNotify, 0, 0);
const ULONGLONG interest =
SPFEI(SPEI_START_INPUT_STREAM) | SPFEI(SPEI_END_INPUT_STREAM);
g_pVoice->SetInterest(interest, interest);
}
// ---------------------------------------------------------------------------
// Voice enumeration (CLSID_SpObjectTokenCategory; no sphelper)
// ---------------------------------------------------------------------------
std::vector<VoiceEntry> EnumerateVoices() {
InitCom();
std::vector<VoiceEntry> voices;
ISpObjectTokenCategory *cat = nullptr;
if (FAILED(CoCreateInstance(CLSID_SpObjectTokenCategory, nullptr, CLSCTX_ALL,
IID_ISpObjectTokenCategory,
reinterpret_cast<void **>(&cat))) ||
cat == nullptr)
return voices;
if (SUCCEEDED(cat->SetId(SPCAT_VOICES, FALSE))) {
IEnumSpObjectTokens *en = nullptr;
if (SUCCEEDED(cat->EnumTokens(nullptr, nullptr, &en)) && en != nullptr) {
ISpObjectToken *tok = nullptr;
ULONG fetched = 0;
int index = 0;
while (en->Next(1, &tok, &fetched) == S_OK && fetched == 1) {
LPWSTR desc = nullptr;
if (SUCCEEDED(tok->GetStringValue(nullptr, &desc)) && desc != nullptr) {
voices.push_back({index, desc});
CoTaskMemFree(desc);
}
tok->Release();
++index;
}
en->Release();
}
}
cat->Release();
return voices;
}
int VoiceCount() {
return static_cast<int>(EnumerateVoices().size());
}
void RefreshVoices() {
auto fresh = EnumerateVoices();
bool changed = fresh.size() != g_cachedVoices.size();
if (!changed) {
for (size_t i = 0; i < fresh.size(); ++i) {
if (fresh[i].name != g_cachedVoices[i].name) {
changed = true;
break;
}
}
}
g_cachedVoices = std::move(fresh);
if (changed)
FireVoicesUpdate();
}
// Select the voice at enumeration index `voiceID` on g_pVoice.
bool SelectVoice(int voiceID) {
ISpObjectTokenCategory *cat = nullptr;
if (FAILED(CoCreateInstance(CLSID_SpObjectTokenCategory, nullptr, CLSCTX_ALL,
IID_ISpObjectTokenCategory,
reinterpret_cast<void **>(&cat))) ||
cat == nullptr)
return false;
bool set = false;
if (SUCCEEDED(cat->SetId(SPCAT_VOICES, FALSE))) {
IEnumSpObjectTokens *en = nullptr;
if (SUCCEEDED(cat->EnumTokens(nullptr, nullptr, &en)) && en != nullptr) {
ISpObjectToken *tok = nullptr;
ULONG fetched = 0;
int index = 0;
while (en->Next(1, &tok, &fetched) == S_OK && fetched == 1) {
if (index == voiceID) {
set = SUCCEEDED(g_pVoice->SetVoice(tok));
tok->Release();
break;
}
tok->Release();
++index;
}
en->Release();
}
}
cat->Release();
return set;
}
// ---------------------------------------------------------------------------
// Speak / stop
// ---------------------------------------------------------------------------
void SpeakText(int voiceID, const std::wstring &text, int destination, int rate,
int volume) {
const int utteranceID = NextUtteranceID();
const int dest = ClampDestination(destination);
InitVoice();
if (g_pVoice == nullptr) {
FirePlaybackFailed("EngineAllocationFailed", utteranceID, dest);
return;
}
if (!SelectVoice(voiceID)) {
FirePlaybackFailed("InternalError", utteranceID, dest);
return;
}
g_pVoice->SetRate(std::clamp(rate, -10, 10));
g_pVoice->SetVolume(static_cast<USHORT>(std::clamp(volume, 0, 100)));
ULONG streamNum = 0;
if (FAILED(g_pVoice->Speak(text.c_str(), SPF_ASYNC, &streamNum))) {
FirePlaybackFailed("InternalError", utteranceID, dest);
return;
}
std::lock_guard<std::mutex> lock(g_streamMx);
g_streamMap[streamNum] = UtteranceMeta{utteranceID, dest, false};
}
void StopAll() {
InitVoice();
if (g_pVoice == nullptr)
return;
g_pVoice->Speak(nullptr, SPF_PURGEBEFORESPEAK, nullptr);
std::lock_guard<std::mutex> lock(g_streamMx);
g_streamMap.clear();
}
// ---------------------------------------------------------------------------
// Lua helpers
// ---------------------------------------------------------------------------
// Push a { {voiceID=, name=}, ... } array built from g_cachedVoices.
void PushVoicesTable(void *L) {
Game::Lua::NewTable(L); // outer array at top
for (size_t i = 0; i < g_cachedVoices.size(); ++i) {
Game::Lua::PushNumber(L, static_cast<double>(i + 1)); // 1-based array key
Game::Lua::NewTable(L); // sub-table
Game::Lua::SetFieldNumber(L, "voiceID", g_cachedVoices[i].voiceID);
Game::Lua::SetFieldString(L, "name",
WideToUtf8(g_cachedVoices[i].name).c_str());
// stack: [outer, key, sub] → outer[key] = sub
Game::Lua::RawSet(L, -3);
}
}
int OptInt(void *L, int idx, int fallback) {
return Game::Lua::IsNumber(L, idx)
? static_cast<int>(Game::Lua::ToNumber(L, idx))
: fallback;
}
// ---------------------------------------------------------------------------
// C_VoiceChat
// ---------------------------------------------------------------------------
int __fastcall Script_GetTtsVoices(void *L) {
RefreshVoices();
PushVoicesTable(L);
return 1;
}
int __fastcall Script_GetRemoteTtsVoices(void *L) {
RefreshVoices();
PushVoicesTable(L);
return 1;
}
int __fastcall Script_SpeakText(void *L) {
if (!Game::Lua::IsNumber(L, 1) || !Game::Lua::IsString(L, 2)) {
Game::Lua::Error(L, "Usage: C_VoiceChat.SpeakText(voiceID, text "
"[, destination, rate, volume])");
return 0;
}
const int voiceID = static_cast<int>(Game::Lua::ToNumber(L, 1));
const std::wstring text = Utf8ToWide(Game::Lua::ToString(L, 2));
const int dest = OptInt(L, 3, DEST_LOCAL_PLAYBACK);
const int rate = OptInt(L, 4, 0);
const int volume = OptInt(L, 5, 100);
SpeakText(voiceID, text, dest, rate, volume);
return 0;
}
int __fastcall Script_StopSpeakingText(void *) {
StopAll();
return 0;
}
// ---------------------------------------------------------------------------
// C_TTSSettings
// ---------------------------------------------------------------------------
int __fastcall Script_GetSpeechRate(void *L) {
Game::Lua::PushNumber(L, CVar::Factory::GetInt(g_cvarSpeed, 0));
return 1;
}
int __fastcall Script_GetSpeechVolume(void *L) {
Game::Lua::PushNumber(L, CVar::Factory::GetInt(g_cvarVolume, 100));
return 1;
}
int __fastcall Script_GetSpeechVoiceID(void *L) {
Game::Lua::PushNumber(L, CVar::Factory::GetInt(g_cvarVoice, 0));
return 1;
}
int __fastcall Script_GetVoiceOptionName(void *L) {
const int id = CVar::Factory::GetInt(g_cvarVoice, 0);
auto voices = EnumerateVoices();
if (id >= 0 && id < static_cast<int>(voices.size()))
Game::Lua::PushString(L, WideToUtf8(voices[id].name).c_str());
else
Game::Lua::PushString(L, "");
return 1;
}
int __fastcall Script_SetSpeechRate(void *L) {
if (Game::Lua::IsNumber(L, 1))
CVar::Factory::SetInt(g_cvarSpeed, static_cast<int>(Game::Lua::ToNumber(L, 1)));
return 0;
}
int __fastcall Script_SetSpeechVolume(void *L) {
if (Game::Lua::IsNumber(L, 1))
CVar::Factory::SetInt(g_cvarVolume, static_cast<int>(Game::Lua::ToNumber(L, 1)));
return 0;
}
int __fastcall Script_SetVoiceOption(void *L) {
if (Game::Lua::IsNumber(L, 1))
CVar::Factory::SetInt(g_cvarVoice, static_cast<int>(Game::Lua::ToNumber(L, 1)));
return 0;
}
int __fastcall Script_SetVoiceOptionByName(void *L) {
if (!Game::Lua::IsString(L, 1))
return 0;
const std::wstring want = Utf8ToWide(Game::Lua::ToString(L, 1));
auto voices = EnumerateVoices();
for (const auto &v : voices) {
if (v.name.size() == want.size()) {
bool eq = true;
for (size_t i = 0; i < want.size(); ++i) {
if (towlower(v.name[i]) != towlower(want[i])) {
eq = false;
break;
}
}
if (eq) {
CVar::Factory::SetInt(g_cvarVoice, v.voiceID);
break;
}
}
}
return 0;
}
int __fastcall Script_SetDefaultSettings(void *) {
const int maxVoice = VoiceCount();
CVar::Factory::SetInt(g_cvarVoice, maxVoice > 1 ? 1 : 0);
CVar::Factory::SetInt(g_cvarSpeed, 0);
CVar::Factory::SetInt(g_cvarVolume, 100);
FireVoicesUpdate();
return 0;
}
int __fastcall Script_RefreshVoices(void *) {
RefreshVoices();
return 0;
}
// ---------------------------------------------------------------------------
// CVar clamp callbacks (re-entrant SetString to clamp; reject out-of-range)
// ---------------------------------------------------------------------------
int __fastcall ClampRange(CVar::Factory::Handle cvar, const char *, const char *next,
int lo, int hi) {
if (next == nullptr)
return 1;
const int v = std::atoi(next);
const int c = std::clamp(v, lo, hi);
if (c != v) {
char buf[16];
std::snprintf(buf, sizeof buf, "%d", c);
CVar::Factory::SetString(cvar, buf);
return 0;
}
return 1;
}
int __fastcall OnSpeedChanged(CVar::Factory::Handle c, const char *p, const char *n, void *) {
return ClampRange(c, p, n, -10, 10);
}
int __fastcall OnVolumeChanged(CVar::Factory::Handle c, const char *p, const char *n, void *) {
return ClampRange(c, p, n, 0, 100);
}
int __fastcall OnVoiceChanged(CVar::Factory::Handle c, const char *p, const char *n, void *) {
const int maxIdx = VoiceCount() - 1;
return ClampRange(c, p, n, 0, maxIdx < 0 ? 0 : maxIdx);
}
// ---------------------------------------------------------------------------
// Registration
// ---------------------------------------------------------------------------
void RegisterLuaFunctions() {
if (StandaloneTtsPresent())
return; // VanillaTTS owns the TTS surface this session — see above.
// Reserve the events on first (real) registration. AutoReserve only
// appends to Event::Custom's reservation list — the actual slot claims
// happen later via the usual RetryClaims walk — so reserving here instead
// of at static-init time changes nothing but the conditionality.
static const Event::Custom::AutoReserve _r1{kEvtPlaybackFailed};
static const Event::Custom::AutoReserve _r2{kEvtPlaybackFinished};
static const Event::Custom::AutoReserve _r3{kEvtPlaybackStarted};
static const Event::Custom::AutoReserve _r4{kEvtSpeakTextUpdate};
static const Event::Custom::AutoReserve _r5{kEvtVoicesUpdate};
g_cvarVoice = CVar::Factory::Register(kCVarVoice, "0", 0, &OnVoiceChanged);
g_cvarSpeed = CVar::Factory::Register(kCVarSpeed, "0", 0, &OnSpeedChanged);
g_cvarVolume = CVar::Factory::Register(kCVarVolume, "100", 0, &OnVolumeChanged);
Game::Lua::RegisterTableFunction("C_VoiceChat", "GetTtsVoices", &Script_GetTtsVoices);
Game::Lua::RegisterTableFunction("C_VoiceChat", "GetRemoteTtsVoices", &Script_GetRemoteTtsVoices);
Game::Lua::RegisterTableFunction("C_VoiceChat", "SpeakText", &Script_SpeakText);
Game::Lua::RegisterTableFunction("C_VoiceChat", "StopSpeakingText", &Script_StopSpeakingText);
Game::Lua::RegisterTableFunction("C_TTSSettings", "GetSpeechRate", &Script_GetSpeechRate);
Game::Lua::RegisterTableFunction("C_TTSSettings", "GetSpeechVolume", &Script_GetSpeechVolume);
Game::Lua::RegisterTableFunction("C_TTSSettings", "GetSpeechVoiceID", &Script_GetSpeechVoiceID);
Game::Lua::RegisterTableFunction("C_TTSSettings", "GetVoiceOptionName", &Script_GetVoiceOptionName);
Game::Lua::RegisterTableFunction("C_TTSSettings", "SetDefaultSettings", &Script_SetDefaultSettings);
Game::Lua::RegisterTableFunction("C_TTSSettings", "SetSpeechRate", &Script_SetSpeechRate);
Game::Lua::RegisterTableFunction("C_TTSSettings", "SetSpeechVolume", &Script_SetSpeechVolume);
Game::Lua::RegisterTableFunction("C_TTSSettings", "SetVoiceOption", &Script_SetVoiceOption);
Game::Lua::RegisterTableFunction("C_TTSSettings", "SetVoiceOptionByName", &Script_SetVoiceOptionByName);
Game::Lua::RegisterTableFunction("C_TTSSettings", "RefreshVoices", &Script_RefreshVoices);
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace
} // namespace VoiceChat::Tts
+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