From e043a8f6b5f92d31dc53085408a9467c8580c857 Mon Sep 17 00:00:00 2001 From: Aaron Kimbrell Date: Wed, 30 Sep 2026 08:40:03 -0500 Subject: [PATCH 1/7] fix(capture): read compressed configs and placed models in replica packets A compressed construction config is a u32 uncompressed size, a u32 compressed size and the zlib bytes: the reader took the first size for the second and lost its place, so live model constructions never read. It now inflates and reads the entries. A model's item component isn't made (Entity::Initialize): its model component writes the item info. The layout left the registry's item component in, so a premade model's components read one block twice and didn't match. Live writes the same bits from its item component, so both now read exactly. Tested with a live placed model's construction and the server's own model. Co-Authored-By: Claude Opus 5.5 --- dGame/dUtilities/ReplicaDecoder.cpp | 31 ++++++++--- .../dComponentsTests/ReplicaDecoderTests.cpp | 55 +++++++++++++++++-- 2 files changed, 74 insertions(+), 12 deletions(-) diff --git a/dGame/dUtilities/ReplicaDecoder.cpp b/dGame/dUtilities/ReplicaDecoder.cpp index 93b6024d4..4b68a2917 100644 --- a/dGame/dUtilities/ReplicaDecoder.cpp +++ b/dGame/dUtilities/ReplicaDecoder.cpp @@ -11,6 +11,7 @@ #include "GeneralUtils.h" #include "magic_enum.hpp" #include "MessageIdentifiers.h" +#include "ZCompression.h" namespace { using json = nlohmann::json; @@ -62,6 +63,9 @@ namespace { return name.empty() ? json(value) : json(std::string(name) + " (" + std::to_string(value) + ")"); } + // Larger compressed LDF is not inflated (a construction's config is a few hundred bytes) + constexpr uint32_t MAX_LDF_BYTES = 1024 * 1024; + // LDF entries as the client reads them: "key=type:value" json ReadLdfEntries(Reader& r, int32_t count) { json out = json::array(); @@ -88,16 +92,26 @@ namespace { return out; } - // u32 size, u8 compressed, then the entries (or the compressed bytes, which are shown as their size) + // u32 size, u8 compressed, then the entries; compressed: u32 uncompressed size, u32 compressed size and the zlib + // bytes of the entries (the count, then each entry) json ReadLdf(Reader& r) { const auto size = r.Get(); const auto compressed = r.Get(); - if (compressed) { - const auto compressedSize = r.Get(); - for (uint32_t i = 0; r.ok && i < compressedSize; i++) r.Get(); - return json{ {"compressed", true}, {"size", size}, {"compressedSize", compressedSize} }; - } - return ReadLdfEntries(r, r.Get()); + if (!compressed) return ReadLdfEntries(r, r.Get()); + const auto uncompressedSize = r.Get(); + const auto compressedSize = r.Get(); + std::vector bytes; + for (uint32_t i = 0; r.ok && i < compressedSize; i++) bytes.push_back(r.Get()); + json shown{ {"compressed", true}, {"size", size}, {"uncompressedSize", uncompressedSize}, {"compressedSize", compressedSize} }; + if (!r.ok || uncompressedSize > MAX_LDF_BYTES) return shown; + std::vector entries(uncompressedSize); + int32_t error{}; + const auto read = ZCompression::Decompress(bytes.data(), compressedSize, entries.data(), uncompressedSize, error); + if (read != static_cast(uncompressedSize)) return shown; + RakNet::BitStream inflated(entries.data(), uncompressedSize, false); + Reader inner{ inflated }; + auto out = ReadLdfEntries(inner, inner.Get()); + return inner.ok ? out : shown; } // Activity user info: object ID and 10 values each @@ -594,6 +608,9 @@ namespace { if (has.contains(DESTROYABLE)) has.insert(BUFF); if (has.contains(CHARACTER)) has.insert({ POSSESSOR, LEVEL_PROGRESSION, PLAYER_FORCED_MOVEMENT }); if (has.contains(PET)) has.erase(MODEL); + // A model's item info is written by its model component (live: by its item component, the same bits in the + // same place), so the item component isn't made for it + if (has.contains(MODEL)) has.erase(ITEM); // Collectibles get one; a quick build without one writes the same empty bits itself in the same place const bool destroyable = has.contains(DESTROYABLE) || has.contains(COLLECTIBLE) || has.contains(QUICK_BUILD) || extraDestroyable; eReplicaComponentType slot = MINI_GAME_CONTROL; diff --git a/tests/dGameTests/dComponentsTests/ReplicaDecoderTests.cpp b/tests/dGameTests/dComponentsTests/ReplicaDecoderTests.cpp index 3f23f5959..9463ecaf1 100644 --- a/tests/dGameTests/dComponentsTests/ReplicaDecoderTests.cpp +++ b/tests/dGameTests/dComponentsTests/ReplicaDecoderTests.cpp @@ -12,7 +12,6 @@ #include "DestroyableComponent.h" #include "Entity.h" #include "InventoryComponent.h" -#include "ItemComponent.h" #include "LevelProgressionComponent.h" #include "MessageIdentifiers.h" #include "ModelComponent.h" @@ -141,13 +140,14 @@ TEST_F(ReplicaDecoderTest, PlayerConstructionReadsTheCharacterParts) { EXPECT_EQ((*FieldsOf(*constructed, "CHARACTER"))["statistics"].size(), 27u); } -// A model's destroyable is not in its registry rows: it is written after the other components +// A model's destroyable is not in its registry rows: it is written after the other components. Its item component is +// in the rows but not made (Entity::Initialize): the model component writes the item info TEST_F(ReplicaDecoderTest, ModelWithTheDestroyableTheRegistryDoesNotList) { info.lot = 6000; Entity model(288300744895900200, info); - model.AddComponent(-1); + auto* const physics = model.AddComponent(-1); + physics->SetPosition(NiPoint3(-12.0f, 432.96f, -49.6f)); model.AddComponent(-1)->LoadBehaviors(); - model.AddComponent(-1); auto* const destroyable = model.AddComponent(-1); destroyable->SetIsSmashable(true); @@ -156,8 +156,10 @@ TEST_F(ReplicaDecoderTest, ModelWithTheDestroyableTheRegistryDoesNotList) { const auto constructed = session.Decode(Construction(model, 3), 1); ASSERT_TRUE(constructed); EXPECT_FALSE(constructed->contains("(layout did not match)")) << constructed->dump(); - EXPECT_EQ(Names(*constructed), "SIMPLE_PHYSICS ITEM MODEL DESTROYABLE "); + EXPECT_EQ(Names(*constructed), "SIMPLE_PHYSICS MODEL DESTROYABLE "); EXPECT_EQ((*FieldsOf(*constructed, "DESTROYABLE"))["smashable"], true); + EXPECT_EQ((*FieldsOf(*constructed, "SIMPLE_PHYSICS"))["position"], json::array({ -12.0f, 432.96f, -49.6f })); + EXPECT_EQ((*FieldsOf(*constructed, "MODEL"))["modelID"], "288300744895900200"); } // When no layout reads the packet exactly, what read is shown with the rest as bits, never a guess @@ -190,3 +192,46 @@ TEST_F(ReplicaDecoderTest, LiveConstructionHeader) { EXPECT_EQ((*constructed)["lot"], 13006); EXPECT_EQ((*constructed)["timeSinceCreatedMs"], 3814335); } + +// A live premade model placed on a property (LOT 12504: simple physics, item, model and render in the registry): its +// config is compressed, and it reads as the server writes a model, with the destroyable after the render +TEST_F(ReplicaDecoderTest, LivePlacedModel) { + const unsigned char live[] = { + 0x24, 0xa1, 0x9b, 0xb6, 0xa7, 0x00, 0x00, 0x00, 0x20, 0x00, 0x02, 0x6c, 0x18, 0x00, 0x00, 0x00, 0x75, 0x00, 0x00, 0x00, + 0x5d, 0x40, 0x00, 0x00, 0x00, 0x62, 0x40, 0x00, 0x00, 0x1b, 0x00, 0x00, 0x00, 0x1e, 0x27, 0x18, 0xd8, 0x58, 0x18, 0x14, + 0x12, 0x59, 0xb2, 0x19, 0xf2, 0x19, 0x4a, 0x00, 0x24, 0x9e, 0x43, 0x2a, 0x50, 0x17, 0x30, 0x84, 0x33, 0xa4, 0x30, 0x64, + 0x02, 0x69, 0x14, 0xc6, 0x5c, 0xa0, 0x1d, 0x0c, 0x64, 0x31, 0x72, 0x02, 0x15, 0x0a, 0x65, 0x02, 0xd5, 0x24, 0xc0, 0x85, + 0x83, 0x58, 0x6a, 0x01, 0x3a, 0x92, 0x19, 0x58, 0x19, 0x00, 0xa2, 0x8a, 0xc5, 0x0c, 0x05, 0x00, 0x59, 0x50, 0x3f, 0x08, + 0xa8, 0x36, 0x92, 0x81, 0x9f, 0xe1, 0x89, 0x21, 0x8b, 0x08, 0x0b, 0xc6, 0x72, 0x3c, 0xd9, 0x1c, 0x18, 0x58, 0x19, 0xc5, + 0x0a, 0xc1, 0x46, 0x80, 0xe4, 0xbd, 0xa1, 0x46, 0xc0, 0x64, 0xb8, 0x2f, 0x18, 0x70, 0x00, 0x01, 0x80, 0x1b, 0xc4, 0xc5, + 0xde, 0x10, 0x63, 0x1c, 0x85, 0xc0, 0x10, 0x00, 0x01, 0x08, 0x00, 0x00, 0x00, 0x00, 0x80, 0x00, 0x00, 0x00, 0x08, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x04, 0x10, 0x00, 0x00, 0x02, 0x00, 0x00, 0x81, 0x83, 0xc2, 0xf5, 0xb0, 0x86, 0xce, 0xcc, 0x8d, 0x84, 0x00, + 0x00, 0x00, 0x01, 0xe6, 0x08, 0x6a, 0x7e, 0x00, 0x00, 0x00, 0x01, 0xe6, 0x08, 0x6a, 0x7f, 0xd8, 0x30, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x40, 0x40, 0x00, 0x00, 0x00, 0x00, 0x08, 0x18, 0x3c, 0x2f, 0x5b, 0x08, 0x6c, + 0xec, 0xc8, 0xd8, 0x5e, 0x60, 0x86, 0xa7, 0xe0, 0x00, 0x00, 0x00, 0x1e, 0x60, 0x86, 0xa7, 0xe0, 0x00, 0x00, 0x00, 0x10, + 0x00, 0x00, 0x00, 0x08, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x01, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x80, 0x3f, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x10, 0x07, 0xe0, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x20, 0x00, 0x00, 0x1f, 0xff, 0xff, 0xff, 0xf0, 0x80, + }; + const ReplicaDecoder::ComponentTable table{ { 12504, { RENDER, SIMPLE_PHYSICS, ITEM, MODEL } } }; + ReplicaDecoder::Session session(table); + const auto constructed = session.Decode(std::string(reinterpret_cast(live), sizeof(live)), 1); + ASSERT_TRUE(constructed); + EXPECT_FALSE(constructed->contains("(layout did not match)")) << constructed->dump(); + EXPECT_EQ((*constructed)["lot"], 12504); + EXPECT_EQ((*constructed)["objectID"], "288300744895909485"); + EXPECT_EQ((*constructed)["spawner"], "1152921510458437894"); + const auto config = (*constructed)["config"].dump(); + EXPECT_NE(config.find("userModelID=9:12504"), std::string::npos) << config; + EXPECT_NE(config.find("propertyObjectID=7:1"), std::string::npos) << config; + EXPECT_EQ(Names(*constructed), "SIMPLE_PHYSICS MODEL RENDER DESTROYABLE "); + const auto& position = (*FieldsOf(*constructed, "SIMPLE_PHYSICS"))["position"]; + EXPECT_FLOAT_EQ(position[0].get(), -12.0f); + EXPECT_NEAR(position[1].get(), 432.96f, 0.001f); + EXPECT_NEAR(position[2].get(), -49.6f, 0.001f); + EXPECT_EQ((*FieldsOf(*constructed, "MODEL"))["modelID"], "12504"); + EXPECT_EQ((*FieldsOf(*constructed, "MODEL"))["behaviors"], 0); +} From 045999a25226da72c7300ad5e81083b8fafbb319 Mon Sep 17 00:00:00 2001 From: Aaron Kimbrell Date: Wed, 30 Sep 2026 08:40:09 -0500 Subject: [PATCH 2/7] 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(); +} From 575b58f475312ff97deeabb751c0ab826085de3a Mon Sep 17 00:00:00 2001 From: Aaron Kimbrell Date: Wed, 30 Sep 2026 08:52:35 -0500 Subject: [PATCH 3/7] feat(dashboard): a packet capture's properties route /api/inspector/sessions/:id/property serves CaptureProperty's worlds, built once per loaded capture on a worker after the replica pass, with the zone's and the models' names from the locale and the property saved now for each zone and clone (read on the main thread) for links and UGC meshes. Co-Authored-By: Claude Opus 5.5 --- dDashboardServer/routes/CaptureReplay.cpp | 56 +++++++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/dDashboardServer/routes/CaptureReplay.cpp b/dDashboardServer/routes/CaptureReplay.cpp index b3cf18278..51fb79530 100644 --- a/dDashboardServer/routes/CaptureReplay.cpp +++ b/dDashboardServer/routes/CaptureReplay.cpp @@ -14,7 +14,9 @@ #include "BinaryPathFinder.h" #include "CaptureBundle.h" +#include "CaptureProperty.h" #include "CaptureTools.h" +#include "ClientAssets.h" #include "Database.h" #include "DashboardRoutes.h" #include "Game.h" @@ -112,6 +114,20 @@ namespace { } const nlohmann::json* ReplicaFields(size_t i) const { return i < replica.size() && replica[i] ? &*replica[i] : nullptr; } + + // The properties the capture saw (CaptureProperty), read once after the replica pass, on a worker thread + std::once_flag propertyOnce; + std::atomic propertyBuilt{}; + nlohmann::json property; + + const nlohmann::json& Property() { + Decode(); + std::call_once(propertyOnce, [this] { + property = CaptureProperty::Build(bundle.records, startUs, [this](size_t i) { return ReplicaFields(i); }); + propertyBuilt = true; + }); + return property; + } }; std::shared_ptr g_Cache; @@ -686,6 +702,46 @@ namespace CaptureReplay { {"worlds", WorldsJson(*cached)}, {"idleSeconds", 3}, {"bucket", 1}, {"interval", 1}, {"truncated", false} }); }); + Route(eHTTPMethod::GET, "/api/inspector/sessions/:id/property", Perm(PERMISSION), + "The properties the capture saw, per world (zone, instance, clone): {worlds: [{zone, zoneName, instance, clone, saved, info, counts, " + "models: [{object, lot, spawner, ugcId, behaviors, spans: [{from, to, i, position, rotation}]}], events}]}. From the captured replica " + "packets and property game messages; `saved` is the property saved for that zone and clone now (its id for links and UGC meshes)", + [](HTTPReply& reply, const HTTPContext& context) { + const auto session = FindSession(context, reply); + if (!session) return; + const auto cached = Load(*session, reply); + if (!cached) return; + // On the main thread: zone names and the saved property of each world the capture was on + std::map, json> saved; + std::map zoneNames; + for (const auto& record : cached->bundle.records) { + const auto& h = record.header; + if (h.source != static_cast(eCaptureSource::WORLD) || !h.zoneId || saved.contains({ h.zoneId, h.cloneId })) continue; + saved[{ h.zoneId, h.cloneId }] = nullptr; + if (!zoneNames.contains(h.zoneId)) zoneNames[h.zoneId] = GameText::ZoneName(h.zoneId); + const auto info = h.cloneId ? Database::Get()->GetPropertyInfo(h.zoneId, h.cloneId) : std::nullopt; + if (!info) continue; + saved[{ h.zoneId, h.cloneId }] = { {"id", std::to_string(info->id)}, {"name", info->name}, {"ownerId", std::to_string(info->ownerId)}, + {"ownerName", CharacterName(info->ownerId)} }; + } + Workers::Reply(reply, context, cached->propertyBuilt, [cached, saved = std::move(saved), zoneNames = std::move(zoneNames)](HTTPReply& out) { + json worlds = cached->Property()["worlds"]; + for (auto& world : worlds) { + const auto zone = world["zone"].get(), clone = world["clone"].get(); + const auto name = zoneNames.find(zone); + world["zoneName"] = name == zoneNames.end() ? std::string{} : name->second; + const auto found = saved.find({ zone, clone }); + world["saved"] = found == saved.end() ? json(nullptr) : found->second; + // Names from the locale, in the viewer's language (read once at startup, so workers may read them) + for (auto& model : world["models"]) model["name"] = ClientAssets::ItemName(model["lot"].get()); + for (auto& event : world["events"]) { + if (event.contains("lot")) event["name"] = ClientAssets::ItemName(event["lot"].get()); + } + } + JsonSuccess(out, { {"worlds", worlds} }); + }); + }); + Route(eHTTPMethod::GET, "/api/inspector/sessions/:id/worlds", Perm(PERMISSION), "Which world server each captured character was on, in time order: one entry per move to another zone or instance (zone 0 is character select)", [](HTTPReply& reply, const HTTPContext& context) { From 056aad5568d718ed9fb3cd50d38a8cfe45392c4d Mon Sep 17 00:00:00 2001 From: Aaron Kimbrell Date: Wed, 30 Sep 2026 08:52:35 -0500 Subject: [PATCH 4/7] feat(dashboard): the models placed on a property now, for World 3D replays /api/world3d/property_models?zone=&clone= lists the saved property's models (LOT, name, UGC ID, position, rotation), for replays of recorded positions, which don't record models. Co-Authored-By: Claude Opus 5.5 --- dDashboardServer/routes/WorldView.cpp | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/dDashboardServer/routes/WorldView.cpp b/dDashboardServer/routes/WorldView.cpp index 630868a31..d4e71d2a8 100644 --- a/dDashboardServer/routes/WorldView.cpp +++ b/dDashboardServer/routes/WorldView.cpp @@ -383,6 +383,26 @@ namespace WorldView { JsonReply(reply, eHTTPStatusCode::OK, { {"instances", instances} }); }); + Route(eHTTPMethod::GET, "/api/world3d/property_models", Perm("players_history"), + "The models placed on a property now, for replays of recorded positions there (which don't record models): " + "{property: {id, name, ownerId, ownerName} or null, models: [{id, lot, ugcId, position, rotation}]}. Query: ?zone=&clone=", + [](HTTPReply& reply, const HTTPContext& context) { + const auto zone = static_cast(std::max(0, QueryInt(context, "zone", 0))); + const auto clone = static_cast(std::max(0, QueryInt(context, "clone", 0))); + const auto info = zone && clone ? Database::Get()->GetPropertyInfo(zone, clone) : std::nullopt; + if (!info) return JsonReply(reply, eHTTPStatusCode::OK, { {"property", nullptr}, {"models", nlohmann::json::array()} }); + nlohmann::json models = nlohmann::json::array(); + for (const auto& model : Database::Get()->GetPropertyModels(info->id)) { + models.push_back({ {"id", std::to_string(model.id)}, {"lot", model.lot}, {"name", ClientAssets::ItemName(model.lot)}, {"ugcId", std::to_string(model.ugcId)}, + {"position", {model.position.x, model.position.y, model.position.z}}, + {"rotation", {model.rotation.x, model.rotation.y, model.rotation.z, model.rotation.w}} }); + } + JsonReply(reply, eHTTPStatusCode::OK, { + {"property", { {"id", std::to_string(info->id)}, {"name", info->name}, {"ownerId", std::to_string(info->ownerId)}, {"ownerName", CharacterName(info->ownerId)} }}, + {"models", models} + }); + }); + Route(eHTTPMethod::GET, "/api/world3d/history", Perm("players_history"), "Where players went in a zone between two times, for replays: per character its samples as [seconds after from, x, y, z, ...]. " "Query: ?zone=&instance= (0: all)&from=&to= (Unix seconds, at most 7 days apart). Long ranges are thinned to about 3000 samples per player. Audited", From 15a743809a2fb618fd8eadb43abff467358d25d5 Mon Sep 17 00:00:00 2001 From: Aaron Kimbrell Date: Wed, 30 Sep 2026 08:52:35 -0500 Subject: [PATCH 5/7] feat(world3d): a property's models in capture and recorded replays In a capture replay, each model stands where the capture saw it at the playhead (the UGC server's mesh for a brick built model when it has one, a box otherwise), appearing, moving and vanishing with the timeline both ways; the Property tab shows the property data of that moment and the models standing, and behavior messages are ticks on the timeline. In a replay of recorded positions on one property instance, the models placed now are drawn and labelled as now. Co-Authored-By: Claude Opus 5.5 --- dDashboardServer/static/css/dashboard.css | 2 + dDashboardServer/static/js/lddviewer.js | 2 +- dDashboardServer/static/js/world3d-core.js | 52 ++++++ dDashboardServer/static/js/world3d.js | 189 ++++++++++++++++++++- dDashboardServer/templates/world3d.jinja2 | 5 + tests/dWebTests/CMakeLists.txt | 3 + tests/dWebTests/world3d-property.test.mjs | 63 +++++++ 7 files changed, 312 insertions(+), 4 deletions(-) create mode 100644 tests/dWebTests/world3d-property.test.mjs diff --git a/dDashboardServer/static/css/dashboard.css b/dDashboardServer/static/css/dashboard.css index 6a8a404ca..47b376242 100644 --- a/dDashboardServer/static/css/dashboard.css +++ b/dDashboardServer/static/css/dashboard.css @@ -262,6 +262,8 @@ html.nav-loading main { opacity: .65; transition: opacity .2s; } border-radius: .2rem; background: var(--bs-warning-bg-subtle); color: var(--bs-warning-text-emphasis); max-width: 9rem; overflow: hidden; text-overflow: ellipsis; } .timeline-marker:hover > span, .timeline-marker:focus-visible > span { max-width: none; z-index: 1; } +/* A property's behavior messages in a capture replay: short unlabelled ticks under the world change markers */ +.timeline-marker.timeline-behavior { top: .55rem; border-left: 1px solid var(--bs-info); } /* Client system info: values whose caveat is a tooltip; unreliable ones stand out more */ .sysinfo-tip { text-decoration: underline dotted; text-underline-offset: .2em; cursor: help; } diff --git a/dDashboardServer/static/js/lddviewer.js b/dDashboardServer/static/js/lddviewer.js index c3e5433aa..cd0f85bec 100644 --- a/dDashboardServer/static/js/lddviewer.js +++ b/dDashboardServer/static/js/lddviewer.js @@ -148,7 +148,7 @@ export function parseLxfml(text) { * The parts of a model the UGC server made (a .nif, converted by the dashboard like the scenery's models: see * scenery-core.js), as [{geometry, material}] in the model's own coordinates. Each part owns its geometry and material. */ -async function loadGeneratedModel(url) { +export async function loadGeneratedModel(url) { const response = await limitedFetch(url); if (!response.ok) throw new Error('HTTP ' + response.status); const model = parseModel(await response.arrayBuffer()); diff --git a/dDashboardServer/static/js/world3d-core.js b/dDashboardServer/static/js/world3d-core.js index 301d19cd7..f6579bd55 100644 --- a/dDashboardServer/static/js/world3d-core.js +++ b/dDashboardServer/static/js/world3d-core.js @@ -257,3 +257,55 @@ export function markersHtml(markers, esc, names = false) { '' + esc(names && m.name ? m.name + ': ' + zone : zone) + ''; }).join(''); } + +// ---- a property as a capture saw it ---- + +/** + * Capture playback: a property world at time t (one entry of /api/inspector/sessions/:id/property's worlds): each model + * standing then with the span it stood in ({model, span}), the property data then (the latest DownloadPropertyData) + * and the model count the property gave then. Every answer comes from t alone, so seeking back and forth is the same. + */ +export function propertyAt(world, t) { + const models = []; + for (const model of (world && world.models) || []) { + const span = model.spans.find((s) => s.from <= t && (s.to === null || s.to === undefined || t < s.to)); + if (span) models.push({ model, span }); + } + const latest = (list) => { + let found = null; + for (const entry of list || []) { + if (entry.t > t) break; + found = entry; + } + return found; + }; + return { models, info: latest(world && world.info), count: latest(world && world.counts) }; +} + +/** + * The property world to show in the zone on screen at time t: the one the followed character was on (worldAt), else + * the first captured on this zone. `worlds`: the property route's worlds; `moves`: the positions route's worlds. + */ +export function propertyWorldFor(worlds, zone, moves, character, t) { + const here = (worlds || []).filter((w) => w.zone === zone); + if (!here.length) return null; + const on = character ? worldAt(moves, character, t) : null; + return (on && here.find((w) => w.instance === on.instance && w.clone === on.clone)) || here[0]; +} + +/** + * Timeline ticks for a property's behavior messages (and what was sent to or from a model): [{t, at, title}] along + * `duration` seconds, one per message, `title` naming the message. + */ +export function behaviorMarkers(world, duration) { + return ((world && world.events) || []).filter((e) => e.kind === 'behavior').map((e) => ({ + t: e.t, at: duration > 0 ? Math.max(0, Math.min(1, e.t / duration)) : 0, + title: e.message + (e.command ? ' ' + e.command : '') + (e.lot !== undefined ? ' (LOT ' + e.lot + ')' : '') + })); +} + +/** The behavior ticks over the timeline's slider (as markersHtml); clicking one seeks to it (data-t). */ +export function behaviorMarkersHtml(markers, esc) { + return markers.map((m) => '').join(''); +} diff --git a/dDashboardServer/static/js/world3d.js b/dDashboardServer/static/js/world3d.js index a50f6a434..d0208238a 100644 --- a/dDashboardServer/static/js/world3d.js +++ b/dDashboardServer/static/js/world3d.js @@ -12,6 +12,9 @@ * scrubbing never rebuilds geometry. A packet capture's movement plays the same way, driven by its timeline. * - Following a player keeps the camera on them across worlds: live, when the position feed reports them in another * world; in a capture, at the time their packets move to another world server (a marker on the timeline). + * - A property's placed models: in a capture, where the capture saw each one at the playhead, with the property's + * data then and ticks on the timeline where behavior messages ran; in a replay of recorded positions, the models + * placed now (position history doesn't record models), labelled as such. * - Heat map (reports_view): the economy map events of one kind per day as coloured 4x4 squares on the ground, * played back day by day. * Names come from the server (kinds, path types, map event kinds); nothing about the game is kept here. @@ -19,11 +22,11 @@ import * as THREE from 'three'; import { OrbitControls } from 'three/addons/controls/OrbitControls.js'; import { RoomEnvironment } from 'three/addons/environments/RoomEnvironment.js'; -import { buildTerrainChunks, TERRAIN_LOOKS } from '/js/lddviewer.js'; +import { buildTerrainChunks, TERRAIN_LOOKS, loadGeneratedModel } from '/js/lddviewer.js'; import { createScenery } from '/js/scenery.js'; import { nearPlaneFor } from '/js/scenery-core.js'; import { Track, replayPosition, trailSegments, heatFrames, heatLevel, heatColor, formatSpan, coreBounds, isPlaceholderTerrain, - followedMove, captureSwitch, worldMarkers, markersHtml } from '/js/world3d-core.js'; + followedMove, captureSwitch, worldMarkers, markersHtml, propertyAt, propertyWorldFor, behaviorMarkers, behaviorMarkersHtml } from '/js/world3d-core.js'; const LIVE_DELAY = 1.2; // seconds live players are drawn behind the newest report const LIVE_FORGET = 8; // seconds without a report before a live player is dropped @@ -498,6 +501,7 @@ async function loadZone(zone, keep = false) { const resume = state.capture && state.replay ? { t: state.replay.t, playing: state.replay.playing } : null; stopReplay(); clearHeat(); + clearPropertyModels(); // The old zone goes at once: its models, flairs, terrain and markers, and whatever of them is still loading scenery.clear(); flairs.clear(); @@ -927,6 +931,7 @@ async function loadReplay() { setStatus(''); if (!data || data.success === false) { toast((data && data.error) || 'Could not load the replay', 'danger'); return; } startReplay(data); + loadCurrentProperty(); } /** @@ -940,6 +945,7 @@ async function loadCaptureReplay(capture) { setStatus(''); if (!data || data.success === false) { toast((data && data.error) || 'Could not load the capture', 'danger'); return; } state.capture = { id: String(capture), data, worlds: data.worlds || [] }; + await loadCaptureProperty(capture); // A capture plays in real time (the saved speed is for scrubbing through hours of recorded positions) $('replaySpeed').value = '1'; // One character captured: follow them from the start @@ -982,7 +988,8 @@ function renderMarkers() { const c = state.capture, r = state.replay; if (!c || !r) { el.innerHTML = ''; return; } const names = !state.followCharacter && new Set(c.worlds.map((w) => w.character)).size > 1; - el.innerHTML = markersHtml(worldMarkers(c.worlds, r.to - r.from, state.followCharacter), esc, names); + el.innerHTML = markersHtml(worldMarkers(c.worlds, r.to - r.from, state.followCharacter), esc, names) + + behaviorMarkersHtml(behaviorMarkers(property.world, r.to - r.from), esc); } function startReplay(data, capture = false) { @@ -1063,6 +1070,178 @@ if ('BroadcastChannel' in window) { }); } +// ---- a property's placed models ---- + +/** + * A property's models drawn where they stood: in a capture replay, as the capture saw them at the playhead + * (/api/inspector/sessions/:id/property, world3d-core.js propertyAt); in a replay of recorded positions, the models + * placed now (/api/world3d/property_models), since position history doesn't record models. A player-built model is + * drawn from the mesh the UGC server made of it when there is one, anything else as a box (as the property view does). + */ +const propertyLayer = new THREE.Group(); +scene.add(propertyLayer); +const PROPERTY_BOX = new THREE.BoxGeometry(1.6, 1.6, 1.6).translate(0, 0.8, 0); +const PROPERTY_BOX_MATERIAL = new THREE.MeshStandardMaterial({ color: 0xd98e3f, roughness: 0.6, transparent: true, opacity: 0.85 }); +const ugcMeshes = new Map(); // "ugcId?via" -> promise of the UGC server's parts, or null when it has none +const property = { + worlds: null, // capture: the property route's worlds + current: null, // recorded positions: {zone, clone, property, models} + drawn: new Map(), // model key -> {group, span} + world: null, // the capture world shown + panelKey: '' // what the panel shows, so it is only written again when that changes +}; + +function ugcParts(ugcId, via) { + const key = ugcId + '?' + via; + if (!ugcMeshes.has(key)) { + ugcMeshes.set(key, loadGeneratedModel('/api/ugc_links/mesh/' + encodeURIComponent(ugcId) + (via ? '?property=' + encodeURIComponent(via) : '')).catch(() => null)); + } + return ugcMeshes.get(key); +} + +// One model's object: a box at once, the UGC server's mesh in its place when that loads +function modelObject(model, via) { + const group = new THREE.Group(); + const box = new THREE.Mesh(PROPERTY_BOX, PROPERTY_BOX_MATERIAL); + group.add(box); + if (model.ugcId && model.ugcId !== '0') { + ugcParts(model.ugcId, via).then((parts) => { + if (!parts || !group.parent) return; + group.remove(box); + for (const part of parts) group.add(new THREE.Mesh(part.geometry, part.material)); + }); + } + return group; +} + +function clearPropertyModels() { + for (const { group } of property.drawn.values()) propertyLayer.remove(group); + property.drawn.clear(); + property.world = null; + property.panelKey = ''; +} + +// Draws [{key, model, span}] and hides what isn't in it (its object is kept for when it comes back) +function placePropertyModels(list, via) { + const shown = new Set(); + for (const { key, model, span } of list) { + let drawn = property.drawn.get(key); + if (!drawn) { + drawn = { group: modelObject(model, via), span: null }; + property.drawn.set(key, drawn); + propertyLayer.add(drawn.group); + } + if (drawn.span !== span) { + drawn.span = span; + drawn.group.position.fromArray(span.position); + drawn.group.quaternion.fromArray(span.rotation); + } + drawn.group.visible = true; + shown.add(key); + } + for (const [key, drawn] of property.drawn) if (!shown.has(key)) drawn.group.visible = false; + propertyLayer.visible = $('propertyModelsToggle').checked; +} + +function showPropertyTab(shown) { + $('propertyTabItem').classList.toggle('d-none', !shown); + if (!shown) $('propertyInfo').innerHTML = ''; +} + +function focusPoint(position) { + const offset = camera.position.clone().sub(controls.target); + if (offset.length() > 60) offset.setLength(30); + controls.target.fromArray(position); + camera.position.copy(controls.target).add(offset); +} + +function modelRows(list) { + return '
' + list.map(({ model, span }) => { + const name = model.name || ('LOT ' + model.lot); + const behaviors = model.behaviors ? ' · ' + model.behaviors + ' behavior' + (model.behaviors === 1 ? '' : 's') : ''; + return ''; + }).join('') + '
'; +} + +// The capture's property at time t in the zone shown +function updateCaptureProperty(t) { + const c = state.capture; + const world = propertyWorldFor(property.worlds, state.zone, c && c.worlds, state.followCharacter, t); + if (world !== property.world) { + clearPropertyModels(); + property.world = world; + renderMarkers(); + } + showPropertyTab(!!world); + if (!world) return; + const now = propertyAt(world, t); + const via = world.saved ? world.saved.id : ''; + placePropertyModels(now.models.map(({ model, span }) => ({ key: model.object + '/' + model.spans.indexOf(span), model, span })), via); + const key = world.zone + ':' + world.instance + ':' + world.clone + '|' + now.models.map((m) => m.model.object + '/' + m.span.i).join(',') + '|' + + (now.info ? now.info.i : '') + '|' + (now.count ? now.count.i : ''); + if (key === property.panelKey) return; + property.panelKey = key; + const info = now.info; + const field = (label, value) => value === '' || value === null || value === undefined ? '' : '
' + esc(label) + '
' + value + '
'; + $('propertyInfo').innerHTML = '
As the capture saw it at this moment: ' + esc(world.zoneName || zoneName(world.zone)) + + ' #' + esc(world.instance) + (world.clone ? ' (clone ' + esc(world.clone) + ')' : '') + '
' + + (info ? '
' + + field('Name', esc(info.name)) + + field('Owner', info.ownerId && info.ownerId !== '0' ? fmt.character(info.ownerId, info.ownerName) : esc(info.ownerName)) + + field('Description', esc(info.description)) + + field('Privacy', info.accessType !== undefined ? esc(Labels.name('privacy', info.accessType) || info.accessType) : '') + + field('Approval', info.moderation ? esc(info.moderation) + (info.rejectionReason ? ' (' + esc(info.rejectionReason) + ')' : '') : '') + + field(GameText.term('reputation'), info.reputation !== undefined ? esc(info.reputation) : '') + + field('Models', now.count ? esc(now.count.count) : '') + + '
Property data from packet ' + esc(info.i) + '.
' + : '
The capture has no property data for this world yet.
') + + (world.saved ? '' : '') + + '
Models standing here (' + now.models.length + ')
' + + (now.models.length ? modelRows(now.models) : '
None at this moment.
'); +} + +async function loadCaptureProperty(capture) { + const data = await api.get('/api/inspector/sessions/' + encodeURIComponent(capture) + '/property').catch(() => null); + property.worlds = data && data.worlds ? data.worlds : []; + property.current = null; + clearPropertyModels(); +} + +// Recorded positions on a property: the models placed there now, labelled as now +async function loadCurrentProperty() { + property.worlds = null; + property.current = null; + clearPropertyModels(); + showPropertyTab(false); + const propertyZone = ((state.meta && state.meta.propertyZones) || []).includes(Number(state.zone)); + const instance = (state.replayInstances || []).find((i) => i.instance === state.instance); + if (!propertyZone) return; + if (!instance || !instance.clone) { + showPropertyTab(true); + $('propertyInfo').innerHTML = '
Each property is its own instance of this zone: pick one to see its placed models.
'; + return; + } + const data = await api.get('/api/world3d/property_models?zone=' + state.zone + '&clone=' + instance.clone).catch(() => null); + if (!data || !data.property) return; + property.current = { zone: state.zone, clone: instance.clone, ...data }; + placePropertyModels(data.models.map((m) => ({ key: m.id, model: m, span: m })), data.property.id); + showPropertyTab(true); + $('propertyInfo').innerHTML = '
Placed models as they are now: position history doesn\'t record models, so they may differ from the time replayed.
' + + '
Name
' + esc(data.property.name) + '
' + + '
Owner
' + fmt.character(data.property.ownerId, data.property.ownerName) + '
' + + '' + + '
Placed models (' + data.models.length + ')
' + modelRows(data.models.map((m) => ({ model: m, span: m }))); +} + +$('propertyInfo').addEventListener('click', (e) => { + const row = e.target.closest('[data-focus]'); + if (row) focusPoint(row.dataset.focus.split(',').map(Number)); +}); +$('propertyModelsToggle').addEventListener('change', (e) => { propertyLayer.visible = e.target.checked; }); + // ---- heat map timelapse ---- // On a property zone every property is its own instance: show them all together, or one (its clone), as the reports do @@ -1182,6 +1361,9 @@ async function setMode(mode) { } if (mode !== 'replay') { stopReplay(); + clearPropertyModels(); + property.worlds = property.current = null; + showPropertyTab(false); if (state.capture) setFollow(null); state.capture = null; renderMarkers(); @@ -1249,6 +1431,7 @@ function animate() { } drawPlayers(currentPlayers(now / 1000)); + if (state.capture && r && property.worlds) updateCaptureProperty(r.t); if (state.capture && r && !state.switching) { const move = captureSwitch(state.capture.worlds, state.followCharacter, r.t, state.zone); if (move) switchWorld(move, move.name); diff --git a/dDashboardServer/templates/world3d.jinja2 b/dDashboardServer/templates/world3d.jinja2 index 178c7b67d..19d081e2d 100644 --- a/dDashboardServer/templates/world3d.jinja2 +++ b/dDashboardServer/templates/world3d.jinja2 @@ -124,6 +124,7 @@ +
@@ -134,6 +135,10 @@
Click a player or an object.
+
+
+
+
View
diff --git a/tests/dWebTests/CMakeLists.txt b/tests/dWebTests/CMakeLists.txt index 009f9d126..b174734cc 100644 --- a/tests/dWebTests/CMakeLists.txt +++ b/tests/dWebTests/CMakeLists.txt @@ -119,4 +119,7 @@ if(NODE_EXECUTABLE) # Following a player across worlds in World 3D (live and capture playback) and the timeline's world markers (static/js/world3d-core.js) add_test(NAME World3dFollowJs COMMAND ${NODE_EXECUTABLE} "${CMAKE_CURRENT_SOURCE_DIR}/world3d-follow.test.mjs" "${PROJECT_SOURCE_DIR}/dDashboardServer/static/js/world3d-core.js") + # A property in a capture replay: its models at the playhead, its data then, and its behavior ticks (static/js/world3d-core.js) + add_test(NAME World3dPropertyJs COMMAND ${NODE_EXECUTABLE} "${CMAKE_CURRENT_SOURCE_DIR}/world3d-property.test.mjs" + "${PROJECT_SOURCE_DIR}/dDashboardServer/static/js/world3d-core.js") endif() diff --git a/tests/dWebTests/world3d-property.test.mjs b/tests/dWebTests/world3d-property.test.mjs new file mode 100644 index 000000000..1c8453824 --- /dev/null +++ b/tests/dWebTests/world3d-property.test.mjs @@ -0,0 +1,63 @@ +// A property in a capture replay (static/js/world3d-core.js): which models stand where at the playhead, the property +// data then, which property world the zone on screen shows, and the behavior ticks. +// Run by ctest: node world3d-property.test.mjs +import { pathToFileURL } from 'node:url'; + +const [modulePath] = process.argv.slice(2); +const W = await import(pathToFileURL(modulePath).href); +let failures = 0; +const same = (actual, expected, what) => { + if (JSON.stringify(actual) !== JSON.stringify(expected)) { + failures++; + console.error(`${what}: ${JSON.stringify(actual)} is not ${JSON.stringify(expected)}`); + } +}; + +// The property route's shape: one model there from the start that moves at 5s, one placed at 4s and picked up at 8s +const world = { + zone: 1150, instance: 7, clone: 42, + info: [{ t: 1, name: 'Old name', reputation: 10 }, { t: 9, name: 'New name', reputation: 25 }], + counts: [{ t: 1, count: 1 }, { t: 4, count: 2 }, { t: 8, count: 1 }], + models: [ + { object: '1', lot: 14, spans: [{ from: 2, to: 5, position: [1, 2, 3] }, { from: 5, to: null, position: [5, 2, 5] }] }, + { object: '2', lot: 14, spans: [{ from: 4, to: 8, position: [10, 2, -4] }] } + ], + events: [ + { kind: 'placed', t: 3, object: '2' }, + { kind: 'behavior', t: 6, message: 'PLAY_BEHAVIOR_SOUND', object: '1', lot: 14 }, + { kind: 'removed', t: 8, object: '2', reason: 'picked up' } + ] +}; +const at = (t) => { + const p = W.propertyAt(world, t); + return { models: p.models.map((m) => m.model.object + '@' + m.span.position.join(',')), name: p.info && p.info.name, count: p.count && p.count.count }; +}; + +same(at(0), { models: [], name: null, count: null }, 'before anything was captured'); +same(at(2), { models: ['1@1,2,3'], name: 'Old name', count: 1 }, 'the model already there'); +same(at(4.5), { models: ['1@1,2,3', '2@10,2,-4'], name: 'Old name', count: 2 }, 'the placed model appears'); +same(at(5), { models: ['1@5,2,5', '2@10,2,-4'], name: 'Old name', count: 2 }, 'the first one moved (a span ends where the next begins)'); +same(at(8), { models: ['1@5,2,5'], name: 'Old name', count: 1 }, 'the second one was picked up'); +same(at(20), { models: ['1@5,2,5'], name: 'New name', count: 1 }, 'still there at the end, renamed'); +// Seeking back gives the same as playing forward +same(at(4.5), { models: ['1@1,2,3', '2@10,2,-4'], name: 'Old name', count: 2 }, 'seeking back'); +same(W.propertyAt(null, 3), { models: [], info: null, count: null }, 'no property'); + +// Which world: the followed character's, else the first of the zone +const other = { ...world, instance: 9, clone: 43 }; +const moves = [{ character: 'c', t: 0, zone: 1150, instance: 9, clone: 43 }, { character: 'c', t: 10, zone: 1150, instance: 7, clone: 42 }]; +same(W.propertyWorldFor([world, other], 1150, moves, 'c', 5).clone, 43, 'the followed character\'s property'); +same(W.propertyWorldFor([world, other], 1150, moves, 'c', 11).clone, 42, 'they moved to another property'); +same(W.propertyWorldFor([world, other], 1150, moves, null, 5).clone, 42, 'nobody followed: the first'); +same(W.propertyWorldFor([world, other], 1100, moves, 'c', 5), null, 'not a property zone'); + +// Behavior ticks +same(W.behaviorMarkers(world, 12), [{ t: 6, at: 0.5, title: 'PLAY_BEHAVIOR_SOUND (LOT 14)' }], 'one behavior tick'); +const html = W.behaviorMarkersHtml(W.behaviorMarkers(world, 12), (s) => String(s).replace(/ Date: Wed, 30 Sep 2026 08:52:35 -0500 Subject: [PATCH 6/7] feat(dashboard): property summary on the Packet Captures page Above the packet list: each property the capture was on, its owner, and every model placed, moved or removed, each jumping to its packet. Co-Authored-By: Claude Opus 5.5 --- dDashboardServer/static/js/packet-captures.js | 75 ++++++++++++++++++- .../templates/packet-captures.jinja2 | 1 + 2 files changed, 73 insertions(+), 3 deletions(-) diff --git a/dDashboardServer/static/js/packet-captures.js b/dDashboardServer/static/js/packet-captures.js index f050708ad..6e786fbca 100644 --- a/dDashboardServer/static/js/packet-captures.js +++ b/dDashboardServer/static/js/packet-captures.js @@ -143,6 +143,7 @@ state.records = []; state.worlds = []; byId('worldMarkers').innerHTML = ''; + byId('propertySummary').classList.add('d-none'); state.selected = -1; history.replaceState(null, '', '#capture=' + id); byId('viewer').classList.remove('d-none'); @@ -182,9 +183,71 @@ else render(); renderHeader(); loadWorlds(c.id); + loadProperty(c.id); }, function () { state.loading = false; }); } + // ---- the properties the capture was on ---- + + function point(p) { return p && p.length === 3 ? p.map(function (v) { return v.toFixed(1); }).join(', ') : ''; } + + // What happened to the models: placed, moved (a run of moves of one model is one entry) and removed + function modelEvents(world) { + var out = []; + (world.events || []).forEach(function (e) { + if (e.kind !== 'placed' && e.kind !== 'moved' && e.kind !== 'removed') return; + var last = out[out.length - 1]; + if (e.kind === 'moved' && last && last.kind === 'moved' && last.object === e.object) { last.moves++; last.position = e.position; return; } + out.push(Object.assign({ moves: 1 }, e)); + }); + return out; + } + + function eventHtml(e) { + var what = e.object ? esc(e.name || ('LOT ' + e.lot)) + ' LOT ' + esc(e.lot) + '' : 'a model'; + var text = e.kind === 'placed' ? 'Placed ' + what + (e.position ? ' at ' + esc(point(e.position)) : '') + (e.object ? '' : ' (not seen made)') + : e.kind === 'moved' ? 'Moved ' + what + (e.moves > 1 ? ' ' + e.moves + ' times' : '') + ' to ' + esc(point(e.position)) + : 'Removed ' + what + (e.reason ? ' (' + esc(e.reason) + ')' : ''); + // Jump to the packet that said it: the client's request for a removal it asked for + var index = e.kind === 'removed' && e.asked !== undefined ? e.asked : e.i; + return ''; + } + + function worldHtml(w) { + var info = (w.info || [])[0], last = (w.info || [])[(w.info || []).length - 1]; + var owner = info ? info.ownerName : (w.saved ? w.saved.ownerName : ''); + var ownerId = info ? info.ownerId : (w.saved ? w.saved.ownerId : ''); + var events = modelEvents(w); + var name = last && last.name ? last.name : (w.saved ? w.saved.name : ''); + return '
' + + '
' + esc(name || 'Property') + '' + (owner ? ' · owner ' + (ownerId && ownerId !== '0' ? fmt.character(ownerId, owner) : esc(owner)) : '') + + ' · ' + esc(w.zoneName || ('Zone ' + w.zone)) + ' #' + esc(w.instance) + (w.clone ? ' (clone ' + esc(w.clone) + ')' : '') + + ' · ' + nf.format(w.models.length) + ' model' + (w.models.length === 1 ? '' : 's') + ' seen' + + (w.saved ? ' · the property now' : '') + '
' + + (info && (w.info || []).length > 1 ? '
Its property data changed ' + ((w.info || []).length - 1) + ' time(s) during the capture.
' : '') + + (events.length ? '
' + events.map(eventHtml).join('') + '
' + : '
No model was placed, moved or removed during the capture.
') + + '
'; + } + + function loadProperty(id) { + api.get(url(id) + '/property').then(function (d) { + if (!state.capture || state.capture.id !== id || !d.worlds) return; + var box = byId('propertySummary'); + box.innerHTML = d.worlds.map(worldHtml).join(''); + box.classList.toggle('d-none', !d.worlds.length); + }).catch(function () {}); + } + + byId('propertySummary').addEventListener('click', function (e) { + var entry = e.target.closest('[data-packet]'); + if (!entry) return; + stop(); + setPlayhead(Number(entry.dataset.at) * 1000 + 1); + showPacket(parseInt(entry.dataset.packet, 10)); + }); + // The timeline's world change markers (world3d-core.js draws them, as World 3D's replay does) function loadWorlds(id) { Promise.all([api.get(url(id) + '/worlds'), import('/js/world3d-core.js')]).then(function (results) { @@ -290,9 +353,15 @@ byId('rows').addEventListener('click', function (e) { var row = e.target.closest('[data-i]'); - if (!row || !state.capture) return; - state.selected = parseInt(row.dataset.i, 10); + if (row) showPacket(parseInt(row.dataset.i, 10)); + }); + + function showPacket(index) { + if (!state.capture) return; + state.selected = index; render(); + var shown = byId('rows').querySelector('tr[data-i="' + index + '"]'); + if (shown) shown.scrollIntoView({ block: 'nearest' }); api.get(url(state.capture.id) + '/packets/' + state.selected).then(function (d) { if (!d.record) { toast(d.error || 'Could not load the packet', 'danger'); return; } var r = d.record; @@ -305,7 +374,7 @@ byId('detailFields').textContent = r.fields ? JSON.stringify(r.fields, null, 2) : (r.gap ? r.gap + ' packets were lost here (a server buffer was full)' : 'Not decoded: no struct reads this packet (the server never sends or handles it). Its bytes are below.'); byId('detailHex').textContent = hexdump(r.hex || ''); }).catch(function () {}); - }); + } byId('detailClose').addEventListener('click', function () { byId('detail').classList.add('d-none'); byId('grid').classList.add('no-detail'); diff --git a/dDashboardServer/templates/packet-captures.jinja2 b/dDashboardServer/templates/packet-captures.jinja2 index 00ffb3750..84296dd42 100644 --- a/dDashboardServer/templates/packet-captures.jinja2 +++ b/dDashboardServer/templates/packet-captures.jinja2 @@ -103,6 +103,7 @@
+