Compare commits

...
2 Commits
Author SHA1 Message Date
Brues 2f3c67be1a Script_EquipmentSetContainsLockedItems: Missing items are not locked 2026-05-31 12:42:40 -05:00
Brues f94af6cb73 Fix C_Item.GetItemUniqueness* return shapes
The two functions have different signatures in modern (Classic Era
1.15.x) WoW, verified against BlizzardInterfaceCode's generated
ItemDocumentation.lua:

  GetItemUniqueness(itemLocation) → (limitCategory, limitMax)
  GetItemUniquenessByID(itemID)   →
      (isUnique, limitCategoryName, limitCategoryCount, limitCategoryID)

We were returning a 3-tuple in the wrong order for both. For a
"Unique (10)" item the byID variant now correctly returns
(true, nil, 10, nil) instead of (10, nil, 0).
2026-05-31 10:20:19 -05:00
3 changed files with 106 additions and 63 deletions
+29 -18
View File
@@ -4302,31 +4302,42 @@ Reads `ITEM_FIELD_STACK_COUNT` directly off the item's
### `C_Item.GetItemUniqueness(itemLocation)` / `C_Item.GetItemUniquenessByID(item)`
Returns `(maxAllowed, category, equippedCount)` — the item's
uniqueness cap, category name, and current equipped-of-category
count.
The two functions have **different signatures** in modern (Classic
Era 1.15.x) WoW — we mirror them exactly:
`C_Item.GetItemUniqueness(itemLocation)` returns
`(limitCategory, limitMax)`:
| Field | Vanilla source |
|-------|----------------|
| `maxAllowed` | `ItemStats_C.m_maxCount` — `0` = unlimited, `1` = unique ("Unique" tag in tooltip), higher = inventory cap |
| `category` | Always `nil` — vanilla's client has no unique-equipped category system (added in TBC). |
| `equippedCount` | Always `0` — without categories there's nothing meaningful to count against. |
| `limitCategory` | Always `0` — vanilla has no `LimitCategory.dbc` (TBC addition). |
| `limitMax` | `ItemStats_C.m_maxCount` — `0` = unlimited, `1` = "Unique", higher = inventory cap. |
```lua
local max = C_Item.GetItemUniqueness({equipmentSlotIndex = 13})
-- max = 1 for typical trinkets (Unique)
local max = C_Item.GetItemUniquenessByID(2943)
-- max = 1 (Eye of Paleth — "Unique")
```
`C_Item.GetItemUniquenessByID(itemID)` returns
`(isUnique, limitCategoryName, limitCategoryCount, limitCategoryID)`:
The by-ID form skips item-location resolution and reads `ItemStats_C`
directly. Fires a background cache fill on miss and returns nil;
| Field | Vanilla source |
|-------|----------------|
| `isUnique` | `m_maxCount > 0` — true for any unique-tagged item. |
| `limitCategoryName` | Always `nil` — no categories in vanilla. |
| `limitCategoryCount` | `m_maxCount` when `isUnique`, else `nil`. |
| `limitCategoryID` | Always `nil`. |
Both functions return nothing (zero Lua return values, matching
modern's `MayReturnNothing` annotation) if the item record isn't
cached. The by-ID variant fires a background cache fill on miss;
re-call after `GET_ITEM_INFO_RECEIVED`.
Modern WoW returns the same 3-tuple but with the category half
populated for unique-equipped categories (e.g. "Brewfest Mug",
"Heart of Azeroth"). On this backport, only `maxAllowed` carries
information.
```lua
local _, max = C_Item.GetItemUniqueness({equipmentSlotIndex = 13})
-- max = 1 for typical trinkets (Unique)
local isUnique, _, count = C_Item.GetItemUniquenessByID(81013)
-- isUnique = true, count = 10 (Southern Sand Crawler Leg, "Unique (10)")
```
Modern WoW populates the category half for items like Brewfest Mug
or Heart of Azeroth ("Unique-Equipped: Eye of Azshara"). Vanilla
has no such items, so the category fields stay nil/0.
### `C_Item.GetItemLink(itemLocation)`
+15
View File
@@ -331,6 +331,19 @@ int __fastcall Script_ClearIgnoredSlotsForSave(void *L) {
// would race with). Reads the per-CGItem instance flag at
// `OFF_ITEM_CLIENT_LOCK` bit 0 — same bit `C_Item.IsLocked` checks
// and the engine's own pickup/unlock paths manipulate.
//
// Gates on `Locations::FindGUID` BEFORE consulting the lock flag:
// for items the player has deleted/mailed/traded away, the engine's
// CGItem cache often retains a stale entry that `ResolveByGUID`
// still hands back, but the inventory walk no longer finds the GUID
// anywhere. Reading the lock flag off the stale CGItem reflects
// whatever bits were last set on it (frequently the "locked"
// in-transaction state from when it was being deleted/swapped),
// which would falsely report the set as "contains locked items"
// and prevent `UseEquipmentSet` from running through addon
// wrappers that gate on this check. The inventory presence check
// matches modern semantics: a missing item is *missing*, not
// *locked*.
int __fastcall Script_EquipmentSetContainsLockedItems(void *L) {
const uint32_t setID = ArgSetID(L, 1);
const Set *s = Data::FindByID(setID);
@@ -341,6 +354,8 @@ int __fastcall Script_EquipmentSetContainsLockedItems(void *L) {
const uint64_t g = s->items[i];
if (g == GUID_EMPTY || g == GUID_IGNORED)
continue;
if (Locations::FindGUID(g) == 0)
continue; // not in inventory — treat as missing, not locked
const uint8_t *item = Locations::ResolveItemByGUID(g);
if (item == nullptr)
continue;
+62 -45
View File
@@ -11,18 +11,31 @@
// You should have received a copy of the GNU Lesser General Public License along with
// ClassicAPI. If not, see <https://www.gnu.org/licenses/>.
// `C_Item.GetItemUniqueness(itemLocation)` — uniqueness cap. Reads
// `ItemStats_C.m_maxCount` from the item's cached record; that's the
// only uniqueness signal vanilla's client carries. `1` = unique
// ("Held In Off-hand" trinkets, special quest items, etc.); `0` =
// unlimited; higher values cap the inventory count.
// `C_Item.GetItemUniqueness(itemLocation)` and the `*ByID` variant
// — uniqueness cap. Both read `ItemStats_C.m_maxCount` from the
// item's cached record; that's the only uniqueness signal vanilla
// carries. `1` = unique-equipped (one at a time); higher values
// cap the inventory count; `0` = unlimited.
//
// Modern WoW returns a 3-tuple `(maxEquipped, category, equippedCount)`
// keyed off the post-TBC category system (e.g. "Unique-Equipped:
// Brewfest Mug"). Vanilla has no category system, so we surface
// `m_maxCount` as the first return and leave the category nil and
// equipped count `0` — matching modern's "field present but inapplicable"
// convention.
// Modern WoW (Classic Era 1.15.x) gives the two functions **different
// signatures**, verified against `_classic_` BlizzardInterfaceCode's
// `ItemDocumentation.lua`:
//
// GetItemUniqueness(itemLocation) →
// limitCategory (number, non-nilable),
// limitMax (number, non-nilable)
//
// GetItemUniquenessByID(itemInfo) →
// isUnique (bool, non-nilable),
// limitCategoryName (string, nilable),
// limitCategoryCount (number, nilable),
// limitCategoryID (number, nilable)
//
// Vanilla has no `LimitCategory.dbc` system (TBC+ addition for
// "Unique-Equipped: Brewfest Mug"-style cross-item category caps),
// so the category fields are always nil/0. `isUnique` collapses to
// `m_maxCount > 0`; `limitMax` and `limitCategoryCount` both surface
// the raw `m_maxCount`.
#include "Game.h"
#include "Offsets.h"
@@ -50,54 +63,58 @@ const uint8_t *PeekItemRecord(uint32_t itemID) {
return fn(cache, itemID, &zeroGuid, nullptr, nullptr, 0);
}
} // namespace
// Pushes the 3-tuple `(maxAllowed, nil, 0)` derived from the
// ItemStats_C record, or nil + early-return on cache miss.
int PushFromItemID(void *L, uint32_t itemID) {
// Returns the item's `m_maxCount` or -1 if the record isn't cached.
// `-1` is a "warm-and-bail" sentinel; caller pushes nothing on -1
// so the Lua side sees the modern "MayReturnNothing" behaviour.
int ReadMaxCount(uint32_t itemID) {
const uint8_t *record = PeekItemRecord(itemID);
if (record == nullptr) {
Item::Data::WarmCache(itemID);
Game::Lua::PushNil(L);
return 1;
return -1;
}
const int maxCount = *reinterpret_cast<const int *>(
return *reinterpret_cast<const int *>(
record + Offsets::OFF_ITEMSTATS_MAX_COUNT);
Game::Lua::PushNumber(L, static_cast<double>(maxCount));
Game::Lua::PushNil(L); // category (vanilla has none)
Game::Lua::PushNumber(L, 0.0); // equippedCount (not tracked)
return 3;
}
} // namespace
// `GetItemUniqueness(itemLocation)` shape: `(limitCategory, limitMax)`.
static int __fastcall Script_GetItemUniqueness(void *L) {
if (!Item::Location::IsLocationArg(L, 1)) {
Game::Lua::PushNil(L);
return 1;
}
if (!Item::Location::IsLocationArg(L, 1))
return 0;
auto *item = Item::Location::Resolve(L, 1);
if (item == nullptr) {
Game::Lua::PushNil(L);
return 1;
}
if (item == nullptr)
return 0;
const int itemID = Item::ID::FromCGItem(item);
if (itemID <= 0) {
Game::Lua::PushNil(L);
return 1;
}
return PushFromItemID(L, static_cast<uint32_t>(itemID));
if (itemID <= 0)
return 0;
const int maxCount = ReadMaxCount(static_cast<uint32_t>(itemID));
if (maxCount < 0)
return 0; // cache miss — modern API documents "MayReturnNothing"
Game::Lua::PushNumber(L, 0.0); // limitCategory (vanilla has no LimitCategory.dbc)
Game::Lua::PushNumber(L, static_cast<double>(maxCount));
return 2;
}
// `GetItemUniquenessByID(itemID)` shape:
// `(isUnique, limitCategoryName, limitCategoryCount, limitCategoryID)`.
static int __fastcall Script_GetItemUniquenessByID(void *L) {
if (!Game::Lua::IsNumber(L, 1)) {
Game::Lua::PushNil(L);
return 1;
}
if (!Game::Lua::IsNumber(L, 1))
return 0;
const int itemID = static_cast<int>(Game::Lua::ToNumber(L, 1));
if (itemID <= 0) {
Game::Lua::PushNil(L);
return 1;
}
return PushFromItemID(L, static_cast<uint32_t>(itemID));
if (itemID <= 0)
return 0;
const int maxCount = ReadMaxCount(static_cast<uint32_t>(itemID));
if (maxCount < 0)
return 0;
Game::Lua::PushBool(L, maxCount > 0);
Game::Lua::PushNil(L); // limitCategoryName (no categories in vanilla)
if (maxCount > 0)
Game::Lua::PushNumber(L, static_cast<double>(maxCount));
else
Game::Lua::PushNil(L); // limitCategoryCount — nil when not limited
Game::Lua::PushNil(L); // limitCategoryID
return 4;
}
static void RegisterLuaFunctions() {