From 045999a25226da72c7300ad5e81083b8fafbb319 Mon Sep 17 00:00:00 2001 From: Aaron Kimbrell Date: Wed, 30 Sep 2026 08:40:09 -0500 Subject: [PATCH] feat(capture): a property's models, data and events from a capture CaptureProperty::Build reads, per world of a capture, the property it was: each model (constructed with a model component, or a brick built model) with its LOT, object, spawner, blueprint and behavior count and where it stood per time span, from its constructions, serializations and destruction; the DownloadPropertyData of that map and the GetModelsOnProperty counts; and the events: placed (a PlaceModelResponse with the model made at that position, before or after it), moved, removed (with the DeleteModelFromClient reason), editing, and behavior messages. CaptureTool property --cdserver prints it. Co-Authored-By: Claude Opus 5.5 --- dCaptureTool/CaptureTool.cpp | 29 ++ dGame/dUtilities/CMakeLists.txt | 1 + dGame/dUtilities/CaptureProperty.cpp | 350 ++++++++++++++++++ dGame/dUtilities/CaptureProperty.h | 39 ++ .../dComponentsTests/CMakeLists.txt | 1 + .../dComponentsTests/CapturePropertyTests.cpp | 231 ++++++++++++ 6 files changed, 651 insertions(+) create mode 100644 dGame/dUtilities/CaptureProperty.cpp create mode 100644 dGame/dUtilities/CaptureProperty.h create mode 100644 tests/dGameTests/dComponentsTests/CapturePropertyTests.cpp diff --git a/dCaptureTool/CaptureTool.cpp b/dCaptureTool/CaptureTool.cpp index 9a6186454..b4160b2cb 100644 --- a/dCaptureTool/CaptureTool.cpp +++ b/dCaptureTool/CaptureTool.cpp @@ -16,6 +16,7 @@ #include "CaptureBundle.h" #include "CaptureTools.h" #include "LiveImport.h" +#include "CaptureProperty.h" #include "GameMessageDecoder.h" #include "PacketDecoder.h" #include "CDClientDatabase.h" @@ -59,6 +60,8 @@ namespace { " info what is in a bundle\n" " decode [--fields] [--limit N] [--cdserver ]\n" " its packets on one timeline (replica packets too with --cdserver)\n" + " property --cdserver \n" + " the properties it saw: models over time, property data, events (JSON)\n" " anonymise a test fixture: names and chat replaced, IDs placeholders\n" " import-live convert live captures (every folder of *_traffic.zip under )\n" " replay ... [options] replay against a fresh sandbox stack per bundle and compare\n" @@ -163,6 +166,31 @@ namespace { return 0; } + // The properties a capture saw, as the dashboard's capture viewer shows them (CaptureProperty) + int Property(const std::string& path, const std::string& cdServer) { + CaptureBundle::Bundle bundle; + if (!LoadBundle(path, bundle)) return 1; + CaptureTools::SortTimeline(bundle.records); + ReplicaDecoder::ComponentTable components; + try { + CDClientDatabase::Connect(cdServer); + ReplicaDecoder::LoadComponentTable(components); + } catch (const std::exception& e) { + std::cerr << "Can't read " << cdServer << ": " << e.what() << "\n"; + return 1; + } + ReplicaDecoder::Session replicas(components); + std::vector> replica(bundle.records.size()); + for (size_t i = 0; i < bundle.records.size(); i++) { + const auto& record = bundle.records[i]; + if (record.header.flags & PacketRecordFlags::GAP || CaptureTools::FromClient(record.header)) continue; + replica[i] = replicas.Decode(record.bytes, CaptureTools::ReplicaConnection(record.header)); + } + const auto start = bundle.records.empty() ? 0 : bundle.records.front().header.timeUs; + std::cout << CaptureProperty::Build(bundle.records, start, [&replica](size_t i) { return replica[i] ? &*replica[i] : nullptr; }).dump(2) << "\n"; + return 0; + } + int Anonymise(const std::string& in, const std::string& out) { CaptureBundle::Bundle bundle; if (!LoadBundle(in, bundle)) return 1; @@ -345,6 +373,7 @@ int main(int argc, char** argv) { const auto& command = args[1]; if (command == "info" && args.size() >= 3) return Info(args[2]); if (command == "decode" && args.size() >= 3) return Decode(args[2], Flag(args, "--fields"), std::stoul(Arg(args, "--limit", "1000000")), Arg(args, "--cdserver", "")); + if (command == "property" && args.size() >= 3) return Property(args[2], Arg(args, "--cdserver", "")); if (command == "anonymise" && args.size() >= 4) return Anonymise(args[2], args[3]); if (command == "import-live" && args.size() >= 4) return ImportLive(args[2], args[3]); if (command == "replay") return Replay(args); diff --git a/dGame/dUtilities/CMakeLists.txt b/dGame/dUtilities/CMakeLists.txt index 7b762bae4..c505c7b24 100644 --- a/dGame/dUtilities/CMakeLists.txt +++ b/dGame/dUtilities/CMakeLists.txt @@ -1,5 +1,6 @@ set(DGAME_DUTILITIES_SOURCES "BrickDatabase.cpp" "BrickByBrick.cpp" + "CaptureProperty.cpp" "ChatServerLink.cpp" "CheatDetection.cpp" "DashboardActions.cpp" diff --git a/dGame/dUtilities/CaptureProperty.cpp b/dGame/dUtilities/CaptureProperty.cpp new file mode 100644 index 000000000..df6931ab5 --- /dev/null +++ b/dGame/dUtilities/CaptureProperty.cpp @@ -0,0 +1,350 @@ +#include "CaptureProperty.h" + +#include +#include +#include +#include +#include + +#include "BrickByBrick.h" +#include "CaptureTools.h" +#include "GeneralUtils.h" +#include "MessageIdentifiers.h" +#include "PacketDecoder.h" +#include "PropertyMessages.h" + +namespace { + using json = nlohmann::json; + using Record = CaptureBundle::Record; + + constexpr const char* GAME_MSG = "GAME_MSG "; + + struct Span { + double from{}; + std::optional to; + size_t index{}; + json position; + json rotation; + }; + + struct Model { + LWOOBJID object{}; + LOT lot{}; + std::string spawner; + std::string ugcId; + json behaviors; + std::vector spans; + bool present{}; + bool placed{}; // a placement in the capture made it + }; + + struct Placement { + double t{}; + size_t index{}; + json position; + json rotation; + }; + + struct World { + uint32_t zone{}; + uint32_t instance{}; + uint32_t clone{}; + double t{}; + bool property{}; + json info = json::array(); + json counts = json::array(); + json events = json::array(); + std::vector models; + std::map byObject; // object -> its entry in models (the latest, if it was made again) + std::vector placements; // placed, their model not made yet + std::map> deletes; // model -> the DeleteModelFromClient (record, reason) asking for it + }; + + const json* ComponentFields(const json& fields, const char* name) { + const auto it = fields.find("components"); + if (it == fields.end() || !it->is_array()) return nullptr; + for (const auto& component : *it) { + if (component.value("component", std::string{}) == name) { + const auto found = component.find("fields"); + return found == component.end() ? nullptr : &*found; + } + } + return nullptr; + } + + // Where the object stands: the first component that says (its physics component) + std::optional> Placed(const json& fields) { + const auto it = fields.find("components"); + if (it == fields.end() || !it->is_array()) return std::nullopt; + for (const auto& component : *it) { + const auto found = component.find("fields"); + if (found == component.end() || !found->is_object()) continue; + const auto position = found->find("position"), rotation = found->find("rotation"); + if (position != found->end() && position->is_array() && position->size() == 3 && rotation != found->end() && rotation->is_array() && rotation->size() == 4) { + return std::pair{ *position, *rotation }; + } + } + return std::nullopt; + } + + bool IsModel(const json& fields) { + if (fields.value("lot", 0) == BrickByBrick::MODEL_OBJECT_LOT) return true; + return ComponentFields(fields, "MODEL") && !ComponentFields(fields, "PET"); + } + + // A config entry's value ("key=type:value") + std::string ConfigValue(const json& fields, const std::string& key) { + const auto it = fields.find("config"); + if (it == fields.end() || !it->is_array()) return {}; + for (const auto& entry : *it) { + if (!entry.is_string()) continue; + const auto& text = entry.get_ref(); + if (!text.starts_with(key + "=")) continue; + const auto colon = text.find(':', key.size() + 1); + return colon == std::string::npos ? std::string{} : text.substr(colon + 1); + } + return {}; + } + + LWOOBJID ObjectOf(const json& fields, const char* key) { + const auto it = fields.find(key); + if (it == fields.end()) return LWOOBJID_EMPTY; + if (it->is_string()) return GeneralUtils::TryParse(it->get()).value_or(LWOOBJID_EMPTY); + if (it->is_number_integer()) return it->get(); + return LWOOBJID_EMPTY; + } + + const char* DeleteReason(int32_t reason) { + switch (static_cast(reason)) { + case BrickByBrick::eDeleteReason::PICKING_MODEL_UP: return "picked up"; + case BrickByBrick::eDeleteReason::RETURNING_MODEL_TO_INVENTORY: return "put away"; + case BrickByBrick::eDeleteReason::BREAKING_MODEL_APART: return "taken apart"; + default: return "removed"; + } + } + + // DownloadPropertyData's moderation status, as the client reads it + const char* Moderation(uint32_t status) { + using Data = GameMessages::DownloadPropertyData; + switch (status) { + case Data::REJECTION_STATUS_APPROVED: return "approved"; + case Data::REJECTION_STATUS_PENDING: return "pending"; + case Data::REJECTION_STATUS_REJECTED: return "rejected"; + default: return nullptr; + } + } + + json SpanJson(const Span& span) { + return { {"from", span.from}, {"to", span.to ? json(*span.to) : json(nullptr)}, {"i", span.index}, {"position", span.position}, {"rotation", span.rotation} }; + } + + json EventJson(const char* kind, double t, size_t index, const Model* model) { + json out{ {"kind", kind}, {"t", t}, {"i", index} }; + if (model) { + out["object"] = std::to_string(model->object); + out["lot"] = model->lot; + } + return out; + } + + // A placement the server confirmed, with the model it made + json PlacedJson(const Placement& placement, Model& model) { + model.placed = true; + auto event = EventJson("placed", placement.t, placement.index, &model); + event["position"] = placement.position; + event["rotation"] = placement.rotation; + event["made"] = model.spans.front().index; + return event; + } + + void Close(Model& model, double t) { + if (!model.spans.empty() && !model.spans.back().to) model.spans.back().to = t; + model.present = false; + } +} + +namespace CaptureProperty { + json Build(const std::vector& records, int64_t startUs, const ReplicaFields& replica) { + std::map, World> worlds; + const auto worldOf = [&](const PacketRecordHeader& h, double t) -> World& { + auto [it, added] = worlds.try_emplace({ h.zoneId, h.instanceId, h.cloneId }); + if (added) { + it->second.zone = h.zoneId; + it->second.instance = h.instanceId; + it->second.clone = h.cloneId; + it->second.t = t; + } + return it->second; + }; + + for (size_t i = 0; i < records.size(); i++) { + const auto& record = records[i]; + const auto& h = record.header; + if (h.flags & PacketRecordFlags::GAP || h.source != static_cast(eCaptureSource::WORLD) || record.bytes.empty()) continue; + const double t = static_cast(h.timeUs - startUs) / 1e6; + const bool fromClient = CaptureTools::FromClient(h); + + // Replica packets: the models themselves + const auto id = static_cast(record.bytes[0]); + if (id == ID_REPLICA_MANAGER_CONSTRUCTION || id == ID_REPLICA_MANAGER_SERIALIZE || id == ID_REPLICA_MANAGER_DESTRUCTION) { + const auto* fields = replica ? replica(i) : nullptr; + if (!fields || fromClient) continue; + const auto object = ObjectOf(*fields, "objectID"); + if (!object) continue; + if (id == ID_REPLICA_MANAGER_CONSTRUCTION) { + if (!IsModel(*fields)) continue; + auto& world = worldOf(h, t); + const auto placed = Placed(*fields); + const auto known = world.byObject.find(object); + // Made again for another captured player on the same world: the same model + if (known != world.byObject.end() && world.models[known->second].present) { + auto& model = world.models[known->second]; + if (placed && !model.spans.empty() && model.spans.back().position == placed->first) continue; + Close(model, t); + } + Model model; + model.object = object; + model.lot = fields->value("lot", 0); + if (const auto spawner = fields->find("spawner"); spawner != fields->end() && spawner->is_string()) model.spawner = spawner->get(); + model.ugcId = ConfigValue(*fields, "blueprintid"); + if (const auto* component = ComponentFields(*fields, "MODEL"); component && component->contains("behaviors")) model.behaviors = (*component)["behaviors"]; + model.present = true; + if (placed) model.spans.push_back({ t, std::nullopt, i, placed->first, placed->second }); + world.byObject[object] = world.models.size(); + world.models.push_back(std::move(model)); + // The model a placement made (a live server answered first): the first one made where it said + if (placed) { + for (auto it = world.placements.begin(); it != world.placements.end(); ++it) { + if (it->position != placed->first) continue; + world.events.push_back(PlacedJson(*it, world.models.back())); + world.placements.erase(it); + break; + } + } + continue; + } + const auto found = worlds.find({ h.zoneId, h.instanceId, h.cloneId }); + if (found == worlds.end()) continue; + auto& world = found->second; + const auto known = world.byObject.find(object); + if (known == world.byObject.end() || !world.models[known->second].present) continue; + auto& model = world.models[known->second]; + if (id == ID_REPLICA_MANAGER_DESTRUCTION) { + Close(model, t); + auto event = EventJson("removed", t, i, &model); + const auto asked = world.deletes.find(object); + if (asked != world.deletes.end()) { + event["reason"] = DeleteReason(asked->second.second); + event["asked"] = asked->second.first; + world.deletes.erase(asked); + } + world.events.push_back(std::move(event)); + continue; + } + // Serialization: behaviors, and a new place + if (const auto* component = ComponentFields(*fields, "MODEL"); component && component->contains("behaviors")) model.behaviors = (*component)["behaviors"]; + const auto placed = Placed(*fields); + if (!placed || (!model.spans.empty() && model.spans.back().position == placed->first && model.spans.back().rotation == placed->second)) continue; + if (!model.spans.empty()) model.spans.back().to = t; + model.spans.push_back({ t, std::nullopt, i, placed->first, placed->second }); + auto event = EventJson("moved", t, i, &model); + event["position"] = placed->first; + world.events.push_back(std::move(event)); + continue; + } + + // Game messages about the property + const auto decoded = PacketDecoder::Decode(record.bytes, fromClient); + if (decoded.gameMessageId < 0 || !decoded.name.starts_with(GAME_MSG)) continue; + const auto name = decoded.name.substr(std::char_traits::length(GAME_MSG)); + const json empty = json::object(); + const auto& fields = decoded.fields ? *decoded.fields : empty; + const bool behavior = name.find("BEHAVIOR") != std::string::npos; + // A live server also sent the data of the player's properties elsewhere (another map): only this world's + // is about it (any, when the capture doesn't know the world's zone) + const bool otherProperty = name == "DOWNLOAD_PROPERTY_DATA" && h.zoneId && fields.value("mapId", 0) != h.zoneId; + const bool propertyMessage = !otherProperty && (name == "DOWNLOAD_PROPERTY_DATA" || name == "GET_MODELS_ON_PROPERTY" || + name == "PLACE_MODEL_RESPONSE" || name == "DELETE_MODEL_FROM_CLIENT" || name == "PROPERTY_EDITOR_BEGIN" || name == "PROPERTY_EDITOR_END"); + const auto existing = worlds.find({ h.zoneId, h.instanceId, h.cloneId }); + if (!propertyMessage && !behavior && existing == worlds.end()) continue; + auto& world = existing != worlds.end() ? existing->second : worldOf(h, t); + if (propertyMessage) world.property = true; + + if (name == "DOWNLOAD_PROPERTY_DATA" && !fromClient && decoded.fields && !otherProperty) { + json info{ {"t", t}, {"i", i} }; + for (const char* key : { "propertyId", "name", "description", "ownerName", "ownerId", "accessType", "moderationStatus", + "rejectionReason", "reputation", "templateId", "mapId", "cloneId", "maxBuildHeight" }) { + if (fields.contains(key)) info[key] = fields[key]; + } + if (const auto* moderation = Moderation(fields.value("moderationStatus", UINT32_MAX))) info["moderation"] = moderation; + // Sent again (to each player, on each edit): kept once while it says the same + auto said = info; + said.erase("t"); + said.erase("i"); + if (!world.info.empty()) { + auto before = world.info.back(); + before.erase("t"); + before.erase("i"); + if (before == said) continue; + } + world.info.push_back(std::move(info)); + } else if (name == "GET_MODELS_ON_PROPERTY" && !fromClient && fields.contains("models") && fields["models"].is_array()) { + // Each captured player on the world gets one: count it once while it says the same + const auto count = fields["models"].size(); + if (world.counts.empty() || world.counts.back()["count"] != count) world.counts.push_back({ {"t", t}, {"i", i}, {"count", count} }); + } else if (name == "PLACE_MODEL_RESPONSE" && !fromClient && fields.value("response", 0) == BrickByBrick::PLACE_MODEL_PLACED) { + Placement placement{ t, i, fields.value("position", json::array()), fields.value("rotation", json::array()) }; + // The model it made: this server made it before answering (the latest one made there not placed yet), or + // it comes after + auto made = std::find_if(world.models.rbegin(), world.models.rend(), [&placement](const Model& model) { + return model.present && !model.placed && !model.spans.empty() && model.spans.front().position == placement.position; + }); + if (made != world.models.rend()) world.events.push_back(PlacedJson(placement, *made)); + else world.placements.push_back(std::move(placement)); + } else if (name == "DELETE_MODEL_FROM_CLIENT" && fromClient && decoded.fields) { + const auto model = ObjectOf(fields, "modelID"); + if (model) world.deletes[model] = { i, fields.value("reason", 0) }; + } else if (name == "PROPERTY_EDITOR_BEGIN" || name == "PROPERTY_EDITOR_END") { + world.events.push_back(EventJson(name == "PROPERTY_EDITOR_BEGIN" ? "editing" : "stopped editing", t, i, nullptr)); + } + + // Behaviors: the behavior messages, and what was sent to or from a model while it stood there + const Model* model = nullptr; + auto target = decoded.objectId; + if (name == "CONTROL_BEHAVIORS" && fields.contains("args") && fields["args"].is_object()) { + const auto fromArgs = ObjectOf(fields["args"], "objectID"); + if (fromArgs) target = fromArgs; + } + if (const auto it = world.byObject.find(target); it != world.byObject.end() && world.models[it->second].present) model = &world.models[it->second]; + if (!behavior && !model) continue; + auto event = EventJson("behavior", t, i, model); + event["message"] = name; + event["toServer"] = fromClient; + if (name == "CONTROL_BEHAVIORS" && fields.contains("command")) event["command"] = fields["command"]; + world.events.push_back(std::move(event)); + } + + json out = json::array(); + for (auto& [key, world] : worlds) { + if (!world.property && world.models.empty()) continue; + json models = json::array(); + for (const auto& model : world.models) { + json spans = json::array(); + for (const auto& span : model.spans) spans.push_back(SpanJson(span)); + models.push_back({ {"object", std::to_string(model.object)}, {"lot", model.lot}, {"spawner", model.spawner}, + {"ugcId", model.ugcId}, {"behaviors", model.behaviors}, {"spans", spans} }); + } + // Placements whose model was never seen made (made before the capture could read it, or refused later) + for (const auto& placement : world.placements) { + auto event = EventJson("placed", placement.t, placement.index, nullptr); + event["position"] = placement.position; + event["rotation"] = placement.rotation; + world.events.push_back(std::move(event)); + } + std::stable_sort(world.events.begin(), world.events.end(), [](const json& a, const json& b) { return a["i"].get() < b["i"].get(); }); + out.push_back({ {"zone", world.zone}, {"instance", world.instance}, {"clone", world.clone}, {"t", world.t}, {"property", world.property}, + {"info", world.info}, {"counts", world.counts}, {"models", models}, {"events", world.events} }); + } + return { {"worlds", out} }; + } +} diff --git a/dGame/dUtilities/CaptureProperty.h b/dGame/dUtilities/CaptureProperty.h new file mode 100644 index 000000000..ce70091b3 --- /dev/null +++ b/dGame/dUtilities/CaptureProperty.h @@ -0,0 +1,39 @@ +#ifndef __CAPTUREPROPERTY__H__ +#define __CAPTUREPROPERTY__H__ + +#include +#include +#include + +#include "CaptureBundle.h" +#include "json.hpp" + +/** + * A property as a packet capture saw it, for the capture viewer and the World 3D replay: per world (zone, instance + * and clone) the models placed on it over time and what the property said about itself. + * + * Everything comes from the captured packets: + * - models: the replica constructions, serializations and destructions of objects with a model component (and brick + * built models, LOT 14), each with its position and rotation per time span (from its physics component); + * - placed: the server's PlaceModelResponse saying a model was placed, with the model that was then made at that + * position; removed: a model's destruction, with the reason from the DeleteModelFromClient that asked for it; + * moved: a model's serialization with a new position; + * - info: each DownloadPropertyData (owner, name, description, access, moderation, reputation) and each + * GetModelsOnProperty (how many models the property had); + * - behaviors: the behavior messages (ControlBehaviors and the others named for behaviors) and the game messages + * sent to or from a model while it stood there. + * A world is listed when a property message was captured on it or a model was constructed there. + */ +namespace CaptureProperty { + // The fields ReplicaDecoder read from record `index` (nullptr when it isn't a replica packet) + using ReplicaFields = std::function; + + /** + * {worlds: [{zone, instance, clone, t, info: [...], counts: [...], models: [...], events: [...]}]}; times are seconds + * from `startUs`, `i` the record's index. A model: {object, lot, spawner, ugcId, behaviors, spans: [{from, to, i, + * position, rotation}]} (`to` null: still there when the capture ended). + */ + nlohmann::json Build(const std::vector& records, int64_t startUs, const ReplicaFields& replica); +} + +#endif //!__CAPTUREPROPERTY__H__ diff --git a/tests/dGameTests/dComponentsTests/CMakeLists.txt b/tests/dGameTests/dComponentsTests/CMakeLists.txt index 103739781..5baeb6486 100644 --- a/tests/dGameTests/dComponentsTests/CMakeLists.txt +++ b/tests/dGameTests/dComponentsTests/CMakeLists.txt @@ -18,6 +18,7 @@ set(DCOMPONENTS_TESTS "RocketLaunchTests.cpp" "ReplicaConstructionTests.cpp" "ReplicaDecoderTests.cpp" + "CapturePropertyTests.cpp" "ItemRemovalTests.cpp" "LootMetricsTests.cpp" ) diff --git a/tests/dGameTests/dComponentsTests/CapturePropertyTests.cpp b/tests/dGameTests/dComponentsTests/CapturePropertyTests.cpp new file mode 100644 index 000000000..bd9887ae4 --- /dev/null +++ b/tests/dGameTests/dComponentsTests/CapturePropertyTests.cpp @@ -0,0 +1,231 @@ +// A property as a packet capture saw it (CaptureProperty): a synthetic capture made from the server's own writers (a +// brick built model and its replica packets, and the property's game messages as the server and client send them), +// read back into the models' timeline, the property's info and the events the capture page lists. +#include "GameDependencies.h" +#include + +#include "BitStream.h" +#include "BrickByBrick.h" +#include "CaptureBundle.h" +#include "CaptureProperty.h" +#include "CaptureTools.h" +#include "DestroyableComponent.h" +#include "Entity.h" +#include "GameMessageDecoder.h" +#include "MessageIdentifiers.h" +#include "MessageType/Client.h" +#include "MessageType/World.h" +#include "ModelComponent.h" +#include "PacketDecoder.h" +#include "PropertyMessages.h" +#include "ReplicaDecoder.h" +#include "ServiceType.h" +#include "SimplePhysicsComponent.h" +#include "eReplicaComponentType.h" +#include "eReplicaPacketType.h" + +namespace { + using json = nlohmann::json; + using enum eReplicaComponentType; + + constexpr LWOOBJID PLAYER = 1152921506064087003; + constexpr LWOOBJID FIRST = 288300744895900001, SECOND = 288300744895900002, THIRD = 288300744895900003; + + std::string Bytes(const RakNet::BitStream& stream) { return std::string(reinterpret_cast(stream.GetData()), stream.GetNumberOfBytesUsed()); } + + std::string Construction(Entity& entity, uint16_t network) { + RakNet::BitStream stream; + stream.Write(ID_REPLICA_MANAGER_CONSTRUCTION); + stream.Write1(); + stream.Write(network); + entity.WriteBaseReplicaData(stream, eReplicaPacketType::CONSTRUCTION); + entity.WriteComponents(stream, eReplicaPacketType::CONSTRUCTION); + return Bytes(stream); + } + + std::string Serialization(Entity& entity, uint16_t network) { + RakNet::BitStream stream; + stream.Write(ID_REPLICA_MANAGER_SERIALIZE); + stream.Write(network); + entity.WriteBaseReplicaData(stream, eReplicaPacketType::SERIALIZATION); + entity.WriteComponents(stream, eReplicaPacketType::SERIALIZATION); + return Bytes(stream); + } + + std::string Destruction(uint16_t network) { + RakNet::BitStream stream; + stream.Write(ID_REPLICA_MANAGER_DESTRUCTION); + stream.Write(network); + return Bytes(stream); + } + + // A game message as the server sends it (CLIENT GAME_MSG) or the client does (WORLD GAME_MSG) + std::string GameMessage(bool toServer, LWOOBJID object, const GameMessages::NetGameMsg& message) { + RakNet::BitStream stream; + if (toServer) LUBitStream(ServiceType::WORLD, MessageType::World::GAME_MSG).WriteHeader(stream); + else LUBitStream(ServiceType::CLIENT, MessageType::Client::GAME_MSG).WriteHeader(stream); + stream.Write(object); + stream.Write(message.msgId); + message.Serialize(stream); + return Bytes(stream); + } + + struct Capture { + std::vector records; + + // One world packet, a second after the one before + void Add(bool fromClient, std::string bytes) { + CaptureBundle::Record record; + record.header.seq = static_cast(records.size()); + record.header.timeUs = 1000000 * static_cast(records.size() + 1); + record.header.source = static_cast(eCaptureSource::WORLD); + record.header.direction = static_cast(fromClient ? ePacketDirection::RECEIVED : ePacketDirection::SENT); + record.header.zoneId = 1150; + record.header.instanceId = 7; + record.header.cloneId = 42; + record.header.characterId = PLAYER; + record.header.bits = static_cast(bytes.size() * 8); + record.bytes = std::move(bytes); + records.push_back(std::move(record)); + } + }; + + GameMessages::DownloadPropertyData PropertyData(const std::u16string& name, uint64_t reputation) { + GameMessages::DownloadPropertyData data; + data.propertyId = 1234; + data.mapId = 1150; + data.name = name; + data.description = u"A garden"; + data.ownerName = u"Builder"; + data.ownerId = PLAYER; + data.reputation = reputation; + data.accessType = 2; + data.moderationStatus = 1; + return data; + } +} + +class CapturePropertyTest : public GameDependenciesTest { +protected: + void SetUp() override { + SetUpDependencies(); + PacketDecoder::SetGameMessageDecoder([](MessageType::Game id, bool toServer, RakNet::BitStream& payload) { + return GameMessageDecoder::Decode(id, toServer, payload); + }); + } + void TearDown() override { TearDownDependencies(); } + + std::unique_ptr MakeModel(LWOOBJID id, const NiPoint3& position, LWOOBJID blueprint) { + info.lot = BrickByBrick::MODEL_OBJECT_LOT; + info.settings = {}; + info.settings.Insert(u"blueprintid", blueprint); + auto model = std::make_unique(id, info); + model->AddComponent(-1)->SetPosition(position); + model->AddComponent(-1)->LoadBehaviors(); + model->AddComponent(-1)->SetIsSmashable(true); + return model; + } +}; + +TEST_F(CapturePropertyTest, ModelsInfoAndEventsOverTime) { + Capture capture; + const NiPoint3 a(1.0f, 2.0f, 3.0f), b(10.0f, 2.0f, -4.0f), c(5.0f, 2.0f, 5.0f); + auto first = MakeModel(FIRST, a, 5551); + auto second = MakeModel(SECOND, b, 5552); + + capture.Add(false, GameMessage(false, PLAYER, PropertyData(u"Old name", 10))); // 0: the property as it loads + capture.Add(false, Construction(*first, 20)); // 1: a model already there + GameMessages::PlaceModelResponse placed; + placed.position = b; + placed.response = BrickByBrick::PLACE_MODEL_PLACED; + capture.Add(false, GameMessage(false, PLAYER, placed)); // 2: the server placed one... + capture.Add(false, Construction(*second, 21)); // 3: ...and made it + first->GetComponent()->SetPosition(c); + capture.Add(false, Serialization(*first, 20)); // 4: the first one moves + GameMessages::PlayBehaviorSound sound; + sound.soundID = 3; + capture.Add(false, GameMessage(false, FIRST, sound)); // 5: its behavior plays a sound + GameMessages::DeleteModelFromClient pickUp; + pickUp.modelID = SECOND; + pickUp.reason = static_cast(BrickByBrick::eDeleteReason::PICKING_MODEL_UP); + capture.Add(true, GameMessage(true, PLAYER, pickUp)); // 6: the player picks the second up + capture.Add(false, Destruction(21)); // 7: and it goes + capture.Add(false, GameMessage(false, PLAYER, PropertyData(u"New name", 25))); // 8: renamed + auto elsewhere = PropertyData(u"Another property", 5); + elsewhere.mapId = 1250; + capture.Add(false, GameMessage(false, PLAYER, elsewhere)); // 9: the player's property on another map + capture.Add(false, GameMessage(false, PLAYER, PropertyData(u"New name", 25))); // 10: the same data again + // This server makes the model before it answers the placement + const NiPoint3 d(-8.8f, 455.04f, 126.4f); + auto third = MakeModel(THIRD, d, 5553); + capture.Add(false, Construction(*third, 22)); // 11: made... + placed.position = d; + capture.Add(false, GameMessage(false, PLAYER, placed)); // 12: ...then placed + + const ReplicaDecoder::ComponentTable table{ { BrickByBrick::MODEL_OBJECT_LOT, {} } }; + ReplicaDecoder::Session session(table); + std::vector> replica(capture.records.size()); + for (size_t i = 0; i < capture.records.size(); i++) replica[i] = session.Decode(capture.records[i].bytes, CaptureTools::ReplicaConnection(capture.records[i].header)); + const auto out = CaptureProperty::Build(capture.records, 0, [&](size_t i) { return replica[i] ? &*replica[i] : nullptr; }); + + ASSERT_EQ(out["worlds"].size(), 1u) << out.dump(); + const auto& world = out["worlds"][0]; + EXPECT_EQ(world["zone"], 1150); + EXPECT_EQ(world["instance"], 7); + EXPECT_EQ(world["clone"], 42); + + // The property as each DownloadPropertyData said it, leaving out the one about another map + ASSERT_EQ(world["info"].size(), 2u); + EXPECT_EQ(world["info"][0]["name"], "Old name"); + EXPECT_EQ(world["info"][0]["ownerName"], "Builder"); + EXPECT_EQ(world["info"][0]["reputation"], "10"); + EXPECT_EQ(world["info"][0]["i"], 0); + EXPECT_EQ(world["info"][1]["name"], "New name"); + EXPECT_EQ(world["info"][1]["reputation"], "25"); + + // The models: where each stood and when + ASSERT_EQ(world["models"].size(), 3u); + const auto& m1 = world["models"][0]; + EXPECT_EQ(m1["object"], std::to_string(FIRST)); + EXPECT_EQ(m1["lot"], BrickByBrick::MODEL_OBJECT_LOT); + EXPECT_EQ(m1["ugcId"], "5551"); + ASSERT_EQ(m1["spans"].size(), 2u) << m1.dump(); + EXPECT_EQ(m1["spans"][0]["from"], 2.0); + EXPECT_EQ(m1["spans"][0]["to"], 5.0); + EXPECT_EQ(m1["spans"][0]["position"], json::array({ 1.0f, 2.0f, 3.0f })); + EXPECT_EQ(m1["spans"][1]["from"], 5.0); + EXPECT_TRUE(m1["spans"][1]["to"].is_null()); + EXPECT_EQ(m1["spans"][1]["position"], json::array({ 5.0f, 2.0f, 5.0f })); + const auto& m2 = world["models"][1]; + EXPECT_EQ(m2["ugcId"], "5552"); + ASSERT_EQ(m2["spans"].size(), 1u); + EXPECT_EQ(m2["spans"][0]["from"], 4.0); + EXPECT_EQ(m2["spans"][0]["to"], 8.0); + + // What the capture page lists: placed (the server's answer, with the model it made), moved, the behavior's + // message and removed (with the reason the client gave) + std::vector kinds; + for (const auto& event : world["events"]) kinds.push_back(event["kind"]); + EXPECT_EQ(kinds, (std::vector{ "placed", "moved", "behavior", "removed", "placed" })) << world["events"].dump(); + EXPECT_EQ(world["events"][0]["i"], 2); + EXPECT_EQ(world["events"][0]["object"], std::to_string(SECOND)); + EXPECT_EQ(world["events"][0]["made"], 3); + EXPECT_EQ(world["events"][1]["object"], std::to_string(FIRST)); + EXPECT_EQ(world["events"][2]["message"], "PLAY_BEHAVIOR_SOUND"); + EXPECT_EQ(world["events"][2]["object"], std::to_string(FIRST)); + EXPECT_EQ(world["events"][3]["object"], std::to_string(SECOND)); + EXPECT_EQ(world["events"][3]["reason"], "picked up"); + EXPECT_EQ(world["events"][3]["asked"], 6); + EXPECT_EQ(world["events"][4]["i"], 12); + EXPECT_EQ(world["events"][4]["object"], std::to_string(THIRD)); + EXPECT_EQ(world["events"][4]["made"], 11); +} + +// A world with no property message and no model isn't a property +TEST_F(CapturePropertyTest, OtherWorldsAreLeftOut) { + Capture capture; + GameMessages::PlayBehaviorSound sound; + capture.Add(false, GameMessage(false, PLAYER, sound)); + const auto out = CaptureProperty::Build(capture.records, 0, [](size_t) { return nullptr; }); + EXPECT_TRUE(out["worlds"].empty()) << out.dump(); +}