Skip to content

galfthan/mvd_analyzer

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

310 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

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.

Three Go modules in one workspace

The repo is a Go workspace (go.work) binding three sibling modules:

Module Path Role
qwdemo qwdemo/ Event schema + MVD source (Layer 1)
qwanalytics qwanalytics/ Analysis pipeline + result schema (Layer 2)
qw-web qw-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 UX into three layers lets each grow on its own timeline. Today's concrete shape:

  • Layer 1 (qwdemo) 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 (qwanalytics) is the only place that knows how to compute match summaries, frag streaks, timeline buckets, or loc-graphs. New analytics (area control, advanced metrics, whatever's next) land here. Analytics never peeks at MVD bytes; it consumes events.
  • Layer 3 (qw-web) is one of several possible consumers. The bundled example is a browser UI on WASM. The in-tree CLI qw-analyze is a second consumer. An AI review agent is a natural third — all they need is qwanalytics + a way to call it.

Quick start

Analyze a demo at the command line

go run ./qwanalytics/cmd/qw-analyze demo.mvd.gz                 # Result JSON to stdout
go run ./qwanalytics/cmd/qw-analyze -format md demo.mvd.gz      # human summary
go run ./qwanalytics/cmd/qw-analyze -format events demo.mvd.gz  # line-delimited events

Run the web UI locally

make serve                                  # http://localhost:8080

Build the WASM bundle for deploy

make build                                  # output in dist/

Other Makefile targets: make test, make fmt, make clean, make help.

The contracts

Event schema (Layer 1 → 2)

Defined in qwdemo/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, StatUpdateEvent, FragUpdateEvent, PlayerPositionEvent, DamageEvent, DemoInfoEvent, IntermissionEvent, StuffTextEvent, CenterPrintEvent, ServerInfoEvent, DeathEvent, SpawnEvent, ItemSpawnEvent, ItemStateEvent, BackpackDropHintEvent, ItemPickupHintEvent, BackpackPickupHintEvent, ItemPickupPrintEvent, BackpackPickupPrintEvent. Domain types carried by events — ServerData, PlayerInfo, PlayerState, Stats — 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 are derived from the entity-state stream (svc_spawnbaseline + svc_packetentities / svc_deltapacketentities): every item's identity and pickup/respawn transitions come out of the wire directly — no KTX prints, no BSP preprocessing. ItemPickupHintEvent / BackpackPickupHintEvent / BackpackDropHintEvent carry KTX's authoritative //ktx took, //ktx bp, //ktx drop directives — the touch-level pickup attribution that entity-state alone can only approximate. They only fire on KTX servers; non-KTX sources get entity-state and stats deltas. ItemPickupPrintEvent / BackpackPickupPrintEvent parse the per-client "You got the X" prints that target the picking player via dem_single; they fill the gap where //ktx took is silent (ammo boxes, H15/H25, non-RL/LG backpacks) but only survive to the MVD for players who set msg 0 in their client config (see qwdemo/MVD_FORMAT.md for the server-side messagelevel filter that strips PRINT_LOW in most competitive demos).

To write a new source: implement events.Source, emit the concrete event types as you decode your wire format. That's it. See qwdemo/source/mvd for the reference implementation backed by MVD files.

Result schema (Layer 2 → 3)

Defined in qwanalytics/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), backpacks (RL/LG drops attributed to the dropping player via KTX's //ktx drop hint), and 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).

Every breaking change bumps CurrentSchemaVersion (currently 6). Consumers can pin or feature-detect by reading result.schemaVersion. The full per-field reference lives in qwanalytics/RESULT_SCHEMA.md.

Running the pipeline

import (
    "github.com/mvd-analyzer/qwanalytics/analyzer"
    mvdsource "github.com/mvd-analyzer/qwdemo/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 three modules
  Makefile                  Top-level coordinator (build / serve / test / fmt)
  netlify.toml              Netlify deploy config
  README.md                 This file

  qwdemo/                   Module: ingestion layer
    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

  qwanalytics/              Module: analysis pipeline
    analyzer/               Analyzer interface + Context + CoreOutputs + Registry (core/derived split + post-processors)
    result/                 JSON result schema (stable contract)
    loc/                    .loc parser + embedded corpus (466 maps)
    mapgen/
      bsp/                  Quake 1 BSP reader (+ entities lump decoder)
      mapgeom/              Floor-face extraction
    diagnostic/             Opt-in bulk validation harness
    cmd/mapgen/             Developer tool: BSP -> per-loc floor-polygon JSON
    cmd/qw-analyze/         CLI: demo -> json|md|events

  qw-web/                   Module: browser UX + WASM glue
    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 ./qwanalytics/analyzer/                         # single package
go test -v -run TestDiagnosticParseDemos \
    ./qwanalytics/diagnostic/                           # opt-in demo corpus

Golden corpus

make test runs TestGoldenCorpus (in qwanalytics/analyzer/golden_test.go) against a manifest of hub.quakeworld.nu game IDs in qwanalytics/testdata/corpus.json. On first run it downloads each demo into qwanalytics/testdata/cache/<gameId>.mvd.gz (gitignored); subsequent runs hit the cache and stay offline. Each demo's Result JSON is pinned against qwanalytics/testdata/golden/<label>.json.

What is pinned: everything except filePath and a sliced timelineAnalysis.highResBuckets. The full 50 ms position track runs ~20 MB per 4on4 demo and most of it is redundant for regression detection, so canonicalJSON keeps three 15 s windows — [0, 15], [60, 75], and the trailing 15 s — enough sampling to catch bucketer / position-extractor drift while keeping the committed corpus around 18 MB total.

The manifest ships with nine demos (three each of 1on1, 2on2, 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 ./qwanalytics/analyzer/... -run TestGoldenCorpus -args -update-golden
git diff qwanalytics/testdata/golden/   # review
git add qwanalytics/testdata/golden/    # commit alongside the analyzer change

(The -update-golden flag is registered only in the analyzer test package; wider scopes like ./qwanalytics/... fail in mapgen with "flag provided but not defined".)

The pipeline also has a CLI for ad-hoc bulk diffs:

go run ./qwanalytics/cmd/qw-analyze -bulk -out-dir /tmp/before -format json demos/
# ... change ...
go run ./qwanalytics/cmd/qw-analyze -bulk -out-dir /tmp/after  -format json demos/
diff -r /tmp/before /tmp/after

Known limitations

  1. Weapon switching scripts: QW players use scripts that switch weapons faster than MVD stat updates, causing RL/GL shot undercounting in MVD-based tracking. KTX demoinfo stats (when available) are authoritative.

  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. 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 now match KTX's authoritative tooks on 8 of 9 corpus demos. The remaining residual is bounded to small healths in rare edge cases (damage-in-same-frame). See qwdemo/MVD_FORMAT.md#item-tracking-via-entity-state and qwanalytics/analyzer/items.md.

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

License

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors