Skip to content

Repository files navigation

MVD Analyzer

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.

Architecture

  ┌─────────────┐   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.

Five Go modules in one workspace

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.

Why layered?

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. The view/ sub-package turns the canonical Streams into 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 Result or call view/ 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 running mvd-api. Two transports: stdio (a small .exe for Claude Desktop / Cursor / Claude Code) and streamable HTTP (-http, for hosting with per-request API-key auth). See deploy/ for the hosted layout.
    • mvd-web — browser UI compiled to WASM.

Quick start

Dev container

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).

Analyze a demo at the command line

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.

Run the web UI locally

make serve                                  # http://localhost:8080

Build the WASM bundle for deploy

make 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.

Serve the REST API (mvd-api)

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-windows

mvd-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.

Run MCP locally (mvd-mcp)

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-mcp

Tool surface

Twenty-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 via tools/list.
  • mvd-api/README.md — REST endpoint responses, including the Overview shape 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 underlying Result / Streams types. 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).

Typical session shape

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.

Architecture

                            ┌─────────────────────────────────────┐
                            │ 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* ───────────────┘
   └──────────┘
  • searchGames goes directly to the hub. Discovery is the hub's job — mvd-api is narrowly responsible for "given a known demoId: fetch bytes, parse, cache, serve view analytics."
  • loadDemo / get* go through mvd-api, which talks to the hub only to download .mvd.gz bytes (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.

Authentication

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.

Client setup

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).

Distribution

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.exe

For 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.

The contracts

Event schema (Layer 1 → 2)

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.

Result schema (Layer 2 → 3)

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 fatepicked 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 / matchEndstartTime 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.

Running the pipeline

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")

Repository layout

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)

Documentation

Testing

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 invariants

Special-cases invariants

mvd-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.

Golden corpus

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

Known limitations

  1. Reconstructed damage is an estimate. On pre-instrumentation demos (no KTX mvdhidden_dmgdone stream) the damage section is rebuilt from the state streams by the damage-recon node and stamped source: "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 in plan-damage-recon.md, and the follow-up backlog distilled from the archive survey in plan-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.

  1. 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 shots analyzer sidesteps this by keying on the svc_sound weapon-fire sound (which carries the firing entity), not ammo — its per-weapon counts match KTX acc.attacks exactly 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, and shots.reconciliation cross-checks against them.

  2. Auth name override: When players authenticate via mvdsv, sv_forcenick can set the userinfo name to the login. The analyzer resolves display names from KTX demoinfo via *auth login join.

  3. 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 identity analyzer folds the occupancies back into one player — via the KTX rejoins/reenters prints, 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 frags print 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.

  4. 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 took hint-driven for armors/MH/weapons/powerups; stat-delta + position for small healths and ammo), so per-touch counts match KTX's authoritative tooks across 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.

  5. Weapon pickups from backpacks (SSG/SNG/GL/NG): KTX emits the //ktx bp backpack-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 KTX weapons.<w>.pickups.spawn-taken but fall short of total-taken by 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 — as source: "unknown" entries, since a bit flip away from any weapon pad can't be tied to a specific pack. See mvd-analytics/README.md.

  6. Damage: raw (unbound) vs bounded families: result.Damage is reconstructed from the KTX mvdhidden_dmgdone stream, 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 additive bounded fields — bounded is the REST/MCP default, raw/both the 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 with demoInfo within 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 on k_midair / k_instagib / k_dmgfrags demos (damage.boundedMode = "skipped:<mode>") where the mode rewrites T_Damage unobservably; dmg=bounded there is a 422 bounded_unavailable. Positional kills — telefrags (the 9999 instakill sentinel) and stomps (landing on a head) — are kept out of events / every per-weapon map / matrix / ewep / totalDamage and tracked separately (damage.telefrags/damage.stomps, the opt-in telefrag/stomp events), but their damage folds into given/givenTeam/taken in both families (mirroring KTX, which applies no tele/stomp exclusion to its scoreboard accumulation). Available only on KTX demos with the MVD-hidden extension; the EWep victim-weapon buckets additionally depend on reconstructing each victim's inventory from STAT_ITEMS updates.

  7. Wall-clock anchor resolution / availability: streams.global's demoStartUnixMs is millisecond-accurate (demoStartAccuracyMs = 1) only when the demo carries the mvdhidden 0x000B block; otherwise it degrades to the whole-second serverinfo epoch cvar (demoStartAccuracyMs = 1000), and — on the ~73% of the archive that has neither — is back-shifted from a wire date marker by demoOffset (schema v72), which costs resolution: demoStartAccuracyMs becomes 50400000 when the marker named no timezone. demoStartSource says which case a result is in, and matchStartConfidence grades the marker; a contradicted marker is deliberately not back-shifted, so demoStartUnixMs can still be absent. ~5% of the archive carries no date signal at all. It anchors demo open, not match start — consumers add demoOffset to reach the match, or read matchStartUnixMs. Some 2026 demos emit a 0x000B block that is not a timestamp at all (a 1–2 byte non-wall-clock value); those are range-checked out and fall back to epoch. See RESULT_SCHEMA.md and mvd-reader/MVD_FORMAT.md. For paused demos the wall-clock mapping also needs streams.global.pauses[]: the durations come from the mvdhidden 0x000A paused_duration block, 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.

  8. 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 — the h column 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's PM_CategorizePosition, submerged samples read h = 0 by 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 in CL_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 raw angle16 state, kept losslessly after svc_playerinfo delta carry-forward (decode uint16(v)*360/65536; pitch > 180° = looking up). Unlike floor height it needs no BSP — the angles ride the same svc_playerinfo samples as x/y/z. The view-layer query API and CLI expose position channels independently: pos is strictly x/y/z, with opt-in view / hgt / lq for 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-in vel), 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) are float32 — the wire-native sub-unit origin, no longer truncated to whole units (which also sharpens the velocity); the h no-floor sentinel is now -1000000000.

  9. Aim target attribution (schema v41, refined v44): the aim block'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 v45 victimKinds / per-bucket splits let consumers separate them (a rocket jump is a self hit, not an enemy hit).

Reference sources

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

License

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.

Acknowledgments

  • QW-Group for KTX, mvdsv, ezQuake, and mvdparser
  • The QuakeWorld community for demo format documentation

About

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.

Resources

Stars

5 stars

Watchers

1 watching

Forks

Releases

Packages

Contributors

Languages