fix(inventory): write is_bound, inventory_type and NPC item slots like live

Equipped items in the inventory component construction, as live wrote
them (49,534 equipped items in the captures):
- is_bound is the item's bound state (false for 2,990 items that bind
  neither way); DLU always wrote true. NPC items are bound when the item
  binds on pickup or on equip (2,640 of 2,640).
- inventory_type is the item's inventory, left out for ITEMS: TempEquip for
  temporary equips and proxies (3,899), Model for models (232). DLU never
  wrote it. DLU keeps player proxies in ITEM_SETS; they are sent as
  TEMP_ITEMS, like live.
- An NPC item's slot is its slot in the inventory it would be in: models
  count from 0 in MODELS and proxies from 0 in TEMP_ITEMS, so a proxy no
  longer takes the next ITEMS slot (1,475 of 1,491 NPC constructions match;
  DLU's numbering matched 1,352).
- equipped_model_transforms is an empty list on every construction (11,214
  of 11,214); DLU wrote none.

Test: InventoryConstructionTest.NpcItemsLikeLive (NPCs 7426 and 13790 from
the captures), InventoryConstructionTest.PlayerItemsLikeLive.

Check in game: NPCs with gear (Numb Chuck in FV, the Ninjago ninjas, faction
vendors) show all their gear; your own and other players' equipped items,
proxies (ninja hoods, capes) and temporary equips (Maelstrom vacuum, quest
items) show normally after zoning and after equipping/unequipping.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Aaron Kimbrell
2026-09-29 05:12:51 -05:00
parent ca7c02cf0c
commit 52f91f307d
3 changed files with 209 additions and 15 deletions

View File

@@ -60,6 +60,38 @@
#include <ranges>
namespace {
// The equipped-item entry for a player's item. Live wrote its bound state and its inventory; DLU keeps proxies in
// ITEM_SETS, which live kept in TEMP_ITEMS (inventory_type TempEquip on every live proxy).
EquippedItem ToEquippedItem(const Item& item, const bool withConfig) {
EquippedItem equipped{ item.GetId(), item.GetLot(), item.GetCount(), item.GetSlot() };
if (withConfig) equipped.config = item.GetConfig();
equipped.bound = item.GetBound();
const auto inventoryType = item.GetInventory() ? item.GetInventory()->GetType() : ITEMS;
equipped.inventoryType = inventoryType == ITEM_SETS ? TEMP_ITEMS : inventoryType;
return equipped;
}
/**
* Equipped-item entries for an NPC's CDClient items, as live wrote them: each item's slot is its slot in the
* inventory it would be in (models count from 0 in MODELS, proxies from 0 in TEMP_ITEMS), the count is the row's,
* and is_bound is set for bind-on-pickup and bind-on-equip items.
*/
class NpcEquipSlots {
public:
EquippedItem Make(const LOT lot, const uint32_t count, const bool isProxy) {
const auto& info = Inventory::FindItemComponent(lot);
const auto inventoryType = isProxy ? TEMP_ITEMS : Inventory::FindInventoryTypeForLot(lot);
EquippedItem item{ ObjectIDManager::GenerateObjectID(), lot, count, m_NextSlot[inventoryType]++ };
item.bound = info.isBOP || info.isBOE;
item.inventoryType = inventoryType;
return item;
}
private:
std::map<eInventoryType, uint32_t> m_NextSlot;
};
}
InventoryComponent::InventoryComponent(Entity* parent, const int32_t componentID) : Component(parent, componentID) {
RegisterMsg(&InventoryComponent::OnGetObjectReportInfo);
this->m_Dirty = true;
@@ -85,7 +117,7 @@ InventoryComponent::InventoryComponent(Entity* parent, const int32_t componentID
auto* inventoryComponentTable = CDClientManager::GetTable<CDInventoryComponentTable>();
auto items = inventoryComponentTable->Query([=](const CDInventoryComponent entry) { return entry.id == componentId; });
auto slot = 0u;
NpcEquipSlots slots;
for (const auto& item : items) {
if (!Inventory::IsValidItem(item.itemid)) continue;
@@ -93,11 +125,9 @@ InventoryComponent::InventoryComponent(Entity* parent, const int32_t componentID
if (!item.equip) continue;
const LWOOBJID id = ObjectIDManager::GenerateObjectID();
const auto& info = Inventory::FindItemComponent(item.itemid);
UpdateSlot(info.equipLocation, { id, static_cast<LOT>(item.itemid), item.count, slot++ });
UpdateSlot(info.equipLocation, slots.Make(static_cast<LOT>(item.itemid), item.count, false));
// Equip this items proxies.
auto subItems = info.subItems;
@@ -111,10 +141,9 @@ InventoryComponent::InventoryComponent(Entity* parent, const int32_t componentID
const auto proxyLOT = static_cast<LOT>(std::stoi(proxyLotAsString));
const auto& proxyInfo = Inventory::FindItemComponent(proxyLOT);
const LWOOBJID proxyId = ObjectIDManager::GenerateObjectID();
// Use item.count since we equip item.count number of the item this is a requested proxy of
UpdateSlot(proxyInfo.equipLocation, { proxyId, proxyLOT, item.count, slot++ });
UpdateSlot(proxyInfo.equipLocation, slots.Make(proxyLOT, item.count, true));
}
}
}
@@ -745,7 +774,7 @@ void InventoryComponent::LoadXml(const tinyxml2::XMLDocument& document) {
if (equipped) {
const auto info = Inventory::FindItemComponent(lot);
UpdateSlot(info.equipLocation, { item->GetId(), item->GetLot(), item->GetCount(), item->GetSlot() });
UpdateSlot(info.equipLocation, ToEquippedItem(*item, false));
AddItemSkills(item->GetLot());
}
@@ -894,7 +923,9 @@ void InventoryComponent::Serialize(RakNet::BitStream& outBitStream, const bool b
outBitStream.Write(item.slot != 0);
if (item.slot != 0) outBitStream.Write<uint16_t>(item.slot);
outBitStream.Write0();
// Live left the inventory out for ITEMS and wrote it for the rest (TempEquip, Model).
outBitStream.Write(item.inventoryType != ITEMS);
if (item.inventoryType != ITEMS) outBitStream.Write<uint32_t>(item.inventoryType);
bool flag = !item.config.values.empty();
outBitStream.Write(flag);
@@ -916,7 +947,7 @@ void InventoryComponent::Serialize(RakNet::BitStream& outBitStream, const bool b
outBitStream.Write(ldfStream);
}
outBitStream.Write1();
outBitStream.Write(item.bound);
}
m_Dirty = false;
@@ -924,7 +955,9 @@ void InventoryComponent::Serialize(RakNet::BitStream& outBitStream, const bool b
outBitStream.Write(false);
}
outBitStream.Write(false);
// Equipped model transforms: live wrote an empty list on every construction, and nothing afterwards.
outBitStream.Write(bIsInitialUpdate);
if (bIsInitialUpdate) outBitStream.Write<uint32_t>(0);
}
void InventoryComponent::Update(float deltaTime) {
@@ -1061,7 +1094,7 @@ void InventoryComponent::EquipItem(Item* item, const bool skipChecks) {
// skills change and the item's proxies are added
SendEquipState(*item, true);
UpdateSlot(item->GetInfo().equipLocation, { item->GetId(), item->GetLot(), item->GetCount(), item->GetSlot(), item->GetConfig() });
UpdateSlot(item->GetInfo().equipLocation, ToEquippedItem(*item, true));
ApplyBuff(item);
@@ -1560,14 +1593,12 @@ std::vector<uint32_t> InventoryComponent::FindBuffs(Item* item, bool castOnEquip
void InventoryComponent::SetNPCItems(const std::vector<LOT>& items) {
m_Equipped.clear();
auto slot = 0u;
NpcEquipSlots slots;
for (const auto& item : items) {
const LWOOBJID id = ObjectIDManager::GenerateObjectID();
const auto& info = Inventory::FindItemComponent(item);
UpdateSlot(info.equipLocation, { id, static_cast<LOT>(item), 1, slot++ }, true);
UpdateSlot(info.equipLocation, slots.Make(static_cast<LOT>(item), 1, false), true);
}
Game::entityManager->SerializeEntity(m_Parent);