fix(replica): send each object's real age as time_since_created_on_server

Live sent the milliseconds since the object was created on the server in
every construction header (nonzero in 142,258 of 145,677 captured
constructions; the same object constructed again later carries a larger
value by the time elapsed; the local player's own construction on load is
0). DLU always wrote 0. The client stores it in the object's config
(LWOGameObject::time_since_created_on_server).

Entities now remember when they were created (steady clock) and write
their age.

Test: ReplicaConstructionTest.TimeSinceCreatedOnServerIsTheObjectsAge.

Check in game: zone in and walk around several worlds; objects, NPCs,
enemies and other players appear and animate as before (no objects
popping, stuck or misplaced animations on platforms/spinners).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Aaron Kimbrell
2026-09-29 04:41:44 -05:00
parent d5563ad5da
commit b035f60e72
4 changed files with 99 additions and 1 deletions

View File

@@ -997,7 +997,9 @@ void Entity::WriteBaseReplicaData(RakNet::BitStream& outBitStream, eReplicaPacke
outBitStream.Write<uint8_t>(name.size());
outBitStream.Write(name);
outBitStream.Write<uint32_t>(0); //Time since created on server
// Live sent the object's age on this server in milliseconds: 0 for an object constructed as it is created
// (a player's own construction on load), the time since load for zone objects.
outBitStream.Write<uint32_t>(GetTimeSinceCreatedMs());
const auto& syncLDF = GetVar<std::vector<std::u16string>>(u"syncLDF");

View File

@@ -1,5 +1,6 @@
#pragma once
#include <chrono>
#include <map>
#include <functional>
#include <tuple>
@@ -218,6 +219,11 @@ public:
std::unordered_map<eReplicaComponentType, Component*>& GetComponents() { return m_Components; } // TODO: Remove
void WriteBaseReplicaData(RakNet::BitStream& outBitStream, eReplicaPacketType packetType);
// Milliseconds since this object was created on this server (the construction's time_since_created_on_server).
uint32_t GetTimeSinceCreatedMs() const {
return static_cast<uint32_t>(std::chrono::duration_cast<std::chrono::milliseconds>(std::chrono::steady_clock::now() - m_CreationTime).count());
}
void WriteComponents(RakNet::BitStream& outBitStream, eReplicaPacketType packetType) const;
void UpdateXMLDoc(tinyxml2::XMLDocument& doc);
void Update(float deltaTime);
@@ -433,6 +439,9 @@ private:
bool m_IsParentChildDirty = true;
// When this object was created on this server. Construction sends its age from this, as live did.
std::chrono::steady_clock::time_point m_CreationTime = std::chrono::steady_clock::now();
bool m_IsSleeping = false;
/*

View File

@@ -15,6 +15,7 @@ set(DCOMPONENTS_TESTS
"ReplicaComponentOrderTests.cpp"
"QuickBuildCompleteTests.cpp"
"RocketLaunchTests.cpp"
"ReplicaConstructionTests.cpp"
)
# Get the folder name and prepend it to the files above

View File

@@ -0,0 +1,86 @@
// 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);
}