A three-layer toolkit for QuakeWorld demo analysis. MVD bytes go in one end, structured analysis comes out the middle, and browser/CLI/AI consumers pick up whatever they need from the Result JSON at the far end.
┌─────────────┐ Event schema ┌─────────────┐ Result schema ┌──────────────┐
│ Source │ ───────────────▶ │ Analytics │ ────────────────▶ │ Consumer │
│ (Layer 1) │ │ (Layer 2) │ │ (Layer 3) │
└─────────────┘ └─────────────┘ └──────────────┘
MVD file, QTV Pipeline of Web UI, CLI,
stream, JSON analyzers over AI review
replayer event stream agent, bulk
batch tool
The schemas — events and results — are the real contracts. Implementations on either side can come and go as long as the schemas hold.
The repo is a Go workspace (go.work) binding five sibling modules:
| Module | Path | Role |
|---|---|---|
| mvd-reader | mvd-reader/ |
Event schema + MVD source (Layer 1) |
| mvd-analytics | mvd-analytics/ |
Analysis pipeline + Result schema + view API (L2) |
| mvd-api | mvd-api/ |
HTTP REST server on top of mvd-analytics/view |
| mvd-mcp | mvd-mcp/ |
MCP shim that talks to mvd-api — stdio (local) or streamable HTTP (hosted) |
| mvd-web | mvd-web/ |
Browser UI + WASM glue (Layer 3) |
Each module has its own go.mod, is tested in isolation, and can be extracted
to its own repo later. Until that's needed, the workspace keeps
cross-layer iteration fast: one git tree, one PR per change.
Splitting ingestion, analytics, and transport into separate layers lets each grow on its own timeline. Today's concrete shape:
- Layer 1 (
mvd-reader) is the only place that knows the MVD binary format. A future QTV live-stream source would sit beside the MVD source and emit the same events — downstream analytics wouldn't change. - Layer 2 (
mvd-analytics) is the only place that knows how to compute match summaries, frag streaks, timeline buckets, or loc-graphs. New analytics land here. Theview/sub-package turns the canonicalStreamsinto bucketed timelines, event lists, point-in-time state, loc trails, and interval segmentations (best-scoring top windows, per-life rollups). Analytics never peeks at MVD bytes; it consumes events. - Layer 3 consumers read
Resultor callview/and produce something user-facing. When hosted, the service presents four surfaces — REST, MCP over stdio, MCP over HTTP, and the web UI:mvd-analytics/cmd/qw-analyze— offline CLI (one demo → JSON / md / events).mvd-api— hosted REST API + three-tier on-disk cache (raw bytes, parsed Result, lazy artifacts). Optionally API-key-gated with a Discord key portal.mvd-mcp— MCP shim that forwards every tool call to a runningmvd-api. Two transports: stdio (a small.exefor Claude Desktop / Cursor / Claude Code) and streamable HTTP (-http, for hosting with per-request API-key auth). Seedeploy/for the hosted layout.mvd-web— browser UI compiled to WASM.
For a reproducible toolchain (pinned Go, gh, jq, build deps, Claude Code)
without installing anything locally, open the repo in a dev-container-aware
editor — Zed ("reopen in container"),
VS Code, or the devcontainer CLI. It builds from
.devcontainer/ with no prebuilt image required;
your git identity and GH_TOKEN stay on the host (see that README for the
environment variables to export).
go run ./mvd-analytics/cmd/qw-analyze demo.mvd.gz # Result JSON to stdout
go run ./mvd-analytics/cmd/qw-analyze -format md demo.mvd.gz # human summary
go run ./mvd-analytics/cmd/qw-analyze -format events demo.mvd.gz # line-delimited events
go run ./mvd-analytics/cmd/qw-analyze -view top-kills demo.mvd.gz # one query-API view-view runs one view instead of the whole Result, covering the same surface
mvd-api serves: buckets, events, stream-slice, state-at, trails,
region-control, top-kills, top-windows, lives, items,
items-summary, airgibs, frags, damage, aim, chat, backpacks,
weapon-pickups, player-stats, shots, loc-graph, loc-table,
metadata, demoinfo. It also takes a comma-separated list — -view top-kills,airgibs returns both from a single analysis pass, keyed by view
name. See mvd-analytics/README.md for the
per-view knobs and the two places CLI defaults differ from REST.
make serve # http://localhost:8080make bsps # fetch the curated BSP set for visibility-aware loc attribution
make build # output in dist/make bsps populates a gitignored top-level bsps/ directory with
the competitive QW map set defined in
scripts/fetch-bsps.sh (id-stock from
id-maps-gpl, community
maps from maps.quakeworld.nu/core,
sha256-pinned). make build then copies them into dist/bsps/ for
the WASM worker to lazy-fetch per map. The script hard-fails on any
download or sha mismatch so a flaky mirror produces a red build
rather than a silent V1-everywhere deploy.
For local dev the step is skippable — maps without a BSP fall back to the V1 Euclidean nearest-neighbour attribution (i.e. the pre-v9 behaviour); only the wall-bleed correction is lost.
make build-api # ./dist/mvd-api
./dist/mvd-api -addr :8080 # HTTP REST on top of mvd-analytics/view
make build-api-linux build-api-darwin build-api-windowsmvd-api hosts the analytics surface for non-Go consumers
(third-party integrations, the MCP shim, a future web frontend that
benefits from server-side caching). Demos are addressed by hub gameId
or uploaded directly (POST /v1/demos, REST-only) and analyzed
identically. See mvd-api/README.md for the
endpoint table; the running server also describes itself — an OpenAPI
3.1 spec at /openapi.yaml, browsable at /docs.
API stability. /v1 is not frozen yet. Most change is additive —
new endpoints and response fields appear without announcement — but a schema
upgrade can still withdraw a documented field or change what one means
(v70 removed four fields from /overview that were copies of other
endpoints). What is guaranteed today is that nothing changes silently: every
observable change bumps schemaVersion and is written up in
RELEASE_NOTES.md against that version, the served spec is
generated from the running code and validated against real responses, and the
MCP shim deploys in lockstep with the API. So schemaVersion is a cache key
and, for now, the signal to go read what moved. The intended destination —
additive-only /v1, breaks as /v2/<endpoint> served alongside it, old
routes retiring on a minimum of 8 weeks' notice — is near-term, and stated as
a direction rather than a promise already in force. A correctness fix (a value
that changes because it was computed wrongly) keeps the field's name and type
and at most narrows its documented meaning. In return clients must ignore
unknown fields and enum values. The full policy is
mvd-api/API.md §2.7,
also served in the spec's own description at /docs.
mvd-mcp is a thin (~7 MB) stdio MCP server that lets AI clients
(Claude Desktop, Claude Code, Cursor, anything that speaks MCP) query
the QuakeWorld demo corpus. It carries no analytics code of its own
— two of its tool calls go straight to hub.quakeworld.nu Supabase
(search), and the rest are forwarded as HTTP requests to a running
mvd-api. The split lets you ship a small distributable binary for
end-users without bundling the parser, and keeps the wire contract
owned by mvd-api.
make build-mcp # ./dist/mvd-mcp
./dist/mvd-api -addr :8080 & # local API (or point at a remote one)
./dist/mvd-mcp -api http://localhost:8080 # stdio MCP shim — read from stdin, respond on stdout
make build-mcp-windows # dist/mvd-mcp-windows-amd64.exe for Claude Desktop
make build-all-platforms # cross-compile both mvd-api and mvd-mcpTwenty-six tools — one for discovery, two for cache control + curated
summary, the high-level Result-section pass-throughs (KTX demoinfo,
metadata, player-stats, frags, damage, aim, loc-graph, chat, backpacks,
items, map entities, weapon-pickups, loc-table), eight for the view
query layer,
and two generic
DAG-artifact tools (listArtifacts + getArtifact) that reach any
servable artifact by name:
| Tool | Backing |
|---|---|
| Discovery | |
searchGames(players, teams, map, mode, matchtag, from, to, limit, offset) |
hub.quakeworld.nu Supabase (direct) |
| Cache + summary | |
loadDemo({gameId or sha256}) |
mvd-api POST /v1/demos/{id} |
getOverview(demoId) |
mvd-api GET /v1/demos/{id}/overview |
| Result section pass-throughs | |
getDemoInfo(demoId) |
mvd-api /demoinfo (KTX scoreboard) |
getMetadata(demoId) |
mvd-api /metadata (server cvars + match settings) |
getPlayerStats(demoId, players, teams) |
mvd-api /player-stats (canonical per-player + per-team row) |
getFrags(demoId, players, weapon) |
mvd-api /frags (aggregates + full kill log) |
getDamage(demoId, players, weapon) |
mvd-api /damage (per-hit log + matrix + EWep buckets + scoreboard cross-check) |
getAim(demoId, players, …) |
mvd-api /aim (crosshair error, LG ramp, rocket direct/splash) |
getLocGraph(demoId) |
mvd-api /loc-graph (per-map loc adjacency) |
getChat(demoId, players, from, to, types) |
mvd-api /chat |
getBackpacks(demoId, players, weapon) |
mvd-api /backpacks |
getItems(demoId, items, players, kinds) |
mvd-api /items |
getMapEntitiesByMap(map, types, kinds) |
mvd-api /v1/maps/{map}/entities (static map layout) |
getWeaponPickups(demoId, players, weapon, source) |
mvd-api /weapon-pickups |
getLocTable(demoId) |
mvd-api /loc-table (decoder for loc=index) |
| View queries | |
getBuckets(demoId, windowMs, fields, reducers, layout, …) |
mvd-api /buckets (default column-major; layout=row for the per-bucket shape) |
getEvents(demoId, types, …) |
mvd-api /events |
getStreamSlice(demoId, from, to, fields, …) |
mvd-api /stream-slice |
getStateAt(demoId, time, fields, …) |
mvd-api /state-at |
getLocTrails(demoId, minDwellMs, …) |
mvd-api /loc-trails |
getRegionControl(demoId, windowMs, regions, …) |
mvd-api /region-control |
getTopWindows(demoId, metric, mode, windowMs, gapMs, limit, perPlayer, …) |
mvd-api /top-windows (each player's best stretches, ranked — fixed-length windows or gap-delimited runs) |
getTopKills(demoId, gapMs, weapons, minDamage, …) |
mvd-api /top-kills (the hardest kill bursts, ranked) |
getLives(demoId, players, minMs, …) |
mvd-api /lives (one row per spawn-to-death life) |
| Generic DAG artifacts | |
listArtifacts() |
mvd-api GET /v1/artifacts (the DAG manifest) |
getArtifact(demoId, name) |
mvd-api GET /v1/demos/{id}/artifacts/{name} (any servable artifact by name) |
Full schemas live in three places:
mvd-mcp/README.md— per-tool input schemas (parameters, types, defaults). What the MCP SDK exposes viatools/list.mvd-api/README.md— REST endpoint responses, including theOverviewshape that's unique to the API layer.mvd-analytics/RESULT_SCHEMA.md— view types (BucketsView/ColumnarBuckets,EventsView,StreamSliceView,StateAtView,LocTrailsView,RegionControlResult,TopWindowsView,LivesView,TopKillsView), the field-code vocabulary, the reducer registry, and the underlyingResult/Streamstypes. View outputs are the same whether reached via WASM, the CLI, or MCP. The view layer is the parameterised-query seam — any function that takes a time / window knob lives there; static analyzer derivations (FragResult,LocGraphResult,MetadataResult, …) are served directly from result fields by the REST/MCP layer.
Tool errors come back as MCP isError: true results with the upstream
error message in TextContent. The model can read them and recover
(e.g. call loadDemo first when a per-demo tool says demo_not_found).
1. searchGames({player: "bps", map: "dm6"})
→ list of recent matches with rosters, scores, dates.
Direct hit on the hub; no mvd-api round-trip.
2. loadDemo({gameId: 12345})
→ mvd-api fetches MVD bytes, parses, caches.
Slow only on cold demos (2–10 s); warm cache is sub-millisecond.
3. getOverview({demoId: "sha:..."}) | getBuckets | getStateAt | ...
→ analytics for the chosen demo. Fast on warm cache.
If the answer is already in a search-result row (e.g. "what was the
score?", "who played?"), the agent should stop there — no
loadDemo needed.
┌─────────────────────────────────────┐
│ hub.quakeworld.nu (Supabase + CDN) │
└─────────────────┬───────────────────┘
│
┌─────────────────┴──────────────────┐
▼ ▼
GET / FTS search GET .mvd.gz bytes
│ │
┌──────────┐ │ │
│ mvd-web │ ────────────┘ │
└──────────┘ │
│
┌──────┴──────┐
│ mvd-api │
│ (parse + │
│ cache + │
│ view) │
└─────┬───────┘
┌──────────┐ │ HTTP REST
│ mvd-mcp │ ◀─── stdio JSON-RPC ─── Claude / Cursor / etc. │
│ (shim) │ ────────────────────── searchGames ─────────────│
│ │ ────────────────────── load/get* ───────────────┘
└──────────┘
searchGamesgoes directly to the hub. Discovery is the hub's job —mvd-apiis narrowly responsible for "given a knowndemoId: fetch bytes, parse, cache, serve view analytics."loadDemo/get*go throughmvd-api, which talks to the hub only to download.mvd.gzbytes (the rest comes from its three-tier on-disk cache: raw bytes, parsed Result, lazy artifacts).mvd-web(the browser UI) uses the same Supabase search path directly — both consumers behave identically against the hub.
There is none, by design. The QW demo corpus is public, the API is
read-only, and the Supabase anon key is the same one shipped in the
web bundle. The optional -label TAG flag (or Authorization: Bearer <label> on mvd-api) is not validated — it's a non-secret
request-source tag captured in mvd-api's access log for analytics.
Common labels: mcp-claude-desktop, claude-code-local,
web-community.
If real auth ever becomes necessary (abuse / rate-limit enforcement),
the surface is small enough to add bearer-token validation in
mvd-api's middleware without changes to the MCP shim.
The shortest path for each major MCP client:
Claude Code — drop a .mcp.json in the repo root:
{
"mcpServers": {
"mvd-mcp": {
"command": "/path/to/mvd-mcp",
"args": ["-api", "http://localhost:8080", "-label", "claude-code-local"]
}
}
}Auto-approve tool calls (skip the permission prompt each time) via
.claude/settings.local.json:
{ "permissions": { "allow": ["mcp__mvd-mcp__*"] } }Claude Desktop — edit claude_desktop_config.json
(%APPDATA%\Claude\ on Windows, ~/Library/Application Support/Claude/
on macOS, ~/.config/Claude/ on Linux):
{
"mcpServers": {
"mvd-mcp": {
"command": "C:\\Tools\\mvd-mcp.exe",
"args": ["-api", "https://mvd-api.example.com", "-label", "mcp-claude-desktop"]
}
}
}Restart the client after editing. See
mvd-mcp/CLAUDE_DESKTOP.md for the full
matrix (proxy vs local-API, Windows SmartScreen / macOS Gatekeeper
notes, Cursor setup).
Cross-compile produces unsigned binaries:
make build-all-platforms
# dist/mvd-mcp-linux-amd64
# dist/mvd-mcp-darwin-amd64
# dist/mvd-mcp-darwin-arm64
# dist/mvd-mcp-windows-amd64.exeFor end-users, distribute the platform-matching mvd-mcp-* binary
and the client config snippet. They don't need mvd-api locally if
you operate one publicly; otherwise the shim runs against a local
mvd-api on localhost:8080.
Windows SmartScreen / macOS Gatekeeper will warn on first run
(unsigned binaries). Documented workarounds in
mvd-mcp/CLAUDE_DESKTOP.md; real
code-signing is a planned follow-up.
See mvd-mcp/README.md for the per-tool input
schemas and the rationale behind keeping the shim small.
Other Makefile targets: make test, make fmt, make clean, make help.
Defined in mvd-reader/events. A Source is a
pull-style iterator:
type Source interface {
Next() (Event, error) // returns io.EOF at clean end
Close() error
}Concrete event types are plain structs: ServerDataEvent, UserInfoEvent,
PrintEvent (one complete console line, reassembled from however many
svc_print fragments the server split it into — old kmod/qwe emits an
obituary as three), StatUpdateEvent, FragUpdateEvent, PlayerPositionEvent,
DamageEvent, DemoInfoEvent, IntermissionEvent, StuffTextEvent,
CenterPrintEvent, ServerInfoEvent, DeathEvent, SpawnEvent,
ItemSpawnEvent, ItemStateEvent, ItemMoveEvent, BackpackDropHintEvent,
ItemPickupHintEvent, BackpackPickupHintEvent,
BackpackExpireHintEvent (KTX //ktx expire — an RL/LG pack removed
untaken at the 120 s timeout, the only wire statement that a pack was
not picked up),
ItemPickupPrintEvent,
PlayerDepartureEvent / PlayerRejoinEvent (the KTX/kmod roster
broadcasts — "left the game with N frags", "rejoins the game with N
frags", "reenters the game without stats"; decoded once in the parser
because the wire fragments them at arbitrary points, including inside
the number),
DemoMarkEvent (KTX //demomark player-inserted bookmark — slot + label),
FinalScoresEvent (KTX //finalscores end-of-match scoreline — the
server's own mode, map and final result, on 64% of the archive against
the demoinfo block's 46%),
DemoStartTimestampEvent (mvdhidden 0x000B wall-clock anchor),
PausedDurationEvent (mvdhidden 0x000A per-frame pause duration),
SoundEvent (svc_sound — emitting entity + channel + resolved sound
path; weapon-fire sounds drive the shots analyzer),
ProjectileSpawnEvent / ProjectileDespawnEvent (rocket/grenade entity
flight brackets — the shots analyzer links RL/GL fires to their impacts),
BeamEvent (svc_temp_entity lightning beams — TE_LIGHTNING2 is the
per-tick LG fire signal),
PointEffectEvent (svc_temp_entity point effects — TE_BLOOD /
TE_LIGHTNINGBLOOD per-hit damage telemetry, TE_EXPLOSION detonation
points, TE_GUNSHOT miss puffs, plus the rest of the TE vocabulary),
NailsFrameEvent (svc_nails spike snapshots — opt-in, off by default),
MoverSpawnEvent / MoverStateEvent (inline brush-model entities —
lifts, doors, trains — identity plus per-frame origin while moving).
Domain types carried by events — ServerData, PlayerInfo — are
source-agnostic.
DeathEvent / SpawnEvent are derived events the parser synthesises
from StatHealth edges so analytics never has to reconstruct
death/spawn by comparing samples across the sampling boundary.
ItemSpawnEvent / ItemStateEvent / ItemMoveEvent are derived from the
entity-state stream (svc_spawnbaseline + svc_packetentities /
svc_deltapacketentities): every item's identity, its
pickup/respawn transitions (each carrying the entity's origin) and, for the
one item class that moves — a dropped backpack, tossed with
MOVETYPE_TOSS — its fall to wherever it landed, come out of the wire
directly, with no KTX prints and no BSP preprocessing. ItemPickupHintEvent /
BackpackPickupHintEvent / BackpackDropHintEvent /
BackpackExpireHintEvent carry KTX's authoritative //ktx took,
//ktx bp, //ktx drop, //ktx expire directives — the touch-level
pickup attribution that entity-state alone can only approximate, plus the
one directive that states a pack was never taken. They only fire on KTX servers; non-KTX sources get
entity-state and stats deltas. ItemPickupPrintEvent parses the
per-client "You got the X" / "You receive N health" prints that
target the picking player via dem_single; it fills the gap where
//ktx took is silent (ammo boxes, H15/H25) but only survives to
the MVD for players who set msg 0 in their client config (see
mvd-reader/MVD_FORMAT.md for the server-side messagelevel filter
that strips PRINT_LOW in most competitive demos). KTX's "You get "
backpack opener is not decoded into a typed event — see MVD_FORMAT.md.
To write a new source: implement events.Source, emit the concrete event
types as you decode your wire format. That's it. See
mvd-reader/source/mvd for the reference
implementation backed by MVD files.
Defined in mvd-analytics/result. Result is
a JSON-serializable struct with sub-results from every analyzer that ran:
match, frags, messages, demoinfo, timeline analysis, metadata, locgraph,
items (per-item pickup / respawn timeline — works on any MVD source),
damage (per-hit damage log + aggregates — attacker→victim matrix,
per-weapon, given/taken, and the EWep victim-weapon buckets — from the
KTX mvdhidden_dmgdone stream with a scoreboard cross-check, or
reconstructed from the state streams on pre-instrumentation demos;
damage.source = ktx | reconstructed tells which),
shots (per-shot weapon-fire stream — who fired what at exactly what ms,
from svc_sound fire sounds + LG TE_LIGHTNING2 beams — with same-frame hitscan→damage
links, entity-tracked rocket/grenade→impact links, per-victim
enemy/team/self classification, and a KTX-accuracy cross-check),
aim (per-player aim analysis derived from shots + streams + damage —
normalized crosshair-error samples for hitscan, LG ramp-onto-target, rocket
direct/splash, LG reach/whiff, and enemy/team/self hit-counter slices;
exact target attribution in duels, a labeled nearest-crosshair heuristic
in team games),
backpacks (RL/LG drops attributed to the dropping player, each row
stamped source: ktx from the //ktx drop hint, or reconstructed
where the mod predates that hint — a replay of KTX's own DropBackpack
rule from the death instant, the wielded-weapon stat and the death
position, validated at 99.97% precision/recall against the hints; a
reconstructed row also carries the pack's fate — picked with the
picker named, expired at KTX's 120 s removal timeout, or
unobserved — read off the wire's backpack-entity track, 100% precision
and 96.1% recall against the //ktx bp hints and 100%/100% on expired
against //ktx expire, which a ktx row carries as its own fate),
weaponPickups (every slot-weapon acquisition —
world spawners and RL/LG backpacks — with a kills-before-next-death
effectiveness metric; joins to backpacks via backpackEnt ==
backpacks[].entNum), opening (schema v51 — each player's
match-start spawn loc plus the first take of every contested spawner,
the one-fetch answer to opening-race questions), and playerStats
(schema v63 — the canonical per-player and per-team statistics row:
corrected scoreboard, damage, pickup tallies and possession time
(time with each weapon, each armor type, and with no armor), each
family tagged with whether it came from KTX or was derived here.
Computed for every demo, including ones with no KTX demoinfo block at
all; demoInfo stays the verbatim KTX pass-through it is diffable
against). Schema v7 introduced streams as the canonical
event-rate storage — every per-player field (vitals, weapons, ammo,
position) recorded at the rate it actually changed. Schema v8 stores
every timestamped field as int32 milliseconds rather than float
seconds — the MVD wire format delivers ms deltas, and keeping the
unit integer end-to-end eliminates the float-precision drift that
previously broke spawn/death-boundary comparisons in locgraph and
keeps the schema consistent and sensible to extend. As of schema v57
(the pure-ms model) the view-layer query API (view.Buckets,
view.Events, view.StreamSlice, view.StateAt) also takes and emits
int32 milliseconds at its public surface — no seconds anywhere,
inputs or outputs, so REST/MCP from/to/time params and every
response time field are int32 ms alike. Schema v65 adds two interval
segmentations to that same view layer:
top-windows (each player's best fixed-length stretches, ranked by a
caller-chosen summable metric) and lives (one row per spawn-to-death
run, cut at the v64 streams.players[].alive boundaries). They share one
per-interval stats block and one envelope measured marker — every
numeric stat is emitted including a measured zero, so measuredness is
read from that marker and never from a field's absence — and a player's
lives partition the match, so unfiltered per-life sums reconcile exactly
with frags.frags[] on the frag side and with /damage's non-summary
aggregate on the damage side — not necessarily with the byPlayer
scoreboards, which count deaths no log row recorded. Schema v67 adds the
third cut of the same primitive, top-kills: the match's hardest kill
bursts, each the run of killing-weapon hits that produced one kill,
ranked by burst damage. It is the one that is not an interval reduction and
so carries no stats block — its gapMs is a CAPTURE gap narrowed
client-side by each row's maxGapMs, positional kills produce no row, and
kills by an already-dead killer stay in. Result itself gains exactly one field in v65,
frags.killsMeasured: the demo-global verdict on whether kill
attribution was observable at all, which the measured marker's frags
flag republishes rather than re-deriving. Schema v9 adds visibility-aware loc
attribution: when a per-map BSP is available, the analyzer rejects
candidate loc-points that fall outside the player's potentially-
visible-set (PVS), eliminating brief "wall-bleed" phantom loc visits
the V1 pure-Euclidean nearest-neighbour produced (see
mvd-analytics/locvis and
experiments/locattr/V2b-V6-HANDOFF.md
for the empirical evidence). Field shapes are unchanged — only the
contents of PlayerStream.Loc (and everything derived: LocTrails,
LocGraph edges, RegionControl) shift for maps with BSPs. Schema v10
makes the DF_DEAD bit in svc_playerinfo the primary
DeathEvent / SpawnEvent signal, with the existing STAT_HEALTH
detector dedupling against it as a safety net — deaths whose
dem_stats block was directed at a different player slot are no
longer dropped (PlayerStream.Spawns / Deaths counts rise; LocGraph
edges, LocTrails durations, RegionControl ticks, and WeaponPickups
windows shift across the now-present boundaries). Schema v11 makes the
50 ms bucket view column-major (view.ColumnarBuckets) the default
across web / REST / MCP. Schema v12 adds optional armed,
unarmed, quad and pent weights to each LocGraph node (time) and
edge (transition counts) — the same breakdown restricted to samples where
the player held RL/LG, held neither, or had an active quad / pent — so
consumers can render a self-contained loc graph / heatmap per combat
posture. Schema v13 adds the mapEntities section — the map's static
designed layout (item spawns, player spawnpoints, teleport
sources/destinations, buttons) from the offline-generated mapents corpus
— which v14 extends with brush entities (teleport/button/door volumes
with bounds) plus the teleport source→destination link. Schema v15 adds
timelineAnalysis.deathEvents: a per-player death stream ({time, player, team}) parallel to fragEvents, sourced from the authoritative protocol
DeathEvent (every death counts once), so the Timeline tab can draw
per-player frags-up / deaths-down charts and KTX-style efficiency
(frags / (frags + deaths)). Schema v16 adds frags.byPlayer[].teamkills
(KTX "tk") and recovers teamkills whose obituary names only one party, so
they re-enter frags.frags as complete killer↔victim pairs: killer-named
("X loses another friend") fill in the victim from the coincident
DeathEvent; victim-named ("X was telefragged by his teammate") fill in
the killer by combining position co-location with the teamkiller's −1
frag-delta. Across the test corpus this brings per-player teamkills to an
exact match with KTX's authoritative tk. Schema v18 adds
timelineAnalysis.killEvents: a per-player enemy-kill stream ({time, player, team}) keyed on the killer, parallel to deathEvents and sourced
from the canonical frag log (suicides/teamkills excluded), so the Timeline
tab's per-player drill-down plots an exact cumulative kills − deaths +/-
that reconciles with frags.byPlayer[].kills and the kills-based
efficiency. (Team is best-effort and ungated, unlike deathEvents, so a
player's curve survives POV demos with an incomplete name↔team join — the
consumer groups by player name.)
Schema v19 adds match.players[].kills, .deaths and .suicides — the
frag-log-corrected counts, independent of the sometimes-wrong KTX demoinfo
stats. KTX credits several self / positional deaths to the wrong entity:
pentagram-deflect telefrags inflate the deflector's kills, and world-dealt
suicides (fall / lava / squish / drown) bump the world entity's counter
instead of the victim's, so demoinfo undercounts suicides. This makes
match.players a complete corrected scoreboard, and the API /overview
surfaces the same counts so non-web consumers get the correction the web
Summary already applied.
Schema v20 adds the damage section: per-hit damage reconstructed from the
KTX mvdhidden_dmgdone stream, with an attacker→victim matrix, per-weapon
and per-player given/taken totals, the EWep victim-weapon buckets
(enemyVsSg/Mid/Lg/Rl/Both, where ewep = lg + rl + both), and a
scoreboard cross-check against the KTX end-of-match totals. Since schema
v54 damage ships in two families: the raw wire value (the full hit
including overkill, capped only at 9999) and a bounded reconstruction of
KTX's scoreboard dmg_dealt (armor absorbed + health capped to the victim's
remaining health) carried in additive bounded fields. Positional kills
(telefrags, stomps) are surfaced separately and kept out of events /
every per-weapon map / matrix / ewep, but their damage now folds into
given/givenTeam/taken in both families (matching KTX's own
accumulation). The REST /damage dmg=raw|bounded|both param picks the
family. Schema v63 adds byWeaponTeam / byWeaponSelf beside byWeapon
in both families (and in playerStats.damage): the same attacker-weapon
split for team and self damage, with the KTX overlays sourcing the team
map from weapons[].damage.team. Schema v69 adds the other axis to
playerStats: score.byEnemyWeapon and damage.byEnemyWeapon split the
same kills and the same enemy damage by what the victim was holding,
in exclusive buckets (both / rl / lg / mid / sg) that partition
kills and given — so "enemies killed while holding an RL" is
rl + both, never rl alone. Both are derived from the victim's
possession streams rather than overlaid: KTX's own ekills counts the
kill side inclusively and force-zeroes whole buckets by mode, and on the
damage side the server keeps only the RL+LG-lumped enemyWeapons scalar.
Schema v71 makes the section available on every demo: where the wire
never carried the damage stream (~45% of the archive), the damage-recon
node reconstructs it from the h/a change streams + beams / projectiles /
fire sounds / position tracks / the frag log, at ~1% median per-player
total error against KTX ground truth; damage.source
(ktx | reconstructed) says which kind a consumer is holding
(see mvd-analytics/damagerecon/ACCURACY.md).
streams.global carries a wall-clock anchor so a consumer can project any
match-relative game time onto real-world time (for syncing voice tracks /
stream overlays): demoStartUnixMs (server clock, Unix epoch ms, at demo
open), demoStartAccuracyMs (its resolution — 1 from the millisecond
mvdhidden 0x000B block, 1000 from the whole-second serverinfo epoch
cvar), demoOffset (demo-open → match-start), and pauses[]
({ atMs, durationMs } per pause). The game clock freezes during a pause
while wall-clock time runs on, so pauses must be folded in:
wallClockMs = demoStartUnixMs + demoOffset + gameMs + Σ durationMs (atMs ≤ gameMs)
The pause durations come from the mvdhidden 0x000A paused_duration blocks
mvdsv embeds once per paused idle frame (the parser handles their
non-standard, length-header-less framing; QW-Group/mvdsv PR #210 adds the
canonical framing, also supported). Anchor fields are omitted when the demo
carries no wall-clock source; implausible 0x000B payloads fall back to
epoch. (Introduced in v21–v22 on timelineAnalysis; moved to
streams.global and exposed via the REST /overview timing block in
v23, alongside matchStart/matchEnd.)
Both of those sources are modern-mvdsv features, present on ~25% of the
archive. Schema v72 adds a second anchor beside them — matchStartUnixMs,
the wall clock at match start — read from the date markers the wire has
always carried: KTX's matchdate: broadcast print (ISO or ctime layout), the
kmod-era matchkey: print, and the ktxstats date string (which names match
end, also published as matchEndUnixMs). That reaches ~95% of the archive.
Timezones are resolved from the printed token where there is one, and a stamp
without one is read as UTC with matchStartAccuracyMs saying so
(50 400 000). Because old servers ran with unset clocks, each anchor is graded
matchStartConfidence = exact / unverified / contradicted — on
CONTRADICTION only (a stamp predating the *version / ktxver binary's
release), never on the date value — with matchStartNote naming the failed
check and dateMarkers[] listing every stamp seen. Nothing is dropped. Where
the demo has no server-clock source, demoStartUnixMs is back-shifted from an
uncontradicted marker by demoOffset so the formula above keeps working, and
demoStartSource records which source it came from.
Schema v24–v28 enrich the position track with map-geometry-derived
per-sample columns: height above the floor (h, v24 — traced through the
map's BSP clip hull, later refined to a bounding-box footprint in v26 and
to stand players on moving brush models in v27) and liquid state (lq,
v28 — dry / water / slime / lava plus submersion level). v25 adds
timelineAnalysis.airgibs — direct airborne rocket hits surfaced for Key
Moments — and v29–v30 refine its ranking and uncap the list. Schema v58
adds timelineAnalysis.demoMarkers — the bookmarks players insert in-game
with KTX /demomark, attributed to the marking player's slot with an
optional label and a negative time for a warmup mark — and a matching
demomark type in the /events default set. The /events default set
also carries airgib (from timelineAnalysis.airgibs) and pause
(from streams.global.pauses, playerless, detail.durationMs) — a
view-layer addition (no schema bump) that puts both on the MCP surface.
v31 adds the
player's view direction (vp / vya, raw angle16 pitch/yaw) and splits
the opt-in view-layer field codes (view / hgt / lq); v32 adds derived
velocity (vx / vy / vz, units/sec) behind the vel code. v33 stores
position, velocity and height as float32 (no longer truncated to whole
units); v34 collapses locationData to one anchor point (the medoid) per
loc name; v35 adds streams.movers[] — the pose timeline of every tracked
brush-model entity (lift, door, plat, train) — so renderers can animate map
geometry. These additive columns are all omitempty and BSP-gated where
noted. Schema v36 is a breaking removal: match.startTime / match.endTime
drop out (they duplicated streams.global.matchStart / matchEnd —
startTime was always 0 and endTime always equalled duration).
Schema v37–v38 add two per-opponent visibility tracks to PlayerStream,
both computed lazily and BSP-gated (absent from the default parse and on
maps without a provisioned BSP, so additive for existing consumers):
LOS (v37) — geometric line of sight, the intervals a player has a
clear ray to an opponent (eye point, nine rays against the BSP clip hull
and moving movers, bbox corners; directional, so asymmetric sightlines
survive) — and PVS (v38) — server-reproduced potential visibility,
wire-exact against mvdsv's SV_PlayerVisibleToClient. PVS ⊇ LOS by
construction; the PVS-minus-LOS gap is an occlusion-tolerant
proximity/awareness signal. Both are surfaced through the REST/MCP
/los endpoint, the CLI, and the web map overlay.
Two fields report that a result is degraded, and they are deliberately
separate. errors[] carries ANALYZER-level failures over events that
decoded fine (a Finalize returned an error, the event stream aborted
mid-demo). parseWarnings (schema v72) carries the layer below — the
reader's own census of bytes it could not decode at all: unknown svc_*
commands, unknown temp-entity or hidden-message types, payloads that
failed to parse, with exact totals, per-category counts and a capped
sample table of distinct messages. It is collected on every run
(previously only in the diagnostic test harness, which is how the
sv_bigcoords desync degraded ~5% of the archive unnoticed) and omitted
entirely on a clean parse. qw-analyze prints a one-line summary to
stderr when a demo raises any, and -warn prints the whole table.
CurrentSchemaVersion (mvd-analytics/result/result.go) is bumped on every
observable change to the analysis output — additive ones included, and
largely view-only ones such as v65, whose two interval segmentations add
no Result field at all — so it is first a regeneration counter and cache
key. It is not only that while the surface is still settling: a bump can
also carry a removal or a reshape (v70 dropped four /overview fields), so
read the version's entry in RELEASE_NOTES.md rather than
assuming a bump was additive. Consumers can pin or feature-detect by reading
result.schemaVersion. The full per-field
reference and the complete version-history table live in
mvd-analytics/RESULT_SCHEMA.md; the
HTTP-level compatibility policy is mvd-api/API.md
§2.7.
import (
"github.com/mvd-analyzer/mvd-analytics/analyzer"
mvdsource "github.com/mvd-analyzer/mvd-reader/source/mvd"
)
src, err := mvdsource.Open("demo.mvd.gz")
if err != nil { ... }
defer src.Close()
reg := analyzer.NewDefaultRegistry()
res, err := reg.AnalyzeSource(src, "demo.mvd.gz")
// res is *result.Result; marshal to JSON, inspect, etc.Swap the source and the rest keeps working:
src := myQTVClient.Open(...) // implements events.Source
res, err := reg.AnalyzeSource(src, "live")mvd-analyzer/
go.work Workspace — names the five modules
Makefile Top-level coordinator (build / serve / test / fmt)
netlify.toml Netlify deploy config
README.md This file
mvd-reader/ Module: ingestion layer (Layer 1)
events/ Public contract — Source, Event types, domain types
mvd/ MVD wire decoder (internal)
parser/ Messages → events (internal)
mvdfile/ Gzip-aware reader
source/mvd/ Source implementation for MVD files
mvd-analytics/ Module: analysis pipeline (Layer 2)
analyzer/ Analyzer interface + Context + CoreOutputs + Registry
result/ JSON result schema (stable contract)
view/ Pure query API: Buckets, Events, StreamSlice, StateAt, ...
loc/ .loc parser + embedded corpus (466 maps)
hubfetch/ Resolve + download from hub.quakeworld.nu (used by mvd-api)
mapgen/ Quake 1 BSP reader + floor-face extraction
mapbsp/ Shared best-effort BSP-bytes loader (locvis + mapclip)
mapclip/ Worldspawn player clip hull + downward floor trace (pos.h)
diagnostic/ Opt-in bulk validation harness
corpus/ Special-cases invariant harness (roster / frag oracles)
cmd/mapgen/ Developer tool: BSP → per-loc floor-polygon JSON
cmd/qw-analyze/ Offline CLI: demo → json|md|events
mvd-api/ Module: REST host + on-disk cache (Layer 3, server)
main.go, serve.go HTTP entry
handlers.go, router.go REST endpoints over mvd-analytics/view
overview.go Curated demo summary
internal/democache/ Two-tier disk cache (raw MVD + parsed Result)
mvd-mcp/ Module: distributable stdio MCP shim
main.go Stdio MCP entry
mcp_backend_proxy.go Forwards each tool call as HTTP to a remote mvd-api
No mvd-analytics import — outputs are opaque JSON pass-through
mvd-web/ Module: browser UX + WASM glue (Layer 3, frontend)
static/ index.html, app.js, worker.js, styles.css, maps/
cmd/wasm/ WASM entry (exports analyzeMVD to JS)
demos/ Corpus for regression + manual testing (untracked)
- .devcontainer/README.md — reproducible dev environment (Zed / VS Code /
devcontainerCLI) - RELEASE_NOTES.md — feature-level changes as they land on
main, with dates and schema bumps - mvd-reader/README.md — ingestion layer, how to add a source
- mvd-reader/MVD_FORMAT.md — MVD binary format spec with ezQuake references
- mvd-analytics/README.md — pipeline, how to add an analyzer, Result schema
- mvd-analytics/RESULT_SCHEMA.md — Result JSON schema reference (every field, every section)
- mvd-analytics/WRITING_AN_ANALYZER.md — tutorial: write and register your own analyzer (DAG node declaration, eager vs lazy, checklist)
- mvd-api/README.md — REST endpoint table, cache layout, smoke tests
- mvd-mcp/README.md — stdio MCP shim, distribution
- mvd-mcp/CLAUDE_DESKTOP.md — Claude Desktop / Claude Code config snippets
- mvd-web/README.md — browser UI, build and deploy
make test # all modules
go test ./mvd-analytics/analyzer/ # single package
go test -v -run TestDiagnosticParseDemos \
./mvd-analytics/diagnostic/ # opt-in demo corpus
go test ./mvd-analytics/corpus/ # special-cases invariantsmvd-analytics/corpus/ walks demo-test-data/mvd/special-cases/ — the
per-machine drop of demos that exercise the degradation paths the golden
corpus does not (a player who times out, a connection the server refuses,
an FFA game where nobody has a team, a POV recording) — and asserts
invariants rather than pinning bytes: team frag totals reconcile with the
serverinfo score key and with the KTX demoinfo scoreboard, every roster
row has a matching player stream, and item-possession intervals only exist
for a player the wire actually saw play. It skips when the directory is
absent.
make test runs TestGoldenCorpus (in mvd-analytics/analyzer/golden_test.go)
against a manifest of hub.quakeworld.nu game IDs in
mvd-analytics/testdata/corpus.json.
On first run it downloads each demo into
mvd-analytics/testdata/cache/<gameId>.mvd.gz (gitignored); subsequent runs
hit the cache and stay offline. Each demo's Result JSON is pinned
against mvd-analytics/testdata/golden/<label>.json.
What is pinned: everything except filePath. At schema v7 the
canonical event-rate storage is streams (per-player change streams +
intervals + native position track) — bucketed views are produced on
demand by mvd-analytics/view.Buckets and not stored. Per-player time
series in streams.players[] are sliced to three 15 s windows
([0, 15], [60, 75], last 15 s) before comparison — the native
position track alone would otherwise run ~10 MB per 4on4 demo and
swamp the git history (see golden_test.go
sampleStreams). On top of that, the dense per-sample position/view
track (streams.players[].pos) is pinned on only two demos — a full
4on4 and a duel — and dropped from the rest (dropPositionTracks),
since that pipeline is map-independent; this keeps the committed corpus
~13 MB total instead of ~34 MB while still verifying every aggregate on
all demos. The golden output also depends on the curated BSP set
(make bsps): a demo whose BSP is missing is skipped in compare mode
and hard-fails -update-golden, so a degraded run can't clobber a good
golden. Bucketed-view behavior is exercised through the unit tests in
mvd-analytics/view/equivalence_test.go.
The manifest ships with ten demos (three 1on1, three 2on2, four 4on4).
Add entries by appending to the JSON array; labels follow
mode_team1_team2_DDMMYY_map (or player names for 1on1, where
team_names is null on the hub).
Workflow when an analyzer change shifts output:
make test
# TestGoldenCorpus fails with first-diff-line per demo.
# Inspect the change, then if it was intended:
go test ./mvd-analytics/analyzer/... -run TestGoldenCorpus -args -update-golden
git diff mvd-analytics/testdata/golden/ # review
git add mvd-analytics/testdata/golden/ # commit alongside the analyzer change(The -update-golden flag is registered only in the analyzer test
package; wider scopes like ./mvd-analytics/... fail in mapgen with
"flag provided but not defined".)
The pipeline also has a CLI for ad-hoc bulk diffs:
go run ./mvd-analytics/cmd/qw-analyze -bulk -out-dir /tmp/before -format json demos/
# ... change ...
go run ./mvd-analytics/cmd/qw-analyze -bulk -out-dir /tmp/after -format json demos/
diff -r /tmp/before /tmp/after- Reconstructed damage is an estimate. On pre-instrumentation demos
(no KTX
mvdhidden_dmgdonestream) the damage section is rebuilt from the state streams by thedamage-reconnode and stampedsource: "reconstructed". Magnitudes are near-exact (the health/armor delta IS the bounded value) but attribution is inference: per-player match totals run ~1% median error against ground truth, individual hits can be misattributed, and team/self splits are indicative only. Pre-MVDSV-0.30 recordings additionally carry far sparser hit telemetry and are not yet ground-truth-validated. Full accuracy tables and trust guidance:mvd-analytics/damagerecon/ACCURACY.md; the feature's design/validation history lives inplan-damage-recon.md, and the follow-up backlog distilled from the archive survey inplan-archive-features.md.
0b. Reconstructed backpack drops are a replay, not a measurement. On
demos older than the KTX //ktx drop hint (KTX 1.38 — 51% of the
archive) the backpack-recon node fills the same backpacks section
by replaying DropBackpack's own rule over the recorded
wielded-weapon stat, stamped source: "reconstructed". Validated at
99.97% precision and recall against the hints on 316 archive demos, so
it is far stronger than the damage reconstruction. Its PICKUP side is
read off the wire's backpack-entity track by the backpack-linkage
node and published on the same row (fate / picker / pickerTeam /
pickupTime, schema v72): 100% precision and 96.1% recall on
picked-vs-not, 99.98% correct pickers, measured against the //ktx bp
hints on 223 demos, and 100% precision and recall on expired against
KTX's third backpack directive //ktx expire — which a hint-carrying
row publishes as its own fate, the only wire statement that a pack
was NOT taken. What stays out of reach: a pack taken inside the
demo frame it dropped in never reaches the wire at all (2% of drops,
the largest residual); pack TRANSFER credit and kill credit still need
the hint, because hadBefore is not derivable; and the server-side
dp 0 (drops disabled) setting is published nowhere on the wire, so it
cannot be ruled out on a pre-1.38 demo. Where the
evidence is unmeasurable — frozen weapon state, no frag log, a
fairpacks/yawnmode/bloodfest ruleset — the section is left absent
rather than half-filled. Accuracy tables and stand-down conditions:
mvd-analytics/analyzer/BACKPACKS.md.
-
Weapon switching scripts: QW players use scripts that switch weapons faster than MVD stat updates, so any ammo-delta-based inference of RL/GL usage undercounts. The
shotsanalyzer sidesteps this by keying on thesvc_soundweapon-fire sound (which carries the firing entity), not ammo — its per-weapon counts match KTXacc.attacksexactly across the corpus, including RL/GL. The one weapon still counted from ammo is LG (it has no per-shot fire sound), which can slip by a single cell at a death/discharge boundary. KTX demoinfo stats (when available) remain the authoritative reference, andshots.reconciliationcross-checks against them. -
Auth name override: When players authenticate via mvdsv,
sv_forcenickcan set the userinfo name to the login. The analyzer resolves display names from KTX demoinfo via*authlogin join. -
Reconnecting players: When a player disconnects and reconnects mid-match they land on a new wire slot (and userid), and their old slot is often reused. The
identityanalyzer folds the occupancies back into one player — via the KTXrejoins/reentersprints, then a per-session demoinfo login/name join — so pickups, frags, timeline and the merged per-player stream stay attributed correctly (matching KTX's own ghost-by-netname behaviour). Residual gap: a reconnect on a non-KTX demo with no demoinfo and a different name each time has no signal to link the two names and will not unify. See mvd-reader/MVD_FORMAT.md (search "reconnect") and mvd-analytics/analyzer/identity.md.The mirror case is a player who leaves and never returns. The server clears the slot's score before broadcasting the departure, so the final total survives only in the mod's
left the game with N fragsprint or by rolling back the reset that shares the drop's timestamp; the match analyser does both. Residual gaps: a slot handed over without the wire ever changing its userid cannot be split, and a mod that neither prints the departure nor drops the client leaves nothing to recover from. See mvd-reader/MVD_FORMAT.md (search "Departure") and mvd-analytics/analyzer/match.md. -
Same-tick item insta-regrab: If an item respawns and is picked up again within a single server tick (camped spawn), the wire never emits a "visible" transition for that cycle. The items analyzer recovers these via two synthesis paths (KTX
//ktx tookhint-driven for armors/MH/weapons/powerups; stat-delta + position for small healths and ammo), so per-touch counts match KTX's authoritativetooksacross the corpus. Two health boxes grabbed in one frame (a coalesced health jump) attribute to the gainer via per-box stat evidence. The one residual is two same-magnitude small healths (e.g. h15 + h15) contested in a single frame, which the health-jump signal can't tell apart. See mvd-reader/MVD_FORMAT.md#item-tracking-via-entity-state and mvd-analytics/analyzer/items.md. -
Weapon pickups from backpacks (SSG/SNG/GL/NG): KTX emits the
//ktx bpbackpack-pickup hint only for RL and LG packs, so super-shotgun / super-nailgun / nailgun / grenade-launcher grabs off a dropped pack have no authoritative wire signal. In non-weapon-stay modes (dmm1/dmm4) they are simply missing: per-weapon totals reconcile with KTXweapons.<w>.pickups.spawn-takenbut fall short oftotal-takenby the backpack grabs (systemic; RL/LG reconcile fully). In weapon-stay modes (deathmatch 2/3/5, coop — dmm3 duels/2on2 included), where world weapon pickups are synthesized from STAT_ITEMS flips, these pack grabs do surface — assource: "unknown"entries, since a bit flip away from any weapon pad can't be tied to a specific pack. See mvd-analytics/README.md. -
Damage: raw (unbound) vs bounded families:
result.Damageis reconstructed from the KTXmvdhidden_dmgdonestream, which reports the full hit including overkill, capped only at 9999 (a telefrag reports 9999) — the raw family. KTX's end-of-match scoreboard (demoInfo.players[].dmg) instead bounds each hit to the victim's remaining health; since schema v55 that bounded family is derived per hit from the death-value identity (a survived hit is exact by construction; a killing hit's overkill is measured by the death broadcast) and carried in additiveboundedfields —boundedis the REST/MCP default,raw/boththe opt-ins, and unfiltered bounded summaries substitute KTX's exact scoreboard figures (boundedSource: "ktx"). Residual approximation exists only where the wire hides state (the −99 corpse clamp, respawn-masked deaths, same-frame multi-hit cascades, pent/teamplay armor-share estimates); corpus-wide totals reconcile withdemoInfowithin pinned tolerances (max ±16 per player on given/taken). Godmode is unobservable, so a hit on a godmode holder is reconstructed as if it landed. The reconstruction is skipped onk_midair/k_instagib/k_dmgfragsdemos (damage.boundedMode = "skipped:<mode>") where the mode rewritesT_Damageunobservably;dmg=boundedthere is a422 bounded_unavailable. Positional kills — telefrags (the 9999 instakill sentinel) and stomps (landing on a head) — are kept out ofevents/ every per-weapon map /matrix/ewep/totalDamageand tracked separately (damage.telefrags/damage.stomps, the opt-intelefrag/stompevents), but their damage folds intogiven/givenTeam/takenin both families (mirroring KTX, which applies no tele/stomp exclusion to its scoreboard accumulation). Available only on KTX demos with the MVD-hidden extension; theEWepvictim-weapon buckets additionally depend on reconstructing each victim's inventory fromSTAT_ITEMSupdates. -
Wall-clock anchor resolution / availability:
streams.global'sdemoStartUnixMsis millisecond-accurate (demoStartAccuracyMs = 1) only when the demo carries the mvdhidden0x000Bblock; otherwise it degrades to the whole-second serverinfoepochcvar (demoStartAccuracyMs = 1000), and — on the ~73% of the archive that has neither — is back-shifted from a wire date marker bydemoOffset(schema v72), which costs resolution:demoStartAccuracyMsbecomes50400000when the marker named no timezone.demoStartSourcesays which case a result is in, andmatchStartConfidencegrades the marker; a contradicted marker is deliberately not back-shifted, sodemoStartUnixMscan still be absent. ~5% of the archive carries no date signal at all. It anchors demo open, not match start — consumers adddemoOffsetto reach the match, or readmatchStartUnixMs. Some 2026 demos emit a0x000Bblock that is not a timestamp at all (a 1–2 byte non-wall-clock value); those are range-checked out and fall back toepoch. See RESULT_SCHEMA.md and mvd-reader/MVD_FORMAT.md. For paused demos the wall-clock mapping also needsstreams.global.pauses[]: the durations come from the mvdhidden0x000Apaused_durationblock, which only current production mvdsv embeds in the .mvd (older servers wrote it to QTV streams only) and which is written with non-standard framing (no inner block-length header) — both are handled, but a demo from a server that doesn't embed it has no per-pause signal, so its wall-clock mapping drifts by the pause time. -
Floor height provisioning and edge cases: the per-sample height above the floor (
streams.players[].pos.h, schema v24) is traced through player clip hulls built from the map's BSP, via the same best-effort provisioning as the visibility-aware loc filter — thehcolumn is absent for any map whose BSP isn't deployed (and for the handful of HL/Quake 2-format maps the BSP parser rejects). Since schema v26 the height is measured over the player's bounding-box footprint, so a player skimming a ledge or well rim — origin over the pit, box overhanging the rim — reads the near floor rather than the distant one far below. Since schema v27 the trace scene also poses every moving brush-model entity (the dm2 RA/quad lift, func_door, func_train) at its demo-streamed origin, so riders read ~0 instead of the static floor beneath the platform. Since schema v28 liquids participate as well: a per-sample liquid-state column (pos.lq, water/slime/lava × feet/waist/eyes) mirrors the engine'sPM_CategorizePosition, submerged samples readh = 0by definition, and a jump over water measures to the water surface rather than the floor beneath it. Remaining caveat: func_illusionary is traced like any other inline brush model (the same approximation the client's prediction makes inCL_SetSolidEntities), so a player passing through one can briefly read it as a floor. See RESULT_SCHEMA.md (PositionTrack.h). Since schema v31 each position sample also carries the player's view direction (pos.vp/pos.vya) — pitch and yaw as the rawangle16state, kept losslessly aftersvc_playerinfodelta carry-forward (decodeuint16(v)*360/65536; pitch > 180° = looking up). Unlike floor height it needs no BSP — the angles ride the samesvc_playerinfosamples as x/y/z. The view-layer query API and CLI expose position channels independently:posis strictly x/y/z, with opt-inview/hgt/lqfor look direction, floor height, and liquid state. Since schema v32 there is also a derived per-sample velocity (pos.vx/vy/vz, Quake units/sec, opt-invel), computed by a central-difference estimator that does not differentiate across respawns, teleporters, or time gaps. Since schema v33 positions (x/y/z), velocity (vx/vy/vz), and floor height (h) arefloat32— the wire-native sub-unit origin, no longer truncated to whole units (which also sharpens the velocity); thehno-floor sentinel is now-1000000000. -
Aim target attribution (schema v41, refined v44): the
aimblock's crosshair error is computed against the player it attributes each shot to. A hit attributes to its server-confirmed victim (exact; nearest by crosshair error when a pellet fire hit several — can be a teammate on team damage, flagged per sample since v45). A miss has no confirmed target: in a duel the one enemy is exact (mode: "duel"); in a team game it is a heuristic — the live enemy nearest the crosshair at the fire time (mode: "team"), so a missed shot tracking one opponent while another crosses closer to the crosshair can be mis-attributed. Misses are only attributed to an enemy whose position track brackets the fire time and who is alive at it. The rocket "direct hit" count is likewise a heuristic (non-splash damage events ≈ direct contacts). These are labeled in the data so consumers can disambiguate. Hit counts include team and self hits (server parity) — the v45victimKinds/ per-bucket splits let consumers separate them (a rocket jump is a self hit, not an enemy hit).
| Project | Description |
|---|---|
| KTX | Server mod — damage calc, demoinfo JSON, hidden message types |
| mvdsv | MVD server — demo recording, userinfo handling |
| ezQuake | Client — demo parsing, character encoding |
mvd-analyzer is released under the MIT License — see LICENSE.
It analyzes demo files from QuakeWorld, whose Quake engine is GPL- licensed; this repo only consumes the wire format and does not incorporate engine source.
- QW-Group for KTX, mvdsv, ezQuake, and mvdparser
- The QuakeWorld community for demo format documentation