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);

View File

@@ -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;
};

View File

@@ -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<uint32_t>(97000 + lot);
auto& registry = CDClientManager::GetEntriesMutable<CDComponentsRegistryTable>();
registry.insert_or_assign(static_cast<uint64_t>(lot), componentID);
registry.insert_or_assign(static_cast<uint64_t>(eReplicaComponentType::ITEM) << 32 | static_cast<uint64_t>(lot), componentID);
CDItemComponent component{};
component.id = componentID;
component.equipLocation = equipLocation;
component.itemType = itemType;
component.isBOP = isBOP;
component.isBOE = isBOE;
component.subItems = subItems;
CDClientManager::GetEntriesMutable<CDItemComponentTable>().insert_or_assign(componentID, component);
}
static void RegisterNpc(const LOT npc, const uint32_t componentID, const std::vector<LOT>& items) {
auto& registry = CDClientManager::GetEntriesMutable<CDComponentsRegistryTable>();
registry.insert_or_assign(static_cast<uint64_t>(npc), 0); // the LOT's components are cached
registry.insert_or_assign(static_cast<uint64_t>(eReplicaComponentType::INVENTORY) << 32 | static_cast<uint64_t>(npc), componentID);
auto& table = CDClientManager::GetEntriesMutable<CDInventoryComponentTable>();
for (const auto item : items) table.push_back({ componentID, static_cast<uint32_t>(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<uint32_t>(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<EquippedItem> Equipped(InventoryComponent& inventory) {
std::vector<EquippedItem> 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<InventoryComponent>(-1);
auto items = Equipped(*inventory);
ASSERT_EQ(items.size(), 2u);
RakNet::BitStream construction;
inventory->Serialize(construction, true);
RakNet::BitStream expected;
expected.Write1();
expected.Write<uint32_t>(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<uint32_t>(0);
ExpectSameBits(construction, expected);
RegisterNpc(13790, 516, { HOOD });
info.lot = 13790;
Entity cole(288300744895900002, info);
auto* const coleInventory = cole.AddComponent<InventoryComponent>(-1);
items = Equipped(*coleInventory);
ASSERT_EQ(items.size(), 2u);
RakNet::BitStream coleConstruction;
coleInventory->Serialize(coleConstruction, true);
RakNet::BitStream coleExpected;
coleExpected.Write1();
coleExpected.Write<uint32_t>(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<uint32_t>(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<CDComponentsRegistryTable>().insert_or_assign(static_cast<uint64_t>(info.lot), 0);
Entity player(0x1000000000000001LL, info);
auto* const inventory = player.AddComponent<InventoryComponent>(-1);
Character character(1, nullptr);
player.SetCharacter(&character);
player.AddComponent<CharacterComponent>(-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<uint32_t>(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<uint32_t>(0);
ExpectSameBits(construction, expected);
player.SetCharacter(nullptr);
}