mirror of
https://github.com/DarkflameUniverse/DarkflameServer.git
synced 2026-10-02 10:53:44 +00:00
Live wrote the item component's UGC info on every construction of an object with an item component (99 of 99 constructions of LOT 14535, a possessable with item, skill and controllable physics and no Objects row: ug_id 0, moderation status NoStatus, no description). DLU wrote a 0 bit. The item component is read after the skill and base-combat-AI components (ComponentOrderVector::Initialize 0x0101f8e0; live constructions of LOT 14535 have Skill then Item). DLU wrote it before the inventory, which did not show while it was a single 0 bit next to the skill's 0 bit, but would misalign every component after it now that it writes more. It now comes after BASE_COMBAT_AI. The ReplicaComponentOrderTests oracle moves the item the same way (intended change). Test: ReplicaConstructionTest.ItemConstructionWritesEmptyUgcInfo, ReplicaComponentOrderTest.EveryListedComponent (oracle updated). Check in game: with a second player watching, use a possessable / mount / vehicle item (e.g. a racing car in the race lobby); it appears, moves and is left normally for both. Rocket launches and property models look as before. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
414 lines
17 KiB
C++
414 lines
17 KiB
C++
// Object construction data as live sent it. The expected bits are built field by field in the order the client reads
|
|
// them (lu_packets replica structs), with the values of live constructions from the 2011/2012 captures.
|
|
#include "GameDependencies.h"
|
|
#include <gtest/gtest.h>
|
|
|
|
#include <chrono>
|
|
#include <thread>
|
|
|
|
#include "BitStream.h"
|
|
#include "CDClientDatabase.h"
|
|
#include "CDComponentsRegistryTable.h"
|
|
#include "CDInventoryComponentTable.h"
|
|
#include "CDItemComponentTable.h"
|
|
#include "Character.h"
|
|
#include "CharacterComponent.h"
|
|
#include "ControllablePhysicsComponent.h"
|
|
#include "DestroyableComponent.h"
|
|
#include "Entity.h"
|
|
#include "InventoryComponent.h"
|
|
#include "ItemComponent.h"
|
|
#include "Item.h"
|
|
#include "ModelComponent.h"
|
|
#include "MovingPlatformComponent.h"
|
|
#include "SimplePhysicsComponent.h"
|
|
#include "User.h"
|
|
#include "eReplicaComponentType.h"
|
|
#include "eReplicaPacketType.h"
|
|
|
|
namespace {
|
|
// Copies the bits of `stream` from bit `from` on into `tail`
|
|
void Tail(RakNet::BitStream& stream, const uint32_t from, RakNet::BitStream& tail) {
|
|
stream.SetReadOffset(from);
|
|
const auto bits = stream.GetNumberOfBitsUsed() - from;
|
|
std::vector<uint8_t> buffer(BITS_TO_BYTES(bits) + 1);
|
|
stream.ReadBits(buffer.data(), bits, false);
|
|
tail.WriteBits(buffer.data(), bits, false);
|
|
}
|
|
|
|
// The stream's bits as '0'/'1' characters, so a failure shows where they differ
|
|
std::string Bits(const RakNet::BitStream& stream) {
|
|
std::string bits;
|
|
for (uint32_t i = 0; i < stream.GetNumberOfBitsUsed(); ++i) bits += (stream.GetData()[i / 8] & (0x80 >> (i % 8))) ? '1' : '0';
|
|
return bits;
|
|
}
|
|
|
|
void ExpectSameBits(const RakNet::BitStream& actual, const RakNet::BitStream& expected) {
|
|
EXPECT_EQ(Bits(actual), Bits(expected));
|
|
}
|
|
}
|
|
|
|
class ReplicaConstructionTest : public GameDependenciesTest {
|
|
protected:
|
|
void SetUp() override { SetUpDependencies(); }
|
|
void TearDown() override { TearDownDependencies(); }
|
|
};
|
|
|
|
// time_since_created_on_server: live sent each object's age on the server in milliseconds (0 for the local player's own
|
|
// construction right after it was made, the zone's uptime for objects loaded with the zone). DLU sent 0.
|
|
TEST_F(ReplicaConstructionTest, TimeSinceCreatedOnServerIsTheObjectsAge) {
|
|
info.lot = 12266;
|
|
Entity entity(288300744895979394, info);
|
|
|
|
const auto readTime = [&entity]() {
|
|
RakNet::BitStream stream;
|
|
entity.WriteBaseReplicaData(stream, eReplicaPacketType::CONSTRUCTION);
|
|
LWOOBJID objectID{};
|
|
LOT lot{};
|
|
uint8_t nameLength{};
|
|
uint32_t time{};
|
|
EXPECT_TRUE(stream.Read(objectID));
|
|
EXPECT_TRUE(stream.Read(lot));
|
|
EXPECT_TRUE(stream.Read(nameLength));
|
|
EXPECT_EQ(nameLength, 0);
|
|
EXPECT_TRUE(stream.Read(time));
|
|
EXPECT_EQ(objectID, 288300744895979394);
|
|
EXPECT_EQ(lot, 12266);
|
|
return time;
|
|
};
|
|
|
|
EXPECT_LT(readTime(), 1000u);
|
|
std::this_thread::sleep_for(std::chrono::milliseconds(30));
|
|
const auto later = readTime();
|
|
EXPECT_GE(later, 30u);
|
|
EXPECT_LT(later, 60000u);
|
|
EXPECT_GE(entity.GetTimeSinceCreatedMs(), later);
|
|
}
|
|
|
|
// A civilian's character component: live always wrote the GM, current-activity and social blocks on construction
|
|
// (9,726 LOT 1 constructions: is_gm false, gm_level 0, current_activity Some(None), social_info always Some with guild 0
|
|
// and an empty guild name). DLU wrote them only after something dirtied them, so a civilian got none of them.
|
|
TEST_F(ReplicaConstructionTest, CharacterConstructionAlwaysWritesGmActivityAndSocialBlocks) {
|
|
User user(UNASSIGNED_SYSTEM_ADDRESS, "tester", "key");
|
|
Character character(1, &user);
|
|
info.lot = 1;
|
|
Entity player(1152921506064087003, info);
|
|
player.SetCharacter(&character);
|
|
character.SetEntity(&player);
|
|
auto* const component = player.AddComponent<CharacterComponent>(-1, &character, UNASSIGNED_SYSTEM_ADDRESS);
|
|
|
|
RakNet::BitStream construction;
|
|
component->Serialize(construction, true);
|
|
|
|
// Everything before the GM block: 4 absent claim codes, 10 u32 appearance fields, 4 u64s (account, last logout,
|
|
// prop mod display time, u-score), the free-trial bit, 27 u64 statistics and the 2-bit transition state.
|
|
constexpr uint32_t beforeGm = 4 + 10 * 32 + 4 * 64 + 1 + 27 * 64 + 2;
|
|
RakNet::BitStream tail;
|
|
Tail(construction, beforeGm, tail);
|
|
|
|
RakNet::BitStream expected;
|
|
expected.Write1(); // gm_pvp_info Some
|
|
expected.Write0(); // pvp_enabled
|
|
expected.Write0(); // is_gm
|
|
expected.Write<uint8_t>(0); // gm_level
|
|
expected.Write0(); // editor_enabled
|
|
expected.Write<uint8_t>(0); // editor_level
|
|
expected.Write1(); // current_activity Some
|
|
expected.Write<uint32_t>(0); // GameActivity::None
|
|
expected.Write1(); // social_info Some
|
|
expected.Write<LWOOBJID>(0); // guild_id
|
|
expected.Write<uint8_t>(0); // guild_name ""
|
|
expected.Write1(); // is_lego_club_member (DLU treats everyone as a member)
|
|
expected.Write<uint32_t>(0); // country code
|
|
ExpectSameBits(tail, expected);
|
|
|
|
// A serialization with nothing changed still writes none of them
|
|
RakNet::BitStream serialization;
|
|
component->Serialize(serialization, false);
|
|
EXPECT_EQ(serialization.GetNumberOfBitsUsed(), 3u);
|
|
|
|
player.SetCharacter(nullptr);
|
|
}
|
|
|
|
// Live wrote the cheat block (gravity scale, speed multiplier) on construction only when one of them was changed: 216
|
|
// of 20,617 controllable-physics constructions had it (players at run speed 1.05, enemies with gravity 0), never 1/1.
|
|
TEST_F(ReplicaConstructionTest, ControllablePhysicsConstructionWritesCheatsOnlyWhenChanged) {
|
|
Entity entity(15, info);
|
|
auto* const physics = entity.AddComponent<ControllablePhysicsComponent>(-1);
|
|
|
|
const auto cheatBits = [physics]() {
|
|
RakNet::BitStream stream;
|
|
physics->Serialize(stream, true);
|
|
stream.IgnoreBits(1 + 1 + 7 * 32); // no jetpack, stun immunities
|
|
bool hasCheats{};
|
|
EXPECT_TRUE(stream.Read(hasCheats));
|
|
std::pair<float, float> cheats{ -1.0f, -1.0f };
|
|
if (hasCheats) {
|
|
EXPECT_TRUE(stream.Read(cheats.first));
|
|
EXPECT_TRUE(stream.Read(cheats.second));
|
|
}
|
|
return std::make_pair(hasCheats, cheats);
|
|
};
|
|
|
|
EXPECT_FALSE(cheatBits().first);
|
|
|
|
physics->SetSpeedMultiplier(1.05f);
|
|
const auto [hasCheats, cheats] = cheatBits();
|
|
EXPECT_TRUE(hasCheats);
|
|
EXPECT_FLOAT_EQ(cheats.first, 1.0f);
|
|
EXPECT_FLOAT_EQ(cheats.second, 1.05f);
|
|
}
|
|
|
|
// Simple physics: live sent motion type Fixed for objects without a motionType (never the invalid 0 DLU sent when the
|
|
// level gave none) and no velocity for fixed objects (38,970 Fixed constructions, all without velocity); moving
|
|
// platforms were Keyframed with a velocity (1,059 of 1,059).
|
|
TEST_F(ReplicaConstructionTest, SimplePhysicsConstructionLikeLive) {
|
|
info.lot = 12266;
|
|
Entity smashable(288300744895979394, info);
|
|
auto* const physics = smashable.AddComponent<SimplePhysicsComponent>(-1);
|
|
physics->SetPosition(NiPoint3(-259.89438f, 77.147575f, 485.42264f));
|
|
NiQuaternion rotation = QuatUtils::IDENTITY;
|
|
rotation.x = 0.51204455f;
|
|
rotation.y = 0.0f;
|
|
rotation.z = 0.0f;
|
|
rotation.w = 0.8589589f;
|
|
physics->SetRotation(rotation);
|
|
|
|
RakNet::BitStream construction;
|
|
physics->Serialize(construction, true);
|
|
RakNet::BitStream expected;
|
|
expected.Write0(); // is_climbable
|
|
expected.Write<int32_t>(0); // climbing_property
|
|
expected.Write0(); // velocity_info None
|
|
expected.Write1(); // motion_type Some
|
|
expected.Write<uint32_t>(5); // Fixed
|
|
expected.Write1(); // position_rotation_info
|
|
expected.Write(-259.89438f);
|
|
expected.Write(77.147575f);
|
|
expected.Write(485.42264f);
|
|
expected.Write(0.51204455f); // x, y, z, w
|
|
expected.Write(0.0f);
|
|
expected.Write(0.0f);
|
|
expected.Write(0.8589589f);
|
|
ExpectSameBits(construction, expected);
|
|
|
|
info.lot = 11950;
|
|
Entity platform(288300744895900003, info);
|
|
auto* const platformPhysics = platform.AddComponent<SimplePhysicsComponent>(-1);
|
|
platform.AddComponent<MovingPlatformComponent>(-1, "");
|
|
EXPECT_EQ(platformPhysics->GetPhysicsMotionState(), SimplePhysicsComponent::MOTION_TYPE_KEYFRAMED);
|
|
RakNet::BitStream platformConstruction;
|
|
platformPhysics->Serialize(platformConstruction, true);
|
|
platformConstruction.IgnoreBits(1 + 32);
|
|
bool hasVelocity{};
|
|
EXPECT_TRUE(platformConstruction.Read(hasVelocity));
|
|
EXPECT_TRUE(hasVelocity);
|
|
|
|
// A property model without behaviors is keyframed
|
|
info.lot = 14;
|
|
Entity model(288300744895908946, info);
|
|
auto* const modelPhysics = model.AddComponent<SimplePhysicsComponent>(-1);
|
|
model.AddComponent<ModelComponent>(-1)->LoadBehaviors();
|
|
EXPECT_EQ(modelPhysics->GetPhysicsMotionState(), SimplePhysicsComponent::MOTION_TYPE_KEYFRAMED);
|
|
|
|
// A level-set motion type wins
|
|
info.settings.Insert<uint32_t>(u"motionType", 1);
|
|
Entity dynamic(288300744895900004, info);
|
|
EXPECT_EQ(dynamic.AddComponent<SimplePhysicsComponent>(-1)->GetPhysicsMotionState(), SimplePhysicsComponent::MOTION_TYPE_DYNAMIC);
|
|
info.settings.values.clear();
|
|
}
|
|
|
|
// Live replicated the DestructibleComponent factionList, so a list of -1 as [-1] (12,829 constructions: vendors,
|
|
// quickbuilds, bouncers; DLU dropped it and sent []), and a row with no faction but factionList 6 as [6].
|
|
TEST_F(ReplicaConstructionTest, TemplateFactionMinusOneIsReplicated) {
|
|
CDClientDatabase::Connect(":memory:");
|
|
CDClientDatabase::ExecuteDML("CREATE TABLE Factions (faction INTEGER, enemyList TEXT);");
|
|
CDClientDatabase::ExecuteDML("INSERT INTO Factions VALUES (4, '1'), (6, '');");
|
|
|
|
Entity vendor(15, info);
|
|
auto* const destroyable = vendor.AddComponent<DestroyableComponent>(-1);
|
|
destroyable->AddTemplateFactions("-1");
|
|
EXPECT_EQ(destroyable->GetFactionIDs(), std::vector<int32_t>{ -1 });
|
|
|
|
Entity enemy(16, info);
|
|
auto* const enemyDestroyable = enemy.AddComponent<DestroyableComponent>(-1);
|
|
enemyDestroyable->AddTemplateFactions("4");
|
|
EXPECT_EQ(enemyDestroyable->GetFactionIDs(), std::vector<int32_t>{ 4 });
|
|
EXPECT_EQ(enemyDestroyable->GetEnemyFactionsIDs(), std::vector<int32_t>{ 1 });
|
|
|
|
Entity smashable(17, info);
|
|
auto* const smashableDestroyable = smashable.AddComponent<DestroyableComponent>(-1);
|
|
smashableDestroyable->AddTemplateFactions("6");
|
|
EXPECT_EQ(smashableDestroyable->GetFactionIDs(), std::vector<int32_t>{ 6 });
|
|
}
|
|
|
|
// An object with an item component (and no model component): live wrote the UGC info on every construction (99 of 99
|
|
// constructions of LOT 14535): ug_id 0, NoStatus, no description. DLU wrote none.
|
|
TEST_F(ReplicaConstructionTest, ItemConstructionWritesEmptyUgcInfo) {
|
|
Entity item(15, info);
|
|
auto* const component = item.AddComponent<ItemComponent>(-1);
|
|
RakNet::BitStream construction;
|
|
component->Serialize(construction, true);
|
|
RakNet::BitStream expected;
|
|
expected.Write1();
|
|
expected.Write<LWOOBJID>(0);
|
|
expected.Write<uint32_t>(0);
|
|
expected.Write0();
|
|
ExpectSameBits(construction, expected);
|
|
|
|
RakNet::BitStream serialization;
|
|
component->Serialize(serialization, false);
|
|
EXPECT_EQ(serialization.GetNumberOfBitsUsed(), 1u);
|
|
}
|
|
|
|
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);
|
|
}
|