Compare commits

...
32 Commits
Author SHA1 Message Date
Brues d09137cae0 player: add C_PlayerInfo Get{Name,Class,Race,Sex} / IsConnected(playerLocation)
The PlayerLocation-taking C_PlayerInfo accessors. Resolve the location to a
unit token - unit locations use their token, GUID locations via
Unit::Identity::TokenFromGUID (while the GUID has a live token) - then:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

GetItemNameByID is unchanged -- an itemID has no instance, so base name
only is correct.
2026-07-15 15:06:04 -05:00
53 changed files with 4837 additions and 500 deletions
+1
View File
@@ -23,6 +23,7 @@ Util\CallbackRegistry.lua
Util\GlobalCallbackRegistry.lua
Util\ColorUtil.lua
Util\ItemLocation.lua
Util\PlayerLocation.lua
Util\EventUtil.lua
Util\ItemUtil.lua
Util\AddOnCompat.lua
@@ -0,0 +1,228 @@
-- Backport of the modern `PlayerLocation` / `PlayerLocationMixin` parameter
-- object (Blizzard_ObjectAPI). Like `ItemLocation`, it's a lightweight
-- descriptor that wraps one of several ways to identify a player — a GUID, a
-- unit token, a chat line, a /who index, a battlefield-scoreboard index, etc.
-- — so retail/Classic addon code can construct and inspect it unchanged.
--
-- It's pure table bookkeeping: every Create/Set/Get/Is is just fields, no
-- engine access. The GUID / unit / chatLine / whoIndex / battlefieldScore
-- kinds all map to things vanilla 1.12 genuinely has. The community, voice,
-- and Battle.net kinds have no vanilla producer, but they're kept as harmless
-- data holders so the mixin matches retail exactly for any code that builds
-- or reads them. Only the two methods that reach into systems 1.12 lacks —
-- `IsValid` (Club) and `IsBattleNetGUID` (account info) — are guarded so they
-- degrade gracefully instead of erroring on the missing `C_Club` /
-- `C_AccountInfo` namespaces.
local CreateFromMixins = CreateFromMixins
PlayerLocation = PlayerLocation or {}
PlayerLocationMixin = PlayerLocationMixin or {}
--[[static]] function PlayerLocation:CreateFromGUID(guid)
local playerLocation = CreateFromMixins(PlayerLocationMixin)
playerLocation:SetGUID(guid)
return playerLocation
end
--[[static]] function PlayerLocation:CreateFromUnit(unit)
local playerLocation = CreateFromMixins(PlayerLocationMixin)
playerLocation:SetUnit(unit)
return playerLocation
end
--[[static]] function PlayerLocation:CreateFromChatLineID(lineID)
local playerLocation = CreateFromMixins(PlayerLocationMixin)
playerLocation:SetChatLineID(lineID)
return playerLocation
end
--[[static]] function PlayerLocation:CreateFromBattlefieldScoreIndex(battlefieldScoreIndex)
local playerLocation = CreateFromMixins(PlayerLocationMixin)
playerLocation:SetBattlefieldScoreIndex(battlefieldScoreIndex)
return playerLocation
end
--[[static]] function PlayerLocation:CreateFromWhoIndex(whoIndex)
local playerLocation = CreateFromMixins(PlayerLocationMixin)
playerLocation:SetWhoIndex(whoIndex)
return playerLocation
end
--[[static]] function PlayerLocation:CreateFromVoiceID(memberID, channelID)
local playerLocation = CreateFromMixins(PlayerLocationMixin)
playerLocation:SetVoiceID(memberID, channelID)
return playerLocation
end
--[[static]] function PlayerLocation:CreateFromBattleNetID(battleNetID)
local playerLocation = CreateFromMixins(PlayerLocationMixin)
playerLocation:SetBattleNetID(battleNetID)
return playerLocation
end
--[[static]] function PlayerLocation:CreateFromCommunityChatData(clubID, streamID, epoch, position)
local playerLocation = CreateFromMixins(PlayerLocationMixin)
playerLocation:SetCommunityData(clubID, streamID, epoch, position)
return playerLocation
end
--[[static]] function PlayerLocation:CreateFromCommunityInvitation(clubID, guid)
local playerLocation = CreateFromMixins(PlayerLocationMixin)
playerLocation:SetCommunityInvitation(clubID, guid)
return playerLocation
end
--[[public api]]
function PlayerLocationMixin:SetGUID(guid)
self:ClearAndSetField("guid", guid)
end
function PlayerLocationMixin:IsValid()
if self:IsCommunityData() then
-- Vanilla has no Community/Club system; if the namespace is
-- absent a community location can never be resolved.
if C_Club and C_Club.CanResolvePlayerLocationFromClubMessageData then
return C_Club.CanResolvePlayerLocationFromClubMessageData(self.communityClubID, self.communityStreamID, self.communityEpoch, self.communityPosition)
end
return false
end
return true
end
function PlayerLocationMixin:IsGUID()
return self.guid ~= nil
end
function PlayerLocationMixin:IsBattleNetGUID()
-- No Battle.net accounts on 1.12; guarded so it returns false instead
-- of erroring on a missing C_AccountInfo.
if self.guid and C_AccountInfo and C_AccountInfo.IsGUIDBattleNetAccountType then
return C_AccountInfo.IsGUIDBattleNetAccountType(self.guid)
end
return false
end
function PlayerLocationMixin:GetGUID()
return self.guid or self.communityClubInviterGUID
end
function PlayerLocationMixin:SetUnit(unit)
self:ClearAndSetField("unit", unit)
end
function PlayerLocationMixin:IsUnit()
return self.unit ~= nil
end
function PlayerLocationMixin:GetUnit()
return self.unit
end
function PlayerLocationMixin:SetChatLineID(lineID)
self:ClearAndSetField("chatLineID", lineID)
end
function PlayerLocationMixin:IsChatLineID()
return self.chatLineID ~= nil
end
function PlayerLocationMixin:GetChatLineID()
return self.chatLineID
end
function PlayerLocationMixin:SetBattlefieldScoreIndex(index)
self:ClearAndSetField("battlefieldScoreIndex", index)
end
function PlayerLocationMixin:IsBattlefieldScoreIndex()
return self.battlefieldScoreIndex ~= nil
end
function PlayerLocationMixin:GetBattlefieldScoreIndex()
return self.battlefieldScoreIndex
end
function PlayerLocationMixin:SetWhoIndex(whoIndex)
self:ClearAndSetField("whoIndex", whoIndex)
end
function PlayerLocationMixin:IsWhoIndex()
return self.whoIndex ~= nil
end
function PlayerLocationMixin:GetWhoIndex()
return self.whoIndex
end
function PlayerLocationMixin:SetVoiceID(memberID, channelID)
self:Clear()
self.voiceMemberID = memberID
self.voiceChannelID = channelID
end
function PlayerLocationMixin:IsVoiceID()
return self.voiceMemberID ~= nil and self.voiceChannelID ~= nil
end
function PlayerLocationMixin:GetVoiceID()
return self.voiceMemberID, self.voiceChannelID
end
function PlayerLocationMixin:SetBattleNetID(battleNetID)
self:Clear()
self.battleNetID = battleNetID
end
function PlayerLocationMixin:IsBattleNetID()
return self.battleNetID ~= nil
end
function PlayerLocationMixin:GetBattleNetID()
return self.battleNetID
end
function PlayerLocationMixin:SetCommunityData(clubID, streamID, epoch, position)
self:Clear()
self.communityClubID = clubID
self.communityStreamID = streamID
self.communityEpoch = epoch
self.communityPosition = position
end
function PlayerLocationMixin:IsCommunityData()
return self.communityClubID ~= nil and self.communityStreamID ~= nil and self.communityEpoch ~= nil and self.communityPosition ~= nil
end
function PlayerLocationMixin:SetCommunityInvitation(clubID, guid)
self:Clear()
self.communityClubID = clubID
self.communityClubInviterGUID = guid
end
function PlayerLocationMixin:IsCommunityInvitation()
return self.communityClubID ~= nil and self.communityClubInviterGUID ~= nil
end
--[[private api]]
function PlayerLocationMixin:Clear()
self.guid = nil
self.unit = nil
self.chatLineID = nil
self.battlefieldScoreIndex = nil
self.whoIndex = nil
self.voiceMemberID = nil
self.voiceChannelID = nil
self.communityClubID = nil
self.communityStreamID = nil
self.communityEpoch = nil
self.communityPosition = nil
self.communityClubInviterGUID = nil
self.battleNetID = nil
end
function PlayerLocationMixin:ClearAndSetField(fieldName, field)
self:Clear()
self[fieldName] = field
end
+11 -7
View File
@@ -24,9 +24,9 @@ Full per-function reference: **[docs/API.md](docs/API.md)**.
| [Combat](docs/API.md#combat) | `InCombatLockdown` |
| [CVar](docs/API.md#cvar) | `C_CVar.GetCVarBool` |
| [Cursor](docs/API.md#cursor) | `GetCursorInfo` |
| [Container](docs/API.md#container) | `C_Container.GetContainerItemCharges`, `C_Container.GetContainerItemDurability`, `C_Container.GetContainerItemID`, `C_Container.GetContainerItemRepairCost`, `C_Container.GetContainerNumFreeSlots`, `C_Container.GetItemCooldown`, `C_Container.IsContainerItemOpenable`, `C_Container.MoveItem`, `C_Container.PlayerHasHearthstone`, `C_Container.SwapItems`, `C_Container.UseHearthstone`, `GetItemCooldown` |
| [Container](docs/API.md#container) | `C_Container.CalculateTotalNumberOfFreeBagSlots`, `C_Container.GetContainerItemCharges`, `C_Container.GetContainerItemDurability`, `C_Container.GetContainerItemID`, `C_Container.GetContainerItemRepairCost`, `C_Container.GetContainerNumFreeSlots`, `C_Container.GetItemCooldown`, `C_Container.HasContainerItem`, `C_Container.IsContainerItemOpenable`, `C_Container.MoveItem`, `C_Container.PlayerHasHearthstone`, `C_Container.SwapItems`, `C_Container.UseHearthstone`, `GetItemCooldown` |
| [Coroutine](docs/API.md#coroutine) | `coroutine.create`, `coroutine.resume`, `coroutine.yield`, `coroutine.status`, `coroutine.wrap` |
| [Creature](docs/API.md#creature) | `C_CreatureInfo.GetCreatureID`, `C_CreatureInfo.GetCreatureInfoByID`, `C_CreatureInfo.RequestLoadCreatureByID` |
| [Creature](docs/API.md#creature) | `C_CreatureInfo.GetCreatureID`, `C_CreatureInfo.GetCreatureInfoByID`, `C_CreatureInfo.RequestLoadCreatureByID`, `C_CreatureInfo.GetRaceInfo`, `C_CreatureInfo.GetClassInfo`, `C_CreatureInfo.GetCreatureFamilyInfo`, `C_CreatureInfo.GetCreatureFamilyIDs`, `C_CreatureInfo.GetFactionInfo`, `C_CreatureInfo.GetCreatureTypeInfo`, `C_CreatureInfo.GetCreatureTypeIDs` |
| EncodingUtil | `C_EncodingUtil.CompressString`, `C_EncodingUtil.DecompressString`, `C_EncodingUtil.EncodeBase64`, `C_EncodingUtil.DecodeBase64`, `C_EncodingUtil.EncodeHex`, `C_EncodingUtil.DecodeHex`, `C_EncodingUtil.SerializeJSON`, `C_EncodingUtil.DeserializeJSON`, `C_EncodingUtil.SerializeCBOR`, `C_EncodingUtil.DeserializeCBOR` |
| [EquipmentSet](docs/API.md#equipmentset) | `C_EquipmentSet.CanUseEquipmentSets`, `C_EquipmentSet.ClearIgnoredSlotsForSave`, `C_EquipmentSet.CreateEquipmentSet`, `C_EquipmentSet.DeleteEquipmentSet`, `C_EquipmentSet.EquipmentSetContainsLockedItems`, `C_EquipmentSet.GetEquipmentSetID`, `C_EquipmentSet.GetEquipmentSetIDs`, `C_EquipmentSet.GetEquipmentSetInfo`, `C_EquipmentSet.GetIgnoredSlots`, `C_EquipmentSet.GetItemIDs`, `C_EquipmentSet.GetItemLocations`, `C_EquipmentSet.GetNumEquipmentSets`, `C_EquipmentSet.IgnoreSlotForSave`, `C_EquipmentSet.IsSlotIgnoredForSave`, `C_EquipmentSet.ModifyEquipmentSet`, `C_EquipmentSet.SaveEquipmentSet`, `C_EquipmentSet.UnignoreSlotForSave`, `C_EquipmentSet.UseEquipmentSet` |
| [Events](docs/API.md#events) | `C_EventUtils.IsEventValid` |
@@ -41,28 +41,31 @@ Full per-function reference: **[docs/API.md](docs/API.md)**.
| [Hooks](docs/API.md#hooks) | `hooksecurefunc` |
| [Input](docs/API.md#input) | `GetMouseButtonClicked`, `IsLeftAltKeyDown`, `IsLeftControlKeyDown`, `IsLeftShiftKeyDown`, `IsModifierKeyDown`, `IsMouseButtonDown`, `IsRightAltKeyDown`, `IsRightControlKeyDown`, `IsRightShiftKeyDown` |
| [Instance](docs/API.md#instance) | `GetInstanceInfo` |
| [Item](docs/API.md#item) | `C_Item.DoesItemExist`, `C_Item.DoesItemExistByID`, `C_Item.EquipItemByName`, `C_Item.GetCurrentItemLevel`, `C_Item.GetDetailedItemLevelInfo`, `C_Item.GetEnchantInfo`, `C_Item.GetItemCount`, `C_Item.GetItemFamily`, `C_Item.GetItemGUID`, `C_Item.GetItemIcon`, `C_Item.GetItemIconByID`, `C_Item.GetItemID`, `C_Item.GetItemInfoInstant`, `C_Item.GetItemInventorySlotInfo`, `C_Item.GetItemInventorySlotKey`, `C_Item.GetItemInventoryType`, `C_Item.GetItemInventoryTypeByID`, `C_Item.GetItemLink`, `C_Item.GetItemLocation`, `C_Item.GetItemMaxStackSize`, `C_Item.GetItemMaxStackSizeByID`, `C_Item.GetItemName`, `C_Item.GetItemNameByID`, `C_Item.GetItemQuality`, `C_Item.GetItemQualityByID`, `C_Item.GetItemSellPrice`, `C_Item.GetItemSellPriceByID`, `C_Item.GetItemSetID`, `C_Item.GetItemSetIDByID`, `C_Item.GetItemSetInfo`, `C_Item.GetItemSpell`, `C_Item.GetItemStatDelta`, `C_Item.GetItemStats`, `C_Item.GetItemSubClassInfo`, `C_Item.GetItemUniqueness`, `C_Item.GetItemUniquenessByID`, `C_Item.GetStackCount`, `C_Item.GetWeaponEnchantInfo`, `C_Item.IsBound`, `C_Item.IsEquippableItem`, `C_Item.IsEquippedItem`, `C_Item.IsItemDataCached`, `C_Item.IsItemDataCachedByID`, `C_Item.IsItemGUIDInInventory`, `C_Item.IsItemOpenable`, `C_Item.IsLocked`, `C_Item.LockItem`, `C_Item.LockItemByGUID`, `C_Item.UnlockAllItems`, `C_Item.UnlockItem`, `C_Item.RequestLoadItemData`, `C_Item.RequestLoadItemDataByID`, `C_Item.UseAtCursor`, `C_Item.UseAtUnit`, `C_Item.UseItemByName`, `GetAuctionItemID`, `GetAuctionSellItemID`, `GetAverageItemLevel`, `GetCraftReagentItemID`, `GetInboxItemID`, `GetInventoryItemDurability`, `GetInventoryItemID`, `GetInventoryItemsForSlot`, `GetInventoryItemRepairCost`, `GetItemIcon`, `GetLootRollItemID`, `GetLootSlotItemID`, `GetMerchantItemID`, `GetQuestItemID`, `GetQuestLogItemID`, `GetTradePlayerItemID`, `GetTradeSkillItemID`, `GetTradeSkillReagentItemID`, `GetTradeTargetItemID`, `OffhandHasWeapon` |
| [Item](docs/API.md#item) | `C_Item.DoesItemExist`, `C_Item.DoesItemExistByID`, `C_Item.EquipItemByName`, `C_Item.GetCurrentItemLevel`, `C_Item.GetDetailedItemLevelInfo`, `C_Item.GetEnchantInfo`, `C_Item.GetItemCount`, `C_Item.GetItemFamily`, `C_Item.GetItemGUID`, `C_Item.GetItemIcon`, `C_Item.GetItemIconByID`, `C_Item.GetItemID`, `C_Item.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.IsConsumableItem`, `C_Item.IsEquippableItem`, `C_Item.IsEquippedItem`, `C_Item.IsItemDataCached`, `C_Item.IsItemDataCachedByID`, `C_Item.IsItemGUIDInInventory`, `C_Item.IsItemInRange`, `C_Item.IsItemOpenable`, `C_Item.IsLocked`, `C_Item.LockItem`, `C_Item.LockItemByGUID`, `C_Item.PickupItem`, `C_Item.UnlockAllItems`, `C_Item.UnlockItem`, `C_Item.RequestLoadItemData`, `C_Item.RequestLoadItemDataByID`, `C_Item.UseAtCursor`, `C_Item.UseAtUnit`, `C_Item.UseItemByName`, `GetAuctionItemID`, `GetAuctionSellItemID`, `GetAverageItemLevel`, `GetCraftReagentItemID`, `GetInboxItemID`, `GetInventoryItemDurability`, `GetInventoryItemID`, `GetInventoryItemsForSlot`, `GetInventoryItemRepairCost`, `GetItemIcon`, `GetLootRollItemID`, `GetLootSlotItemID`, `GetMerchantItemID`, `GetQuestItemID`, `GetQuestLogItemID`, `GetTradePlayerItemID`, `GetTradeSkillItemID`, `GetTradeSkillReagentItemID`, `GetTradeTargetItemID`, `OffhandHasWeapon` |
| [Loot](docs/API.md#loot) | `C_Loot.GetNearbyLootableUnits`, `C_Loot.GetLastScanResults`, `C_Loot.IsScanInProgress`, `C_Loot.LootUnit`, `C_Loot.LootUnitItem`, `C_Loot.ScanNearbyLoot` |
| [LootHistory](docs/API.md#loothistory) | `C_LootHistory.GetNumItems`, `C_LootHistory.GetItem`, `C_LootHistory.GetPlayerInfo`, `C_LootHistory.Clear` |
| [LossOfControl](docs/API.md#lossofcontrol) | `C_LossOfControl.GetActiveLossOfControlData`, `C_LossOfControl.GetActiveLossOfControlDataCount` |
| [Macros](docs/API.md#macros) | `GetLooseMacroIcons`, `GetLooseMacroItemIcons`, `GetMacroIcons`, `GetMacroItemIcons`, `GetMacroSpell` |
| [Mail](docs/API.md#mail) | `GetInboxItemLink`, `GetSendMailItemLink` |
| [Map](docs/API.md#map) | `C_Map.GetBestMapForUnit`, `C_Map.GetMapOverlays` |
| [Map](docs/API.md#map) | `C_Map.GetAreaInfo`, `C_Map.GetAreas`, `C_Map.GetAreaTriggerInfo`, `C_Map.GetAreaTriggers`, `C_Map.GetBestMapForUnit`, `C_Map.GetMapAreaIDs`, `C_Map.GetMapOverlays`, `C_Map.GetMapWorldSize` |
| [MapExplorationInfo](docs/API.md#mapexplorationinfo) | `C_MapExplorationInfo.GetExploredMapTextures`, `C_MapExplorationInfo.GetUnexploredMapTextures` |
| [MerchantFrame](docs/API.md#merchantframe) | `C_MerchantFrame.GetBuybackItemID`, `C_MerchantFrame.GetItemInfo`, `C_MerchantFrame.GetNumJunkItems`, `C_MerchantFrame.IsMerchantItemRefundable`, `C_MerchantFrame.IsSellAllJunkEnabled`, `C_MerchantFrame.SellAllJunkItems` |
| [NamePlate](docs/API.md#nameplate) | `C_NamePlate.GetNamePlateForGUID`, `C_NamePlate.GetNamePlateForUnit`, `C_NamePlate.GetNamePlateGUIDs`, `C_NamePlate.GetNamePlates` |
| [NameCache](docs/API.md#namecache) | `C_PlayerCache.GetPlayerInfoByName`, `C_PlayerCache.IsEnabled`, `C_PlayerCache.IsScanEnabled`, `C_PlayerCache.RememberPlayer`, `C_PlayerCache.SetEnabled`, `C_PlayerCache.SetScanEnabled`, `GetPlayerInfoByGUID`, `UnitNameFromGUID` |
| [PlayerInfo](docs/API.md#playerinfo) | `C_PlayerInfo.CanUseItem`, `C_PlayerInfo.GUIDIsCreature`, `C_PlayerInfo.GUIDIsGameObject`, `C_PlayerInfo.GUIDIsPet`, `C_PlayerInfo.GUIDIsPlayer` |
| [NewItems](docs/API.md#newitems) | `C_NewItems.ClearAll`, `C_NewItems.IsNewItem`, `C_NewItems.RemoveNewItem` |
| [PlayerInfo](docs/API.md#playerinfo) | `C_PlayerInfo.CanUseItem`, `C_PlayerInfo.GetClass`, `C_PlayerInfo.GetName`, `C_PlayerInfo.GetRace`, `C_PlayerInfo.GetSex`, `C_PlayerInfo.GUIDIsCreature`, `C_PlayerInfo.GUIDIsGameObject`, `C_PlayerInfo.GUIDIsPet`, `C_PlayerInfo.GUIDIsPlayer`, `C_PlayerInfo.IsConnected` |
| [Quest](docs/API.md#quest) | `C_QuestLog.GetNumQuestObjectives`, `C_QuestLog.GetQuestDetails`, `C_QuestLog.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.GetSpellLevelInfo`, `C_Spell.GetSpellLink`, `C_Spell.GetSpellMechanicByID`, `C_Spell.GetSpellName`, `C_Spell.GetSpellPowerCost`, `C_Spell.GetSpellRadius`, `C_Spell.GetSpellReagents`, `C_Spell.GetSpellRequiredTargetLevel`, `C_Spell.GetSpellSubtext`, `C_Spell.GetSpellTexture`, `C_Spell.IsAutoAttackSpell`, `C_Spell.IsCurrentSpell`, `C_Spell.IsRangedAutoAttackSpell`, `C_Spell.IsSelfBuff`, `C_Spell.IsSpellHarmful`, `C_Spell.IsSpellHelpful`, `C_Spell.IsSpellInRange`, `C_Spell.IsSpellPassive`, `C_Spell.IsSpellUsable`, `C_Spell.SpellHasRange`, `C_Spell.UnitCastingInfo`, `C_Spell.UnitChannelInfo`, `CancelSpellByName`, `CastSpellNoToggle`, `GetCraftSpellID`, `GetSpellInfo`, `GetSpellLink`, `GetSpellRadius`, `GetSpellRequiredTargetLevel`, `GetSpellSchool`, `IsHarmfulSpell`, `IsHelpfulSpell`, `IsPassiveSpell`, `IsPlayerSpell`, `IsSpellKnown`, `IsUsableSpell`, `SpellHasRange` |
| [SpellBook](docs/API.md#spellbook) | `C_SpellBook.GetCurrentLevelSpells`, `C_SpellBook.GetSpellLevelLearned`, `C_SpellBook.GetSpellSkillLine`, `C_SpellBook.IsAutoAttackSpellBookItem`, `C_SpellBook.IsRangedAutoAttackSpellBookItem`, `FindSpellBookSlotByID` |
| [SpellBook](docs/API.md#spellbook) | `C_SpellBook.GetCurrentLevelSpells`, `C_SpellBook.GetSkillLineName`, `C_SpellBook.GetSkillLineRank`, `C_SpellBook.GetSpellLevelLearned`, `C_SpellBook.GetSpellSkillLine`, `C_SpellBook.IsAutoAttackSpellBookItem`, `C_SpellBook.IsRangedAutoAttackSpellBookItem`, `FindSpellBookSlotByID` |
| [State](docs/API.md#state) | `CancelShapeshiftForm`, `Dismount`, `GetMirrorTimerInfo`, `GetMirrorTimerProgress`, `GetShapeshiftFormID`, `IsAssistingRitual`, `IsFalling`, `IsInGroup`, `IsInRaid`, `IsLoggedIn`, `IsMounted`, `IsStealthed`, `IsSwimming` |
| [Table](docs/API.md#table) | `table.wipe` |
| [Talent](docs/API.md#talent) | `GetTalentIDByIndex`, `GetTalentSpellID` |
| [TaxiMap](docs/API.md#taximap) | `C_TaxiMap.GetTaxiNodesForMap`, `C_TaxiMap.GetAllTaxiNodes` |
| [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`, `UnitDistanceSquared`, `UnitGUID`, `UnitInLineOfSight`, `UnitInRange`, `UnitIsAFK`, `UnitIsDND`, `UnitIsFeignDeath`, `UnitIsInMyGuild`, `UnitIsMinion`, `UnitIsOtherPlayersPet`, `UnitIsPet`, `UnitIsPossessed`, `UnitPower`, `UnitPowerMax`, `UnitPowerType`, `UnitRaceBase`, `UnitStandState`, `UnitSubName`, `UnitTokenFromGUID` |
| [Unit](docs/API.md#unit) | `GetUnitSpeed`, `UnitClassBase`, `UnitCreatureFamilyID`, `UnitCreatureTypeID`, `UnitDistanceSquared`, `UnitGUID`, `UnitInLineOfSight`, `UnitInRange`, `UnitIsAFK`, `UnitIsDND`, `UnitIsFeignDeath`, `UnitIsInMyGuild`, `UnitIsMinion`, `UnitIsOtherPlayersPet`, `UnitIsPet`, `UnitIsPossessed`, `UnitPower`, `UnitPowerMax`, `UnitPowerType`, `UnitRaceBase`, `UnitStandState`, `UnitSubName`, `UnitTokenFromGUID` |
| [UnitAuras](docs/API.md#unitauras) | `C_UnitAuras.GetAuraDataByIndex`, `C_UnitAuras.GetAuraDataBySpellName`, `C_UnitAuras.GetAuraDispelTypeColor`, `C_UnitAuras.GetBuffDataByIndex`, `C_UnitAuras.GetDebuffDataByIndex`, `C_UnitAuras.GetPlayerAuraBySpellID`, `C_UnitAuras.GetUnitAuraBySpellID`, `C_UnitAuras.GetUnitAuras`, `C_UnitAuras.RegisterComboDuration` |
| [VoiceChat](docs/API.md#voicechat) | `C_VoiceChat.GetTtsVoices`, `C_VoiceChat.GetRemoteTtsVoices`, `C_VoiceChat.SpeakText`, `C_VoiceChat.StopSpeakingText`, `C_TTSSettings.GetSpeechRate`, `C_TTSSettings.GetSpeechVolume`, `C_TTSSettings.GetSpeechVoiceID`, `C_TTSSettings.GetVoiceOptionName`, `C_TTSSettings.SetSpeechRate`, `C_TTSSettings.SetSpeechVolume`, `C_TTSSettings.SetVoiceOption`, `C_TTSSettings.SetVoiceOptionByName`, `C_TTSSettings.SetDefaultSettings`, `C_TTSSettings.RefreshVoices` |
| [XMLUtil](docs/API.md#xmlutil) | `C_XMLUtil.DoesTemplateExist`, `C_XMLUtil.GetTemplateInfo`, `C_XMLUtil.GetTemplates` |
@@ -114,6 +117,7 @@ when launching with `-console`), not as Lua functions. See the
| Event | Payload |
|-------|---------|
| `BAG_NEW_ITEMS_UPDATED` | *(none)* |
| `BAG_UPDATE_DELAYED` | *(none)* |
| `EQUIPMENT_SETS_CHANGED` | *(none)* |
| `EQUIPMENT_SWAP_PENDING` | `setID` |
+905 -15
View File
File diff suppressed because it is too large Load Diff
+218 -7
View File
@@ -287,6 +287,18 @@ enum Offsets {
// the item isn't in the player's bag/equipment.
FUN_GAMETOOLTIP_BUILD_ITEM_LINK = 0x0052AE00,
// CGItem → decorated instance display name. __thiscall(ecx = CGItem *,
// char *outBuf, uint outSize). Reads the instance's random-suffix ID
// off the descriptor (+0x98, gated on the broken flag) and writes the
// plain — uncolored, unbracketed — display name into outBuf:
// "Ethereum Torque of the Sorcerer" when a suffix is present, the base
// ItemStats name otherwise (via the item cache + ItemRandomProperties
// localized suffix at record +0x1c). This is the inner name builder
// FUN_GAMETOOLTIP_BUILD_ITEM_LINK wraps in brackets, so the name it
// produces matches GetItemLink's bracketed name and modern
// C_Item.GetItemName exactly. Inner formatter FUN_005D8B00.
FUN_ITEM_BUILD_INSTANCE_NAME = 0x005D8BC0,
// Engine's inventory swap-and-send. Same primitive
// `Script_EquipCursorItem` (0x00489660) uses after the cursor's
// source location has been resolved. Sends opcode 0x10D
@@ -518,14 +530,23 @@ enum Offsets {
OFF_UNIT_FIELD_CREATEDBY = 0x20,
// Pet-vs-minion discriminator for an owned unit: `int __fastcall(unit)`.
// Returns 1 for a controllable **pet** (classified by creature family
// at descriptor +0x212 → family DBC, with a ChrRaces fallback), a
// non-1 value otherwise. The engine's unit-title builder `FUN_0052FD30`
// uses it as `(fn(unit) != 1) + 1` → 1 = "X's Pet" title, 2 = "X's
// Minion" — so it's the exact signal separating pet from minion, which
// neither the GUID (pets and guardians share the 0xF14… prefix) nor
// UNIT_FLAG_PLAYER_CONTROLLED reliably does.
// `0x00605570` is really the engine's **creature-type resolver** — it's
// `Script_UnitCreatureType`'s (`0x0051A280`) inner helper and returns the
// `CreatureType.dbc` id (1=Beast, 3=Demon, 7=Humanoid, …), via three
// paths in order: a display-override at descriptor `+0x212`, then the
// creature-cache type at `[unit+0xB30]+0x18`, then the player-race type
// at `ChrRaces[[unit+0x110]+0x78]+0x24`. `FUN_UNIT_CREATURE_TYPE` is the
// canonical name; `FUN_UNIT_PET_MINION_CLASS` is a historical alias for
// the same address.
//
// The pet/minion use: the unit-title builder `FUN_0052FD30` calls it as
// `(fn(unit) != 1) + 1` → 1 = "X's Pet" title, 2 = "X's Minion". That
// works because it's really testing "is this a Beast (type 1)": hunter
// pets are Beasts, warlock minions are Demons (type 3) — so `!= 1`
// cleanly separates them, which neither the GUID (pets and guardians
// share the 0xF14… prefix) nor UNIT_FLAG_PLAYER_CONTROLLED reliably does.
FUN_UNIT_PET_MINION_CLASS = 0x00605570,
FUN_UNIT_CREATURE_TYPE = 0x00605570,
// Party / raid roster counts and the party GUID array referenced
// in the `FUN_TOKEN_TO_GUID` dispatch comment above. Used by
@@ -694,11 +715,19 @@ enum Offsets {
// +0x00 ?
// +0x0C name C-string ptr (alias of UNIT_NAME for creatures)
// +0x10 subName C-string ptr ("Innkeeper", "<Master Trainer>", etc.)
// +0x1C CreatureFamily id (→ CreatureFamily.dbc), 0 = none
//
// Used by `Script_UnitName`'s creature path and by VanillaMinimapTracking's
// subname-based blip filter (`src/Blips.cpp::ExtractUnitSubName`).
OFF_UNIT_CREATURE_CACHE_ROW = 0xB30,
OFF_CREATURE_CACHE_SUB_NAME = 0x10,
// CreatureFamily id within the +0xB30 cache row — the value the engine's
// `Script_UnitCreatureFamily` (`0x0051A310`) reads via its family helper
// `FUN_006055e0` (`[unit+0xB30] ? [row+0x1C] : 0`) before mapping it
// through `CreatureFamily.dbc` for the localized name. `UnitCreatureFamilyID`
// returns the raw id. Same field as `OFF_CREATURE_FAMILY` in the creature
// cache data block, reached through the unit here.
OFF_CREATURE_CACHE_FAMILY = 0x1C,
// Pointer to the CGUnit's 8-byte GUID at `*(CGUnit + 0x08)`. Verified
// in `Script_GetInventoryItemLink` at `0x004C8CB0`-`0x004C8CB5`:
// `mov eax, [esi+8]; mov edi, [eax]; mov ecx, [eax+4]` reads the
@@ -1195,8 +1224,33 @@ enum Offsets {
// locLeft/locRight/locTop/locBottom floats}
VAR_WORLDMAP_AREA_RECORDS = 0x00C0D5BC,
VAR_WORLDMAP_AREA_COUNT = 0x00C0D5C0,
OFF_WMA_MAP_ID = 0x04,
OFF_WMA_AREA_ID = 0x08,
OFF_WMA_NAME = 0x0C,
OFF_WMA_LOC_LEFT = 0x10, // world Y at the map's left edge (max Y)
OFF_WMA_LOC_RIGHT = 0x14, // world Y at the map's right edge (min Y)
OFF_WMA_LOC_TOP = 0x18, // world X at the map's top edge (max X)
OFF_WMA_LOC_BOTTOM = 0x1C, // world X at the map's bottom edge (min X)
// AreaTrigger.dbc record (10 fields, 0x28 bytes) — instance cataloged
// in docs/DBCs.md (records slot 0x00C0E034, count 0x00C0E038). The
// client loads it but exposes NO geometry to Lua, which is why map
// addons (pfQuest et al.) ship hand-scraped trigger tables. Schema:
// {ID@+0, mapID@+4 (Map.dbc — continent or instance),
// x@+8, y@+0xC, z@+0x10 (continent-space world coords),
// radius@+0x14 (sphere trigger; 0 => box trigger),
// boxLength@+0x18, boxWidth@+0x1C, boxHeight@+0x20, boxYaw@+0x24}
VAR_AREATRIGGER_RECORDS = 0x00C0E034,
VAR_AREATRIGGER_COUNT = 0x00C0E038,
OFF_AT_MAP_ID = 0x04,
OFF_AT_X = 0x08,
OFF_AT_Y = 0x0C,
OFF_AT_Z = 0x10,
OFF_AT_RADIUS = 0x14,
OFF_AT_BOX_LENGTH = 0x18,
OFF_AT_BOX_WIDTH = 0x1C,
OFF_AT_BOX_HEIGHT = 0x20,
OFF_AT_BOX_YAW = 0x24,
// WorldMapOverlay.dbc record (17 fields, 0x44 bytes):
// {ID@+0, worldMapAreaID@+4 (-> WorldMapArea row),
@@ -1211,6 +1265,7 @@ enum Offsets {
VAR_WORLDMAP_OVERLAY_RECORDS = 0x00C0D594,
VAR_WORLDMAP_OVERLAY_COUNT = 0x00C0D598,
OFF_WMO_WORLDMAP_AREA = 0x04,
OFF_WMO_AREA_ID = 0x08, // areaID[4] @ +0x08..+0x14 (AreaTable ids revealed)
OFF_WMO_MAP_POINT_X = 0x18,
OFF_WMO_MAP_POINT_Y = 0x1C,
OFF_WMO_TEXTURE_NAME = 0x20,
@@ -1218,6 +1273,10 @@ enum Offsets {
OFF_WMO_TEXTURE_HEIGHT = 0x28,
OFF_WMO_OFFSET_X = 0x2C,
OFF_WMO_OFFSET_Y = 0x30,
OFF_WMO_HITRECT_TOP = 0x34, // hit rect in world-map canvas px (~1002x668):
OFF_WMO_HITRECT_LEFT = 0x38, // the tight clickable bounds of the landmass,
OFF_WMO_HITRECT_BOTTOM = 0x3C, // used for subzone-center placement (pfQuest's
OFF_WMO_HITRECT_RIGHT = 0x40, // pfDB["zones"] was scraped from these)
// Current world-map view selection — the globals the engine's
// current-map-name resolver (FUN_004a6cf0, behind GetMapInfo) reads:
@@ -1345,6 +1404,11 @@ enum Offsets {
// (see OFF_UNIT_FIELD_CHANNEL_SPELL below); PLAYER_FLAGS only
// exists on the +0xE68 sub-struct here.
OFF_CGPLAYER_INFO = 0xE68,
// Explored-areas bitfield inside the CGPlayer info sub-struct
// (`[player+0xE68] + 0xE6C`): bit `AreaBit` set == that AreaTable area
// is explored. 0x100 bytes. Read by the discovered-overlay rebuild
// FUN_004a67a0 as `bits[AreaBit>>3] & (1 << (AreaBit&7))`.
OFF_PLAYER_EXPLORED_BITS = 0xE6C,
// PvP rank fields within the CGPlayer +0xE68 sub-struct (siblings of
// the combo-point byte at +0x1029). Read by the item tooltip's
@@ -1475,6 +1539,25 @@ enum Offsets {
// Reads bagID at Lua stack[1] and slot at stack[2], validates them, and
// returns the inventory manager + linear slot ready to feed into GetItemBySlot.
FUN_PACK_BAG_SLOT = 0x004F9820,
// Equipped-bag container-GUID getter — `uint64 __fastcall(uint bagIndex0)`
// where `bagIndex0` is 0-based (Lua bagID 1..4 → 0..3; 4..9 are bank bags,
// gated on the bank-open globals). Returns the CGContainer GUID of the bag
// equipped in that slot, or 0 if none. This is the internal PackBagSlot
// uses to resolve bags 1..4: get the bag GUID here, resolve the container
// via FUN_OBJECT_RESOLVE_BY_GUID(OBJ_TYPE_CONTAINER, guid), then call the
// container's vtable[+OFF_CONTAINER_GET_INVENTORY] to get the inventory
// object GetItemBySlot indexes. Lets us enumerate bag contents in pure C++
// without PackBagSlot's Lua-stack coupling. (Reads GUID arrays at
// 0x00BDD060 lo / 0x00BDD064 hi, stride 8.)
FUN_GET_EQUIPPED_BAG_GUID = 0x004F93E0,
// CGContainer vtable slot (byte offset into the vtable) whose method
// returns the container's inventory object — `void* __thiscall(container)`.
// The inventory object's slot count lives at its +0x00 (OFF_INVMGR_SLOT_COUNT).
OFF_CONTAINER_GET_INVENTORY = 0x10,
// Backpack (bagID 0) linear-slot base in the player invMgr GUID/item
// arrays: a 1-based backpack slot S maps to linear (S - 1) + 23. Matches
// PackBagSlot's `*param_3 += 0x17` for the backpack branch.
BACKPACK_LINEAR_BASE = 23,
// Player inventory manager layout — used for direct GUID-array reads
// that bypass `GetItemBySlot`'s bank gate at `0x006228C1`.
// +0x00 uint32 max slot count
@@ -1583,6 +1666,17 @@ enum Offsets {
// an item, swaps with the bag slot). Used by `EquipItemByName` to
// pick up the source item before dispatching to the equip helpers.
FUN_SCRIPT_PICKUP_CONTAINER_ITEM = 0x004F9B30,
// Container-content cursor-pickup PRIMITIVE — the plain-pickup branch
// inside `Script_PickupContainerItem`'s state machine, called directly
// (no Lua stack, no state-machine modes).
// void __fastcall(uint linearSlot, int count, uint itemGuidLo,
// uint itemGuidHi, uint containerGuidLo,
// uint containerGuidHi, int flag)
// Self-resolves the player, sets the cursor-state globals
// (VAR_CURSOR_ITEM_GUID_*, cursor type = 1) and fires the cursor-update
// event itself. Does NOT apply the cursor-empty / item-lock guards the
// Script wrapper does — callers replicate those (Item::Cursor::PickupBagItem).
FUN_CURSOR_PICKUP_ITEM = 0x00494B60,
// `CGItem::UseItem` — the engine's actual "use this item" primitive.
// It's the fallback dispatch in `Script_UseContainerItem` (the call
// site at 0x004FA430 after every special-cursor-mode branch is
@@ -2154,6 +2248,56 @@ enum Offsets {
OFF_CHRRACES_FILENAME = 0x3C,
OFF_CHRRACES_NAMES = 0x44,
// CreatureFamily.dbc — pet/beast family metadata. Standard 5-DWORD
// class shape (instance 0x00C0DE74; records 0x00C0DE7C, count
// 0x00C0DE80 — the same globals `Script_UnitCreatureFamily`
// (`0x0051A310`) bounds-checks/indexes). 72-byte stride, familyID-keyed
// and sparse (10/13/14/22/… are unused). Localized `Name[]` at +0x20
// (the engine reads `record + 0x20 + locale*4`), `IconFile` texture-path
// string at +0x44 (last field; empty for warlock-pet families). Backs
// `C_CreatureInfo.GetCreatureFamilyInfo` / `GetCreatureFamilyIDs`.
VAR_CREATUREFAMILY_RECORDS = 0x00C0DE7C,
VAR_CREATUREFAMILY_COUNT = 0x00C0DE80,
OFF_CREATUREFAMILY_NAMES = 0x20,
OFF_CREATUREFAMILY_ICON = 0x44,
// CreatureType.dbc — pet/NPC classification (1=Beast … 11=Totem;
// contiguous ids, 11 rows). Records 0x00C0DE2C, count 0x00C0DE30 (the
// globals `Script_UnitCreatureType` (`0x0051A280`) bounds-checks/indexes),
// localized `Name[]` at +0x04 (engine reads `record + 0x04 + locale*4`).
// Backs `C_CreatureInfo.GetCreatureTypeInfo` / `GetCreatureTypeIDs`;
// `UnitCreatureTypeID` returns the id `FUN_UNIT_CREATURE_TYPE` resolves.
VAR_CREATURETYPE_RECORDS = 0x00C0DE2C,
VAR_CREATURETYPE_COUNT = 0x00C0DE30,
OFF_CREATURETYPE_NAMES = 0x04,
// Race → faction group, mirroring the player branch of
// `Script_UnitFactionGroup` (`0x00516630`) — backs
// `C_CreatureInfo.GetFactionInfo(raceID)`. Chain:
// ChrRaces[race] + 0x08 → FactionTemplate id
// FactionTemplate[id] + 0x0C → group mask (ourMask)
// scan FactionGroup for the row whose bit is set (and whose
// localized name is non-empty — that non-empty test is what
// skips the always-set "Player" bit and lands on Alliance/Horde)
// push { groupTag = row+0x08 (english), name = row+0x0C+locale*4 }
// FactionTemplate is a pointer-array DBC (records[id]); FactionGroup
// is stored CONTIGUOUSLY (records-base + i*stride, direct field reads,
// NOT a pointer array), so iterate it by hand rather than via DBC::Record.
// FactionTemplate globals match docs/DBCs.md; the FactionGroup
// records-base/count globals are the ones the engine actually
// dereferences per the disassembly (0x00C0DD5C / 0x00C0DD60), which
// differ from DBCs.md's standard-shape tabulation for this table.
OFF_CHRRACES_FACTION_TEMPLATE = 0x08,
VAR_FACTIONTEMPLATE_RECORDS = 0x00C0DD3C,
VAR_FACTIONTEMPLATE_COUNT = 0x00C0DD40,
OFF_FACTIONTEMPLATE_GROUP_MASK = 0x0C,
VAR_FACTIONGROUP_RECORDS = 0x00C0DD5C,
VAR_FACTIONGROUP_COUNT = 0x00C0DD60,
FACTIONGROUP_STRIDE = 0x30,
OFF_FACTIONGROUP_BIT = 0x04,
OFF_FACTIONGROUP_ENGLISH = 0x08,
OFF_FACTIONGROUP_NAMES = 0x0C,
// AreaTable.dbc — used by `Script_GetCharacterInfo` to resolve
// each character's last-known-area to a localized zone name.
// Same shape as the other DBCs (records pointer + count globals,
@@ -2161,6 +2305,55 @@ enum Offsets {
VAR_AREATABLE_RECORDS = 0x00C0E048,
VAR_AREATABLE_COUNT = 0x00C0E04C,
OFF_AREATABLE_NAMES = 0x2C,
// AreaBit @ +0x0C: index into the player's explored-areas bitfield
// (OFF_PLAYER_EXPLORED_BITS). Gate @ +0x28: when < 0 the area needs no
// exploration (always counts explored); when >= 0 the AreaBit check
// applies. Source: the discovered-overlay rebuild FUN_004a67a0.
OFF_AREATABLE_AREA_BIT = 0x0C,
OFF_AREATABLE_EXPLORE_GATE = 0x28,
// TaxiNodes.dbc (16 fields, 0x40 bytes) — flight points. records/count
// per docs/DBCs.md. mapID @ +0x04 is the Map.dbc continent; x/y/z floats;
// Name[8] localized @ +0x14; the two MountCreatureID columns are
// [Horde @ +0x38, Alliance @ +0x3C] (0 = that faction has no flight
// master at this node — both 0 = a transport/system waypoint).
VAR_TAXINODES_RECORDS = 0x00C0D6C0,
VAR_TAXINODES_COUNT = 0x00C0D6C4,
OFF_TAXINODE_MAP_ID = 0x04,
OFF_TAXINODE_X = 0x08,
OFF_TAXINODE_Y = 0x0C,
OFF_TAXINODE_Z = 0x10,
OFF_TAXINODE_NAME = 0x14,
OFF_TAXINODE_HORDE_MOUNT = 0x38,
OFF_TAXINODE_ALLIANCE_MOUNT = 0x3C,
// Live taxi session — the open flight master's reachable node list,
// populated between TAXIMAP_OPENED/CLOSED (count 0 otherwise). Backs the
// legacy NumTaxiNodes/TaxiNodeName/TaxiNodePosition/TaxiNodeGetType Lua
// globals. Node slots are 0x20-stride records at VAR_TAXI_NODE_ARRAY:
// +0x00 = pointer to the node's TaxiNodes.dbc record (id @ +0, name @
// +0x14), +0x04 = flight-map x (0-1 float), +0x08 = y. Count at
// VAR_TAXI_NODE_COUNT. Derived from Script_TaxiNode* (FUN_004dc380 name,
// FUN_004dc3b0 position); the reachability type comes from
// FUN_TAXI_NODE_TYPE.
VAR_TAXI_NODE_COUNT = 0x00BB4AA8,
VAR_TAXI_NODE_ARRAY = 0x00BB4F10,
TAXI_NODE_STRIDE = 0x20,
OFF_TAXI_SLOT_RECORD = 0x00,
OFF_TAXI_SLOT_X = 0x04,
OFF_TAXI_SLOT_Y = 0x08,
// __fastcall(uint slot0Based) -> const char* ("CURRENT" / "REACHABLE" /
// "DISTANT" / other) — the node's reachability for the open session.
FUN_TAXI_NODE_TYPE = 0x004DC4E0,
// TaxiPath.dbc (4 fields, 0x10 bytes): {id, fromNode@+0x04, toNode@+0x08,
// cost}. A real flight master is a node some flight path connects to;
// Northshire Abbey / spurious duplicate rows / standalone markers are not
// endpoints and so are filtered out of GetTaxiNodesForMap.
VAR_TAXIPATH_RECORDS = 0x00C0D698,
VAR_TAXIPATH_COUNT = 0x00C0D69C,
OFF_TAXIPATH_FROM = 0x04,
OFF_TAXIPATH_TO = 0x08,
// Engine player-info cache — populated by the
// SMSG_NAME_QUERY_RESPONSE handler (opcode 0x51) at 0x005551A0,
@@ -3403,6 +3596,15 @@ enum Offsets {
// shape from SMSG_PARTY_MEMBER_STATS_FULL).
FUN_GROUP_MEMBER_STATS_LOOKUP = 0x00496400,
OFF_GROUP_MEMBER_AREA_ID = 0x14,
// `+0x08` status-flags byte of the stats block (see field map above);
// bit 0 is the online flag `Script_UnitIsConnected` tests. Sibling
// `FUN_GROUP_MEMBER_SLOT_LOOKUP` (`0x00496420`, `__fastcall(u64 *guid)
// → member slot*`) is the presence fallback UnitIsConnected consults
// when no stats block exists — a non-null slot means the member is
// present (and thus connected). Both take a pointer to the 64-bit GUID.
OFF_GROUP_MEMBER_STATUS_FLAGS = 0x08,
GROUP_MEMBER_STATUS_ONLINE = 0x1,
FUN_GROUP_MEMBER_SLOT_LOOKUP = 0x00496420,
// Per-slot GUID tables — used to map party/raid token strings
// ("party1", "raid17") to GUIDs without going through
@@ -4193,6 +4395,15 @@ enum Offsets {
// assemble the pickup→equip chain for each set item.
FUN_SCRIPT_CLEAR_CURSOR = 0x004895B0,
FUN_SCRIPT_PICKUP_INVENTORY_ITEM = 0x004C8DA0,
// Equipment/bag-slot pickup PRIMITIVE behind Script_PickupInventoryItem
// (`FUN_004C8DA0` is just `FUN_004C7300(arg - 1)`). Called directly:
// void __fastcall(uint slot0Based)
// Self-contained — resolves the player, applies its own cursor/lock
// guards, and handles the drop case (slot == 0xFFFFFFFF). Covers the
// 0-based equipment (0..18) and equipped-bag (19..22) slots; container
// contents go through FUN_CURSOR_PICKUP_ITEM instead. Modern
// `equipmentSlotIndex` is 1-based, so pass `equipmentSlotIndex - 1`.
FUN_PICKUP_INVENTORY_SLOT = 0x004C7300,
// Bag-update fire sites — see TODO #67 for the full Ghidra
// investigation. Hook these three to detect BAG_UPDATE fires and
+11
View File
@@ -56,6 +56,7 @@
#include "Game.h"
#include "Offsets.h"
#include "bag/UpdateDelayed.h"
#include "event/Custom.h"
#include "tick/WorldTick.h"
@@ -71,8 +72,15 @@ const Event::Custom::AutoReserve _reserve{kEventName};
// Single bool, no atomics needed — the engine is single-threaded.
bool g_pending = false;
// C++ subscribers notified once per bag-change frame (see UpdateDelayed.h).
AutoSubscribe *g_subs = nullptr;
} // namespace
AutoSubscribe::AutoSubscribe(Callback cb) : cb(cb), next(g_subs) {
g_subs = this;
}
// `FUN_004F91A0` — `__cdecl void()`. Bag-slot diff loop. Walks the
// 10 bag slots, fires BAG_UPDATE for each that changed.
using BagSlotDiff_t = void(__cdecl *)();
@@ -123,6 +131,9 @@ void OnWorldTick() {
const int slot = Event::Custom::Lookup(kEventName);
if (slot >= 0)
Event::Custom::Fire(slot, "");
// Notify C++ subscribers of the bag-change frame (see UpdateDelayed.h).
for (auto *node = g_subs; node != nullptr; node = node->next)
node->cb();
}
static const Game::HookAutoRegister _hookSlotDiff{
+39
View File
@@ -0,0 +1,39 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
#pragma once
// Authoritative "a bag changed this frame" subscription. `Bag::UpdateDelayed`
// co-hooks the three (and only three) engine `BAG_UPDATE` fire sites and
// coalesces them into a single per-frame signal — the same signal that backs
// the `BAG_UPDATE_DELAYED` event. Subscribe here to run C++ work exactly once
// per frame in which any bag actually changed, instead of polling every frame.
//
// Callbacks fire at the tail of the world tick, after every BAG_UPDATE for the
// frame has already fired (so bag contents are settled). No firing on frames
// where nothing changed. Order across subscribers is unspecified.
//
// Module pattern, matching `Tick::WorldTick::AutoSubscribe`: declare a
// file-scope `static const Bag::UpdateDelayed::AutoSubscribe _s{&Callback};`.
namespace Bag::UpdateDelayed {
using Callback = void (*)();
struct AutoSubscribe {
explicit AutoSubscribe(Callback cb);
Callback cb;
AutoSubscribe *next;
};
} // namespace Bag::UpdateDelayed
+28
View File
@@ -130,11 +130,39 @@ int __fastcall Script_C_Container_GetContainerNumFreeSlots(void *L) {
return 2;
}
// `C_Container.CalculateTotalNumberOfFreeBagSlots()` — the total number of
// free slots across the player's *general-purpose* bags (backpack + equipped
// bags 0..4 whose BagFamily is 0). Specialty bags (quivers, soul bags, herb
// bags, …) are excluded, mirroring FrameXML's implementation exactly:
//
// for bag = BACKPACK .. lastBag:
// free, family = C_Container.GetContainerNumFreeSlots(bag)
// if family == 0 then total = total + free end
//
// Takes no arguments. Caveat inherited from `GetContainerNumFreeSlots`:
// vanilla-era server data leaves most bags' BagFamily at 0, so specialty
// bags that retail *would* exclude (Soul Pouch, Enchanting Bag, …) get
// counted here — the client just doesn't carry the family bit for them. The
// result stays consistent with what `GetContainerNumFreeSlots` reports for
// those same bags.
int __fastcall Script_C_Container_CalculateTotalNumberOfFreeBagSlots(void *L) {
int total = 0;
for (int bagID = 0; bagID <= 4; bagID++) {
const BagInfo info = ResolveBagInfo(bagID);
if (info.slotCount > 0 && info.bagType == 0)
total += CountFreeSlotsInBag(L, bagID, info.slotCount);
}
Game::Lua::PushNumber(L, static_cast<double>(total));
return 1;
}
} // namespace
static void RegisterLuaFunctions() {
Game::Lua::RegisterTableFunction("C_Container", "GetContainerNumFreeSlots",
&Script_C_Container_GetContainerNumFreeSlots);
Game::Lua::RegisterTableFunction("C_Container", "CalculateTotalNumberOfFreeBagSlots",
&Script_C_Container_CalculateTotalNumberOfFreeBagSlots);
}
static const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
+52
View File
@@ -0,0 +1,52 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
// `C_Container.HasContainerItem(bagIndex, slotIndex)` — returns whether the
// given bag slot currently holds an item (a Classic Era addition). It's the
// occupancy sibling of `GetContainerItemID`: resolves the slot to its CGItem
// the same way (`Item::Location::ResolveBag` → engine `PackBagSlot` →
// `GetItemBySlot`), and a populated slot yields a non-null pointer while an
// empty slot yields null. Presence only — it does not depend on the item's
// static data being cached. Returns `false` for empty slots and invalid
// bag/slot indices.
#include "Game.h"
#include "item/Location.h"
namespace Container::HasItem {
namespace {
int __fastcall Script_C_Container_HasContainerItem(void *L) {
if (!Game::Lua::IsNumber(L, 1) || !Game::Lua::IsNumber(L, 2)) {
Game::Lua::Error(L, "Usage: C_Container.HasContainerItem(bagIndex, slotIndex)");
return 0;
}
const int bagID = static_cast<int>(Game::Lua::ToNumber(L, 1));
const int slotIndex = static_cast<int>(Game::Lua::ToNumber(L, 2));
const bool hasItem = Item::Location::ResolveBag(L, bagID, slotIndex) != nullptr;
Game::Lua::PushBool(L, hasItem);
return 1;
}
void RegisterLuaFunctions() {
Game::Lua::RegisterTableFunction("C_Container", "HasContainerItem",
&Script_C_Container_HasContainerItem);
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace
} // namespace Container::HasItem
+245
View File
@@ -36,10 +36,24 @@
// Field offsets verified against the binary (rank read by the engine's
// classification helper as `[block+0x20]`) and real creaturecache.wdb
// rows. See Offsets.h `OFF_CREATURE_*`.
//
// This file also hosts the two static-DBC lookups on the same table:
//
// C_CreatureInfo.GetRaceInfo(raceID) -> { raceName, clientFileString, raceID }
// C_CreatureInfo.GetClassInfo(classID) -> { className, classFile, classID }
//
// retail backports over `ChrRaces.dbc` / `ChrClasses.dbc`. Unlike the
// creature cache these DBCs are always loaded, localized, and synchronous —
// so a by-id lookup works for any race/class id at any time, with no WDB
// cold-cache problem. The file-string columns (`clientFileString` /
// `classFile`) are the same non-localized tokens `UnitRace`/`UnitClass`
// return as their 2nd value ("NightElf", "WARRIOR"), so they round-trip
// with the unit APIs. See Offsets.h `OFF_CHRRACES_*` / `OFF_CHRCLASSES_*`.
#include "Game.h"
#include "Offsets.h"
#include "cache/QueryLoad.h"
#include "dbc/Lookup.h"
#include "event/Custom.h"
#include <cstdint>
@@ -122,11 +136,242 @@ int __fastcall Script_RequestLoadCreatureByID(void *L) {
return 1;
}
// `C_CreatureInfo.GetRaceInfo(raceID)` — RaceInfo table, or nil for a
// non-numeric / non-positive id or one with no ChrRaces row.
int __fastcall Script_GetRaceInfo(void *L) {
if (!Game::Lua::IsNumber(L, 1))
return 0; // nil
const int raceID = static_cast<int>(Game::Lua::ToNumber(L, 1));
const char *file =
(raceID > 0) ? DBC::StringField(Offsets::VAR_CHRRACES_RECORDS,
Offsets::VAR_CHRRACES_COUNT,
static_cast<uint32_t>(raceID),
Offsets::OFF_CHRRACES_FILENAME)
: nullptr;
if (file == nullptr)
return 0; // unknown id -> nil
const char *name = DBC::LocalizedField(Offsets::VAR_CHRRACES_RECORDS,
Offsets::VAR_CHRRACES_COUNT,
static_cast<uint32_t>(raceID),
Offsets::OFF_CHRRACES_NAMES);
Game::Lua::NewTable(L);
Game::Lua::SetFieldString(L, "raceName", name); // NULL coerced to ""
Game::Lua::SetFieldString(L, "clientFileString", file);
Game::Lua::SetFieldNumber(L, "raceID", raceID);
return 1;
}
// `C_CreatureInfo.GetClassInfo(classID)` — ClassInfo table, or nil for a
// non-numeric / non-positive id or one with no ChrClasses row.
int __fastcall Script_GetClassInfo(void *L) {
if (!Game::Lua::IsNumber(L, 1))
return 0; // nil
const int classID = static_cast<int>(Game::Lua::ToNumber(L, 1));
const char *file =
(classID > 0) ? DBC::StringField(Offsets::VAR_CHRCLASSES_RECORDS,
Offsets::VAR_CHRCLASSES_COUNT,
static_cast<uint32_t>(classID),
Offsets::OFF_CHRCLASSES_FILENAME)
: nullptr;
if (file == nullptr)
return 0; // unknown id -> nil
const char *name = DBC::LocalizedField(Offsets::VAR_CHRCLASSES_RECORDS,
Offsets::VAR_CHRCLASSES_COUNT,
static_cast<uint32_t>(classID),
Offsets::OFF_CHRCLASSES_NAMES);
Game::Lua::NewTable(L);
Game::Lua::SetFieldString(L, "className", name); // NULL coerced to ""
Game::Lua::SetFieldString(L, "classFile", file);
Game::Lua::SetFieldNumber(L, "classID", classID);
return 1;
}
// `C_CreatureInfo.GetCreatureFamilyInfo(creatureFamilyID)` — CreatureFamilyInfo
// table for a `CreatureFamily.dbc` row (1 = Wolf, 27 = Wind Serpent, …), or
// nil for a non-numeric / non-positive / unused id. `iconFile` is a texture
// path string here (retail exposes a numeric fileID; vanilla's DBC stores the
// path) — empty "" for families with no icon (e.g. warlock pets).
int __fastcall Script_GetCreatureFamilyInfo(void *L) {
if (!Game::Lua::IsNumber(L, 1))
return 0; // nil
const int familyID = static_cast<int>(Game::Lua::ToNumber(L, 1));
const char *name =
(familyID > 0) ? DBC::LocalizedField(Offsets::VAR_CREATUREFAMILY_RECORDS,
Offsets::VAR_CREATUREFAMILY_COUNT,
static_cast<uint32_t>(familyID),
Offsets::OFF_CREATUREFAMILY_NAMES)
: nullptr;
if (name == nullptr)
return 0; // unused / OOR id -> nil
const char *icon = DBC::StringField(Offsets::VAR_CREATUREFAMILY_RECORDS,
Offsets::VAR_CREATUREFAMILY_COUNT,
static_cast<uint32_t>(familyID),
Offsets::OFF_CREATUREFAMILY_ICON);
Game::Lua::NewTable(L);
Game::Lua::SetFieldNumber(L, "id", familyID);
Game::Lua::SetFieldString(L, "name", name);
Game::Lua::SetFieldString(L, "iconFile", icon); // NULL/empty coerced to ""
return 1;
}
// `C_CreatureInfo.GetCreatureFamilyIDs()` — array of every populated
// `CreatureFamily.dbc` id (sparse: skips the unused rows). Round-trips with
// GetCreatureFamilyInfo for each element.
int __fastcall Script_GetCreatureFamilyIDs(void *L) {
Game::Lua::SetTop(L, 0);
Game::Lua::NewTable(L);
const int count =
*reinterpret_cast<const int *>(Offsets::VAR_CREATUREFAMILY_COUNT);
int n = 0;
for (int id = 1; id <= count; ++id) {
const char *name = DBC::LocalizedField(Offsets::VAR_CREATUREFAMILY_RECORDS,
Offsets::VAR_CREATUREFAMILY_COUNT,
static_cast<uint32_t>(id),
Offsets::OFF_CREATUREFAMILY_NAMES);
if (name == nullptr)
continue; // unused row
++n;
Game::Lua::PushNumber(L, static_cast<double>(n));
Game::Lua::PushNumber(L, static_cast<double>(id));
Game::Lua::SetTable(L, -3);
}
return 1;
}
// `C_CreatureInfo.GetFactionInfo(raceID)` — faction-group info for a race:
// { name = localized ("Alliance"/"Horde"/…), groupTag = english }. Mirrors
// the player branch of `Script_UnitFactionGroup` (`0x00516630`): resolve the
// race's FactionTemplate group mask, then scan `FactionGroup.dbc` for the row
// whose bit the mask sets and whose localized name is non-empty (which is what
// skips the always-set "Player" / the nameless "Monster" rows and lands on
// Alliance / Horde). nil for a non-numeric / non-positive / unknown race or a
// race with no named group. See Offsets.h for the full chain.
int __fastcall Script_GetFactionInfo(void *L) {
if (!Game::Lua::IsNumber(L, 1))
return 0; // nil
const int raceID = static_cast<int>(Game::Lua::ToNumber(L, 1));
if (raceID <= 0)
return 0;
const uint8_t *crRec = DBC::Record(Offsets::VAR_CHRRACES_RECORDS,
Offsets::VAR_CHRRACES_COUNT,
static_cast<uint32_t>(raceID));
if (crRec == nullptr)
return 0; // unknown race
const int ftId = *reinterpret_cast<const int *>(
crRec + Offsets::OFF_CHRRACES_FACTION_TEMPLATE);
if (ftId <= 0)
return 0;
const uint8_t *ftRec = DBC::Record(Offsets::VAR_FACTIONTEMPLATE_RECORDS,
Offsets::VAR_FACTIONTEMPLATE_COUNT,
static_cast<uint32_t>(ftId));
if (ftRec == nullptr)
return 0;
const uint32_t mask = *reinterpret_cast<const uint32_t *>(
ftRec + Offsets::OFF_FACTIONTEMPLATE_GROUP_MASK);
// FactionGroup is stored contiguously (records-base + i*stride), not as a
// pointer array — iterate by hand rather than via DBC::Record.
auto *fgBase = *reinterpret_cast<const uint8_t *const *>(
Offsets::VAR_FACTIONGROUP_RECORDS);
const int fgCount =
*reinterpret_cast<const int *>(Offsets::VAR_FACTIONGROUP_COUNT);
const int locale = *reinterpret_cast<const int *>(Offsets::VAR_LOCALE_INDEX);
if (fgBase == nullptr)
return 0;
for (int i = 0; i < fgCount; ++i) {
const uint8_t *row = fgBase + i * Offsets::FACTIONGROUP_STRIDE;
const int bit = *reinterpret_cast<const int *>(
row + Offsets::OFF_FACTIONGROUP_BIT);
if ((mask & (1u << (bit & 0x1F))) == 0)
continue;
const char *loc = *reinterpret_cast<const char *const *>(
row + Offsets::OFF_FACTIONGROUP_NAMES + locale * 4);
if (loc == nullptr || *loc == '\0')
continue; // "Player" / "Monster" have empty localized names
const char *eng = *reinterpret_cast<const char *const *>(
row + Offsets::OFF_FACTIONGROUP_ENGLISH);
Game::Lua::NewTable(L);
Game::Lua::SetFieldString(L, "name", loc);
Game::Lua::SetFieldString(L, "groupTag", eng);
return 1;
}
return 0; // no named group -> nil
}
// `C_CreatureInfo.GetCreatureTypeInfo(creatureTypeID)` — CreatureTypeInfo
// table for a `CreatureType.dbc` row (1 = Beast … 11 = Totem), or nil for a
// non-numeric / non-positive / unused id.
int __fastcall Script_GetCreatureTypeInfo(void *L) {
if (!Game::Lua::IsNumber(L, 1))
return 0; // nil
const int typeID = static_cast<int>(Game::Lua::ToNumber(L, 1));
const char *name =
(typeID > 0) ? DBC::LocalizedField(Offsets::VAR_CREATURETYPE_RECORDS,
Offsets::VAR_CREATURETYPE_COUNT,
static_cast<uint32_t>(typeID),
Offsets::OFF_CREATURETYPE_NAMES)
: nullptr;
if (name == nullptr)
return 0; // unused / OOR id -> nil
Game::Lua::NewTable(L);
Game::Lua::SetFieldNumber(L, "id", typeID);
Game::Lua::SetFieldString(L, "name", name);
return 1;
}
// `C_CreatureInfo.GetCreatureTypeIDs()` — array of every populated
// `CreatureType.dbc` id (contiguous 1..N in vanilla). Round-trips with
// GetCreatureTypeInfo for each element.
int __fastcall Script_GetCreatureTypeIDs(void *L) {
Game::Lua::SetTop(L, 0);
Game::Lua::NewTable(L);
const int count =
*reinterpret_cast<const int *>(Offsets::VAR_CREATURETYPE_COUNT);
int n = 0;
for (int id = 1; id <= count; ++id) {
const char *name = DBC::LocalizedField(Offsets::VAR_CREATURETYPE_RECORDS,
Offsets::VAR_CREATURETYPE_COUNT,
static_cast<uint32_t>(id),
Offsets::OFF_CREATURETYPE_NAMES);
if (name == nullptr)
continue;
++n;
Game::Lua::PushNumber(L, static_cast<double>(n));
Game::Lua::PushNumber(L, static_cast<double>(id));
Game::Lua::SetTable(L, -3);
}
return 1;
}
void RegisterLuaFunctions() {
Game::Lua::RegisterTableFunction("C_CreatureInfo", "GetCreatureInfoByID",
&Script_GetCreatureInfoByID);
Game::Lua::RegisterTableFunction("C_CreatureInfo", "RequestLoadCreatureByID",
&Script_RequestLoadCreatureByID);
Game::Lua::RegisterTableFunction("C_CreatureInfo", "GetRaceInfo",
&Script_GetRaceInfo);
Game::Lua::RegisterTableFunction("C_CreatureInfo", "GetClassInfo",
&Script_GetClassInfo);
Game::Lua::RegisterTableFunction("C_CreatureInfo", "GetCreatureFamilyInfo",
&Script_GetCreatureFamilyInfo);
Game::Lua::RegisterTableFunction("C_CreatureInfo", "GetCreatureFamilyIDs",
&Script_GetCreatureFamilyIDs);
Game::Lua::RegisterTableFunction("C_CreatureInfo", "GetFactionInfo",
&Script_GetFactionInfo);
Game::Lua::RegisterTableFunction("C_CreatureInfo", "GetCreatureTypeInfo",
&Script_GetCreatureTypeInfo);
Game::Lua::RegisterTableFunction("C_CreatureInfo", "GetCreatureTypeIDs",
&Script_GetCreatureTypeIDs);
Cache::QueryLoad::Register(reinterpret_cast<void *>(Offsets::VAR_CREATURE_CACHE),
kCreatureDataLoadResult, Offsets::FUN_CREATURE_GET_RECORD);
}
+7
View File
@@ -45,6 +45,8 @@
// `CursorHasItem` / engine `PickupX` calls when they need to detect
// that.
#include "cursor/Info.h"
#include "Game.h"
#include "Offsets.h"
#include "item/ID.h"
@@ -273,4 +275,9 @@ const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace
bool HasItem() {
return ReadVar(Offsets::VAR_CURSOR_ITEM_GUID_LO) != 0 ||
ReadVar(Offsets::VAR_CURSOR_ITEM_GUID_HI) != 0;
}
} // namespace Cursor::Info
+24
View File
@@ -0,0 +1,24 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
#pragma once
namespace Cursor::Info {
// True if a GUID-bearing item is currently on the cursor —
// `VAR_CURSOR_ITEM_GUID != 0`, the same globals `GetCursorInfo`'s bag-item
// path reads and the exact signal the engine's item-pickup path guards on.
// Used to gate cursor pickups (don't clobber a held item).
bool HasItem();
} // namespace Cursor::Info
+3 -11
View File
@@ -18,6 +18,7 @@
#include "event/Custom.h"
#include "item/ID.h"
#include "item/Location.h"
#include "unit/Identity.h"
#include <cstdint>
#include <cstring>
@@ -31,24 +32,15 @@ std::string g_path;
bool g_loaded = false;
bool g_ignoredForSave[SLOT_COUNT] = {};
void *ResolvePlayerInvMgr() {
using ResolveUnitToken_t = void *(__fastcall *)(const char *);
auto fn = reinterpret_cast<ResolveUnitToken_t>(Offsets::FUN_RESOLVE_UNIT_TOKEN);
auto *player = static_cast<uint8_t *>(fn("player"));
if (player == nullptr)
return nullptr;
return player + Offsets::OFF_PLAYER_INVENTORY_MANAGER;
}
// Reads the GUID currently in paperdoll slot `slot1Based` (1..19) by
// indexing the player invMgr's flat GUID array. Slot 1 (HEAD) lives
// at linear index 0; the equipment paperdoll occupies indices 0..18.
// Returns 0 for empty slots or unresolved player.
uint64_t ReadEquippedGUID(int slot1Based) {
auto *invMgr = static_cast<uint8_t *>(ResolvePlayerInvMgr());
const uint8_t *invMgr = Unit::Identity::PlayerInventoryManager();
if (invMgr == nullptr)
return 0;
auto *guids = *reinterpret_cast<uint64_t **>(
auto *guids = *reinterpret_cast<uint64_t *const *>(
invMgr + Offsets::OFF_INVMGR_GUID_ARRAY);
if (guids == nullptr)
return 0;
+5 -30
View File
@@ -15,6 +15,7 @@
#include "Offsets.h"
#include "item/Location.h"
#include "unit/Identity.h"
#include <cstdint>
@@ -41,19 +42,6 @@ constexpr int LINEAR_BAG_SLOT_FIRST = 19; // bag1 → linear 19
constexpr int LINEAR_BACKPACK_FIRST = 23;
constexpr int LINEAR_BACKPACK_LAST = 38;
void *ResolvePlayer() {
using ResolveUnitToken_t = void *(__fastcall *)(const char *);
auto fn = reinterpret_cast<ResolveUnitToken_t>(Offsets::FUN_RESOLVE_UNIT_TOKEN);
return fn("player");
}
uint8_t *ResolvePlayerInvMgr() {
auto *player = static_cast<uint8_t *>(ResolvePlayer());
if (player == nullptr)
return nullptr;
return player + Offsets::OFF_PLAYER_INVENTORY_MANAGER;
}
uint64_t *InvMgrGuidArray(const uint8_t *invMgr) {
if (invMgr == nullptr)
return nullptr;
@@ -79,20 +67,6 @@ uint8_t *ResolveByGUID(int type, uint64_t guid) {
0x172));
}
// CGContainer exposes its own `CContainerInventory *` via vtable
// `+0x10` — same dispatch `GetItemCount`'s bank walk uses. The
// returned invMgr has the standard shape: slot count at +0x00,
// flat GUID array at +0x04.
uint8_t *BagInvMgr(uint8_t *bag) {
if (bag == nullptr)
return nullptr;
auto *vtable = *reinterpret_cast<uint8_t **>(bag);
using GetInvMgr_t = void *(__thiscall *)(void *);
auto getInvMgr = reinterpret_cast<GetInvMgr_t>(
*reinterpret_cast<uintptr_t *>(vtable + 0x10));
return static_cast<uint8_t *>(getInvMgr(bag));
}
// Walks one bag's contents for a matching GUID. Returns the 1-based
// slot index of the match, or 0 if not found / bag unresolvable.
// Works for both player bags (linear 19..22 of player invMgr) and
@@ -102,7 +76,8 @@ int WalkBagContents(uint64_t bagGuid, uint64_t targetGuid) {
auto *bag = ResolveByGUID(Offsets::OBJ_TYPE_CONTAINER, bagGuid);
if (bag == nullptr)
return 0;
auto *bagInvMgr = BagInvMgr(bag);
auto *bagInvMgr =
static_cast<const uint8_t *>(Item::Location::ContainerInventory(bag));
if (bagInvMgr == nullptr)
return 0;
const int count = InvMgrSlotCount(bagInvMgr);
@@ -122,7 +97,7 @@ int FindGUID(uint64_t targetGuid) {
if (targetGuid == GUID_EMPTY || targetGuid == GUID_IGNORED)
return 0;
auto *invMgr = ResolvePlayerInvMgr();
auto *invMgr = Unit::Identity::PlayerInventoryManager();
auto *guids = InvMgrGuidArray(invMgr);
if (guids == nullptr)
return 0;
@@ -180,7 +155,7 @@ const uint8_t *ResolveItemByGUID(uint64_t guid) {
void SnapshotPaperdoll(uint64_t *out) {
for (int i = 0; i < SLOT_COUNT; ++i)
out[i] = 0;
auto *invMgr = ResolvePlayerInvMgr();
auto *invMgr = Unit::Identity::PlayerInventoryManager();
auto *guids = InvMgrGuidArray(invMgr);
if (guids == nullptr)
return;
+68 -19
View File
@@ -63,6 +63,14 @@ const char *const kKeyName[6] = {
"LSHIFT", "RSHIFT", "LCTRL", "RCTRL", "LALT", "RALT",
};
// L/R-specific virtual keys, one per bit, for the focus-regain resync.
// `GetAsyncKeyState` accepts the L/R-distinguishing VKs directly (unlike
// the keyboard-message path, where `VK_SHIFT` arrives merged and has to be
// disambiguated via the scancode).
const int kBitVk[6] = {
VK_LSHIFT, VK_RSHIFT, VK_LCONTROL, VK_RCONTROL, VK_LMENU, VK_RMENU,
};
constexpr const char *kModifierStateChanged = "MODIFIER_STATE_CHANGED";
// Static-init reservation — runs before DllMain. The
@@ -72,6 +80,7 @@ const Event::Custom::AutoReserve _reserveModifierStateChanged{kModifierStateChan
uint32_t g_modifierMask = 0;
HHOOK g_msgHook = nullptr;
HHOOK g_wndProcHook = nullptr;
void PushDown(void *L, bool down) {
if (down)
@@ -80,6 +89,28 @@ void PushDown(void *L, bool down) {
Game::Lua::PushNil(L);
}
// Apply a single modifier bit transition: update the cached mask and, on a
// genuine change, fire `MODIFIER_STATE_CHANGED`. Both the keyboard-message
// path and the focus-regain resync funnel through here so the "only
// transitions fire" invariant holds regardless of source.
void SetBit(int bitIdx, bool down) {
const uint32_t bit = 1u << bitIdx;
const bool wasDown = (g_modifierMask & bit) != 0;
if (down == wasDown)
return;
if (down) g_modifierMask |= bit;
else g_modifierMask &= ~bit;
// Slot was assigned when the engine populated its event table; our
// hook on `RebuildEventTable` recorded the index. Lookup at fire
// time rather than caching because `/reload` re-runs the rebuild
// and the index may shift.
const int slot = Event::Custom::Lookup(kModifierStateChanged);
if (slot >= 0)
Event::Custom::Fire(slot, "%s%d", kKeyName[bitIdx], static_cast<int>(down));
}
template <int BitIdx>
int __fastcall Script_IsKeyDown(void *L) {
const bool down = (g_modifierMask & (1u << BitIdx)) != 0;
@@ -131,23 +162,22 @@ void ProcessKeyMessage(UINT msg, WPARAM wParam, LPARAM lParam) {
const int bitIdx = VkToBitIdx(wParam, lParam);
if (bitIdx < 0)
return;
const uint32_t bit = 1u << bitIdx;
const bool wasDown = (g_modifierMask & bit) != 0;
const bool nowDown = (msg == WM_KEYDOWN || msg == WM_SYSKEYDOWN);
if (nowDown == wasDown)
return;
SetBit(bitIdx, nowDown);
}
if (nowDown) g_modifierMask |= bit;
else g_modifierMask &= ~bit;
// Slot was assigned when the engine populated its event table; our
// hook on `RebuildEventTable` recorded the index. Lookup at fire
// time rather than caching because `/reload` re-runs the rebuild
// and the index may shift.
const int slot = Event::Custom::Lookup(kModifierStateChanged);
if (slot >= 0)
Event::Custom::Fire(slot, "%s%d", kKeyName[bitIdx], static_cast<int>(nowDown));
// Reconcile the cached mask with the true physical key state. Needed on
// focus regain: keyboard messages only arrive while WoW has focus, so a
// modifier released while WoW was in the background is never seen by the
// `WH_GETMESSAGE` hook and the bit stays stuck down (and the release event
// never fires). `GetAsyncKeyState` reads global async keyboard state
// regardless of focus, so polling it on activation recovers the missed
// transitions — `SetBit` fires `MODIFIER_STATE_CHANGED` for each drift.
void ResyncFromPhysicalState() {
for (int b = 0; b < 6; ++b) {
const bool down = (GetAsyncKeyState(kBitVk[b]) & 0x8000) != 0;
SetBit(b, down);
}
}
LRESULT CALLBACK GetMsgHook(int code, WPARAM wParam, LPARAM lParam) {
@@ -164,11 +194,30 @@ LRESULT CALLBACK GetMsgHook(int code, WPARAM wParam, LPARAM lParam) {
return CallNextHookEx(nullptr, code, wParam, lParam);
}
// `WM_ACTIVATEAPP` is *sent* (via the window manager), not *posted* to the
// queue, so `WH_GETMESSAGE` never sees it — a `WH_CALLWNDPROC` hook catches
// sent messages before the target WNDPROC runs. On app activation
// (`wParam != FALSE`) we resync from physical key state to recover any
// modifier released while WoW was in the background. Like the message hook,
// this is thread-level (not per-`HWND`), so it survives window recreation.
LRESULT CALLBACK CallWndProcHook(int code, WPARAM wParam, LPARAM lParam) {
if (code == HC_ACTION) {
const CWPSTRUCT *cwp = reinterpret_cast<const CWPSTRUCT *>(lParam);
if (cwp->message == WM_ACTIVATEAPP && cwp->wParam != FALSE)
ResyncFromPhysicalState();
}
return CallNextHookEx(nullptr, code, wParam, lParam);
}
void InstallHook() {
if (g_msgHook != nullptr)
return;
g_msgHook = SetWindowsHookExA(WH_GETMESSAGE, &GetMsgHook, nullptr,
GetCurrentThreadId());
if (g_msgHook == nullptr) {
g_msgHook = SetWindowsHookExA(WH_GETMESSAGE, &GetMsgHook, nullptr,
GetCurrentThreadId());
}
if (g_wndProcHook == nullptr) {
g_wndProcHook = SetWindowsHookExA(WH_CALLWNDPROC, &CallWndProcHook,
nullptr, GetCurrentThreadId());
}
}
void RegisterLuaFunctions() {
+5 -17
View File
@@ -55,6 +55,7 @@
#include "item/Data.h"
#include "item/ID.h"
#include "item/Location.h"
#include "unit/Identity.h"
#include <algorithm>
#include <cstdint>
@@ -153,21 +154,11 @@ ItemInfo ReadItemInfo(const uint8_t *cgItem) {
// Bank-walk helpers — bypass the bank-window gate on `GetItemBySlot`
// by reading the player's invMgr GUID array directly. Same shape as
// `C_Item.GetItemCount`'s bank path.
using ResolveUnitToken_t = void *(__fastcall *)(const char *token);
using ResolveObjectByGuid_t = void *(__fastcall *)(int type,
const char *debugName,
uint32_t guidLo,
uint32_t guidHi,
int priority);
using GetBagInvMgr_t = void *(__thiscall *)(void *bag);
const uint8_t *PlayerInvMgr() {
auto resolveUnit = reinterpret_cast<ResolveUnitToken_t>(Offsets::FUN_RESOLVE_UNIT_TOKEN);
auto *player = static_cast<uint8_t *>(resolveUnit("player"));
if (player == nullptr)
return nullptr;
return player + Offsets::OFF_PLAYER_INVENTORY_MANAGER;
}
const uint8_t *ResolveByGuid(int type, uint64_t guid) {
if (guid == 0)
@@ -229,7 +220,7 @@ void WalkGuidArrayRange(const uint8_t *invMgr, int firstSlot, int lastSlot,
// player invMgr hold the bank bags themselves; each bag has its own
// invMgr accessed via the bag CGContainer's vtable+0x10 method).
void WalkBankBags(std::vector<Candidate> &candidates) {
auto *playerInvMgr = PlayerInvMgr();
auto *playerInvMgr = Unit::Identity::PlayerInventoryManager();
if (playerInvMgr == nullptr)
return;
auto *playerGuidArray = *reinterpret_cast<const uint64_t *const *>(
@@ -242,11 +233,8 @@ void WalkBankBags(std::vector<Candidate> &candidates) {
playerGuidArray[slot]);
if (bag == nullptr)
continue;
auto *vtable = *reinterpret_cast<const uint8_t *const *>(bag);
auto getInvMgr = reinterpret_cast<GetBagInvMgr_t>(
*reinterpret_cast<const uintptr_t *>(vtable + 0x10));
auto *bagInvMgr = static_cast<const uint8_t *>(
getInvMgr(const_cast<uint8_t *>(bag)));
auto *bagInvMgr =
static_cast<const uint8_t *>(Item::Location::ContainerInventory(bag));
if (bagInvMgr == nullptr)
continue;
const int bagSlots =
@@ -295,7 +283,7 @@ static int __fastcall Script_GetAverageItemLevel(void *L) {
// session — GUIDs are populated from server data at login. Main
// bank first (linear slots 39..62 in the player invMgr), then
// each equipped bank bag's contents via the per-bag CGContainer.
const uint8_t *playerInvMgr = PlayerInvMgr();
const uint8_t *playerInvMgr = Unit::Identity::PlayerInventoryManager();
WalkGuidArrayRange(playerInvMgr,
Offsets::INVMGR_BANK_MAIN_FIRST_SLOT,
Offsets::INVMGR_BANK_MAIN_LAST_SLOT,
+95
View File
@@ -0,0 +1,95 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
// `C_Item.IsConsumableItem(item)` — true iff the item is a consumable:
// item class `Consumable` (0), or an inventory type of `INVTYPE_AMMO`
// (24) / `INVTYPE_THROWN` (25) — ammo and thrown weapons being consumed
// in use. `item` is an itemID, `"item:NNN"` string, or item link (names
// aren't resolvable in vanilla → false).
//
// Definition established by decoding 3.3.5's `Script_IsConsumableItem`
// (`0x005173E0`) and then verifying the surviving contract against a
// modern client with `/dump`: it is NOT 3.3.5's on-use-spell-effect walk
// (an on-use trinket like Talisman of Ephemeral Power returns *false*),
// but a plain class-or-ammo/thrown check. Confirmed true: Minor Healing
// Potion / Linen Bandage / Faintly Glowing Skull (all class 0), Rough
// Arrow (Ammo). Confirmed false: Hearthstone (class Misc, valueless
// teleport), on-use trinkets, plain armor.
//
// Cache-miss handling mirrors `C_Item.IsEquippableItem`: returns false
// without firing a load (sync call, matches modern semantics). Callers
// that want auto-warm behavior should run `C_Item.RequestLoadItemDataByID`
// first and re-check on `ITEM_DATA_LOAD_RESULT`.
#include "Game.h"
#include "Offsets.h"
#include "item/Arg.h"
#include <cstdint>
namespace Item::Consumable {
namespace {
using GetItemRecord_t = const uint8_t *(__thiscall *)(void *cache, uint32_t itemID,
const uint64_t *guid, void *callback,
void *userData, int unused);
const uint8_t *PeekItemRecord(uint32_t itemID) {
auto fn = reinterpret_cast<GetItemRecord_t>(Offsets::FUN_DBCACHE_ITEMSTATS_GET_RECORD);
auto *cache = reinterpret_cast<void *>(Offsets::VAR_ITEMDB_CACHE);
const uint64_t zeroGuid = 0;
return fn(cache, itemID, &zeroGuid, nullptr, nullptr, 0);
}
constexpr uint32_t kItemClassConsumable = 0;
constexpr uint32_t kInvTypeAmmo = 24; // INVTYPE_AMMO
constexpr uint32_t kInvTypeThrown = 25; // INVTYPE_THROWN
int __fastcall Script_C_Item_IsConsumableItem(void *L) {
const int itemID = Item::Arg::ResolveItemID(L, 1);
if (itemID <= 0) {
Game::Lua::PushBoolean(L, 0);
return 1;
}
const uint8_t *record = PeekItemRecord(static_cast<uint32_t>(itemID));
if (record == nullptr) {
Game::Lua::PushBoolean(L, 0);
return 1;
}
const uint32_t classID = *reinterpret_cast<const uint32_t *>(
record + Offsets::OFF_ITEMSTATS_CLASS);
const uint32_t invType = *reinterpret_cast<const uint32_t *>(
record + Offsets::OFF_ITEMSTATS_INVENTORY_TYPE);
const bool consumable = classID == kItemClassConsumable ||
invType == kInvTypeAmmo || invType == kInvTypeThrown;
Game::Lua::PushBoolean(L, consumable);
return 1;
}
void RegisterLuaFunctions() {
// Modern exposes both the namespaced `C_Item.IsConsumableItem` and the
// bare global `IsConsumableItem` (the older, still-live form); register
// both against the same handler, like `IsUsableItem`.
Game::Lua::RegisterTableFunction("C_Item", "IsConsumableItem",
&Script_C_Item_IsConsumableItem);
Game::Lua::RegisterGlobalFunction("IsConsumableItem",
&Script_C_Item_IsConsumableItem);
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace
} // namespace Item::Consumable
+5 -19
View File
@@ -16,6 +16,7 @@
#include "item/Arg.h"
#include "item/ID.h"
#include "item/Location.h"
#include "unit/Identity.h"
#include <cstdint>
@@ -94,22 +95,12 @@ int CountInBag(void *L, int bagID, int targetItemID, bool includeUses) {
// object resolver `FUN_OBJECT_RESOLVE_BY_GUID` (the same function
// `GetItemBySlot` would call internally if the gate let us through).
using ResolveUnitToken_t = void *(__fastcall *)(const char *token);
using ResolveObjectByGuid_t = void *(__fastcall *)(int type,
const char *debugName,
uint32_t guidLo,
uint32_t guidHi,
int priority);
const uint8_t *PlayerInvMgr() {
auto ResolveUnitToken =
reinterpret_cast<ResolveUnitToken_t>(Offsets::FUN_RESOLVE_UNIT_TOKEN);
auto *player = static_cast<uint8_t *>(ResolveUnitToken("player"));
if (player == nullptr)
return nullptr;
return player + Offsets::OFF_PLAYER_INVENTORY_MANAGER;
}
const uint8_t *ResolveByGuid(int type, uint64_t guid) {
if (guid == 0)
return nullptr;
@@ -155,7 +146,7 @@ int CountInGuidArray(const uint8_t *invMgr, int firstSlot, int lastSlot,
// Layout: bag invMgr+0x00 = max slot count (the bag's size); bag
// invMgr+0x04 = its GUID array. Same shape as the player invMgr.
int CountInBankBags(int targetItemID, bool includeUses) {
auto *playerInvMgr = PlayerInvMgr();
auto *playerInvMgr = Unit::Identity::PlayerInventoryManager();
if (playerInvMgr == nullptr)
return 0;
auto *playerGuidArray = *reinterpret_cast<const uint64_t *const *>(
@@ -164,19 +155,14 @@ int CountInBankBags(int targetItemID, bool includeUses) {
return 0;
int total = 0;
using GetBagInvMgr_t = void *(__thiscall *)(void *bag);
for (int slot = Offsets::INVMGR_BANK_BAG_FIRST_SLOT;
slot <= Offsets::INVMGR_BANK_BAG_LAST_SLOT; slot++) {
const uint8_t *bag = ResolveByGuid(Offsets::OBJ_TYPE_CONTAINER,
playerGuidArray[slot]);
if (bag == nullptr)
continue;
// vtable +0x10 returns the bag's own invMgr pointer
auto *vtable = *reinterpret_cast<const uint8_t *const *>(bag);
auto getInvMgr = reinterpret_cast<GetBagInvMgr_t>(
*reinterpret_cast<const uintptr_t *>(vtable + 0x10));
auto *bagInvMgr = static_cast<const uint8_t *>(
getInvMgr(const_cast<uint8_t *>(bag)));
auto *bagInvMgr =
static_cast<const uint8_t *>(Item::Location::ContainerInventory(bag));
if (bagInvMgr == nullptr)
continue;
const int bagSlots = static_cast<int>(*reinterpret_cast<const uint32_t *>(bagInvMgr));
@@ -251,7 +237,7 @@ int __fastcall Script_C_Item_GetItemCount(void *L) {
// Direct GUID-array reads — bypass the bank gate so this works
// without the bank window having ever been opened in the
// session.
total += CountInGuidArray(PlayerInvMgr(),
total += CountInGuidArray(Unit::Identity::PlayerInventoryManager(),
Offsets::INVMGR_BANK_MAIN_FIRST_SLOT,
Offsets::INVMGR_BANK_MAIN_LAST_SLOT,
itemID, includeUses);
+91
View File
@@ -0,0 +1,91 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
#include "item/Cursor.h"
#include "Game.h"
#include "Offsets.h"
#include "cursor/Info.h"
#include "item/Location.h"
#include <cstdint>
namespace Item::Cursor {
namespace {
using PickupInventorySlot_t = void(__fastcall *)(uint32_t slot0Based);
using CursorPickupItem_t = void(__fastcall *)(uint32_t linearSlot, int count,
uint32_t itemGuidLo, uint32_t itemGuidHi,
uint32_t containerGuidLo,
uint32_t containerGuidHi, int flag);
using LockByGuid_t = void(__stdcall *)(uint32_t guidLo, uint32_t guidHi);
// The container object stores its own GUID inline at +0x08/+0x0C (unlike a
// CGItem, whose GUID sits behind the +0x08 instance-block pointer). This
// is the "source container" the engine records for the eventual drop.
constexpr int OFF_CONTAINER_GUID_LO = 0x08;
constexpr int OFF_CONTAINER_GUID_HI = 0x0C;
bool ItemLocked(const uint8_t *item) {
return (*reinterpret_cast<const uint32_t *>(item + Offsets::OFF_ITEM_CLIENT_LOCK) & 1) != 0;
}
// Reads a CGItem's 64-bit instance GUID into lo/hi. False if the instance
// block pointer is absent.
bool ReadItemGuid(const uint8_t *item, uint32_t *lo, uint32_t *hi) {
auto *instance = *reinterpret_cast<const uint8_t *const *>(
item + Offsets::OFF_ITEM_INSTANCE_BLOCK);
if (instance == nullptr)
return false;
*lo = *reinterpret_cast<const uint32_t *>(instance + Offsets::OFF_INSTANCE_BLOCK_GUID);
*hi = *reinterpret_cast<const uint32_t *>(instance + Offsets::OFF_INSTANCE_BLOCK_GUID + 4);
return true;
}
} // namespace
void PickupEquipmentSlot(int equipmentSlotIndex) {
// The primitive takes a 0-based slot; modern equipmentSlotIndex is 1-based.
reinterpret_cast<PickupInventorySlot_t>(Offsets::FUN_PICKUP_INVENTORY_SLOT)(
static_cast<uint32_t>(equipmentSlotIndex - 1));
}
bool PickupBagItem(void *L, int bagID, int slotIndex) {
Item::Location::BagSlotDetail d;
if (!Item::Location::ResolveBagSlotDetail(L, bagID, slotIndex, &d) || d.item == nullptr)
return false;
// Guards the Script_ wrapper applies that the raw primitive omits: only
// pick up onto an empty cursor, and never a locked (mid-transaction) item.
// `::Cursor` — unqualified `Cursor` would bind to `Item::Cursor` here.
if (::Cursor::Info::HasItem() || ItemLocked(d.item))
return false;
uint32_t itemLo = 0, itemHi = 0;
if (!ReadItemGuid(d.item, &itemLo, &itemHi))
return false;
const uint32_t containerLo =
*reinterpret_cast<const uint32_t *>(d.container + OFF_CONTAINER_GUID_LO);
const uint32_t containerHi =
*reinterpret_cast<const uint32_t *>(d.container + OFF_CONTAINER_GUID_HI);
reinterpret_cast<CursorPickupItem_t>(Offsets::FUN_CURSOR_PICKUP_ITEM)(
static_cast<uint32_t>(d.linearSlot), 1, itemLo, itemHi, containerLo, containerHi, 0);
// Visually lock the source item while it's held — the Script pickup
// does this immediately after the cursor primitive.
reinterpret_cast<LockByGuid_t>(Offsets::FUN_ITEM_LOCK_BY_GUID)(itemLo, itemHi);
return true;
}
} // namespace Item::Cursor
+35
View File
@@ -0,0 +1,35 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
#pragma once
// Cursor pickup primitives — thin C++ wrappers over the engine's own
// pickup functions (`FUN_PICKUP_INVENTORY_SLOT` / `FUN_CURSOR_PICKUP_ITEM`),
// called directly rather than through the Lua `Script_Pickup*` state-machine
// wrappers (which would require faking the Lua stack). Shared by
// `C_Item.PickupItem` and `C_Item.EquipItemByName`'s auto-slot path.
namespace Item::Cursor {
// Picks the item in a 1-based character-pane equipment slot onto the
// cursor via the engine's self-contained equipment-pickup primitive
// (which resolves the player and applies its own cursor/lock guards).
void PickupEquipmentSlot(int equipmentSlotIndex);
// Picks the item in a bag slot onto the cursor. Guarded to match the Lua
// wrapper's behavior: no-op (returns false) when the slot is empty, the
// cursor already holds an item, or the item is locked (mid-transaction).
// Returns true when the pickup was issued. Needs `L` for slot resolution
// (PackBagSlot); stomps the Lua stack.
bool PickupBagItem(void *L, int bagID, int slotIndex);
} // namespace Item::Cursor
+15 -36
View File
@@ -14,12 +14,12 @@
#include "Game.h"
#include "Offsets.h"
#include "item/Arg.h"
#include "item/Cursor.h"
#include "item/ID.h"
#include "item/Location.h"
#include "item/Swap.h"
#include <cstdint>
#include <string.h>
namespace Item::Equipment {
@@ -132,31 +132,13 @@ int __fastcall Script_C_Item_IsEquippedItem(void *L) {
return 1;
}
// Equipment-first walk; itemID or decorated-name match via the shared
// `Item::Location::MatchesArg` predicate (same logic the bag find-by-
// name uses, so match semantics live in one place).
for (int slot = Offsets::EQUIPMENT_SLOT_FIRST; slot <= Offsets::EQUIPMENT_SLOT_LAST; ++slot) {
auto *item = Item::Location::ResolveEquipmentSlot(slot);
if (item == nullptr) continue;
const int id = Item::ID::FromCGItem(item);
if (id == 0) continue;
if (arg.itemID > 0) {
if (id == arg.itemID) {
Game::Lua::PushBoolean(L, 1);
return 1;
}
continue;
}
// Name-match path — peek the item-cache record and compare
// `m_name[0]`. Uncached items short-circuit to "no match"
// rather than firing a load; matches modern API semantics
// (sync call, possibly stale, never blocks).
auto *record = PeekItemRecord(static_cast<uint32_t>(id));
if (record == nullptr) continue;
const char *name = *reinterpret_cast<const char *const *>(
record + Offsets::OFF_ITEMSTATS_NAME);
if (name == nullptr) continue;
if (_stricmp(name, arg.name) == 0) {
if (Item::Location::MatchesArg(item, arg)) {
Game::Lua::PushBoolean(L, 1);
return 1;
}
@@ -200,8 +182,6 @@ int __fastcall Script_C_Item_EquipItemByName(void *L) {
const bool hasDstSlot = Game::Lua::IsNumber(L, 2);
const int dstSlot = hasDstSlot ? static_cast<int>(Game::Lua::ToNumber(L, 2)) : 0;
using ScriptFn_t = int(__fastcall *)(void *L);
// Return any held cursor item to its slot BEFORE searching for
// the target. FUN_INVENTORY_SWAP's end-of-call cleanup at
// FUN_00495190(0, 1) clears LOCAL cursor globals but skips the
@@ -222,17 +202,16 @@ int __fastcall Script_C_Item_EquipItemByName(void *L) {
return 0;
}
// Auto-slot path: pickup the item via Script_PickupContainerItem
// (its state-machine handles spell-cast targeting / repair /
// enchant-scroll / etc., none of which we want to replicate), then
// call the engine's `CGPlayer::AutoEquipCursorItem` helper directly
// — `Script_AutoEquipCursorItem` is a thin wrapper that just
// resolves the local player and calls this with flag=0.
Game::Lua::SetTop(L, 0);
Game::Lua::PushNumber(L, static_cast<double>(found.bagID));
Game::Lua::PushNumber(L, static_cast<double>(found.slotIndex));
auto pickup = reinterpret_cast<ScriptFn_t>(Offsets::FUN_SCRIPT_PICKUP_CONTAINER_ITEM);
pickup(L);
// Auto-slot path: pick the item up onto the cursor, then call the
// engine's `CGPlayer::AutoEquipCursorItem` helper directly
// (`Script_AutoEquipCursorItem` is a thin wrapper that just resolves
// the local player and calls this with flag=0). The pickup goes through
// the shared `Item::Cursor::PickupBagItem`, which drives the engine's
// cursor primitive directly (no Script_PickupContainerItem stack-fake);
// its empty-cursor guard is satisfied by the ClearCursor above. Bail if
// the pickup didn't take (locked item / busy cursor) — nothing to equip.
if (!Item::Cursor::PickupBagItem(L, found.bagID, found.slotIndex))
return 0;
using ResolveUnitToken_t = void *(__fastcall *)(const char *token);
using AutoEquipCursor_t = void (__thiscall *)(void *player, int flag);
+74
View File
@@ -0,0 +1,74 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
// `C_Item.IsItemInRange(item, targetUnit)` — true if the item is in
// range of the unit, false if out of range, nil when the range check
// doesn't apply. `item` is an itemID, `"item:NNN"` string, or item link
// (names aren't resolvable — vanilla has no name→ID map — and yield nil);
// `targetUnit` is a unit token ("target", "mouseover", "party1", …).
//
// An item's range comes from the spell it fires on use: we resolve the
// item to its on-use spell (`Item::Spell::OnUseSpellIDForItemID`) and run
// the same range test as `C_Spell.IsSpellInRange`, via the shared
// `Spell::Range::PlayerVsUnit` core. So items with a targeted on-use
// effect that has a range (nets, bombs, thrown/targeted quest items, …)
// get a true/false answer; items with no on-use spell, or whose on-use
// spell is rangeless (potions, food, self-buff trinkets, hearthstone),
// return nil — matching retail, which also reports nil for items without
// a range restriction.
//
// Range-only, like `IsSpellInRange`: ignores line of sight and target
// faction. Passive reader — an item not yet in the client item cache
// returns nil (no background fetch); items you'd range-check are normally
// in bags / on the action bar and already cached.
#include "Game.h"
#include "item/Arg.h"
#include "item/Spell.h"
#include "spell/Range.h"
#include <cstdint>
namespace Item::InRange {
namespace {
int __fastcall Script_C_Item_IsItemInRange(void *L) {
const int itemID = Item::Arg::ResolveItemID(L, 1);
const int spellID =
itemID > 0 ? Item::Spell::OnUseSpellIDForItemID(
static_cast<uint32_t>(itemID))
: 0;
const char *unit =
Game::Lua::IsString(L, 2) ? Game::Lua::ToString(L, 2) : nullptr;
// `::Spell` — unqualified `Spell` would bind to `Item::Spell` here.
const int result = ::Spell::Range::PlayerVsUnit(spellID, unit);
if (result < 0) {
Game::Lua::PushNil(L);
return 1;
}
Game::Lua::PushBool(L, result == 1);
return 1;
}
void RegisterLuaFunctions() {
Game::Lua::RegisterTableFunction("C_Item", "IsItemInRange",
&Script_C_Item_IsItemInRange);
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace
} // namespace Item::InRange
+13
View File
@@ -27,6 +27,9 @@ namespace {
using BuildItemLink_t = const char *(__fastcall *)(const void *cgItem);
using BuildInstanceName_t = void(__thiscall *)(const void *cgItem, char *out,
unsigned outSize);
// Cached ItemStats record lookup. Same `_GetRecord` pattern the rest of
// the codebase uses; passes a noop NULL callback so no SMSG fires for
// uncached items — caller treats "not cached" as a clean failure.
@@ -50,6 +53,16 @@ const char *FromCGItem(const uint8_t *cgItem) {
return fn(cgItem);
}
bool NameFromCGItem(const uint8_t *cgItem, char *out, size_t outSize) {
if (cgItem == nullptr || out == nullptr || outSize == 0)
return false;
out[0] = '\0';
auto fn = reinterpret_cast<BuildInstanceName_t>(
Offsets::FUN_ITEM_BUILD_INSTANCE_NAME);
fn(cgItem, out, static_cast<unsigned>(outSize));
return out[0] != '\0';
}
bool BasicFromItemID(uint32_t itemID, char *out, size_t outSize) {
if (out == nullptr || outSize == 0)
return false;
+11
View File
@@ -29,6 +29,17 @@ namespace Item::Link {
// produce for the same slot.
const char *FromCGItem(const uint8_t *cgItem);
// Writes the item's per-instance decorated *display name* into `out` —
// the same `[Name]` the engine stamps into `FromCGItem`'s link, e.g.
// "Iridium Chain of the Owl" (or the base name when the instance carries
// no random suffix). Reads the suffix ID off the CGItem descriptor
// (`+0x98`) and formats it through the engine's own name builder
// (`FUN_ITEM_BUILD_INSTANCE_NAME`). Returns false (and leaves `out`
// empty) for a null item or an empty build result, so callers can fall
// back to the base ItemStats name. Use whenever a name is returned
// alongside a `FromCGItem` link, so the two agree.
bool NameFromCGItem(const uint8_t *cgItem, char *out, size_t outSize);
// Builds a basic itemID-only hyperlink (`"|cffRRGGBB|Hitem:N:0:0:0|h[Name]|h|r"`)
// into `out` from the cached ItemStats record for `itemID`. No
// enchant / random-suffix decoration — use this when the caller has
+102 -75
View File
@@ -16,8 +16,10 @@
#include "../Game.h"
#include "../Offsets.h"
#include "../guid/Guid.h"
#include "../unit/Identity.h"
#include "Arg.h"
#include "ID.h"
#include "Link.h"
#include <cstring>
@@ -28,20 +30,10 @@ namespace {
using GetItemBySlot_t = void *(__thiscall *)(void *thisInvMgr, int slot);
using PackBagSlot_t = int(__fastcall *)(void *L, void **outInvMgr, int *outLinearSlot,
int *outUnused);
using ResolveUnitToken_t = void *(__fastcall *)(const char *token);
using GetItemRecord_t = const uint8_t *(__thiscall *)(void *cache, uint32_t itemID,
const uint64_t *guid, void *callback,
void *userData, int unused);
void *ResolveActivePlayerInvMgr() {
auto ResolveUnitToken =
reinterpret_cast<ResolveUnitToken_t>(Offsets::FUN_RESOLVE_UNIT_TOKEN);
auto *player = static_cast<uint8_t *>(ResolveUnitToken("player"));
if (player == nullptr)
return nullptr;
return player + Offsets::OFF_PLAYER_INVENTORY_MANAGER;
}
// Reads `loc.fieldName` and returns it as an int. Returns false via the
// boolean result if the field is missing or non-numeric. Always leaves the
// Lua stack as it found it.
@@ -56,24 +48,10 @@ bool TryReadIntField(void *L, int locIdx, const char *fieldName, int *out) {
}
const uint8_t *ResolveBagSlot(void *L, int bagID, int slotIndex) {
// PackBagSlot reads bagID/slotIndex from Lua stack[1] and stack[2] (it's
// designed to be called from a Lua-callback context like
// Script_GetContainerItemInfo). Replace the stack so positions 1 and 2
// hold the raw values for PackBagSlot's internal lua_tonumber reads.
Game::Lua::SetTop(L, 0);
Game::Lua::PushNumber(L, static_cast<double>(bagID));
Game::Lua::PushNumber(L, static_cast<double>(slotIndex));
void *invMgr = nullptr;
int linearSlot = 0;
int unused = 0;
auto PackBagSlot = reinterpret_cast<PackBagSlot_t>(Offsets::FUN_PACK_BAG_SLOT);
if (!PackBagSlot(L, &invMgr, &linearSlot, &unused) || invMgr == nullptr)
BagSlotDetail d;
if (!ResolveBagSlotDetail(L, bagID, slotIndex, &d))
return nullptr;
auto GetItemBySlot = reinterpret_cast<GetItemBySlot_t>(
Offsets::FUN_ITEMMGR_GET_ITEM_BY_SLOT);
return static_cast<const uint8_t *>(GetItemBySlot(invMgr, linearSlot));
return d.item;
}
// --- GUID-walk helpers ---------------------------------------------------
@@ -97,7 +75,7 @@ uint64_t ReadCGItemGUID(const uint8_t *item) {
} // namespace
const uint8_t *ResolveEquipmentSlot(int slot1Based) {
void *invMgr = ResolveActivePlayerInvMgr();
void *invMgr = const_cast<uint8_t *>(Unit::Identity::PlayerInventoryManager());
if (invMgr == nullptr)
return nullptr;
// GetItemBySlot expects 0-based linearized slot indices. The built-in
@@ -108,6 +86,34 @@ const uint8_t *ResolveEquipmentSlot(int slot1Based) {
return static_cast<const uint8_t *>(GetItemBySlot(invMgr, slot1Based - 1));
}
void *ContainerInventory(const uint8_t *container) {
if (container == nullptr)
return nullptr;
auto *vtable = *reinterpret_cast<const uint8_t *const *>(container);
using GetInventory_t = void *(__thiscall *)(void *container);
auto getInventory = *reinterpret_cast<const GetInventory_t *>(
vtable + Offsets::OFF_CONTAINER_GET_INVENTORY);
return getInventory(const_cast<uint8_t *>(container));
}
void *EquippedBagInventory(int bagID) {
if (bagID < 1 || bagID > 4)
return nullptr;
using GetBagGuid_t = uint64_t(__fastcall *)(uint32_t bagIndex0);
auto getBagGuid =
reinterpret_cast<GetBagGuid_t>(Offsets::FUN_GET_EQUIPPED_BAG_GUID);
const uint64_t bagGuid = getBagGuid(static_cast<uint32_t>(bagID - 1));
if (bagGuid == 0)
return nullptr; // no bag equipped in that slot
using ResolveByGUID_t = void *(__fastcall *)(int, const char *, uint32_t,
uint32_t, int);
auto resolve = reinterpret_cast<ResolveByGUID_t>(Offsets::FUN_OBJECT_RESOLVE_BY_GUID);
auto *container = static_cast<const uint8_t *>(
resolve(Offsets::OBJ_TYPE_CONTAINER, "ItemMgr", static_cast<uint32_t>(bagGuid),
static_cast<uint32_t>(bagGuid >> 32), 0x172));
return ContainerInventory(container);
}
// Mirrors `Item::Bag::ResolveBagInfo`'s slot-count derivation: backpack
// is fixed at 16, equipped bags read `m_containerSlots` off the cache
// record. Returns 0 for empty bag slots or out-of-range bagIDs.
@@ -129,6 +135,30 @@ int GetBagSlotCount(int bagID) {
record + Offsets::OFF_ITEMSTATS_CONTAINER_SLOTS));
}
bool ResolveBagSlotDetail(void *L, int bagID, int slotIndex, BagSlotDetail *out) {
// PackBagSlot reads bagID/slotIndex from Lua stack[1] and stack[2] (it's
// designed to be called from a Lua-callback context like
// Script_GetContainerItemInfo). Replace the stack so positions 1 and 2
// hold the raw values for PackBagSlot's internal lua_tonumber reads.
Game::Lua::SetTop(L, 0);
Game::Lua::PushNumber(L, static_cast<double>(bagID));
Game::Lua::PushNumber(L, static_cast<double>(slotIndex));
void *container = nullptr;
int linearSlot = 0;
int unused = 0;
auto PackBagSlot = reinterpret_cast<PackBagSlot_t>(Offsets::FUN_PACK_BAG_SLOT);
if (!PackBagSlot(L, &container, &linearSlot, &unused) || container == nullptr)
return false;
auto GetItemBySlot = reinterpret_cast<GetItemBySlot_t>(
Offsets::FUN_ITEMMGR_GET_ITEM_BY_SLOT);
out->container = static_cast<const uint8_t *>(container);
out->linearSlot = linearSlot;
out->item = static_cast<const uint8_t *>(GetItemBySlot(container, linearSlot));
return true;
}
const uint8_t *ResolveBag(void *L, int bagID, int slotIndex) {
return ResolveBagSlot(L, bagID, slotIndex);
}
@@ -152,38 +182,29 @@ const uint8_t *ResolveByGUID(uint64_t guid) {
bool FindByItemID(void *L, int itemID, ByGUIDResult *out) {
if (itemID <= 0)
return false;
Item::Arg::Resolved arg{};
arg.itemID = itemID;
arg.name = nullptr;
return FindByArg(L, arg, out);
}
for (int slot = Offsets::EQUIPMENT_SLOT_FIRST;
slot <= Offsets::EQUIPMENT_SLOT_LAST; ++slot) {
auto *item = ResolveEquipmentSlot(slot);
if (item == nullptr)
continue;
if (Item::ID::FromCGItem(item) == itemID) {
out->equipmentSlotIndex = slot;
out->bagID = 0;
out->slotIndex = 0;
out->item = item;
return true;
}
}
for (int bagID = 0; bagID <= 4; ++bagID) {
const int slotCount = GetBagSlotCount(bagID);
for (int slotIndex = 1; slotIndex <= slotCount; ++slotIndex) {
auto *item = ResolveBag(L, bagID, slotIndex);
if (item == nullptr)
continue;
if (Item::ID::FromCGItem(item) == itemID) {
out->equipmentSlotIndex = 0;
out->bagID = bagID;
out->slotIndex = slotIndex;
out->item = item;
return true;
}
}
}
return false;
bool MatchesArg(const uint8_t *cgItem, const Item::Arg::Resolved &arg) {
const int id = Item::ID::FromCGItem(cgItem);
if (id == 0)
return false;
if (arg.itemID > 0)
return id == arg.itemID;
if (arg.name == nullptr)
return false;
// Compare against the item's *decorated* display name (random suffix
// included) — modern IsEquippedItem / find-by-name match the full name
// only ("Foo of the Owl", not the base "Foo"; verified on the modern
// client). NameFromCGItem falls back to the base name for unsuffixed
// items, so plain items still match; uncached/unnameable → no match.
char name[128];
if (!Item::Link::NameFromCGItem(cgItem, name, sizeof(name)))
return false;
return _stricmp(name, arg.name) == 0;
}
bool FindByArgInBags(void *L, const Item::Arg::Resolved &arg, ByGUIDResult *out) {
@@ -196,24 +217,9 @@ bool FindByArgInBags(void *L, const Item::Arg::Resolved &arg, ByGUIDResult *out)
auto *item = ResolveBag(L, bagID, slotIndex);
if (item == nullptr)
continue;
const int id = Item::ID::FromCGItem(item);
if (id == 0)
if (!MatchesArg(item, arg))
continue;
if (arg.itemID > 0) {
if (id != arg.itemID)
continue;
} else {
auto *record = PeekItemRecord(static_cast<uint32_t>(id));
if (record == nullptr)
continue;
const char *name = *reinterpret_cast<const char *const *>(
record + Offsets::OFF_ITEMSTATS_NAME);
if (name == nullptr || _stricmp(name, arg.name) != 0)
continue;
}
out->equipmentSlotIndex = 0;
out->bagID = bagID;
out->slotIndex = slotIndex;
@@ -224,6 +230,27 @@ bool FindByArgInBags(void *L, const Item::Arg::Resolved &arg, ByGUIDResult *out)
return false;
}
bool FindByArg(void *L, const Item::Arg::Resolved &arg, ByGUIDResult *out) {
if (arg.itemID <= 0 && arg.name == nullptr)
return false;
// Equipment slots first (no Lua stack interaction, cheaper), matching
// `FindByItemID`'s equipment-before-bags order; then fall through to the
// bag walk. Both compare with the shared `MatchesArg` predicate.
for (int slot = Offsets::EQUIPMENT_SLOT_FIRST;
slot <= Offsets::EQUIPMENT_SLOT_LAST; ++slot) {
auto *item = ResolveEquipmentSlot(slot);
if (item != nullptr && MatchesArg(item, arg)) {
out->equipmentSlotIndex = slot;
out->bagID = 0;
out->slotIndex = 0;
out->item = item;
return true;
}
}
return FindByArgInBags(L, arg, out);
}
bool FindByGUID(void *L, uint64_t guid, ByGUIDResult *out) {
if (guid == 0)
return false;
+58 -3
View File
@@ -69,6 +69,28 @@ const uint8_t *ResolveEquipmentSlot(int slot1Based);
// guaranteed cached by the engine's bag-sync flow at login.
int GetBagSlotCount(int bagID);
// Given a `CGContainer*` (an equipped bag or bank bag, resolved by GUID via
// `FUN_OBJECT_RESOLVE_BY_GUID` with `OBJ_TYPE_CONTAINER`), returns its own
// inventory-manager object via the container's vtable method at
// `+OFF_CONTAINER_GET_INVENTORY`. The returned object has the same layout as
// the player inventory manager: slot count at `+0x00`, flat GUID array at
// `+OFF_INVMGR_GUID_ARRAY`. Null-safe (null container → null). Pure C — no
// Lua stack, safe from any context. Consolidates the vtable dispatch that was
// hand-rolled across the bag/bank walks in `item/Count`, `item/AverageLevel`,
// `equipmentset/Locations`, and `item/NewItems`.
void *ContainerInventory(const uint8_t *container);
// Returns the inventory object of the equipped bag in player bag slot `bagID`
// (1..4): its container GUID via `FUN_GET_EQUIPPED_BAG_GUID`, resolved to the
// CGContainer, then its inventory via `ContainerInventory`. The result has the
// player-invMgr shape (slot count at `+0x00`, GUID array at
// `+OFF_INVMGR_GUID_ARRAY`) and is what `GetItemBySlot` indexes with a 0-based
// slot. Returns null for `bagID` outside 1..4 or when no bag is equipped
// there. Pure C — no Lua stack. (The backpack, `bagID` 0, has no container
// object; index it directly off the player inventory manager at
// `BACKPACK_LINEAR_BASE + slot - 1`.)
void *EquippedBagInventory(int bagID);
// GUID-walk result. `equipmentSlotIndex != 0` means the item was
// found in a character-pane equipment slot; otherwise it's in
// `bagID`/`slotIndex`. `item` always points to the found CGItem
@@ -96,6 +118,22 @@ bool FindByGUID(void *L, uint64_t guid, ByGUIDResult *out);
// for link-decoration purposes. Stomps the Lua stack.
bool FindByItemID(void *L, int itemID, ByGUIDResult *out);
// Full detail for a bag slot, for callers that drive the engine's
// cursor-pickup primitive — which needs the *container object* and the
// engine *linear slot*, not just the CGItem.
struct BagSlotDetail {
const uint8_t *container; // PackBagSlot container (invMgr for the backpack, else the bag)
int linearSlot; // engine linear inventory slot
const uint8_t *item; // CGItem at the slot, or null if the slot is empty
};
// Resolves `(bagID, slotIndex)` to its `BagSlotDetail`. Returns false when
// the coordinates don't resolve (bad bag / out-of-range slot); a resolved
// but empty slot returns true with `out->item == nullptr`. Stomps the Lua
// stack (PackBagSlot). `ResolveBag`/`ResolveBagSlot` are thin wrappers that
// keep only `out->item`.
bool ResolveBagSlotDetail(void *L, int bagID, int slotIndex, BagSlotDetail *out);
} // namespace Item::Location
// Forward-declare for `FindByArgInBags` below.
@@ -103,10 +141,19 @@ namespace Item::Arg { struct Resolved; }
namespace Item::Location {
// The single item name/id match predicate: true if `cgItem` satisfies
// the resolved arg. `arg.itemID > 0` ⇒ exact itemID compare; otherwise
// `arg.name` is matched case-insensitively against the item's *decorated*
// display name (random suffix included — via `Item::Link::NameFromCGItem`,
// which falls back to the base name for unsuffixed items). Matches modern
// `IsEquippedItem` / by-name semantics: "Foo of the Owl" matches, the base
// "Foo" does not. Null / uncached / unnameable items → false. Shared by
// `FindByArgInBags` and `C_Item.IsEquippedItem` so the match logic lives
// in exactly one place.
bool MatchesArg(const uint8_t *cgItem, const Item::Arg::Resolved &arg);
// Walks the player's bags (0..4 only — no equipment) looking for the
// first item matching `arg`. `arg.itemID > 0` ⇒ direct ID compare;
// otherwise `arg.name` is matched case-insensitively against the
// cached `ItemStats_C.m_name[0]` of each candidate. On hit, populates
// first item matching `arg` (via `MatchesArg`). On hit, populates
// `out->bagID` / `out->slotIndex` / `out->item` and sets
// `equipmentSlotIndex = 0`. Stomps the Lua stack.
//
@@ -116,6 +163,14 @@ namespace Item::Location {
// equipped or use-from-equipped; the item has to be in bags first).
bool FindByArgInBags(void *L, const Item::Arg::Resolved &arg, ByGUIDResult *out);
// Like `FindByArgInBags` but searches **equipment (1..19) first, then
// bags** — for callers that act on an item wherever it's carried (e.g.
// `C_Item.PickupItem`). On hit `out->equipmentSlotIndex` is non-zero for
// an equipped match, else `out->bagID`/`out->slotIndex` for a bag match.
// `FindByItemID` is the itemID-only specialization of this. Stomps the
// Lua stack.
bool FindByArg(void *L, const Item::Arg::Resolved &arg, ByGUIDResult *out);
// Parses the `"0xHHHHHHHHLLLLLLLL"` GUID string format
// `C_Item.GetItemGUID` returns. Strict: requires exactly `0x` prefix
// + 16 case-insensitive hex digits. Returns false (and leaves `*out`
+25 -5
View File
@@ -16,6 +16,7 @@
#include "item/Arg.h"
#include "item/Data.h"
#include "item/ID.h"
#include "item/Link.h"
#include "item/Location.h"
#include <cstdint>
@@ -56,18 +57,37 @@ int PushNameForItemID(void *L, int itemID) {
return 1;
}
// Pushes the suffix-decorated display name for a resolved `CGItem` via
// the shared engine name builder. Returns 0 (pushes nothing) for a null
// item or an empty build result, so the caller can fall back to the
// base-name-by-itemID path.
int PushInstanceName(void *L, const uint8_t *cgItem) {
char buf[128];
if (!Item::Link::NameFromCGItem(cgItem, buf, sizeof(buf)))
return 0;
Game::Lua::PushString(L, buf);
return 1;
}
} // namespace
// `C_Item.GetItemName(itemLocation)` — returns the cached display name
// of the item at the location, or nil for empty / uncached / invalid
// inputs. Reads `m_name[0]` straight off `ItemStats_C` — no engine
// round-trip, no `GetItemInfo` chaining.
// `C_Item.GetItemName(itemLocation)` — returns the display name of the
// item at the location, or nil for empty / uncached / invalid inputs.
// The location points at a real item instance, so the name carries any
// random-suffix decoration ("... of the Sorcerer") — built off the
// CGItem via the engine's own name builder, matching modern WoW and
// `C_Item.GetItemLink`'s bracketed name. Falls back to the base
// `ItemStats_C` name (and warms the cache) if the instance build yields
// nothing.
static int __fastcall Script_C_Item_GetItemName(void *L) {
if (!Item::Location::IsLocationArg(L, 1)) {
Game::Lua::Error(L, "Usage: C_Item.GetItemName(itemLocation)");
return 0;
}
return PushNameForItemID(L, Item::ID::FromCGItem(Item::Location::Resolve(L, 1)));
const uint8_t *cgItem = Item::Location::Resolve(L, 1);
if (int pushed = PushInstanceName(L, cgItem))
return pushed;
return PushNameForItemID(L, Item::ID::FromCGItem(cgItem));
}
// `C_Item.GetItemNameByID(itemInfo)` — returns the cached display name
+299
View File
@@ -0,0 +1,299 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
// `C_NewItems` — the modern "new item glow" bookkeeping system. Retail
// flags items freshly added to your bags as "new" (the sparkle/flash on
// the bag button) and clears the flag once you've acknowledged them.
// Vanilla 1.12 never had the feature — no DBC, no server data, no engine
// bit; it's pure client-side bookkeeping over bag contents. So we add it,
// entirely in C++ the same way retail's client owns it — no addon Lua.
//
// Public surface (matches retail exactly):
// C_NewItems.IsNewItem(bagID, slotIndex) -> bool
// C_NewItems.RemoveNewItem(bagID, slotIndex)
// C_NewItems.ClearAll()
//
// Newness is keyed on each item's **instance GUID** (read off the CGItem),
// not on (bagID, slotIndex). That's what makes a flag survive the player
// rearranging a bag — exactly like retail, and impossible for a pure-Lua
// addon (vanilla exposes no per-instance GUID to Lua).
//
// The feed is event-driven, not polled: it hangs off `Bag::UpdateDelayed`,
// which co-hooks the engine's three `BAG_UPDATE` fire sites and signals once
// per frame in which a bag actually changed. On that signal we scan bags
// 0..4, diff the resident item GUIDs against the previous scan, flag anything
// newly acquired, and prune flags for items that left. `BAG_NEW_ITEMS_UPDATED`
// (no payload — matching retail) fires whenever the new-item set changes. So
// nothing runs on quiet frames.
//
// The one exception is establishing the login baseline: a lightweight
// WorldTick handler waits ~1.5s after the player is first resolvable (long
// enough for the initial bag population to finish), takes a single scan as the
// baseline, and then goes idle — items already owned at login are therefore
// never flagged. It re-arms on player-GUID change (relog / character switch).
// Bank and equipment are out of scope, matching retail.
//
// Bag enumeration is pure C++: it replicates what `PackBagSlot` does
// internally (see Offsets.h). The backpack is indexed straight off the
// player inventory manager; each equipped bag 1..4 is resolved to its
// container object and inventory via the same GUID getter + vtable call the
// engine uses, then walked with `GetItemBySlot`. None of the Lua-stack-bound
// helpers are touched, so the scan is safe from the frame-loop tick.
#include "Game.h"
#include "Offsets.h"
#include "bag/UpdateDelayed.h"
#include "event/Custom.h"
#include "item/Location.h"
#include "tick/WorldTick.h"
#include "unit/Identity.h"
#include <cstdint>
namespace Item::NewItems {
namespace {
constexpr const char *kEvtNewItems = "BAG_NEW_ITEMS_UPDATED";
const Event::Custom::AutoReserve _r{kEvtNewItems};
// Bags cap at backpack (16) + four equipped bags; even the largest Turtle
// bags stay well under this. Overflow silently drops — a flagged-new item
// past the cap just won't glow, never a crash.
constexpr int kMaxItems = 256;
// Delay after the player is first resolvable before the baseline is taken,
// giving the initial bag population time to finish. Wall-clock (via the
// engine ms counter) so it's independent of frame rate.
constexpr uint32_t kSettleMs = 1500;
uint64_t g_seen[kMaxItems]; // all bag-resident GUIDs at the last scan
int g_seenCount = 0;
uint64_t g_new[kMaxItems]; // subset currently flagged "new"
int g_newCount = 0;
bool g_seeded = false; // true once the login baseline has been taken
uint64_t g_playerGuid = 0; // re-arm the baseline when this changes (relog)
uint32_t g_loginMs = 0; // engine ms at which the current player resolved
uint32_t NowMs() {
using TickCount_t = uint32_t(__fastcall *)();
return reinterpret_cast<TickCount_t>(Offsets::FUN_OS_TICKCOUNT_MS)();
}
bool Contains(const uint64_t *arr, int count, uint64_t guid) {
for (int i = 0; i < count; ++i)
if (arr[i] == guid)
return true;
return false;
}
bool AddUnique(uint64_t *arr, int *count, uint64_t guid) {
if (Contains(arr, *count, guid) || *count >= kMaxItems)
return false;
arr[(*count)++] = guid;
return true;
}
bool RemoveFrom(uint64_t *arr, int *count, uint64_t guid) {
for (int i = 0; i < *count; ++i) {
if (arr[i] == guid) {
arr[i] = arr[--(*count)];
return true;
}
}
return false;
}
uint64_t ItemGUID(const uint8_t *item) {
if (item == nullptr)
return 0;
auto *instance = *reinterpret_cast<const uint8_t *const *>(
item + Offsets::OFF_ITEM_INSTANCE_BLOCK);
if (instance == nullptr)
return 0;
return *reinterpret_cast<const uint64_t *>(
instance + Offsets::OFF_INSTANCE_BLOCK_GUID);
}
// --- pure-C++ bag enumeration (no Lua stack) -----------------------------
using GetItemBySlot_t = void *(__thiscall *)(void *inventory, int slot0Based);
// Collects the instance GUIDs of every item in bags 0..4 into `out`.
// Returns the count. Pure C++ — safe to call off the frame-loop tick.
int ScanBags(const uint8_t *player, uint64_t *out) {
auto getItemBySlot =
reinterpret_cast<GetItemBySlot_t>(Offsets::FUN_ITEMMGR_GET_ITEM_BY_SLOT);
int count = 0;
// Backpack (bagID 0): indexed directly off the player inventory manager
// at linear slot (S-1) + 23. GetItemBySlot only reads the manager, so the
// const_cast to satisfy its `this` parameter is safe.
auto *invMgr = const_cast<uint8_t *>(player) + Offsets::OFF_PLAYER_INVENTORY_MANAGER;
const int backpackSlots = Item::Location::GetBagSlotCount(0);
for (int slot = 1; slot <= backpackSlots; ++slot) {
auto *item = static_cast<const uint8_t *>(
getItemBySlot(invMgr, Offsets::BACKPACK_LINEAR_BASE + slot - 1));
const uint64_t guid = ItemGUID(item);
if (guid != 0)
AddUnique(out, &count, guid);
}
// Equipped bags 1..4: resolve each bag's inventory object, then walk it.
for (int bagID = 1; bagID <= 4; ++bagID) {
const int slots = Item::Location::GetBagSlotCount(bagID);
if (slots <= 0)
continue;
void *bagInv = Item::Location::EquippedBagInventory(bagID);
if (bagInv == nullptr)
continue;
for (int slot = 1; slot <= slots; ++slot) {
auto *item = static_cast<const uint8_t *>(getItemBySlot(bagInv, slot - 1));
const uint64_t guid = ItemGUID(item);
if (guid != 0)
AddUnique(out, &count, guid);
}
}
return count;
}
void FireChanged() {
Event::Custom::Fire(Event::Custom::Lookup(kEvtNewItems), "");
}
void CopyToSeen(const uint64_t *current, int count) {
for (int i = 0; i < count; ++i)
g_seen[i] = current[i];
g_seenCount = count;
}
// Login-baseline handler. Runs every frame but does no work once the
// baseline is taken — it only watches for (re)login and, after a short
// settle, takes the single authoritative baseline scan. Steady state is just
// a GUID read + `if (g_seeded) return`.
void OnTick() {
const uint64_t guid = Unit::Identity::PlayerGuid();
if (guid == 0)
return; // not in world yet
// Fresh login / character switch: drop all state and re-arm the settle.
if (guid != g_playerGuid) {
g_playerGuid = guid;
g_newCount = 0;
g_seenCount = 0;
g_seeded = false;
g_loginMs = NowMs();
}
if (g_seeded)
return; // baseline established; the bag-change signal drives the rest
if (NowMs() - g_loginMs < kSettleMs)
return; // let the initial bag population finish
const uint8_t *player = Unit::Identity::PlayerObject();
if (player == nullptr)
return; // not resolvable yet — retry next tick
// Everything currently in the bags becomes the baseline; nothing owned at
// login is "new". Any change up to this point is intentionally absorbed.
g_seenCount = ScanBags(player, g_seen);
g_newCount = 0;
g_seeded = true;
}
// Authoritative bag-change handler — fires only on frames where the engine
// actually changed a bag (via Bag::UpdateDelayed). Diffs the current bag set
// against the baseline, flags newly-acquired items, prunes departed ones.
void OnBagChanged() {
if (!g_seeded)
return; // pre-baseline changes are folded into the login baseline
const uint64_t guid = Unit::Identity::PlayerGuid();
if (guid == 0 || guid != g_playerGuid)
return; // mid-relog; OnTick re-arms the baseline
const uint8_t *player = Unit::Identity::PlayerObject();
if (player == nullptr)
return;
uint64_t current[kMaxItems];
const int currentCount = ScanBags(player, current);
bool changed = false;
// Drop flags for items no longer in the bags (used, sold, banked).
for (int i = 0; i < g_newCount;) {
if (!Contains(current, currentCount, g_new[i])) {
g_new[i] = g_new[--g_newCount];
changed = true;
} else {
++i;
}
}
// Flag anything not present at the previous scan.
for (int i = 0; i < currentCount; ++i) {
if (!Contains(g_seen, g_seenCount, current[i]))
changed |= AddUnique(g_new, &g_newCount, current[i]);
}
CopyToSeen(current, currentCount);
if (changed)
FireChanged();
}
const Tick::WorldTick::AutoSubscribe _tick{&OnTick};
const Bag::UpdateDelayed::AutoSubscribe _bagChange{&OnBagChanged};
// --- C_NewItems Lua surface ----------------------------------------------
// Resolves a `(bagID, slotIndex)` Lua-arg pair to the item's instance GUID,
// or 0 if the args are missing/non-numeric or the slot is empty. Uses the
// Lua-context bag resolver — fine here, these run as Lua C functions.
uint64_t SlotGUID(void *L) {
if (!Game::Lua::IsNumber(L, 1) || !Game::Lua::IsNumber(L, 2))
return 0;
const int bagID = static_cast<int>(Game::Lua::ToNumber(L, 1));
const int slotIndex = static_cast<int>(Game::Lua::ToNumber(L, 2));
return ItemGUID(Item::Location::ResolveBag(L, bagID, slotIndex));
}
int __fastcall Script_IsNewItem(void *L) {
const uint64_t guid = SlotGUID(L);
Game::Lua::PushBool(L, guid != 0 && Contains(g_new, g_newCount, guid));
return 1;
}
int __fastcall Script_RemoveNewItem(void *L) {
const uint64_t guid = SlotGUID(L);
if (guid != 0 && RemoveFrom(g_new, &g_newCount, guid))
FireChanged();
return 0;
}
int __fastcall Script_ClearAll(void *L) {
(void)L;
if (g_newCount > 0) {
g_newCount = 0;
FireChanged();
}
return 0;
}
void RegisterLuaFunctions() {
Game::Lua::RegisterTableFunction("C_NewItems", "IsNewItem", &Script_IsNewItem);
Game::Lua::RegisterTableFunction("C_NewItems", "RemoveNewItem", &Script_RemoveNewItem);
Game::Lua::RegisterTableFunction("C_NewItems", "ClearAll", &Script_ClearAll);
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace
} // namespace Item::NewItems
+63
View File
@@ -0,0 +1,63 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
// `C_Item.PickupItem(itemInfo)` — finds the item matching `itemInfo` in
// the player's equipment or bags and picks it up onto the cursor.
// `itemInfo` is an itemID, `"item:NNN"` string, item link, or item name —
// the same input `C_Item.UseItemByName` / `EquipItemByName` accept,
// resolved via `Item::Arg::Resolve` and matched by `FindByArg` (itemID, or
// decorated name for a name arg) across equipment (1..19, checked first)
// and bags (0..4).
//
// The pickup goes through the shared `Item::Cursor` wrappers, which call
// the engine's cursor primitives directly (no Lua `Script_Pickup*`
// wrapper): an equipped match uses the equipment-slot primitive, a bag
// match the bag primitive (guarded on an empty cursor + unlocked item).
// Silently no-ops on bad input, no match, a busy cursor, or a locked item.
#include "Game.h"
#include "item/Arg.h"
#include "item/Cursor.h"
#include "item/Location.h"
namespace Item::Pickup {
namespace {
int __fastcall Script_C_Item_PickupItem(void *L) {
const auto arg = Item::Arg::Resolve(L, 1);
if (arg.itemID <= 0 && arg.name == nullptr)
return 0;
Item::Location::ByGUIDResult found;
if (!Item::Location::FindByArg(L, arg, &found))
return 0;
// equipmentSlotIndex != 0 → equipped (paperdoll slot); else a bag slot.
if (found.equipmentSlotIndex != 0)
Item::Cursor::PickupEquipmentSlot(found.equipmentSlotIndex);
else
Item::Cursor::PickupBagItem(L, found.bagID, found.slotIndex);
return 0;
}
void RegisterLuaFunctions() {
Game::Lua::RegisterTableFunction("C_Item", "PickupItem",
&Script_C_Item_PickupItem);
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace
} // namespace Item::Pickup
+8 -13
View File
@@ -16,6 +16,7 @@
#include "Game.h"
#include "Offsets.h"
#include "item/Location.h"
#include "unit/Identity.h"
#include <cstdint>
@@ -23,7 +24,6 @@ namespace Item::Swap {
namespace {
using ResolveUnitToken_t = void *(__fastcall *)(const char *token);
using SwapFn_t = void(__thiscall *)(
void *player,
uint32_t srcItemLo, uint32_t srcItemHi,
@@ -33,11 +33,6 @@ using SwapFn_t = void(__thiscall *)(
uint32_t dstLinearSlot,
int flag);
void *ResolvePlayer() {
auto fn = reinterpret_cast<ResolveUnitToken_t>(Offsets::FUN_RESOLVE_UNIT_TOKEN);
return fn("player");
}
// Reads the 64-bit GUID stored at instance-block offset 0 of any
// CGObject-derived pointer (CGItem, CGPlayer, CGContainer all use
// the same +0x8 → instance block → +0x00 GUID layout).
@@ -98,7 +93,7 @@ bool EncodeBagSlot(int bagID, int slotInBag,
if (slotInBag < 1)
return false;
if (bagID == 0) {
void *player = ResolvePlayer();
void *player = const_cast<uint8_t *>(Unit::Identity::PlayerObject());
if (player == nullptr)
return false;
if (!ReadGuid(player, containerLo, containerHi))
@@ -119,7 +114,7 @@ bool EncodeBagSlot(int bagID, int slotInBag,
if (bagID == -1) {
if (slotInBag > BANK_MAIN_NUM_SLOTS)
return false;
void *player = ResolvePlayer();
void *player = const_cast<uint8_t *>(Unit::Identity::PlayerObject());
if (player == nullptr)
return false;
if (!ReadGuid(player, containerLo, containerHi))
@@ -152,7 +147,7 @@ bool SendSwap(const void *srcItem,
if (srcItem == nullptr)
return false;
void *player = ResolvePlayer();
void *player = const_cast<uint8_t *>(Unit::Identity::PlayerObject());
if (player == nullptr)
return false;
@@ -187,7 +182,7 @@ bool FromPaperdoll(const void *cgItem, int srcPaperdollSlot, int dstPaperdollSlo
if (srcPaperdollSlot < 1 || srcPaperdollSlot > 19)
return false;
void *player = ResolvePlayer();
void *player = const_cast<uint8_t *>(Unit::Identity::PlayerObject());
if (player == nullptr)
return false;
uint32_t playerLo = 0, playerHi = 0;
@@ -205,7 +200,7 @@ bool ToBag(const void *cgItem, int srcPaperdollSlot, int dstBagID, int dstSlotIn
if (cgItem == nullptr)
return false;
void *player = ResolvePlayer();
void *player = const_cast<uint8_t *>(Unit::Identity::PlayerObject());
if (player == nullptr)
return false;
uint32_t playerLo = 0, playerHi = 0;
@@ -272,7 +267,7 @@ bool MoveCount(void *L, int srcBag, int srcSlot, int dstBag, int dstSlot, int co
if (count == srcStack)
return Containers(L, srcBag, srcSlot, dstBag, dstSlot);
void *player = ResolvePlayer();
void *player = const_cast<uint8_t *>(Unit::Identity::PlayerObject());
if (player == nullptr)
return false;
@@ -308,7 +303,7 @@ bool Containers(void *L, int srcBag, int srcSlot, int dstBag, int dstSlot) {
if (srcItem == nullptr)
return false; // empty source slot
void *player = ResolvePlayer();
void *player = const_cast<uint8_t *>(Unit::Identity::PlayerObject());
if (player == nullptr)
return false;
+13 -18
View File
@@ -220,24 +220,19 @@ static int __fastcall Script_GameTooltipGetItem(void *L) {
const char *link = Item::Link::FromCGItem(
static_cast<const uint8_t *>(cgItem));
if (link != nullptr && *link != '\0') {
// Engine's link builder also writes the dressed
// (random-suffixed) name into the link's `[Name]`
// slot; pull it out by reading the cached base name
// for the return value. The engine doesn't expose
// the dressed name as a separate string, so we
// return the base name — matches modern semantics
// where (name, link) name is the cached display name
// and the link is the full hyperlink.
const uint8_t *record = PeekItemRecord(static_cast<uint32_t>(itemID));
if (record != nullptr) {
const char *name = *reinterpret_cast<const char *const *>(
record + Offsets::OFF_ITEMSTATS_NAME);
if (name != nullptr && *name != '\0') {
Game::Lua::PushString(L, name);
Game::Lua::PushString(L, link);
Game::Lua::PushNumber(L, static_cast<double>(itemID));
return 3;
}
// Return the dressed (random-suffixed) name the engine
// wrote into the link's `[Name]` slot — built off the same
// CGItem via Item::Link::NameFromCGItem — so the returned
// name agrees with the link ("Iridium Chain of the Owl",
// not the base "Iridium Chain") and matches modern
// GameTooltip:GetItem() on suffixed items.
char name[128];
if (Item::Link::NameFromCGItem(
static_cast<const uint8_t *>(cgItem), name, sizeof(name))) {
Game::Lua::PushString(L, name);
Game::Lua::PushString(L, link);
Game::Lua::PushNumber(L, static_cast<double>(itemID));
return 3;
}
}
}
+251
View File
@@ -0,0 +1,251 @@
// 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 "map/Area.h"
#include "Offsets.h"
#include "dbc/Lookup.h"
namespace Map::Area {
int RowForAreaID(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;
}
int CurrentViewRow() {
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;
}
namespace {
double FloatField(const uint8_t *rec, int off) {
return *reinterpret_cast<const float *>(rec + off);
}
// Vanilla zone world-map canvas size (WorldMapDetailFrame); WorldMapOverlay
// hit rects live in this pixel space.
constexpr double kZoneCanvasW = 1002.0;
constexpr double kZoneCanvasH = 668.0;
// How deep inside its containing WorldMapOverlay hit rect the map-percent point
// (0..100) sits on WMA row `wmaRow` — the max, over that zone's overlays whose
// hit rect encloses the point, of the normalized margin to the nearest overlay
// edge (0 at the edge .. 0.5 at center). Returns -1 when the point is on none
// of the zone's overlays.
//
// This is the runtime analog of an ADT area-grid lookup. WorldMapArea rects are
// loose overlapping boxes; the overlay hit rects trace the real landmass, so
// "which zone is the point drawn on" disambiguates them (Un'Goro's crater vs
// Thousand Needles' empty overlap). But hit rects are themselves rectangles
// that overlap at borders, so the DEPTH matters: a border node deep inside its
// own zone's overlay beats one merely clipping a neighbor's overlay edge
// (Nijel's Point is deep in Desolace's "Nijel's Point" overlay but only nicks
// the edge of Stonetalon's Charred Vale box).
double OverlayMargin(int wmaRow, double mapXpct, double mapYpct) {
const double cx = mapXpct / 100.0 * kZoneCanvasW;
const double cy = mapYpct / 100.0 * kZoneCanvasH;
const int count =
*reinterpret_cast<const int *>(Offsets::VAR_WORLDMAP_OVERLAY_COUNT);
double best = -1.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 int hl = *reinterpret_cast<const int *>(rec + Offsets::OFF_WMO_HITRECT_LEFT);
const int hr = *reinterpret_cast<const int *>(rec + Offsets::OFF_WMO_HITRECT_RIGHT);
const int ht = *reinterpret_cast<const int *>(rec + Offsets::OFF_WMO_HITRECT_TOP);
const int hb = *reinterpret_cast<const int *>(rec + Offsets::OFF_WMO_HITRECT_BOTTOM);
const double sw = hr - hl, sh = hb - ht;
if (sw <= 0.0 || sh <= 0.0)
continue; // no / degenerate hit rect
if (cx < hl || cx > hr || cy < ht || cy > hb)
continue; // point outside this overlay
const double mX = (cx - hl < hr - cx ? cx - hl : hr - cx) / sw;
const double mY = (cy - ht < hb - cy ? cy - ht : hb - cy) / sh;
const double m = mX < mY ? mX : mY;
if (m > best)
best = m;
}
return best;
}
} // namespace
bool ZonePercent(int mapID, float x, float y, int *outAreaID, double *outMapX,
double *outMapY) {
const int count =
*reinterpret_cast<const int *>(Offsets::VAR_WORLDMAP_AREA_COUNT);
// Two candidate tracks over the zones whose rect contains the point:
// land* — the point is on the zone's drawn landmass (an overlay hit rect
// encloses it): authoritative, wins whenever any zone qualifies.
// Scored by how DEEP inside the overlay the point sits, so a
// border node deep in its own zone's overlay beats one nicking a
// neighbor's overlay edge.
// box* — bounding-box only: the fallback when the point is on no zone's
// overlay (map gaps / overlay-less custom zones), scored by the
// most-interior zone-rect margin.
bool haveLand = false, haveBox = false;
double landScore = -1.0, boxMargin = 0.0;
int landArea = 0, boxArea = 0;
double landX = 0.0, landY = 0.0, boxX = 0.0, boxY = 0.0;
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)
continue;
if (*reinterpret_cast<const int *>(rec + Offsets::OFF_WMA_MAP_ID) != mapID)
continue;
const int areaID =
*reinterpret_cast<const int *>(rec + Offsets::OFF_WMA_AREA_ID);
if (areaID == 0)
continue; // continent-spanning row, not a zone
const double left = FloatField(rec, Offsets::OFF_WMA_LOC_LEFT);
const double right = FloatField(rec, Offsets::OFF_WMA_LOC_RIGHT);
const double top = FloatField(rec, Offsets::OFF_WMA_LOC_TOP);
const double bottom = FloatField(rec, Offsets::OFF_WMA_LOC_BOTTOM);
const double spanX = top - bottom; // > 0
const double spanY = left - right; // > 0
if (spanX <= 0.0 || spanY <= 0.0)
continue; // degenerate / unmapped zone
if (x < bottom || x > top || y < right || y > left)
continue; // point outside this zone's rect
// mapX = horizontal (world Y), mapY = vertical (world X).
const double mapX = (left - y) / spanY * 100.0;
const double mapY = (top - x) / spanX * 100.0;
const double marginX = (x - bottom < top - x ? x - bottom : top - x) / spanX;
const double marginY = (y - right < left - y ? y - right : left - y) / spanY;
const double margin = marginX < marginY ? marginX : marginY;
if (!haveBox || margin > boxMargin) {
haveBox = true;
boxMargin = margin;
boxArea = areaID;
boxX = mapX;
boxY = mapY;
}
const double overlayMargin = OverlayMargin(id, mapX, mapY);
if (overlayMargin >= 0.0 && (!haveLand || overlayMargin > landScore)) {
haveLand = true;
landScore = overlayMargin;
landArea = areaID;
landX = mapX;
landY = mapY;
}
}
if (haveLand) {
*outAreaID = landArea;
*outMapX = landX;
*outMapY = landY;
return true;
}
if (haveBox) {
*outAreaID = boxArea;
*outMapX = boxX;
*outMapY = boxY;
return true;
}
return false;
}
bool PercentInZone(int areaID, float x, float y, double *outMapX, double *outMapY) {
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)
continue;
if (*reinterpret_cast<const int *>(rec + Offsets::OFF_WMA_AREA_ID) != areaID)
continue;
const double left = FloatField(rec, Offsets::OFF_WMA_LOC_LEFT);
const double right = FloatField(rec, Offsets::OFF_WMA_LOC_RIGHT);
const double top = FloatField(rec, Offsets::OFF_WMA_LOC_TOP);
const double bottom = FloatField(rec, Offsets::OFF_WMA_LOC_BOTTOM);
const double spanX = top - bottom;
const double spanY = left - right;
if (spanX <= 0.0 || spanY <= 0.0)
continue; // degenerate row — keep scanning for a usable one
if (x < bottom || x > top || y < right || y > left)
return false; // point outside this zone's rect — not in this zone
*outMapX = (left - y) / spanY * 100.0; // horizontal (world Y)
*outMapY = (top - x) / spanX * 100.0; // vertical (world X)
return true;
}
return false;
}
bool ContinentPercent(int mapID, float x, float y, double *outPx, double *outPy) {
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)
continue;
if (*reinterpret_cast<const int *>(rec + Offsets::OFF_WMA_MAP_ID) != mapID)
continue;
if (*reinterpret_cast<const int *>(rec + Offsets::OFF_WMA_AREA_ID) != 0)
continue; // want the continent-spanning row
const double left = FloatField(rec, Offsets::OFF_WMA_LOC_LEFT);
const double right = FloatField(rec, Offsets::OFF_WMA_LOC_RIGHT);
const double top = FloatField(rec, Offsets::OFF_WMA_LOC_TOP);
const double bottom = FloatField(rec, Offsets::OFF_WMA_LOC_BOTTOM);
const double spanX = top - bottom;
const double spanY = left - right;
if (spanX <= 0.0 || spanY <= 0.0)
continue; // stray zero-rect continent row (e.g. "World") — skip
*outPx = (left - y) / spanY; // horizontal (world Y)
*outPy = (top - x) / spanX; // vertical (world X)
return true;
}
return false;
}
} // namespace Map::Area
+79
View File
@@ -0,0 +1,79 @@
// 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>
// `Map::Area` — shared `WorldMapArea.dbc` row resolution. A WorldMapArea
// row is the client's per-zone map descriptor (directory name + the
// world-coordinate rect used for world↔map transforms); every C_Map.*
// reader that keys off "which zone" resolves a row first. Both
// `Map::Overlays` (GetMapOverlays) and `Map::WorldSize` (GetMapWorldSize)
// go through here so the two ways of naming a zone — an AreaTable areaID
// argument, or the world map's current continent/zone view — live in one
// place.
namespace Map::Area {
// WorldMapArea row for an AreaTable `areaID` (the stable zone identity
// `C_Map.GetBestMapForUnit` returns). Linear walk of the ~175-row table.
// Returns -1 when no row carries that areaID.
int RowForAreaID(uint32_t areaID);
// WorldMapArea row for the world map's currently *viewed* zone — the same
// continent/zone selection the engine's map-name resolver (FUN_004a6cf0,
// behind GetMapInfo) performs: continent index → per-continent blob, zone
// index (-1 = whole continent) → that continent's zone-row array; no
// continent selected falls back to the default-row global. Returns -1 when
// the continent blob isn't resident.
int CurrentViewRow();
// Resolves world point (x, y) on continent `mapID` to a zone: its AreaTable
// areaID and a 0..100 zone-relative percent (`mapX` horizontal off world Y,
// `mapY` vertical off world X — the WoW/pfQuest convention).
//
// WorldMapArea rects are loose, overlapping bounding boxes, so a point near a
// zone edge falls inside several. Resolution therefore prefers the zone whose
// **drawn landmass** (WorldMapOverlay hit rect) the point actually sits on —
// the runtime analog of an ADT area-grid lookup (Un'Goro's crater vs Thousand
// Needles' empty overlap). Overlay hit rects are themselves rectangles that
// overlap at borders, so among landmass matches the winner is the zone the
// point is DEEPEST inside an overlay of (Nijel's Point is deep in Desolace's
// own overlay but only clips the edge of Stonetalon's Charred Vale box). If the
// point is on no zone's overlay, falls back to the most-interior containing
// rect. Returns false when no zone rect encloses the point (open sea,
// degenerate rect, an instance with no zone row). Shared by GetAreaTriggerInfo
// and the taxi-node zone resolve.
bool ZonePercent(int mapID, float x, float y, int *outAreaID, double *outMapX,
double *outMapY);
// Projects world (x, y) into the WorldMapArea rect of a SPECIFIC AreaTable
// zone (`areaID`), filling the 0..100 zone-relative percent (`mapX` horizontal
// off world Y, `mapY` vertical off world X). Returns false when that zone has
// no usable WorldMapArea row OR the point lies outside its rect. Used when the
// zone is already known by other means (a taxi node's name) and only the
// in-zone position is needed — bypassing the containment / landmass search of
// ZonePercent. The containment check lets a caller test candidate zones (a
// node's city vs its outdoor region) and take the first that actually holds
// the point.
bool PercentInZone(int areaID, float x, float y, double *outMapX, double *outMapY);
// Projects world (x, y) on `mapID` to 0..1 CONTINENT-map coordinates using
// the continent's WorldMapArea row (the areaID == 0 row with a non-degenerate
// rect — some maps carry a stray zero-rect continent row that is skipped).
// `outPx` is horizontal (off world Y), `outPy` vertical (off world X).
// Returns false when the map has no usable continent row. This is the
// projection retail's TaxiNodeInfo.position uses.
bool ContinentPercent(int mapID, float x, float y, double *outPx, double *outPy);
} // namespace Map::Area
+169
View File
@@ -0,0 +1,169 @@
// 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.GetAreaTriggerInfo(triggerID)` and `C_Map.GetAreaTriggers([mapID])`
// — expose `AreaTrigger.dbc`, the client's static trigger-volume geometry
// (subzone-entry / exploration / teleport triggers). Vanilla loads the DBC
// but exposes NONE of its geometry to Lua, so map addons (pfQuest, …) ship
// hand-scraped trigger tables. Same value proposition as `GetMapOverlays`:
// live, correct client data, no per-server table drift.
//
// ## Return shape (one info table)
//
// id — AreaTrigger.dbc row id
// mapID — Map.dbc id (continent 0/1, or an instance map)
// x, y, z — continent-space world coordinates of the trigger center
// radius — sphere-trigger radius (0 for a box trigger)
// isBox — true when the trigger is an oriented box (see below)
// boxLength / boxWidth / boxHeight / boxYaw — box dimensions + yaw
// (all 0 for a pure sphere trigger)
// areaID — AreaTable zone id the point falls in (via WorldMapArea);
// absent when no zone rect on this map contains the point
// mapX / mapY — the trigger's position as a 0..100 map-relative percent
// within that zone (the coordinate form map addons draw
// with); absent alongside areaID when unresolved
//
// The raw world geometry is authoritative (straight from the DBC); the
// derived `areaID`/`mapX`/`mapY` is the consumable form. Both are provided
// so a consumer can draw immediately or run its own world→map transform.
//
// ## World → map-percent transform
//
// `WorldMapArea.dbc` gives each zone a world-coordinate rect
// (`locTop`/`locBottom` bound world X, `locLeft`/`locRight` bound world Y —
// WoW's world axes: +X north, +Y west). In map space the HORIZONTAL axis
// (mapX) runs off world Y and the VERTICAL axis (mapY) off world X — the
// standard WoW / pfQuest / Astrolabe convention:
// mapX% = (locLeft - y) / (locLeft - locRight) * 100 (horizontal)
// mapY% = (locTop - x) / (locTop - locBottom) * 100 (vertical)
// We pick the zone by containment: among WorldMapArea rows on the trigger's
// map with a real (non-continent) areaID whose rect encloses the point, the
// smallest-area rect wins (the tightest zone — a subzone beats its parent).
// The engine's own map-name resolver reads the same rect fields; see
// `Map::Overlays` for the shared WorldMapArea wiring.
#include "Game.h"
#include "Offsets.h"
#include "dbc/Lookup.h"
#include "map/Area.h"
#include <cstdint>
namespace Map::AreaTriggers {
namespace {
float FloatField(const uint8_t *rec, int off) {
return *reinterpret_cast<const float *>(rec + off);
}
// Zone containment + map-percent projection lives in the shared
// `Map::Area::ZonePercent` (taxi nodes use the same transform).
// Builds one trigger's info table and leaves it on the Lua stack top.
// Returns false (pushing nothing) when `id` is out of range or the slot
// is empty.
bool PushTriggerInfo(void *L, int id) {
const uint8_t *rec = DBC::Record(Offsets::VAR_AREATRIGGER_RECORDS,
Offsets::VAR_AREATRIGGER_COUNT,
static_cast<uint32_t>(id));
if (rec == nullptr)
return false;
const int mapID = *reinterpret_cast<const int *>(rec + Offsets::OFF_AT_MAP_ID);
const float x = FloatField(rec, Offsets::OFF_AT_X);
const float y = FloatField(rec, Offsets::OFF_AT_Y);
const float z = FloatField(rec, Offsets::OFF_AT_Z);
const float radius = FloatField(rec, Offsets::OFF_AT_RADIUS);
const float boxLength = FloatField(rec, Offsets::OFF_AT_BOX_LENGTH);
const float boxWidth = FloatField(rec, Offsets::OFF_AT_BOX_WIDTH);
const float boxHeight = FloatField(rec, Offsets::OFF_AT_BOX_HEIGHT);
const float boxYaw = FloatField(rec, Offsets::OFF_AT_BOX_YAW);
const bool isBox = (boxLength != 0.0f || boxWidth != 0.0f || boxHeight != 0.0f);
Game::Lua::NewTable(L);
Game::Lua::SetFieldNumber(L, "id", id);
Game::Lua::SetFieldNumber(L, "mapID", mapID);
Game::Lua::SetFieldNumber(L, "x", x);
Game::Lua::SetFieldNumber(L, "y", y);
Game::Lua::SetFieldNumber(L, "z", z);
Game::Lua::SetFieldNumber(L, "radius", radius);
Game::Lua::SetFieldBool(L, "isBox", isBox);
Game::Lua::SetFieldNumber(L, "boxLength", boxLength);
Game::Lua::SetFieldNumber(L, "boxWidth", boxWidth);
Game::Lua::SetFieldNumber(L, "boxHeight", boxHeight);
Game::Lua::SetFieldNumber(L, "boxYaw", boxYaw);
int areaID = 0;
double mapX = 0.0, mapY = 0.0;
if (Map::Area::ZonePercent(mapID, x, y, &areaID, &mapX, &mapY)) {
Game::Lua::SetFieldNumber(L, "areaID", areaID);
Game::Lua::SetFieldNumber(L, "mapX", mapX);
Game::Lua::SetFieldNumber(L, "mapY", mapY);
}
return true;
}
// `C_Map.GetAreaTriggerInfo(triggerID)` — one trigger's info table, or nil
// for a missing / out-of-range id.
int __fastcall Script_GetAreaTriggerInfo(void *L) {
const bool hasID = Game::Lua::IsNumber(L, 1);
const int id = hasID ? static_cast<int>(Game::Lua::ToNumber(L, 1)) : 0;
Game::Lua::SetTop(L, 0);
if (!hasID || !PushTriggerInfo(L, id))
Game::Lua::PushNil(L);
return 1;
}
// `C_Map.GetAreaTriggers([mapID])` — array of every trigger's info table,
// optionally filtered to a single Map.dbc id.
int __fastcall Script_GetAreaTriggers(void *L) {
const bool filter = Game::Lua::IsNumber(L, 1);
const int wantMap = filter ? static_cast<int>(Game::Lua::ToNumber(L, 1)) : 0;
Game::Lua::SetTop(L, 0);
Game::Lua::NewTable(L);
const int count =
*reinterpret_cast<const int *>(Offsets::VAR_AREATRIGGER_COUNT);
int outIdx = 0;
for (int id = 1; id <= count; ++id) {
const uint8_t *rec = DBC::Record(Offsets::VAR_AREATRIGGER_RECORDS,
Offsets::VAR_AREATRIGGER_COUNT,
static_cast<uint32_t>(id));
if (rec == nullptr)
continue;
if (filter &&
*reinterpret_cast<const int *>(rec + Offsets::OFF_AT_MAP_ID) != wantMap)
continue;
outIdx += 1;
Game::Lua::PushNumber(L, static_cast<double>(outIdx));
PushTriggerInfo(L, id); // rec is valid, so this always pushes a table
Game::Lua::SetTable(L, -3);
}
return 1;
}
void RegisterLuaFunctions() {
Game::Lua::RegisterTableFunction("C_Map", "GetAreaTriggerInfo",
&Script_GetAreaTriggerInfo);
Game::Lua::RegisterTableFunction("C_Map", "GetAreaTriggers",
&Script_GetAreaTriggers);
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace
} // namespace Map::AreaTriggers
+133
View File
@@ -0,0 +1,133 @@
// 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.GetAreaInfo(areaID)` and `C_Map.GetAreas()` — surface
// `AreaTable.dbc`'s localized name column to Lua. `AreaTable.dbc` is a
// client DBC (always loaded, synchronous, localized, ~1500 rows), so
// unlike creature/item names there's no WDB cold-cache problem — the name
// is always available for any id.
//
// - `GetAreaInfo(areaID)` is a retail backport: the localized name for one
// area id (zone or subzone), matching retail's signature.
// - `GetAreas()` is a ClassicAPI extension (retail uses the uiMapID tree,
// not raw AreaTable enumeration): the full id→name map, the load-bearing
// piece for name databases that need name→id / the full id set (reverse
// search, name resolution). Same "surface the hidden DBC" pattern as
// `GetAreaTriggers` / `GetMapOverlays`.
//
// Names are the client's active locale (same source as `GetRealZoneText` /
// `Script_GetCharacterInfo`'s zone-name resolve), so they match what the
// game shows everywhere — no locale/rename drift vs a shipped table.
#include "Game.h"
#include "Offsets.h"
#include "dbc/Lookup.h"
#include <cstdint>
namespace Map::Areas {
namespace {
// `C_Map.GetAreaInfo(areaID)` — localized AreaTable name, or nil for
// non-numeric / non-positive input or an id with no row / empty name.
int __fastcall Script_GetAreaInfo(void *L) {
if (!Game::Lua::IsNumber(L, 1)) {
Game::Lua::PushNil(L);
return 1;
}
const int areaID = static_cast<int>(Game::Lua::ToNumber(L, 1));
const char *name =
(areaID > 0) ? DBC::LocalizedField(Offsets::VAR_AREATABLE_RECORDS,
Offsets::VAR_AREATABLE_COUNT,
static_cast<uint32_t>(areaID),
Offsets::OFF_AREATABLE_NAMES)
: nullptr;
if (name == nullptr) {
Game::Lua::PushNil(L);
return 1;
}
Game::Lua::PushString(L, name);
return 1;
}
// `C_Map.GetAreas()` — { [areaID] = name } for every row with a non-empty
// localized name. Empty-name rows (DBC::LocalizedField → nullptr) are
// omitted, so the map round-trips with GetAreaInfo for every key.
int __fastcall Script_GetAreas(void *L) {
Game::Lua::SetTop(L, 0);
Game::Lua::NewTable(L);
const int count =
*reinterpret_cast<const int *>(Offsets::VAR_AREATABLE_COUNT);
for (int id = 1; id <= count; ++id) {
const char *name = DBC::LocalizedField(Offsets::VAR_AREATABLE_RECORDS,
Offsets::VAR_AREATABLE_COUNT,
static_cast<uint32_t>(id),
Offsets::OFF_AREATABLE_NAMES);
if (name == nullptr)
continue;
Game::Lua::PushNumber(L, static_cast<double>(id));
Game::Lua::PushString(L, name);
Game::Lua::SetTable(L, -3);
}
return 1;
}
// `C_Map.GetMapAreaIDs()` — { [mapName] = areaID } for every
// `WorldMapArea.dbc` row: the map's name (the dir the engine uses for its map
// textures) → its `AreaTable` areaID. Vanilla has no `GetCurrentMapAreaID`;
// this lets an addon resolve a *browsed* map to a zone id — including the
// city / instance maps `GetMapZones()` doesn't enumerate (Orgrimmar,
// Undercity, Alterac Valley), which otherwise can't be matched to zone content
// (e.g. a flight master pinned by areaID). Continent-spanning rows (areaID 0)
// and unnamed rows are omitted.
//
// Note: this is the WorldMapArea NAME, which matches `GetMapInfo()`'s returned
// dir for almost all maps but not a few misspelled Blizzard folders (the
// Orgrimmar map's texture folder is "Ogrimmar"), so `[GetMapInfo()]` can miss
// for those — resolve them by zone name instead.
int __fastcall Script_GetMapAreaIDs(void *L) {
Game::Lua::SetTop(L, 0);
Game::Lua::NewTable(L);
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)
continue;
const int areaID = *reinterpret_cast<const int *>(rec + Offsets::OFF_WMA_AREA_ID);
if (areaID == 0)
continue; // continent-spanning row, no zone id
const char *dir = *reinterpret_cast<const char *const *>(rec + Offsets::OFF_WMA_NAME);
if (dir == nullptr || dir[0] == '\0')
continue;
Game::Lua::SetFieldNumber(L, dir, static_cast<double>(areaID));
}
return 1;
}
void RegisterLuaFunctions() {
Game::Lua::RegisterTableFunction("C_Map", "GetAreaInfo", &Script_GetAreaInfo);
Game::Lua::RegisterTableFunction("C_Map", "GetAreas", &Script_GetAreas);
Game::Lua::RegisterTableFunction("C_Map", "GetMapAreaIDs", &Script_GetMapAreaIDs);
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace
} // namespace Map::Areas
+80
View File
@@ -0,0 +1,80 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
// `C_MapExplorationInfo.GetExploredMapTextures(areaID)` and
// `GetUnexploredMapTextures(areaID)` — the exploration-filtered views of a
// zone's `WorldMapOverlay.dbc` entries.
//
// - `GetExploredMapTextures` is the retail-named backport: only the overlays
// the local player has **discovered**. Retail keys on `uiMapID` and returns
// numeric `fileDataIDs`; here it's an `AreaTable` areaID and the tiles are
// texture paths (`fileDataIDs` holds those paths, see Overlays.cpp).
// - `GetUnexploredMapTextures` is the ClassicAPI-extension complement: the
// overlays NOT yet discovered — what a map-reveal addon draws over the
// fogged base map. No retail equivalent (retail only ships the explored
// getter); its inverse, `C_Map.GetMapOverlays`, returns *all* overlays.
//
// All three share `Map::Overlays::PushZoneOverlays` — same per-overlay table
// shape (texture, resolved tiles, hit rect, areaIDs, fileDataIDs); these two
// just pass an exploration Filter. "Explored" is computed per-areaID from the
// player's explored-areas bitfield (see Overlays.cpp `IsOverlayExplored`), so
// the areaID argument works for any zone, not just the one on screen.
//
// With no argument, both default to the world map's currently viewed zone
// (like `GetMapOverlays()`).
#include "Game.h"
#include "map/Area.h"
#include "map/Overlays.h"
#include <cstdint>
namespace Map::Exploration {
namespace {
int ResolveRow(void *L) {
return Game::Lua::IsNumber(L, 1)
? Map::Area::RowForAreaID(
static_cast<uint32_t>(Game::Lua::ToNumber(L, 1)))
: Map::Area::CurrentViewRow();
}
int __fastcall Script_GetExploredMapTextures(void *L) {
const int row = ResolveRow(L);
Game::Lua::SetTop(L, 0);
Map::Overlays::PushZoneOverlays(L, row, Map::Overlays::Filter::Explored);
return 1;
}
int __fastcall Script_GetUnexploredMapTextures(void *L) {
const int row = ResolveRow(L);
Game::Lua::SetTop(L, 0);
Map::Overlays::PushZoneOverlays(L, row, Map::Overlays::Filter::Unexplored);
return 1;
}
void RegisterLuaFunctions() {
Game::Lua::RegisterTableFunction("C_MapExplorationInfo",
"GetExploredMapTextures",
&Script_GetExploredMapTextures);
Game::Lua::RegisterTableFunction("C_MapExplorationInfo",
"GetUnexploredMapTextures",
&Script_GetUnexploredMapTextures);
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace
} // namespace Map::Exploration
+181 -71
View File
@@ -80,23 +80,37 @@
// Array of overlay tables:
// textureName — bare DBC name ("GOLDSHIRE")
// texturePath — "Interface\WorldMap\<dir>\GOLDSHIRE" (engine format)
// areaID — primary AreaTable id revealed (first nonzero)
// areaIDs — all AreaTable ids revealed (WorldMapOverlay.dbc
// areaID[4]; usually one, some overlays span 2-3 subzones)
// 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
// hitRectTop/Left/Bottom/Right — hit rectangle in world-map canvas px
// (~1002x668); the tight clickable bounds of the landmass,
// the subzone-center source map addons scrape
// 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
// fileDataIDs — ordered tile texture paths (retail's field name; vanilla
// has no numeric fileDataIDs, so these are paths). Redundant
// with tiles[].file — for retail-ported exploration addons.
//
// A consumer draws an overlay with a dumb loop: per tile SetTexture,
// SetWidth/Height, SetTexCoord(0, texCoordX, 0, texCoordY), SetPoint.
// Texture-less rows (a subzone hit region with no distinct art) still appear,
// with textureName == "", zero size, and an empty `tiles` table — so
// geometry consumers see every subzone while `ipairs(ov.tiles)` draw loops
// stay safe. A consumer draws a textured 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 "map/Area.h"
#include "map/Overlays.h"
#include <cstdint>
#include <cstdio>
@@ -279,46 +293,8 @@ const ResolvedOverlay *CachedResolve(int overlayId, const char *basePath,
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;
}
// WorldMapArea row resolution (areaID → row, current view → row) lives in
// the shared `Map::Area` helper — `GetMapWorldSize` keys off the same rows.
// Pushes the `tiles` array for one overlay (leaves it on the stack top).
void PushTiles(void *L, const ResolvedOverlay *r, const char *basePath,
@@ -401,20 +377,78 @@ void PushTiles(void *L, const ResolvedOverlay *r, const char *basePath,
}
}
// `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();
}
using ResolveUnitToken_t = void *(__fastcall *)(const char *token);
// The local player's explored-areas bitfield (`[player+0xE68] + 0xE6C`), or
// null before the player is resident. Bit `AreaBit` set == that AreaTable
// area is explored. Same storage the engine's discovered-overlay rebuild
// (FUN_004a67a0) reads.
const uint8_t *ExplorationBits() {
auto fn = reinterpret_cast<ResolveUnitToken_t>(Offsets::FUN_RESOLVE_UNIT_TOKEN);
auto *player = static_cast<const uint8_t *>(fn("player"));
if (player == nullptr)
return nullptr;
auto *sub = *reinterpret_cast<const uint8_t *const *>(
player + Offsets::OFF_CGPLAYER_INFO);
if (sub == nullptr)
return nullptr;
return sub + Offsets::OFF_PLAYER_EXPLORED_BITS;
}
// True if the local player has discovered `overlayRec` — mirrors the
// per-areaID test in FUN_004a67a0's discovered-overlay rebuild: an overlay
// is explored if ANY of its (up to 4) AreaTable ids is explored. An area
// with gate (+0x28) < 0 needs no exploration; otherwise its AreaBit (+0x0C)
// is looked up in `bits`. `bits == null` (no player) → not explored.
bool IsOverlayExplored(const uint8_t *overlayRec, const uint8_t *bits) {
if (bits == nullptr)
return false;
for (int k = 0; k < 4; ++k) {
const int areaID = *reinterpret_cast<const int *>(
overlayRec + Offsets::OFF_WMO_AREA_ID + k * 4);
if (areaID <= 0)
continue;
const uint8_t *areaRec =
DBC::Record(Offsets::VAR_AREATABLE_RECORDS,
Offsets::VAR_AREATABLE_COUNT, static_cast<uint32_t>(areaID));
if (areaRec == nullptr)
continue;
if (*reinterpret_cast<const int *>(areaRec + Offsets::OFF_AREATABLE_EXPLORE_GATE) < 0)
return true; // area needs no exploration → always counts
const int areaBit = *reinterpret_cast<const int *>(
areaRec + Offsets::OFF_AREATABLE_AREA_BIT);
if (areaBit < 0)
continue;
const int byteIdx = areaBit >> 3;
if (byteIdx >= 0x100)
return true; // out of bitfield range → engine treats as explored
if ((bits[byteIdx] & (1u << (areaBit & 7))) != 0)
return true;
}
return false;
}
// `C_Map.GetMapOverlays([areaID])` — see the module comment. Thin wrapper
// over the shared `PushZoneOverlays` with no exploration filter.
int __fastcall Script_GetMapOverlays(void *L) {
const int wmaRow = Game::Lua::IsNumber(L, 1)
? Map::Area::RowForAreaID(static_cast<uint32_t>(
Game::Lua::ToNumber(L, 1)))
: Map::Area::CurrentViewRow();
Game::Lua::SetTop(L, 0);
Map::Overlays::PushZoneOverlays(L, wmaRow, Map::Overlays::Filter::All);
return 1;
}
} // namespace
// Shared overlay walk — see Overlays.h. Filters by exploration and builds
// the rich per-overlay table (texture + resolved tiles + hit rect + areaIDs
// + fileDataIDs). Uses the anonymous-namespace helpers above (same TU).
void PushZoneOverlays(void *L, int wmaRow, Filter filter) {
Game::Lua::NewTable(L);
if (wmaRow <= 0)
return 1;
return;
const uint8_t *wmaRec = DBC::Record(Offsets::VAR_WORLDMAP_AREA_RECORDS,
Offsets::VAR_WORLDMAP_AREA_COUNT,
@@ -425,6 +459,10 @@ int __fastcall Script_GetMapOverlays(void *L) {
Offsets::OFF_WMA_NAME)
: nullptr;
// Exploration bitfield only needed for the filtered variants.
const uint8_t *bits =
(filter == Filter::All) ? nullptr : ExplorationBits();
const int count =
*reinterpret_cast<const int *>(Offsets::VAR_WORLDMAP_OVERLAY_COUNT);
int outIdx = 0;
@@ -436,35 +474,56 @@ int __fastcall Script_GetMapOverlays(void *L) {
*reinterpret_cast<const int *>(rec + Offsets::OFF_WMO_WORLDMAP_AREA) !=
wmaRow)
continue;
if (filter != Filter::All) {
const bool explored = IsOverlayExplored(rec, bits);
if (filter == Filter::Explored && !explored)
continue;
if (filter == Filter::Unexplored && explored)
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 bool hasTexture = (texName != nullptr && texName[0] != '\0');
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);
// Texture placement + tile grid only exist for rows that carry art.
// A hit-rect-only row (areaID + hit rect, no texture — a clickable
// subzone region with no distinct BLP) still gets a result row: empty
// textureName/path, zero size, and an empty `tiles` table (r == null →
// PushTiles emits `{}`), so geometry consumers see it while art-draw
// loops (`ipairs(ov.tiles)`) iterate zero times. Stock 1.12 and Octo
// both happen to give every overlay a texture, but the DBC schema
// allows texture-less rows, so we don't assume.
char basePath[0x104];
int dbcW = 0, dbcH = 0;
const ResolvedOverlay *r = nullptr;
if (hasTexture) {
// Full path, mirroring the engine's own GetMapOverlayInfo format
// (unresolvable map dir falls back to the "World" folder).
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);
dbcW = fieldInt(Offsets::OFF_WMO_TEXTURE_WIDTH);
dbcH = fieldInt(Offsets::OFF_WMO_TEXTURE_HEIGHT);
r = CachedResolve(id, basePath, dbcW, dbcH);
} else {
basePath[0] = '\0';
}
outIdx += 1;
Game::Lua::PushNumber(L, static_cast<double>(outIdx));
Game::Lua::NewTable(L);
Game::Lua::SetFieldString(L, "textureName", texName);
Game::Lua::SetFieldString(L, "textureName", hasTexture ? texName : "");
Game::Lua::SetFieldString(L, "texturePath", basePath);
Game::Lua::SetFieldNumber(L, "textureWidth", dbcW);
Game::Lua::SetFieldNumber(L, "textureHeight", dbcH);
@@ -474,6 +533,41 @@ int __fastcall Script_GetMapOverlays(void *L) {
L, "mapPointX", fieldInt(Offsets::OFF_WMO_MAP_POINT_X));
Game::Lua::SetFieldNumber(
L, "mapPointY", fieldInt(Offsets::OFF_WMO_MAP_POINT_Y));
// Hit rectangle (world-map canvas px, ~1002x668) — the tight clickable
// bounds of the landmass, present for every row (texture or not). The
// subzone-center source: (left+right)/2 / 1002, (top+bottom)/2 / 668.
Game::Lua::SetFieldNumber(
L, "hitRectTop", fieldInt(Offsets::OFF_WMO_HITRECT_TOP));
Game::Lua::SetFieldNumber(
L, "hitRectLeft", fieldInt(Offsets::OFF_WMO_HITRECT_LEFT));
Game::Lua::SetFieldNumber(
L, "hitRectBottom", fieldInt(Offsets::OFF_WMO_HITRECT_BOTTOM));
Game::Lua::SetFieldNumber(
L, "hitRectRight", fieldInt(Offsets::OFF_WMO_HITRECT_RIGHT));
// AreaTable ids this overlay reveals (WorldMapOverlay.dbc areaID[4]).
// `areaID` is the primary (first nonzero — every overlay has one);
// `areaIDs` is the full nonzero list (52 of 707 overlays span 2-3
// subzones, e.g. a lake straddling two areas). Same table shape as
// `tiles` — build the array, then assign the scalar.
int firstArea = 0, aCount = 0;
Game::Lua::PushString(L, "areaIDs");
Game::Lua::NewTable(L);
for (int k = 0; k < 4; ++k) {
const int aid = fieldInt(Offsets::OFF_WMO_AREA_ID + k * 4);
if (aid == 0)
continue;
if (firstArea == 0)
firstArea = aid;
aCount += 1;
Game::Lua::PushNumber(L, static_cast<double>(aCount));
Game::Lua::PushNumber(L, static_cast<double>(aid));
Game::Lua::SetTable(L, -3);
}
Game::Lua::SetTable(L, -3); // overlay.areaIDs = { ... }
Game::Lua::SetFieldNumber(L, "areaID", firstArea);
if (r != nullptr) {
Game::Lua::SetFieldNumber(L, "tileCols", r->cols);
Game::Lua::SetFieldNumber(L, "tileRows", r->rows);
@@ -483,18 +577,34 @@ int __fastcall Script_GetMapOverlays(void *L) {
PushTiles(L, r, basePath, dbcW, dbcH, offX, offY);
Game::Lua::SetTable(L, -3);
// fileDataIDs — ordered tile texture paths (retail's field name for
// the tile list). Vanilla has no numeric fileDataIDs, so these are the
// SetTexture-ready paths; redundant with tiles[].file, provided so
// retail-ported exploration addons reading info.fileDataIDs work,
// while tiles[] carries the per-tile draw geometry Octo needs.
Game::Lua::PushString(L, "fileDataIDs");
Game::Lua::NewTable(L);
if (r != nullptr) {
char file[0x120];
for (int i = 0; i < r->count; ++i) {
std::snprintf(file, sizeof(file), "%s%d", basePath, i + 1);
Game::Lua::PushNumber(L, static_cast<double>(i + 1));
Game::Lua::PushString(L, file);
Game::Lua::SetTable(L, -3);
}
}
Game::Lua::SetTable(L, -3); // overlay.fileDataIDs = { ... }
Game::Lua::SetTable(L, -3);
}
return 1;
}
namespace {
void RegisterLuaFunctions() {
Game::Lua::RegisterTableFunction("C_Map", "GetMapOverlays",
&Script_GetMapOverlays);
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace
} // namespace Map::Overlays
+36
View File
@@ -0,0 +1,36 @@
// 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 Map::Overlays {
// Which overlays of a zone to include, by the local player's exploration.
enum class Filter {
All, // every WorldMapOverlay.dbc row for the zone (C_Map.GetMapOverlays)
Explored, // only overlays the player has discovered
Unexplored, // only overlays the player has NOT discovered
};
// Pushes an array of overlay tables for `wmaRow` (a WorldMapArea.dbc row),
// filtered by exploration, onto the Lua stack as a single value. Shared by
// `C_Map.GetMapOverlays` (Filter::All) and `C_MapExplorationInfo`'s
// explored / unexplored texture getters. The caller resolves the row (via
// `Map::Area`) and clears the stack first; this leaves exactly one table.
// The per-overlay table shape is documented in Overlays.cpp's header.
//
// Explored/Unexplored read the player's explored-areas bitfield; before the
// player is resident (character select) every overlay reads as unexplored.
void PushZoneOverlays(void *L, int wmaRow, Filter filter);
} // namespace Map::Overlays
+82
View File
@@ -0,0 +1,82 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
// `C_Map.GetMapWorldSize([areaID])` — the world size (in yards) of a zone,
// as `width, height`. This is the physical extent the map represents: the
// span of the `WorldMapArea.dbc` placement rect. Modern addons use it to
// convert map-relative percents to yard distances (e.g. tracking-range
// rings, "X yards away" readouts).
//
// With a numeric `areaID` (AreaTable id — the same identity
// `C_Map.GetBestMapForUnit` returns and `C_Map.GetMapOverlays` accepts),
// returns that zone's size. With no argument, returns the world map's
// currently viewed zone (same resolution `GetMapOverlays()` uses with no
// arg). Returns nothing (→ nil, nil) when the zone can't be resolved.
//
// WoW's world axes are +X north, +Y west; the WorldMapArea rect bounds
// them as locTop/locBottom (world X) and locLeft/locRight (world Y). Map
// width is the horizontal (east-west) extent and height the vertical
// (north-south):
// width = locLeft - locRight (world Y span)
// height = locTop - locBottom (world X span)
// The same spans are the denominators in the world→map percent transform
// (see `Map::AreaTriggers`), so a point's `mapX% * width / 100` is its
// offset in yards from the zone's east edge, and likewise for height.
#include "Game.h"
#include "Offsets.h"
#include "dbc/Lookup.h"
#include "map/Area.h"
#include <cstdint>
namespace Map::WorldSize {
namespace {
int __fastcall Script_GetMapWorldSize(void *L) {
const int row = Game::Lua::IsNumber(L, 1)
? Map::Area::RowForAreaID(
static_cast<uint32_t>(Game::Lua::ToNumber(L, 1)))
: Map::Area::CurrentViewRow();
Game::Lua::SetTop(L, 0);
if (row <= 0)
return 0; // no values -> nil, nil
const uint8_t *rec = DBC::Record(Offsets::VAR_WORLDMAP_AREA_RECORDS,
Offsets::VAR_WORLDMAP_AREA_COUNT,
static_cast<uint32_t>(row));
if (rec == nullptr)
return 0;
const float left = *reinterpret_cast<const float *>(rec + Offsets::OFF_WMA_LOC_LEFT);
const float right = *reinterpret_cast<const float *>(rec + Offsets::OFF_WMA_LOC_RIGHT);
const float top = *reinterpret_cast<const float *>(rec + Offsets::OFF_WMA_LOC_TOP);
const float bottom = *reinterpret_cast<const float *>(rec + Offsets::OFF_WMA_LOC_BOTTOM);
Game::Lua::PushNumber(L, static_cast<double>(left) - right); // width (world Y span)
Game::Lua::PushNumber(L, static_cast<double>(top) - bottom); // height (world X span)
return 2;
}
void RegisterLuaFunctions() {
Game::Lua::RegisterTableFunction("C_Map", "GetMapWorldSize",
&Script_GetMapWorldSize);
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace
} // namespace Map::WorldSize
+2 -11
View File
@@ -33,6 +33,7 @@
#include "Game.h"
#include "Offsets.h"
#include "item/Location.h"
#include "unit/Identity.h"
#include "tick/WorldTick.h"
#include <cstdint>
@@ -42,7 +43,6 @@ namespace Merchant::Frame {
namespace {
using ResolveUnitToken_t = void *(__fastcall *)(const char *token);
using GetItemRecord_t = const uint8_t *(__thiscall *)(
void *cache, uint32_t itemID, const uint64_t *guid,
void *callback, void *userData, bool requestIfMissing);
@@ -108,15 +108,6 @@ int ItemQuality(uint32_t itemID) {
// Player invMgr lives inline at player + 0x1D38. First uint32 is the
// max slot count; +0x04 is the GUID array pointer.
const uint8_t *PlayerInvMgr() {
auto resolve = reinterpret_cast<ResolveUnitToken_t>(
Offsets::FUN_RESOLVE_UNIT_TOKEN);
auto *player = static_cast<uint8_t *>(resolve("player"));
if (player == nullptr)
return nullptr;
return player + Offsets::OFF_PLAYER_INVENTORY_MANAGER;
}
// Walk a single bag, invoking `cb(cgItem)` for each non-empty slot.
// `cb` returns true to continue, false to stop iteration. Stomps
// the Lua stack — caller must own it (`ResolveBag` does the stomping).
@@ -161,7 +152,7 @@ int __fastcall Script_GetBuybackItemID(void *L) {
if (invSlot == 0)
return 0;
auto *invMgr = PlayerInvMgr();
auto *invMgr = Unit::Identity::PlayerInventoryManager();
if (invMgr == nullptr)
return 0;
// First u32 of the invMgr struct is the max slot count.
+2 -9
View File
@@ -55,6 +55,7 @@
#include "Offsets.h"
#include "item/Arg.h"
#include "spell/Lookup.h"
#include "unit/Identity.h"
#include <cstdint>
@@ -65,7 +66,6 @@ namespace {
using GetItemRecord_t = const uint8_t *(__thiscall *)(void *cache, uint32_t itemID,
const uint64_t *guid, void *callback,
void *userData, int unused);
using ResolveUnitToken_t = void *(__fastcall *)(const char *token);
// `__thiscall(player /*ecx*/, skillLineID) -> slot` (-1 if the player
// doesn't have the skill line). See VAR docs on FUN_SKILL_LINE_TO_SLOT.
using SkillLineToSlot_t = int(__thiscall *)(void *player, uint32_t skillLineID);
@@ -83,13 +83,6 @@ const uint8_t *PeekItemRecord(uint32_t itemID) {
return fn(cache, itemID, &zeroGuid, nullptr, nullptr, 0);
}
// The local player's CGUnit/CGPlayer object (holds the descriptor at
// +OFF_UNIT_DESCRIPTOR and the CGPlayer sub-struct at +OFF_CGPLAYER_INFO).
const uint8_t *ResolvePlayer() {
auto resolve = reinterpret_cast<ResolveUnitToken_t>(Offsets::FUN_RESOLVE_UNIT_TOKEN);
return static_cast<const uint8_t *>(resolve("player"));
}
// True iff `mask` restricts a 1-based `index` (class/race) the player
// isn't part of. Mask 0 and 0xFFFFFFFF both mean "no restriction".
bool RestrictedOut(uint32_t mask, uint32_t index1Based) {
@@ -246,7 +239,7 @@ bool ComputeCanUse(int itemID) {
auto *record = PeekItemRecord(static_cast<uint32_t>(itemID));
if (record == nullptr)
return false; // not cached → sync false, no load fired
auto *player = ResolvePlayer();
auto *player = Unit::Identity::PlayerObject();
if (player == nullptr)
return false; // pre-login / no player
auto *desc = *reinterpret_cast<const uint8_t *const *>(
+244
View File
@@ -0,0 +1,244 @@
// 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/>.
// The `PlayerLocation`-taking `C_PlayerInfo` accessors:
//
// C_PlayerInfo.GetName(playerLocation) -> name
// C_PlayerInfo.GetClass(playerLocation) -> className, classFilename, classID
// C_PlayerInfo.GetRace(playerLocation) -> raceID
// C_PlayerInfo.GetSex(playerLocation) -> sex (1 neutral / 2 male / 3 female)
// C_PlayerInfo.IsConnected([playerLocation]) -> isConnected
//
// A `PlayerLocation` (see Util/PlayerLocation.lua) is a plain table carrying
// either a unit token (`.unit`) or a GUID string (`.guid`). We resolve it to
// a 64-bit GUID — unit tokens through the engine's token→GUID table
// (`FUN_TOKEN_TO_GUID`, which resolves group members in other zones too, not
// just synced CGUnits), GUID strings by parsing — and then read everything
// straight from the engine's own data structures. No `Script_*` Lua wrappers
// are called; each value comes from the same engine internals the codebase
// already uses elsewhere:
//
// - GetName -> resolve the GUID to a CGObject and call the polymorphic
// name getter `FUN_OBJECT_GET_NAME` (same path as
// `UnitNameFromGUID`).
// - GetRace / -> UNIT_FIELD_BYTES_0: the login-time mirror globals when the
// GetClass / location is the local player (valid before the descriptor
// GetSex spawns, matching `Script_UnitClass`'s own player path),
// otherwise the resolved unit's descriptor bytes. GetClass
// maps classID -> className/classFile via `ChrClasses.dbc`
// like `C_CreatureInfo.GetClassInfo`. GetSex reports the
// API's 2/3 (raw gender byte 0/1 + 2), matching
// `GetPlayerInfoByGUID`.
// - IsConnected -> a resolvable CGObject means connected; otherwise the
// group-member roster's online bit (then slot presence),
// exactly what `Script_UnitIsConnected` checks.
//
// Only the `unit` and `guid` PlayerLocation kinds resolve on 1.12 — the chat
// line / who / battlefield-score / community / voice kinds have no
// client-side unit resolution. Unsupported or unresolvable locations return
// nil.
#include "Game.h"
#include "Offsets.h"
#include "dbc/Lookup.h"
#include "guid/Guid.h"
#include "unit/Identity.h"
#include <cstdint>
namespace Player::LocationInfo {
namespace {
using TokenToGuid_t = uint64_t(__fastcall *)(const char *token);
using ResolveObject_t = void *(__fastcall *)(uint32_t typeMask, const char *dbg,
uint32_t guidLo, uint32_t guidHi, int line);
using GetName_t = const char *(__thiscall *)(void *obj, int *outFlags);
using GroupLookup_t = void *(__fastcall *)(const uint64_t *guid);
// Reads a string field `t[field]` at table stack index `locIdx` into `out`.
// Returns true only for a non-empty string. Stack-neutral.
bool ReadStringField(void *L, int locIdx, const char *field, char *out, int size) {
Game::Lua::PushString(L, field);
Game::Lua::GetTable(L, locIdx); // pops key, pushes value
bool ok = false;
if (Game::Lua::IsString(L, -1)) {
const char *s = Game::Lua::ToString(L, -1);
if (s != nullptr && s[0] != '\0') {
int i = 0;
for (; s[i] != '\0' && i < size - 1; ++i)
out[i] = s[i];
out[i] = '\0';
ok = true;
}
}
Game::Lua::SetTop(L, -2); // pop value (or nil)
return ok;
}
// Resolves the PlayerLocation table at `locIdx` to a 64-bit GUID. Unit tokens
// go through the engine token→GUID table (handles out-of-zone group members);
// GUID strings are parsed. Returns false for unsupported kinds or a token
// that maps to nothing.
bool LocationGuid(void *L, int locIdx, uint64_t *out) {
char token[64];
if (ReadStringField(L, locIdx, "unit", token, sizeof(token))) {
auto toGuid = reinterpret_cast<TokenToGuid_t>(Offsets::FUN_TOKEN_TO_GUID);
*out = toGuid(token);
return *out != 0;
}
char guidStr[40];
if (ReadStringField(L, locIdx, "guid", guidStr, sizeof(guidStr)))
return Guid::Parse(guidStr, out) && *out != 0;
return false;
}
// Resolves a GUID to its currently-synced CGUnit, or null if not resident.
const uint8_t *ResolveUnit(uint64_t guid) {
auto resolve = reinterpret_cast<ResolveObject_t>(Offsets::FUN_CLNT_OBJ_MGR_OBJECT_PTR);
return static_cast<const uint8_t *>(
resolve(Offsets::OBJ_TYPE_UNIT, "ClassicAPI", static_cast<uint32_t>(guid),
static_cast<uint32_t>(guid >> 32), 0));
}
// Reads a UNIT_FIELD_BYTES_0 byte: the login-time mirror global when `guid` is
// the local player (works before the descriptor spawns), otherwise the
// resolved unit's descriptor byte. Returns -1 when the unit isn't resident.
int UnitByte(uint64_t guid, uintptr_t playerMirror, uint32_t descByteOffset) {
if (guid == Unit::Identity::PlayerGuid())
return *reinterpret_cast<const uint8_t *>(playerMirror);
const uint8_t *unit = ResolveUnit(guid);
if (unit == nullptr)
return -1;
auto *desc = *reinterpret_cast<const uint8_t *const *>(
unit + Offsets::OFF_CGUNIT_OBJECT_FIELDS);
if (desc == nullptr)
return -1;
return *reinterpret_cast<const uint8_t *>(desc + descByteOffset);
}
int __fastcall Script_GetName(void *L) {
uint64_t guid = 0;
if (!LocationGuid(L, 1, &guid))
return 0;
const uint8_t *unit = ResolveUnit(guid);
if (unit == nullptr)
return 0;
auto getName = reinterpret_cast<GetName_t>(Offsets::FUN_OBJECT_GET_NAME);
const char *name = getName(const_cast<uint8_t *>(unit), nullptr);
if (name == nullptr || name[0] == '\0')
return 0;
Game::Lua::PushString(L, name);
return 1;
}
int __fastcall Script_GetRace(void *L) {
uint64_t guid = 0;
if (!LocationGuid(L, 1, &guid))
return 0;
const int raceID = UnitByte(guid, Offsets::VAR_PLAYER_RACE_BYTE,
Offsets::OFF_UNIT_DESCRIPTOR_RACE_BYTE);
if (raceID <= 0)
return 0;
Game::Lua::PushNumber(L, static_cast<double>(raceID));
return 1;
}
int __fastcall Script_GetClass(void *L) {
uint64_t guid = 0;
if (!LocationGuid(L, 1, &guid))
return 0;
const int classID = UnitByte(guid, Offsets::VAR_PLAYER_CLASS_BYTE,
Offsets::OFF_UNIT_DESCRIPTOR_CLASS_BYTE);
if (classID <= 0)
return 0;
// className / classFile from ChrClasses.dbc — same reads as
// C_CreatureInfo.GetClassInfo(classID).
const char *classFile = DBC::StringField(Offsets::VAR_CHRCLASSES_RECORDS,
Offsets::VAR_CHRCLASSES_COUNT,
static_cast<uint32_t>(classID),
Offsets::OFF_CHRCLASSES_FILENAME);
if (classFile == nullptr)
return 0; // classID with no ChrClasses row
const char *className = DBC::LocalizedField(Offsets::VAR_CHRCLASSES_RECORDS,
Offsets::VAR_CHRCLASSES_COUNT,
static_cast<uint32_t>(classID),
Offsets::OFF_CHRCLASSES_NAMES);
Game::Lua::PushString(L, className); // NULL → nil via lua_pushstring
Game::Lua::PushString(L, classFile);
Game::Lua::PushNumber(L, static_cast<double>(classID));
return 3;
}
int __fastcall Script_GetSex(void *L) {
uint64_t guid = 0;
if (!LocationGuid(L, 1, &guid))
return 0;
const int gender = UnitByte(guid, Offsets::VAR_PLAYER_SEX_BYTE,
Offsets::OFF_UNIT_DESCRIPTOR_SEX_BYTE);
if (gender < 0)
return 0;
// UNIT_FIELD_BYTES_0 stores 0 = male / 1 = female; the API (and UnitSex)
// reports 2 = male / 3 = female. Same +2 remap as GetPlayerInfoByGUID.
Game::Lua::PushNumber(L, static_cast<double>(gender + 2));
return 1;
}
int __fastcall Script_IsConnected(void *L) {
// `playerLocation` is optional — with no table arg it defaults to the
// local player (always connected while playing).
uint64_t guid = 0;
if (Game::Lua::Type(L, 1) == Game::Lua::TYPE_TABLE) {
if (!LocationGuid(L, 1, &guid))
return 0;
} else {
guid = Unit::Identity::PlayerGuid();
}
if (guid == 0)
return 0;
bool connected = ResolveUnit(guid) != nullptr; // a synced object is online
if (!connected) {
// Not resident — consult the group-member roster, mirroring
// Script_UnitIsConnected: the stats block's online bit, else slot
// presence (a member with no stats block yet still counts as present).
auto stats = reinterpret_cast<GroupLookup_t>(
Offsets::FUN_GROUP_MEMBER_STATS_LOOKUP)(&guid);
if (stats != nullptr) {
connected = (*reinterpret_cast<const uint8_t *>(
static_cast<const uint8_t *>(stats) +
Offsets::OFF_GROUP_MEMBER_STATUS_FLAGS) &
Offsets::GROUP_MEMBER_STATUS_ONLINE) != 0;
} else {
connected = reinterpret_cast<GroupLookup_t>(
Offsets::FUN_GROUP_MEMBER_SLOT_LOOKUP)(&guid) != nullptr;
}
}
Game::Lua::PushBool(L, connected);
return 1;
}
void RegisterLuaFunctions() {
Game::Lua::RegisterTableFunction("C_PlayerInfo", "GetName", &Script_GetName);
Game::Lua::RegisterTableFunction("C_PlayerInfo", "GetClass", &Script_GetClass);
Game::Lua::RegisterTableFunction("C_PlayerInfo", "GetRace", &Script_GetRace);
Game::Lua::RegisterTableFunction("C_PlayerInfo", "GetSex", &Script_GetSex);
Game::Lua::RegisterTableFunction("C_PlayerInfo", "IsConnected", &Script_IsConnected);
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace
} // namespace Player::LocationInfo
+2 -8
View File
@@ -29,6 +29,7 @@
#include "Offsets.h"
#include "aura/Data.h"
#include "dbc/Lookup.h"
#include "unit/Identity.h"
#include <cstdint>
#include <cstring>
@@ -37,15 +38,8 @@ namespace Spell::Cancel {
namespace {
using ResolveUnitToken_t = void *(__fastcall *)(const char *token);
using CancelAuraSend_t = void(__fastcall *)(int spellID);
const uint8_t *ResolvePlayer() {
auto fn = reinterpret_cast<ResolveUnitToken_t>(
static_cast<uintptr_t>(Offsets::FUN_RESOLVE_UNIT_TOKEN));
return static_cast<const uint8_t *>(fn("player"));
}
// Pre-validates `spellID` against Spell.dbc and ships the cancel
// packet. The engine's sender unconditionally derefs `[record+0x1C]`
// after a NULL-on-OOR record lookup, so skipping the
@@ -71,7 +65,7 @@ int __fastcall Script_CancelSpellByName(void *L) {
const char *targetName = Game::Lua::ToString(L, 1);
if (targetName == nullptr || *targetName == '\0')
return 0;
const uint8_t *player = ResolvePlayer();
const uint8_t *player = Unit::Identity::PlayerObject();
if (player == nullptr)
return 0;
// Walk the player's buff range only (slots 0..31). Modern
+3 -43
View File
@@ -23,57 +23,17 @@
#include "Arg.h"
#include "Lookup.h"
#include "Range.h"
#include "Game.h"
#include "Offsets.h"
#include "dbc/Lookup.h"
#include <cstdint>
#include <cstring>
namespace Spell::HasRange {
namespace {
// Spell.dbc RangeIndex field — mirrors `Script_GetSpellInfo`'s
// local constant. Future refactor: promote to `Offsets.h`.
constexpr int OFF_SPELL_RANGE_INDEX = 0x90;
// SpellRange.dbc record layout (verified, documented in CLAUDE.md
// under "Sub-DBC record layouts"):
// +0x00 id, +0x04 minRange (float), +0x08 maxRange (float), ...
constexpr int OFF_SPELLRANGE_MIN = 0x04;
constexpr int OFF_SPELLRANGE_MAX = 0x08;
bool SpellIDHasRange(int spellID) {
if (spellID <= 0)
return false;
const uint8_t *spellRec = DBC::Record(Offsets::VAR_SPELL_RECORDS,
Offsets::VAR_SPELL_RECORD_COUNT,
static_cast<uint32_t>(spellID));
if (spellRec == nullptr)
return false;
const uint32_t rangeIdx = *reinterpret_cast<const uint32_t *>(
spellRec + OFF_SPELL_RANGE_INDEX);
if (rangeIdx == 0)
return false;
const uint8_t *rangeRec = DBC::Record(Offsets::VAR_SPELL_RANGE_RECORDS,
Offsets::VAR_SPELL_RANGE_COUNT,
rangeIdx);
if (rangeRec == nullptr)
return false;
const float minRange = *reinterpret_cast<const float *>(
rangeRec + OFF_SPELLRANGE_MIN);
const float maxRange = *reinterpret_cast<const float *>(
rangeRec + OFF_SPELLRANGE_MAX);
return minRange > 0.0f || maxRange > 0.0f;
}
} // namespace
static int __fastcall Script_C_Spell_SpellHasRange(void *L) {
const int spellID = Spell::Arg::ResolveSpellID(L, 1);
Game::Lua::PushBool(L, SpellIDHasRange(spellID));
Game::Lua::PushBool(L, Spell::Range::SpellIDHasRange(spellID));
return 1;
}
@@ -94,7 +54,7 @@ static int __fastcall Script_SpellHasRange(void *L) {
bookType = 1;
}
const int spellID = Spell::Lookup::SpellbookSlotToID(slot, bookType);
Game::Lua::PushBool(L, SpellIDHasRange(spellID));
Game::Lua::PushBool(L, Spell::Range::SpellIDHasRange(spellID));
return 1;
}
+8 -83
View File
@@ -18,106 +18,31 @@
// name of a spell in the player's spellbook; `targetUnit` is a unit
// token ("target", "mouseover", "party1", …).
//
// Backed by the engine's own geometric range core `FUN_006E47B0` — the
// same test the action-button range coloring uses:
// Script_IsActionInRange (0x004E7550) → FUN_004E56F0 → FUN_006E4440
// → FUN_006E47B0
// It derives the spell's min/max range (via FUN_006E3480, which folds
// in the target's bounding radius), then compares center-to-center
// distance, so the boundary matches the client's own "in range"
// exactly — melee ranges and min-range (too-close) spells included.
//
// We call the pure core, not the higher-level FUN_006E4440: that
// wrapper also gates on target *type* (friend/foe/etc.) and collapses
// "range doesn't apply" into the same truthy return as "in range", so
// it can't yield a clean true/false/nil. The core answers range only,
// matching retail IsSpellInRange's range-only semantics (it ignores
// line of sight). One deliberate consequence: we don't reject
// wrong-faction targets — a friendly-only heal still reports a range
// answer against an enemy. Callers needing target validity should
// check it separately.
// Thin Lua wrapper over the shared `Spell::Range::PlayerVsUnit` core
// (see spell/Range.h) — the same range test behind `C_Item.IsItemInRange`
// (via the item's on-use spell). Range-only, ignoring line of sight and
// target faction, matching retail IsSpellInRange.
#include "Arg.h"
#include "Range.h"
#include "Game.h"
#include "Offsets.h"
#include "dbc/Lookup.h"
#include "unit/Position.h"
#include <cstdint>
namespace Spell::InRange {
namespace {
// Spell.dbc RangeIndex + SpellRange.dbc min/max offsets — mirrors
// `Spell::HasRange` (kept local to avoid a cross-TU dependency; small
// and stable, same layout documented in CLAUDE.md).
constexpr int OFF_SPELL_RANGE_INDEX = 0x90;
constexpr int OFF_SPELLRANGE_MIN = 0x04;
constexpr int OFF_SPELLRANGE_MAX = 0x08;
// True iff the spell's SpellRange.dbc row has a non-zero min or max.
// Rangeless spells (self buffs, etc.) have no meaningful "in range"
// answer, so we return nil for them — matching retail.
bool SpellIDHasRange(int spellID) {
if (spellID <= 0)
return false;
const uint8_t *rec = DBC::Record(Offsets::VAR_SPELL_RECORDS,
Offsets::VAR_SPELL_RECORD_COUNT,
static_cast<uint32_t>(spellID));
if (rec == nullptr)
return false;
const uint32_t rangeIdx =
*reinterpret_cast<const uint32_t *>(rec + OFF_SPELL_RANGE_INDEX);
if (rangeIdx == 0)
return false;
const uint8_t *rangeRec = DBC::Record(Offsets::VAR_SPELL_RANGE_RECORDS,
Offsets::VAR_SPELL_RANGE_COUNT, rangeIdx);
if (rangeRec == nullptr)
return false;
const float minRange =
*reinterpret_cast<const float *>(rangeRec + OFF_SPELLRANGE_MIN);
const float maxRange =
*reinterpret_cast<const float *>(rangeRec + OFF_SPELLRANGE_MAX);
return minRange > 0.0f || maxRange > 0.0f;
}
using SpellRangeCheck_t = char(__fastcall *)(void *caster, void *target,
int spellID, char *outFlag);
int __fastcall Script_C_Spell_IsSpellInRange(void *L) {
const int spellID = Spell::Arg::ResolveSpellID(L, 1);
const char *unit =
Game::Lua::IsString(L, 2) ? Game::Lua::ToString(L, 2) : nullptr;
// nil when the range check can't apply: unknown/rangeless spell, or
// no unit token given.
if (spellID <= 0 || unit == nullptr || !SpellIDHasRange(spellID)) {
const int result = Spell::Range::PlayerVsUnit(spellID, unit);
if (result < 0) {
Game::Lua::PushNil(L);
return 1;
}
void *caster = Unit::Position::ResolveToken("player");
void *target = Unit::Position::ResolveToken(unit);
// Guard positions before handing the objects to the engine core:
// FUN_006E47B0 dereferences each GetPosition result without a null
// check, and a resolved-but-position-less object (or an absent token
// → null) would otherwise crash. A failed read → nil.
float casterPos[3];
float targetPos[3];
if (!Unit::Position::Read(caster, casterPos) ||
!Unit::Position::Read(target, targetPos)) {
Game::Lua::PushNil(L);
return 1;
}
char minMaxFlag = 0;
auto check =
reinterpret_cast<SpellRangeCheck_t>(Offsets::FUN_SPELL_RANGE_CHECK);
const char inRange = check(caster, target, spellID, &minMaxFlag);
Game::Lua::PushBool(L, inRange != 0);
Game::Lua::PushBool(L, result == 1);
return 1;
}
+84
View File
@@ -0,0 +1,84 @@
// 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 "Range.h"
#include "Offsets.h"
#include "dbc/Lookup.h"
#include "unit/Position.h"
namespace Spell::Range {
namespace {
// Spell.dbc RangeIndex + SpellRange.dbc min/max offsets (verified,
// documented in CLAUDE.md under "Sub-DBC record layouts").
constexpr int OFF_SPELL_RANGE_INDEX = 0x90;
constexpr int OFF_SPELLRANGE_MIN = 0x04;
constexpr int OFF_SPELLRANGE_MAX = 0x08;
// FUN_SPELL_RANGE_CHECK: char __fastcall(caster, target, spellID, outFlag).
using SpellRangeCheck_t = char(__fastcall *)(void *caster, void *target,
int spellID, char *outFlag);
} // namespace
bool SpellIDHasRange(int spellID) {
if (spellID <= 0)
return false;
const uint8_t *rec = DBC::Record(Offsets::VAR_SPELL_RECORDS,
Offsets::VAR_SPELL_RECORD_COUNT,
static_cast<uint32_t>(spellID));
if (rec == nullptr)
return false;
const uint32_t rangeIdx =
*reinterpret_cast<const uint32_t *>(rec + OFF_SPELL_RANGE_INDEX);
if (rangeIdx == 0)
return false;
const uint8_t *rangeRec = DBC::Record(Offsets::VAR_SPELL_RANGE_RECORDS,
Offsets::VAR_SPELL_RANGE_COUNT, rangeIdx);
if (rangeRec == nullptr)
return false;
const float minRange =
*reinterpret_cast<const float *>(rangeRec + OFF_SPELLRANGE_MIN);
const float maxRange =
*reinterpret_cast<const float *>(rangeRec + OFF_SPELLRANGE_MAX);
return minRange > 0.0f || maxRange > 0.0f;
}
int PlayerVsUnit(int spellID, const char *unitToken) {
// No answer when the range check can't apply: unknown/rangeless spell,
// or no unit token given.
if (spellID <= 0 || unitToken == nullptr || !SpellIDHasRange(spellID))
return -1;
void *caster = Unit::Position::ResolveToken("player");
void *target = Unit::Position::ResolveToken(unitToken);
// Guard positions before handing the objects to the engine core:
// FUN_SPELL_RANGE_CHECK dereferences each GetPosition result without a
// null check, and a resolved-but-position-less object (or an absent
// token → null) would otherwise crash. A failed read → no answer.
float casterPos[3];
float targetPos[3];
if (!Unit::Position::Read(caster, casterPos) ||
!Unit::Position::Read(target, targetPos))
return -1;
char minMaxFlag = 0;
auto check =
reinterpret_cast<SpellRangeCheck_t>(Offsets::FUN_SPELL_RANGE_CHECK);
return check(caster, target, spellID, &minMaxFlag) != 0 ? 1 : 0;
}
} // namespace Spell::Range
+43
View File
@@ -0,0 +1,43 @@
// 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 spell-range core. One place for "does this spell have a range"
// and "is the player's cast of this spell in range of a unit", used by
// `C_Spell.SpellHasRange`, `C_Spell.IsSpellInRange`, and (via the item's
// on-use spell) `C_Item.IsItemInRange`.
namespace Spell::Range {
// True iff the spell's `SpellRange.dbc` row has a non-zero min or max
// range. Rangeless spells (self buffs, etc.) have no meaningful "in
// range" answer.
bool SpellIDHasRange(int spellID);
// Range check of the local player's cast of `spellID` against
// `unitToken` (`"target"`, `"mouseover"`, `"party1"`, …). Returns:
// 1 — in range
// 0 — out of range
// -1 — no answer: `spellID <= 0`, a rangeless spell, a null/absent
// unit token, or either unit's position can't be read.
// Backed by the engine's geometric range core `FUN_SPELL_RANGE_CHECK`
// (the same test the action-button range coloring uses), which folds in
// the target's bounding radius so the boundary matches the client's own
// "in range" exactly — melee and min-range (too-close) spells included.
// Ignores line of sight and target faction, matching retail's range-only
// `Is*InRange` semantics.
int PlayerVsUnit(int spellID, const char *unitToken);
} // namespace Spell::Range
+101
View File
@@ -22,6 +22,7 @@ namespace Spell::SkillLine {
namespace {
using ResolveUnitToken_t = void *(__fastcall *)(const char *token);
using SkillLineToSlot_t = int(__thiscall *)(void *player, uint32_t skillLineID);
// Reads UNIT_FIELD_BYTES_0 from the local player's descriptor. Same
// pattern Spell::Level::PlayerDescriptor uses. Returns (0, 0) when the
@@ -171,9 +172,109 @@ int __fastcall Script_GetSpellSkillLine(void *L) {
return 2;
}
// `C_SpellBook.GetSkillLineName(skillLineID) → name`
//
// Localized `SkillLine.dbc::Name` for a skill-line id — the id → name
// half of the bridge addons build by hand. Pure DBC read: returns the
// name for ANY valid skill line (Herbalism = 182, Mining = 186, Swords,
// Defense, …) whether or not the player has learned it. `nil` for
// non-numeric / non-positive input or an id with no SkillLine.dbc row.
//
// Pairs with `GetSkillLineRank` (the learned-rank lookup): name comes
// from the DBC, rank from the player's live skill list.
int __fastcall Script_GetSkillLineName(void *L) {
if (!Game::Lua::IsNumber(L, 1)) {
Game::Lua::PushNil(L);
return 1;
}
const int id = static_cast<int>(Game::Lua::ToNumber(L, 1));
const char *name =
(id > 0) ? DBC::LocalizedField(Offsets::VAR_SKILL_LINE_RECORDS,
Offsets::VAR_SKILL_LINE_COUNT,
static_cast<uint32_t>(id),
Offsets::OFF_SKILL_LINE_NAME)
: nullptr;
if (name == nullptr) {
Game::Lua::PushNil(L);
return 1;
}
Game::Lua::PushString(L, name);
return 1;
}
// `C_SpellBook.GetSkillLineRank(skillLineID) → curRank, maxRank, modifier`
//
// The local player's rank in a skill line, keyed by `SkillLine.dbc` id.
// Vanilla's `GetSkillLineInfo(index)` only walks the skill window by
// position and returns skills by *name*, so an addon starting from a
// SkillLine.dbc id (a quest's `requiredSkill`, "Herbalism" = 182, …) has
// to map id → localized name and string-match every skill line to recover
// the rank. This reads the player's live skill list keyed by id directly.
//
// Returns the same values `GetSkillLineInfo` reports for the line:
// curRank — current skill value (base, no temp bonus)
// maxRank — the line's cap at the player's level
// modifier — temporary bonus (weapon oils, buffs; 0 if none)
//
// Returns `nil` (nothing) when the player hasn't learned the line
// (`FUN_SKILL_LINE_TO_SLOT` reports no slot), for bad input, or before
// the player object / skill sub-struct is resident (character select).
// Reads the same CGPlayer `+0xE68` skill table `Player::CanUseItem`'s
// RequiredSkill gate uses; ranks verified there (Tailoring 261/300, …).
int __fastcall Script_GetSkillLineRank(void *L) {
if (!Game::Lua::IsNumber(L, 1)) {
Game::Lua::PushNil(L);
return 1;
}
const int skillLineID = static_cast<int>(Game::Lua::ToNumber(L, 1));
if (skillLineID <= 0) {
Game::Lua::PushNil(L);
return 1;
}
auto resolve = reinterpret_cast<ResolveUnitToken_t>(Offsets::FUN_RESOLVE_UNIT_TOKEN);
auto *player = static_cast<uint8_t *>(resolve("player"));
if (player == nullptr) {
Game::Lua::PushNil(L);
return 1;
}
auto toSlot = reinterpret_cast<SkillLineToSlot_t>(Offsets::FUN_SKILL_LINE_TO_SLOT);
const int slot = toSlot(player, static_cast<uint32_t>(skillLineID));
if (slot < 0) { // player doesn't have the skill line
Game::Lua::PushNil(L);
return 1;
}
auto *sub = *reinterpret_cast<const uint8_t *const *>(
player + Offsets::OFF_CGPLAYER_INFO);
if (sub == nullptr) {
Game::Lua::PushNil(L);
return 1;
}
const uint8_t *rec = sub + Offsets::OFF_SKILL_INFO_TABLE +
slot * Offsets::SKILL_INFO_STRIDE;
const uint32_t cur =
*reinterpret_cast<const uint16_t *>(rec + Offsets::OFF_SKILL_INFO_CUR);
const uint32_t max =
*reinterpret_cast<const uint16_t *>(rec + Offsets::OFF_SKILL_INFO_MAX);
const uint32_t modifier =
*reinterpret_cast<const uint16_t *>(rec + Offsets::OFF_SKILL_INFO_BONUS);
Game::Lua::PushNumber(L, static_cast<double>(cur));
Game::Lua::PushNumber(L, static_cast<double>(max));
Game::Lua::PushNumber(L, static_cast<double>(modifier));
return 3;
}
void RegisterLuaFunctions() {
Game::Lua::RegisterTableFunction("C_SpellBook", "GetSpellSkillLine",
&Script_GetSpellSkillLine);
Game::Lua::RegisterTableFunction("C_SpellBook", "GetSkillLineName",
&Script_GetSkillLineName);
Game::Lua::RegisterTableFunction("C_SpellBook", "GetSkillLineRank",
&Script_GetSkillLineRank);
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
+394
View File
@@ -0,0 +1,394 @@
// 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_TaxiMap.GetTaxiNodesForMap(mapID)` (static) and
// `C_TaxiMap.GetAllTaxiNodes([uiMapID])` (live) — backports of the retail
// taxi-map node enumerators, plus the `Enum.FlightPathFaction` /
// `Enum.FlightPathState` tables so retail code ports unchanged.
//
// - **GetTaxiNodesForMap** reads `TaxiNodes.dbc` directly: every flight point
// on a continent (or all continents with no arg), faction-tagged,
// regardless of discovery — the static DB a zone-map addon (pfQuest's
// `pfDB.meta.flight`) wants. No flight master needed. Non-flight rows are filtered out — the DBC also holds transports/
// boats/zeppelins (no flight mount) and non-selectable markers like
// Northshire Abbey (no connecting flight path); a real flight master has a
// mount AND is a TaxiPath endpoint.
// - **GetAllTaxiNodes** wraps the open flight master's live reachable-node
// list (the legacy NumTaxiNodes/TaxiNode* globals' backing state): the
// nodes you can actually fly to right now, with per-node reachability and
// the slot index `TakeTaxiNode` takes. Empty when no taxi map is open.
//
// Ship both: static/DBC for databases + overlays, live/session for a custom
// flight-map UI. See docs/API.md for the field-by-field contract and which
// fields are retail vs ClassicAPI extensions.
#include "Game.h"
#include "Offsets.h"
#include "dbc/Lookup.h"
#include "map/Area.h"
#include <cstdint>
#include <cstring>
namespace Taxi::Map {
namespace {
// Enum.FlightPathFaction (retail values).
enum { FACTION_NEUTRAL = 0, FACTION_HORDE = 1, FACTION_ALLIANCE = 2 };
// Enum.FlightPathState (retail values).
enum { STATE_CURRENT = 0, STATE_REACHABLE = 1, STATE_UNREACHABLE = 2 };
float FloatField(const uint8_t *rec, int off) {
return *reinterpret_cast<const float *>(rec + off);
}
int IntField(const uint8_t *rec, int off) {
return *reinterpret_cast<const int *>(rec + off);
}
// --- name-based zone resolution -------------------------------------------
//
// A taxi node's name is "Location, Zone" (e.g. "Chillwind Camp, Western
// Plaguelands"), so the zone is authoritative in the data itself — which
// beats any geometric world->zone guess at genuine border interleaving that
// the WorldMapArea/overlay boxes can't separate (Chillwind sits by Strahnbrad,
// deep in Alterac's overlay box, but is a WPL node). We resolve the name
// suffix against AreaTable and fall back to the geometric resolver only when a
// node has no ", Zone" suffix or the zone name doesn't match.
char AsciiLower(char c) { return (c >= 'A' && c <= 'Z') ? char(c - 'A' + 'a') : c; }
bool IEqual(const char *a, const char *b) {
while (*a && *b) {
if (AsciiLower(*a) != AsciiLower(*b))
return false;
++a;
++b;
}
return *a == *b;
}
// True if `name` begins with `prefix` followed by a space — a word-prefix, so
// the abbreviated taxi suffixes ("Redridge", "Arathi") match the full
// AreaTable names ("Redridge Mountains", "Arathi Highlands").
bool IStartsWord(const char *name, const char *prefix) {
while (*prefix) {
if (!*name || AsciiLower(*name) != AsciiLower(*prefix))
return false;
++name;
++prefix;
}
return *name == ' ';
}
// Pointer into `name` at the zone part (after the last ", "), or null when the
// name has no ", " separator.
const char *ZoneSuffix(const char *name) {
const char *best = nullptr;
if (name != nullptr)
for (const char *p = name; p[0] != '\0'; ++p)
if (p[0] == ',' && p[1] == ' ')
best = p + 2;
return best;
}
// Copies the location part (before the FIRST ", ") of `name` into `out`,
// null-terminated. Returns `out`, or null when `name` has no ", " separator.
// This is the "Orgrimmar" in "Orgrimmar, Durotar" — a capital's own city map
// when it's a mapped area, distinct from its outdoor region.
const char *LocationPrefix(const char *name, char *out, int outSize) {
if (name == nullptr)
return nullptr;
for (int i = 0; name[i] != '\0'; ++i) {
if (name[i] == ',' && name[i + 1] == ' ') {
int n = (i < outSize - 1) ? i : outSize - 1;
for (int j = 0; j < n; ++j)
out[j] = name[j];
out[n] = '\0';
return out;
}
}
return nullptr;
}
// AreaTable areaID for a zone name: exact (case-insensitive) match preferred,
// else the first word-prefix match. 0 when nothing matches.
int ResolveZoneByName(const char *zone) {
if (zone == nullptr || *zone == '\0')
return 0;
const int count =
*reinterpret_cast<const int *>(Offsets::VAR_AREATABLE_COUNT);
int prefixHit = 0;
for (int id = 1; id <= count; ++id) {
const char *nm = DBC::LocalizedField(Offsets::VAR_AREATABLE_RECORDS,
Offsets::VAR_AREATABLE_COUNT,
static_cast<uint32_t>(id),
Offsets::OFF_AREATABLE_NAMES);
if (nm == nullptr)
continue;
if (IEqual(nm, zone))
return id; // exact wins outright
if (prefixHit == 0 && IStartsWord(nm, zone))
prefixHit = id;
}
return prefixHit;
}
// Pushes { x = px, y = py } as a subtable field `key` of the table on top.
void PushPosition(void *L, const char *key, double px, double py) {
Game::Lua::PushString(L, key);
Game::Lua::NewTable(L);
Game::Lua::SetFieldNumber(L, "x", px);
Game::Lua::SetFieldNumber(L, "y", py);
Game::Lua::SetTable(L, -3);
}
// Walks TaxiPath.dbc into two per-node-id flags:
// endpoint[] — the node is either end of some flight path. A real flight
// master always is; the fake rows (Northshire Abbey, spurious
// duplicate hub rows, standalone markers like "Eastern
// Plaguelands"/"Naxxramas") connect to nothing.
// dest[] — the node is a path DESTINATION (in-degree > 0), i.e. you can
// fly TO it. In-degree 0 marks a departure-only node reachable
// only by other means: druid Teleport (Nighthaven, Moonglade),
// a boat dock (Stormwind Harbor), etc.
// ids >= `size` are ignored (the vanilla table maxes ~122, so the fixed buffer
// is ample). Returns false when the path table isn't loaded.
constexpr int kMaxTaxiNodeID = 2048;
bool BuildTaxiGraph(bool *endpoint, bool *dest, int size) {
const int count = *reinterpret_cast<const int *>(Offsets::VAR_TAXIPATH_COUNT);
if (count <= 0)
return false;
for (int i = 1; i <= count; ++i) {
const uint8_t *rec = DBC::Record(Offsets::VAR_TAXIPATH_RECORDS,
Offsets::VAR_TAXIPATH_COUNT,
static_cast<uint32_t>(i));
if (rec == nullptr)
continue;
const int from = IntField(rec, Offsets::OFF_TAXIPATH_FROM);
const int to = IntField(rec, Offsets::OFF_TAXIPATH_TO);
if (from >= 0 && from < size)
endpoint[from] = true;
if (to >= 0 && to < size) {
endpoint[to] = true;
dest[to] = true;
}
}
return true;
}
// `C_TaxiMap.GetTaxiNodesForMap([mapID])` — see module comment. A numeric
// `mapID` filters to that continent; omitted / non-number returns every
// flight master on every continent.
int __fastcall Script_GetTaxiNodesForMap(void *L) {
const bool filterMap = Game::Lua::IsNumber(L, 1);
const int wantMap =
filterMap ? static_cast<int>(Game::Lua::ToNumber(L, 1)) : -1;
Game::Lua::SetTop(L, 0);
Game::Lua::NewTable(L);
// Real flight masters are TaxiPath endpoints; `dest` marks the ones a
// path leads to (in-degree > 0) for the `reachable` field.
bool endpoint[kMaxTaxiNodeID] = {};
bool dest[kMaxTaxiNodeID] = {};
BuildTaxiGraph(endpoint, dest, kMaxTaxiNodeID);
const int count =
*reinterpret_cast<const int *>(Offsets::VAR_TAXINODES_COUNT);
int outIdx = 0;
for (int id = 1; id <= count; ++id) {
const uint8_t *rec = DBC::Record(Offsets::VAR_TAXINODES_RECORDS,
Offsets::VAR_TAXINODES_COUNT,
static_cast<uint32_t>(id));
if (rec == nullptr)
continue;
const int mapID = IntField(rec, Offsets::OFF_TAXINODE_MAP_ID);
if (filterMap && mapID != wantMap)
continue;
// Two filters isolate real flight masters from the DBC's non-flight
// rows: (1) at least one flight mount — drops transports/boats/
// zeppelins/instance markers (both mounts 0); (2) the node is a
// TaxiPath endpoint — drops Northshire Abbey, spurious duplicate hub
// rows, and standalone markers that connect to no flight path.
const int horde = IntField(rec, Offsets::OFF_TAXINODE_HORDE_MOUNT);
const int alliance = IntField(rec, Offsets::OFF_TAXINODE_ALLIANCE_MOUNT);
if (horde == 0 && alliance == 0)
continue;
if (id >= kMaxTaxiNodeID || !endpoint[id])
continue;
const char *name = DBC::LocalizedField(Offsets::VAR_TAXINODES_RECORDS,
Offsets::VAR_TAXINODES_COUNT,
static_cast<uint32_t>(id),
Offsets::OFF_TAXINODE_NAME);
// The one path-connected, mounted row that still isn't a flight
// master: Blizzard's internal "Generic, World target" zeppelin-route
// marker. It's the sole such placeholder across all continents.
if (name != nullptr && std::strncmp(name, "Generic", 7) == 0)
continue;
const int faction = (horde != 0 && alliance != 0) ? FACTION_NEUTRAL
: (alliance != 0) ? FACTION_ALLIANCE
: FACTION_HORDE;
const float x = FloatField(rec, Offsets::OFF_TAXINODE_X);
const float y = FloatField(rec, Offsets::OFF_TAXINODE_Y);
const float z = FloatField(rec, Offsets::OFF_TAXINODE_Z);
outIdx += 1;
Game::Lua::PushNumber(L, static_cast<double>(outIdx));
Game::Lua::NewTable(L);
Game::Lua::SetFieldNumber(L, "nodeID", id);
Game::Lua::SetFieldString(L, "name", name ? name : "");
Game::Lua::SetFieldNumber(L, "faction", faction);
// reachable: a flight path leads to this node (in-degree > 0). False
// marks a departure-only node reachable only by other means — druid
// Teleport (Nighthaven, Moonglade) or a boat dock (Stormwind Harbor);
// hide those from a non-class-specific "where can I fly" map.
Game::Lua::SetFieldBool(L, "reachable", id < kMaxTaxiNodeID && dest[id]);
Game::Lua::SetFieldNumber(L, "mapID", mapID);
Game::Lua::SetFieldNumber(L, "x", x);
Game::Lua::SetFieldNumber(L, "y", y);
Game::Lua::SetFieldNumber(L, "z", z);
// position: 0-1 on the continent map (retail-accurate). Falls back to
// {0,0} when the continent has no usable WorldMapArea row (instance
// maps); zone-map addons use areaID + mapX/mapY below instead.
double px = 0.0, py = 0.0;
::Map::Area::ContinentPercent(mapID, x, y, &px, &py);
PushPosition(L, "position", px, py);
// Derived zone anchor (areaID + 0-100 zone-relative percent), from the
// node's own name — authoritative, and right at borders geometry can't
// separate. Two candidate zones from the name, each accepted only if
// its WorldMapArea rect actually contains the point:
// 1. the LOCATION ("Orgrimmar" in "Orgrimmar, Durotar") — a capital's
// own city map, so the flight master pins on the city, not the
// surrounding region;
// 2. else the ZONE suffix ("Western Plaguelands") — the outdoor zone
// for a normal node (whose location is an unmapped subzone).
// Falls back to the geometric landmass resolver when neither name part
// resolves to a containing zone (no ", Zone" suffix, unknown name).
int areaID = 0;
double mapX = 0.0, mapY = 0.0;
bool haveZone = false;
char locBuf[128];
const int locArea = ResolveZoneByName(LocationPrefix(name, locBuf, sizeof(locBuf)));
const int zoneArea = ResolveZoneByName(ZoneSuffix(name));
if (locArea > 0 && ::Map::Area::PercentInZone(locArea, x, y, &mapX, &mapY)) {
areaID = locArea; // capital / mapped location -> its own city map
haveZone = true;
} else if (zoneArea > 0 &&
::Map::Area::PercentInZone(zoneArea, x, y, &mapX, &mapY)) {
areaID = zoneArea; // outdoor zone from the name suffix
haveZone = true;
} else {
haveZone = ::Map::Area::ZonePercent(mapID, x, y, &areaID, &mapX, &mapY);
}
if (haveZone) {
Game::Lua::SetFieldNumber(L, "areaID", areaID);
Game::Lua::SetFieldNumber(L, "mapX", mapX);
Game::Lua::SetFieldNumber(L, "mapY", mapY);
}
Game::Lua::SetTable(L, -3);
}
return 1;
}
using TaxiNodeType_t = const char *(__fastcall *)(uint32_t slot0Based);
int StateFromType(const char *type) {
if (type == nullptr)
return STATE_UNREACHABLE;
if (std::strcmp(type, "CURRENT") == 0)
return STATE_CURRENT;
if (std::strcmp(type, "REACHABLE") == 0)
return STATE_REACHABLE;
return STATE_UNREACHABLE; // DISTANT / NONE / other
}
// `C_TaxiMap.GetAllTaxiNodes([uiMapID])` — see module comment. The uiMapID
// arg is accepted for retail signature parity but ignored (vanilla has a
// single active taxi map — the open flight master's).
int __fastcall Script_GetAllTaxiNodes(void *L) {
Game::Lua::SetTop(L, 0);
Game::Lua::NewTable(L);
const int count =
*reinterpret_cast<const int *>(Offsets::VAR_TAXI_NODE_COUNT);
if (count <= 0)
return 1; // no taxi map open
auto typeFn = reinterpret_cast<TaxiNodeType_t>(Offsets::FUN_TAXI_NODE_TYPE);
const auto *arrayBase =
reinterpret_cast<const uint8_t *>(Offsets::VAR_TAXI_NODE_ARRAY);
for (int slot = 0; slot < count; ++slot) {
const uint8_t *slotRec = arrayBase + slot * Offsets::TAXI_NODE_STRIDE;
const uint8_t *dbcRec = *reinterpret_cast<const uint8_t *const *>(
slotRec + Offsets::OFF_TAXI_SLOT_RECORD);
const float x = FloatField(slotRec, Offsets::OFF_TAXI_SLOT_X);
const float y = FloatField(slotRec, Offsets::OFF_TAXI_SLOT_Y);
int nodeID = 0;
const char *name = nullptr;
if (dbcRec != nullptr) {
nodeID = IntField(dbcRec, 0); // TaxiNodes.dbc id @ +0
name = DBC::LocalizedField(Offsets::VAR_TAXINODES_RECORDS,
Offsets::VAR_TAXINODES_COUNT,
static_cast<uint32_t>(nodeID),
Offsets::OFF_TAXINODE_NAME);
}
const int state = StateFromType(typeFn(static_cast<uint32_t>(slot)));
Game::Lua::PushNumber(L, static_cast<double>(slot + 1));
Game::Lua::NewTable(L);
Game::Lua::SetFieldNumber(L, "slotIndex", slot + 1); // TakeTaxiNode arg
Game::Lua::SetFieldString(L, "name", name ? name : "");
Game::Lua::SetFieldNumber(L, "nodeID", nodeID);
Game::Lua::SetFieldNumber(L, "state", state);
PushPosition(L, "position", x, y); // flight-map 0-1, matches retail
Game::Lua::SetTable(L, -3);
}
return 1;
}
void RegisterLuaFunctions() {
static const Game::Lua::EnumIntegerEntry kFaction[] = {
{"Neutral", FACTION_NEUTRAL},
{"Horde", FACTION_HORDE},
{"Alliance", FACTION_ALLIANCE},
};
Game::Lua::RegisterIntegerEnum("Enum", "FlightPathFaction", kFaction, 3);
static const Game::Lua::EnumIntegerEntry kState[] = {
{"Current", STATE_CURRENT},
{"Reachable", STATE_REACHABLE},
{"Unreachable", STATE_UNREACHABLE},
};
Game::Lua::RegisterIntegerEnum("Enum", "FlightPathState", kState, 3);
Game::Lua::RegisterTableFunction("C_TaxiMap", "GetTaxiNodesForMap",
&Script_GetTaxiNodesForMap);
Game::Lua::RegisterTableFunction("C_TaxiMap", "GetAllTaxiNodes",
&Script_GetAllTaxiNodes);
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace
} // namespace Taxi::Map
+87
View File
@@ -0,0 +1,87 @@
// This file is part of ClassicAPI.
//
// ClassicAPI is free software: you can redistribute it and/or modify it under the terms
// of the GNU General Public License as published by the Free Software Foundation, either
// version 3 of the License, or (at your option) any later version.
//
// ClassicAPI is distributed in the hope that it will be useful, but WITHOUT ANY
// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
// PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
// `UnitCreatureFamilyID(unit)` — the numeric CreatureFamily id (the
// `CreatureFamily.dbc` row: 1 = Wolf, 3 = Cat, 4 = Bear, …) for a unit,
// or `nil` when the unit has no family.
//
// The stock `UnitCreatureFamily(unit)` returns only the *localized name*
// ("Wolf", "Chat"), which is awkward to compare and locale-dependent. This
// extension exposes the raw id addons actually want for stable checks
// (tameable-pet logic, family-specific ability tables, etc.) — the same
// value that indexes `CreatureFamily.dbc`.
//
// This is exactly the id the engine's `Script_UnitCreatureFamily`
// (`0x0051A310`) reads before its DBC name lookup: its family helper
// `FUN_006055e0` returns `[unit + 0xB30] ? [row + 0x1C] : 0`, then the id is
// bounds-checked against `CreatureFamily.dbc` and used to push the localized
// name. We stop at the id. `nil` for:
// - Unresolvable unit token (`target` with nothing targeted, empty
// `partyN` slot, etc.).
// - Players and non-beast NPCs — family id is 0 (`UnitCreatureFamily`
// returns nil for these too).
// - NPCs whose creature data hasn't synced yet (no +0xB30 row).
//
// Raises a Lua error on a missing / non-string `unit` argument — same
// semantics as `UnitCreatureFamily` / `UnitSubName`.
#include "Game.h"
#include "Offsets.h"
#include <cstdint>
namespace Unit::CreatureFamily {
namespace {
using ResolveUnitToken_t = void *(__fastcall *)(const char *token);
int __fastcall Script_UnitCreatureFamilyID(void *L) {
if (!Game::Lua::IsString(L, 1)) {
Game::Lua::Error(L, "Usage: UnitCreatureFamilyID(\"unit\")");
return 0;
}
const char *token = Game::Lua::ToString(L, 1);
if (token == nullptr)
return 0;
auto resolve = reinterpret_cast<ResolveUnitToken_t>(
Offsets::FUN_RESOLVE_UNIT_TOKEN);
auto *unit = static_cast<const uint8_t *>(resolve(token));
if (unit == nullptr)
return 0;
auto *cacheRow = *reinterpret_cast<const uint8_t *const *>(
unit + Offsets::OFF_UNIT_CREATURE_CACHE_ROW);
if (cacheRow == nullptr)
return 0; // unsynced NPC / player unit
const int familyID = *reinterpret_cast<const int *>(
cacheRow + Offsets::OFF_CREATURE_CACHE_FAMILY);
if (familyID <= 0)
return 0; // no family (players, non-beast NPCs)
Game::Lua::PushNumber(L, static_cast<double>(familyID));
return 1;
}
void RegisterLuaFunctions() {
Game::Lua::RegisterGlobalFunction("UnitCreatureFamilyID",
&Script_UnitCreatureFamilyID);
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace
} // namespace Unit::CreatureFamily
+81
View File
@@ -0,0 +1,81 @@
// 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/>.
// `UnitCreatureTypeID(unit)` — the numeric CreatureType id (the
// `CreatureType.dbc` row: 1 = Beast, 3 = Demon, 7 = Humanoid, …) for a
// unit, or `nil` when it has none.
//
// The stock `UnitCreatureType(unit)` returns only the *localized name*
// ("Humanoid", "Humanoïde"), which is awkward to compare and locale-
// dependent. This extension exposes the raw id — the twin of
// `UnitCreatureFamilyID`.
//
// It's exactly the value the engine's `Script_UnitCreatureType`
// (`0x0051A280`) resolves before its DBC name lookup, so we just call that
// same inner helper (`FUN_UNIT_CREATURE_TYPE`). The helper handles all three
// engine paths itself — a display-override, the creature-cache type, and the
// player-race type (players resolve to Humanoid via their race) — so a
// straight call matches `UnitCreatureType` for every unit kind. `nil` for:
// - Unresolvable unit token (`target` with nothing targeted, etc.).
// - A unit with no resolvable type (helper returns 0).
//
// Raises a Lua error on a missing / non-string `unit` argument — same
// semantics as `UnitCreatureType` / `UnitCreatureFamilyID`.
#include "Game.h"
#include "Offsets.h"
#include <cstdint>
namespace Unit::CreatureType {
namespace {
using ResolveUnitToken_t = void *(__fastcall *)(const char *token);
using CreatureType_t = int(__fastcall *)(void *unit);
int __fastcall Script_UnitCreatureTypeID(void *L) {
if (!Game::Lua::IsString(L, 1)) {
Game::Lua::Error(L, "Usage: UnitCreatureTypeID(\"unit\")");
return 0;
}
const char *token = Game::Lua::ToString(L, 1);
if (token == nullptr)
return 0;
auto resolve = reinterpret_cast<ResolveUnitToken_t>(
Offsets::FUN_RESOLVE_UNIT_TOKEN);
void *unit = resolve(token);
if (unit == nullptr)
return 0;
auto creatureType = reinterpret_cast<CreatureType_t>(
Offsets::FUN_UNIT_CREATURE_TYPE);
const int typeID = creatureType(unit);
if (typeID <= 0)
return 0; // no resolvable type
Game::Lua::PushNumber(L, static_cast<double>(typeID));
return 1;
}
void RegisterLuaFunctions() {
Game::Lua::RegisterGlobalFunction("UnitCreatureTypeID",
&Script_UnitCreatureTypeID);
}
const Game::ModuleAutoRegister _autoreg{&RegisterLuaFunctions};
} // namespace
} // namespace Unit::CreatureType
+7
View File
@@ -119,6 +119,13 @@ const uint8_t *PlayerObject() {
/*line*/ 0));
}
const uint8_t *PlayerInventoryManager() {
const uint8_t *player = PlayerObject();
if (player == nullptr)
return nullptr;
return player + Offsets::OFF_PLAYER_INVENTORY_MANAGER;
}
const uint8_t *PlayerDescriptor() {
const uint8_t *player = PlayerObject();
if (player == nullptr)
+11
View File
@@ -44,6 +44,17 @@ const uint8_t *PlayerObject();
// re-deriving the player object + descriptor offset in each module.
const uint8_t *PlayerDescriptor();
// The local player's inventory manager (`PlayerObject() +
// OFF_PLAYER_INVENTORY_MANAGER`), or null. Layout: slot count at `+0x00`,
// flat GUID array at `+OFF_INVMGR_GUID_ARRAY`; it's the `this` that
// `GetItemBySlot` expects. This is the SAME pointer the throwing token
// resolver (`FUN_RESOLVE_UNIT_TOKEN("player")`) yields — both resolve the
// local player's GUID through the object manager (verified: the token
// resolver is `TokenToGUID → FUN_OBJECT_RESOLVE_BY_GUID`, differing only in
// the type-mask, which both the player object passes) — but non-throwing
// (null pre-world). Prefer this over re-deriving `player + 0x1D38` per module.
const uint8_t *PlayerInventoryManager();
// 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`).