diff --git a/dCaptureTool/CaptureTool.cpp b/dCaptureTool/CaptureTool.cpp index f71c1fd60..9162d7dfd 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/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) { 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", 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/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/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/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 @@ +
+
+
View
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..9c469ff5e --- /dev/null +++ b/dGame/dUtilities/CaptureProperty.cpp @@ -0,0 +1,364 @@ +#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; + } + + // 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(); + 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 = 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::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/dGame/dUtilities/ReplicaDecoder.cpp b/dGame/dUtilities/ReplicaDecoder.cpp index 797860ecf..f2fa8f645 100644 --- a/dGame/dUtilities/ReplicaDecoder.cpp +++ b/dGame/dUtilities/ReplicaDecoder.cpp @@ -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(); diff --git a/docs/CaptureReplay.md b/docs/CaptureReplay.md index 891a8426a..1e5669450 100644 --- a/docs/CaptureReplay.md +++ b/docs/CaptureReplay.md @@ -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 ... --client [--cdserver convert live captures CaptureTool anonymise make a fixture CaptureTool info|decode [--cdserver ] look inside (replica packets too with --cdserver) +CaptureTool property --cdserver 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. 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(); +} diff --git a/tests/dGameTests/dComponentsTests/ReplicaDecoderTests.cpp b/tests/dGameTests/dComponentsTests/ReplicaDecoderTests.cpp index ae5442dac..a9df395ba 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" @@ -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(-1); + auto* const physics = model.AddComponent(-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(-1)->LoadBehaviors(); auto* const destroyable = model.AddComponent(-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(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); } 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(/