mirror of
https://github.com/DarkflameUniverse/DarkflameServer.git
synced 2026-10-02 02:43:44 +00:00
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:
@@ -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);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
set(DGAME_DUTILITIES_SOURCES "BrickDatabase.cpp"
|
||||
"BrickByBrick.cpp"
|
||||
"CaptureProperty.cpp"
|
||||
"ChatServerLink.cpp"
|
||||
"CheatDetection.cpp"
|
||||
"DashboardActions.cpp"
|
||||
|
||||
350
dGame/dUtilities/CaptureProperty.cpp
Normal file
350
dGame/dUtilities/CaptureProperty.cpp
Normal 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} };
|
||||
}
|
||||
}
|
||||
39
dGame/dUtilities/CaptureProperty.h
Normal file
39
dGame/dUtilities/CaptureProperty.h
Normal 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__
|
||||
@@ -18,6 +18,7 @@ set(DCOMPONENTS_TESTS
|
||||
"RocketLaunchTests.cpp"
|
||||
"ReplicaConstructionTests.cpp"
|
||||
"ReplicaDecoderTests.cpp"
|
||||
"CapturePropertyTests.cpp"
|
||||
"ItemRemovalTests.cpp"
|
||||
"LootMetricsTests.cpp"
|
||||
)
|
||||
|
||||
231
tests/dGameTests/dComponentsTests/CapturePropertyTests.cpp
Normal file
231
tests/dGameTests/dComponentsTests/CapturePropertyTests.cpp
Normal 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();
|
||||
}
|
||||
Reference in New Issue
Block a user