Skip to content

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

Latest commit

 

History

1,026 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

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, highlights, 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,highlights 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
MVD_API_KEY=qwmvd_… make serve              # hub search + demos via the production API
MVD_API=http://127.0.0.1:8090/v1/ make serve   # …or via a local `mvd-api serve -addr 127.0.0.1:8090`

The page reads hub search, game lookup and hub demos from mvd-api at /api on its own origin; scripts/serve-dev.py forwards that path to MVD_API with MVD_API_KEY added, as Caddy does in production. Without a reachable API the page still works on its own: search and game lookup query the hub directly, and hub demos are analysed in the browser.

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 with fallbacks to vikpe/qw-assets and this repo's bsp-mirror-1 release, every source held to the same sha256 pin). make build then copies them into dist/bsps/ for the WASM worker to lazy-fetch per map. The script hard-fails when no mirror yields a map matching its pin, so an outage 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 — every tool call is forwarded as an HTTP request to a running mvd-api, search included. 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

Thirty 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, two generic DAG-artifact tools (listArtifacts + getArtifact) that reach any servable artifact by name, and createPlaylistLink, which turns clips from any number of games into a link that plays them:

Tool Backing
Discovery
searchGames(players, teams, map, mode, matchtag, from, to, limit, offset) mvd-api GET /v1/games/search (the hub's catalog)
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
getPointEffects(demoId, from, to, …) mvd-api /streams/point-effects
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)
getHighlights(demoId, kinds, players, preMs) mvd-api /highlights (discharges, quadbores, telefrags, airgibs with what everyone had)
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)
createPlaylistLink(items, name) mvd-api POST /v1/playlist-links (a light-player link playing clips from many games, title cards and text)

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)  │
                    └───────┬──────────────────┬──────────┘
          catalog search,   │                  │  .mvd.gz bytes
          game lookup       ▼                  ▼  (public CDN)
                    ┌───────────────┐          │
                    │    mvd-api    │◀─────────┤
                    │ (parse, cache,│          │
                    │  view, search)│          │
                    └───┬───────▲───┘          │
              HTTP REST │       │ /api (the    │
                        │       │ host adds    │  local files; the Player's
   ┌──────────┐         │       │ the key)     │  demo; API fallback
   │ mvd-mcp  │◀────────┘   ┌───┴──────┐◀──────┘
   │ (shim)   │             │ mvd-web  │ (WASM build: local files,
   └────▲─────┘             └──────────┘  or when the API cannot answer)
        │ stdio / HTTP JSON-RPC
   Claude / Cursor / etc.
  • searchGames goes through mvd-api's GET /v1/games/search, which holds the hub's catalog credentials.
  • 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) reads hub demos, search and game lookup from the same mvd-api through /api on its own origin, where the host adds the key (plan-web-api.md); local files, and any demo the API cannot answer for, it analyses in the browser.

Authentication

A local mvd-api started without -auth-dir has none: every route is open, and the optional -label TAG flag (or Authorization: Bearer <label>) is not validated — it's a non-secret request-source tag captured in the access log (common labels: mcp-claude-desktop, claude-code-local, web-community). The hosted API at mvdanalyzer.com runs with -auth-dir: every /v1 call needs an API key (from the Discord portal at /portal, or one the operator issues), rate-limited per key; its MCP endpoint forwards the operator's own service key, so MCP clients need none. See mvd-api/README.md and mvd-api/API.md §2.5.

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), MatchStartEvent (the match went live — emitted once per demo at the first of four wire signals: the matchdate: stamp, a match-start print, the //ktx matchstart stuffcmd or a serverinfo status transition into a running clock; the three non-print ones are what make KTX matchless servers analyzable, since they never print a start line), 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. An event never changes after the Source hands it out (UserInfoEvent.Player is a snapshot, not the parser's live record), so with Registry.Parallel the event pass reads the Source on its own goroutine, overlapping decompress + parse with the analyzers.

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 (map, duration, the scoreboard, and since v75 match.gameMode — the one normalised mode verdict: canonical shape, teamBased, rounds, submodes[], each with a sources entry naming which of the server's five mode vocabularies decided it. When teamBased is false — every duel, FFA, race — the match is laid out with one side per player (match.teams one row per player, players[].team equal to the player's own name, the raw userinfo tag kept on players[].rawTeam), so a team tag is never read as a side), 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, and a reconstructed section carries damage.coverage — how much of the match the recording let it see), 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; aim.hitsSource names the damage evidence, and on a reconstructed section the hit counts appear only in the separate weapons[].recon tier), 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; every row carries the pack's fate — picked with the picker named and pickerRelation self/team/enemy, expired at KTX's 120 s removal timeout, or unobserved — on a ktx row (v77) copied from the //ktx bp pickup with its pickerHadWeapon / pickerKills, on a reconstructed row 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), 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), highlights (schema v76 — the Key-Moments catalogue: every discharge, quadbore, telefrag and airgib as one row shape whose actor and victims each carry what they had at that instant — relation, health / armor / stack read one millisecond before the event, weapons, powerups, loc — so toplists are a sort; alongside it frags[].cause marks the discharge / pentagram-deflect / spawnicide prints beneath their ordinary weapon), 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 v74 publishes the leftovers instead of dropping them: an obituary whose other party neither recovery could name lands in frags.unpaired[], out of frags.frags and out of every per-player tally (one side is the placeholder "teammate"), but carrying the death and its cause. 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, and since v74 a reconstructed section also carries damage.coverage — the share of the frag log's kills whose damage it accounts for, which is what separates a quiet match from a recording that never broadcast the evidence — and damage.rocketDirectRegime (fixed | spread | unestablished), what the demo's own rocket hits established about the server's direct-damage constant, which is the era signal the reconstructed RL touch count leans on (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 the airgib list — direct airborne rocket hits surfaced for Key Moments (since v76 it lives at highlights.airgibs with the victim's state added; the old timelineAnalysis.airgibs field is removed) — v29–v30 refine its ranking and uncap the list, and v73 gates on pre-impact evidence — clear air at every sample of a 100 ms look-back window and no grounded reading beside the hit (samples near the damage stamp can already carry the rocket's own knockback, so the hit-time sample alone reported standing players as airgibs; the look-back is tunable per request via /highlights?preMs=). 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 highlights.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.

Three fields report that a result is degraded or empty, 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. noMatch (schema v74) is the third and answers a different question entirely — not "what went wrong" but "why is there nothing here": it is present exactly when the streams block is absent, and names the reason (midMatchRecording / matchStartUnannounced / noMatchDeclared / noPlayRecorded / demoUnreadable) with the wire evidence behind it. 2% of the archive carries it; see the known-limitations list below. (v75 removed the whole matchStartUnannounced slice of that by detecting the match start from three more wire signals — see MVD_FORMAT.md.) Check it FIRST of the three: it decides whether errors[] and parseWarnings describe a partial match or nothing at all, and demoUnreadable is the one reason that means both.

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)
    index.html, src/       Vite + Svelte: app/ (load, URL, tabs), shell/, tabs/, player/ (Map and Player tabs), play/ (play.html, the light player), data/, state/, lib/, ui/ (shared widgets: playback bar, panels)
    static/                worker.js, styles.css, vendor/, maps/ (copied as-is)
    test/                  node --test unit tests for the DOM-free modules
    cmd/wasm/              WASM entry (exports analyzeMVD to JS)

  mvd-map-view/            ESM: the embeddable WebGL2 map renderer (MvdMap) the Map and Player tabs' map panes draw with
  mvd-fte-player/          ESM: FTE demo player page for the Player tab (port of hub's, MIT)

  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 frontend's node tests (mvd-web/test/, run by make test when node is on PATH) read the same mvd-analytics/testdata/golden/*.json and pin values out of them, so rerun make test after a regen and update a JS expectation that the intended change moved — see mvd-web/README.md § Tests.

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. That holds across every server generation: pre-MVDSV-0.30 recordings carry far sparser hit telemetry but were measured, on 15 254 demos against the obituary with the frag log withheld from attribution, to attribute at or above the instrumented eras. What those recordings do cost is elsewhere — on 2.1% of qwsv demos the health stat channel is barely broadcast, so the section reports only the fraction of the match the wire showed. Since v74 it says so: damage.coverage {kills, covered, ratio} publishes the share of the frag log's kills whose damage the reconstruction accounts for, on every reconstructed section. Rescored over the full 10 702-demo oracle sweep: 99.0% read ≥ 0.95 (median 1.000), 0.80% are that broken class (0.182 median, 0.488 worst), and 0.18% fall between the two, spanning 0.500–0.944 — a hard bimodal core with a thin gradient tail, so ratio is a magnitude to read rather than a two-valued flag. It measures the frag-log-visible match, so a loss that drops obituaries and damage together still reads 1.000, and nothing in the pipeline gates on the number. 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.

0a. The derived demoinfo summary is not the KTX block. 54% of the archive predates ktxstats, so /demo-info 422s there permanently. playerStats answers the same questions from the wire instead — including, since v74, the kill-streak maxima (score.maxSpree / maxQuadSpree) and accuracy.byWeapon[].hits recovered from the aim recon tier (accuracy.src: "reconstructed"). Every family says where its numbers came from, and the withholds are real: ng/sng carry no recovered hits at all, and the whole kill side — sprees included — is absent on a demo whose obituaries never matched. Two things it does NOT claim. maxSpree deliberately diverges from KTX's, which credits a player's own suicide to their streak wherever teamplay is off; and a reconstructed family keeps trigger pulls on sg/ssg, whose pellet split its rebuilt log cannot carry. A derived family is on KTX's own scale per weapon since v75 — sg/ssg pellets, rl/gl direct impacts, the rest a connecting fire. gl's is the one that is not a wire reading: KTX counts a grenade that TOUCHED a player, the touch detonates it, and every damage row the wire then carries is flagged splash — so the touch is re-derived from the grenade's flight and its 2.5 s fuse by the same classifier a pre-instrumentation demo uses (92% of 424 archive player rows exact against the verbatim block). That classifier reads the projectile streams, which mvd-api and the web build always request and a bare CLI parse does not; without them the row says anyDamage rather than guessing. That is stated in the payload rather than only in prose: every weapon carrying hits carries hitsConvention (anyDamage | directImpact | pellets), so a consumer can gate a cross-era comparison instead of re-deriving the rule. On a reconstructed family rl/gl DO answer KTX's question (schema v74): the reconstruction classifies each explosion direct-vs-splash from the flight's trajectory against the player hull, the flat-110 direct-damage constant where the demo established it (damage.rocketDirectRegime says whether it did, and whether the demo's own hits argue against it), and — for grenades — the spent 2.5 s fuse, which makes a full-fuse flight a certain non-touch. The resulting counts land within 0.13 hits per player row on rl and 0.08 on gl, where the already-shipped lg row sits at 0.95. Measured against the verbatim block on 188 instrumented demos by cmd/qw-demoinfo-eval; the full agreement table is in mvd-analytics/RESULT_SCHEMA.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/BACKPACK_RECON.md.

0c. 2% of the archive holds no analyzable match — and now says so. Streams are built only inside the DETECTED match window, so a demo whose match start was never declared on the wire produces no player streams and, with them, no damage / playerStats / locGraph / buckets. Over the 50 951-demo readability sweep that is 1 032 demos (2.03%), and until v74 every one of them came out silent — empty sections and an empty errors[], with no way to tell "this recording holds no match" from "the recording starts mid-game" from "the parse failed". The v74 noMatch block is present on exactly those results and names the reason: midMatchRecording (68 — the serverinfo status key already read "13 min left" at demo open), matchStartUnannounced (138 before v75, 0 after — the server started a match under our watch and no analyzable player stream came out; v75 gave the reader three signals besides the broadcast line and every one of the 138 is now analyzable), noMatchDeclared (170 — no match declaration we can see, yet kills were parsed; usually unmanaged play, 165 on a foreign *gamedir like fortress / jteams / ctf, but 168 send no status key at all, so a managed match on a mod we cannot read lands here too), noPlayRecorded (636 — the same absent declaration and no kills parsed; usually an idle or aborted server) and demoUnreadable (20 — a truncated read, which errors[] also reports). It is deliberately not an errors[] entry: that list means the pipeline failed, and this is a fact about the demo. Zero markers on a 1 500-demo control drawn from the demos that DO produce streams. Half the SALVAGE work is now done: v75 made the match start a Layer-1 event with four wire signals instead of one broadcast line, and all 138 matchStartUnannounced demos — the KTX matchless FFA servers that never print a start line, plus the fortress / ctf mods — now produce a full result. What is still NOT salvaged is the 68 midMatchRecording demos, where the match began before the first frame and the wire carries no origin to rebase onto; that is the rest of plan lead 8 stage (b) in plan-archive-features.md. FFA MODE semantics landed in the same version: match.gameMode is now the one normalised mode verdict, and a mode with no teams (gameMode.teamBased false) is laid out with one side per player, so a userinfo team tag is never read as a side. See plan-ffa-support.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 — re-measured in v75 on 186 instrumented archive demos, where playerStats accuracy.attacks matches KTX to the row 98-100% of the time on every single-projectile weapon and 100% on both shotguns, whose attacks are now published in KTX's own pellet unit.

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

9b. Accuracy on old demos is a second, separately-named tier (schema v73). Demos with no KTX damage stream have no wire hit to link a fire to, so aim.hitsMeasured is false there and every measured hit counter is withheld (schema v71) — that has not changed. What is new is that the RECONSTRUCTED damage log is now joined back to the fires, and the recovered count published as weapons[].recon.hits, with aim.hitsSource naming the evidence (ktx / reconstructed / absent). The two tiers live in different fields and are never merged, so a reconstructed count cannot be mistaken for a measured one. It covers lg/sg/ssg/axe — the weapons whose damage lands in the fire's own server frame — and, since v74, rl/gl, which join through the fire's own tracked projectile (shots[].flightEnd) on the measured counter's definition rather than by counting impacts. Measured against the wire-linked counter: 0.3–1.8 percentage points of accuracy error for the hitscan set, 0.7 pp rl / 0.4 pp gl. Deliberately NOT ng/sng: nail linking is opt-in, so the measured counter a recovery would be validated against is zero everywhere. Everything below the hit COUNT stays withheld on those demos too: no per-fire hit flags, no pellet split, no direct/splash, no LG whiff classes, no enemy/team/self slices. Method and per-weapon tables: mvd-analytics/damagerecon/ACCURACY.md §aim hit recovery.

  1. Hoony / blitz points are one match window, not rounds. A HoonyMode series is a sequence of points; KTX ends each with "The point is over" (ktx/src/match.c:326), re-readies the players and runs StartMatch again, and only the final point reaches svc_intermission. The pipeline takes the first //ktx matchstart / matchdate: and that intermission, so streams, buckets, damage and playerStats span the whole series including the between-point countdowns, and match.gameMode.rounds is true with nothing yet consuming it. The point boundaries are on the wire as timelineAnalysis.demoMarkers (0 round-N, match.c:447); slicing the analysis per point is future work.

  2. Highlights see only what the wire named. A discharge that killed nobody and left no damage-log hit is invisible — the only evidence of a discharge is an obituary or an LG radius hit. A discharge that killed a teammate prints one of KTX's cause-less team-kill lines, so it is attributed to the discharge only by coincidence with the discharger's own evidence inside the 500 ms cluster window. A dtTELE2 pentagram deflect ("Satan's power deflects X's telefrag") names nobody, so the surviving pent holder is resolved from the pent intervals and only when exactly one other player held pent at the instant — otherwise the row names no survivor.

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