From 52f91f307d7775de0c030c49d0b6ab6fe0f44d87 Mon Sep 17 00:00:00 2001 From: Aaron Kimbrell Date: Tue, 29 Sep 2026 05:12:51 -0500 Subject: [PATCH] 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 --- dGame/dComponents/InventoryComponent.cpp | 61 +++++-- dGame/dInventory/EquippedItem.h | 12 ++ .../ReplicaConstructionTests.cpp | 151 ++++++++++++++++++ 3 files changed, 209 insertions(+), 15 deletions(-) diff --git a/dGame/dComponents/InventoryComponent.cpp b/dGame/dComponents/InventoryComponent.cpp index 661022417..c40394c04 100644 --- a/dGame/dComponents/InventoryComponent.cpp +++ b/dGame/dComponents/InventoryComponent.cpp @@ -60,6 +60,38 @@ #include +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 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(); 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(item.itemid), item.count, slot++ }); + UpdateSlot(info.equipLocation, slots.Make(static_cast(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(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(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(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(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 InventoryComponent::FindBuffs(Item* item, bool castOnEquip void InventoryComponent::SetNPCItems(const std::vector& 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(item), 1, slot++ }, true); + UpdateSlot(info.equipLocation, slots.Make(static_cast(item), 1, false), true); } Game::entityManager->SerializeEntity(m_Parent); diff --git a/dGame/dInventory/EquippedItem.h b/dGame/dInventory/EquippedItem.h index ec10f95b4..c88cce76b 100644 --- a/dGame/dInventory/EquippedItem.h +++ b/dGame/dInventory/EquippedItem.h @@ -2,6 +2,7 @@ #include "dCommonVars.h" #include "LDFFormat.h" +#include "eInventoryType.h" /** * An item that's equipped, generally as a smaller return type than the regular Item class @@ -32,4 +33,15 @@ struct EquippedItem * The configuration of the item with any extra data */ LwoNameValue config = {}; + + /** + * Whether the item is bound to its owner (the construction's is_bound) + */ + bool bound = true; + + /** + * The inventory the item is in (the construction's inventory_type): TEMP_ITEMS for temporary equips and + * proxies, MODELS for models, ITEMS for the rest + */ + eInventoryType inventoryType = ITEMS; }; diff --git a/tests/dGameTests/dComponentsTests/ReplicaConstructionTests.cpp b/tests/dGameTests/dComponentsTests/ReplicaConstructionTests.cpp index 6e114a640..8729135fc 100644 --- a/tests/dGameTests/dComponentsTests/ReplicaConstructionTests.cpp +++ b/tests/dGameTests/dComponentsTests/ReplicaConstructionTests.cpp @@ -129,3 +129,154 @@ TEST_F(ReplicaConstructionTest, CharacterConstructionAlwaysWritesGmActivityAndSo player.SetCharacter(nullptr); } + +class InventoryConstructionTest : public GameDependenciesTest { +protected: + // The items of the live sample below (CDClient 1.10.64 values) + static constexpr LOT HOOD = 2642; // hair, bind on pickup, proxy 10482 + static constexpr LOT HOOD_PROXY = 10482; // clavicle, bind on equip + static constexpr LOT NPC_HELMET = 8520; // hair, item type 22 (a model), bind on equip + static constexpr LOT NPC_KNIFE = 16682; // special_r, bind on equip + static constexpr LOT PLAIN_SHIRT = 4000; // chest, bound neither way + + void SetUp() override { + SetUpDependencies(); + CDClientDatabase::Connect(":memory:"); + CDClientDatabase::ExecuteDML("CREATE TABLE ItemSets (setID INTEGER, itemIDs TEXT);"); + RegisterItem(HOOD, "hair", 2, true, false, std::to_string(HOOD_PROXY)); + RegisterItem(HOOD_PROXY, "clavicle", 4, false, true); + RegisterItem(NPC_HELMET, "hair", 22, false, true); + RegisterItem(NPC_KNIFE, "special_r", 6, false, true); + RegisterItem(PLAIN_SHIRT, "chest", 15, false, false); + } + + void TearDown() override { TearDownDependencies(); } + + static void RegisterItem(const LOT lot, const std::string& equipLocation, const int32_t itemType, const bool isBOP, const bool isBOE, const std::string& subItems = "") { + const auto componentID = static_cast(97000 + lot); + auto& registry = CDClientManager::GetEntriesMutable(); + registry.insert_or_assign(static_cast(lot), componentID); + registry.insert_or_assign(static_cast(eReplicaComponentType::ITEM) << 32 | static_cast(lot), componentID); + CDItemComponent component{}; + component.id = componentID; + component.equipLocation = equipLocation; + component.itemType = itemType; + component.isBOP = isBOP; + component.isBOE = isBOE; + component.subItems = subItems; + CDClientManager::GetEntriesMutable().insert_or_assign(componentID, component); + } + + static void RegisterNpc(const LOT npc, const uint32_t componentID, const std::vector& items) { + auto& registry = CDClientManager::GetEntriesMutable(); + registry.insert_or_assign(static_cast(npc), 0); // the LOT's components are cached + registry.insert_or_assign(static_cast(eReplicaComponentType::INVENTORY) << 32 | static_cast(npc), componentID); + auto& table = CDClientManager::GetEntriesMutable(); + for (const auto item : items) table.push_back({ componentID, static_cast(item), 1, true }); + } + + // One EquippedItemInfo the way lu_packets reads it + static void WriteItem(RakNet::BitStream& out, const LWOOBJID id, const LOT lot, const uint16_t slot, const uint32_t inventoryType, const bool bound) { + out.Write(id); + out.Write(lot); + out.Write0(); // subkey + out.Write1(); // count + out.Write(1); + out.Write(slot != 0); + if (slot != 0) out.Write(slot); + out.Write(inventoryType != 0); + if (inventoryType != 0) out.Write(inventoryType); + out.Write0(); // extra_info + out.Write(bound); + } + + static std::vector Equipped(InventoryComponent& inventory) { + std::vector items; + for (const auto& item : inventory.GetEquippedItems() | std::views::values) items.push_back(item); + return items; + } +}; + +// Live, NPC LOT 7426 (FV Numb Chuck; capture 60a58346ece1 idx 58): the helmet, a model, is in the MODELS inventory, so +// it has inventory_type Model and slot 0 like the knife in ITEMS; both bind on equip, so both are bound. +// Live, NPC LOT 13790 (NJ Cole; capture 8846569a7d52 idx 10614): the hood's proxy is TempEquip, slot 0, bound. +// Every live construction ended with an empty equipped_model_transforms list. +TEST_F(InventoryConstructionTest, NpcItemsLikeLive) { + RegisterNpc(7426, 263, { NPC_HELMET, NPC_KNIFE }); + info.lot = 7426; + Entity numbChuck(288300744895900001, info); + auto* const inventory = numbChuck.AddComponent(-1); + auto items = Equipped(*inventory); + ASSERT_EQ(items.size(), 2u); + + RakNet::BitStream construction; + inventory->Serialize(construction, true); + RakNet::BitStream expected; + expected.Write1(); + expected.Write(2); + WriteItem(expected, items[0].id, NPC_HELMET, 0, 5 /* Model */, true); // "hair" sorts before "special_r" + WriteItem(expected, items[1].id, NPC_KNIFE, 0, 0, true); + expected.Write1(); // equipped_model_transforms Some([]) + expected.Write(0); + ExpectSameBits(construction, expected); + + RegisterNpc(13790, 516, { HOOD }); + info.lot = 13790; + Entity cole(288300744895900002, info); + auto* const coleInventory = cole.AddComponent(-1); + items = Equipped(*coleInventory); + ASSERT_EQ(items.size(), 2u); + + RakNet::BitStream coleConstruction; + coleInventory->Serialize(coleConstruction, true); + RakNet::BitStream coleExpected; + coleExpected.Write1(); + coleExpected.Write(2); + WriteItem(coleExpected, items[0].id, HOOD_PROXY, 0, 4 /* TempEquip */, true); // "clavicle" before "hair" + WriteItem(coleExpected, items[1].id, HOOD, 0, 0, true); + coleExpected.Write1(); + coleExpected.Write(0); + ExpectSameBits(coleConstruction, coleExpected); + + // A serialization with nothing changed writes neither list + RakNet::BitStream serialization; + coleInventory->Serialize(serialization, false); + EXPECT_EQ(serialization.GetNumberOfBitsUsed(), 2u); +} + +// A player's items: is_bound is the item's bound state (live: bind-on-pickup and equipped bind-on-equip items true, +// items that bind neither way false, 2,990 of 49,534), and proxies are TempEquip. +TEST_F(InventoryConstructionTest, PlayerItemsLikeLive) { + info.lot = 1; + CDClientManager::GetEntriesMutable().insert_or_assign(static_cast(info.lot), 0); + Entity player(0x1000000000000001LL, info); + auto* const inventory = player.AddComponent(-1); + Character character(1, nullptr); + player.SetCharacter(&character); + player.AddComponent(-1, &character, UNASSIGNED_SYSTEM_ADDRESS)->InitializeStatisticsFromString(""); + + auto* const bag = inventory->GetInventory(eInventoryType::ITEMS); + // A bind-on-pickup item is bound from the moment it is picked up + auto* const hood = new Item(0x1000000000007000LL, HOOD, bag, 0, 1, true, {}, LWOOBJID_EMPTY, LWOOBJID_EMPTY, eLootSourceType::NONE); + auto* const shirt = new Item(0x1000000000007001LL, PLAIN_SHIRT, bag, 1, 1, false, {}, LWOOBJID_EMPTY, LWOOBJID_EMPTY, eLootSourceType::NONE); + inventory->EquipItem(hood); + inventory->EquipItem(shirt); + + LWOOBJID proxyID = LWOOBJID_EMPTY; + for (const auto& item : Equipped(*inventory)) if (item.lot == HOOD_PROXY) proxyID = item.id; + ASSERT_NE(proxyID, LWOOBJID_EMPTY); + + RakNet::BitStream construction; + inventory->Serialize(construction, true); + RakNet::BitStream expected; + expected.Write1(); + expected.Write(3); + WriteItem(expected, shirt->GetId(), PLAIN_SHIRT, 1, 0, false); // "chest", "clavicle", "hair" + WriteItem(expected, proxyID, HOOD_PROXY, 0, 4 /* TempEquip */, true); + WriteItem(expected, hood->GetId(), HOOD, 0, 0, true); + expected.Write1(); + expected.Write(0); + ExpectSameBits(construction, expected); + + player.SetCharacter(nullptr); +}