/** * Packet Captures (docs/CaptureReplay.md): arm a capture for an account, a character or everything; the running and * saved ones; and a viewer that plays a capture back on one timeline (play, pause, seek, speed), with each packet's * decoded fields and bytes. While it plays, the time is sent to World 3D pages showing the same capture. The timeline * marks each time a captured character moved to another world server, with the zone's name. */ (function () { 'use strict'; var byId = function (id) { return document.getElementById(id); }; var nf = new Intl.NumberFormat(); var MAX_LOADED = 50000; // packets loaded into the viewer var MAX_ROWS = 400; // table rows drawn at once (the ones up to the playhead) var channel = 'BroadcastChannel' in window ? new BroadcastChannel('dlu-capture-replay') : null; var state = { live: [], // running and recently finished, from the server saved: [], // saved packet captures capture: null, // the one in the viewer records: [], // its packets, in time order (without fields) duration: 0, // ms playhead: 0, // ms from its start playing: false, selected: -1, loading: false, worlds: [] // the characters' moves between world servers (the worlds route) }; function bytesText(n) { if (n < 1024) return n + ' B'; if (n < 1024 * 1024) return (n / 1024).toFixed(1) + ' KB'; return (n / 1024 / 1024).toFixed(1) + ' MB'; } function timeText(ms) { var s = Math.max(0, ms) / 1000, m = Math.floor(s / 60); return m + ':' + (s - m * 60).toFixed(3).padStart(6, '0'); } function url(id) { return '/api/inspector/sessions/' + id; } // ---- target picker ---- function target() { return document.querySelector('input[name="target"]:checked').value; } var picker = SearchSelect(byId('targetPicker'), { search: function (q) { return api.get('/api/inspector/targets?q=' + encodeURIComponent(q)).then(function (d) { var items = []; (d.accounts || []).forEach(function (a) { if (target() === 'account') { items.push({ value: String(a.id), label: a.name + ' (account ' + a.id + ')' + (a.online ? ' · online' : ''), detail: a.characters.map(function (c) { return c.name + (c.online ? ' (online)' : ''); }).join(', ') || 'No characters' }); } else { a.characters.forEach(function (c) { items.push({ value: c.id, label: c.name + ' (' + c.id + ')' + (c.online ? ' · online' : ''), detail: 'Account ' + a.name + ' (' + a.id + ')' }); }); } }); return items; }); } }); function onTarget() { var t = target(); byId('pickerWrap').classList.toggle('d-none', t === 'everything'); byId('everythingHint').classList.toggle('d-none', t !== 'everything'); byId('pickerLabel').textContent = t === 'account' ? 'Account' : 'Character'; picker.set('', ''); } document.querySelectorAll('input[name="target"]').forEach(function (r) { r.addEventListener('change', onTarget); }); byId('armForm').addEventListener('submit', function (e) { e.preventDefault(); var t = target(), body = { target: t, seconds: parseInt(byId('armSeconds').value, 10) }; if (t !== 'everything') { var value = byId('targetPicker').dataset.value; if (!value) { toast('Pick ' + (t === 'account' ? 'an account' : 'a character') + ' from the list', 'warning'); return; } body[t] = value; } api.action('/api/inspector/packet-captures', body, 'Capture armed on every server').then(function (d) { upsert(d.capture); open(d.capture.id); }).catch(function () {}); }); // ---- list ---- function upsert(capture) { var i = state.live.findIndex(function (c) { return c.id === capture.id; }); if (i >= 0) state.live[i] = capture; else state.live.unshift(capture); if (state.capture && state.capture.id === capture.id) { state.capture = capture; renderHeader(); } renderList(); } function renderList() { var seen = {}, all = []; state.live.concat(state.saved).forEach(function (c) { if (!seen[c.id]) { seen[c.id] = true; all.push(c); } }); byId('captureList').innerHTML = all.length ? all.map(function (c) { var active = state.capture && state.capture.id === c.id; var badge = c.state === 'ended' ? '' : ' capturing'; return '
' + '
' + esc(c.describe || c.target) + '' + badge + '
' + '
' + esc(fmt.unix(c.startedAt)) + ' · ' + nf.format(c.received) + ' packets · ' + esc(bytesText(c.bytes)) + (c.dropped ? ' · ' + nf.format(c.dropped) + ' lost' : '') + '
'; }).join('') : '
No packet captures yet.
'; } byId('captureList').addEventListener('click', function (e) { var item = e.target.closest('[data-id]'); if (item) open(parseInt(item.dataset.id, 10)); }); function refreshList() { return Promise.all([ api.get('/api/inspector/packet-captures').then(function (d) { state.live = d.captures || []; }), api.get('/api/inspector/sessions?limit=100').then(function (d) { state.saved = (d.sessions || []).filter(function (s) { return s.kind === 1; }).map(function (s) { if (!s.describe) s.describe = s.target === 'everything' ? 'everything' : s.target === 'character' ? s.characterName : s.accountName; return s; }); }) ]).then(renderList).catch(function () {}); } // ---- viewer ---- function renderHeader() { var c = state.capture; if (!c) return; byId('viewTitle').textContent = 'Capture ' + c.id + ': ' + (c.describe || c.target); byId('viewSubtitle').textContent = 'Armed by ' + c.startedBy + ' ' + fmt.unix(c.startedAt) + ' · ' + nf.format(c.received) + ' packets, ' + bytesText(c.bytes) + (c.dropped ? ' · ' + nf.format(c.dropped) + ' lost on the servers' : '') + ' · ' + (c.state === 'ended' ? (c.endReason || 'ended') : 'capturing until ' + fmt.unix(c.endsAt)); byId('stopBtn').classList.toggle('d-none', c.state === 'ended'); byId('deleteBtn').classList.toggle('d-none', c.state !== 'ended'); byId('bundleBtn').href = url(c.id) + '/bundle'; byId('fixtureBtn').href = url(c.id) + '/bundle?anonymise=1'; } function open(id) { stop(); state.capture = state.live.concat(state.saved).filter(function (c) { return c.id === id; })[0] || { id: id, received: 0, bytes: 0 }; 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'); byId('emptyViewer').classList.add('d-none'); byId('detail').classList.add('d-none'); byId('grid').classList.add('no-detail'); renderHeader(); renderList(); load(true); } // Loads the capture's packets (the new ones, when `fresh` is false and it is still running) function load(fresh) { var c = state.capture; if (!c || state.loading) return Promise.resolve(); state.loading = true; var offset = fresh ? 0 : state.records.length; var step = function () { return api.get(url(c.id) + '/packets?fields=0&limit=2000&offset=' + offset).then(function (d) { if (!state.capture || state.capture.id !== c.id) return; if (d.success === false) { toast(d.error || 'Could not load the capture', 'danger'); return; } if (offset === 0) state.records = []; Array.prototype.push.apply(state.records, d.records); state.duration = d.duration || 0; state.capture = Object.assign(state.capture, d.capture); offset += d.records.length; if (d.records.length === 2000 && state.records.length < MAX_LOADED) return step(); }); }; return step().then(function () { state.loading = false; var worlds = state.records.filter(function (r) { return r.source === 'world' && r.zone; }); var zone = worlds.length ? worlds[0].zone : ''; byId('world3dBtn').href = '/world3d?capture=' + c.id + (zone ? '&zone=' + zone : ''); byId('scrub').max = String(Math.max(1, Math.ceil(state.duration))); if (fresh) setPlayhead(state.duration); 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) { var d = results[0], core = results[1]; if (!state.capture || state.capture.id !== id || !d.worlds) return; state.worlds = d.worlds; var characters = {}; d.worlds.forEach(function (w) { characters[w.character] = true; }); var markers = core.worldMarkers(d.worlds, state.duration / 1000); byId('worldMarkers').innerHTML = core.markersHtml(markers, esc, Object.keys(characters).length > 1); }).catch(function () {}); } function filtered() { var name = byId('nameFilter').value.trim().toUpperCase(), source = byId('sourceFilter').value; if (!name && !source) return state.records; return state.records.filter(function (r) { return (!source || r.source === source) && (!name || String(r.name || '').indexOf(name) >= 0); }); } // The rows up to the playhead (the last MAX_ROWS of them) function render() { var list = filtered(), until = 0; while (until < list.length && list[until].t <= state.playhead) until++; var from = Math.max(0, until - MAX_ROWS); byId('rows').innerHTML = list.slice(from, until).map(function (r) { var cls = (r.gap ? 'gap ' : '') + (r.toServer ? 'to-server ' : '') + (r.i === state.selected ? 'selected' : ''); return '' + r.i + '' + timeText(r.t) + '' + '' + esc(r.source + (r.source === 'world' && r.zone ? ' ' + r.zone : '')) + '' + '' + esc(r.gap ? '' : (r.from + ' → ' + r.to)) + '' + '' + esc(r.name || '') + (r.cut ? ' cut' : '') + (r.unreadable ? ' unreadable' : '') + '' + '' + nf.format(r.bytes) + ''; }).join(''); byId('countText').textContent = nf.format(until) + ' of ' + nf.format(list.length) + ' shown' + (list.length !== state.records.length ? ' (filtered from ' + nf.format(state.records.length) + ')' : '') + (state.records.length >= MAX_LOADED ? '; the first ' + nf.format(MAX_LOADED) + ' are loaded' : ''); var wrap = byId('tableWrap'); if (state.playing || state.playhead >= state.duration) wrap.scrollTop = wrap.scrollHeight; } function setPlayhead(ms) { state.playhead = Math.max(0, Math.min(state.duration, ms)); byId('scrub').value = String(Math.round(state.playhead)); byId('timeText').textContent = timeText(state.playhead) + ' / ' + timeText(state.duration); render(); if (channel && state.capture) channel.postMessage({ capture: state.capture.id, t: state.playhead / 1000 }); } var lastFrame = 0; function frame(now) { if (!state.playing) return; var dt = lastFrame ? Math.min(now - lastFrame, 250) : 0; lastFrame = now; setPlayhead(state.playhead + dt * Number(byId('speed').value)); if (state.playhead >= state.duration) { stop(); return; } requestAnimationFrame(frame); } function play() { if (!state.records.length) return; if (state.playhead >= state.duration) setPlayhead(0); state.playing = true; lastFrame = 0; byId('playBtn').textContent = 'Pause'; requestAnimationFrame(frame); } function stop() { state.playing = false; byId('playBtn').textContent = 'Play'; } byId('playBtn').addEventListener('click', function () { if (state.playing) stop(); else play(); }); byId('endBtn').addEventListener('click', function () { stop(); setPlayhead(state.duration); }); byId('scrub').addEventListener('input', function () { stop(); setPlayhead(Number(byId('scrub').value)); }); byId('worldMarkers').addEventListener('click', function (e) { var marker = e.target.closest('[data-t]'); if (!marker) return; stop(); setPlayhead(Number(marker.dataset.t) * 1000); }); byId('nameFilter').addEventListener('input', render); byId('sourceFilter').addEventListener('change', render); document.addEventListener('keydown', function (e) { if (e.key !== ' ' || !state.capture || /INPUT|SELECT|TEXTAREA|BUTTON/.test(document.activeElement.tagName)) return; e.preventDefault(); if (state.playing) stop(); else play(); }); // ---- one packet ---- function hexdump(hex) { var lines = []; for (var at = 0; at < hex.length; at += 32) { var chunk = hex.slice(at, at + 32), bytes = [], text = ''; for (var i = 0; i < chunk.length; i += 2) { var b = parseInt(chunk.substr(i, 2), 16); bytes.push(chunk.substr(i, 2)); text += b >= 32 && b < 127 ? String.fromCharCode(b) : '.'; } lines.push((at / 2).toString(16).padStart(6, '0') + ' ' + bytes.join(' ').padEnd(47, ' ') + ' ' + text); } return lines.join('\n'); } byId('rows').addEventListener('click', function (e) { var row = e.target.closest('[data-i]'); 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; byId('detail').classList.remove('d-none'); byId('grid').classList.remove('no-detail'); byId('detailTitle').textContent = r.name || 'Packet'; byId('detailMeta').textContent = '#' + r.i + ' at ' + timeText(r.t) + ' · ' + (r.from || '') + ' → ' + (r.to || '') + ' · ' + r.source + (r.zone ? ' ' + r.zone + ':' + r.instance + (r.clone ? ':' + r.clone : '') : '') + ' · ' + nf.format(r.bytes) + ' bytes' + (r.account ? ' · account ' + r.account : '') + (r.character !== '0' ? ' · character ' + r.character : '') + (r.peer ? ' · ' + r.peer : ''); 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'); state.selected = -1; render(); }); byId('stopBtn').addEventListener('click', function () { if (!state.capture) return; api.action('/api/inspector/packet-captures/' + state.capture.id + '/stop', {}, 'Capture stopped').then(function (d) { upsert(d.capture); }).catch(function () {}); }); byId('deleteBtn').addEventListener('click', function () { var c = state.capture; if (!c) return; Decide({ title: 'Delete capture ' + c.id, text: 'Deletes the capture and its ' + nf.format(c.received) + ' packets for good.', reasonLabel: 'Why (for the audit log)', action: 'Delete' }) .then(function (choice) { if (!choice) return; return api.action(url(c.id) + '/delete', { reason: choice.reason }, 'Capture deleted').then(function () { state.live = state.live.filter(function (x) { return x.id !== c.id; }); state.saved = state.saved.filter(function (x) { return x.id !== c.id; }); state.capture = null; byId('viewer').classList.add('d-none'); byId('emptyViewer').classList.remove('d-none'); renderList(); }); }).catch(function () {}); }); // ---- live ---- var reloadTimer = null; Live.onTopic('packet_capture', function (message) { upsert(message.capture); // New packets of the open capture: load them (at most every 2 seconds) if (state.capture && message.capture.id === state.capture.id && message.recent && message.recent.length && !reloadTimer) { reloadTimer = setTimeout(function () { reloadTimer = null; var atEnd = state.playhead >= state.duration; load(false).then(function () { if (atEnd && !state.playing) setPlayhead(state.duration); }); }, 2000); } }); onTarget(); refreshList().then(function () { var match = /capture=(\d+)/.exec(window.location.hash); if (match) open(parseInt(match[1], 10)); }); })();