feat(dashboard): play packet captures back and replay their movement in World 3D

The Packet Captures page (/inspector/packets, dev_message_inspector) arms captures with a
search picker (part of an account or character name, or a pasted account or object ID; online
ones marked; everything needs no pick), lists running and saved ones live (packet_capture
topic), and plays one back on its timeline: play, pause, seek and speed, packets appearing in
order with their decoded fields and bytes. World 3D takes ?capture=<id>&zone=<zone> and replays
the captured position updates in its replay mode; the capture page's playhead drives it over a
BroadcastChannel. Saved packet captures show in the Message Inspector's list and open there.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Aaron Kimbrell
2026-09-27 08:48:25 -05:00
parent 332bc04ce8
commit 88a97b893e
6 changed files with 524 additions and 4 deletions

View File

@@ -426,6 +426,11 @@ namespace CaptureReplay {
void RegisterRoutes() {
Game::web.RegisterWSSubscription(TOPIC, std::function<uint8_t()>([] { return Permissions::Level(PERMISSION); }));
Route(eHTTPMethod::GET, "/inspector/packets", Perm(PERMISSION), "Packet captures: arm, play back, export",
[](HTTPReply& reply, const HTTPContext& context) {
RenderPage(reply, context, "packet-captures.jinja2", "inspector");
});
Route(eHTTPMethod::GET, "/api/inspector/targets", Perm(PERMISSION),
"Accounts and characters to capture, by part of an account or character name, or an account or character ID. Query: ?q=",
[](HTTPReply& reply, const HTTPContext& context) {

View File

@@ -210,7 +210,8 @@
var end = s.state === 'ended' ? s.endedAt : Date.now() / 1000;
return '<tr data-session="' + s.id + '">' +
'<td class="small">' + esc(fmt.unix(s.startedAt)) + '</td>' +
'<td>' + fmt.character(s.characterId, s.characterName) +
'<td>' + (s.kind === 1 ? '<span class="badge text-bg-info me-1">Packets</span>' + esc(s.target === 'everything' ? 'everything' : s.target === 'account' ? s.accountName : s.characterName) :
fmt.character(s.characterId, s.characterName)) +
(s.accountId ? '<div class="small text-body-secondary">account ' + fmt.link('/accounts/' + s.accountId, s.accountName || String(s.accountId)) + '</div>' : '') + '</td>' +
'<td>' + esc(s.startedBy) + '</td>' +
'<td class="text-nowrap">' + esc(durationText(end - s.startedAt)) + '</td>' +
@@ -218,7 +219,8 @@
'<td class="text-end">' + nf.format(s.received) + (s.dropped ? ' <span class="text-warning" title="Left out by the world (too many at once)">+' + nf.format(s.dropped) + '</span>' : '') + '</td>' +
'<td class="text-end text-nowrap">' + esc(bytesText(s.bytes)) + '</td>' +
'<td class="small">' + (s.state === 'ended' ? esc(s.endReason) : stateBadge(s)) + '</td>' +
'<td class="text-end text-nowrap small"><a class="btn btn-sm btn-link p-0 me-2" href="' + sessionUrl(s.id) + '/download" title="Download JSON (audited)">JSON</a>' +
'<td class="text-end text-nowrap small">' + (s.kind === 1 ? '<a class="btn btn-sm btn-link p-0 me-2" href="' + sessionUrl(s.id) + '/bundle" title="Portable bundle (audited)">Bundle</a>' :
'<a class="btn btn-sm btn-link p-0 me-2" href="' + sessionUrl(s.id) + '/download" title="Download JSON (audited)">JSON</a>') +
(s.state === 'ended' ? '<button type="button" class="btn btn-sm btn-link text-danger p-0" data-delete="' + s.id + '">Delete</button>' : '') + '</td></tr>';
}).join('') : '<tr><td colspan="9" class="text-body-secondary small">No saved captures' + (filtered() ? ' match these filters' : ' yet') + '.</td></tr>';
var limit = parseInt(byId('sessionPageSize').value, 10);
@@ -240,7 +242,12 @@
return;
}
var row = e.target.closest('[data-session]');
if (row) open(parseInt(row.dataset.session, 10));
if (!row) return;
var id = parseInt(row.dataset.session, 10);
var picked = sessions.rows.filter(function (r) { return r.id === id; })[0];
// Packet captures have their own viewer
if (picked && picked.kind === 1) { window.location.href = '/inspector/packets#capture=' + id; return; }
open(id);
});
document.querySelectorAll('.sessions-table th[data-sort]').forEach(function (th) {

View File

@@ -0,0 +1,333 @@
/**
* 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.
*/
(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
};
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' ? '' : ' <span class="badge text-bg-success">capturing</span>';
return '<div class="list-group-item list-group-item-action' + (active ? ' active' : '') + '" data-id="' + c.id + '">' +
'<div class="d-flex justify-content-between gap-2"><span class="text-truncate">' + esc(c.describe || c.target) + '</span>' + badge + '</div>' +
'<div class="small text-body-secondary">' + esc(fmt.unix(c.startedAt)) + ' · ' + nf.format(c.received) + ' packets · ' + esc(bytesText(c.bytes)) +
(c.dropped ? ' · <span class="text-warning">' + nf.format(c.dropped) + ' lost</span>' : '') + '</div></div>';
}).join('') : '<div class="list-group-item text-body-secondary small">No packet captures yet.</div>';
}
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.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();
}, function () { state.loading = false; });
}
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 '<tr class="' + cls + '" data-i="' + r.i + '"><td>' + r.i + '</td><td class="font-monospace">' + timeText(r.t) + '</td>' +
'<td>' + esc(r.source + (r.source === 'world' && r.zone ? ' ' + r.zone : '')) + '</td>' +
'<td>' + esc(r.gap ? '' : (r.from + ' → ' + r.to)) + '</td>' +
'<td title="' + esc(r.name || '') + '">' + esc(r.name || '') + (r.cut ? ' <span class="badge text-bg-warning">cut</span>' : '') + (r.unreadable ? ' <span class="badge text-bg-danger">unreadable</span>' : '') + '</td>' +
'<td class="text-end">' + nf.format(r.bytes) + '</td></tr>';
}).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('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 || !state.capture) return;
state.selected = parseInt(row.dataset.i, 10);
render();
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)' : 'No decoder for this packet; 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));
});
})();

View File

@@ -874,6 +874,25 @@ async function loadReplay() {
const data = await api.get('/api/world3d/history?zone=' + state.zone + '&instance=' + state.instance + '&from=' + range.from + '&to=' + range.to).catch(() => null);
setStatus('');
if (!data || data.success === false) { toast((data && data.error) || 'Could not load the replay', 'danger'); return; }
startReplay(data);
}
/**
* A packet capture's movement (the Packet Captures page's "World 3D" button: /world3d?capture=<id>&zone=<zone>): the
* captured characters' position updates, replayed like recorded positions. The capture page's timeline drives the
* time here while it plays (BroadcastChannel "dlu-capture-replay").
*/
async function loadCaptureReplay(capture) {
setStatus('Loading the capture…');
const data = await api.get('/api/inspector/sessions/' + encodeURIComponent(capture) + '/positions?zone=' + state.zone).catch(() => null);
setStatus('');
if (!data || data.success === false) { toast((data && data.error) || 'Could not load the capture', 'danger'); return; }
startReplay(data);
state.replay.capture = String(capture);
if (!data.players.length) toast('No movement was captured in this zone' + (data.zones && data.zones.length ? ' (captured in: ' + data.zones.map(zoneName).join(', ') + ')' : ''), 'info');
}
function startReplay(data) {
stopReplay();
const gap = Math.max(data.idleSeconds * 1.6, data.bucket * 2.5, data.interval * 3);
const replay = { from: data.from, to: data.to, players: data.players, gap, hold: data.idleSeconds, t: 0, playing: false, trails: null };
@@ -935,6 +954,16 @@ if (canHistory) {
$('replayScrub').addEventListener('input', () => setReplayTime(Number($('replayScrub').value)));
}
if ('BroadcastChannel' in window) {
new BroadcastChannel('dlu-capture-replay').addEventListener('message', (e) => {
const r = state.replay, m = e.data || {};
if (!r || !r.capture || String(m.capture) !== r.capture) return;
r.playing = false;
$('replayPlay').textContent = 'Play';
setReplayTime(Number(m.t) || 0);
});
}
// ---- 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
@@ -1149,5 +1178,14 @@ function animate() {
// Running worlds and their player counts change; refresh them now and then
setInterval(() => api.get('/api/world3d/meta').then((m) => { state.meta.worlds = m.worlds; renderInstances(); }).catch(() => {}), 30000);
animate();
const params = new URLSearchParams(location.search);
if (params.get('zone') && $('zoneSelect').querySelector('option[value="' + CSS.escape(params.get('zone')) + '"]')) $('zoneSelect').value = params.get('zone');
if ($('zoneSelect').value) await loadZone(Number($('zoneSelect').value));
if (params.get('capture') && canHistory) {
$('modeReplay').checked = true;
await setMode('replay');
await loadCaptureReplay(params.get('capture'));
} else if (params.get('capture')) {
toast('Replaying a capture here needs the players_history permission', 'warning');
}
})();

View File

@@ -58,7 +58,7 @@
{% block content %}
<div id="inspectorPage">
<div class="mb-3">
<h2 class="mb-1">Game Message Inspector</h2>
<h2 class="mb-1">Game Message Inspector <a class="btn btn-sm btn-outline-primary align-middle ms-2" href="/inspector/packets">Packet captures</a></h2>
<p class="text-body-secondary mb-0">Capture the game messages one online player sends and receives, live, to debug scripts and behaviors, and look
at them again later. The world server the player is in captures them; messages the server has a typed struct for show their fields, all
show their bytes. A capture follows the player to other zones and always stops at its time limit. Every capture is saved (deleted after

View File

@@ -0,0 +1,137 @@
{% extends "base.jinja2" %}
{% block title %}Packet Captures - DarkflameServer{% endblock %}
{% block css %}
<style>
.pc-layout { display: grid; grid-template-columns: 19rem minmax(0, 1fr); gap: 1rem; align-items: start; }
.pc-side { position: sticky; top: 4rem; }
.pc-side .list-group-item { cursor: pointer; }
.pc-side .list-group-item.active .text-body-secondary { color: inherit !important; opacity: .85; }
.pc-grid { display: grid; grid-template-columns: minmax(0, 1fr) minmax(22rem, 30rem); gap: 1rem; align-items: start; }
.pc-grid.no-detail { grid-template-columns: minmax(0, 1fr); }
.pc-table-wrap { height: calc(100vh - 24rem); min-height: 18rem; overflow: auto; }
.pc-table { font-size: .8rem; table-layout: fixed; width: 100%; }
.pc-table td, .pc-table th { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.pc-table tbody tr { cursor: pointer; }
.pc-table tbody tr.selected > td { background: var(--bs-primary-bg-subtle); }
.pc-table tbody tr.gap > td { background: var(--bs-warning-bg-subtle); }
.pc-table tbody tr.to-server > td:first-child { box-shadow: inset 3px 0 0 var(--bs-primary); }
.pc-table .c-i { width: 4.5rem; }
.pc-table .c-t { width: 6.5rem; }
.pc-table .c-src { width: 7.5rem; }
.pc-table .c-dir { width: 9rem; }
.pc-table .c-b { width: 4.5rem; }
.pc-detail { position: sticky; top: 4rem; max-height: calc(100vh - 5rem); overflow: auto; }
.pc-hex { font-family: var(--bs-font-monospace); font-size: .75rem; white-space: pre; overflow-x: auto; margin: 0; }
.pc-fields { font-family: var(--bs-font-monospace); font-size: .75rem; white-space: pre-wrap; word-break: break-all; margin: 0; }
@media (max-width: 1599.98px) { .pc-grid { grid-template-columns: minmax(0, 1fr); } .pc-detail { position: static; max-height: none; } }
@media (max-width: 991.98px) { .pc-layout { grid-template-columns: minmax(0, 1fr); } .pc-side { position: static; } }
</style>
{% endblock %}
{% block content %}
<div id="packetCapturePage">
<div class="mb-3">
<h2 class="mb-1">Packet Captures</h2>
<p class="text-body-secondary mb-0">Record every packet of one account (from its next login, or now if it is online), one character (from when
it is picked), or everything, on all servers at once: auth, chat, every world and master, on one timeline. Play a capture back here, watch the
movement in <a href="/world3d">World 3D</a>, or export it as a bundle for the capture tool's replay against a sandbox server
(<code>docs/CaptureReplay.md</code>). Passwords, session keys and user keys are never recorded. Arming, stopping and exporting are audited;
captures are kept as long as the <a href="/inspector">Message Inspector</a>'s.</p>
</div>
<div class="pc-layout">
<aside class="pc-side">
<div class="card mb-3">
<div class="card-header"><h5 class="mb-0 fs-6">New packet capture</h5></div>
<div class="card-body">
<form id="armForm" class="d-grid gap-2">
<div class="btn-group btn-group-sm" role="group" aria-label="What to capture">
<input type="radio" class="btn-check" name="target" id="targetAccount" value="account" checked>
<label class="btn btn-outline-primary" for="targetAccount">Account</label>
<input type="radio" class="btn-check" name="target" id="targetCharacter" value="character">
<label class="btn btn-outline-primary" for="targetCharacter">Character</label>
<input type="radio" class="btn-check" name="target" id="targetEverything" value="everything">
<label class="btn btn-outline-primary" for="targetEverything">Everything</label>
</div>
<div id="pickerWrap"><label class="form-label small mb-1" for="targetPicker" id="pickerLabel">Account</label>
<input class="form-control form-control-sm" id="targetPicker" placeholder="Part of a name, or an ID">
<div class="form-text small" id="pickerHint">Account or character name, account ID or character object ID. Online ones are marked.</div></div>
<div id="everythingHint" class="small text-warning d-none">Everything on every server: a busy server makes large files. Keep it short.</div>
<div><label class="form-label small mb-1" for="armSeconds">For</label>
<select class="form-select form-select-sm" id="armSeconds" data-pref="packets.seconds">
<option value="60">1 minute</option>
<option value="300" selected>5 minutes</option>
<option value="600">10 minutes</option>
<option value="900">15 minutes</option>
</select></div>
<button class="btn btn-sm btn-primary">Arm capture</button>
</form>
</div>
</div>
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center"><h5 class="mb-0 fs-6">Captures</h5>
<a class="small" href="/inspector">Game messages</a></div>
<div class="list-group list-group-flush" id="captureList"><div class="list-group-item text-body-secondary small">Loading&hellip;</div></div>
</div>
</aside>
<div style="min-width: 0">
<div class="card d-none" id="viewer">
<div class="card-header d-flex flex-wrap justify-content-between align-items-center gap-2">
<div style="min-width: 0"><h5 class="mb-0" id="viewTitle"></h5><div class="small text-body-secondary" id="viewSubtitle"></div></div>
<div class="d-flex flex-wrap gap-2">
<a class="btn btn-sm btn-outline-secondary" id="world3dBtn" target="_blank" rel="noopener" title="The captured movement in World 3D; this page's timeline drives it while it plays">World 3D</a>
<a class="btn btn-sm btn-outline-secondary" id="bundleBtn" title="A portable bundle for the capture tool (audited)">Export bundle</a>
<a class="btn btn-sm btn-outline-secondary" id="fixtureBtn" title="Names and chat replaced, for a local test fixture (audited)">Export anonymised</a>
<button class="btn btn-sm btn-outline-danger d-none" id="stopBtn">Stop</button>
<button class="btn btn-sm btn-outline-danger d-none" id="deleteBtn">Delete</button>
</div>
</div>
<div class="card-body">
<div class="d-flex flex-wrap align-items-center gap-2 mb-2 small" aria-label="Playback">
<button type="button" class="btn btn-sm btn-primary" id="playBtn" title="Play or pause (space)">Play</button>
<select class="form-select form-select-sm w-auto" id="speed" data-pref="packets.speed" title="Speed">
<option value="0.25">0.25&times;</option><option value="0.5">0.5&times;</option><option value="1" selected>1&times;</option>
<option value="2">2&times;</option><option value="4">4&times;</option><option value="16">16&times;</option><option value="64">64&times;</option>
</select>
<input type="range" class="form-range flex-grow-1 w-auto" id="scrub" min="0" max="1" step="1" value="0" aria-label="Time in the capture">
<span class="font-monospace text-nowrap" id="timeText"></span>
<button type="button" class="btn btn-sm btn-outline-secondary" id="endBtn" title="Show everything">End</button>
</div>
<div class="row g-2 align-items-end mb-2">
<div class="col-md-5"><input class="form-control form-control-sm" id="nameFilter" placeholder="Packet name part, e.g. POSITION, GAME_MSG REQUEST_USE"></div>
<div class="col-md-3"><select class="form-select form-select-sm" id="sourceFilter" aria-label="Server">
<option value="">Every server</option><option value="auth">Auth</option><option value="chat">Chat</option><option value="world">Worlds</option><option value="master">Master</option></select></div>
<div class="col-md-4 small text-body-secondary" id="countText"></div>
</div>
<div class="pc-grid no-detail" id="grid">
<div class="pc-table-wrap border rounded" id="tableWrap">
<table class="table table-sm table-hover mb-0 pc-table">
<thead class="sticky-top"><tr><th class="c-i">#</th><th class="c-t">Time</th><th class="c-src">Server</th><th class="c-dir">From &rarr; to</th><th>Packet</th><th class="c-b text-end">Bytes</th></tr></thead>
<tbody id="rows"></tbody>
</table>
</div>
<div class="card pc-detail d-none" id="detail">
<div class="card-header d-flex justify-content-between align-items-center gap-2"><h5 class="mb-0 fs-6 text-truncate" id="detailTitle"></h5>
<button type="button" class="btn-close" id="detailClose" aria-label="Close"></button></div>
<div class="card-body">
<div class="small text-body-secondary mb-2" id="detailMeta"></div>
<pre class="pc-fields border rounded p-2 mb-2" id="detailFields"></pre>
<h6 class="small text-uppercase text-body-secondary">Bytes (as sent over RakNet)</h6>
<pre class="pc-hex border rounded p-2" id="detailHex"></pre>
</div>
</div>
</div>
</div>
</div>
<div class="text-body-secondary small" id="emptyViewer">Arm a capture, or pick one on the left.</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script src="/js/packet-captures.js"></script>
{% endblock %}