Files
DarkflameServer/tests/dGameTests/dComponentsTests/ReplicaConstructionTests.cpp
Aaron Kimbrell 5ac39dca8b fix: an activity's construction always carries its players
LWOActivityComponent::Deserialize (0x00d002b0) reads a flag, the player
count and each player with its 10 values; a construction is the only way
a client coming into range learns who is in the activity. The block was
written only while it was waiting for the next update. Live finish line
constructions (LOT 4967) had the flag and the count in 40 of 40, one of
them with a player in it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 14:50:33 -06:00

833 lines
34 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 "LevelProgressionComponent.h"
#include "LUPExhibitComponent.h"
#include "PlayerForcedMovementComponent.h"
#include "ScriptedActivityComponent.h"
#include "Item.h"
#include "ModelComponent.h"
#include "MovingPlatformComponent.h"
#include "PhantomPhysicsComponent.h"
#include "SimplePhysicsComponent.h"
#include "User.h"
#include "eGameActivity.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);
}
// A current-activity or PVP change is written once, as a guild change is: LWOCharacterComponent::Deserialize reads each
// block behind its own flag bit.
TEST_F(ReplicaConstructionTest, CharacterActivityAndPvpChangesAreWrittenOnce) {
User user(UNASSIGNED_SYSTEM_ADDRESS, "tester", "key");
Character character(1, &user);
info.lot = 1;
Entity player(1152921506064087005, info);
player.SetCharacter(&character);
character.SetEntity(&player);
auto* const component = player.AddComponent<CharacterComponent>(-1, &character, UNASSIGNED_SYSTEM_ADDRESS);
component->SetCurrentActivity(eGameActivity::QUICKBUILDING);
component->SetPvpEnabled(true);
RakNet::BitStream serialization;
component->Serialize(serialization, false);
RakNet::BitStream expected;
expected.Write1(); // gm_pvp_info
expected.Write1(); // 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
expected.Write(eGameActivity::QUICKBUILDING);
expected.Write0(); // social_info
ExpectSameBits(serialization, expected);
RakNet::BitStream again;
component->Serialize(again, false);
EXPECT_EQ(again.GetNumberOfBitsUsed(), 3u);
// A construction in between (another player coming into range) doesn't swallow a pending change
component->SetCurrentActivity(eGameActivity::NONE);
RakNet::BitStream construction;
component->Serialize(construction, true);
RakNet::BitStream after;
component->Serialize(after, false);
EXPECT_EQ(after.GetNumberOfBitsUsed(), 3u + 32u);
player.SetCharacter(nullptr);
}
// A player in jetpack mode is constructed in it: live player constructions carried the jetpack block (effect 167,
// flying 0, bypass checks 1) and a 0 after the pickup radius. LWOControllablePhysComponent::Deserialize reads both.
TEST_F(ReplicaConstructionTest, ControllablePhysicsConstructionCarriesJetPackMode) {
Entity player(1152921506064087006, info);
auto* const physics = player.AddComponent<ControllablePhysicsComponent>(-1);
const auto construct = [physics]() {
RakNet::BitStream construction;
physics->Serialize(construction, true);
return Bits(construction);
};
const auto bits = [](const RakNet::BitStream& stream) { return Bits(stream); };
// No jetpack: one 0 bit, then the stun immunities
const auto plain = construct();
EXPECT_EQ(plain.substr(0, 2), "01");
physics->SetJetPackMode(true, 167, true);
RakNet::BitStream head;
head.Write1(); // jetpack
head.Write<int32_t>(167); // effect
head.Write0(); // flying
head.Write1(); // bypass checks
head.Write1(); // stun immunities
const auto jetpack = construct();
EXPECT_EQ(jetpack.substr(0, head.GetNumberOfBitsUsed()), bits(head));
// After the 7 immunity counts: no cheats, then the pickup radius and a 0 (not the jetpack flag)
const size_t afterImmunities = head.GetNumberOfBitsUsed() + 7 * 32;
EXPECT_EQ(jetpack.substr(afterImmunities, 2), "01");
EXPECT_EQ(jetpack[afterImmunities + 2 + 32], '0');
// The rest is what a player without a jetpack gets
EXPECT_EQ(jetpack.substr(head.GetNumberOfBitsUsed()), plain.substr(2));
physics->SetJetPackMode(false, -1, false);
EXPECT_EQ(construct(), plain);
}
namespace {
struct VolumeEffect {
uint32_t type{};
float amount{};
bool hasDistance{};
float min{};
float max{};
bool isDirectional{};
NiPoint3 direction{};
};
// A phantom volume looks its LOT up in the registry when it is made
void ConnectRegistry() {
if (CDClientDatabase::isConnected) return;
CDClientDatabase::Connect(":memory:");
CDClientDatabase::ExecuteDML("CREATE TABLE ComponentsRegistry (id INTEGER, component_type INTEGER, component_id INTEGER);");
}
// The effect block of a phantom physics update made from a trigger's SetPhysicsVolumeEffect arguments
VolumeEffect ReadVolumeEffect(Entity& volume, const std::vector<std::string>& args) {
auto* const phantom = volume.AddComponent<PhantomPhysicsComponent>(-1);
phantom->SetVolumeEffect(args);
RakNet::BitStream stream;
phantom->Serialize(stream, false);
VolumeEffect effect;
bool flag{};
stream.Read(flag);
if (flag) stream.IgnoreBits(7 * 32); // position and rotation
stream.Read(flag);
EXPECT_TRUE(flag); // the effect block
stream.Read(flag);
EXPECT_TRUE(flag); // active
stream.Read(effect.type);
stream.Read(effect.amount);
stream.Read(effect.hasDistance);
if (effect.hasDistance) {
stream.Read(effect.min);
stream.Read(effect.max);
}
stream.Read(effect.isDirectional);
if (effect.isDirectional) {
stream.Read(effect.direction.x);
stream.Read(effect.direction.y);
stream.Read(effect.direction.z);
}
EXPECT_EQ(stream.GetNumberOfUnreadBits(), 0u);
return effect;
}
}
// Trigger volume effects as live sent them (phantom physics constructions in the live captures; the client reads
// them in LWOPhantomPhysicsComponent::Deserialize): the direction has the amount as its length, the distances go out
// for "true" only and as 1 and the max, and a zero direction is no direction.
TEST_F(ReplicaConstructionTest, PhantomPhysicsVolumeEffectsAsLive) {
ConnectRegistry();
Entity pushWithDistance(1001, info);
auto effect = ReadVolumeEffect(pushWithDistance, { "Push", "50", "0.91", "-0.34", "-0.22", "true", "20", "50" });
EXPECT_EQ(effect.type, 0u);
EXPECT_EQ(effect.amount, 50.0f);
EXPECT_TRUE(effect.hasDistance);
EXPECT_EQ(effect.min, 1.0f);
EXPECT_EQ(effect.max, 50.0f);
EXPECT_TRUE(effect.isDirectional);
EXPECT_NEAR(effect.direction.x, 45.68f, 0.01f);
EXPECT_NEAR(effect.direction.y, -17.07f, 0.01f);
EXPECT_NEAR(effect.direction.z, -11.04f, 0.01f);
Entity pushFar(1002, info);
effect = ReadVolumeEffect(pushFar, { "Push", "200", "1", "0", "0", "true", "150", "175" });
EXPECT_TRUE(effect.hasDistance);
EXPECT_EQ(effect.min, 1.0f);
EXPECT_EQ(effect.max, 175.0f);
EXPECT_EQ(effect.direction, NiPoint3(200.0f, 0.0f, 0.0f));
// "1" is not "true": no distances
Entity updraft(1003, info);
effect = ReadVolumeEffect(updraft, { "Push", "40.0", "0", "1", "0", "1", "20", "50" });
EXPECT_EQ(effect.amount, 40.0f);
EXPECT_FALSE(effect.hasDistance);
EXPECT_TRUE(effect.isDirectional);
EXPECT_EQ(effect.direction, NiPoint3(0.0f, 40.0f, 0.0f));
Entity repulse(1004, info);
effect = ReadVolumeEffect(repulse, { "Repulse", "150", "0", "0", "0", "true", "25", "100" });
EXPECT_EQ(effect.type, 2u);
EXPECT_EQ(effect.amount, 150.0f);
EXPECT_TRUE(effect.hasDistance);
EXPECT_EQ(effect.min, 1.0f);
EXPECT_EQ(effect.max, 100.0f);
EXPECT_FALSE(effect.isDirectional);
Entity slanted(1005, info);
effect = ReadVolumeEffect(slanted, { "Push", " 35", " .79", " 0", " .61" });
EXPECT_FALSE(effect.hasDistance);
EXPECT_NEAR(effect.direction.x, 27.70f, 0.01f);
EXPECT_NEAR(effect.direction.z, 21.39f, 0.01f);
Entity friction(1006, info);
effect = ReadVolumeEffect(friction, { "Friction", "0" });
EXPECT_EQ(effect.type, 4u);
EXPECT_FALSE(effect.hasDistance);
EXPECT_FALSE(effect.isDirectional);
// The amount is a float all the way: LWOPhantomPhysicsComponent::Deserialize (0x00c34db0) reads 32 bits into a
// float, and trigger commands are run by the server only, so a fraction is kept as the zone file has it
Entity lowFriction(1007, info);
effect = ReadVolumeEffect(lowFriction, { "Friction", "0.1" });
EXPECT_EQ(effect.type, 4u);
EXPECT_EQ(effect.amount, 0.1f);
}
// A construction (the volume coming into one player's range) doesn't use up an effect change the players already
// watching it still have to get.
TEST_F(ReplicaConstructionTest, PhantomPhysicsConstructionKeepsThePendingEffect) {
ConnectRegistry();
Entity volume(1007, info);
auto* const phantom = volume.AddComponent<PhantomPhysicsComponent>(-1);
RakNet::BitStream first;
phantom->Serialize(first, false);
const auto idle = first.GetNumberOfBitsUsed();
phantom->SetVolumeEffect({ "Friction", "0" });
RakNet::BitStream construction;
phantom->Serialize(construction, true);
RakNet::BitStream update;
phantom->Serialize(update, false);
EXPECT_EQ(update.GetNumberOfBitsUsed(), 2u + 1 + 32 + 32 + 1 + 1);
RakNet::BitStream again;
phantom->Serialize(again, false);
EXPECT_EQ(again.GetNumberOfBitsUsed(), 2u);
EXPECT_LE(again.GetNumberOfBitsUsed(), idle);
}
// A construction goes to the one client the object came into range of. It must not use up a change the clients
// already watching are still waiting for (the level, rail and billboard flags are each read behind a flag bit).
TEST_F(ReplicaConstructionTest, AConstructionKeepsPendingLevelAndForcedMovementChanges) {
Entity player(1152921506064087008, info);
auto* const level = player.AddComponent<LevelProgressionComponent>(-1);
auto* const forcedMovement = player.AddComponent<PlayerForcedMovementComponent>(-1);
level->SetLevel(7);
forcedMovement->SetPlayerOnRail(true);
RakNet::BitStream construction;
level->Serialize(construction, true);
forcedMovement->Serialize(construction, true);
RakNet::BitStream update;
level->Serialize(update, false);
forcedMovement->Serialize(update, false);
RakNet::BitStream expected;
expected.Write1();
expected.Write<uint32_t>(7);
expected.Write1();
expected.Write1(); // on rail
expected.Write0(); // show billboard
ExpectSameBits(update, expected);
RakNet::BitStream again;
level->Serialize(again, false);
forcedMovement->Serialize(again, false);
EXPECT_EQ(again.GetNumberOfBitsUsed(), 2u);
}
// The client learns the exhibit's LOT only from the component's block (LWOLUPExhibitComponent::Deserialize: a flag
// and the LOT). DLU wrote it on a construction only while it was dirty, so a player who came into range after the
// first update saw no exhibit until the next one was picked.
TEST_F(ReplicaConstructionTest, LupExhibitConstructionAlwaysCarriesTheExhibit) {
Entity exhibit(1009, info);
auto* const component = exhibit.AddComponent<LUPExhibitComponent>(-1);
RakNet::BitStream update;
component->Serialize(update, false);
RakNet::BitStream idle;
component->Serialize(idle, false);
EXPECT_EQ(idle.GetNumberOfBitsUsed(), 1u);
RakNet::BitStream construction;
component->Serialize(construction, true);
RakNet::BitStream expected;
expected.Write1();
expected.Write<LOT>(11121);
ExpectSameBits(construction, expected);
}
// A guild change (docs/Guilds.md) is written once: the client reads guild id, a u8 length and 16-bit characters, and
// redraws the name billboard every time it reads a name.
TEST_F(ReplicaConstructionTest, CharacterGuildChangeIsWrittenOnce) {
User user(UNASSIGNED_SYSTEM_ADDRESS, "tester", "key");
Character character(1, &user);
info.lot = 1;
Entity player(1152921506064087004, 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);
component->SetGuild(7, u"Brick");
EXPECT_EQ(component->GetGuildID(), 7);
RakNet::BitStream serialization;
component->Serialize(serialization, false);
RakNet::BitStream expected;
expected.Write0(); // gm_pvp_info
expected.Write0(); // current_activity
expected.Write1(); // social_info
expected.Write<LWOOBJID>(7);
expected.Write<uint8_t>(5);
for (const char16_t c : std::u16string(u"Brick")) expected.Write<uint16_t>(c);
expected.Write1();
expected.Write<uint32_t>(0);
ExpectSameBits(serialization, expected);
RakNet::BitStream again;
component->Serialize(again, false);
EXPECT_EQ(again.GetNumberOfBitsUsed(), 3u);
// The same guild again changes nothing
component->SetGuild(7, u"Brick");
RakNet::BitStream same;
component->Serialize(same, false);
EXPECT_EQ(same.GetNumberOfBitsUsed(), 3u);
player.SetCharacter(nullptr);
}
// A client that comes into range of an activity is told its players, whether or not they changed since the last
// update. Live finish line constructions (LOT 4967): flag 1 and the count in 40 of 40, one of them with a player.
TEST_F(ReplicaConstructionTest, AnActivityConstructionAlwaysCarriesItsPlayers) {
ConnectRegistry();
Entity activity(1010, info);
auto* const component = activity.AddComponent<ScriptedActivityComponent>(-1);
const LWOOBJID player = 1152921506064087006;
component->SetActivityValue(player, 1, 42.0f);
RakNet::BitStream update;
component->Serialize(update, false);
RakNet::BitStream idle;
component->Serialize(idle, false);
EXPECT_EQ(Bits(idle), "0"); // nothing changed since
RakNet::BitStream construction;
component->Serialize(construction, true);
EXPECT_EQ(Bits(construction), Bits(update));
EXPECT_TRUE(construction.ReadBit());
uint32_t count{};
ASSERT_TRUE(construction.Read(count));
EXPECT_EQ(count, 1u);
LWOOBJID id{};
ASSERT_TRUE(construction.Read(id));
EXPECT_EQ(id, player);
for (uint32_t i = 0; i < 10; i++) {
float value{};
ASSERT_TRUE(construction.Read(value));
EXPECT_EQ(value, i == 1 ? 42.0f : 0.0f);
}
EXPECT_EQ(construction.GetNumberOfUnreadBits(), 0u);
// Without players: the flag and a count of 0
component->RemoveActivityPlayerData(player);
RakNet::BitStream cleared;
component->Serialize(cleared, false);
RakNet::BitStream empty;
component->Serialize(empty, true);
EXPECT_EQ(Bits(empty), "1" + std::string(32, '0'));
}
// Run speed modifiers stack as in LWOControllablePhysComponent::msgAddRunspeedModifier (0x00be0af0) and
// msgRemoveRunSpeedModifier (0x00be0720): the lowest slow wins, otherwise the highest boost; 500 and anything over 1000
// count for nothing
TEST_F(ReplicaConstructionTest, RunSpeedModifiersStackAsTheClientDoes) {
Entity entity(15, info);
auto* const physics = entity.AddComponent<ControllablePhysicsComponent>(-1);
EXPECT_FLOAT_EQ(physics->GetSpeedMultiplier(), 1.0f);
physics->AddSpeedboost(750.0f);
EXPECT_FLOAT_EQ(physics->GetSpeedMultiplier(), 1.5f);
physics->AddSpeedboost(550.0f); // a smaller boost applied later does not replace the bigger one
EXPECT_FLOAT_EQ(physics->GetSpeedMultiplier(), 1.5f);
physics->AddSpeedboost(250.0f); // any slow beats every boost
EXPECT_FLOAT_EQ(physics->GetSpeedMultiplier(), 0.5f);
physics->AddSpeedboost(133.0f);
EXPECT_FLOAT_EQ(physics->GetSpeedMultiplier(), 133.0f / 500.0f);
physics->AddSpeedboost(350.0f);
EXPECT_FLOAT_EQ(physics->GetSpeedMultiplier(), 133.0f / 500.0f);
physics->AddSpeedboost(1500.0f);
physics->AddSpeedboost(500.0f);
EXPECT_FLOAT_EQ(physics->GetSpeedMultiplier(), 133.0f / 500.0f);
physics->RemoveSpeedboost(133.0f);
EXPECT_FLOAT_EQ(physics->GetSpeedMultiplier(), 0.5f);
physics->RemoveSpeedboost(250.0f);
EXPECT_FLOAT_EQ(physics->GetSpeedMultiplier(), 0.7f);
physics->RemoveSpeedboost(350.0f);
EXPECT_FLOAT_EQ(physics->GetSpeedMultiplier(), 1.5f);
physics->RemoveSpeedboost(750.0f);
EXPECT_FLOAT_EQ(physics->GetSpeedMultiplier(), 1.1f);
physics->RemoveSpeedboost(550.0f);
EXPECT_FLOAT_EQ(physics->GetSpeedMultiplier(), 1.0f); // 1500 and 500 are left and count for nothing
}
// 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();
}
// The client reads a moving platform's subcomponents while a 1 bit comes before one (as live wrote them): the list
// ends with a 0 bit, which DLU left out, so the client took the next component's first bit for it
TEST_F(ReplicaConstructionTest, MovingPlatformEndsItsSubcomponentList) {
info.lot = 2349;
Entity platform(288300744895900005, info);
auto* const component = platform.AddComponent<MovingPlatformComponent>(-1, "");
component->SetSerialized(true);
RakNet::BitStream stream;
component->Serialize(stream, true);
bool bit{};
ASSERT_TRUE(stream.Read(bit));
EXPECT_TRUE(bit); // has subcomponents
ASSERT_TRUE(stream.Read(bit));
EXPECT_TRUE(bit); // the path is written with the construction
ASSERT_TRUE(stream.Read(bit));
EXPECT_FALSE(bit); // no path
ASSERT_TRUE(stream.Read(bit));
EXPECT_TRUE(bit); // a subcomponent follows
uint32_t type{};
ASSERT_TRUE(stream.Read(type));
EXPECT_EQ(type, 4u); // mover
// The mover: a 1 bit, state, desired waypoint, 2 flags, percent, position, current and next waypoint, 2 times
stream.IgnoreBits(1 + 32 + 32 + 2 + 32 + 96 + 32 + 32 + 32 + 32);
ASSERT_TRUE(stream.Read(bit));
EXPECT_FALSE(bit); // no more subcomponents
EXPECT_EQ(stream.GetNumberOfUnreadBits(), 0u);
}
// 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);
// The equipment put on before the construction still goes to the clients already watching, once
RakNet::BitStream pending;
coleInventory->Serialize(pending, false);
EXPECT_GT(pending.GetNumberOfBitsUsed(), 2u);
// 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);
}