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 <bundle> --cdserver prints it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Aaron Kimbrell
2026-09-30 08:40:09 -05:00
parent e043a8f6b5
commit 045999a252
6 changed files with 651 additions and 0 deletions

View File

@@ -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 <bundle> what is in a bundle\n"
" decode <bundle> [--fields] [--limit N] [--cdserver <CDServer.sqlite>]\n"
" its packets on one timeline (replica packets too with --cdserver)\n"
" property <bundle> --cdserver <CDServer.sqlite>\n"
" the properties it saw: models over time, property data, events (JSON)\n"
" anonymise <in> <out> a test fixture: names and chat replaced, IDs placeholders\n"
" import-live <folder> <out-dir> convert live captures (every folder of *_traffic.zip under <folder>)\n"
" replay <bundle>... [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<std::optional<json>> 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);

View File

@@ -1,5 +1,6 @@
set(DGAME_DUTILITIES_SOURCES "BrickDatabase.cpp"
"BrickByBrick.cpp"
"CaptureProperty.cpp"
"ChatServerLink.cpp"
"CheatDetection.cpp"
"DashboardActions.cpp"

View File

@@ -0,0 +1,350 @@
#include "CaptureProperty.h"
#include <algorithm>
#include <map>
#include <optional>
#include <string>
#include <tuple>
#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<double> to;
size_t index{};
json position;
json rotation;
};
struct Model {
LWOOBJID object{};
LOT lot{};
std::string spawner;
std::string ugcId;
json behaviors;
std::vector<Span> 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<Model> models;
std::map<LWOOBJID, size_t> byObject; // object -> its entry in models (the latest, if it was made again)
std::vector<Placement> placements; // placed, their model not made yet
std::map<LWOOBJID, std::pair<size_t, int32_t>> 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<std::pair<json, json>> 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<const std::string&>();
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<LWOOBJID>(it->get<std::string>()).value_or(LWOOBJID_EMPTY);
if (it->is_number_integer()) return it->get<LWOOBJID>();
return LWOOBJID_EMPTY;
}
const char* DeleteReason(int32_t reason) {
switch (static_cast<BrickByBrick::eDeleteReason>(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<Record>& records, int64_t startUs, const ReplicaFields& replica) {
std::map<std::tuple<uint32_t, uint32_t, uint32_t>, 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<uint8_t>(eCaptureSource::WORLD) || record.bytes.empty()) continue;
const double t = static_cast<double>(h.timeUs - startUs) / 1e6;
const bool fromClient = CaptureTools::FromClient(h);
// Replica packets: the models themselves
const auto id = static_cast<uint8_t>(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<std::string>();
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<char>::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<size_t>() < b["i"].get<size_t>(); });
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} };
}
}

View File

@@ -0,0 +1,39 @@
#ifndef __CAPTUREPROPERTY__H__
#define __CAPTUREPROPERTY__H__
#include <cstdint>
#include <functional>
#include <vector>
#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<const nlohmann::json*(size_t index)>;
/**
* {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<CaptureBundle::Record>& records, int64_t startUs, const ReplicaFields& replica);
}
#endif //!__CAPTUREPROPERTY__H__

View File

@@ -18,6 +18,7 @@ set(DCOMPONENTS_TESTS
"RocketLaunchTests.cpp"
"ReplicaConstructionTests.cpp"
"ReplicaDecoderTests.cpp"
"CapturePropertyTests.cpp"
"ItemRemovalTests.cpp"
"LootMetricsTests.cpp"
)

View File

@@ -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 <gtest/gtest.h>
#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<const char*>(stream.GetData()), stream.GetNumberOfBytesUsed()); }
std::string Construction(Entity& entity, uint16_t network) {
RakNet::BitStream stream;
stream.Write<uint8_t>(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<uint8_t>(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<uint8_t>(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<CaptureBundle::Record> 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<uint32_t>(records.size());
record.header.timeUs = 1000000 * static_cast<int64_t>(records.size() + 1);
record.header.source = static_cast<uint8_t>(eCaptureSource::WORLD);
record.header.direction = static_cast<uint8_t>(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<uint32_t>(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<Entity> MakeModel(LWOOBJID id, const NiPoint3& position, LWOOBJID blueprint) {
info.lot = BrickByBrick::MODEL_OBJECT_LOT;
info.settings = {};
info.settings.Insert<LWOOBJID>(u"blueprintid", blueprint);
auto model = std::make_unique<Entity>(id, info);
model->AddComponent<SimplePhysicsComponent>(-1)->SetPosition(position);
model->AddComponent<ModelComponent>(-1)->LoadBehaviors();
model->AddComponent<DestroyableComponent>(-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<SimplePhysicsComponent>()->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<int32_t>(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<std::optional<json>> 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<std::string> kinds;
for (const auto& event : world["events"]) kinds.push_back(event["kind"]);
EXPECT_EQ(kinds, (std::vector<std::string>{ "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();
}