Merge exp/property-replay: property models, info and behaviours in capture replays

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Aaron Kimbrell
2026-09-30 08:54:20 -05:00
20 changed files with 1211 additions and 12 deletions

View File

@@ -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<bool> 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<Cached> 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<std::pair<uint32_t, uint32_t>, json> saved;
std::map<uint32_t, std::string> zoneNames;
for (const auto& record : cached->bundle.records) {
const auto& h = record.header;
if (h.source != static_cast<uint8_t>(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<uint32_t>(), clone = world["clone"].get<uint32_t>();
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<LOT>());
for (auto& event : world["events"]) {
if (event.contains("lot")) event["name"] = ClientAssets::ItemName(event["lot"].get<LOT>());
}
}
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) {

View File

@@ -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<uint32_t>(std::max<int64_t>(0, QueryInt(context, "zone", 0)));
const auto clone = static_cast<uint32_t>(std::max<int64_t>(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",