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

@@ -16,6 +16,7 @@
#include "CaptureBundle.h"
#include "CaptureTools.h"
#include "LiveImport.h"
#include "CaptureProperty.h"
#include "GameMessageDecoder.h"
#include "PacketDecoder.h"
#include "CDClientDatabase.h"
@@ -59,6 +60,8 @@ namespace {
" info <bundle> what is in a bundle\n"
" decode <bundle> [--fields] [--limit N] [--cdserver <CDServer.sqlite>]\n"
" its packets on one timeline (replica packets too with --cdserver)\n"
" property <bundle> --cdserver <CDServer.sqlite>\n"
" the properties it saw: models over time, property data, events (JSON)\n"
" anonymise <in> <out> a test fixture: names and chat replaced, IDs placeholders\n"
" import-live <folder> <out-dir> convert live captures (every folder of *_traffic.zip under <folder>)\n"
" replay <bundle>... [options] replay against a fresh sandbox stack per bundle and compare\n"
@@ -163,6 +166,31 @@ namespace {
return 0;
}
// The properties a capture saw, as the dashboard's capture viewer shows them (CaptureProperty)
int Property(const std::string& path, const std::string& cdServer) {
CaptureBundle::Bundle bundle;
if (!LoadBundle(path, bundle)) return 1;
CaptureTools::SortTimeline(bundle.records);
ReplicaDecoder::ComponentTable components;
try {
CDClientDatabase::Connect(cdServer);
ReplicaDecoder::LoadComponentTable(components);
} catch (const std::exception& e) {
std::cerr << "Can't read " << cdServer << ": " << e.what() << "\n";
return 1;
}
ReplicaDecoder::Session replicas(components);
std::vector<std::optional<json>> replica(bundle.records.size());
for (size_t i = 0; i < bundle.records.size(); i++) {
const auto& record = bundle.records[i];
if (record.header.flags & PacketRecordFlags::GAP || CaptureTools::FromClient(record.header)) continue;
replica[i] = replicas.Decode(record.bytes, CaptureTools::ReplicaConnection(record.header));
}
const auto start = bundle.records.empty() ? 0 : bundle.records.front().header.timeUs;
std::cout << CaptureProperty::Build(bundle.records, start, [&replica](size_t i) { return replica[i] ? &*replica[i] : nullptr; }).dump(2) << "\n";
return 0;
}
int Anonymise(const std::string& in, const std::string& out) {
CaptureBundle::Bundle bundle;
if (!LoadBundle(in, bundle)) return 1;
@@ -345,6 +373,7 @@ int main(int argc, char** argv) {
const auto& command = args[1];
if (command == "info" && args.size() >= 3) return Info(args[2]);
if (command == "decode" && args.size() >= 3) return Decode(args[2], Flag(args, "--fields"), std::stoul(Arg(args, "--limit", "1000000")), Arg(args, "--cdserver", ""));
if (command == "property" && args.size() >= 3) return Property(args[2], Arg(args, "--cdserver", ""));
if (command == "anonymise" && args.size() >= 4) return Anonymise(args[2], args[3]);
if (command == "import-live" && args.size() >= 4) return ImportLive(args[2], args[3]);
if (command == "replay") return Replay(args);

View File

@@ -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",

View File

@@ -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; }

View File

@@ -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());

View File

@@ -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)) + ' <span class="text-body-secondary">LOT ' + esc(e.lot) + '</span>' : 'a model';
var text = e.kind === 'placed' ? 'Placed ' + what + (e.position ? ' at ' + esc(point(e.position)) : '') + (e.object ? '' : ' <span class="text-body-secondary">(not seen made)</span>')
: 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 '<button type="button" class="list-group-item list-group-item-action px-2 py-1" data-packet="' + esc(index) + '" data-at="' + esc(e.t) + '">' +
'<span class="font-monospace text-body-secondary me-2">' + timeText(e.t * 1000) + '</span>' + text + ' <span class="text-body-secondary">#' + esc(index) + '</span></button>';
}
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 '<div class="card mb-2"><div class="card-body p-2">' +
'<div><strong>' + esc(name || 'Property') + '</strong>' + (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 ? ' · <a href="/properties/' + esc(w.saved.id) + '">the property now</a>' : '') + '</div>' +
(info && (w.info || []).length > 1 ? '<div class="text-body-secondary">Its property data changed ' + ((w.info || []).length - 1) + ' time(s) during the capture.</div>' : '') +
(events.length ? '<div class="list-group list-group-flush mt-1" style="max-height: 12rem; overflow-y: auto">' + events.map(eventHtml).join('') + '</div>'
: '<div class="text-body-secondary">No model was placed, moved or removed during the capture.</div>') +
'</div></div>';
}
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');

View File

@@ -257,3 +257,55 @@ export function markersHtml(markers, esc, names = false) {
'<span>' + esc(names && m.name ? m.name + ': ' + zone : zone) + '</span></button>';
}).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) => '<button type="button" class="timeline-marker timeline-behavior" style="left:' + (m.at * 100).toFixed(3) + '%" data-t="' + m.t +
'" title="' + esc(m.title) + '"></button>').join('');
}

View File

@@ -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 '<div class="list-group list-group-flush">' + list.map(({ model, span }) => {
const name = model.name || ('LOT ' + model.lot);
const behaviors = model.behaviors ? ' · ' + model.behaviors + ' behavior' + (model.behaviors === 1 ? '' : 's') : '';
return '<button type="button" class="list-group-item list-group-item-action px-1 py-1" data-focus="' + esc(span.position.join(',')) + '">' +
'<div class="text-truncate">' + esc(name) + ' <span class="text-body-secondary">LOT ' + esc(model.lot) + '</span></div>' +
'<div class="text-body-secondary">' + (model.ugcId && model.ugcId !== '0' ? 'UGC ' + esc(model.ugcId) + ' · ' : '') +
span.position.map((v) => v.toFixed(1)).join(', ') + esc(behaviors) + '</div></button>';
}).join('') + '</div>';
}
// 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 ? '' : '<dt class="col-5">' + esc(label) + '</dt><dd class="col-7 text-break">' + value + '</dd>';
$('propertyInfo').innerHTML = '<div class="text-body-secondary mb-2">As the capture saw it at this moment: ' + esc(world.zoneName || zoneName(world.zone)) +
' #' + esc(world.instance) + (world.clone ? ' (clone ' + esc(world.clone) + ')' : '') + '</div>' +
(info ? '<dl class="row mb-2">' +
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 ? ' <span class="text-body-secondary">(' + esc(info.rejectionReason) + ')</span>' : '') : '') +
field(GameText.term('reputation'), info.reputation !== undefined ? esc(info.reputation) : '') +
field('Models', now.count ? esc(now.count.count) : '') +
'</dl><div class="text-body-secondary mb-2">Property data from packet ' + esc(info.i) + '.</div>'
: '<div class="text-body-secondary mb-2">The capture has no property data for this world yet.</div>') +
(world.saved ? '<div class="mb-2"><a href="/properties/' + esc(world.saved.id) + '">The property now</a></div>' : '') +
'<h6 class="mb-1">Models standing here (' + now.models.length + ')</h6>' +
(now.models.length ? modelRows(now.models) : '<div class="text-body-secondary">None at this moment.</div>');
}
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 = '<div class="text-body-secondary">Each property is its own instance of this zone: pick one to see its placed models.</div>';
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 = '<div class="alert alert-warning py-1 px-2 mb-2">Placed models as they are <strong>now</strong>: position history doesn\'t record models, so they may differ from the time replayed.</div>' +
'<dl class="row mb-2"><dt class="col-5">Name</dt><dd class="col-7 text-break">' + esc(data.property.name) + '</dd>' +
'<dt class="col-5">Owner</dt><dd class="col-7">' + fmt.character(data.property.ownerId, data.property.ownerName) + '</dd></dl>' +
'<div class="mb-2"><a href="/properties/' + esc(data.property.id) + '">Open the property</a></div>' +
'<h6 class="mb-1">Placed models (' + data.models.length + ')</h6>' + 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);

View File

@@ -103,6 +103,7 @@
<span class="font-monospace text-nowrap" id="timeText"></span>
<button type="button" class="btn btn-sm btn-outline-secondary" id="endBtn" title="Show everything">End</button>
</div>
<div class="d-none mb-2 small" id="propertySummary" aria-label="Properties in this capture"></div>
<div class="row g-2 align-items-end mb-2">
<div class="col-md-5"><input class="form-control form-control-sm" id="nameFilter" placeholder="Packet name part, e.g. POSITION, GAME_MSG REQUEST_USE"></div>
<div class="col-md-3"><select class="form-select form-select-sm" id="sourceFilter" aria-label="Server">

View File

@@ -124,6 +124,7 @@
<li class="nav-item" role="presentation"><button class="nav-link active py-1" data-bs-toggle="pill" data-bs-target="#tabPlayers" type="button" role="tab">Players <span class="badge text-bg-secondary" id="playerCount">0</span></button></li>
<li class="nav-item" role="presentation"><button class="nav-link py-1" data-bs-toggle="pill" data-bs-target="#tabSelected" type="button" role="tab" id="selectedTabBtn">Selected</button></li>
<li class="nav-item" role="presentation"><button class="nav-link py-1" data-bs-toggle="pill" data-bs-target="#tabLayers" type="button" role="tab">Layers</button></li>
<li class="nav-item d-none" role="presentation" id="propertyTabItem"><button class="nav-link py-1" data-bs-toggle="pill" data-bs-target="#tabProperty" type="button" role="tab">Property</button></li>
</ul>
</div>
<div class="tab-content flex-grow-1 overflow-hidden">
@@ -134,6 +135,10 @@
<div class="tab-pane h-100 p-2 small" id="tabSelected" role="tabpanel">
<div id="selectionInfo"><span class="text-body-secondary">Click a player or an object.</span></div>
</div>
<div class="tab-pane h-100 p-2 small overflow-auto" id="tabProperty" role="tabpanel">
<div class="form-check form-switch mb-2"><input class="form-check-input" type="checkbox" id="propertyModelsToggle" data-pref="world3d.propertyModels" checked><label class="form-check-label" for="propertyModelsToggle">Placed models</label></div>
<div id="propertyInfo"></div>
</div>
<div class="tab-pane h-100 p-2 small" id="tabLayers" role="tabpanel">
<div class="fw-semibold mb-1">View</div>
<div class="d-flex flex-wrap align-items-center gap-2 mb-2">

View File

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

View File

@@ -0,0 +1,364 @@
#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;
}
// A model's component: the plain one, or the mutable one (with behaviors) property and inventory models get
const json* ModelFields(const json& fields) {
const auto* model = ComponentFields(fields, "MUTABLE_MODEL_BEHAVIORS");
return model ? model : ComponentFields(fields, "MODEL");
}
bool IsModel(const json& fields) {
if (fields.value("lot", 0) == BrickByBrick::MODEL_OBJECT_LOT) return true;
return ModelFields(fields) && !ComponentFields(fields, "PET");
}
// A config entry's value ("key=type:value")
std::string ConfigValue(const json& fields, const std::string& key) {
const auto found = fields.find("config");
if (found == fields.end()) return {};
// Compressed config shows its inflated entries under "entries"
const json* config = &*found;
if (config->is_object()) {
const auto entries = config->find("entries");
if (entries == config->end()) return {};
config = &*entries;
}
if (!config->is_array()) return {};
for (const auto& entry : *config) {
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 = ModelFields(*fields); 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 = ModelFields(*fields); component && component->contains("behaviors")) model.behaviors = (*component)["behaviors"];
const auto placed = Placed(*fields);
if (!placed || (!model.spans.empty() && model.spans.back().position == placed->first && model.spans.back().rotation == placed->second)) continue;
if (!model.spans.empty()) model.spans.back().to = t;
model.spans.push_back({ t, std::nullopt, i, placed->first, placed->second });
auto event = EventJson("moved", t, i, &model);
event["position"] = placed->first;
world.events.push_back(std::move(event));
continue;
}
// Game messages about the property
const auto decoded = PacketDecoder::Decode(record.bytes, fromClient);
if (decoded.gameMessageId < 0 || !decoded.name.starts_with(GAME_MSG)) continue;
const auto name = decoded.name.substr(std::char_traits<char>::length(GAME_MSG));
const json empty = json::object();
const auto& fields = decoded.fields ? *decoded.fields : empty;
const bool behavior = name.find("BEHAVIOR") != std::string::npos;
// A live server also sent the data of the player's properties elsewhere (another map): only this world's
// is about it (any, when the capture doesn't know the world's zone)
const bool otherProperty = name == "DOWNLOAD_PROPERTY_DATA" && h.zoneId && fields.value("mapId", 0) != h.zoneId;
const bool propertyMessage = !otherProperty && (name == "DOWNLOAD_PROPERTY_DATA" || name == "GET_MODELS_ON_PROPERTY" ||
name == "PLACE_MODEL_RESPONSE" || name == "DELETE_MODEL_FROM_CLIENT" || name == "PROPERTY_EDITOR_BEGIN" || name == "PROPERTY_EDITOR_END");
const auto existing = worlds.find({ h.zoneId, h.instanceId, h.cloneId });
if (!propertyMessage && !behavior && existing == worlds.end()) continue;
auto& world = existing != worlds.end() ? existing->second : worldOf(h, t);
if (propertyMessage) world.property = true;
if (name == "DOWNLOAD_PROPERTY_DATA" && !fromClient && decoded.fields && !otherProperty) {
json info{ {"t", t}, {"i", i} };
for (const char* key : { "propertyId", "name", "description", "ownerName", "ownerId", "accessType", "moderationStatus",
"rejectionReason", "reputation", "templateId", "mapId", "cloneId", "maxBuildHeight" }) {
if (fields.contains(key)) info[key] = fields[key];
}
if (const auto* moderation = Moderation(fields.value("moderationStatus", UINT32_MAX))) info["moderation"] = moderation;
// Sent again (to each player, on each edit): kept once while it says the same
auto said = info;
said.erase("t");
said.erase("i");
if (!world.info.empty()) {
auto before = world.info.back();
before.erase("t");
before.erase("i");
if (before == said) continue;
}
world.info.push_back(std::move(info));
} else if (name == "GET_MODELS_ON_PROPERTY" && !fromClient && fields.contains("models") && fields["models"].is_array()) {
// Each captured player on the world gets one: count it once while it says the same
const auto count = fields["models"].size();
if (world.counts.empty() || world.counts.back()["count"] != count) world.counts.push_back({ {"t", t}, {"i", i}, {"count", count} });
} else if (name == "PLACE_MODEL_RESPONSE" && !fromClient && fields.value("response", 0) == BrickByBrick::PLACE_MODEL_PLACED) {
Placement placement{ t, i, fields.value("position", json::array()), fields.value("rotation", json::array()) };
// The model it made: this server made it before answering (the latest one made there not placed yet), or
// it comes after
auto made = std::find_if(world.models.rbegin(), world.models.rend(), [&placement](const Model& model) {
return model.present && !model.placed && !model.spans.empty() && model.spans.front().position == placement.position;
});
if (made != world.models.rend()) world.events.push_back(PlacedJson(placement, *made));
else world.placements.push_back(std::move(placement));
} else if (name == "DELETE_MODEL_FROM_CLIENT" && fromClient && decoded.fields) {
const auto model = ObjectOf(fields, "modelID");
if (model) world.deletes[model] = { i, fields.value("reason", 0) };
} else if (name == "PROPERTY_EDITOR_BEGIN" || name == "PROPERTY_EDITOR_END") {
world.events.push_back(EventJson(name == "PROPERTY_EDITOR_BEGIN" ? "editing" : "stopped editing", t, i, nullptr));
}
// Behaviors: the behavior messages, and what was sent to or from a model while it stood there
const Model* model = nullptr;
auto target = decoded.objectId;
if (name == "CONTROL_BEHAVIORS" && fields.contains("args") && fields["args"].is_object()) {
const auto fromArgs = ObjectOf(fields["args"], "objectID");
if (fromArgs) target = fromArgs;
}
if (const auto it = world.byObject.find(target); it != world.byObject.end() && world.models[it->second].present) model = &world.models[it->second];
if (!behavior && !model) continue;
auto event = EventJson("behavior", t, i, model);
event["message"] = name;
event["toServer"] = fromClient;
if (name == "CONTROL_BEHAVIORS" && fields.contains("command")) event["command"] = fields["command"];
world.events.push_back(std::move(event));
}
json out = json::array();
for (auto& [key, world] : worlds) {
if (!world.property && world.models.empty()) continue;
json models = json::array();
for (const auto& model : world.models) {
json spans = json::array();
for (const auto& span : model.spans) spans.push_back(SpanJson(span));
models.push_back({ {"object", std::to_string(model.object)}, {"lot", model.lot}, {"spawner", model.spawner},
{"ugcId", model.ugcId}, {"behaviors", model.behaviors}, {"spans", spans} });
}
// Placements whose model was never seen made (made before the capture could read it, or refused later)
for (const auto& placement : world.placements) {
auto event = EventJson("placed", placement.t, placement.index, nullptr);
event["position"] = placement.position;
event["rotation"] = placement.rotation;
world.events.push_back(std::move(event));
}
std::stable_sort(world.events.begin(), world.events.end(), [](const json& a, const json& b) { return a["i"].get<size_t>() < b["i"].get<size_t>(); });
out.push_back({ {"zone", world.zone}, {"instance", world.instance}, {"clone", world.clone}, {"t", world.t}, {"property", world.property},
{"info", world.info}, {"counts", world.counts}, {"models", models}, {"events", world.events} });
}
return { {"worlds", out} };
}
}

View File

@@ -0,0 +1,39 @@
#ifndef __CAPTUREPROPERTY__H__
#define __CAPTUREPROPERTY__H__
#include <cstdint>
#include <functional>
#include <vector>
#include "CaptureBundle.h"
#include "json.hpp"
/**
* A property as a packet capture saw it, for the capture viewer and the World 3D replay: per world (zone, instance
* and clone) the models placed on it over time and what the property said about itself.
*
* Everything comes from the captured packets:
* - models: the replica constructions, serializations and destructions of objects with a model component (and brick
* built models, LOT 14), each with its position and rotation per time span (from its physics component);
* - placed: the server's PlaceModelResponse saying a model was placed, with the model that was then made at that
* position; removed: a model's destruction, with the reason from the DeleteModelFromClient that asked for it;
* moved: a model's serialization with a new position;
* - info: each DownloadPropertyData (owner, name, description, access, moderation, reputation) and each
* GetModelsOnProperty (how many models the property had);
* - behaviors: the behavior messages (ControlBehaviors and the others named for behaviors) and the game messages
* sent to or from a model while it stood there.
* A world is listed when a property message was captured on it or a model was constructed there.
*/
namespace CaptureProperty {
// The fields ReplicaDecoder read from record `index` (nullptr when it isn't a replica packet)
using ReplicaFields = std::function<const nlohmann::json*(size_t index)>;
/**
* {worlds: [{zone, instance, clone, t, info: [...], counts: [...], models: [...], events: [...]}]}; times are seconds
* from `startUs`, `i` the record's index. A model: {object, lot, spawner, ugcId, behaviors, spans: [{from, to, i,
* position, rotation}]} (`to` null: still there when the capture ended).
*/
nlohmann::json Build(const std::vector<CaptureBundle::Record>& records, int64_t startUs, const ReplicaFields& replica);
}
#endif //!__CAPTUREPROPERTY__H__

View File

@@ -91,6 +91,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();

View File

@@ -16,6 +16,30 @@ The dashboard serves them with the zone's name from the client's locale (`/api/i
its slider; World 3D switches its scene to the followed character's world at that time (`world3d-core.js`
`captureSwitch`, `worldMarkers`).
## Properties in a capture
`CaptureProperty::Build` (`/api/inspector/sessions/:id/property`, `CaptureTool property`) reads the property each
world of a capture was, from its packets alone:
- **Models:** objects constructed with a model component (and brick built models, LOT 14), each with its LOT, object
ID, spawner, blueprint (the UGC ID, from its config) and behavior count, and where it stood per time span (its
physics component's position and rotation, from the construction and each serialization that moves it) until its
destruction.
- **Events:** placed (a `PlaceModelResponse` saying a model was placed, with the model made at that exact position:
this server makes it before answering, a live server after), moved (a serialization with a new position), removed
(a destruction, with the reason from the `DeleteModelFromClient` that asked for it), editing, and behavior messages
(those named for behaviors, and any game message sent to or from a model while it stood there).
- **Property data:** each `DownloadPropertyData` for this world's map (a live server also sent the player's other
properties), kept once while it says the same, and each `GetModelsOnProperty`'s model count.
The dashboard adds the zone's and the models' names from the locale and the property saved now for that zone and clone
(its link, and the `?property=` that lets the UGC mesh route serve its models). World 3D draws each model where it stood
at the playhead (the UGC server's mesh for a brick built model when it has one, a box otherwise), shows the property
data of that moment in its Property tab and ticks the behavior messages on the timeline; seeking either way reads the
spans again, nothing is replayed. The capture page lists each property with its owner and every model placed, moved or
removed, each jumping to its packet. A replay of recorded positions on one property instance shows the models placed
now (`/api/world3d/property_models`), labelled as such, since position history doesn't record models.
Captures, bundles and fixtures are player data. None of them is ever committed: `captures/`, `*.bundle` and
`tests/fixtures-local/` are in `.gitignore`.
@@ -122,7 +146,9 @@ thread, and the result is kept with the loaded capture:
age, config, trigger, spawner, spawner node, scale, world state, GM level, parent and children), then each
component's data in the order the client reads them (`Entity::WriteComponents`). Which components an object has
comes from the ComponentsRegistry rows of its LOT, read from the CDClient at startup, with the ones the server adds
itself (a destroyable for collectibles, quick builds and models, the character's parts, a buff with a destroyable).
itself (a destroyable for collectibles, quick builds and models, the character's parts, a buff with a destroyable)
and without the item component of a model (its model component writes the item info). A compressed config (live
servers compressed model configs) is inflated and read.
- **Serialization:** the object's LOT and components from its construction earlier on the same world instance, then
each component's update. An object constructed before the capture started shows "object not constructed in this
capture" and the bytes.
@@ -211,6 +237,7 @@ CaptureTool replay <bundle>... --client <game client folder> [--cdserver <CDServ
CaptureTool import-live <folder> <out-dir> convert live captures
CaptureTool anonymise <in> <out> make a fixture
CaptureTool info|decode <bundle> [--cdserver <CDServer.sqlite>] look inside (replica packets too with --cdserver)
CaptureTool property <bundle> --cdserver <CDServer.sqlite> the properties it saw (JSON)
```
### The sandbox
@@ -290,6 +317,12 @@ decoder registry knows and checks it writes back to the same bytes; without fixt
- In a capture of a zone load: every packet shows fields; constructions list their components (no
`(layout did not match)` on players, enemies, smashables, NPCs); a later `ID_REPLICA_MANAGER_SERIALIZE` of an enemy
hit in the capture shows its new health; game messages both ways (a skill, an emote, a vendor purchase) show fields.
- Capture a property visit where you place, pick up and put away a model, change the name or privacy, and run a
model's behavior: the capture page lists the property and its owner with each placement and removal (clicking one
selects its packet); in World 3D the models stand where they were at the playhead (brick built ones as the UGC
server made them), appear and vanish at those moments forward and when seeking back, the Property tab's name,
privacy and model count change when the update arrives, and the behavior messages are ticks on the timeline.
- Replay recorded positions on one property instance: its placed models show, marked as how they are now.
- Export a bundle, replay it with `CaptureTool replay`, and open a kept sandbox's logs.
- Moving platforms (Starbase 3001's snail, Nexus Tower's lifts) move as before, and objects built with them look right.
- A tamed pet that follows you through a fight and an untamed one being tamed: status and name updates show as before.

View File

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

View File

@@ -0,0 +1,231 @@
// A property as a packet capture saw it (CaptureProperty): a synthetic capture made from the server's own writers (a
// brick built model and its replica packets, and the property's game messages as the server and client send them),
// read back into the models' timeline, the property's info and the events the capture page lists.
#include "GameDependencies.h"
#include <gtest/gtest.h>
#include "BitStream.h"
#include "BrickByBrick.h"
#include "CaptureBundle.h"
#include "CaptureProperty.h"
#include "CaptureTools.h"
#include "DestroyableComponent.h"
#include "Entity.h"
#include "GameMessageDecoder.h"
#include "MessageIdentifiers.h"
#include "MessageType/Client.h"
#include "MessageType/World.h"
#include "ModelComponent.h"
#include "PacketDecoder.h"
#include "PropertyMessages.h"
#include "ReplicaDecoder.h"
#include "ServiceType.h"
#include "SimplePhysicsComponent.h"
#include "eReplicaComponentType.h"
#include "eReplicaPacketType.h"
namespace {
using json = nlohmann::json;
using enum eReplicaComponentType;
constexpr LWOOBJID PLAYER = 1152921506064087003;
constexpr LWOOBJID FIRST = 288300744895900001, SECOND = 288300744895900002, THIRD = 288300744895900003;
std::string Bytes(const RakNet::BitStream& stream) { return std::string(reinterpret_cast<const char*>(stream.GetData()), stream.GetNumberOfBytesUsed()); }
std::string Construction(Entity& entity, uint16_t network) {
RakNet::BitStream stream;
stream.Write<uint8_t>(ID_REPLICA_MANAGER_CONSTRUCTION);
stream.Write1();
stream.Write(network);
entity.WriteBaseReplicaData(stream, eReplicaPacketType::CONSTRUCTION);
entity.WriteComponents(stream, eReplicaPacketType::CONSTRUCTION);
return Bytes(stream);
}
std::string Serialization(Entity& entity, uint16_t network) {
RakNet::BitStream stream;
stream.Write<uint8_t>(ID_REPLICA_MANAGER_SERIALIZE);
stream.Write(network);
entity.WriteBaseReplicaData(stream, eReplicaPacketType::SERIALIZATION);
entity.WriteComponents(stream, eReplicaPacketType::SERIALIZATION);
return Bytes(stream);
}
std::string Destruction(uint16_t network) {
RakNet::BitStream stream;
stream.Write<uint8_t>(ID_REPLICA_MANAGER_DESTRUCTION);
stream.Write(network);
return Bytes(stream);
}
// A game message as the server sends it (CLIENT GAME_MSG) or the client does (WORLD GAME_MSG)
std::string GameMessage(bool toServer, LWOOBJID object, const GameMessages::NetGameMsg& message) {
RakNet::BitStream stream;
if (toServer) LUBitStream(ServiceType::WORLD, MessageType::World::GAME_MSG).WriteHeader(stream);
else LUBitStream(ServiceType::CLIENT, MessageType::Client::GAME_MSG).WriteHeader(stream);
stream.Write(object);
stream.Write(message.msgId);
message.Serialize(stream);
return Bytes(stream);
}
struct Capture {
std::vector<CaptureBundle::Record> records;
// One world packet, a second after the one before
void Add(bool fromClient, std::string bytes) {
CaptureBundle::Record record;
record.header.seq = static_cast<uint32_t>(records.size());
record.header.timeUs = 1000000 * static_cast<int64_t>(records.size() + 1);
record.header.source = static_cast<uint8_t>(eCaptureSource::WORLD);
record.header.direction = static_cast<uint8_t>(fromClient ? ePacketDirection::RECEIVED : ePacketDirection::SENT);
record.header.zoneId = 1150;
record.header.instanceId = 7;
record.header.cloneId = 42;
record.header.characterId = PLAYER;
record.header.bits = static_cast<uint32_t>(bytes.size() * 8);
record.bytes = std::move(bytes);
records.push_back(std::move(record));
}
};
GameMessages::DownloadPropertyData PropertyData(const std::u16string& name, uint64_t reputation) {
GameMessages::DownloadPropertyData data;
data.propertyId = 1234;
data.mapId = 1150;
data.name = name;
data.description = u"A garden";
data.ownerName = u"Builder";
data.ownerId = PLAYER;
data.reputation = reputation;
data.accessType = 2;
data.moderationStatus = 1;
return data;
}
}
class CapturePropertyTest : public GameDependenciesTest {
protected:
void SetUp() override {
SetUpDependencies();
PacketDecoder::SetGameMessageDecoder([](MessageType::Game id, bool toServer, RakNet::BitStream& payload) {
return GameMessageDecoder::Decode(id, toServer, payload);
});
}
void TearDown() override { TearDownDependencies(); }
std::unique_ptr<Entity> MakeModel(LWOOBJID id, const NiPoint3& position, LWOOBJID blueprint) {
info.lot = BrickByBrick::MODEL_OBJECT_LOT;
info.settings = {};
info.settings.Insert<LWOOBJID>(u"blueprintid", blueprint);
auto model = std::make_unique<Entity>(id, info);
model->AddComponent<SimplePhysicsComponent>(-1)->SetPosition(position);
model->AddComponent<ModelComponent>(-1)->LoadBehaviors();
model->AddComponent<DestroyableComponent>(-1)->SetIsSmashable(true);
return model;
}
};
TEST_F(CapturePropertyTest, ModelsInfoAndEventsOverTime) {
Capture capture;
const NiPoint3 a(1.0f, 2.0f, 3.0f), b(10.0f, 2.0f, -4.0f), c(5.0f, 2.0f, 5.0f);
auto first = MakeModel(FIRST, a, 5551);
auto second = MakeModel(SECOND, b, 5552);
capture.Add(false, GameMessage(false, PLAYER, PropertyData(u"Old name", 10))); // 0: the property as it loads
capture.Add(false, Construction(*first, 20)); // 1: a model already there
GameMessages::PlaceModelResponse placed;
placed.position = b;
placed.response = BrickByBrick::PLACE_MODEL_PLACED;
capture.Add(false, GameMessage(false, PLAYER, placed)); // 2: the server placed one...
capture.Add(false, Construction(*second, 21)); // 3: ...and made it
first->GetComponent<SimplePhysicsComponent>()->SetPosition(c);
capture.Add(false, Serialization(*first, 20)); // 4: the first one moves
GameMessages::PlayBehaviorSound sound;
sound.soundID = 3;
capture.Add(false, GameMessage(false, FIRST, sound)); // 5: its behavior plays a sound
GameMessages::DeleteModelFromClient pickUp;
pickUp.modelID = SECOND;
pickUp.reason = static_cast<int32_t>(BrickByBrick::eDeleteReason::PICKING_MODEL_UP);
capture.Add(true, GameMessage(true, PLAYER, pickUp)); // 6: the player picks the second up
capture.Add(false, Destruction(21)); // 7: and it goes
capture.Add(false, GameMessage(false, PLAYER, PropertyData(u"New name", 25))); // 8: renamed
auto elsewhere = PropertyData(u"Another property", 5);
elsewhere.mapId = 1250;
capture.Add(false, GameMessage(false, PLAYER, elsewhere)); // 9: the player's property on another map
capture.Add(false, GameMessage(false, PLAYER, PropertyData(u"New name", 25))); // 10: the same data again
// This server makes the model before it answers the placement
const NiPoint3 d(-8.8f, 455.04f, 126.4f);
auto third = MakeModel(THIRD, d, 5553);
capture.Add(false, Construction(*third, 22)); // 11: made...
placed.position = d;
capture.Add(false, GameMessage(false, PLAYER, placed)); // 12: ...then placed
const ReplicaDecoder::ComponentTable table{ { BrickByBrick::MODEL_OBJECT_LOT, {} } };
ReplicaDecoder::Session session(table);
std::vector<std::optional<json>> replica(capture.records.size());
for (size_t i = 0; i < capture.records.size(); i++) replica[i] = session.Decode(capture.records[i].bytes, CaptureTools::ReplicaConnection(capture.records[i].header));
const auto out = CaptureProperty::Build(capture.records, 0, [&](size_t i) { return replica[i] ? &*replica[i] : nullptr; });
ASSERT_EQ(out["worlds"].size(), 1u) << out.dump();
const auto& world = out["worlds"][0];
EXPECT_EQ(world["zone"], 1150);
EXPECT_EQ(world["instance"], 7);
EXPECT_EQ(world["clone"], 42);
// The property as each DownloadPropertyData said it, leaving out the one about another map
ASSERT_EQ(world["info"].size(), 2u);
EXPECT_EQ(world["info"][0]["name"], "Old name");
EXPECT_EQ(world["info"][0]["ownerName"], "Builder");
EXPECT_EQ(world["info"][0]["reputation"], "10");
EXPECT_EQ(world["info"][0]["i"], 0);
EXPECT_EQ(world["info"][1]["name"], "New name");
EXPECT_EQ(world["info"][1]["reputation"], "25");
// The models: where each stood and when
ASSERT_EQ(world["models"].size(), 3u);
const auto& m1 = world["models"][0];
EXPECT_EQ(m1["object"], std::to_string(FIRST));
EXPECT_EQ(m1["lot"], BrickByBrick::MODEL_OBJECT_LOT);
EXPECT_EQ(m1["ugcId"], "5551");
ASSERT_EQ(m1["spans"].size(), 2u) << m1.dump();
EXPECT_EQ(m1["spans"][0]["from"], 2.0);
EXPECT_EQ(m1["spans"][0]["to"], 5.0);
EXPECT_EQ(m1["spans"][0]["position"], json::array({ 1.0f, 2.0f, 3.0f }));
EXPECT_EQ(m1["spans"][1]["from"], 5.0);
EXPECT_TRUE(m1["spans"][1]["to"].is_null());
EXPECT_EQ(m1["spans"][1]["position"], json::array({ 5.0f, 2.0f, 5.0f }));
const auto& m2 = world["models"][1];
EXPECT_EQ(m2["ugcId"], "5552");
ASSERT_EQ(m2["spans"].size(), 1u);
EXPECT_EQ(m2["spans"][0]["from"], 4.0);
EXPECT_EQ(m2["spans"][0]["to"], 8.0);
// What the capture page lists: placed (the server's answer, with the model it made), moved, the behavior's
// message and removed (with the reason the client gave)
std::vector<std::string> kinds;
for (const auto& event : world["events"]) kinds.push_back(event["kind"]);
EXPECT_EQ(kinds, (std::vector<std::string>{ "placed", "moved", "behavior", "removed", "placed" })) << world["events"].dump();
EXPECT_EQ(world["events"][0]["i"], 2);
EXPECT_EQ(world["events"][0]["object"], std::to_string(SECOND));
EXPECT_EQ(world["events"][0]["made"], 3);
EXPECT_EQ(world["events"][1]["object"], std::to_string(FIRST));
EXPECT_EQ(world["events"][2]["message"], "PLAY_BEHAVIOR_SOUND");
EXPECT_EQ(world["events"][2]["object"], std::to_string(FIRST));
EXPECT_EQ(world["events"][3]["object"], std::to_string(SECOND));
EXPECT_EQ(world["events"][3]["reason"], "picked up");
EXPECT_EQ(world["events"][3]["asked"], 6);
EXPECT_EQ(world["events"][4]["i"], 12);
EXPECT_EQ(world["events"][4]["object"], std::to_string(THIRD));
EXPECT_EQ(world["events"][4]["made"], 11);
}
// A world with no property message and no model isn't a property
TEST_F(CapturePropertyTest, OtherWorldsAreLeftOut) {
Capture capture;
GameMessages::PlayBehaviorSound sound;
capture.Add(false, GameMessage(false, PLAYER, sound));
const auto out = CaptureProperty::Build(capture.records, 0, [](size_t) { return nullptr; });
EXPECT_TRUE(out["worlds"].empty()) << out.dump();
}

View File

@@ -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"
@@ -142,11 +141,13 @@ 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<SimplePhysicsComponent>(-1);
auto* const physics = model.AddComponent<SimplePhysicsComponent>(-1);
physics->SetPosition(NiPoint3(-12.0f, 432.96f, -49.6f));
// The model component writes the item's user-generated-content block itself, where the client reads the item
model.AddComponent<ModelComponent>(-1)->LoadBehaviors();
auto* const destroyable = model.AddComponent<DestroyableComponent>(-1);
@@ -157,8 +158,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
@@ -366,4 +369,45 @@ TEST_F(ReplicaDecoderTest, ModelBehaviorsOnlyOnPropertyModels) {
EXPECT_EQ(fields->contains("behaviors"), onProperty);
}
info.settings.values.clear();
// 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<const char*>(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<float>(), -12.0f);
EXPECT_NEAR(position[1].get<float>(), 432.96f, 0.001f);
EXPECT_NEAR(position[2].get<float>(), -49.6f, 0.001f);
EXPECT_EQ((*FieldsOf(*constructed, "MODEL"))["modelID"], "12504");
EXPECT_EQ((*FieldsOf(*constructed, "MODEL"))["behaviors"], 0);
}

View File

@@ -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()

View File

@@ -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 <world3d-core.js>
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(/</g, '&lt;'));
same(html.includes('data-t="6"') && html.includes('left:50.000%'), true, 'the tick seeks to its message');
if (failures) {
console.error(`${failures} failure(s)`);
process.exit(1);
}
console.log('world3d property: ok');