From e043a8f6b5f92d31dc53085408a9467c8580c857 Mon Sep 17 00:00:00 2001 From: Aaron Kimbrell Date: Wed, 30 Sep 2026 08:40:03 -0500 Subject: [PATCH] fix(capture): read compressed configs and placed models in replica packets A compressed construction config is a u32 uncompressed size, a u32 compressed size and the zlib bytes: the reader took the first size for the second and lost its place, so live model constructions never read. It now inflates and reads the entries. A model's item component isn't made (Entity::Initialize): its model component writes the item info. The layout left the registry's item component in, so a premade model's components read one block twice and didn't match. Live writes the same bits from its item component, so both now read exactly. Tested with a live placed model's construction and the server's own model. Co-Authored-By: Claude Opus 5.5 --- dGame/dUtilities/ReplicaDecoder.cpp | 31 ++++++++--- .../dComponentsTests/ReplicaDecoderTests.cpp | 55 +++++++++++++++++-- 2 files changed, 74 insertions(+), 12 deletions(-) diff --git a/dGame/dUtilities/ReplicaDecoder.cpp b/dGame/dUtilities/ReplicaDecoder.cpp index 93b6024d4..4b68a2917 100644 --- a/dGame/dUtilities/ReplicaDecoder.cpp +++ b/dGame/dUtilities/ReplicaDecoder.cpp @@ -11,6 +11,7 @@ #include "GeneralUtils.h" #include "magic_enum.hpp" #include "MessageIdentifiers.h" +#include "ZCompression.h" namespace { using json = nlohmann::json; @@ -62,6 +63,9 @@ namespace { return name.empty() ? json(value) : json(std::string(name) + " (" + std::to_string(value) + ")"); } + // Larger compressed LDF is not inflated (a construction's config is a few hundred bytes) + constexpr uint32_t MAX_LDF_BYTES = 1024 * 1024; + // LDF entries as the client reads them: "key=type:value" json ReadLdfEntries(Reader& r, int32_t count) { json out = json::array(); @@ -88,16 +92,26 @@ namespace { return out; } - // u32 size, u8 compressed, then the entries (or the compressed bytes, which are shown as their size) + // u32 size, u8 compressed, then the entries; compressed: u32 uncompressed size, u32 compressed size and the zlib + // bytes of the entries (the count, then each entry) json ReadLdf(Reader& r) { const auto size = r.Get(); const auto compressed = r.Get(); - if (compressed) { - const auto compressedSize = r.Get(); - for (uint32_t i = 0; r.ok && i < compressedSize; i++) r.Get(); - return json{ {"compressed", true}, {"size", size}, {"compressedSize", compressedSize} }; - } - return ReadLdfEntries(r, r.Get()); + if (!compressed) return ReadLdfEntries(r, r.Get()); + const auto uncompressedSize = r.Get(); + const auto compressedSize = r.Get(); + std::vector bytes; + for (uint32_t i = 0; r.ok && i < compressedSize; i++) bytes.push_back(r.Get()); + json shown{ {"compressed", true}, {"size", size}, {"uncompressedSize", uncompressedSize}, {"compressedSize", compressedSize} }; + if (!r.ok || uncompressedSize > MAX_LDF_BYTES) return shown; + std::vector entries(uncompressedSize); + int32_t error{}; + const auto read = ZCompression::Decompress(bytes.data(), compressedSize, entries.data(), uncompressedSize, error); + if (read != static_cast(uncompressedSize)) return shown; + RakNet::BitStream inflated(entries.data(), uncompressedSize, false); + Reader inner{ inflated }; + auto out = ReadLdfEntries(inner, inner.Get()); + return inner.ok ? out : shown; } // Activity user info: object ID and 10 values each @@ -594,6 +608,9 @@ namespace { if (has.contains(DESTROYABLE)) has.insert(BUFF); if (has.contains(CHARACTER)) has.insert({ POSSESSOR, LEVEL_PROGRESSION, PLAYER_FORCED_MOVEMENT }); if (has.contains(PET)) has.erase(MODEL); + // A model's item info is written by its model component (live: by its item component, the same bits in the + // same place), so the item component isn't made for it + if (has.contains(MODEL)) has.erase(ITEM); // Collectibles get one; a quick build without one writes the same empty bits itself in the same place const bool destroyable = has.contains(DESTROYABLE) || has.contains(COLLECTIBLE) || has.contains(QUICK_BUILD) || extraDestroyable; eReplicaComponentType slot = MINI_GAME_CONTROL; diff --git a/tests/dGameTests/dComponentsTests/ReplicaDecoderTests.cpp b/tests/dGameTests/dComponentsTests/ReplicaDecoderTests.cpp index 3f23f5959..9463ecaf1 100644 --- a/tests/dGameTests/dComponentsTests/ReplicaDecoderTests.cpp +++ b/tests/dGameTests/dComponentsTests/ReplicaDecoderTests.cpp @@ -12,7 +12,6 @@ #include "DestroyableComponent.h" #include "Entity.h" #include "InventoryComponent.h" -#include "ItemComponent.h" #include "LevelProgressionComponent.h" #include "MessageIdentifiers.h" #include "ModelComponent.h" @@ -141,13 +140,14 @@ TEST_F(ReplicaDecoderTest, PlayerConstructionReadsTheCharacterParts) { EXPECT_EQ((*FieldsOf(*constructed, "CHARACTER"))["statistics"].size(), 27u); } -// A model's destroyable is not in its registry rows: it is written after the other components +// A model's destroyable is not in its registry rows: it is written after the other components. Its item component is +// in the rows but not made (Entity::Initialize): the model component writes the item info TEST_F(ReplicaDecoderTest, ModelWithTheDestroyableTheRegistryDoesNotList) { info.lot = 6000; Entity model(288300744895900200, info); - model.AddComponent(-1); + auto* const physics = model.AddComponent(-1); + physics->SetPosition(NiPoint3(-12.0f, 432.96f, -49.6f)); model.AddComponent(-1)->LoadBehaviors(); - model.AddComponent(-1); auto* const destroyable = model.AddComponent(-1); destroyable->SetIsSmashable(true); @@ -156,8 +156,10 @@ TEST_F(ReplicaDecoderTest, ModelWithTheDestroyableTheRegistryDoesNotList) { const auto constructed = session.Decode(Construction(model, 3), 1); ASSERT_TRUE(constructed); EXPECT_FALSE(constructed->contains("(layout did not match)")) << constructed->dump(); - EXPECT_EQ(Names(*constructed), "SIMPLE_PHYSICS ITEM MODEL DESTROYABLE "); + EXPECT_EQ(Names(*constructed), "SIMPLE_PHYSICS MODEL DESTROYABLE "); EXPECT_EQ((*FieldsOf(*constructed, "DESTROYABLE"))["smashable"], true); + EXPECT_EQ((*FieldsOf(*constructed, "SIMPLE_PHYSICS"))["position"], json::array({ -12.0f, 432.96f, -49.6f })); + EXPECT_EQ((*FieldsOf(*constructed, "MODEL"))["modelID"], "288300744895900200"); } // When no layout reads the packet exactly, what read is shown with the rest as bits, never a guess @@ -190,3 +192,46 @@ TEST_F(ReplicaDecoderTest, LiveConstructionHeader) { EXPECT_EQ((*constructed)["lot"], 13006); EXPECT_EQ((*constructed)["timeSinceCreatedMs"], 3814335); } + +// A live premade model placed on a property (LOT 12504: simple physics, item, model and render in the registry): its +// config is compressed, and it reads as the server writes a model, with the destroyable after the render +TEST_F(ReplicaDecoderTest, LivePlacedModel) { + const unsigned char live[] = { + 0x24, 0xa1, 0x9b, 0xb6, 0xa7, 0x00, 0x00, 0x00, 0x20, 0x00, 0x02, 0x6c, 0x18, 0x00, 0x00, 0x00, 0x75, 0x00, 0x00, 0x00, + 0x5d, 0x40, 0x00, 0x00, 0x00, 0x62, 0x40, 0x00, 0x00, 0x1b, 0x00, 0x00, 0x00, 0x1e, 0x27, 0x18, 0xd8, 0x58, 0x18, 0x14, + 0x12, 0x59, 0xb2, 0x19, 0xf2, 0x19, 0x4a, 0x00, 0x24, 0x9e, 0x43, 0x2a, 0x50, 0x17, 0x30, 0x84, 0x33, 0xa4, 0x30, 0x64, + 0x02, 0x69, 0x14, 0xc6, 0x5c, 0xa0, 0x1d, 0x0c, 0x64, 0x31, 0x72, 0x02, 0x15, 0x0a, 0x65, 0x02, 0xd5, 0x24, 0xc0, 0x85, + 0x83, 0x58, 0x6a, 0x01, 0x3a, 0x92, 0x19, 0x58, 0x19, 0x00, 0xa2, 0x8a, 0xc5, 0x0c, 0x05, 0x00, 0x59, 0x50, 0x3f, 0x08, + 0xa8, 0x36, 0x92, 0x81, 0x9f, 0xe1, 0x89, 0x21, 0x8b, 0x08, 0x0b, 0xc6, 0x72, 0x3c, 0xd9, 0x1c, 0x18, 0x58, 0x19, 0xc5, + 0x0a, 0xc1, 0x46, 0x80, 0xe4, 0xbd, 0xa1, 0x46, 0xc0, 0x64, 0xb8, 0x2f, 0x18, 0x70, 0x00, 0x01, 0x80, 0x1b, 0xc4, 0xc5, + 0xde, 0x10, 0x63, 0x1c, 0x85, 0xc0, 0x10, 0x00, 0x01, 0x08, 0x00, 0x00, 0x00, 0x00, 0x80, 0x00, 0x00, 0x00, 0x08, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x04, 0x10, 0x00, 0x00, 0x02, 0x00, 0x00, 0x81, 0x83, 0xc2, 0xf5, 0xb0, 0x86, 0xce, 0xcc, 0x8d, 0x84, 0x00, + 0x00, 0x00, 0x01, 0xe6, 0x08, 0x6a, 0x7e, 0x00, 0x00, 0x00, 0x01, 0xe6, 0x08, 0x6a, 0x7f, 0xd8, 0x30, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x40, 0x40, 0x00, 0x00, 0x00, 0x00, 0x08, 0x18, 0x3c, 0x2f, 0x5b, 0x08, 0x6c, + 0xec, 0xc8, 0xd8, 0x5e, 0x60, 0x86, 0xa7, 0xe0, 0x00, 0x00, 0x00, 0x1e, 0x60, 0x86, 0xa7, 0xe0, 0x00, 0x00, 0x00, 0x10, + 0x00, 0x00, 0x00, 0x08, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x01, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x80, 0x3f, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x10, 0x07, 0xe0, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x20, 0x00, 0x00, 0x1f, 0xff, 0xff, 0xff, 0xf0, 0x80, + }; + const ReplicaDecoder::ComponentTable table{ { 12504, { RENDER, SIMPLE_PHYSICS, ITEM, MODEL } } }; + ReplicaDecoder::Session session(table); + const auto constructed = session.Decode(std::string(reinterpret_cast(live), sizeof(live)), 1); + ASSERT_TRUE(constructed); + EXPECT_FALSE(constructed->contains("(layout did not match)")) << constructed->dump(); + EXPECT_EQ((*constructed)["lot"], 12504); + EXPECT_EQ((*constructed)["objectID"], "288300744895909485"); + EXPECT_EQ((*constructed)["spawner"], "1152921510458437894"); + const auto config = (*constructed)["config"].dump(); + EXPECT_NE(config.find("userModelID=9:12504"), std::string::npos) << config; + EXPECT_NE(config.find("propertyObjectID=7:1"), std::string::npos) << config; + EXPECT_EQ(Names(*constructed), "SIMPLE_PHYSICS MODEL RENDER DESTROYABLE "); + const auto& position = (*FieldsOf(*constructed, "SIMPLE_PHYSICS"))["position"]; + EXPECT_FLOAT_EQ(position[0].get(), -12.0f); + EXPECT_NEAR(position[1].get(), 432.96f, 0.001f); + EXPECT_NEAR(position[2].get(), -49.6f, 0.001f); + EXPECT_EQ((*FieldsOf(*constructed, "MODEL"))["modelID"], "12504"); + EXPECT_EQ((*FieldsOf(*constructed, "MODEL"))["behaviors"], 0); +}