From 575b58f475312ff97deeabb751c0ab826085de3a Mon Sep 17 00:00:00 2001 From: Aaron Kimbrell Date: Wed, 30 Sep 2026 08:52:35 -0500 Subject: [PATCH] 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) {