# mvd-api — OpenAPI 3.1 description of the REST surface.
#
# FORMATTING IS LOAD-BEARING. mvd-api/openapi_test.go scans this file
# line-wise:
#   - path items are "  /path:" (2-space indent), methods "    get:" (4-space);
#   - the marker comments (artifact-enum, error-code-enum, field-code-enum,
#     event-type-enum, top-window-metric-enum) delimit enum blocks pinned to
#     the code — each names the test that pins it, and a block whose lines
#     are not all "- value" fails the parser;
#   - info.version must equal result.CurrentSchemaVersion.
# Keep those conventions when editing, or the drift tests will fail loudly.
#
# This file is THE per-endpoint reference and must stay self-contained:
# it is served to external integrators who cannot follow repo-relative
# file references, so inline semantics here rather than pointing at
# API.md (high-level guide + recipes only). Deep Result internals may
# link to /docs/result-schema — RESULT_SCHEMA.md served by this API.
# Response shapes are authored from the view/result structs and are
# validated against real golden-corpus responses by openapi_validate_test.go.
openapi: 3.1.0
info:
  title: mvdanalyzer-api
  version: "74"
  summary: QuakeWorld MVD demo analysis REST API
  description: |
    REST host for the mvd-analyzer pipeline: point it at a demo
    (`gameId:N` from hub.quakeworld.nu, or `sha:HEX` of the demo file) and
    read curated analysis sections — frags, damage, aim, item timelines,
    positional streams — as JSON.

    ### Time units — one rule
    **All times are int32 milliseconds** — request params and response
    fields, REST and MCP alike. The time-valued query params `from` / `to`
    / `time` are **integer milliseconds**; a non-integer value (e.g.
    `from=10.5`) 400s with code `invalid_param` and an `(integer
    milliseconds)` hint rather than misfiltering. Every response time field
    is int32 ms — but two key spellings share that one unit, by a
    dense/sparse rule: sample-rate-scaled arrays use the terse key `t`
    (stream tracks, aim samples, projectile/beam columns); the columnar
    bucket grid carries no time key at all — its axis is implicit
    (`time(i) = start + i*windowMs`); event-scaled sparse lists and
    singleton timestamps use the descriptive key `time`
    (frag/damage/shot/chat/backpack/weapon-pickup/airgib/timeline entries,
    events rows, row-bucket `time`, state-at `time`, item
    `firstTake.time`). The name never encodes the unit —
    `timeUnit`, where present, is a constant `"ms"` self-description echo.

    Two documented exceptions, neither a match-relative time: `/demoinfo`
    is the KTX-native units island (integer seconds on KTX's own clock,
    `timelimit` in minutes, item hold in seconds) and carries no `timeUnit`
    echo; search `from` / `to` are calendar dates (`YYYY-MM-DD`), not
    times.

    ### Caching
    Per-demo 2xx responses carry `Cache-Control: no-cache` and an `ETag`
    of the form `"<sha>-v<schemaVersion>"` (per-artifact:
    `"<sha>-<name>@v<n>"`; binary-statics: `"<kind>-v<n>"`). no-cache
    means store-but-revalidate: send `If-None-Match` on every reuse. The
    ETag embeds the schema version, so the mandatory revalidation is a
    version check — an unchanged result is a cheap 304, a schema bump
    re-downloads immediately (nothing serves a stale shape from cache).

    ### Availability — the 422 rule
    Object-shaped sections that need a demo capability return
    `422 <section>_unavailable` when the demo structurally lacks the signal
    (non-KTX server, no position track, …). Treat these as "panel
    unavailable for this demo", not failures. Always-computable list
    sections (`/items`, `/backpacks`, `/weapon-pickups`, `/chat`) return
    200 with an empty body instead.

    ### Parameter conventions
    Query parameter **names** are case-insensitive (canonical spelling is
    camelCase). CSV parameters are comma-separated lists. Player-name
    values are case-sensitive (QW names are case-significant); weapon /
    item / kind / loc / layout tokens are case-insensitive.

    Unknown query parameter names are rejected: a request carrying a query
    key the endpoint does not accept 400s with code `unknown_param` (the
    message names the offender and the accepted keys), rather than silently
    ignoring it. The `label` traffic-source tag is accepted on every
    endpoint. Enum-valued params (e.g. `/events` and `/chat` `types`)
    likewise 400 (`invalid_param`) on an unknown value instead of matching
    nothing.

    ### API stability
    **`/v1` is not frozen yet.** Most change is additive — new endpoints and
    new response fields appear at any time without announcement — but a
    schema upgrade **can still remove a documented field or change what one
    means, inside `/v1`**. Schema v70 is the worked example: `/overview` lost
    `topKills`, `topStreaks`, `topPowerups` and `hasRegionControl` once they
    were measured at 78-88% of the response and found to be copies of
    `/top-kills`, `/lives` and `/events`. Treat this surface as one you can
    build against and will occasionally have to re-check, not one you can
    pin and forget.

    What holds today is that **nothing changes silently**: every observable
    change bumps `schemaVersion`, every change is written up in
    RELEASE_NOTES.md against the version that carries it, this spec is
    generated from the running code and validated against real responses in
    CI, and the MCP shim deploys in lockstep with the API. So watch
    `schemaVersion` — it is your cache key, and until the freeze it is also
    the signal to go read what moved.

    **Where this is going, in the near future:** an additive-only `/v1`
    where documented fields don't change meaning, change type, or disappear;
    a genuine break shipping as `/v2/<endpoint>` served alongside `/v1`
    rather than replacing it; and old routes retiring on a minimum of 8
    weeks' notice, in practice only once measured usage has drained. That
    is the destination and the rules it will be run by — it is not a promise
    that is already in force. A **correctness fix is not a break**: when a value
    changes because it was computed wrongly, the field keeps its name and
    type and its documented meaning is not replaced — at most narrowed to
    the semantics it was always meant to have, with the docs updated to say
    so (schema v64: loc/region "time spent" is now documented as "alive,
    observed time spent"). Such fixes ship inside `/v1` and only raise
    `schemaVersion`. The **MCP tool surface** is covered by the same rules:
    the shim and this API deploy in lockstep, so tool names, parameters and
    result semantics change only under the process above.

    `schemaVersion` (the ETag `-v<n>` suffix, `X-Schema-Version`, and this
    document's `info.version`) is a **regeneration counter** that ticks on
    every observable change, most of them purely additive. Treat it as a
    cache key — and, while `/v1` is still moving, as the prompt to check
    RELEASE_NOTES.md before assuming a bump was additive. Once the surface
    is declared stable it goes back to being a pure cache key, because
    breaks will live at `/v2` instead.

    Covered by the contract: documented request parameters, documented
    response fields and their types, documented enum members and status
    codes. **Not** covered, and free to change without a version bump:
    ordering where no order is documented, rounding of derived floats,
    whether an empty value is omitted or emitted, rate-limit thresholds,
    and which demos return `422 <section>_unavailable` (a demo's
    capabilities are a property of the recording). Adding a member to a
    *default* set is additive even though it changes the rows you receive —
    schema v58 added the `demomark` event type to `/events` and to its
    default set, so callers omitting `types` began seeing new rows. Filter
    explicitly if you need a fixed set.

    In return, clients **must ignore unknown fields and unknown enum
    values**, and treat this document as the contract — undocumented
    behaviour you happen to observe may change without notice.

    This document is the complete per-endpoint reference. Deep
    field-level semantics of the underlying analysis (the Result
    contract shared with the Go library and WASM builds) are served by
    this API at [/docs/result-schema](/docs/result-schema)
    (raw markdown: /docs/result-schema.md).
  license:
    name: MIT
    identifier: MIT
servers:
  - url: /
    description: Same origin — correct for localhost and the hosted deployment alike.
security:
  - bearerKey: []
tags:
  - name: meta
    description: Liveness, build stamp, auth check, this spec.
  - name: demo
    description: Demo identity, warm-up, and curated summaries.
  - name: combat
    description: Frags, damage, shots, aim.
  - name: items
    description: Item spawners, backpacks, weapon pickups.
  - name: timeline
    description: Unified events, chat, region control, key moments.
  - name: positional
    description: Position-derived views — buckets, slices, state-at, trails, LOS.
  - name: streams
    description: Raw projectile / beam / nail flight streams.
  - name: artifacts
    description: The analyzer DAG surface — manifest, graph, generic artifact accessor.
  - name: maps
    description: Per-map static data (no demo needed).
paths:
  /healthz:
    get:
      operationId: getHealth
      tags: [meta]
      summary: Liveness probe
      security: []
      parameters:
        - $ref: "#/components/parameters/Label"
      responses:
        "200":
          description: Server is up.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Health" }
  /openapi.yaml:
    get:
      operationId: getOpenAPISpec
      tags: [meta]
      summary: This document
      description: >
        The OpenAPI 3.1 description of this server, embedded in the binary.
        ETag is a content hash; revalidate with If-None-Match.
      security: []
      parameters:
        - $ref: "#/components/parameters/Label"
      responses:
        "200":
          description: The spec.
          content:
            application/yaml:
              schema: { type: string }
        "304": { $ref: "#/components/responses/NotModified" }
  /docs:
    get:
      operationId: getDocsPage
      tags: [meta]
      summary: Browsable API reference
      description: >
        Interactive reference for this spec (vendored RapiDoc; no external
        requests). Point a browser here.
      security: []
      parameters:
        - $ref: "#/components/parameters/Label"
      responses:
        "200":
          description: The viewer page.
          content:
            text/html:
              schema: { type: string }
        "304": { $ref: "#/components/responses/NotModified" }
  /docs/result-schema:
    get:
      operationId: getResultSchemaPage
      tags: [meta]
      summary: Result schema reference
      description: >
        RESULT_SCHEMA.md — the field-level reference for the analysis
        output (vocabulary codes, stream forms, per-section field
        tables, schema version history) — rendered as a standalone page
        (vendored renderer, no external requests). Raw markdown at
        /docs/result-schema.md.
      security: []
      parameters:
        - $ref: "#/components/parameters/Label"
      responses:
        "200":
          description: The rendered reference.
          content:
            text/html:
              schema: { type: string }
        "304": { $ref: "#/components/responses/NotModified" }
  /v1/version:
    get:
      operationId: getVersion
      tags: [meta]
      summary: Build stamp
      security: []
      parameters:
        - $ref: "#/components/parameters/Label"
      responses:
        "200":
          description: Build identity of the running binary.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Version" }
  /v1/auth/check:
    get:
      operationId: checkAuthKey
      tags: [meta]
      summary: Validate the presented API key
      description: >
        204 when the Bearer key is valid. Deliberately NOT auth-exempt —
        this route is the check itself. In no-auth (localhost) mode it
        always succeeds.
      parameters:
        - $ref: "#/components/parameters/Label"
      responses:
        "204":
          description: Key accepted.
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimited" }
  /v1/artifacts:
    get:
      operationId: listArtifacts
      tags: [artifacts]
      summary: Analyzer DAG artifact manifest
      description: >
        Every DAG node (name, requires, provides, mutates, lazy, cost,
        resultKey, servable, description). Static per binary; ETag
        `"artifacts-v<schemaVersion>"`.
      parameters:
        - $ref: "#/components/parameters/Label"
      responses:
        "200":
          description: The manifest.
          headers: &staticHeaders
            ETag: { $ref: "#/components/headers/ETag" }
            X-Schema-Version: { $ref: "#/components/headers/XSchemaVersion" }
            X-Request-Id: { $ref: "#/components/headers/XRequestId" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ArtifactsManifest" }
        "304": { $ref: "#/components/responses/NotModified" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/Internal" }
  /v1/graph:
    get:
      operationId: getGraph
      tags: [artifacts]
      summary: Analyzer DAG as JSON
      description: >
        Nodes (cost / resultKey / lazy) + artifact edges, exactly
        `analyzer.ExportGraph("json")`. Static per binary; ETag
        `"graph-v<schemaVersion>"`.
      parameters:
        - $ref: "#/components/parameters/Label"
      responses:
        "200":
          description: The graph.
          headers: *staticHeaders
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Graph" }
        "304": { $ref: "#/components/responses/NotModified" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/Internal" }
  /v1/demos:
    post:
      operationId: uploadDemo
      tags: [demo]
      summary: Upload a demo file for analysis
      description: |
        Analyze a demo you hold locally — the API counterpart of the web
        app's local-file flow — instead of one addressable by hub gameId.

        POST the demo as the raw request body (`application/octet-stream`,
        e.g. `curl --data-binary @demo.mvd.gz`); a raw `.mvd` or a gzipped
        `.mvd.gz` is accepted, sniffed by the gzip magic bytes (Content-Type
        is not enforced). The server stores it, parses it synchronously, and
        returns the same identity metadata as `POST /v1/demos/{id}` — a
        `sha:HEX` `demoId` you then read with the per-demo GET endpoints. Like
        a cold gameId load this call can be slow (it parses before
        responding); a client that times out can retry into a cache hit.

        **Identity.** The `sha` keys the demo by the SHA-256 of its
        UNCOMPRESSED content, so uploading the raw `.mvd` and its `.mvd.gz`
        yields the same id, and re-uploading an already-known demo is
        idempotent (`fromCache: true`, deduped — no reparse).

        **Privacy.** An uploaded demo has no owner — it is readable by ANY key
        holder who knows its `sha`. It is a first-class entry in the shared
        cache eviction pool, so after eviction a `sha:` GET returns 404 and
        the client simply re-uploads.

        **Limits.** The on-wire body is capped at 64 MiB and the decompressed
        content at 512 MiB; in auth mode a per-key daily quota (bytes + demo
        count) also applies. A body that is not a parseable MVD demo — or one
        that parses to no actual game — is rejected and not retained. When the
        server runs with uploads disabled (`-max-upload-bytes 0`) this
        endpoint returns 404 `uploads_disabled`.
      requestBody:
        required: true
        description: The demo file bytes — raw `.mvd` or gzipped `.mvd.gz`.
        content:
          application/octet-stream:
            schema:
              type: string
              format: binary
      parameters:
        - $ref: "#/components/parameters/Label"
      responses:
        "200":
          description: Demo identity + cache state.
          headers:
            X-Cache: { $ref: "#/components/headers/XCache" }
            X-Schema-Version: { $ref: "#/components/headers/XSchemaVersion" }
            X-Request-Id: { $ref: "#/components/headers/XRequestId" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/LoadResult" }
        "400":
          description: >
            Empty body (`invalid_param`) or a gzip-magic body that is not a
            valid gzip stream (`invalid_gzip`).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorEnvelope" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404":
          description: Uploads are disabled on this server (`uploads_disabled`).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorEnvelope" }
        "413":
          description: >
            Body exceeds the on-wire limit, or decompresses past the
            512 MiB cap (`demo_too_large`).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorEnvelope" }
        "422":
          description: >
            The uploaded bytes are not a parseable MVD demo, or parse to no
            analyzable game (`unparseable_demo`). Not retained.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorEnvelope" }
        "429":
          description: >
            Per-key daily upload quota exceeded (`upload_quota_exceeded`) or
            the request rate limit (`rate_limited`); both carry `Retry-After`.
          headers:
            Retry-After:
              schema: { type: string }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorEnvelope" }
        "500": { $ref: "#/components/responses/Internal" }
  /v1/demos/{id}:
    post:
      operationId: loadDemo
      tags: [demo]
      summary: Warm the cache for a demo (idempotent)
      description: >
        Resolves + parses the demo into the cache and returns identity
        metadata. Optional: every per-demo GET auto-loads on cache miss —
        use this only to warm up or to learn the SHA behind a gameId.
        Non-cacheable action: no Cache-Control/ETag on the response.
      parameters:
        - $ref: "#/components/parameters/DemoId"
        - $ref: "#/components/parameters/Label"
      responses:
        "200":
          description: Demo identity + cache state.
          headers:
            X-Cache: { $ref: "#/components/headers/XCache" }
            X-Schema-Version: { $ref: "#/components/headers/XSchemaVersion" }
            X-Request-Id: { $ref: "#/components/headers/XRequestId" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/LoadResult" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/DemoNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/Internal" }
        "502": { $ref: "#/components/responses/HubUpstream" }
  /v1/demos/{id}/overview:
    get:
      operationId: getOverview
      tags: [demo]
      summary: Curated match summary
      description: |
        Map, teams, corrected scoreboard, top streaks, top powerup runs,
        timing, data-quality `errors[]` and the reader's `parseWarnings`
        census (wire data the MVD decoder could not read at all).

        **Units seam.** Overview times are int32 MILLISECONDS (verbatim from
        the stored schema).

        **Ordering.** `teams` and `players` are frag-sorted descending — index
        0 is the winner.

        **Wall-clock mapping.** `timing` converts a match-relative game time g
        (ms) to real time — the game clock freezes during pauses, so
        wallClockMs = demoStartUnixMs + demoOffset + g + the durations of
        pauses at or before g (±demoStartAccuracyMs). `timing` is omitted when
        the demo carries no wall-clock source; `pauses` when the match had
        none.

        **When was it played?** Read `timing.matchStartUnixMs` (schema v72),
        not `demoStartUnixMs`: it is what the wire date markers actually state
        and is present on ~95% of demos against ~25% for the server-clock
        anchor. Always check `matchStartConfidence` —
        `contradicted` means the stamp fails a check (e.g. it predates the
        server binary's release) and is reported rather than dropped, with
        `matchStartNote` naming the check.
      parameters:
        - $ref: "#/components/parameters/DemoId"
        - $ref: "#/components/parameters/Label"
      responses:
        "200":
          description: The overview.
          headers: &perDemoHeaders
            ETag: { $ref: "#/components/headers/ETag" }
            X-Cache: { $ref: "#/components/headers/XCache" }
            X-Schema-Version: { $ref: "#/components/headers/XSchemaVersion" }
            X-Request-Id: { $ref: "#/components/headers/XRequestId" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Overview" }
        "304": { $ref: "#/components/responses/NotModified" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/DemoNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/Internal" }
        "502": { $ref: "#/components/responses/HubUpstream" }
  /v1/demos/{id}/demoinfo:
    get:
      operationId: getDemoInfo
      tags: [demo]
      summary: KTX demoinfo block (verbatim pass-through)
      description: >
        Per-player weapon accuracy, kills/deaths, damage, sprees, item
        pickup counts, RL/LG transfers — exactly as KTX reported them.
        KTX UNITS ISLAND: integer seconds on KTX's own clock; `timelimit`
        is minutes; item `{took, time}` is count + cumulative-hold seconds.
        Unavailable (422 `demoinfo_unavailable`) on non-KTX servers or
        pre-match aborts.
      parameters:
        - $ref: "#/components/parameters/DemoId"
        - $ref: "#/components/parameters/Label"
      responses:
        "200":
          description: The KTX demoinfo section.
          headers: *perDemoHeaders
          content:
            application/json:
              schema: { $ref: "#/components/schemas/DemoInfo" }
        "304": { $ref: "#/components/responses/NotModified" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/DemoNotFound" }
        "422": { $ref: "#/components/responses/Unavailable" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/Internal" }
        "502": { $ref: "#/components/responses/HubUpstream" }
  /v1/demos/{id}/metadata:
    get:
      operationId: getMetadata
      tags: [demo]
      summary: Server cvars + KTX match settings
      description: >
        Full serverinfo cvars plus parsed match settings (timelimit,
        fraglimit, antilag, midair, …). 422 `metadata_unavailable` when the
        demo has no fullserverinfo / countdown centerprint.
      parameters:
        - $ref: "#/components/parameters/DemoId"
        - $ref: "#/components/parameters/Label"
      responses:
        "200":
          description: The metadata section.
          headers: *perDemoHeaders
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Metadata" }
        "304": { $ref: "#/components/responses/NotModified" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/DemoNotFound" }
        "422": { $ref: "#/components/responses/Unavailable" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/Internal" }
        "502": { $ref: "#/components/responses/HubUpstream" }
  /v1/demos/{id}/player-stats:
    get:
      operationId: getPlayerStats
      tags: [demo]
      summary: Canonical per-player + per-team statistics
      description: |
        The one-call scoreboard: corrected
        frags/kills/deaths/suicides/teamKills with efficiency, damage,
        per-weapon accuracy, per-kind pickup tallies, and POSSESSION TIME —
        how long each player held each weapon, each armor type, and NO armor.

        **Availability & provenance.** Available on EVERY demo, including ones
        with no KTX demoinfo block (a missing KTX block is never a reason to
        422 — see the 422 response below for the one case that is). Each stat
        family carries `src` ("derived" | "ktx" | "derived:unbounded" on
        damage; "reconstructed" on damage rebuilt for a pre-instrumentation
        demo; "mixed" on a team row is the phantom-roster canary) naming its
        source, with a `sources` roll-up; `/demoinfo` stays the verbatim KTX
        pass-through to diff against.

        **Units.** `window.*Ms` and every `hold.*.ms` / `longestMs` are int32
        MILLISECONDS; `efficiency`, `shareAlive` and `shareMatch` are RATIOS
        in [0,1], not percentages. Prefer the emitted shares over dividing
        yourself — on a team row the denominators are team time.

        **Note** our armor hold time reads LOWER than a KTX end-of-match table
        by design: KTX's armor clock keeps running after the armor is chewed
        to zero (ktx/src/items.c:505-522).
      parameters:
        - $ref: "#/components/parameters/DemoId"
        - $ref: "#/components/parameters/Players"
        - $ref: "#/components/parameters/PlayerStatsTeams"
        - $ref: "#/components/parameters/Label"
      responses:
        "200":
          description: The player-stats section.
          headers: *perDemoHeaders
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PlayerStats" }
        "304": { $ref: "#/components/responses/NotModified" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/DemoNotFound" }
        "422": { $ref: "#/components/responses/Unavailable" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/Internal" }
        "502": { $ref: "#/components/responses/HubUpstream" }
  /v1/demos/{id}/frags:
    get:
      operationId: getFrags
      tags: [combat]
      summary: Frag aggregates + kill log
      description: |
        Top-level frag aggregates plus the full kill log.

        **Filters.** When any scoping filter (players / weapon / from / to) is
        active, ALL aggregates are recomputed from the filtered log so they
        stay consistent with the entries shown.

        **Measuredness.** `killsMeasured` (always present) is the demo-global
        verdict on whether kill ATTRIBUTION was observable at all. When it is
        `false` the log is empty on a demo where players demonstrably died —
        every obituary went unmatched — so `kills`, `teamkills` and `byWeapon`
        are zeros that are NOT measurements, while `deaths` still is. It is
        not recomputed per filter: a filter that matches nothing does not make
        a demo unmeasurable. `/top-windows` and `/lives` publish the same
        verdict as `measured.frags`.

        **Errors.** 422 `frags_unavailable` when the demo has no frag log.
      parameters:
        - $ref: "#/components/parameters/DemoId"
        - $ref: "#/components/parameters/Players"
        - $ref: "#/components/parameters/Weapons"
        - $ref: "#/components/parameters/WeaponsLegacy"
        - $ref: "#/components/parameters/From"
        - $ref: "#/components/parameters/To"
        - $ref: "#/components/parameters/Summary"
        - $ref: "#/components/parameters/Label"
      responses:
        "200":
          description: Aggregates + kill log (log omitted with `summary`).
          headers: *perDemoHeaders
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Frags" }
        "304": { $ref: "#/components/responses/NotModified" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/DemoNotFound" }
        "422": { $ref: "#/components/responses/Unavailable" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/Internal" }
        "502": { $ref: "#/components/responses/HubUpstream" }
  /v1/demos/{id}/damage:
    get:
      operationId: getDamage
      tags: [combat]
      summary: Damage log + aggregates
      description: |
        Per-hit damage log plus aggregates (matrix, per-weapon, given/taken)
        and the KTX-scoreboard cross-check.

        **Positional kills.** Telefrags and stomps are excluded from every
        per-weapon map (byWeapon/byWeaponTeam/byWeaponSelf) and from
        matrix/totalDamage, and listed separately, but their kill value DOES
        fold into given/taken and, on enemy kills, the EWep victim-weapon
        buckets (no fold at all on skipped:* demos — pure exclusion there). On
        a telefrag/stomp the folded value appears as `bounded` (and `damage`
        when the raw fold differs) on the telefrags[]/stomps[] entries.

        **Two damage families** (schema v54), selected by `dmg`:

        - the RAW family is the unbound wire value (includes overkill), so
          totals run higher than the KTX scoreboard;
        - the BOUNDED family reconstructs KTX-scoreboard semantics (armor
          absorbed + health capped to victim health).

        `boundedMode` reports whether that reconstruction ran (`standard`) or
        was skipped (`skipped:midair` / `skipped:instagib` /
        `skipped:dmgfrags` / `skipped:ca` / `skipped:wipeout` / `skipped:ra` /
        `skipped:lgc` / `skipped:race`, where the server mode rewrites or
        suppresses damage in ways the wire does not expose).

        An unset `dmg` defaults to `bounded` for both the summary and the full
        log; an unfiltered bounded/both SUMMARY sources its per-player bounded
        figures from the exact KTX end-of-match scoreboard when the demo
        carries demoInfo (`boundedSource` echoes `ktx` vs `reconstructed`;
        `taken` and the enemyVs* buckets stay reconstructed).

        **EWep buckets.** Per-player EWep buckets slice enemy-given damage by
        what the victim held:
        enemyVsSg/enemyVsMid/enemyVsLg/enemyVsRl/enemyVsBoth, with
        ewep = lg+rl+both.

        **Filters.** Filters recompute aggregates like /frags. The
        `scoreboard` cross-check is a KTX whole-match figure with no per-event
        provenance, so it CANNOT be recomputed against a `weapons` or a
        RESTRICTIVE time window: it is narrowed by a players-only filter but
        OMITTED entirely once a `weapons` or a restrictive time filter is
        active (an explicit `from`/`to` window covering the whole match counts
        as unfiltered).

        **Errors.** 422 `damage_unavailable` only when neither the wire KTX
        mvdhidden_dmgdone stream nor a reconstructed section is available
        (reconstruction covers most pre-instrumentation demos; `source`
        tells which family you got), or `bounded_unavailable` for an
        EXPLICIT `dmg=bounded` on a skipped:* demo (a DEFAULTED bounded
        there falls back to raw).
      parameters:
        - $ref: "#/components/parameters/DemoId"
        - $ref: "#/components/parameters/Players"
        - $ref: "#/components/parameters/Weapons"
        - $ref: "#/components/parameters/WeaponsLegacy"
        - $ref: "#/components/parameters/From"
        - $ref: "#/components/parameters/To"
        - $ref: "#/components/parameters/Summary"
        - $ref: "#/components/parameters/Dmg"
        - $ref: "#/components/parameters/Label"
      responses:
        "200":
          description: Aggregates + per-hit log (log omitted with `summary`).
          headers: *perDemoHeaders
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Damage" }
        "304": { $ref: "#/components/responses/NotModified" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/DemoNotFound" }
        "422": { $ref: "#/components/responses/Unavailable" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/Internal" }
        "502": { $ref: "#/components/responses/HubUpstream" }
  /v1/demos/{id}/shots:
    get:
      operationId: getShots
      tags: [combat]
      summary: Per-fire weapon stream
      description: >
        Every detected weapon fire with time/player/weapon/source, hit +
        victims where linkable, per-player aggregates, and the KTX
        reconciliation cross-check. Takes only the deprecated no-op `nails`
        param (accepted and ignored). 422 `shots_unavailable` when no weapon
        fires were decoded.
      parameters:
        - $ref: "#/components/parameters/DemoId"
        - $ref: "#/components/parameters/ShotsNails"
        - $ref: "#/components/parameters/Label"
      responses:
        "200":
          description: The shots section.
          headers: *perDemoHeaders
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Shots" }
        "304": { $ref: "#/components/responses/NotModified" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/DemoNotFound" }
        "422": { $ref: "#/components/responses/Unavailable" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/Internal" }
        "502": { $ref: "#/components/responses/HubUpstream" }
  /v1/demos/{id}/aim:
    get:
      operationId: getAim
      tags: [combat]
      summary: Per-player aim analysis
      description: >
        Per-weapon effectiveness (SG/SSG pellet stats, RL/GL direct/splash,
        the LG near/blocked/out-of-range whiff split), columnar
        crosshair-error samples, and the LG ramp series. A from/to window
        recomputes aim over the shots in that window; `summary` drops the
        big crosshair/lgRamp blocks. 422 `aim_unavailable` without shots +
        position/view streams.
      parameters:
        - $ref: "#/components/parameters/DemoId"
        - $ref: "#/components/parameters/Players"
        - $ref: "#/components/parameters/From"
        - $ref: "#/components/parameters/To"
        - $ref: "#/components/parameters/Summary"
        - $ref: "#/components/parameters/Label"
      responses:
        "200":
          description: The aim section (columnar blocks omitted with `summary`).
          headers: *perDemoHeaders
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Aim" }
        "304": { $ref: "#/components/responses/NotModified" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/DemoNotFound" }
        "422": { $ref: "#/components/responses/Unavailable" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/Internal" }
        "502": { $ref: "#/components/responses/HubUpstream" }
  /v1/demos/{id}/loc-graph:
    get:
      operationId: getLocGraph
      tags: [positional]
      summary: Per-map loc adjacency graph
      description: |
        Which locs are reachable from which, weighted by observed traversal.
        Node weights (ALIVE, OBSERVED time spent; dead players excluded since
        schema v64) are int32 ms; edge weights are transition counts.

        Two bounds shape both:

        - a position sample's evidence expires after 250 ms, so time inside an
          unobserved hole (a PVS gap on a POV recording, packet loss) is
          credited to nobody and no edge is recorded across such a hole;
        - a player stops contributing at their end-of-track (final sample held
          for one measured cadence, capped at 250 ms) rather than holding
          their last loc to match end.

        Both are inert on server-recorded demos, where gaps are cadence-sized.

        Echoes `timeUnit:"ms"`. 422 `locgraph_unavailable` when no position
        track was emitted.
      parameters:
        - $ref: "#/components/parameters/DemoId"
        - $ref: "#/components/parameters/Label"
      responses:
        "200":
          description: The loc graph.
          headers: *perDemoHeaders
          content:
            application/json:
              schema: { $ref: "#/components/schemas/LocGraph" }
        "304": { $ref: "#/components/responses/NotModified" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/DemoNotFound" }
        "422": { $ref: "#/components/responses/Unavailable" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/Internal" }
        "502": { $ref: "#/components/responses/HubUpstream" }
  /v1/demos/{id}/chat:
    get:
      operationId: getChat
      tags: [timeline]
      summary: Chat-only event slice
      description: >
        Chat messages with speaker/team, mirrored from result.MatchEvent
        (Time, Type, Player, Team, Message, MessageClean). Always 200 —
        empty list when nothing matches.
      parameters:
        - $ref: "#/components/parameters/DemoId"
        - $ref: "#/components/parameters/From"
        - $ref: "#/components/parameters/To"
        - $ref: "#/components/parameters/Players"
        - $ref: "#/components/parameters/ChatTypes"
        - $ref: "#/components/parameters/Label"
      responses:
        "200":
          description: Matching chat events.
          headers: *perDemoHeaders
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Chat" }
        "304": { $ref: "#/components/responses/NotModified" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/DemoNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/Internal" }
        "502": { $ref: "#/components/responses/HubUpstream" }
  /v1/demos/{id}/backpacks:
    get:
      operationId: getBackpacks
      tags: [items]
      summary: RL/LG backpack drops
      description: >
        RL/LG backpack drops: time, dropper, weapon (rl/lg), origin XYZ
        (the pack's own position, 24 units below the victim's origin) and
        resolved loc. Each row names its provenance in `source`, and the two
        are never mixed within one demo:

          * `ktx` — decoded from KTX's `//ktx drop` hint. `entNum` is the
            hinted edict and joins to WeaponPickup.backpackEnt, which is
            where that row's pickup lives; `picker`/`pickerTeam`/
            `pickupTime` are absent. `fate` appears only as `expired`, and
            only when KTX announced the 120 s removal in `//ktx expire` —
            an absent `fate` here means "ask /weapon-pickups", NOT "nobody
            took it".
          * `reconstructed` — replayed from the server's own drop rule on a
            demo whose mod never hinted. There is no pickup hint to join to,
            so the pickup side is carried on the row itself (schema v72):
            `fate` (`picked`/`expired`/`unobserved`) plus `pickupTime` and
            `picker`/`pickerTeam` when the wire named one, read off the
            bound backpack entity — whose edict number `entNum` now carries
            as well. A `reconstructed` `entNum` still joins to nothing;
            it identifies the pack track, not a pickup row.

        Always 200 — empty when none, which means either no pack dropped or
        the reconstruction stood down; the wire shape does not distinguish
        the two.
      parameters:
        - $ref: "#/components/parameters/DemoId"
        - $ref: "#/components/parameters/Players"
        - $ref: "#/components/parameters/Weapons"
        - $ref: "#/components/parameters/WeaponsLegacy"
        - $ref: "#/components/parameters/From"
        - $ref: "#/components/parameters/To"
        - $ref: "#/components/parameters/Label"
      responses:
        "200":
          description: Matching backpack drops.
          headers: *perDemoHeaders
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Backpacks" }
        "304": { $ref: "#/components/responses/NotModified" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/DemoNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/Internal" }
        "502": { $ref: "#/components/responses/HubUpstream" }
  /v1/demos/{id}/items:
    get:
      operationId: getItems
      tags: [items]
      summary: Per-item pickup/respawn timeline
      description: |
        One entry per tracked spawner (unique name like `ya_1`, kind token,
        world position + nearest loc) with its phase timeline
        (availableFrom, takenBy/takenAt, respawnAt).

        **Filters.** `from`/`to` keep phases OVERLAPPING the window; `summary`
        returns per-item take aggregates (takenCount, byPlayer, firstTake)
        instead of phases.

        **Times.** Warmup pickups rebase to negative times;
        `availableFrom: 0` means "available since match start".

        Always 200 — empty when none.
      parameters:
        - $ref: "#/components/parameters/DemoId"
        - $ref: "#/components/parameters/ItemsFilter"
        - $ref: "#/components/parameters/Players"
        - $ref: "#/components/parameters/Kinds"
        - $ref: "#/components/parameters/From"
        - $ref: "#/components/parameters/To"
        - $ref: "#/components/parameters/Summary"
        - $ref: "#/components/parameters/Label"
      responses:
        "200":
          description: Item timelines, or per-item aggregates with `summary`.
          headers: *perDemoHeaders
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Items" }
        "304": { $ref: "#/components/responses/NotModified" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/DemoNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/Internal" }
        "502": { $ref: "#/components/responses/HubUpstream" }
  /v1/demos/{id}/weapon-pickups:
    get:
      operationId: getWeaponPickups
      tags: [items]
      summary: Slot-weapon acquisitions + effectiveness
      description: >
        Every slot-weapon acquisition (world spawners + RL/LG backpacks)
        with kills-before-next-death effectiveness. Backpack pickups carry
        the dropper and the ent number joining to /backpacks. Always 200 —
        empty when none.
      parameters:
        - $ref: "#/components/parameters/DemoId"
        - $ref: "#/components/parameters/Players"
        - $ref: "#/components/parameters/Weapons"
        - $ref: "#/components/parameters/WeaponsLegacy"
        - $ref: "#/components/parameters/Source"
        - $ref: "#/components/parameters/From"
        - $ref: "#/components/parameters/To"
        - $ref: "#/components/parameters/Label"
      responses:
        "200":
          description: Matching pickups.
          headers: *perDemoHeaders
          content:
            application/json:
              schema: { $ref: "#/components/schemas/WeaponPickups" }
        "304": { $ref: "#/components/responses/NotModified" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/DemoNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/Internal" }
        "502": { $ref: "#/components/responses/HubUpstream" }
  /v1/demos/{id}/buckets:
    get:
      operationId: getBuckets
      tags: [positional]
      summary: Time-bucketed position/stat samples
      description: >
        Per-player field series reduced into fixed windows. ⚠️ `windowMs`
        defaults to 50 ms — on a 20-minute match that is ~24,000 windows
        per field per player; always pass a windowMs sized to your
        question. `layout=column` (default) returns the compact columnar
        grid (int32 ms axis); `layout=row` returns row objects (int32 ms
        `time`).
      parameters:
        - $ref: "#/components/parameters/DemoId"
        - $ref: "#/components/parameters/WindowMs"
        - $ref: "#/components/parameters/From"
        - $ref: "#/components/parameters/To"
        - $ref: "#/components/parameters/Players"
        - $ref: "#/components/parameters/Fields"
        - $ref: "#/components/parameters/Reducers"
        - $ref: "#/components/parameters/IncludeTeam"
        - $ref: "#/components/parameters/Loc"
        - $ref: "#/components/parameters/Layout"
        - $ref: "#/components/parameters/Label"
      responses:
        "200":
          description: Bucketed series (columnar or row layout).
          headers: *perDemoHeaders
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Buckets" }
        "304": { $ref: "#/components/responses/NotModified" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/DemoNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/Internal" }
        "502": { $ref: "#/components/responses/HubUpstream" }
  /v1/demos/{id}/events:
    get:
      operationId: getEvents
      tags: [timeline]
      summary: Unified event stream
      description: |
        Discrete match events (frags, spawns, pickups, powerups, streaks,
        chat, demo markers, …) in one time-ordered list. `detail` is
        polymorphic per event type. Omit `types` for the default set
        (includes `pickup`, `demomark`, `airgib`, and `pause`).

        Three event types worth spelling out:

        - **`demomark`** events are the bookmarks players insert in-game with
          `/demomark`; a warmup mark carries a negative `time`, and a mark
          inserted by a spectator carries `detail.spectator: true`.
        - **`airgib`** events are direct enemy rocket hits landed on an
          airborne victim (the Key Moments highlight list): `player` is the
          attacker (rocketeer) and `detail` carries `victim`,
          `attackerTeam?`/`victimTeam?`, `height` (victim feet above the floor
          at the hit), `heightAboveAttacker?`, `loc?`, `damage` (raw), and
          `lethal: true` when the hit killed.
        - **`pause`** events mark a game-clock freeze segment and carry NO
          `player` (so a `players=` filter excludes them);
          `detail.durationMs` is the real wall-clock ms the pause consumed.
      parameters:
        - $ref: "#/components/parameters/DemoId"
        - $ref: "#/components/parameters/From"
        - $ref: "#/components/parameters/To"
        - $ref: "#/components/parameters/Players"
        - $ref: "#/components/parameters/EventTypes"
        - $ref: "#/components/parameters/Loc"
        - $ref: "#/components/parameters/Label"
      responses:
        "200":
          description: Matching events.
          headers: *perDemoHeaders
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Events" }
        "304": { $ref: "#/components/responses/NotModified" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/DemoNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/Internal" }
        "502": { $ref: "#/components/responses/HubUpstream" }
  /v1/demos/{id}/stream-slice:
    get:
      operationId: getStreamSlice
      tags: [positional]
      summary: Raw per-player stream slice
      description: |
        Verbatim slices of the stored per-player streams (change-stream /
        interval / position tracks) for a time window. Entries and the
        envelope `start`/`end` bounds are all int32 MILLISECONDS.

        **Liveness.** Each player also carries `alive` — the stored lives
        clamped to the window — so liveness never has to be re-derived from
        the `sp`/`d` markers. It is not field-gated and keeps its three states
        through the clip: `null` not measurable, `[]` measured but never alive
        in this window, `[…]` the lives.
      parameters:
        - $ref: "#/components/parameters/DemoId"
        - $ref: "#/components/parameters/From"
        - $ref: "#/components/parameters/To"
        - $ref: "#/components/parameters/Players"
        - $ref: "#/components/parameters/Fields"
        - $ref: "#/components/parameters/Loc"
        - $ref: "#/components/parameters/Label"
      responses:
        "200":
          description: The slice.
          headers: *perDemoHeaders
          content:
            application/json:
              schema: { $ref: "#/components/schemas/StreamSlice" }
        "304": { $ref: "#/components/responses/NotModified" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/DemoNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/Internal" }
        "502": { $ref: "#/components/responses/HubUpstream" }
  /v1/demos/{id}/state-at:
    get:
      operationId: getStateAt
      tags: [positional]
      summary: Point-in-time player state
      description: |
        Every requested field carried forward to the instant `time`
        (match-relative integer ms; REQUIRED — missing returns 400
        missing_param). The response echoes the sample instant as `time`
        (int32 ms).

        **Field selectors.** The same registry as /buckets and /stream-slice,
        except `sp`/`d` are rejected (spawns and deaths have no point-in-time
        meaning — use /events); note the `li` code renders as output key
        `loc`.

        **Two fields ride rows outside the selector:**

        - `alive` rides every row regardless of the selector
          (`true`/`false`/`null`, the stored liveness at the instant, `null` =
          not measurable).
        - `posAgeMs` rides a row whenever the request asked for at least one
          positional field (`pos`, `view`, `hgt`, `lq`, `vel`) AND a sample
          resolved: it is how old the snapped position sample is — the
          carry-forward is unbounded by design, so this is what lets a caller
          apply the 250 ms staleness rule the occupancy endpoints use.
      parameters:
        - $ref: "#/components/parameters/DemoId"
        - $ref: "#/components/parameters/TimeRequired"
        - $ref: "#/components/parameters/Players"
        - $ref: "#/components/parameters/Fields"
        - $ref: "#/components/parameters/Loc"
        - $ref: "#/components/parameters/Label"
      responses:
        "200":
          description: Per-player state at the instant.
          headers: *perDemoHeaders
          content:
            application/json:
              schema: { $ref: "#/components/schemas/StateAt" }
        "304": { $ref: "#/components/responses/NotModified" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/DemoNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/Internal" }
        "502": { $ref: "#/components/responses/HubUpstream" }
  /v1/demos/{id}/los:
    get:
      operationId: getLOS
      tags: [positional]
      summary: Per-player line-of-sight intervals (lazy)
      description: |
        LOS/PVS interval sets per player. The heaviest position-derived
        pass — computed lazily on first request, then cached (tier-3
        artifact).

        200 with a players array (`los` omitted for a player with no
        sightlines).

        422 `los_unavailable` when the map has no usable visibility BSP (no
        map name, BSP not provisioned, or a provisioned BSP that fails to
        parse) — that outcome is never cached, so a retry after the BSP is
        provisioned succeeds.
      parameters:
        - $ref: "#/components/parameters/DemoId"
        - $ref: "#/components/parameters/Label"
      responses:
        "200":
          description: Per-player LOS/PVS intervals.
          headers: *perDemoHeaders
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Los" }
        "304": { $ref: "#/components/responses/NotModified" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/DemoNotFound" }
        "422": { $ref: "#/components/responses/Unavailable" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/Internal" }
        "502": { $ref: "#/components/responses/HubUpstream" }
  /v1/demos/{id}/streams/projectiles:
    get:
      operationId: getProjectiles
      tags: [streams]
      summary: Rocket/grenade flight stream
      description: >
        The raw projectile flight stream. Body is `{"projectiles": …}`,
        null when the demo has none.
      parameters:
        - $ref: "#/components/parameters/DemoId"
        - $ref: "#/components/parameters/Label"
      responses:
        "200":
          description: The stream.
          headers: *perDemoHeaders
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Projectiles" }
        "304": { $ref: "#/components/responses/NotModified" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/DemoNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/Internal" }
        "502": { $ref: "#/components/responses/HubUpstream" }
  /v1/demos/{id}/streams/beams:
    get:
      operationId: getBeams
      tags: [streams]
      summary: LG bolt stream
      description: >
        The raw lightning-bolt stream. Body is `{"beams": …}`, null when
        none.
      parameters:
        - $ref: "#/components/parameters/DemoId"
        - $ref: "#/components/parameters/Label"
      responses:
        "200":
          description: The stream.
          headers: *perDemoHeaders
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Beams" }
        "304": { $ref: "#/components/responses/NotModified" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/DemoNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/Internal" }
        "502": { $ref: "#/components/responses/HubUpstream" }
  /v1/demos/{id}/streams/nails:
    get:
      operationId: getNails
      tags: [streams]
      summary: Nail flight stream (ng/sng)
      description: >
        The raw nail flight stream — the highest-volume stream. Body is
        `{"nails": …}`, null when none.
      parameters:
        - $ref: "#/components/parameters/DemoId"
        - $ref: "#/components/parameters/Label"
      responses:
        "200":
          description: The stream.
          headers: *perDemoHeaders
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Nails" }
        "304": { $ref: "#/components/responses/NotModified" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/DemoNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/Internal" }
        "502": { $ref: "#/components/responses/HubUpstream" }
  /v1/demos/{id}/streams/point-effects:
    get:
      operationId: getPointEffects
      tags: [streams]
      summary: Point-effect temp-entity stream
      description: >
        The point-effect temp-entity stream (schema v71): exact
        rocket/grenade detonation points (`explosion`), hitscan damage
        telemetry on players (`blood` — the wire evidence behind the
        damage reconstruction), LG cell hits (`lightningblood`), wall
        puffs (`gunshot`), teleporter sparkles (`teleport`) and the rest.
        Body is `{"types": …, "pointEffects": …}` — `types` is the
        code→name legend, `pointEffects` the columnar stream, null when
        the demo carried none. The optional `types=` query narrows rows
        to a CSV set of effect names (an unknown name is a 400 listing
        the vocabulary). The count column's packaging varies per server
        generation — see the PointEffectStreams schema note before
        reading it as a damage magnitude.
      parameters:
        - $ref: "#/components/parameters/DemoId"
        - $ref: "#/components/parameters/Label"
        - name: types
          in: query
          required: false
          schema: { type: string }
          description: >
            Comma-separated effect names to keep: spike, superspike,
            gunshot, explosion, tarexplosion, wizspike, knightspike,
            lavasplash, teleport, blood, lightningblood, or te<code> for
            a code outside the named vocabulary. Case-insensitive.
            Absent = every effect.
      responses:
        "200":
          description: The stream.
          headers: *perDemoHeaders
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PointEffects" }
        "304": { $ref: "#/components/responses/NotModified" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/DemoNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/Internal" }
        "502": { $ref: "#/components/responses/HubUpstream" }
  /v1/demos/{id}/loc-trails:
    get:
      operationId: getLocTrails
      tags: [positional]
      summary: Per-player loc dwell trails
      description: |
        Consecutive loc residences per player as `[start, end)` spans
        (int32 ms).

        **Alive dwell.** A residence is dwell, i.e. presence, so it is ALIVE
        dwell (schema v64): truncated at a death and resumed at the respawn,
        and the player's last residence ends at their end-of-track (final
        position sample held for one measured cadence, capped at 250 ms)
        rather than at match end — the same bounds `/loc-graph` node time and
        `/region-control` presence use.

        **`minDwellMs`** filters out blips shorter than the threshold by
        folding them into the preceding residence, but the fold never crosses
        a gap: dead spans (and stretches whose loc did not resolve) are never
        re-merged at any `minDwellMs`, and a residence that follows a gap is
        returned even when it is shorter than `minDwellMs`.
      parameters:
        - $ref: "#/components/parameters/DemoId"
        - $ref: "#/components/parameters/From"
        - $ref: "#/components/parameters/To"
        - $ref: "#/components/parameters/MinDwellMs"
        - $ref: "#/components/parameters/Players"
        - $ref: "#/components/parameters/Loc"
        - $ref: "#/components/parameters/Label"
      responses:
        "200":
          description: The trails.
          headers: *perDemoHeaders
          content:
            application/json:
              schema: { $ref: "#/components/schemas/LocTrails" }
        "304": { $ref: "#/components/responses/NotModified" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/DemoNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/Internal" }
        "502": { $ref: "#/components/responses/HubUpstream" }
  /v1/demos/{id}/loc-table:
    get:
      operationId: getLocTable
      tags: [positional]
      summary: Interned loc-name table
      description: >
        The decoder for `li` indices returned by the loc-bearing views in
        index mode (`?loc=index`). Index 0 is the "" no-loc sentinel.
        Empty array when the demo carried no loc data.
      parameters:
        - $ref: "#/components/parameters/DemoId"
        - $ref: "#/components/parameters/Label"
      responses:
        "200":
          description: The table.
          headers: *perDemoHeaders
          content:
            application/json:
              schema: { $ref: "#/components/schemas/LocTable" }
        "304": { $ref: "#/components/responses/NotModified" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/DemoNotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/Internal" }
        "502": { $ref: "#/components/responses/HubUpstream" }
  /v1/demos/{id}/region-control:
    get:
      operationId: getRegionControl
      tags: [timeline]
      summary: Region-control timeline
      description: |
        Team control of named map regions over time.

        **`windowMs` is display-only.** It (⚠️ defaults to 50 ms — pass an
        explicit window) sets only the `bucketStates` display resolution; the
        aggregate `stats` (percentages + `byPlayer`) are the exact
        time-weighted integral over the native position samples (no grid),
        independent of `windowMs`, so they don't change with the display
        window.

        **Presence is alive + observed.** `byPlayer.armed`/`unarmed` are
        integer milliseconds of ALIVE, OBSERVED presence (schema v64): dead
        players are excluded, a sample's evidence expires after 250 ms so
        unobserved holes are credited to nobody, and a player stops
        contributing at their end-of-track (final position sample held for one
        measured cadence, capped at 250 ms) rather than holding their last
        region to match end. `bucketStates` shares the same classification
        point, so a bucket in which no player qualifies reads `_`.

        **Payload trimming.** The `regions` param trims the polygon payload:
        `full` (default) ships the region `points`; `summary` strips them;
        `none` omits the `regions` list entirely — `bucketStates` and `stats`
        are unaffected.

        **Errors.** 422 `region_control_unavailable` when the map has no
        region layout.
      parameters:
        - $ref: "#/components/parameters/DemoId"
        - $ref: "#/components/parameters/WindowMs"
        - $ref: "#/components/parameters/From"
        - $ref: "#/components/parameters/To"
        - $ref: "#/components/parameters/RegionsMode"
        - $ref: "#/components/parameters/Label"
      responses:
        "200":
          description: The control timeline.
          headers: *perDemoHeaders
          content:
            application/json:
              schema: { $ref: "#/components/schemas/RegionControl" }
        "304": { $ref: "#/components/responses/NotModified" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/DemoNotFound" }
        "422": { $ref: "#/components/responses/Unavailable" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/Internal" }
        "502": { $ref: "#/components/responses/HubUpstream" }
  /v1/demos/{id}/top-windows:
    get:
      operationId: getTopWindows
      tags: [timeline]
      summary: Each player's best stretches of the match
      description: |
        Top-scoring windows under one of two segmentations, chosen with
        `mode`. Each has a one-sentence contract:

        - `mode=fixed` (the default) — "in these `windowMs` milliseconds this
          player scored higher on `metric` than in any other stretch of the
          same length."
        - `mode=gap` — "a window is a maximal run of scoring events in which
          consecutive events are no more than `gapMs` apart; its score is
          their sum."

        `windowMs` is fixed mode's main knob — 5000 gives damage bursts, 30000
        gives hot streaks. `gapMs` is gap mode's, is REQUIRED there and has no
        default; each mode rejects the other's knob with a 400. There is
        deliberately no adaptive mode with a tuning constant in it; for the
        naturally variable-length unit use `/lives`, and for a fixed grid use
        `/buckets`.

        **Everything below applies to BOTH modes** — metric vocabulary,
        ranking and the tie-break, both caps, `weapons=` scoping, `minScore`,
        the stats block, measuredness and the damage family — except where the
        text names `windowMs`. What gap mode changes is only where a window
        starts and ends: rows are the runs themselves (so they are disjoint
        per player by construction, with no overlap suppression), `end` is the
        run's last event rather than `start + windowMs`, a run of one event is
        a legitimate row with `durationMs` 0, and a run may span the player's
        own death — `/lives` is the per-life view. See the `mode` and `gapMs`
        parameters for the per-metric starting points and the rest of the
        contract.

        **`/top-windows mode=gap` vs `/top-kills`.** They are adjacent but not
        substitutes: `/top-kills` is the per-KILL view (each row one kill plus
        the killing-weapon burst that produced it, kill-anchored, clipped at
        the killer's life start, with `returnDamage` and `maxGapMs`), while
        gap windows are the per-STRETCH view (best sustained output, no kill
        required — a 300-damage stretch with zero kills is a row — and rows
        never overlap). At `metric=frags&weapons=rl&gapMs=3000` the two do
        collapse onto each other, and there gap mode is the wrong tool: RL
        inter-kill gaps run p50 ≈ 11.8 s, so 85% of clusters would be
        singletons. Use frag gaps of 8000-15000 for multi-kill stretches, or
        damage gaps around 3000 for rampages.

        **`from`/`to` bound where a window may START, not what it covers** —
        the one place this endpoint departs from the shared parameter
        description. A window anchored at or before `to` still runs the full
        `windowMs` past it, so `to=1000&windowMs=30000` returns the best
        window ANCHORED in the first second, which covers up to 31 s, not
        "the best window inside the first second". This is deliberate:
        scoring is clipped to the same span the row reports, so a row's
        `score` and its stats block always describe one identical interval.
        To constrain what a window COVERS, shrink `windowMs`.

        **Anchoring & shape.** Windows are anchored at REAL event times, not
        a grid, so "the best 30 s" is exact rather than snapped. They are
        non-overlapping per player and returned as ONE FLAT LIST, each row
        carrying `player`, `team` and a 1-based `rank`.

        **Ordering: `score` first, then a fixed complementary metric.** Ties
        on `score` are the common case — on `metric=frags` most of a page
        holds the same small integer — so rows tied on the metric are ranked
        by the other half of the same moment: `damageGiven` under
        `frags`/`netFrags`/`shots`/`hits`, `frags` under
        `damageGiven`/`netDamage`, `damageTaken` under `deaths`, `deaths`
        under `damageTaken`; then earlier start, then player name. There is
        no parameter for it. The secondary is read UNSCOPED and in the
        response's own damage family (the `dmg` echo), so it is exactly the
        same-named field of the row's stats block — `weapons=` narrows the
        score but never the tie-break. It also decides WHICH of a player's
        overlapping equal-scoring candidates survives, so a window may now be
        the stretch that ENDS on the scoring event rather than the one that
        starts on it, when that stretch carries more of the complementary
        metric. Demos with no stream for the secondary (no damage log under
        `metric=frags`) simply tie at 0 and fall through to the positional
        keys.

        **Caps & defaults.** Two independent caps shape it:

        - `perPlayer` (max windows from any one player) is applied BEFORE
          `limit` (the total), so `perPlayer=3&limit=10` means "the top 10,
          at most 3 from anyone" rather than "the top 3 of the first three
          players".
        - The defaults — `limit=10`, `perPlayer=0` — make a bare call the
          match-wide top 10.

        **`weapons=` and scoring.** `weapons` scopes the SCORING events only:
        the stats block still describes everything that happened in the
        window, so with `metric=damageGiven&weapons=lg` the `score` is a
        subset of `damageGiven`. The envelope's `scoredBy` (once, not per
        row — it is the same rule for every window) says which is which.
        Valid weapon tokens depend on the metric's own source (the frag log
        knows `hook` and `water`, the damage log `explobox` and `drown`, the
        shot stream only what can be fired); `water` and `drown` are accepted
        interchangeably.

        - Suicides and teamkills never score, though they do appear in the
          stats block.
        - Telefrags and stomps DO score, under every metric they contribute
          to: their value folds into `damageGiven`/`damageTaken` as `/damage`
          reports it and is scored on the same terms, which is why an
          UNFILTERED row's `score` equals the same-named field of its own
          stats block exactly. `weapons` is the only thing that makes the two
          diverge, and it selects positional kills too — `weapons=tele`
          scores telefrags alone.
        - The one exclusion is `damageByWeapon`, matching `/damage`'s own
          `byWeapon`: a positional kill carries no wire weapon.

        **Measuredness.** Every numeric stat is emitted even at zero; read
        the envelope's `measured` block, never a field's absence, to tell an
        unmeasured source from a measured zero.

        **`dmg` — the damage family.** `dmg` selects the family of the whole
        STATS BLOCK and so applies under EVERY metric, not only the damage
        ones — a window picked by `metric=frags` still reports `damageGiven`.
        The resolved family and the demo's reconstruction state come back on
        the envelope as `dmg` and `boundedMode`, exactly as `/damage` echoes
        them; read `dmg` rather than assuming the `bounded` default took,
        since it silently becomes `raw` on a demo whose reconstruction was
        skipped.

        **Errors.** 422 `top_windows_unavailable` when the demo carries no
        source stream for the chosen metric, and 422 `bounded_unavailable` —
        under any metric — for an EXPLICIT `dmg=bounded` on a demo whose
        bounded reconstruction was skipped. Missing loc data omits
        `locs`/`eventLocs` only and never fails the request.
      parameters:
        - $ref: "#/components/parameters/DemoId"
        - $ref: "#/components/parameters/Metric"
        - $ref: "#/components/parameters/TopWindowMode"
        - $ref: "#/components/parameters/TopWindowMs"
        - $ref: "#/components/parameters/TopWindowGapMs"
        - $ref: "#/components/parameters/TopWindowLimit"
        - $ref: "#/components/parameters/PerPlayer"
        - $ref: "#/components/parameters/Players"
        - $ref: "#/components/parameters/Weapons"
        - $ref: "#/components/parameters/From"
        - $ref: "#/components/parameters/To"
        - $ref: "#/components/parameters/DmgFamily"
        - $ref: "#/components/parameters/MinScore"
        - $ref: "#/components/parameters/Label"
      responses:
        "200":
          description: The ranked windows.
          headers: *perDemoHeaders
          content:
            application/json:
              schema: { $ref: "#/components/schemas/TopWindows" }
        "304": { $ref: "#/components/responses/NotModified" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/DemoNotFound" }
        "422": { $ref: "#/components/responses/Unavailable" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/Internal" }
        "502": { $ref: "#/components/responses/HubUpstream" }
  /v1/demos/{id}/top-kills:
    get:
      operationId: getTopKills
      tags: [timeline]
      summary: The match's hardest kill bursts
      description: |
        The highlight-reel view: every enemy kill ranked by how hard the burst
        that produced it hit. Neighbours — `/frags` is the exhaustive kill LOG
        with no damage figure, `/damage` the whole-match aggregates plus the
        per-hit log, `/top-windows` the best fixed-length stretches; this one
        ranks individual KILLS.

        **What a burst is.** For each enemy kill, the contiguous run of
        **killing-weapon** hits the killer landed on that victim leading up to
        it, summed in the requested damage family. The run is clipped below by
        the start of the victim's current life.

        **The burst is the KILLING WEAPON's run, and that is the question this
        endpoint answers** — "how hard was this burst with this weapon".
        Measured over 1,866 enemy kills, on ~8% of them that UNDERSTATES what
        produced the kill: a rocket softens, a shotgun finishes, and the row
        reads `weapon: sg, damage: 16` for a kill that took 250 across
        weapons. Deliberate, not a defect. "What did the kill take across
        weapons" stays a `/damage` question. A different-weapon hit landing
        inside the run neither joins it nor breaks it.

        **`gapMs` is a CAPTURE gap — narrow with `maxGapMs`, not with
        `gapMs`.** The default 3000 is generous on purpose: truncation is
        unrecoverable downstream while over-merge is filterable. To get the
        tighter per-weapon burst your product wants, keep the rows whose
        `maxGapMs` is within that weapon's cadence (LG ≈ 1200 ms, RL ≈
        2300 ms) — every KEPT row then carries its gap-`g` value EXACTLY,
        because dropping hits from a run only widens gaps. An over-merged row
        (`maxGapMs > g`) is dropped by that filter, not truncated, and a row
        outside this response's `limit` cannot re-enter — `?gapMs=g` on this
        endpoint is the exact tighter walk when either matters. Lowering
        `gapMs` as a display knob instead loses damage: a baked 1200 ms gap truncated 11% of rl and 23% of sg bursts,
        worst measured a 291-damage triple-rocket kill reported as **2**.
        `spanMs` is the DISPLAY figure ("291 dmg in 1.7 s") and is not a valid
        narrowing rule — 21 of the 24 over-merged rl bursts in the corpus pass
        a `span <= 2×gap` test.

        **Positional kills produce no row.** A telefrag, stomp or squish
        carries no damage event, so there is no run to sum and no honest
        figure to rank by; they are absent from this ranking only (13 of 1,879
        measured kills) and still present in `/frags` and in `/damage`'s
        `telefrags` / `stomps`.

        **Kills by an already-dead killer stay in**, deliberately: the walk
        consults the VICTIM's liveness and never the killer's, so a rocket in
        flight when its shooter died still ranks. Measured 38 of 1,866 —
        posthumous rockets and mutual frags, exactly the highlights this
        endpoint exists to surface.

        **`returnDamage` is a value, not a flag.** It is what the victim dealt
        back to this killer over the `contestedMs` window before the kill —
        any weapon, same damage family. The server owns the window; the
        threshold that calls a kill "contested" is yours.

        **Ranking & filters.** Rows are ranked by `damage`, ties broken by the
        earlier kill then by killer / victim name, so the response is stable.
        `minDamage` filters on that same figure; `weapons` filters the KILLING
        weapon (burst-capable tokens only: rl, lg, gl, ssg, sng, ng, sg, axe,
        unknown — a positional or environmental cause cannot anchor a burst
        and is rejected, not silently empty); `players`
        restricts the KILLER — there is deliberately no victim-side filter,
        which would be a different question. `from`/`to` bound the KILL
        instant, not the burst: a kept row's run may reach back before `from`.

        **Errors.** 422 `top_kills_unavailable` when the demo lacks any of the
        three sources — the frag log, the damage log, or measurable liveness.
        The last is not pedantry: at the 3000 ms capture gap the walk crosses
        the victim's previous death on 4% of measured kills, and without the
        life clip those bursts absorb the victim's PREVIOUS life. Since the
        list is ranked BY damage, the contaminated rows are exactly the ones
        that reach the top, and a consumer holding only this response could
        not tell them apart. 422 `bounded_unavailable` for an EXPLICIT
        `dmg=bounded` on a demo whose bounded reconstruction was skipped; a
        defaulted one falls back to `raw` and says so in `dmg`.
      parameters:
        - $ref: "#/components/parameters/DemoId"
        - $ref: "#/components/parameters/TopKillGapMs"
        - $ref: "#/components/parameters/TopKillContestedMs"
        - $ref: "#/components/parameters/TopKillLimit"
        - $ref: "#/components/parameters/Players"
        - $ref: "#/components/parameters/Weapons"
        - $ref: "#/components/parameters/MinDamage"
        - $ref: "#/components/parameters/From"
        - $ref: "#/components/parameters/To"
        - $ref: "#/components/parameters/DmgFamily"
        - $ref: "#/components/parameters/Label"
      responses:
        "200":
          description: The ranked kill bursts.
          headers: *perDemoHeaders
          content:
            application/json:
              schema: { $ref: "#/components/schemas/TopKills" }
        "304": { $ref: "#/components/responses/NotModified" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/DemoNotFound" }
        "422": { $ref: "#/components/responses/Unavailable" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/Internal" }
        "502": { $ref: "#/components/responses/HubUpstream" }
  /v1/demos/{id}/lives:
    get:
      operationId: getLives
      tags: [timeline]
      summary: Every player's lives, spawn to death
      description: |
        One row per spawn-to-death run — the natural unit of QuakeWorld
        analysis — carrying the same per-interval stats block `/top-windows`
        uses, plus `endReason`, `spawnLoc`, `deathLoc`, `killedBy`,
        `deathWeapon`, `itemsTaken` and `weaponsHeld`.

        **Segmentation & attribution.** Segmentation comes from
        `streams.players[].alive` (schema v64). Two attribution rules matter
        if you sum per-life stats:

        - a kill made POSTHUMOUSLY — a rocket landing after its shooter died,
          measured 76-197 ms later on real demos — is credited to the life
          that fired it;
        - an event on the boundary between two TOUCHING lives (a
          same-millisecond death and respawn) goes to the life that was
          ending.

        `durationMs` is ALIVE time and is not extended by the posthumous
        window.

        **What the sums reconcile against.** Every stat here is built from the
        per-event LOGS, so an unfiltered response sums, on the frag side, to
        that player's `/frags` `frags[]` rows, and on the damage side to
        `/damage`'s NON-SUMMARY aggregate for them. It does NOT sum:

        - to `/damage`'s `events[]` rows: a telefrag or stomp folds its value
          into the totals without a per-hit row of its own, so on a demo with
          any positional kill the `events[]` sum comes in low by exactly that
          fold;
        - to an unfiltered BOUNDED SUMMARY either — that response substitutes
          KTX's end-of-match scoreboard for the per-hit reconstruction these
          rows are built from;
        - and not necessarily to a scoreboard aggregate such as `/frags`'
          `byPlayer`, which has other sources — a death detected from
          `DF_DEAD` / `STAT_HEALTH` with no obituary counts there and leaves
          no log row for any life to carry.

        On `wipeout_3on2_red_vs_blue[q3dm6qw]` three players' per-life deaths
        sum to 3/4/7 against a `byPlayer` 4/5/9, and the frag log itself holds
        3/4/7 — the lives are faithful to their source.

        **One demo class breaks the identity outright**, and says so by
        reading zero. Where two wire slots resolve to the same player NAME,
        that player's per-player stream is keyed with a `#slot` suffix while
        the event logs keep the bare name, so nothing in their lives rows can
        match: every stat there — kills, deaths, damage, shots — is 0, and
        their pickups attach to no life at all rather than to the wrong
        slot's. `/top-windows` is unaffected (its subjects come from the log
        names directly).

        **`deaths` is not a 0/1 flag.** It is the count of death rows
        attributed to the life. It is usually 1; it is 0 whenever no frag-log
        row names the player at that instant, INCLUDING on lives whose
        `endReason` is `death` (four such rows on the demo above), so do not
        infer one field from the other; and it can EXCEED 1, because a life
        carries both the death that ended it and any death recorded during the
        dead gap that followed, which is the KTX `dtTELE2` deflection
        (measured across 11558 corpus lives: 12 with 2 deaths, one with 3).
        Never treat it as a 0/1 flag — read `endReason` for how the life
        ended.

        **Rates.** Each row carries its attribution window explicitly as
        `attrStart`/`attrEnd`, which is WIDER than `durationMs` (alive time
        only) — divide a count by `attrEnd − attrStart` for an exact rate.

        **Filtering.** `from`/`to` keep lives OVERLAPPING the window, not only
        those contained; note that a filtered response does NOT reconcile —
        each selected life still carries its whole attribution window, and
        `minMs` drops a life together with the events in its window. They are
        therefore not a response-size control either; that is `summary`. An
        INVERTED window (`from` > `to`) selects nothing and returns
        `lives: []`, matching `/frags`, `/damage` and `/top-windows`.

        **Measuredness.** Every numeric stat is emitted even at zero; read the
        envelope's `measured` block, never a field's absence, to tell an
        unmeasured source from a measured zero. A demo with no damage stream
        still yields lives — there, and only there, the envelope's
        `dmg`/`boundedMode` echoes are absent.

        **Errors.** 422 `lives_unavailable` when the demo carries no
        per-player streams to segment, and equally when it has streams but
        liveness was never MEASURABLE on any of them. The second case would
        otherwise serve `lives: []`, which reads as "nobody ever lived" — a
        different and false claim from "we could not tell".
        `measured.liveness` carries the same fact on the responses that do get
        served. 422 `bounded_unavailable` for an EXPLICIT `dmg=bounded` on a
        demo whose bounded reconstruction was skipped; a defaulted one falls
        back to `raw` and says so in `dmg`.
      parameters:
        - $ref: "#/components/parameters/DemoId"
        - $ref: "#/components/parameters/Players"
        - $ref: "#/components/parameters/From"
        - $ref: "#/components/parameters/To"
        - $ref: "#/components/parameters/MinMs"
        - $ref: "#/components/parameters/DmgFamily"
        - $ref: "#/components/parameters/LivesSummary"
        - $ref: "#/components/parameters/Label"
      responses:
        "200":
          description: The lives.
          headers: *perDemoHeaders
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Lives" }
        "304": { $ref: "#/components/responses/NotModified" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/DemoNotFound" }
        "422": { $ref: "#/components/responses/Unavailable" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/Internal" }
        "502": { $ref: "#/components/responses/HubUpstream" }
  /v1/demos/{id}/airgibs:
    get:
      operationId: getAirgibs
      tags: [timeline]
      summary: Airgib key moments
      description: >
        Every direct enemy rocket hit on an airborne victim above the
        height threshold, sorted by height descending. Empty (not an
        error) when the map's BSP was not provisioned at parse time. 422
        `airgibs_unavailable` when the demo has no timeline analysis.


        Airborne is measured on pre-impact evidence: every position
        sample in the `preMs` look-back window (default 100 ms) must
        read clear air, the preceding tick deciding when the window
        holds no sample (coarse-tick demos), and no sample beside the
        hit may read ground contact — samples near the damage stamp can
        already carry the rocket's own knockback, which over-reports
        height but cannot fake a grounded reading. `preMs=0` drops the
        pre-hit gate; the response echoes the effective value.
      parameters:
        - $ref: "#/components/parameters/DemoId"
        - $ref: "#/components/parameters/AirgibPreMs"
        - $ref: "#/components/parameters/Label"
      responses:
        "200":
          description: The airgib list.
          headers: *perDemoHeaders
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Airgibs" }
        "304": { $ref: "#/components/responses/NotModified" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/DemoNotFound" }
        "422": { $ref: "#/components/responses/Unavailable" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/Internal" }
        "502": { $ref: "#/components/responses/HubUpstream" }
  /v1/demos/{id}/artifacts/{name}:
    get:
      operationId: getArtifact
      tags: [artifacts]
      summary: Generic artifact accessor
      description: |
        Materialise and serve any servable artifact by DAG node name. The
        response is a single-key envelope — the key is the artifact's
        **resultKey, NOT the node name**.

        - Accepts NO query parameters (parameterised reads are the curated
          endpoints) — any parameter returns 400 invalid_param.
        - Unknown / non-servable names 404 artifact_unknown.
        - Capability-gated artifacts return their section-specific 422 code.
        - ETag is the finer per-artifact form `"<sha>-<name>@v<n>"`.

        | DAG node | curated route | body key (resultKey) | when the demo lacks it |
        |---|---|---|---|
        | demoinfo | /demoinfo | demoInfo | 422 demoinfo_unavailable |
        | frag | /frags | frags | 422 frags_unavailable |
        | metadata | /metadata | metadata | 422 metadata_unavailable |
        | match | — (feeds /overview) | match | 200, null body value |
        | messages | /chat (filtered view) | messages | 200, null body value |
        | timeline | — (feeds /events, /buckets, /region-control) | timelineAnalysis | 200, null body value |
        | items | /items (filtered view) | items | 200, null body value |
        | damage | /damage | damage | 422 damage_unavailable |
        | shots | /shots | shots | 422 shots_unavailable |
        | map-entities | /v1/maps/{map}/entities (per-map form) | mapEntities | 200, null body value |
        | backpacks | /backpacks (filtered view) | backpacks | 200, null/[] |
        | weapon-pickups | /weapon-pickups (filtered view) | weaponPickups | 200, null/[] |
        | aim | /aim | aim | 422 aim_unavailable |
        | loc-graph | /loc-graph | locGraph | 422 locgraph_unavailable |
        | no-match | — (republished by /overview) | noMatch | 200, null body value — null is the answer on the 98% of demos that DO hold a match |
        | opening | — (artifact only) | opening | 422 opening_unavailable |
        | player-stats | /player-stats | playerStats | 422 playerstats_unavailable (only a degraded no-streams parse; a missing KTX block is served normally) |
        | los | /los | — (serves the /los body, no envelope key) | 422 los_unavailable (map has no usable visibility BSP) |
      parameters:
        - $ref: "#/components/parameters/DemoId"
        - $ref: "#/components/parameters/ArtifactName"
        - $ref: "#/components/parameters/Label"
      responses:
        "200":
          description: Single-key artifact envelope.
          headers: *perDemoHeaders
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ArtifactEnvelope" }
        "304": { $ref: "#/components/responses/NotModified" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404":
          description: Unknown demo (`demo_not_found`) or unknown/non-servable artifact name (`artifact_unknown`).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorEnvelope" }
        "422": { $ref: "#/components/responses/Unavailable" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/Internal" }
        "502": { $ref: "#/components/responses/HubUpstream" }
  /v1/games/search:
    get:
      operationId: searchGames
      tags: [demo]
      summary: Search the hub game catalog
      description: |
        Discovery over hub.quakeworld.nu's public game catalog — no demo is
        involved. Filter by player names, teams, map, mode, matchtag, or date
        range; the newest matches come back when no filter is given. Feed a
        result's `gameId:<id>` to any per-demo endpoint to analyse it.

        **Paging.** `count` is rows in THIS page, `total` (when the hub
        reports it) is all matching rows — page with limit/offset until
        offset+count >= total.

        **Row shape.** Rows are compact by default (each player projected to
        {name, team, frags}); `roster=true` returns the verbatim hub rows
        (per-player ping, colors, is_bot).

        **Caching.** This endpoint proxies a live upstream, so it is not
        cached and can 502 `hub_upstream`.
      parameters:
        - $ref: "#/components/parameters/SearchPlayers"
        - $ref: "#/components/parameters/SearchTeams"
        - $ref: "#/components/parameters/SearchMap"
        - $ref: "#/components/parameters/SearchMode"
        - $ref: "#/components/parameters/SearchMatchtag"
        - $ref: "#/components/parameters/SearchFrom"
        - $ref: "#/components/parameters/SearchTo"
        - $ref: "#/components/parameters/SearchLimit"
        - $ref: "#/components/parameters/SearchOffset"
        - $ref: "#/components/parameters/SearchRoster"
        - $ref: "#/components/parameters/Label"
      responses:
        "200":
          description: The matching page of games.
          headers:
            X-Request-Id: { $ref: "#/components/headers/XRequestId" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/GamesSearchResult" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "502": { $ref: "#/components/responses/HubUpstream" }
        "500": { $ref: "#/components/responses/Internal" }
  /v1/maps/{map}/entities:
    get:
      operationId: getMapEntitiesByMap
      tags: [maps]
      summary: Static map entity layout
      description: >
        Items, spawns, teleporters, buttons, doors for a map from the
        embedded corpus — no demo needed, identical for every demo on the
        map (a caller holding a demo id reads the map name from /overview
        first; the per-match pickup timeline is /items). ETag
        `"ents-<map>-v<corpusVersion>"`.
      parameters:
        - $ref: "#/components/parameters/MapName"
        - $ref: "#/components/parameters/EntityTypes"
        - $ref: "#/components/parameters/Kinds"
        - $ref: "#/components/parameters/Label"
      responses:
        "200":
          description: The entity list.
          headers: &mapStaticHeaders
            ETag: { $ref: "#/components/headers/ETag" }
            X-Request-Id: { $ref: "#/components/headers/XRequestId" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/MapEntities" }
        "304": { $ref: "#/components/responses/NotModified" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/MapUnavailable" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/Internal" }
  /v1/maps/{map}/geometry:
    get:
      operationId: getMapGeometry
      tags: [maps]
      summary: Per-map floor-polygon geometry
      description: >
        Streams the per-map floor-polygon geometry JSON (large; REST-only,
        no MCP tool). 404 `map_unavailable` when geometry is not
        provisioned on this server or unknown for the map. ETag
        `"geo-<map>-<size>"`.
      parameters:
        - $ref: "#/components/parameters/MapName"
        - $ref: "#/components/parameters/Label"
      responses:
        "200":
          description: The geometry document.
          headers: *mapStaticHeaders
          content:
            application/json:
              schema: { $ref: "#/components/schemas/MapGeometry" }
        "304": { $ref: "#/components/responses/NotModified" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/MapUnavailable" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/Internal" }
components:
  securitySchemes:
    bearerKey:
      type: http
      scheme: bearer
      description: >
        API key as `Authorization: Bearer <key>`. Required only when the
        server runs in auth mode (the hosted deployment); localhost /
        no-auth mode accepts every request and treats the header as an
        unvalidated source label for the access log. Exempt even in auth
        mode: /healthz, /v1/version, /openapi.yaml, /docs.
  parameters:
    Label:
      name: label
      in: query
      required: false
      description: >
        Non-secret traffic-source tag for access-log attribution. Accepted on
        every endpoint; never affects the response.
      schema: { type: string }
    DemoId:
      name: id
      in: path
      required: true
      description: >
        Demo identity: `gameId:N` (a hub.quakeworld.nu game id, positive
        integer) or `sha:HEX` (lowercase-insensitive 64-hex SHA-256 of the
        demo file). Every per-demo endpoint auto-resolves and auto-loads
        on cache miss.
      schema:
        type: string
        pattern: "^(gameId:[0-9]+|sha:[0-9a-fA-F]{64})$"
      examples:
        byGameId: { value: "gameId:145060" }
        bySha: { value: "sha:ffda58010f3ab43c020dfd267dd90ba6775ec8e6f0b969ade82ca15d9162f57d" }
    ArtifactName:
      name: name
      in: path
      required: true
      description: >
        DAG node name of a servable artifact (see GET /v1/artifacts).
        Note the body key of the response is the artifact's `resultKey`,
        which may differ from this name.
      schema:
        type: string
        enum:
          # artifact-enum:begin — pinned to analyzer.ArtifactManifest() (servable only) by TestOpenAPIArtifactEnumCurrent
          - demoinfo
          - frag
          - metadata
          - match
          - messages
          - timeline
          - items
          - damage
          - shots
          - map-entities
          - backpacks
          - weapon-pickups
          - aim
          - loc-graph
          - no-match
          - opening
          - player-stats
          - los
          # artifact-enum:end
    MapName:
      name: map
      in: path
      required: true
      description: Map name, e.g. `dm3` (normalized server-side).
      schema: { type: string }
    From:
      name: from
      in: query
      description: >
        Window start, match-relative integer MILLISECONDS. 0 / omitted = no
        lower bound. Endpoints treat the window as inclusive on both ends.
        A non-integer value 400s with code `invalid_param` and an `(integer
        milliseconds)` hint.
      schema: { type: integer, minimum: 0 }
    To:
      name: to
      in: query
      description: >
        Window end, match-relative integer MILLISECONDS. 0 / omitted = no
        upper bound. A non-integer value 400s with code `invalid_param` and
        an `(integer milliseconds)` hint.
      schema: { type: integer, minimum: 0 }
    PlayerStatsTeams:
      name: teams
      in: query
      description: >
        CSV of team names. Case-SENSITIVE. Restricts both the player rows
        and the team aggregate rows. Omit for all teams.
      style: form
      explode: false
      schema:
        type: array
        items: { type: string }
    Players:
      name: players
      in: query
      description: >
        CSV of player display names. Case-SENSITIVE (QW names are
        case-significant). Omit for all players. Filter semantics are
        per-endpoint (e.g. killer-or-victim on /frags, speaker on /chat).
      style: form
      explode: false
      schema:
        type: array
        items: { type: string }
    Weapons:
      name: weapons
      in: query
      description: |
        CSV of weapon codes (case-insensitive). Core codes: rl, lg, gl, ssg,
        sng, ng, sg, axe; on /damage the pseudo-codes tele/stomp select the
        positional instant kills (which carry no real weapon).

        Each endpoint validates against its OWN closed vocabulary and returns
        400 `invalid_param` (listing the valid set) for an unknown token — no
        more silent empty result:

        - **/frags** additionally accepts the obituary environmental / cause
          codes (hook, rail, squish, explobox, fall, lava, slime, water,
          world, tele, stomp, unknown, suicide, teamkill);
        - **/damage** the death-type + environmental codes (hook, explobox,
          squish, lava, slime, drown, fall, trigger, suicide, unknown);
        - **/backpacks** accepts rl/lg only;
        - **/weapon-pickups** ssg,ng,sng,gl,rl,lg;
        - **/top-kills** filters the KILLING weapon (which is also the
          burst's own weapon) and accepts only the burst-capable subset —
          the core codes plus `unknown`. Positional (tele/stomp/squish),
          environmental and bookkeeping causes are REJECTED here: no damage
          event can anchor them, so the token would be a valid filter that
          structurally selects nothing.

        `water` and `drown` are the SAME cause under the two logs' different
        spellings and are accepted interchangeably wherever either is valid,
        so you need not know which log backs the endpoint you are calling.

        On `/frags`, `teamkill` selects only the teamkills whose obituary
        phrasing has no cause in it ("X checks his glasses" and its siblings,
        which KTX picks at random). KTX's team branch tests three deathtypes
        by name before that random pick, and those keep their real code —
        filter `tele` (teammate telefrag), `stomp` (teammate jump/crush) or
        `squish` (teammate mover crush) for them, and `isTeamKill` to
        separate any of these from the enemy-facing rows carrying the same
        code.

        The pre-16.2 singular spelling `weapon` is an accepted legacy alias;
        `weapons` wins when both are present.
      style: form
      explode: false
      schema:
        type: array
        items: { type: string }
    WeaponsLegacy:
      name: weapon
      in: query
      deprecated: true
      description: >
        Deprecated legacy alias of `weapons` (the pre-16.2 singular
        spelling). Ignored when `weapons` is present.
      style: form
      explode: false
      schema:
        type: array
        items: { type: string }
    ShotsNails:
      name: nails
      in: query
      deprecated: true
      description: >
        Deprecated no-op. The retired opt-in that once gated ng/sng nail
        linking; ng/sng fires are always in the stream now. Accepted and
        ignored so old callers do not 400.
      schema: { type: string }
    Summary:
      name: summary
      in: query
      description: >
        `1`/`true` drops the big per-event log / sample arrays / phase
        timeline and returns only the aggregates.
      schema: { type: boolean }
    ItemsFilter:
      name: items
      in: query
      description: >
        CSV restricting to items whose Name or Kind matches
        (case-insensitive). A kind token matches every instance ("ya" →
        ya_1, ya_2); an instance name ("ya_1") matches one.
      style: form
      explode: false
      schema:
        type: array
        items: { type: string }
    Kinds:
      name: kinds
      in: query
      description: >
        CSV of item categories — armor, mega, health, powerup, weapon,
        ammo — or raw kind tokens (ra, quad). Case-insensitive.
      style: form
      explode: false
      schema:
        type: array
        items: { type: string }
    Source:
      name: source
      in: query
      description: Restrict weapon pickups by provenance.
      schema:
        type: string
        enum: [world, backpack, unknown]
    ChatTypes:
      name: types
      in: query
      description: >
        CSV of message types; defaults to `chat,teamsay`. Pass a subset to
        narrow. An unknown value 400s (invalid_param).
      style: form
      explode: false
      schema:
        type: array
        items:
          type: string
          enum:
            - chat
            - teamsay
    EventTypes:
      name: types
      in: query
      description: >
        CSV of event types: frag, powerup, streak, spawn, death, weapon,
        item, chat, pickup (the default set), plus the opt-in
        high-frequency / dedicated lenses damage, health, armor, loc,
        telefrag, stomp. Omit for the default set. An unknown type value
        400s (invalid_param) rather than silently matching nothing.
      style: form
      explode: false
      schema:
        type: array
        items:
          type: string
          enum:
            # event-type-enum:begin — pinned to view.KnownEventTypes by TestOpenAPIEventTypesCurrent
            - frag
            - powerup
            - streak
            - spawn
            - death
            - weapon
            - item
            - chat
            - pickup
            - demomark
            - airgib
            - pause
            - damage
            - telefrag
            - stomp
            - health
            - armor
            - loc
            # event-type-enum:end
    EntityTypes:
      name: types
      in: query
      description: >
        CSV of entity types: item, spawn, teleportDst, teleportSrc,
        button, door.
      style: form
      explode: false
      schema:
        type: array
        items: { type: string }
    Fields:
      name: fields
      in: query
      description: >
        CSV of field selector codes. Omit for the default set (everything
        except the opt-in view/hgt/lq/vel/aw). NOTE: the selector code `li`
        renders as output key `loc` in name mode — `loc` is NOT a selector
        code. Unknown codes 400 with the full vocabulary enumerated.
      style: form
      explode: false
      schema:
        type: array
        items:
          type: string
          enum:
            # field-code-enum:begin — pinned to view/fields.go by TestOpenAPIFieldCodesCurrent
            - h
            - a
            - at
            - li
            - pos
            - view
            - hgt
            - lq
            - vel
            - rl
            - lg
            - gl
            - ssg
            - sng
            - q
            - pe
            - r
            - sh
            - nl
            - rk
            - cl
            - aw
            - sp
            - d
            # field-code-enum:end
    Loc:
      name: loc
      in: query
      description: >
        Loc rendering — `name` (default) resolves loc indices to names;
        `index` returns raw LocTable indices (decode via /loc-table).
      schema:
        type: string
        enum: [name, index]
    Layout:
      name: layout
      in: query
      description: >
        /buckets response layout — `column` (default, compact columnar
        grid) or `row` (row objects).
      schema:
        type: string
        enum: [column, row]
    Metric:
      name: metric
      in: query
      description: >
        What a top window is ranked by — any SUMMABLE per-event quantity.
        Ratios (accuracy, efficiency) are deliberately absent: they do not sum,
        so "the best window" is undefined for them; they ride the per-window
        stats block instead. `netFrags` (kills − deaths) is usually what people
        mean by hot — five kills bought with four deaths is a trade, not a run.
        `deaths` finds a player's WORST stretch. Case-insensitive.

        **`shots`/`hits` want a `weapons=` filter.** A "shot" is one discrete
        fire (an LG beam tick and a rocket each count 1), so the unfiltered
        sum is dominated by fast-cycling weapons; and hits are counted only
        for weapons whose fires LINK to damage (axe/sg/ssg/lg always, rl/gl via
        projectile tracking, ng/sng only when nail decoding ran), so the
        unfiltered figure mixes measured and unmeasured weapons. A rocket's
        SPLASH is a hit here — including a rocket jump's self-splash, which the
        stats split out as selfHits — and that is NOT KTX's rl/gl convention:
        KTX counts only rockets that TOUCHED a player
        (ktx/src/weapons.c:994, :1329) and reads ~4x lower, so never diff this
        against a demoinfo `acc.rl.hits`. For RL windows
        `metric=damageGiven&weapons=rl` is the quality signal (enemy-only by
        definition), while `metric=hits&weapons=lg` is the LG tracking signal
        (each cell counts the same; raw damage ≈ 30×hits, bounded discounts
        the killing cell's overkill).
      schema:
        type: string
        default: frags
        enum:
          # top-window-metric-enum:begin — TestOpenAPITopWindowMetricsCurrent pins this to view.KnownTopWindowMetrics
          - frags
          - deaths
          - netFrags
          - damageGiven
          - damageTaken
          - netDamage
          - shots
          - hits
          # top-window-metric-enum:end
      example: netFrags
    TopWindowMode:
      name: mode
      in: query
      description: |
        The SEGMENTATION — how a window's boundaries are decided (default
        `fixed`, so a request written before this parameter existed is
        unchanged). Case-insensitive; the resolved value is echoed on every
        response, `fixed` ones included.

        - `fixed` — every window is exactly `windowMs` long. "In these
          `windowMs` ms this player scored higher on `metric` than in any
          other stretch of the same length."
        - `gap` — a window is a maximal run of scoring events in which
          consecutive events are no more than `gapMs` apart; its score is
          their sum. The stretch lasts as long as the player kept doing it,
          not as long as a stopwatch says. A gap of EXACTLY `gapMs` joins and
          `gapMs+1` splits; a run of one event is a legitimate window with
          `durationMs` 0.

        Each mode takes exactly ONE knob and REJECTS the other's with a 400
        rather than ignoring it: `gapMs` under `mode=fixed`, and `windowMs`
        under `mode=gap`. `gapMs` is REQUIRED under `mode=gap` and has no
        default.

        Gap-mode specifics worth knowing before you read the rows:

        - Signed metrics (`netFrags`, `netDamage`) cluster on ALL their
          events, negatives included — a death both extends the run and
          lowers its score. That is "net" as the fight went, and it is what
          keeps `score` equal to the same-named stat.
        - A cluster MAY SPAN THE PLAYER'S OWN DEATH (kill, die, respawn, kill
          again within `gapMs` is one window). `/lives` is the per-life view.
        - Clusters are disjoint per player by construction, so unlike fixed
          mode there is no overlap suppression to reason about.
        - `from`/`to` still bound where a window may START — here, its first
          event; the run may end past `to`. Note the consequence on the
          `from` side: a cluster whose first event precedes `from` is not
          returned AT ALL, its post-`from` mass included — `from` never
          re-anchors a cluster. Slicing a match into `from`/`to` panes
          therefore drops straddling clusters rather than truncating them.
      schema:
        type: string
        default: fixed
        enum:
          # top-window-mode-enum:begin — TestOpenAPITopWindowModesCurrent pins this to view.KnownTopWindowModes
          - fixed
          - gap
          # top-window-mode-enum:end
      example: gap
    TopWindowGapMs:
      name: gapMs
      in: query
      description: |
        Gap mode's knob, in integer MILLISECONDS: consecutive scoring events
        no more than `gapMs` apart belong to the same window.

        **REQUIRED under `mode=gap`, with deliberately NO default** — omitting
        it is a 400. The metrics' event cadences are too far apart for one
        value to serve them: measured over the 44-demo corpus, per-player
        inter-KILL gaps run p50 ≈ 11-12 s while inter-damage-event gaps run
        p50 ≈ 1.0-1.1 s, so the value that reads as "a streak" on frags
        dissolves damage into phase-length blobs, and the value that reads as
        "a rampage" on damage leaves 85% of RL frag clusters singletons.
        Requiring it also keeps the mode honest: you state what "kept doing
        it" means rather than inheriting a constant nobody can justify.

        **Measured starting points: ~10000 for the frag metrics
        (`frags`/`deaths`/`netFrags`), ~3000 for the damage and shot
        metrics.** At `metric=frags` a gap of 8000-15000 reads as a streak
        (top clusters 18-22 kills); at `metric=damageGiven` a gap of
        2000-3000 reads as a rampage (~900 damage over 13-21 s) and 8000
        already merges whole map phases.

        **Range 1..the match duration**, `windowMs`' rule exactly, and an
        out-of-range value is REJECTED with a 400 naming the bound rather than
        clamped. The floor covers an explicit `gapMs=0` (an omitted MCP
        integer arrives as 0, which must not look deliberate) and any negative
        value; the ceiling is the match duration, past which every one of a
        player's events joins a single window.

        **Rejected under `mode=fixed`** with a 400 — an explicit conflict, not
        a silent drop, since the response echoes only its own mode's knob and
        so could not show that anything was discarded.
      schema: { type: integer, format: int32, minimum: 1 }
      example: 3000
    TopWindowMs:
      name: windowMs
      in: query
      description: |
        FIXED MODE'S knob: the window length in integer MILLISECONDS (default
        30000). 5000 gives damage bursts, 30000 hot streaks, 120000 map-phase
        dominance. Sweep it — there is no single right value and the API does
        not pick one for you.

        **Rejected under `mode=gap`** with a 400 (that mode's knob is
        `gapMs`), and absent from a gap response, where a fixed length would
        be a lie.

        **Bounded on both sides**, and an out-of-range value is REJECTED with
        a 400 naming the bound rather than clamped.

        - The floor is 1 (`windowMs=0` and any negative value alike; omit the
          parameter for the default).
        - The CEILING IS THE MATCH DURATION — see `/overview`'s `matchEnd` —
          because a window at least that long is simply the whole match, and a
          longer one would break the `end = start + windowMs` identity every
          row is documented by (the response would have to clamp `end`,
          leaving a `durationMs` covering time that does not exist).
      schema: { type: integer, format: int32, minimum: 1, default: 30000 }
      example: 30000
    TopWindowLimit:
      name: limit
      in: query
      description: |
        Total windows returned across all players (default 10, max 200).
        Applied AFTER `perPlayer`. A NEGATIVE value means uncapped.

        Out-of-range values are REJECTED with a 400, never silently clamped: an
        explicit `limit=0` (0 cannot mean "uncapped" — an omitted MCP integer
        arrives as 0, which would make a forgotten argument look deliberate)
        and any `limit` above the 200 maximum.
      schema: { type: integer, default: 10, maximum: 200 }
      example: 10
    PerPlayer:
      name: perPlayer
      in: query
      description: |
        Maximum windows contributed by any ONE player. Applied BEFORE `limit`,
        so `perPlayer=3&limit=10` is "the top 10 with at most 3 from anyone".
        Set it to spread a leaderboard across the roster; omit it for a pure
        match-wide ranking in which one dominant player may own every row.

        **Name-keyed.** The cap is keyed on the player NAME, so a human the
        server scored as two players (a reconnect renamed `(N)<name>`) can
        occupy twice the quota. `/player-stats`' `identity` is what lets you
        detect that — two names, one identity; the cap itself is deliberately
        left name-keyed.

        **Same rule as `limit`, deliberately:** omitted is the default
        (uncapped), a NEGATIVE value means uncapped, and an explicit
        `perPlayer=0` is REJECTED with a 400 — an omitted MCP integer arrives
        as 0, and 0 must not make a forgotten argument look like a deliberate
        one.
      schema: { type: integer, default: 0 }
      example: 3
    MinScore:
      name: minScore
      in: query
      description: >
        Drop windows scoring below this many points OF THE CHOSEN METRIC
        (default 1) — a score threshold, not a duration; the ms-valued filter
        on the sibling `/lives` is `minMs`. Matters most for the net metrics,
        which can go negative — a window nobody would call hot. An explicit
        `minScore=0` is a MEANINGFUL request (keep the windows that broke even)
        and is honoured, not treated as "unset".
      schema: { type: integer, default: 1 }
    TopKillGapMs:
      name: gapMs
      in: query
      description: |
        The burst walk's CAPTURE gap, in integer MILLISECONDS (default 3000,
        max 5000): a hit joins the run while it lands within `gapMs` of the
        run's earliest hit so far.

        **Do not lower this to get tighter bursts** — filter the response by
        `maxGapMs` instead. The default is generous because truncation is
        unrecoverable downstream while over-merge is filterable, and keeping
        the rows with `maxGapMs <= g` gives every KEPT row its gap-`g` value
        EXACTLY. A run with `maxGapMs > g` is dropped by that filter, not
        truncated — the true gap-`g` walk would report its shorter suffix
        instead — so the narrowed list is the gap-`g` ranking restricted to
        intact runs. A baked 1200 ms gap truncated 11% of rl and 23% of sg
        bursts. The parameter exists for the exact server-side recompute
        (`gapMs=g` when the truncated remainders matter), not as the display
        knob.

        Out-of-range values are REJECTED with a 400 naming the range, never
        clamped: below 1 (including an explicit `gapMs=0` — an omitted MCP
        integer arrives as 0, which must not look deliberate) and above 5000,
        beyond which a "burst" is two fights. The resolved value is echoed on
        the response.
      schema: { type: integer, format: int32, minimum: 1, maximum: 5000, default: 3000 }
      example: 3000
    TopKillContestedMs:
      name: contestedMs
      in: query
      description: >
        The window each row's `returnDamage` sums the victim's damage back
        over, in integer MILLISECONDS before the kill (default 4000, max
        30000). It sets the WINDOW only — `returnDamage` is a value, not a
        flag, so the threshold that calls a kill "contested" stays yours.
        Out-of-range values (below 1, including an explicit 0, or above 30000)
        are REJECTED with a 400 naming the range. The resolved value is echoed
        on the response.
      schema: { type: integer, format: int32, minimum: 1, maximum: 30000, default: 4000 }
      example: 4000
    TopKillLimit:
      name: limit
      in: query
      description: >
        Rows returned (default 20, max 200). A NEGATIVE value means uncapped.
        Out-of-range values are REJECTED with a 400, never silently clamped:
        an explicit `limit=0` (0 cannot mean "uncapped" — an omitted MCP
        integer arrives as 0, which would make a forgotten argument look
        deliberate) and any `limit` above the 200 maximum. The default is 20
        rather than `/top-windows`' 10 because the per-weapon `maxGapMs`
        narrowing this endpoint expects thins the list.
      schema: { type: integer, default: 20, maximum: 200 }
      example: 20
    MinDamage:
      name: minDamage
      in: query
      description: >
        Drop kill bursts below this much burst damage (default 0, keep all) —
        the same figure the rows are ranked by, in the resolved `dmg` family.
        A negative value is rejected with a 400: 0 already means "no filter",
        since a burst's damage is non-negative.
      schema: { type: integer, minimum: 0, default: 0 }
      example: 150
    MinMs:
      name: minMs
      in: query
      description: >
        Drop lives shorter than this many integer milliseconds (default 0, keep
        all). Useful for filtering out spawn-frag lives.
      schema: { type: integer, format: int32, minimum: 0, default: 0 }
      example: 1000
    RegionsMode:
      name: regions
      in: query
      description: |
        Region-list detail on /region-control.

        - `full` (default, backward-compatible) — the whole ControlRegion list
          including each region's polygon `points` (~6 KB, needed only to draw
          the map overlay).
        - `summary` — regions with `points` stripped (`name`, `locs`,
          `centroidX`/`centroidY` kept), for stats-only consumers.
        - `none` — the `regions` list is omitted from the response entirely.

        `bucketStates` and `stats` are identical across all three.
      schema:
        type: string
        enum: [full, summary, none]
    Dmg:
      name: dmg
      in: query
      description: |
        Damage family (schema v55).

        - `raw` — the unbound wire values in the v53 field layout, with v54
          fold semantics (given/taken include the telefrag/stomp folds on
          standard demos; the kill entries keep their fold fields and
          boundedMode survives).
        - `bounded` — KTX-scoreboard semantics (armor absorbed + health capped
          to remaining) materialized into the same field names; an EXPLICIT
          `dmg=bounded` is a 422 `bounded_unavailable` on `skipped:*` demos
          (midair / instagib / dmgfrags / the clan-arena family), whose
          server mode makes the reconstruction impossible.
        - `both` — the raw fields plus per-player `bounded` nests and a
          per-event `bounded`.

        Default (unset): `bounded`, for both `summary=true` and the full log —
        a defaulted bounded on a skipped:* demo falls back to `raw` rather
        than 422.
      schema:
        type: string
        enum: [raw, bounded, both]
    DmgFamily:
      name: dmg
      in: query
      description: |
        Damage family for the on-demand damage endpoints (`/top-windows`,
        `/lives`, `/top-kills`):

        - `raw` — the unbound wire values;
        - `bounded` — KTX-scoreboard semantics (armor absorbed + health capped
          to remaining).

        Default (unset): `bounded`, falling back to `raw` on a demo whose
        bounded reconstruction was skipped; an EXPLICIT `dmg=bounded` there is
        a 422 `bounded_unavailable` naming the demo's `boundedMode`.

        **On the interval endpoints it selects the family of the whole STATS
        BLOCK**, so it applies under EVERY `/top-windows` metric and not only
        the damage ones — a window picked by `metric=frags` still reports
        `damageGiven`, and that number has a family. Both rules above hold
        under every metric too: the skipped-demo 422 fires for an explicit
        `dmg=bounded` whatever the metric, and a defaulted one falls back to
        `raw` whatever the metric. On `/top-kills` it selects the family of
        both `damage` and `returnDamage`, so one response is one family there
        too — and since `damage` is the RANKING key, the two families can
        order the list differently. The resolved family comes back as the
        response's own `dmg` — read it rather than assuming the default took.

        **Two values, not three:** `both` is a response SHAPE — raw fields
        plus a parallel `bounded` nest, as `/damage` returns it — and these
        endpoints have no such shape. One interval stats block — one kill
        burst — carries one set of damage numbers, and a quantity that RANKS
        has to rank on a single family, so `both` is rejected with a 400 here
        for every metric and on every one of the three endpoints.
      schema:
        type: string
        enum: [raw, bounded]
    LivesSummary:
      name: summary
      in: query
      description: |
        Size control. `/lives` returns one row per spawn-to-death run and a
        whole 4on4 match is ~400 of them (~240 KB), of which ~40% is per-row
        collections. Note that `from`/`to` only SELECT rows here — each kept
        row still carries its whole attribution window — so they are not a
        size control on their own.

        **What `summary=true` keeps:** EVERY ROW and every scalar (the counts,
        the durations, `endReason`, `spawnLoc`/`deathLoc`,
        `killedBy`/`deathWeapon`, `mainWeapon`, `weaponsHeld`).

        **What it drops:** the per-row breakdown collections — `itemsTaken`,
        `locs`, `eventLocs`, `victims`, `byWeapon`, `damageByWeapon`.

        **One consequence to know:** `itemsTaken` is `null` on every summary
        row. In a summary response that null means "not requested" and says
        NOTHING about the demo — the envelope's `measured.items` remains the
        authority on whether an item timeline exists. Re-ask with
        `summary=false` (plus `players` / `from` / `to`) for the detail of the
        lives you care about.
      schema: { type: boolean, default: false }
    Reducers:
      name: reducers
      in: query
      description: >
        CSV of `field=reducerName` pairs overriding the per-field default
        reducer, e.g. `reducers=h=min,a=last`. Registry: first (default
        for most fields — value at window start), last, mean, min, max,
        dominant (most common value), held-any, majority, any. Unknown
        names 400.
      schema: { type: string }
    IncludeTeam:
      name: includeTeam
      in: query
      description: Include each player's team in the bucket envelope.
      schema: { type: boolean }
    WindowMs:
      name: windowMs
      in: query
      description: >
        Window size, integer MILLISECONDS. ⚠️ Defaults to 50 ms — on a
        20-minute match that is ~24,000 windows per field per player.
        Always pass a windowMs sized to your question. All time params are
        ms: windowMs, from, and to alike. Omit it for the default; an
        explicit windowMs=0 is rejected with 400 invalid_param.
      schema:
        type: integer
        default: 50
        minimum: 1
    MinDwellMs:
      name: minDwellMs
      in: query
      description: >
        Fold loc residences shorter than this many milliseconds into the
        preceding residence. The fold never crosses a gap, so a residence
        that follows a dead span (or an unresolved stretch) survives even
        when shorter than the threshold. Default 0 = no filter.
      schema:
        type: integer
        minimum: 0
    AirgibPreMs:
      name: preMs
      in: query
      description: >
        Pre-hit look-back in MILLISECONDS: every position sample in the
        window [hit-preMs, hit-40ms] must read at or above the airborne
        height threshold; the tick preceding the window decides when the
        window holds no sample near its start (old coarse-tick demos,
        recording holes), and a grounded reading beside the hit vetoes.
        Values at or below 40 collapse to a point check at the
        pre-impact boundary. Default 100. The gate exists because the KTX damage entry is stamped
        at (or one frame after) the physics frame in which the rocket's own
        knockback already moved the victim, so the sample nearest the damage
        time reports players who were STANDING at impact as hundreds of units
        airborne. `preMs=0` turns the pre-hit gate off (hit-time sample only,
        the pre-v73 detection); values above 1000 are rejected with 400
        `invalid_param`, since a look-back that long lands before the flight
        that made the hit an airgib at all. The response echoes the effective
        value in `preMs`.
      schema:
        type: integer
        default: 100
        minimum: 0
        maximum: 1000
    TimeRequired:
      name: time
      in: query
      required: true
      description: >
        The instant to sample, match-relative integer MILLISECONDS. A
        non-integer value 400s with code `invalid_param` and an `(integer
        milliseconds)` hint.
      schema: { type: integer, minimum: 0 }
    SearchPlayers:
      name: players
      in: query
      description: >
        CSV of player names to match (full-text search on the roster,
        AND'd across multiple names). Case-INsensitive: the hub matches via
        PostgREST full-text search, which lowercases both the query and the
        indexed names (`to_tsvector`), so `bps` and `BPS` match the same
        games. This differs from the per-demo endpoints' `players` filter,
        which is an exact, case-sensitive name match.
      style: form
      explode: false
      schema:
        type: array
        items: { type: string }
    SearchTeams:
      name: teams
      in: query
      description: >
        CSV of team names that must all appear in the game's team list
        (contains).
      style: form
      explode: false
      schema:
        type: array
        items: { type: string }
    SearchMap:
      name: map
      in: query
      description: Map name, exact match (e.g. `dm6`).
      schema: { type: string }
    SearchMode:
      name: mode
      in: query
      description: Game mode, exact match (e.g. `1on1`, `2on2`, `4on4`, `FFA`).
      schema: { type: string }
    SearchMatchtag:
      name: matchtag
      in: query
      description: >
        Tournament / event tag, case-insensitive substring (e.g. `qwsl`).
        Documented casing exception: the param name is the lowercase single
        word `matchtag` (mirroring the KTX serverinfo key), not camelCase like
        the other multi-word query params.
      schema: { type: string }
    SearchFrom:
      name: from
      in: query
      description: >
        Calendar date lower bound, inclusive. Strict `YYYY-MM-DD` (exactly
        10 characters, a real date); a malformed value is rejected with
        `400 invalid_param` at the API boundary (it no longer reaches the
        hub as a `502 hub_upstream`). NOTE these are calendar dates that
        select which games to list — unrelated to the per-demo endpoints'
        `from`/`to`, which are match-relative times bounding a window
        within one demo.
      schema: { type: string }
    SearchTo:
      name: to
      in: query
      description: >
        Calendar date upper bound, inclusive (the whole end day is
        included). Strict `YYYY-MM-DD` (exactly 10 characters, a real
        date); a malformed value is rejected with `400 invalid_param` at
        the API boundary (it no longer reaches the hub as a `502
        hub_upstream`). NOTE these are calendar dates that select which
        games to list — unrelated to the per-demo endpoints' `from`/`to`,
        which are match-relative times bounding a window within one demo.
      schema: { type: string }
    SearchLimit:
      name: limit
      in: query
      description: >
        Max rows per page. Omit for the default 20. An explicit `limit=0`
        is distinguishable from an absent one and is rejected with `400
        invalid_param` (omit the param for the default instead); a value
        above 1000 or a negative value is likewise rejected — no longer
        silently clamped. 1000 is the hub's own page ceiling (its
        PostgREST truncates any larger page), so a bigger single page is
        impossible upstream — page with `offset` against `total` to walk
        every match.
      schema:
        type: integer
        default: 20
        minimum: 1
        maximum: 1000
    SearchOffset:
      name: offset
      in: query
      description: >
        Pagination offset (number of rows to skip). A negative value is
        rejected with `400 invalid_param`.
      schema:
        type: integer
        minimum: 0
    SearchRoster:
      name: roster
      in: query
      description: >
        `1`/`true` returns the verbatim hub rows (per-player ping, color
        arrays, name_color, is_bot); default returns compact rows with each
        player projected to {name, team, frags}.
      schema: { type: boolean }
  headers:
    ETag:
      description: >
        Strong validator. Per-demo `"<sha>-v<schemaVersion>"`; per-artifact
        `"<sha>-<name>@v<n>"`; binary-static `"<kind>-v<n>"`; map-static
        `"ents-<map>-v<n>"` / `"geo-<map>-<size>"`; spec/docs content hash.
      schema: { type: string }
    XCache:
      description: Which cache tier served the demo — HIT, WARM, or MISS.
      schema:
        type: string
        enum: [HIT, WARM, MISS]
    XSchemaVersion:
      description: result.CurrentSchemaVersion of the serving binary.
      schema: { type: string }
    XRequestId:
      description: >
        Per-request id echoed in the server access log; quote it when
        reporting a problem.
      schema: { type: string }
  responses:
    NotModified:
      description: >
        Not Modified — the If-None-Match validator matched; the cached
        representation is current.
    BadRequest:
      description: >
        Malformed `{id}` (`invalid_demo_id`), malformed or rejected query
        parameter (`invalid_param`), or required parameter absent
        (`missing_param`).
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorEnvelope" }
    Unauthorized:
      description: >
        Auth mode only — missing / invalid / revoked API key
        (`unauthorized`). Carries `WWW-Authenticate: Bearer`. The body is
        deliberately generic.
      headers:
        WWW-Authenticate:
          schema: { type: string }
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorEnvelope" }
    DemoNotFound:
      description: The hub has no row for this gameId (`demo_not_found`).
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorEnvelope" }
    MapUnavailable:
      description: >
        No entity corpus / geometry for this map (`map_unavailable`).
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorEnvelope" }
    Unavailable:
      description: >
        The demo structurally lacks the signal this section needs
        (`<section>_unavailable` — the operation description names the
        specific code). Expected for some demos; treat as "panel
        unavailable", not failure.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorEnvelope" }
    RateLimited:
      description: >
        Auth mode only — per-key rate limit exceeded (`rate_limited`).
        Carries `Retry-After` (seconds); wait and retry.
      headers:
        Retry-After:
          schema: { type: string }
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorEnvelope" }
    HubUpstream:
      description: Network error / 5xx from the hub (`hub_upstream`).
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorEnvelope" }
    Internal:
      description: >
        Unexpected server error (`internal`). The body is generic — a
        fixed message plus the request id; the real error is logged
        server-side keyed by X-Request-Id.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorEnvelope" }
  schemas:
    ErrorEnvelope:
      type: object
      description: Stable error envelope for every non-2xx JSON response.
      required: [error]
      additionalProperties: false
      properties:
        error:
          description: "Error object (code + message)."
          type: object
          required: [code, message]
          additionalProperties: false
          properties:
            code: { $ref: "#/components/schemas/ErrorCode" }
            message:
              type: string
              description: >
                Human-readable and user-actionable for 4xx; generic (request
                id only) for 5xx.
    ErrorCode:
      type: string
      description: Machine-readable error class.
      enum:
        # error-code-enum:begin — pinned to the writeError/writeUnavailable call sites by TestOpenAPIErrorCodesCurrent
        - aim_unavailable
        - airgibs_unavailable
        - artifact_unknown
        - bounded_unavailable
        - damage_unavailable
        - demo_not_found
        - demo_too_large
        - demoinfo_unavailable
        - frags_unavailable
        - hub_upstream
        - internal
        - invalid_demo_id
        - invalid_gzip
        - invalid_param
        - lives_unavailable
        - locgraph_unavailable
        - los_unavailable
        - map_unavailable
        - metadata_unavailable
        - missing_param
        - opening_unavailable
        - playerstats_unavailable
        - rate_limited
        - region_control_unavailable
        - shots_unavailable
        - top_kills_unavailable
        - top_windows_unavailable
        - unauthorized
        - unknown_param
        - unparseable_demo
        - upload_quota_exceeded
        - uploads_disabled
        # error-code-enum:end
    TimeUnit:
      type: string
      description: >
        The unit of a governed response's time values (schema v57): always
        `ms` (int32 milliseconds). In the pure-ms model there is only one
        unit, so this echo is a constant `"ms"` self-description present so
        the body is self-describing. This shared component is referenced by
        the schemas reused across the raw /artifacts envelope
        (Frags/Damage/Shots/ItemsTimelines); the value is invariant.
      enum: [ms]
    Health:
      type: object
      required: [ok, schemaVersion]
      additionalProperties: false
      properties:
        ok: { type: boolean, description: "Service healthy." }
        schemaVersion: { type: integer, description: "Result schema version this response was produced under." }
    Version:
      type: object
      required: [hash, tag, buildDate]
      additionalProperties: false
      properties:
        hash: { type: string, description: Git commit hash of the build. }
        tag: { type: string, description: Git tag, if any. }
        buildDate: { type: string, description: "Build date." }
    LoadResult:
      type: object
      required: [demoId, sha256, fromCache, schemaVersion]
      additionalProperties: false
      properties:
        demoId:
          type: string
          description: Canonical `sha:HEX` id — use it for subsequent calls.
        sha256: { type: string, description: "Demo SHA-256." }
        fromCache: { type: boolean, description: "The result was served from cache." }
        schemaVersion: { type: integer, description: "Result schema version this response was produced under." }
    ArtifactsManifest:
      type: object
      required: [schemaVersion, artifacts]
      additionalProperties: false
      properties:
        schemaVersion: { type: integer, description: "Result schema version this response was produced under." }
        artifacts:
          description: "DAG node manifest entries."
          type: array
          items: { $ref: "#/components/schemas/ArtifactMeta" }
    ArtifactMeta:
      type: object
      description: One analyzer DAG node, as listed in ARTIFACTS.md.
      required: [name, mutates, lazy, cost, servable]
      additionalProperties: false
      properties:
        name: { type: string, description: DAG node name. }
        requires:
          description: "Upstream nodes this node depends on."
          type: [array, "null"]
          items: { type: string }
        provides:
          description: "Result sections this node provides."
          type: [array, "null"]
          items: { type: string }
        mutates: { type: boolean, description: "Result sections this node mutates." }
        lazy: { type: boolean, description: "Materialised on demand (not in the base parse)." }
        cost:
          description: "Materialisation cost class."
          type: string
          enum: [light, heavy]
        resultKey:
          type: string
          description: >
            Top-level Result JSON key (the generic artifact endpoint's body
            key). Empty = internal / no direct section.
        servable: { type: boolean, description: "Exposed via GET /v1/demos/{id}/artifacts/{name}." }
        description: { type: string, description: "Human-readable description." }
    GamesSearchResult:
      type: object
      description: >
        One page of hub game-catalog matches (GET /v1/games/search). The
        game rows are passed through from the hub verbatim (roster=true) or
        with each player compacted to {name, team, frags} (the default), so
        the shape is left open here.
      required: [limit, offset, count, games]
      additionalProperties: false
      properties:
        limit:
          type: integer
          description: Page size actually applied (the request's limit, 1..1000).
        offset:
          type: integer
          description: Pagination offset echoed from the request.
        count:
          type: integer
          description: Rows returned in THIS page.
        total:
          type: integer
          description: >
            Total matching rows across all pages, present only when the hub
            reports it. Absent = unknown (do not assume count == total).
        games:
          type: array
          description: >
            Matching games, newest first. Each row carries id, timestamp,
            mode, matchtag, map, teams, players, demo_sha256,
            demo_source_url, server_hostname; `players` is compact
            ({name, team, frags}) unless roster=true.
          items:
            type: object
            additionalProperties: true
            properties:
              server_hostname:
                type: string
                description: >
                  The QuakeWorld server the game was played on (hub
                  passthrough, snake_case verbatim). Present in both compact
                  and roster rows.
    # --- Per-endpoint response schemas (full field-level).
    # Bootstrapped from the Go structs (result/, view/, mvd-api Overview)
    # via reflection and hand-curated; every schema is validated against
    # real golden-corpus responses by openapi_validate_test.go, so shape
    # drift fails tests. Deep field semantics: /docs/result-schema (the
    # served RESULT_SCHEMA.md; do not add repo-relative file refs in
    # rendered descriptions).
    Overview:
      additionalProperties: false
      properties:
        timeUnit: { type: string, enum: [ms], description: "Constant \"ms\" self-description echo — in the v57 pure-ms model every time value is int32 milliseconds." }
        duration:
          description: "Match duration, int32 ms."
          maximum: 2.147483647e+09
          minimum: -2.147483648e+09
          type: integer
        errors:
          description: "Non-fatal analyzer errors encountered while producing this result."
          items:
            type: string
          type:
            - "null"
            - array
        noMatch:
          description: >
            Present ONLY when this demo yielded no analyzable match (schema
            v74): `players` is empty, `available.*` is false almost
            throughout, and this block says why. It is what separates "this
            recording holds no match" from "the parse failed" — the latter is
            `errors[]`, and only the `demoUnreadable` reason means both. 2.0%
            of the QuakeWorld archive lands here. Omitted on every demo with
            players.
          oneOf:
            - $ref: "#/components/schemas/NoMatch"
            - type: "null"
        parseWarnings:
          description: >
            READER-level census (schema v72): wire data the MVD decoder could not
            read at all — unknown svc_* commands, unknown temp-entity or hidden-message
            types, payloads that failed to parse. Distinct from `errors[]`, which
            reports analyzer failures over events that DID decode. Omitted on a clean
            parse, which is the normal case; a present block means this demo hit a
            protocol gap and the views downstream of it may be thin.
          type: object
          additionalProperties: false
          properties:
            total:
              description: "Exact number of parse warnings raised. Never capped."
              type: integer
            byType:
              description: >
                Exact per-category counts. Categories are a small fixed vocabulary:
                parse_error, unknown_svc, unknown_te, unknown_hidden.
              type: object
              additionalProperties:
                description: "Warnings raised in this category."
                type: integer
            groups:
              description: >
                Capped sample table of distinct (type, message) rows, loudest first.
                The counts above stay exact even when a row is dropped from here.
                Rows are RETAINED in first-encounter order, so on a badly broken demo
                this is a sample of the distinct messages rather than the top-k.
              type: array
              items:
                description: "One distinct warning message with its count and first occurrence."
                type: object
                additionalProperties: false
                properties:
                  type:
                    description: "Warning category (parse_error | unknown_svc | unknown_te | unknown_hidden)."
                    type: string
                  message:
                    description: "The reader's message, e.g. the svc name plus the bytes it abandoned."
                    type: string
                  count:
                    description: "How often this exact message fired."
                    type: integer
                  firstDemoTimeMs:
                    description: >
                      RAW DEMO time of the first occurrence (the wire clock), NOT the
                      match-relative base every other ms field uses — the reader has no
                      match clock and a warning can fire before the match starts.
                    type: integer
            droppedWarnings:
              description: >
                Warnings beyond the 64-group retention cap, i.e. missing from `groups`.
                An OCCURRENCE count, never a count of distinct messages — past the cap
                the reader stops retaining keys, which is why the cap exists.
                Normally absent.
              type: integer
        filePath:
          description: "Source demo path."
          type: string
        gameDir:
          description: "Game directory (mod)."
          type: string
        available:
          description: >
            Per-demo CAPABILITY MANIFEST (schema v70): for each detailed view, whether THIS demo
            on THIS deployment can answer it. Call /overview first and branch on this instead of
            spending a request to discover a 422.

            Each eager flag mirrors the predicate behind that view's 422, so a false here is
            exactly the 422 you would have received. A true flag means the section EXISTS, not
            that it is non-empty — a demo where nobody fired still reports shots: true with an
            empty shot list.

            height / liquid / los are the ones you CANNOT infer from anything else: they turn on
            which BSPs the server was provisioned with, not on what the demo recorded, so the
            same demo can answer differently on two deployments. There is deliberately no
            separate `pvs` flag — PVS and LOS come off one pass behind one BSP gate (PVS is a
            superset of LOS by construction), so two flags could never disagree.
          additionalProperties: false
          properties:
            demoInfo: { description: "KTX demoinfo block (non-KTX / pre-match-abort demos lack it).", type: boolean }
            metadata: { description: "Server cvars + KTX match settings (needs fullserverinfo / countdown centerprint).", type: boolean }
            frags: { description: "Obituary-derived frag log.", type: boolean }
            damage: { description: "Per-hit damage (the wire KTX mvdhidden_dmgdone stream, or the reconstructed section on pre-instrumentation demos — check /damage `source`).", type: boolean }
            shots: { description: "Weapon-fire stream.", type: boolean }
            aim: { description: "Aim analysis (needs shots + position/view streams).", type: boolean }
            locGraph: { description: "Loc adjacency graph (needs a position track).", type: boolean }
            opening: { description: "Opening analysis (needs a detected match start).", type: boolean }
            playerStats: { description: "Canonical per-player statistics (needs player streams; a missing KTX block is NOT a reason for false).", type: boolean }
            regionControl: { description: "Region-control output with at least one region. Was the top-level hasRegionControl before v70.", type: boolean }
            height: { description: "BSP-DERIVED: the floor-height column (pos.h) was COMPUTED — not that it holds a non-zero value. Depends on server-side map provisioning (the collision hull). Where there is no floor beneath a sample the column carries the NoFloor sentinel, which is still a reading.", type: boolean }
            liquid: { description: "BSP-DERIVED: the liquid column (pos.lq) was COMPUTED — not that any sample is wet. Depends on server-side map provisioning (the vis BSP), a SEPARATE gate from height. A map with no water yields a full-length all-zero column, and this flag stays TRUE: that is a measured `dry`, and is exactly what distinguishes it from an unprovisioned server, where the flag is false and the question is unanswerable.", type: boolean }
            los: { description: "BSP-DERIVED: the line-of-sight pass can run, covering BOTH the los and pvs interval sets. Unlike the others this is a PREDICTION (streams, 2+ players, a provisioned BSP) because the pass is heavy and lazy; a provisioned BSP whose visibility data will not load still 422s.", type: boolean }
          required: [demoInfo, metadata, frags, damage, shots, aim, locGraph, opening, playerStats, regionControl, height, liquid, los]
          type: object
        locCount:
          description: "Number of named locs available for the map."
          type: integer
        map:
          description: "Canonical map shortname (dm2, dm3, …) — the same value searchGames rows and /metadata serverinfo carry, so a caller can join on it. Resolved from demoinfo with a serverinfo fallback; on a degraded demo that resolves no shortname this falls back to the BSP title."
          type: string
        mapTitle:
          description: "The BSP's pretty level title (e.g. \"Claustrophobopolis\" on dm2). Omitted when identical to `map` — the common case where the map has no distinct title."
          type: string
        matchEnd:
          description: "Match end, int32 ms."
          maximum: 2.147483647e+09
          minimum: -2.147483648e+09
          type: integer
        matchStart:
          description: "Match start, int32 ms."
          maximum: 2.147483647e+09
          minimum: -2.147483648e+09
          type: integer
        matchtag:
          description: "Tournament / event tag."
          type: string
        mode:
          description: >
            Match mode. Two vocabularies reach this field: the KTX countdown
            table's display spelling ("Duel", "Team", "LGC") where the demo had
            a countdown, else (schema v72) KTX's own mode name from the
            demoinfo block or the `//finalscores` stuffcmd ("duel", "team",
            "FFA", "Clan Arena"). Compare case-insensitively, and read
            `/metadata` for the exact source.
          type: string
        playerUserIDs:
          description: "Player name → UserID map (Hub viewer links). Per connection SESSION, not per wire slot: for a player who reconnected this is the last session of theirs that had play — normally the id live at the end of the demo, though the ranking is by last play evidence, so an exact tie resolves to the lower slot rather than to the surviving connection (schema v66)."
          additionalProperties:
            type: integer
          type: object
        players:
          description: "Final per-player scoreboard summary."
          items:
            additionalProperties: false
            properties:
              deaths:
                description: "Deaths (all causes)."
                type: integer
              frags:
                description: "Net QW frag score."
                type: integer
              kills:
                description: "Enemy kills (from the corrected frag log)."
                type: integer
              name:
                description: "Name / display label."
                type: string
              suicides:
                description: "Suicide deaths."
                type: integer
              team:
                description: "Team name."
                type: string
            required:
              - name
              - frags
              - kills
              - deaths
              - suicides
            type: object
          type:
            - "null"
            - array
        schemaVersion:
          description: "Result schema version this response was produced under."
          type: integer
        teams:
          description: "Per-team frag totals."
          items:
            additionalProperties: false
            properties:
              frags:
                description: "Net QW frag score."
                type: integer
              name:
                description: "Name / display label."
                type: string
            required:
              - name
              - frags
            type: object
          type:
            - "null"
            - array
        timing:
          description: "Wall-clock anchor + pause data mapping match game-time to real time."
          additionalProperties: false
          properties:
            demoOffset:
              description: "Demo-start offset from match start, int32 ms."
              maximum: 2.147483647e+09
              minimum: -2.147483648e+09
              type: integer
            demoStartAccuracyMs:
              description: "Uncertainty of the demo-start wall-clock anchor, int32 ms."
              maximum: 2.147483647e+09
              minimum: -2.147483648e+09
              type: integer
            demoStartUnixMs:
              description: "Wall-clock time of demo start, Unix ms."
              type: integer
            demoStartSource:
              description: >-
                Where the demo-start anchor came from: `mvdhidden` / `epoch`
                (server clock) or `matchdate` / `matchkey` / `ktxstats` (a wire
                date marker back-shifted by demoOffset). Schema v72.
              type: string
            matchEndUnixMs:
              description: >-
                Wall-clock time of match end, Unix ms: the ktxstats `date` (KTX
                writes that block at intermission), else the `//finalscores`
                stamp once its year has been completed from the anchoring
                marker — which is the common case on the pre-ktxstats half of
                the archive, and is minute-resolution and only as good as the
                marker its year came from. Absent when the demo carries
                neither. Schema v72.
              type: integer
            matchStartAccuracyMs:
              description: >-
                Uncertainty of matchStartUnixMs, int32 ms: 1/1000 for a server
                clock or a zoned marker, 3600000 for a zone name of unstated DST
                status, 50400000 when the marker named no zone and UTC was
                assumed (real offsets span UTC-12..UTC+14). Schema v72.
              maximum: 2.147483647e+09
              minimum: -2.147483648e+09
              type: integer
            matchStartConfidence:
              description: >-
                Grade of matchStartUnixMs: `exact` (zone resolved, no check
                failed), `unverified` (nothing contradicts it but something is
                unpinned), `contradicted` (a check it provably fails — the
                anchor is still reported). Schema v72.
              enum:
                - exact
                - unverified
                - contradicted
              type: string
            matchStartNote:
              description: "Names the checks behind a non-`exact` matchStartConfidence. Schema v72."
              type: string
            matchStartSource:
              description: >-
                Which marker matchStartUnixMs was derived from: `mvdhidden`,
                `epoch`, `matchdate`, `matchkey`, `ktxstats`. Schema v72.
              type: string
            matchStartUnixMs:
              description: >-
                Wall-clock time of MATCH start (the instant g=0 names), Unix ms.
                Present on ~95% of demos against ~25% for demoStartUnixMs, since
                the wire date markers describe the match. Schema v72.
              type: integer
            pauses:
              description: "Game pauses as flat game-time→wall-clock segments."
              items:
                additionalProperties: false
                properties:
                  atMs:
                    description: "Match-relative game time the pause sits at, int32 ms."
                    maximum: 2.147483647e+09
                    minimum: -2.147483648e+09
                    type: integer
                  durationMs:
                    description: "Real wall-clock ms the pause lasted."
                    maximum: 2.147483647e+09
                    minimum: -2.147483648e+09
                    type: integer
                required:
                  - atMs
                  - durationMs
                type: object
              type:
                - "null"
                - array
          type:
            - "null"
            - object
      required:
        - schemaVersion
        - timeUnit
        - duration
        - matchStart
        - matchEnd
        - players
        - locCount
        - available
      type: object
    NoMatch:
      description: >
        Why a demo yielded no analyzable match (schema v74). Streams are the
        spine of this pipeline and are built only inside a detected match
        window, so a recording whose match start was never announced produces
        no player streams and, with them, no damage / playerStats / locGraph /
        buckets. This block is present exactly when the `streams` block is
        absent — one predicate, not two: `streams` is written only when it
        holds at least one player stream, so a result carrying both `streams`
        and `noMatch` is not constructible. It is a statement about the DEMO,
        not a pipeline failure, which is why it is not an `errors[]` entry.
        Check it BEFORE `errors` and `parseWarnings`: it decides whether
        those describe a partial match or nothing at all, and
        `demoUnreadable` is the one reason that means both.
      additionalProperties: false
      type: object
      required: [reason, detail]
      properties:
        reason:
          description: >
            Why there is no match. `midMatchRecording`: the serverinfo
            `status` key already named a running game at demo open, so the
            match started before the first frame. `matchStartUnannounced`:
            `status` became a running game DURING the recording but no
            match-start broadcast was recognised — the server ran a match and
            announced it in a form (or on a mod) outside the known set.
            `noMatchDeclared`: no match declaration this pipeline can see —
            `status` never named a running game — yet the frag log parsed
            kills. Usually unmanaged play (TeamFortress, CTF, custom
            gamedirs, free play), but it is an ABSENCE of evidence: 168 of
            the 170 such demos send no `status` key at all, so a managed
            match on a mod whose declarations this pipeline cannot read
            lands here too. `noPlayRecorded`: the same absent declaration
            and no kills in the parsed frag log; usually an idle or aborted
            server. `demoUnreadable`: the event stream aborted, so nothing
            can be concluded — see `errors[]` for the reader's reason.
          type: string
          enum:
            - midMatchRecording
            - matchStartUnannounced
            - noMatchDeclared
            - noPlayRecorded
            - demoUnreadable
        detail:
          description: >
            The same verdict as one human-readable sentence, naming the
            evidence (the verbatim status string, the gamedir, the kill
            count). Show this beside the reason code. UNSTABLE, DISPLAY
            ONLY — do not parse it or key logic off it: the wording changes
            without a schema bump, and every fact it states appears as a
            structured field beside it (`reason`, `statusAtOpen`,
            `statusRunningSeen`, `gameDir`, `kills`).
          type: string
        statusAtOpen:
          description: >
            The serverinfo `status` value as it stood in the OPENING
            fullserverinfo dump, verbatim — the wire's own statement of the
            game state at the first frame. Distinct from
            `metadata.serverInfo.status`, which is last-write-wins and so
            names the state at demo END. A running-game reading is one of
            exactly two clock formats, KTX's "13 min left" and a CTF mod's
            "14:59 left"; every other observed value is idle or mod-specific
            vocabulary ("Standby", "Countdown", "Forcestart", "Normal",
            "Game Ended", "Round 1/15"). Omitted when the opening dump
            carried no `status` key (pre-KTX servers and mods that never set
            it — 21% of these demos — and the 3 in 1 032 where the key first
            appears in a later update, which `statusRunningSeen` still
            reflects).
          type: string
        statusRunningSeen:
          description: >
            True when `status` named a running game at any point in the
            recording, at open or in a later update. It is what separates
            `matchStartUnannounced` (the server DID start a match) from
            `noMatchDeclared` / `noPlayRecorded` (it never did). Omitted when
            false.
          type: boolean
        gameDir:
          description: >
            The serverinfo `*gamedir` key: the mod the server ran. "qw" is
            stock deathmatch; anything else ("fortress", "ctf", "jteams",
            "runes", …) has rules this pipeline does not model. Reported as
            EVIDENCE, never as a reason of its own — a foreign gamedir can
            still run a managed match, and a "qw" server can still record
            nothing. Omitted when the server named none.
          type: string
        kills:
          description: >
            Length of the frag log — the kills whose obituaries THIS
            PIPELINE RECOGNISES, so a floor on how much play the window
            held, not a census: the obituary table is id1/KTX vocabulary and
            a foreign mod's own death messages are invisible to it. Zero
            means "the frag log parsed nothing", not "nobody died".
            Non-zero with any reason except `noPlayRecorded`, which is
            defined by it being zero. Omitted when zero.
          type: integer
        dateMarkers:
          description: >
            Every date stamp the wire carried, verbatim and in the order
            seen — the same list `streams.global.dateMarkers` carries, given
            a home here because there is no streams block on this result.
            Published RAW and deliberately without the graded
            `matchStartUnixMs` anchor that accompanies them on
            `streams.global`: that anchor is a projection through the match
            window (demo offset for the start prints, match length for the
            end stamps), and this result has no match window. Read `kind` to
            see which instant each stamp names.
          type: array
          items:
            description: One date stamp observed on the wire.
            type: object
            additionalProperties: false
            required: [source, kind, unixMs]
            properties:
              source:
                description: >
                  The wire marker: "matchdate" (KTX's match-start bprint),
                  "matchkey" (the kmod/KTeam-era bprint), "ktxstats" (the
                  `date` field of the KTX demoinfo block) or "finalscores"
                  (the `//finalscores` end-of-match stuffcmd).
                type: string
              kind:
                description: 'What instant the stamp names: "matchStart" or "matchEnd".'
                type: string
              unixMs:
                description: >
                  The parsed instant (Unix epoch ms), timezone applied. 0 on
                  a "finalscores" marker whose year could not be completed.
                type: integer
              atMs:
                description: >
                  The DEMO-clock ms the marker was printed at, for the
                  print-borne markers. Absent for the ktxstats block and the
                  `//finalscores` stuffcmd, which ride no print.
                type: integer
              yearFrom:
                description: >
                  Names the anchor source whose year completed this stamp.
                  Set only on "finalscores", whose layout carries no year.
                type: string
              tz:
                description: >
                  The zone token exactly as printed ("CET", "+0200"). Absent
                  when the stamp carried none.
                type: string
              assumedUtc:
                description: >
                  Set when the zone was missing or unknown, so the instant
                  was read as UTC.
                type: boolean
              raw:
                description: The stamp text as it appeared after the marker prefix.
                type: string
    Metadata:
      additionalProperties: false
      properties:
        countdownText:
          description: "Raw color-stripped countdown centerprint text."
          type: string
        matchSettings:
          description: "Parsed KTX countdown-centerprint match settings."
          additionalProperties: false
          properties:
            airstep:
              description: "Airstep mode."
              type: boolean
            antilag:
              description: "Antilag setting (0/1/2)."
              type: integer
            deathmatch:
              description: "serverinfo deathmatch mode (0..5)."
              type: integer
            dmgfrags:
              description: "Damage-as-frags mode."
              type: boolean
            fraglimit:
              description: "Frag limit."
              type: integer
            instagib:
              description: "Instagib mode."
              type: boolean
            matchtag:
              description: "Tournament / event tag."
              type: string
            midair:
              description: "Midair mode."
              type: boolean
            mode:
              description: "Mode."
              type: string
            noItems:
              description: "No-items mode."
              type: boolean
            noweapon:
              description: "Disabled weapons (e.g. \"gl axe\")."
              type: string
            overtime:
              description: "Overtime rule (\"5\" minutes or \"sd\")."
              type: string
            powerups:
              description: "Powerups setting (\"on\"/\"off\"/\"QPRS\")."
              type: string
            socdv2:
              description: "SOCD v2 policy (\"stats\"/\"warn\"/\"block\")."
              type: string
            spawnK:
              description: "Numeric k_spw value (0..4)."
              type:
                - "null"
                - integer
            spawnmodel:
              description: "Respawn model short name (QW/KTS/KT/KTX/KT2)."
              type: string
            teamplay:
              description: "QW teamplay setting."
              type: integer
            timelimit:
              description: "Time limit, minutes."
              type: integer
            vwep:
              description: "Visible-weapons mode."
              type: boolean
            yawnmode:
              description: "Yawnmode (SSG) mode."
              type: boolean
          type:
            - "null"
            - object
        finalScores:
          description: >
            KTX's `//finalscores` end-of-match scoreline, verbatim (schema v72).
            Present on 64% of the archive against demoinfo's 46%, so on the
            pre-ktxstats half this is the only place the SERVER states a final
            result. `date` is strftime "%b %d, %H:%M" with no year, no seconds
            and no timezone — read /overview's `timing` block
            (`matchEndUnixMs`, plus `matchStartUnixMs` and its confidence
            grade) for the resolved instant; this API does not serve the raw
            marker list. `mode` is KTX's lastscores name ("duel", "team",
            "FFA", "Clan Arena", ...), a different spelling from the demoinfo
            `mode`; neither is rewritten. Scores can be negative (suicides).
          additionalProperties: false
          properties:
            date:
              description: "Server-local match-end stamp, \"%b %d, %H:%M\" — no year."
              type: string
            mode:
              description: "KTX lastscores mode name (\"duel\", \"team\", \"FFA\", \"Clan Arena\", ...)."
              type: string
            map:
              description: "Canonical short map name."
              type: string
            team1:
              description: "First side's team name (the player's own name on a duel)."
              type: string
            score1:
              description: "First side's final score; can be negative."
              type: integer
            team2:
              description: "Second side's team name."
              type: string
            score2:
              description: "Second side's final score; can be negative."
              type: integer
          type:
            - "null"
            - object
        serverInfo:
          description: "Union of serverinfo key/value pairs (fullserverinfo + later updates)."
          additionalProperties:
            type: string
          type: object
      type: object
    DemoInfo:
      additionalProperties: false
      properties:
        date:
          description: "Match date (KTX)."
          type: string
        demo:
          description: "Demo filename (KTX)."
          type: string
        duration:
          description: "Match duration, KTX-native units."
          type: integer
        fraglimit:
          description: "Frag limit."
          type: integer
        hostname:
          description: "Server hostname."
          type: string
        ip:
          description: "Server IP."
          type: string
        map:
          description: "Map name (KTX)."
          type: string
        mode:
          description: "Match mode (KTX)."
          type: string
        players:
          description: "Per-player KTX stat blocks."
          items:
            additionalProperties: false
            properties:
              bot:
                description: "Bot block (present only for frogbot slots)."
                additionalProperties: false
                properties:
                  customised:
                    description: "Bot settings were customised."
                    type: boolean
                  skill:
                    description: "Bot skill level."
                    type: integer
                required:
                  - skill
                  - customised
                type:
                  - "null"
                  - object
              bottomColor:
                description: "Player pants colour index."
                type: integer
              control:
                description: >
                  KTX map-control time, seconds. Written unconditionally by
                  KTX builds that record it, so a `0` here is a measured
                  reading (the player never held control), not a gap; the
                  key is absent only on builds that never wrote it.
                type: number
              dmg:
                description: "Damage stat block (KTX)."
                additionalProperties: false
                properties:
                  enemy-weapons:
                    description: "Enemy-weapon damage given (KTX dmg.enemy-weapons)."
                    type: integer
                  given:
                    description: "Damage given (KTX dmg.given)."
                    type: integer
                  self:
                    description: "Self damage / self kills (KTX)."
                    type: integer
                  taken:
                    description: "Damage taken (KTX dmg.taken, enemy-only)."
                    type: integer
                  taken-to-die:
                    description: "Damage taken to die (KTX dmg.taken-to-die)."
                    type: integer
                  team:
                    description: "Team name."
                    type: integer
                  team-weapons:
                    description: "Team-weapon damage given (KTX dmg.team-weapons)."
                    type: integer
                required:
                  - taken
                  - given
                type:
                  - "null"
                  - object
              handicap:
                description: "Player handicap setting."
                type: integer
              items:
                description: "Per-item stat blocks (KTX), keyed by item name."
                additionalProperties:
                  additionalProperties: false
                  properties:
                    time:
                      description: "Item hold time (KTX-native units)."
                      type: integer
                    took:
                      description: "Times this item was taken (KTX)."
                      type: integer
                  type:
                    - "null"
                    - object
                type: object
              login:
                description: "Authenticated login handle."
                type: string
              name:
                description: "Name / display label."
                type: string
              ping:
                description: "Player ping, ms."
                type: integer
              speed:
                description: "Speed stats (KTX units/sec)."
                additionalProperties: false
                properties:
                  avg:
                    description: "Average."
                    type: number
                  max:
                    description: "Maximum."
                    type: number
                type:
                  - "null"
                  - object
              spree:
                description: "Spree stats (KTX)."
                additionalProperties: false
                properties:
                  max:
                    description: "Maximum."
                    type: integer
                  quad:
                    description: "Quad-conditioned metric."
                    type: integer
                type:
                  - "null"
                  - object
              stats:
                description: "Frag/death stat block (KTX)."
                additionalProperties: false
                properties:
                  deaths:
                    description: "Deaths (KTX stat)."
                    type: integer
                  frags:
                    description: "Frags (KTX stat)."
                    type: integer
                  kills:
                    description: "Kills (KTX stat)."
                    type: integer
                  spawn-frags:
                    description: "Spawn frags (KTX stat)."
                    type: integer
                  suicides:
                    description: "Suicides (KTX stat)."
                    type: integer
                  tk:
                    description: "Team kills (KTX stat)."
                    type: integer
                required:
                  - frags
                  - deaths
                type:
                  - "null"
                  - object
              team:
                description: "Team name."
                type: string
              topColor:
                description: "Player shirt colour index."
                type: integer
              weapons:
                description: "Per-weapon stat blocks (KTX), keyed by weapon name."
                additionalProperties:
                  additionalProperties: false
                  properties:
                    acc:
                      description: "Accuracy block (KTX authoritative)."
                      additionalProperties: false
                      properties:
                        attacks:
                          description: "Attack count in KTX weapon units (pellets for SG/SSG)."
                          type: integer
                        hits:
                          description: "Hits (KTX acc)."
                          type: integer
                        real:
                          description: "Real hits (not splash) — KTX acc."
                          type: integer
                        virtual:
                          description: "Virtual hits (splash) — KTX acc."
                          type: integer
                      required:
                        - attacks
                        - hits
                      type:
                        - "null"
                        - object
                    damage:
                      description: "Raw/unbound damage amount, including overkill."
                      additionalProperties: false
                      properties:
                        enemy:
                          description: "Enemy-weapon damage (KTX dmg.enemy-weapons)."
                          type: integer
                        team:
                          description: "Team name."
                          type: integer
                      type:
                        - "null"
                        - object
                    deaths:
                      description: "Deaths (KTX stat)."
                      type: integer
                    kills:
                      description: "Kills (KTX stat)."
                      additionalProperties: false
                      properties:
                        enemy:
                          description: "Enemy-weapon damage (KTX dmg.enemy-weapons)."
                          type: integer
                        self:
                          description: "Self damage / self kills (KTX)."
                          type: integer
                        team:
                          description: "Team name."
                          type: integer
                        total:
                          description: "Total kills (KTX)."
                          type: integer
                      type:
                        - "null"
                        - object
                    pickups:
                      description: "Pickup stat block (KTX)."
                      additionalProperties: false
                      properties:
                        dropped:
                          description: "Backpacks dropped (KTX)."
                          type: integer
                        spawn-taken:
                          description: "Spawn-item takes (KTX pickup stat)."
                          type: integer
                        spawn-total-taken:
                          description: "Spawn-item total takes (KTX pickup stat)."
                          type: integer
                        taken:
                          description: "Damage taken (KTX dmg.taken, enemy-only)."
                          type: integer
                        total-taken:
                          description: "Total item takes (KTX pickup stat)."
                          type: integer
                      type:
                        - "null"
                        - object
                  type:
                    - "null"
                    - object
                type: object
              xferLG:
                description: "LG transfers (KTX stat)."
                type: integer
              xferRL:
                description: "RL transfers (KTX stat)."
                type: integer
            required:
              - name
              - team
            type: object
          type:
            - "null"
            - array
        port:
          description: "Server port."
          type: integer
        rawJson:
          description: "Raw KTX JSON (kept only for debugging failed parses)."
          type: string
        teams:
          description: "Team names."
          items:
            type: string
          type:
            - "null"
            - array
        timelimit:
          description: "Time limit, minutes."
          type: integer
        version:
          description: "Version."
          type: integer
      type: object
    Frags:
      additionalProperties: false
      properties:
        timeUnit: { $ref: "#/components/schemas/TimeUnit" }
        byPlayer:
          description: "Per-player breakdown, keyed by player name."
          additionalProperties:
            additionalProperties: false
            properties:
              byWeapon:
                description: "Total frags by weapon, keyed by weapon name."
                additionalProperties:
                  type: integer
                type:
                  - object
                  - "null"
              deaths:
                description: "Deaths (all causes)."
                type: integer
              kills:
                description: "Enemy kills (from the corrected frag log)."
                type: integer
              teamkills:
                description: "Teammates this player killed (KTX \"tk\")."
                type: integer
            required:
              - kills
              - deaths
              - byWeapon
            type:
              - "null"
              - object
          type:
            - object
            - "null"
        byWeapon:
          description: "Total frags by weapon, keyed by weapon name."
          additionalProperties:
            type: integer
          type:
            - object
            - "null"
        frags:
          description: "Chronological frag-event log."
          items:
            additionalProperties: false
            properties:
              isSuicide:
                description: "Suicide frag."
                type: boolean
              isTeamKill:
                description: "Team-kill frag."
                type: boolean
              killer:
                description: "Killer (attacker) name."
                type: string
              time:
                description: "Event/sample time, int32 ms since match start."
                maximum: 2.147483647e+09
                minimum: -2.147483648e+09
                type: integer
              victim:
                description: "Victim (killed player) name."
                type: string
              weapon:
                description: "Weapon — lowercase KTX class name (axe/sg/ssg/ng/sng/gl/rl/lg)."
                type: string
            required:
              - time
              - killer
              - victim
              - weapon
            type: object
          type:
            - "null"
            - array
        killsMeasured:
          description: |
            Whether kill ATTRIBUTION was observable on this demo at all — the
            demo-global verdict, decided once by the analyzer and stored on
            the Result (schema v65).

            `false` means the frag log is EMPTY on a demo where players
            demonstrably died: every obituary went unmatched because the
            server printed them in a form this pipeline does not parse. Deaths
            still count (they come from the protocol death events, not the
            obituaries), which is exactly what makes the zeros dangerous — a
            row reading `kills: 0, deaths: 92` looks measured. Present
            `kills`, `teamkills` and `byWeapon` as UNMEASURED rather than zero
            when this is false.

            It is DEMO-GLOBAL and survives every filter: narrowing the log by
            `players`/`weapons`/`from`/`to` cannot make a demo's obituaries
            matchable or unmatchable. This is the same verdict `/top-windows`
            and `/lives` publish as `measured.frags`.
          type: boolean
        unpaired:
          description: |
            Teamkill obituaries that name only ONE party and whose other side
            the recovery could not identify — exactly one of `killer` /
            `victim` is the placeholder string `"teammate"` (schema v74).

            These are NOT in `frags`, NOT counted in `totalFrags`, `byWeapon`
            or `byPlayer`, and must not be folded into per-player tallies: a
            placeholder is not a player. They are published so a consumer can
            still see that the death happened and read its CAUSE — the
            victim-named forms carry the real weapon (`tele` / `stomp`),
            which is what lets the reconstructed `damage` section book such a
            kill as a positional instant kill rather than pricing the
            victim's whole corpse drop as team damage.

            Omitted when empty, which is the common case. The
            `weapons` / `from` / `to` filters narrow this list exactly as they
            narrow `frags`; `players` matches only the side the wire actually
            named, so filtering on the literal `teammate` selects nothing.
            `summary=true` drops it along with `frags` — it is a log, not an
            aggregate.
          items:
            additionalProperties: false
            properties:
              isSuicide:
                description: "Suicide frag."
                type: boolean
              isTeamKill:
                description: "Team-kill frag."
                type: boolean
              killer:
                description: "Killer (attacker) name, or the placeholder \"teammate\"."
                type: string
              time:
                description: "Event/sample time, int32 ms since match start."
                maximum: 2.147483647e+09
                minimum: -2.147483648e+09
                type: integer
              victim:
                description: "Victim (killed player) name, or the placeholder \"teammate\"."
                type: string
              weapon:
                description: "Cause the obituary carried (tele/stomp for the victim-named forms, teamkill when the phrasing had none)."
                type: string
            required:
              - time
              - killer
              - victim
              - weapon
            type: object
          type:
            - "null"
            - array
        totalFrags:
          description: "Total frags in the log."
          type: integer
      required:
        # timeUnit is flattened INTO the governed /frags response (always
        # echoed) but carried as an /artifacts envelope SIBLING of the `frags`
        # key (required on that branch, not inside this object), so within this
        # reused schema it stays optional-but-declared.
        - totalFrags
        - frags
        - byWeapon
        - byPlayer
        - killsMeasured
      type: object
    Damage:
      additionalProperties: false
      properties:
        timeUnit: { $ref: "#/components/schemas/TimeUnit" }
        source:
          description: >
            Where the damage log itself came from — `ktx` when decoded from
            the wire's KTX mvdhidden_dmgdone stream (every figure a direct
            measurement), or `reconstructed` when the demo predates that
            instrumentation and the log was rebuilt from the health/armor
            change streams + spectator-visible evidence (LG beams, projectile
            flights, fire sounds, position tracks, the frag log). On
            reconstructed data magnitudes are near-exact but attribution is
            best-effort inference; treat per-player figures as ~1-2%%
            estimates. Absent only on pre-v71 cached results. Distinct from
            `boundedSource`, which records a view-time substitution WITHIN a
            KTX-sourced payload.
          type: string
          enum: [ktx, reconstructed]
        boundedMode:
          description: >
            Whether the bounded family was reconstructed — `standard`, or a
            `skipped:*` reason when the server mode rewrites the take in ways
            the wire does not expose (every bounded field is then absent).
            Survives a raw view. The clan-arena family (`ca`, `wipeout`,
            `ra`), `lgc` and `race` joined the skip set with schema v71 —
            detected from the composite serverinfo `mode` string, the only
            place newer KTX exposes its submodes.
          type: string
          enum: [standard, "skipped:midair", "skipped:instagib", "skipped:dmgfrags",
                 "skipped:ca", "skipped:wipeout", "skipped:ra", "skipped:lgc", "skipped:race"]
        coverage:
          description: >
            How much of the match a RECONSTRUCTED section could actually see
            (schema v74). A reconstruction is only as complete as the
            health/armor stat channel the recording broadcast, and a small
            class of archive recordings barely broadcasts it: positions and
            the frag log are intact, the damage is not observable, and the
            section then describes a fraction of the real match. This is the
            field that says so — the share of the frag log's weapon kills
            whose lethal instant the `events` log accounts for. READ IT
            BEFORE TREATING A RECONSTRUCTED FIGURE AS A MATCH TOTAL, and
            note that everything riding this section inherits it
            (playerStats' damage family and its reconstructed accuracy
            hits, /aim's weapons[].recon.hits and .directHits) rather than
            restating it.
            1.000 is the norm, not the top of a noisy scale. Measured over
            the full 10 702-demo validation sweep: 99.0% of reconstructions
            read 0.95 or better (median 1.000, 96% of them exactly 1.000),
            0.80% are the silent-channel class at 0.182 median / 0.488
            worst, and 0.18% fall BETWEEN the two, spanning 0.500 to 0.944.
            A hard bimodal core with a thin gradient tail — read `ratio` as
            a MAGNITUDE, not as a two-valued healthy/broken flag. Take a
            cutoff from those percentiles if you need one; the API gates
            nothing on it. Two limits: the denominator is the frag log
            itself, so a loss that drops the obituaries WITH the damage
            evidence (a late-started recording, a hole in the stream) reads
            a clean 1.000 over the surviving fraction — this says how much
            of the FRAG-LOG-VISIBLE match is here, not whether the
            recording was complete; and it is one number for the whole
            demo, so it does not localize to a player. Present only when
            `source` is `reconstructed` AND the frag log names at least one
            scoreable kill — a wire log records every T_Damage call, so a
            `ktx` section's coverage would be the constant 1 (measured
            exactly 1.000 on all 65 ground-truth demos) and is not emitted.
            Absent on a reconstructed section therefore means "no anchor
            existed, completeness unassessed", never "coverage was zero".
            WHOLE-MATCH, like `source` and `boundedMode`: a players /
            weapons / time filter carries it through unchanged rather than
            rescoping it to the filtered hits.
          additionalProperties: false
          properties:
            kills:
              description: >
                The denominator: frag-log kills carrying damage arithmetic —
                an ENEMY kill (not a suicide, not a team kill, killer named
                and not `world`, killer != victim) by a WEAPON (telefrags
                and stomps are positional and aggregate outside `events`) on
                two players the roster names.
              type: integer
            covered:
              description: >
                Of those, the kills with at least one `events` entry at the
                victim's death instant.
              type: integer
            ratio:
              description: "covered / kills, in [0,1]."
              type: number
          required: [kills, covered, ratio]
          type: object
        rocketDirectDamage:
          description: >
            The server's DIRECT rocket damage constant, measured from this
            demo's own hits (schema v74): 110 on every KTX since 1.36, which
            replaced id1's `100 + g_random()*20` in 2008. Present exactly
            when `rocketDirectRegime` is `fixed` — read the regime, not this
            field's absence, for why it is missing. It is published as an era
            signal with consequences: the direct/splash classifier behind
            `/aim`'s `weapons[].recon.directHits` leans on that constant for
            its sharper signal, and the derived rl touch count runs 1.25%
            aggregate error against the verbatim KTX block where it is
            present against 13.9% with the prior disabled. Nothing is gated
            on it.
          type: integer
        rocketDirectRegime:
          description: >
            WHICH verdict the measurement above reached (schema v74) — a
            three-value total partition of every RECONSTRUCTED damage
            section, absent on `source: "ktx"` where the wire's own splash
            flag makes the question moot. `fixed`: the demo's near-direct
            rocket hits clustered on 110, `rocketDirectDamage` carries it and
            the classifier's magnitude prior is in force (0.65% aggregate
            error on the rl touch count). `spread`: there were enough
            near-direct hits to test the hypothesis and they did NOT cluster
            — what a pre-1.36 `100 + g_random()*20` server looks like, and
            evidence against the constant rather than absence of evidence;
            the weakest population (13.0% aggregate, 31.2% of rows exact).
            `unestablished`: fewer than six such hits, so nothing was
            established either way — the low-rocket case, and the strongest
            population (59.2% of rows exact).
          enum: [fixed, spread, unestablished]
          type: string
        boundedSource:
          description: >
            Where a SUMMARY response's per-player bounded figures came from —
            `ktx` when substituted with KTX's exact end-of-match scoreboard
            totals (demoInfo dmg + weapons[].damage.enemy/.team), else
            `reconstructed`. Present only on an unfiltered summary that serves
            the bounded family (`dmg=bounded` or `dmg=both`); absent on the
            full-log, filtered, and raw responses. NOTE `taken`, the
            enemyVs* buckets and `byWeaponSelf` stay reconstructed even when
            `ktx` (KTX has no matching counter), so the buckets may not sum
            exactly to the substituted `given`; `byWeapon` and `byWeaponTeam`
            both come from KTX's own weapons[].damage sub-block.
          type: string
          enum: [ktx, reconstructed]
        byPlayer:
          description: "Per-player breakdown, keyed by player name."
          additionalProperties:
            additionalProperties: false
            properties:
              bounded:
                description: >
                  The same damage figures under KTX bounded-scoreboard
                  semantics (dmg=both only). A flat nest carrying just the
                  damage-figure fields — no telefrags/stomps/bounded of its
                  own. Absent on a raw view and when the reconstruction was
                  skipped (see boundedMode).
                additionalProperties: false
                properties:
                  byWeapon:
                    description: "Enemy damage by attacker weapon, keyed by weapon name."
                    additionalProperties:
                      type: integer
                    type:
                      - object
                      - "null"
                  byWeaponTeam:
                    description: >
                      Team damage by attacker weapon, keyed like byWeapon.
                      Measured whenever this damage family is present; an
                      absent key means the player dealt no team damage with
                      that weapon, and the whole map is omitted when empty.
                      Telefrags and stomps are excluded, as from byWeapon.
                    additionalProperties:
                      type: integer
                    type: object
                  byWeaponSelf:
                    description: >
                      Self damage by attacker weapon, keyed like byWeapon.
                      Measured whenever this damage family is present; an
                      absent key means the player dealt no self damage with
                      that weapon, and the whole map is omitted when empty.
                      Telefrags and stomps are excluded — their damage folds
                      into givenSelf only.
                    additionalProperties:
                      type: integer
                    type: object
                  enemyVsBoth:
                    description: "Enemy-given damage where the victim held both RL and LG."
                    type: integer
                  enemyVsLg:
                    description: "Enemy-given damage where the victim held LG (not RL)."
                    type: integer
                  enemyVsMid:
                    description: "Enemy-given damage where the victim held ssg/sng/gl, no LG/RL."
                    type: integer
                  enemyVsRl:
                    description: "Enemy-given damage where the victim held RL (not LG)."
                    type: integer
                  enemyVsSg:
                    description: "Enemy-given damage where the victim held shotgun-tier only."
                    type: integer
                  ewep:
                    description: "Effective weapon (most kills with)."
                    type: integer
                  given:
                    description: "Damage given to enemies (the \"useful\" number; KTX dmg.given)."
                    type: integer
                  givenSelf:
                    description: "Self damage (attacker == victim)."
                    type: integer
                  givenTeam:
                    description: "Damage given to teammates."
                    type: integer
                  taken:
                    description: "Damage taken from all sources (enemy + team + self + env)."
                    type: integer
                  takenEnv:
                    description: "Damage taken from world / environment."
                    type: integer
                required:
                  - given
                  - taken
                  - givenTeam
                  - givenSelf
                  - takenEnv
                  - byWeapon
                  - enemyVsSg
                  - enemyVsMid
                  - enemyVsLg
                  - enemyVsRl
                  - enemyVsBoth
                  - ewep
                type: object
              byWeapon:
                description: "Enemy damage by attacker weapon, keyed by weapon name."
                additionalProperties:
                  type: integer
                type:
                  - object
                  - "null"
              byWeaponTeam:
                description: >
                  Team damage by attacker weapon, keyed like byWeapon.
                  Measured whenever this damage family is present; an absent
                  key means the player dealt no team damage with that weapon,
                  and the whole map is omitted when empty. Telefrags and
                  stomps are excluded, as they are from byWeapon.
                additionalProperties:
                  type: integer
                type: object
              byWeaponSelf:
                description: >
                  Self damage by attacker weapon, keyed like byWeapon.
                  Measured whenever this damage family is present; an absent
                  key means the player dealt no self damage with that weapon,
                  and the whole map is omitted when empty. Telefrags and
                  stomps are excluded — their damage folds into givenSelf
                  only.
                additionalProperties:
                  type: integer
                type: object
              enemyVsBoth:
                description: "Enemy-given damage where the victim held both RL and LG."
                type: integer
              enemyVsLg:
                description: "Enemy-given damage where the victim held LG (not RL)."
                type: integer
              enemyVsMid:
                description: "Enemy-given damage where the victim held ssg/sng/gl, no LG/RL."
                type: integer
              enemyVsRl:
                description: "Enemy-given damage where the victim held RL (not LG)."
                type: integer
              enemyVsSg:
                description: "Enemy-given damage where the victim held shotgun-tier only."
                type: integer
              ewep:
                description: "Effective weapon (most kills with)."
                type: integer
              given:
                description: "Damage given to enemies (the \"useful\" number; KTX dmg.given)."
                type: integer
              givenSelf:
                description: "Self damage (attacker == victim)."
                type: integer
              givenTeam:
                description: "Damage given to teammates."
                type: integer
              stomps:
                description: "Head-stomp kills, separate from weapon damage."
                type: integer
              taken:
                description: "Damage taken from all sources (enemy + team + self + env)."
                type: integer
              takenEnv:
                description: "Damage taken from world / environment."
                type: integer
              telefrags:
                description: "Instant-kill telefrags, separate from weapon damage."
                type: integer
            required:
              - given
              - taken
              - givenTeam
              - givenSelf
              - takenEnv
              - byWeapon
              - enemyVsSg
              - enemyVsMid
              - enemyVsLg
              - enemyVsRl
              - enemyVsBoth
              - ewep
            type:
              - "null"
              - object
          type:
            - object
            - "null"
        byWeapon:
          description: "Enemy damage by attacker weapon, keyed by weapon name."
          additionalProperties:
            type: integer
          type:
            - object
            - "null"
        dmg:
          description: >
            Which family this payload carries — `both` as stored (raw fields +
            bounded nests) or `bounded` when the bounded family was
            materialized into the raw field names. Absent on a raw view.
          type: string
          enum: [both, bounded]
        events:
          description: "Per-hit damage log, time-ordered (excludes telefrags + stomps)."
          items:
            additionalProperties: false
            properties:
              attacker:
                description: "Attacker name (\"world\" for an environmental / non-player inflictor)."
                type: string
              bounded:
                description: >
                  This hit under KTX bounded semantics (armor + health capped
                  to remaining). Omitted means equal to `damage` (the common
                  no-overkill case); 0 is a real value — a hit whose health
                  share was fully nullified (pent / teamplay). Present only on
                  dmg=both.
                type: integer
              damage:
                description: "Raw/unbound damage amount, including overkill."
                type: integer
              isEnv:
                description: "Environmental / world-sourced."
                type: boolean
              isSelf:
                description: "Attacker == victim."
                type: boolean
              isSplash:
                description: "Indirect hit (e.g. rocket splash)."
                type: boolean
              isTeam:
                description: "Same team, not self."
                type: boolean
              time:
                description: "Event/sample time, int32 ms since match start."
                maximum: 2.147483647e+09
                minimum: -2.147483648e+09
                type: integer
              victim:
                description: "Victim (killed player) name."
                type: string
              victimWep:
                description: "Victim weapon class at hit time (sg|mid|lg|rl|both)."
                type: string
              weapon:
                description: "Weapon — lowercase KTX class name (axe/sg/ssg/ng/sng/gl/rl/lg)."
                type: string
            required:
              - time
              - attacker
              - victim
              - weapon
              - damage
            type: object
          type:
            - "null"
            - array
        matrix:
          description: "Attacker→victim damage totals."
          items:
            additionalProperties: false
            properties:
              attacker:
                description: "Attacker name (\"world\" for an environmental / non-player inflictor)."
                type: string
              byWeapon:
                description: "Enemy damage by attacker weapon, keyed by weapon name."
                additionalProperties:
                  type: integer
                type:
                  - object
                  - "null"
              damage:
                description: "Raw/unbound damage amount, including overkill."
                type: integer
              victim:
                description: "Victim (killed player) name."
                type: string
            required:
              - attacker
              - victim
              - damage
              - byWeapon
            type: object
          type:
            - "null"
            - array
        scoreboard:
          description: "Stream-derived totals vs the KTX end-of-match scoreboard (diagnostic only). Whole-match, no per-event provenance: narrowed by a players-only filter, but omitted entirely when a weapons or a RESTRICTIVE time filter is active (an explicit from/to window covering the whole match counts as unfiltered)."
          additionalProperties: false
          properties:
            byPlayer:
              description: "Per-player breakdown, keyed by player name."
              additionalProperties:
                additionalProperties: false
                properties:
                  bounded:
                    description: >
                      The bounded-reconstruction side of the reconciliation
                      (dmg=both only): our bounded figures against the same KTX
                      scoreboard, where near-equality is the correctness
                      signal. streamTaken here is ENEMY-ONLY (KTX dmg_t).
                      Absent on a raw view / when reconstruction was skipped.
                    additionalProperties: false
                    properties:
                      scoreTeam:
                        description: "KTX scoreboard team damage."
                        type: integer
                      streamEwep:
                        description: "This pipeline's enemy-weapon figure."
                        type: integer
                      streamGiven:
                        description: "This pipeline's given figure (unbound in the raw row; bounded in the nest)."
                        type: integer
                      streamTaken:
                        description: "This pipeline's taken figure (unbound raw; bounded-enemy-only in the nest)."
                        type: integer
                      streamTeam:
                        description: "This pipeline's bounded team-given figure."
                        type: integer
                    required:
                      - streamGiven
                      - streamTaken
                      - streamEwep
                      - streamTeam
                      - scoreTeam
                    type: object
                  scoreEwep:
                    description: "KTX scoreboard enemy-weapon damage (bounded)."
                    type: integer
                  scoreGiven:
                    description: "KTX scoreboard given (bounded)."
                    type: integer
                  scoreTaken:
                    description: "KTX scoreboard taken (bounded)."
                    type: integer
                  streamEwep:
                    description: "This pipeline's enemy-weapon figure."
                    type: integer
                  streamGiven:
                    description: "This pipeline's given figure (unbound in the raw row; bounded in the nest)."
                    type: integer
                  streamTaken:
                    description: "This pipeline's taken figure (unbound raw; bounded-enemy-only in the nest)."
                    type: integer
                required:
                  - streamGiven
                  - scoreGiven
                  - streamTaken
                  - scoreTaken
                  - streamEwep
                  - scoreEwep
                type:
                  - "null"
                  - object
              type:
                - object
                - "null"
          required:
            - byPlayer
          type:
            - "null"
            - object
        stomps:
          description: "Head-stomp kills, separate from weapon damage."
          items:
            additionalProperties: false
            properties:
              attacker:
                description: "Attacker name (\"world\" for an environmental / non-player inflictor)."
                type: string
              bounded:
                description: >
                  Reconstructed kill value folded into the bounded aggregates.
                  Absent exactly when the fold was skipped (boundedMode
                  skipped:*); 0 is a real value — a teamplay-nullified stomp
                  with no armor.
                type: integer
              damage:
                description: >
                  The RAW-family fold value when it differs from `bounded`
                  (a stomp whose bounded arithmetic capped below the wire
                  value). Omitted means equal to `bounded`.
                type: integer
              isTeam:
                description: "Same team, not self."
                type: boolean
              time:
                description: "Event/sample time, int32 ms since match start."
                maximum: 2.147483647e+09
                minimum: -2.147483648e+09
                type: integer
              victim:
                description: "Victim (killed player) name."
                type: string
              victimWep:
                description: >
                  Victim's weapon class (sg|mid|lg|rl|both) at hit time, on
                  enemy kills only — so a re-aggregation can reproduce the
                  EnemyVs*/EWep fold. Omitted on team/self/world kills.
                type: string
            required:
              - time
              - attacker
              - victim
            type: object
          type:
            - "null"
            - array
        telefrags:
          description: "Instant-kill telefrags, separate from weapon damage."
          items:
            additionalProperties: false
            properties:
              attacker:
                description: "Attacker name (\"world\" for an environmental / non-player inflictor)."
                type: string
              bounded:
                description: >
                  Reconstructed kill value (victim's full armor + remaining
                  health; armor alone for the pent-vs-pent dtTELE3 variant,
                  where KTX zeroes the health share) folded into the bounded
                  aggregates. Absent exactly when the fold was skipped
                  (boundedMode skipped:*).
                type: integer
              damage:
                description: >
                  The RAW-family fold value when it differs from `bounded`
                  (telefrags fold the same number into both families, so this
                  is normally omitted).
                type: integer
              isTeam:
                description: "Same team, not self."
                type: boolean
              time:
                description: "Event/sample time, int32 ms since match start."
                maximum: 2.147483647e+09
                minimum: -2.147483648e+09
                type: integer
              victim:
                description: "Victim (killed player) name."
                type: string
              victimWep:
                description: >
                  Victim's weapon class (sg|mid|lg|rl|both) at hit time, on
                  enemy kills only. Omitted on team/self/world kills.
                type: string
            required:
              - time
              - attacker
              - victim
            type: object
          type:
            - "null"
            - array
        totalDamage:
          description: "Total enemy damage dealt."
          type: integer
      required:
        # timeUnit is flattened INTO the governed /damage response (always
        # echoed) but carried as an /artifacts envelope SIBLING of the `damage`
        # key (required on that branch, not inside this object), so within this
        # reused schema it stays optional-but-declared.
        - totalDamage
        - events
        - byWeapon
        - byPlayer
        - matrix
      type: object
    Shots:
      additionalProperties: false
      properties:
        timeUnit: { $ref: "#/components/schemas/TimeUnit" }
        byPlayer:
          description: "Per-player match-time fire counts and accuracy."
          items:
            additionalProperties: false
            properties:
              byWeapon:
                description: "Per-weapon fire counts / accuracy for this player."
                items:
                  additionalProperties: false
                  properties:
                    accuracy:
                      description: "Hits / shots in [0,1] over all victims (KTX scoreboard parity)."
                      type: number
                    enemyHits:
                      description: "Fires with ≥1 enemy victim."
                      type: integer
                    hits:
                      description: "Linkable fires that connected."
                      type: integer
                    selfHits:
                      description: "Fires with ≥1 self victim (rl/gl splash)."
                      type: integer
                    shots:
                      description: "Every detected fire, chronological."
                      type: integer
                    teamHits:
                      description: "Fires with ≥1 teammate victim."
                      type: integer
                    weapon:
                      description: "Weapon — lowercase KTX class name (axe/sg/ssg/ng/sng/gl/rl/lg)."
                      type: string
                  required:
                    - weapon
                    - shots
                  type: object
                type:
                  - "null"
                  - array
              player:
                description: "Shooter name."
                type: string
              team:
                description: "Team name."
                type: string
              total:
                description: "Total fires by this player."
                type: integer
            required:
              - player
              - total
              - byWeapon
            type: object
          type:
            - "null"
            - array
        reconciliation:
          description: "Cross-check of detected counts against KTX authoritative stats (diagnostic only)."
          additionalProperties: false
          properties:
            byPlayer:
              description: "Per-player match-time fire counts and accuracy."
              additionalProperties:
                items:
                  additionalProperties: false
                  properties:
                    ktxAttacks:
                      description: "KTX authoritative attack count."
                      type: integer
                    ktxHits:
                      description: "KTX authoritative hit count."
                      type: integer
                    streamAttacks:
                      description: "Detected fires converted to KTX attack units."
                      type: integer
                    streamShots:
                      description: "Detected discrete fires (this pipeline)."
                      type: integer
                    weapon:
                      description: "Weapon — lowercase KTX class name (axe/sg/ssg/ng/sng/gl/rl/lg)."
                      type: string
                  required:
                    - weapon
                    - streamShots
                    - streamAttacks
                    - ktxAttacks
                    - ktxHits
                  type: object
                type:
                  - "null"
                  - array
              type:
                - object
                - "null"
          required:
            - byPlayer
          type:
            - "null"
            - object
        shots:
          description: "Every detected fire, chronological."
          items:
            additionalProperties: false
            properties:
              flightEnd:
                description: >-
                  Match-relative ms at which the tracked projectile this fire
                  launched died — its impact (same clock as `time`). Present
                  only for a projectile fire (rl/gl, and ng/sng when nail
                  decoding ran) whose entity the server broadcast; absent on
                  hitscan fires and on a projectile whose flight was never
                  tracked, which is the state the hit counters read as a miss.
                  Set whether or not the impact damaged anyone. It is the
                  observed despawn frame, not a flight duration: `time` and the
                  entity update are quantized independently, so a point-blank
                  impact may sit up to about one demo frame BEFORE its own fire
                  (measured worst −29 ms) and is published unclamped.
                maximum: 2.147483647e+09
                minimum: -2.147483648e+09
                type: integer
              hit:
                description: "Whether the fire connected (Go-linked hit truth)."
                type: boolean
              player:
                description: "Shooter name."
                type: string
              source:
                description: "Source."
                type: string
              team:
                description: "Team name."
                type: string
              time:
                description: "Event/sample time, int32 ms since match start."
                maximum: 2.147483647e+09
                minimum: -2.147483648e+09
                type: integer
              victimKinds:
                description: "Per-victim class — \"enemy\"/\"team\"/\"self\" (parallel to victims)."
                items:
                  type: string
                type:
                  - "null"
                  - array
              victims:
                description: "Victim names for this fire (parallel to victimKinds)."
                items:
                  type: string
                type:
                  - "null"
                  - array
              weapon:
                description: "Weapon — lowercase KTX class name (axe/sg/ssg/ng/sng/gl/rl/lg)."
                type: string
            required:
              - time
              - player
              - weapon
              - source
            type: object
          type:
            - "null"
            - array
      required:
        # timeUnit is flattened INTO the governed /shots response (always
        # echoed) but carried as an /artifacts envelope SIBLING of the `shots`
        # key (required on that branch, not inside this object), so within this
        # reused schema it stays optional-but-declared.
        - shots
      type: object
    Aim:
      additionalProperties: false
      properties:
        timeUnit: { $ref: "#/components/schemas/TimeUnit" }
        players:
          description: "Per-player aim analysis blocks."
          items:
            additionalProperties: false
            properties:
              crosshair:
                description: "Columnar per-hitscan-fire crosshair-error samples (shared index)."
                additionalProperties: false
                properties:
                  dist:
                    description: "Muzzle→target-center distance, map units."
                    items:
                      type: number
                    type:
                      - "null"
                      - array
                  dpitch:
                    description: "Signed pitch error to target, float degrees (target above = positive)."
                    items:
                      type: number
                    type:
                      - "null"
                      - array
                  dyaw:
                    description: "Signed yaw error to target, float degrees (target left = positive)."
                    items:
                      type: number
                    type:
                      - "null"
                      - array
                  hit:
                    description: >-
                      Per-sample hit flag (shares the column index). ABSENT
                      whenever the top-level `hitsMeasured` is false — on
                      reconstructed or absent damage every per-fire flag would
                      be a fabricated miss, so the column is omitted rather
                      than filled with false. Not required for that reason.
                    items:
                      type: boolean
                    type:
                      - "null"
                      - array
                  npitch:
                    description: "Pitch error normalised by target angular half-height (|n|≤1 ≈ on hitbox)."
                    items:
                      type: number
                    type:
                      - "null"
                      - array
                  nyaw:
                    description: "Yaw error normalised by target angular half-width."
                    items:
                      type: number
                    type:
                      - "null"
                      - array
                  t:
                    description: "Event/sample time, int32 ms since match start."
                    items:
                      maximum: 2.147483647e+09
                      minimum: -2.147483648e+09
                      type: integer
                    type:
                      - "null"
                      - array
                  team:
                    description: "Per-sample flag: the attributed target was a teammate (nil when none)."
                    items:
                      type: boolean
                    type:
                      - "null"
                      - array
                  tgt:
                    description: "Attributed target name."
                    items:
                      type: string
                    type:
                      - "null"
                      - array
                  w:
                    description: "Weapon per sample."
                    items:
                      type: string
                    type:
                      - "null"
                      - array
                required:
                  - t
                  - w
                  - dyaw
                  - dpitch
                  - nyaw
                  - npitch
                  - dist
                  - tgt
                type:
                  - "null"
                  - object
              lgRamp:
                description: "Columnar per-LG-fire ramp-onto-target series."
                additionalProperties: false
                properties:
                  hit:
                    description: >-
                      Per-fire hit flag (shares the column index). ABSENT
                      whenever the top-level `hitsMeasured` is false, for the
                      same reason as `crosshair.hit`. Not required.
                    items:
                      type: boolean
                    type:
                      - "null"
                      - array
                  since:
                    description: "Milliseconds since the start of the shaft this fire belongs to."
                    items:
                      maximum: 2.147483647e+09
                      minimum: -2.147483648e+09
                      type: integer
                    type:
                      - "null"
                      - array
                  team:
                    description: "Per-sample flag: the attributed target was a teammate (nil when none)."
                    items:
                      type: boolean
                    type:
                      - "null"
                      - array
                required:
                  - since
                type:
                  - "null"
                  - object
              mode:
                description: "Mode."
                type: string
              player:
                description: "Player display name."
                type: string
              team:
                description: "Team name."
                type: string
              weapons:
                description: "Per-weapon effectiveness entries (ordered by weapon rank)."
                items:
                  additionalProperties: false
                  properties:
                    blocked:
                      description: "LG whiffs blocked short by geometry with a live enemy behind."
                      type: integer
                    direct:
                      description: "Direct (non-splash) contacts ≈ KTX hits."
                      type: integer
                    enemy:
                      description: "Enemy-victim slice of this weapon's hit counters (emitted when it differs from the top-level counters)."
                      additionalProperties: false
                      properties:
                        direct:
                          description: "Direct (non-splash) contacts ≈ KTX hits."
                          type: integer
                        full:
                          description: "Fires where all pellets hit."
                          type: integer
                        hits:
                          description: "Fires that connected."
                          type: integer
                        miss:
                          description: "Zero-hit fires (SG/SSG per-pellet; LG aim-error whiffs)."
                          type: integer
                        partial:
                          description: "This bucket is a short trailing window."
                          type: integer
                        pelletHits:
                          description: "Pellet hits (Σ damage / 4; KTX acc.hits)."
                          type: integer
                      type:
                        - "null"
                        - object
                    full:
                      description: "Fires where all pellets hit."
                      type: integer
                    hits:
                      description: "Fires that connected. Omitted when the top-level hitsMeasured is false (no wire damage stream to link against); with hitsMeasured true, absent means a measured zero."
                      type: integer
                    miss:
                      description: "Zero-hit fires (SG/SSG per-pellet; LG aim-error whiffs)."
                      type: integer
                    missed:
                      description: "Fires that linked to no impact."
                      type: integer
                    outOfRange:
                      description: "LG whiffs where the enemy was beyond the beam reach."
                      type: integer
                    partial:
                      description: "This bucket is a short trailing window."
                      type: integer
                    pelletHits:
                      description: "Pellet hits (Σ damage / 4; KTX acc.hits)."
                      type: integer
                    pellets:
                      description: "Pellets fired (shots × 6/14)."
                      type: integer
                    recon:
                      description: >-
                        RECONSTRUCTED hit tier (schema v73). Present only when
                        the top-level `hitsSource` is "reconstructed" — i.e. the
                        demo predates the KTX damage instrumentation and its
                        damage log was reconstructed — and never beside the
                        measured counters, which stay withheld there. Accuracy
                        is `recon.hits / shots`. Emitted for the same-server-
                        frame weapons lg/sg/ssg/axe and (schema v74) for the
                        projectiles rl/gl, which link through the fire's tracked
                        flight (`shots[].flightEnd`) on the measured counter's
                        own definition — an untracked projectile is a miss on
                        both sides. A block appears for every covered weapon the
                        player fired: a shooter the reconstruction credits with
                        no damage gets `hits: 0`, not an absent block. ng/sng
                        carry no block at all (nail linking is opt-in, so there
                        is no measured baseline to validate a recovery against),
                        so an absent block means "not recovered for this
                        weapon", never "no hits".
                      additionalProperties: false
                      properties:
                        hits:
                          description: >-
                            Fires that connected, per the reconstructed damage
                            log — a fire that landed damage by ANY path, splash
                            included (`anyDamage` in the playerStats
                            `hitsConvention` vocabulary). A zero inside a
                            present block is a real "linked nothing".
                          type: integer
                        directHits:
                          description: >-
                            RL/GL ONLY (schema v74): projectiles that TOUCHED a
                            player — `directImpact`, the convention KTX's own
                            `acc.rl.hits` / `acc.gl.hits` counts, since KTX
                            increments that counter in the touch handler and
                            nowhere else. This is the number
                            `playerStats.accuracy.byWeapon[rl|gl].hits` carries
                            on a reconstructed row, which is what makes an old
                            demo comparable with a demoinfo-carrying one. NOT a
                            subset of `hits` — it counts direct damage rows
                            rather than joining through the fire's flight, so a
                            point-blank rocket whose entity was never broadcast
                            counts here and not there. Absent on every other
                            weapon, where the two conventions coincide; absent
                            means "the question does not arise", never "no
                            direct hits".
                          type: integer
                      required:
                        - hits
                      type:
                        - "null"
                        - object
                    self:
                      description: "Self-victim slice of this weapon's hit counters (rl/gl self-splash)."
                      additionalProperties: false
                      properties:
                        direct:
                          description: "Direct (non-splash) contacts ≈ KTX hits."
                          type: integer
                        full:
                          description: "Fires where all pellets hit."
                          type: integer
                        hits:
                          description: "Fires that connected."
                          type: integer
                        miss:
                          description: "Zero-hit fires (SG/SSG per-pellet; LG aim-error whiffs)."
                          type: integer
                        partial:
                          description: "This bucket is a short trailing window."
                          type: integer
                        pelletHits:
                          description: "Pellet hits (Σ damage / 4; KTX acc.hits)."
                          type: integer
                      type:
                        - "null"
                        - object
                    shots:
                      description: "Weapon fires."
                      type: integer
                    splash:
                      description: "Linked hits that were splash-only."
                      type: integer
                    team:
                      description: "Team-victim slice of this weapon's hit counters (same fields, team victims only)."
                      additionalProperties: false
                      properties:
                        direct:
                          description: "Direct (non-splash) contacts ≈ KTX hits."
                          type: integer
                        full:
                          description: "Fires where all pellets hit."
                          type: integer
                        hits:
                          description: "Fires that connected."
                          type: integer
                        miss:
                          description: "Zero-hit fires (SG/SSG per-pellet; LG aim-error whiffs)."
                          type: integer
                        partial:
                          description: "This bucket is a short trailing window."
                          type: integer
                        pelletHits:
                          description: "Pellet hits (Σ damage / 4; KTX acc.hits)."
                          type: integer
                      type:
                        - "null"
                        - object
                    unresolved:
                      description: "LG fires with no matching beam."
                      type: integer
                    weapon:
                      description: "Weapon this effectiveness entry covers."
                      type: string
                  required:
                    - weapon
                    - shots
                  type: object
                type:
                  - "null"
                  - array
            required:
              - player
              - mode
            type: object
          type:
            - "null"
            - array
        hitsMeasured:
          description: >-
            Whether the hit-derived counters (hits, pellet full/partial/miss,
            direct/splash, LG whiff classes) were measured against a wire KTX
            damage stream. False on reconstructed/absent damage — those fields
            are withheld there rather than fabricated as zeros; when true an
            absent per-weapon `hits` means a measured zero.
          type: boolean
        hitsSource:
          description: >-
            Which damage evidence the hit counters were linked against, from
            `damage.source`'s vocabulary (schema v73). "ktx" = the wire
            mvdhidden_dmgdone stream (hitsMeasured true; the measured counters
            and the per-fire `hit` columns are populated). "reconstructed" =
            the reconstructed damage log (hitsMeasured stays FALSE, every
            measured counter stays withheld, and the recovered counts appear
            ONLY under `weapons[].recon`). Absent = the demo carried no damage
            section at all, the state hitsMeasured alone could not tell from a
            reconstructed one.
          enum: ["ktx", "reconstructed"]
          type: string
      required:
        # timeUnit is flattened INTO the governed /aim response (always echoed)
        # but carried as an /artifacts envelope SIBLING of the `aim` key
        # (required on that branch, not inside this object), so within this
        # reused schema it stays optional-but-declared.
        - players
        - hitsMeasured
      type: object
    LocGraph:
      additionalProperties: false
      properties:
        timeUnit: { $ref: "#/components/schemas/TimeUnit" }
        edges:
          description: "Directed loc-transition edges."
          items:
            additionalProperties: false
            properties:
              armed:
                description: "Same transition-count breakdown restricted to transitions while holding RL or LG."
                additionalProperties: false
                properties:
                  byPlayer:
                    description: "Per-player transition counts, keyed by player name."
                    additionalProperties:
                      type: integer
                    type:
                      - object
                      - "null"
                  byTeam:
                    description: "Per-team transition counts, keyed by team name."
                    additionalProperties:
                      type: integer
                    type: object
                  total:
                    description: "Aggregate transition count."
                    type: integer
                required:
                  - total
                  - byPlayer
                type:
                  - "null"
                  - object
              byPlayer:
                description: "Per-player transition counts, keyed by player name."
                additionalProperties:
                  type: integer
                type:
                  - object
                  - "null"
              byTeam:
                description: "Per-team transition counts, keyed by team name."
                additionalProperties:
                  type: integer
                type: object
              from:
                description: "Edge source."
                type: string
              kind:
                description: "Transition kind."
                type: string
              pent:
                description: "Same transition-count breakdown restricted to transitions with an active Pent."
                additionalProperties: false
                properties:
                  byPlayer:
                    description: "Per-player transition counts, keyed by player name."
                    additionalProperties:
                      type: integer
                    type:
                      - object
                      - "null"
                  byTeam:
                    description: "Per-team transition counts, keyed by team name."
                    additionalProperties:
                      type: integer
                    type: object
                  total:
                    description: "Aggregate transition count."
                    type: integer
                required:
                  - total
                  - byPlayer
                type:
                  - "null"
                  - object
              quad:
                description: "Same transition-count breakdown restricted to transitions with an active Quad."
                additionalProperties: false
                properties:
                  byPlayer:
                    description: "Per-player transition counts, keyed by player name."
                    additionalProperties:
                      type: integer
                    type:
                      - object
                      - "null"
                  byTeam:
                    description: "Per-team transition counts, keyed by team name."
                    additionalProperties:
                      type: integer
                    type: object
                  total:
                    description: "Aggregate transition count."
                    type: integer
                required:
                  - total
                  - byPlayer
                type:
                  - "null"
                  - object
              to:
                description: "Edge target."
                type: string
              total:
                description: "Aggregate transition count."
                type: integer
              unarmed:
                description: "Same transition-count breakdown restricted to transitions holding neither RL nor LG."
                additionalProperties: false
                properties:
                  byPlayer:
                    description: "Per-player transition counts, keyed by player name."
                    additionalProperties:
                      type: integer
                    type:
                      - object
                      - "null"
                  byTeam:
                    description: "Per-team transition counts, keyed by team name."
                    additionalProperties:
                      type: integer
                    type: object
                  total:
                    description: "Aggregate transition count."
                    type: integer
                required:
                  - total
                  - byPlayer
                type:
                  - "null"
                  - object
            required:
              - from
              - to
              - kind
              - total
              - byPlayer
            type: object
          type:
            - "null"
            - array
        locs:
          description: "Location nodes / membership list."
          items:
            additionalProperties: false
            properties:
              armed:
                description: "Same time-spent breakdown restricted to samples while holding RL or LG."
                additionalProperties: false
                properties:
                  byPlayer:
                    description: "Per-player ALIVE, OBSERVED time spent (int32 ms), keyed by player name. Dead time is excluded and sample-and-hold expires after 250 ms, so unobserved holes are credited to nobody (schema v64)."
                    additionalProperties:
                      type: integer
                    type:
                      - object
                      - "null"
                  byTeam:
                    description: "Per-team ALIVE, OBSERVED time spent (int32 ms), keyed by team name — the sum of its members' values."
                    additionalProperties:
                      type: integer
                    type: object
                  total:
                    description: "Aggregate ALIVE, OBSERVED time spent, int32 ms."
                    type: integer
                required:
                  - total
                  - byPlayer
                type:
                  - "null"
                  - object
              byPlayer:
                description: "Per-player ALIVE, OBSERVED time spent (int32 ms), keyed by player name. Dead time is excluded and sample-and-hold expires after 250 ms, so unobserved holes are credited to nobody (schema v64)."
                additionalProperties:
                  type: integer
                type:
                  - object
                  - "null"
              byTeam:
                description: "Per-team ALIVE, OBSERVED time spent (int32 ms), keyed by team name — the sum of its members' values."
                additionalProperties:
                  type: integer
                type: object
              name:
                description: "Loc name."
                type: string
              pent:
                description: "Same time-spent breakdown restricted to samples with an active Pent."
                additionalProperties: false
                properties:
                  byPlayer:
                    description: "Per-player ALIVE, OBSERVED time spent (int32 ms), keyed by player name. Dead time is excluded and sample-and-hold expires after 250 ms, so unobserved holes are credited to nobody (schema v64)."
                    additionalProperties:
                      type: integer
                    type:
                      - object
                      - "null"
                  byTeam:
                    description: "Per-team ALIVE, OBSERVED time spent (int32 ms), keyed by team name — the sum of its members' values."
                    additionalProperties:
                      type: integer
                    type: object
                  total:
                    description: "Aggregate ALIVE, OBSERVED time spent, int32 ms."
                    type: integer
                required:
                  - total
                  - byPlayer
                type:
                  - "null"
                  - object
              quad:
                description: "Same time-spent breakdown restricted to samples with an active Quad."
                additionalProperties: false
                properties:
                  byPlayer:
                    description: "Per-player ALIVE, OBSERVED time spent (int32 ms), keyed by player name. Dead time is excluded and sample-and-hold expires after 250 ms, so unobserved holes are credited to nobody (schema v64)."
                    additionalProperties:
                      type: integer
                    type:
                      - object
                      - "null"
                  byTeam:
                    description: "Per-team ALIVE, OBSERVED time spent (int32 ms), keyed by team name — the sum of its members' values."
                    additionalProperties:
                      type: integer
                    type: object
                  total:
                    description: "Aggregate ALIVE, OBSERVED time spent, int32 ms."
                    type: integer
                required:
                  - total
                  - byPlayer
                type:
                  - "null"
                  - object
              total:
                description: "Aggregate ALIVE, OBSERVED time spent, int32 ms."
                type: integer
              unarmed:
                description: "Same time-spent breakdown restricted to samples holding neither RL nor LG."
                additionalProperties: false
                properties:
                  byPlayer:
                    description: "Per-player ALIVE, OBSERVED time spent (int32 ms), keyed by player name. Dead time is excluded and sample-and-hold expires after 250 ms, so unobserved holes are credited to nobody (schema v64)."
                    additionalProperties:
                      type: integer
                    type:
                      - object
                      - "null"
                  byTeam:
                    description: "Per-team ALIVE, OBSERVED time spent (int32 ms), keyed by team name — the sum of its members' values."
                    additionalProperties:
                      type: integer
                    type: object
                  total:
                    description: "Aggregate ALIVE, OBSERVED time spent, int32 ms."
                    type: integer
                required:
                  - total
                  - byPlayer
                type:
                  - "null"
                  - object
              x:
                description: "X coordinate, map units."
                type: number
              "y":
                description: "Y coordinate, map units."
                type: number
              z:
                description: "Z coordinate, map units."
                type: number
            required:
              - name
              - x
              - "y"
              - z
              - total
              - byPlayer
            type: object
          type:
            - "null"
            - array
      required:
        - locs
        - edges
      type: object
    MatchEvent:
      additionalProperties: false
      properties:
        message:
          description: "Chat text or frag description (ezQuake-markup-preserving)."
          type: string
        messageClean:
          description: "Same text with ezQuake markup stripped (plain text)."
          type: string
        player:
          description: "Player display name."
          type: string
        team:
          description: "Team name."
          type: string
        time:
          description: "Event/sample time, int32 ms since match start."
          maximum: 2.147483647e+09
          minimum: -2.147483648e+09
          type: integer
        type:
          description: "Type discriminator."
          type: string
        victim:
          description: "Victim (killed player) name."
          type: string
        weapon:
          description: "Weapon — lowercase KTX class name (axe/sg/ssg/ng/sng/gl/rl/lg)."
          type: string
      required:
        - time
        - type
        - player
        - team
        - message
      type: object
    BackpackDrop:
      additionalProperties: false
      properties:
        entNum:
          description: >
            Server entity/edict number — stable id within a match. On a `ktx`
            row it comes from the wire hint and joins to
            WeaponPickup.backpackEnt. On a `reconstructed` row it is the
            backpack-model entity the linkage bound to the drop and joins to
            nothing (that row's pickup side is `fate`/`picker` below). 0
            means no entity was identified; never treat 0 as an edict.
          type: integer
        fate:
          description: >
            What the wire showed happening to the pack (schema v72).
            `picked` — the pack entity left the wire with a live player's
            bounding box overlapping it, the same test the server runs
            before BackpackTouch; `pickupTime` is when, and `picker` is set
            only when the evidence named exactly one player. `expired` —
            SUB_Remove took it at KTX's 120 s timeout, untaken: the one
            outcome that is positively not a pickup. `unobserved` — no
            entity bound to the drop, or it was still on the wire when the
            recording stopped, or it left early with nobody on it; none of
            which is evidence that nobody took the pack.

            A `reconstructed` row can carry any of the three, read off the
            backpack-entity track. A `ktx` row carries only `expired`, and
            only because KTX said so in `//ktx expire <ent>` — its other
            outcomes are the /weapon-pickups join, so an ABSENT `fate` on a
            `ktx` row means "ask that endpoint", never "nobody took it".
            Read `source` for a `fate`'s provenance, exactly as for the row.
            Measured against the `//ktx bp` and `//ktx expire` hints on 223
            demos with every hint withheld: 100.00% precision and 96.13%
            recall on picked-vs-not, 99.98% of named pickers correct.
          enum: [picked, expired, unobserved]
          type: string
        picker:
          description: >
            Who took the pack (schema v72). Set only with `fate: picked`, and
            only when the evidence named exactly ONE player — two players on
            one pack with nothing to separate them leave this empty rather
            than guess. Reconstructed rows only.
          type: string
        pickerTeam:
          description: >
            Team of `picker`. Omitted when the picker has no team — an FFA
            or duel picker is named with no `pickerTeam`, so read its
            absence as "teamless", not as "no picker". Reconstructed rows
            only (schema v72).
          type: string
        pickupTime:
          description: >
            When the pack left the wire, int32 ms since match start. Set with
            `fate: picked` (with or without a `picker`). Reconstructed rows
            only (schema v72).
          maximum: 2.147483647e+09
          minimum: -2.147483648e+09
          type: integer
        source:
          description: >
            Where this row came from (schema v72). `ktx` — decoded from KTX's
            `//ktx drop` hint, which fires exactly once per real pack and is
            emitted only by KTX >= 1.38 (49% of the archive). `reconstructed`
            — replayed from DropBackpack's own rule (the victim's wielded
            weapon at the death instant, dropped at their last broadcast
            position) on a demo whose mod never hinted. The two are never
            mixed within one demo; a reconstructed section stands down
            entirely rather than guess where the evidence is unmeasurable.
          enum: [ktx, reconstructed]
          type: string
        loc:
          description: "Nearest named location from the map .loc file."
          type: string
        origin:
          description: "World-space origin [x,y,z], map units."
          items:
            type: number
          maxItems: 3
          minItems: 3
          type: array
        player:
          description: "Player display name."
          type: string
        team:
          description: "Team name."
          type: string
        time:
          description: "Event/sample time, int32 ms since match start."
          maximum: 2.147483647e+09
          minimum: -2.147483648e+09
          type: integer
        weapon:
          description: "Weapon — lowercase KTX class name (axe/sg/ssg/ng/sng/gl/rl/lg)."
          type: string
      required:
        - time
        - player
        - weapon
        - origin
        - entNum
      type: object
    WeaponPickup:
      additionalProperties: false
      properties:
        backpackEnt:
          description: "Originating BackpackDrop entNum (backpack source only)."
          type: integer
        dropTime:
          description: "Drop time of the source backpack, int32 ms."
          maximum: 2.147483647e+09
          minimum: -2.147483648e+09
          type: integer
        dropper:
          description: "Backpack dropper name."
          type: string
        dropperTeam:
          description: "Backpack dropper team."
          type: string
        hadBefore:
          description: "Picker already held this weapon (redundant grab, zero kills credited)."
          type: boolean
        inferred:
          description: "Synthesized from a STAT_ITEMS flip (weapon-stay recovery), not a KTX hint."
          type: boolean
        kills:
          description: "Kills credited to the weapon this pickup granted."
          type: integer
        nextDeathTime:
          description: "Picker's next death, int32 ms (0 = never died before match end)."
          maximum: 2.147483647e+09
          minimum: -2.147483648e+09
          type: integer
        player:
          description: "Player display name."
          type: string
        source:
          description: "Pickup source (\"world\"/\"backpack\"/\"unknown\")."
          type: string
        team:
          description: "Team name."
          type: string
        time:
          description: "Event/sample time, int32 ms since match start."
          maximum: 2.147483647e+09
          minimum: -2.147483648e+09
          type: integer
        weapon:
          description: "Weapon — lowercase KTX class name (axe/sg/ssg/ng/sng/gl/rl/lg)."
          type: string
      required:
        - time
        - player
        - weapon
        - source
        - hadBefore
        - kills
      type: object
    ItemsTimelines:
      additionalProperties: false
      properties:
        timeUnit: { $ref: "#/components/schemas/TimeUnit" }
        items:
          description: "Per-item timelines."
          items:
            additionalProperties: false
            properties:
              entNum:
                description: "Server entity/edict number — stable id within a match."
                type: integer
              kind:
                description: "Item kind — compact vocabulary (rl/lg/ra/mh/quad/…)."
                type: string
              loc:
                description: "Nearest named location from the map .loc file."
                type: string
              name:
                description: "Name / display label."
                type: string
              phases:
                description: "Observable up→taken→respawn rows for this item."
                items:
                  additionalProperties: false
                  properties:
                    availableFrom:
                      description: "Time this phase became available, int32 ms since match start."
                      maximum: 2.147483647e+09
                      minimum: -2.147483648e+09
                      type: integer
                    respawnAt:
                      description: "Respawn time, int32 ms since match start (0 = unknown / still held)."
                      maximum: 2.147483647e+09
                      minimum: -2.147483648e+09
                      type: integer
                    takenAt:
                      description: "Take time, int32 ms since match start."
                      maximum: 2.147483647e+09
                      minimum: -2.147483648e+09
                      type: integer
                    takenBy:
                      description: "Picker display name."
                      type: string
                    team:
                      description: "Team name."
                      type: string
                  required:
                    - availableFrom
                  type: object
                type:
                  - "null"
                  - array
              x:
                description: "X coordinate, map units."
                type: number
              "y":
                description: "Y coordinate, map units."
                type: number
              z:
                description: "Z coordinate, map units."
                type: number
            required:
              - name
              - kind
              - entNum
              - x
              - "y"
              - z
              - phases
            type: object
          type:
            - "null"
            - array
      required:
        # timeUnit is flattened INTO the governed /items timeline response
        # (always echoed) but carried as an /artifacts envelope SIBLING of the
        # `items` key (required on that branch, not inside this object), so
        # within this reused schema it stays optional-but-declared.
        - items
      type: object
    ItemsSummary:
      additionalProperties: false
      properties:
        timeUnit: { type: string, enum: [ms], description: "Constant \"ms\" self-description echo — in the v57 pure-ms model every time value is int32 milliseconds." }
        items:
          description: "Per-item take summaries."
          items:
            additionalProperties: false
            properties:
              byPlayer:
                description: "Take counts by player, keyed by player name."
                additionalProperties:
                  type: integer
                type: object
              entNum:
                description: "Server entity/edict number — stable id within a match."
                type: integer
              firstTake:
                description: "First in-match take (null if never taken)."
                additionalProperties: false
                properties:
                  time:
                    type: integer
                    description: First-take time, int32 ms.
                  takenBy:
                    description: "Picker display name."
                    type: string
                  team:
                    description: "Team name."
                    type: string
                required:
                  - time
                type:
                  - "null"
                  - object
              kind:
                description: "Item kind — compact vocabulary (rl/lg/ra/mh/quad/…)."
                type: string
              loc:
                description: "Nearest named location from the map .loc file."
                type: string
              name:
                description: "Name / display label."
                type: string
              takenCount:
                description: "Number of in-match takes."
                type: integer
            required:
              - name
              - kind
              - entNum
              - takenCount
            type: object
          type:
            - "null"
            - array
      required:
        - timeUnit
        - items
      type: object
    AirgibEvent:
      additionalProperties: false
      properties:
        attacker:
          description: "Attacker name (\"world\" for an environmental / non-player inflictor)."
          type: string
        attackerTeam:
          description: "Attacker team name."
          type: string
        attackerUserID:
          description: "Attacker UserID (Hub viewer link), resolved at the hit's own instant — the connection that player held then, not their demo-wide id."
          type: integer
        damage:
          description: "Raw rocket damage (unbound, incl. overkill)."
          type: integer
        height:
          description: "Victim feet above the floor at the hit, map units."
          type: number
        heightAboveAttacker:
          description: "Victim origin minus shooter origin at the hit, map units (negative = below)."
          type: number
        lethal:
          description: "The hit killed the victim (matching rocket frag within a short window)."
          type: boolean
        loc:
          description: "Nearest named location from the map .loc file."
          type: string
        time:
          description: "Event/sample time, int32 ms since match start."
          maximum: 2.147483647e+09
          minimum: -2.147483648e+09
          type: integer
        victim:
          description: "Victim (killed player) name."
          type: string
        victimTeam:
          description: "Victim team name."
          type: string
        victimUserID:
          description: "Victim UserID (Hub viewer link), resolved at the hit's own instant — the connection that player held then, not their demo-wide id."
          type: integer
      required:
        - time
        - attacker
        - victim
        - height
        - damage
      type: object
    TopWindows:
      type: object
      additionalProperties: false
      required: [scoredBy, mode, limit, perPlayer, measured, windows]
      # EXACTLY ONE knob accompanies mode, machine-enforced: a fixed envelope
      # carries windowMs and never gapMs, a gap envelope the mirror image. The
      # branches discriminate on the mode const, so exactly one can match.
      # TestTopWindowsEnvelopeExactlyOneKnob pins the invalid combinations.
      oneOf:
        - properties:
            mode:
              const: fixed
              description: Branch discriminator — the fixed envelope.
          required: [windowMs]
          not: { required: [gapMs] }
        - properties:
            mode:
              const: gap
              description: Branch discriminator — the gap envelope.
          required: [gapMs]
          not: { required: [windowMs] }
      properties:
        timeUnit: { $ref: "#/components/schemas/TimeUnit" }
        scoredBy: { $ref: "#/components/schemas/ScoringRule" }
        measured: { $ref: "#/components/schemas/MeasuredSources" }
        dmg: { $ref: "#/components/schemas/IntervalDmgFamily" }
        boundedMode: { $ref: "#/components/schemas/IntervalBoundedMode" }
        mode:
          type: string
          enum: [fixed, gap]
          description: >
            The segmentation these windows were cut by, echoed on EVERY
            response — fixed ones included — so a consumer never has to infer
            it from which knob field happens to be present. Exactly one of
            `windowMs` (fixed) and `gapMs` (gap) accompanies it.
        windowMs:
          type: integer
          format: int32
          description: >
            Window length in ms (echoed). FIXED RESPONSES ONLY: it is absent
            under `mode=gap`, where windows are as long as their events and a
            fixed length would be a lie.
        gapMs:
          type: integer
          format: int32
          description: >
            The inter-event gap these clusters were cut at, echoed. GAP
            RESPONSES ONLY (it has no meaning, and no default, under
            `mode=fixed`).
        limit:
          type: integer
          description: >
            The total cap as it was applied, echoed after defaulting. An
            out-of-range request is REJECTED with a 400, not clamped, so this
            only ever differs from what you sent when you sent nothing (you get
            the default 10). A NEGATIVE value is echoed verbatim and is not a
            cap — it is the "uncapped" spelling.
        perPlayer:
          type: integer
          description: >
            The per-player cap as it was applied, echoed. `0` (the default,
            from omitting the parameter) and any negative value both mean
            uncapped; neither is a cap.
        windows:
          type: array
          description: Ranked windows, best first. Never null.
          items: { $ref: "#/components/schemas/TopWindow" }
    TopWindow:
      type: object
      required: [rank, player, start, end, score]
      properties:
        rank: { type: integer, description: "1-based position in this response." }
        player: { type: string, description: "The player who scored in this window." }
        team: { type: string, description: "Their team at match end; absent in a demo with no team labels." }
        start: { type: integer, format: int32, description: "Match-relative ms." }
        end:
          type: integer
          format: int32
          description: >
            Under `mode=fixed`, `start + windowMs` (the identity holds over
            REST because `windowMs` is capped at the match duration; the view
            clamps `end` at the int32 ceiling for in-process callers, who face
            no such cap). Under `mode=gap` it is the run's LAST scoring event,
            so the row is as long as the stretch was and `end == start` on a
            single-event run.
        score:
          type: integer
          description: >
            The metric total over the window — the value that selected and
            ranked it. WITHOUT a `weapons` filter it equals the same-named stat
            below exactly (telefrags and stomps score just as they fold); with
            one it is a SUBSET of that stat. The envelope's `scoredBy` says
            exactly what produced it.
      allOf:
        - $ref: "#/components/schemas/IntervalStats"
    TopKills:
      type: object
      additionalProperties: false
      required: [gapMs, contestedMs, limit, measured, kills]
      properties:
        timeUnit: { $ref: "#/components/schemas/TimeUnit" }
        measured: { $ref: "#/components/schemas/MeasuredSources" }
        dmg: { $ref: "#/components/schemas/IntervalDmgFamily" }
        boundedMode: { $ref: "#/components/schemas/IntervalBoundedMode" }
        gapMs:
          type: integer
          format: int32
          description: >
            The capture gap as it was applied, echoed after defaulting — it is
            what every `damage` / `hits` / `spanMs` / `maxGapMs` below means.
        contestedMs:
          type: integer
          format: int32
          description: The returnDamage window as it was applied, echoed after defaulting.
        limit:
          type: integer
          description: >
            The cap as it was applied, echoed after defaulting. An
            out-of-range request is REJECTED with a 400, not clamped, so this
            only ever differs from what you sent when you sent nothing (you
            get the default 20). A NEGATIVE value is echoed verbatim and is
            not a cap — it is the "uncapped" spelling.
        kills:
          type: array
          description: Ranked bursts, hardest first. Never null.
          items: { $ref: "#/components/schemas/TopKill" }
    TopKill:
      type: object
      additionalProperties: false
      required: [rank, killer, victim, time, weapon, damage, hits, spanMs, maxGapMs, returnDamage]
      description: >
        One kill burst. Also the row shape of `/overview`'s `topKills`, which
        carries this same list at the documented defaults.
      properties:
        rank: { type: integer, description: "1-based position in this response." }
        killer: { type: string, description: "Who made the kill; the frag log's name." }
        victim: { type: string, description: "Who died." }
        team: { type: string, description: "The KILLER's team at match end; absent in a demo with no team labels." }
        time:
          type: integer
          format: int32
          description: "The kill instant, match-relative ms — also the run's last hit."
        weapon:
          type: string
          description: "The killing weapon, which IS the burst's weapon (see the endpoint description)."
        damage:
          type: integer
          description: >
            The run's summed damage in the envelope's `dmg` family — the value
            that ranked this row.
        hits: { type: integer, description: "How many damage events the run holds." }
        spanMs:
          type: integer
          format: int32
          description: >
            `time` minus the run's first hit — the DISPLAY figure ("291 dmg in
            1.7 s"). Not a valid rule for narrowing to a tighter gap; use
            `maxGapMs`.
        maxGapMs:
          type: integer
          format: int32
          description: >
            The largest gap between consecutive hits in the run, `0` when
            `hits` is 1. This is the EXACT client-side narrowing filter:
            a kept row (`maxGapMs <= g`) carries its gap-`g` value verbatim;
            an over-merged row is dropped by the filter rather than
            truncated (request `gapMs=g` for the exact tighter walk).
        victimWep:
          type: string
          description: >
            The victim's weapon class (`sg`|`mid`|`lg`|`rl`|`both`) at the
            killing hit, straight from that damage event — the same field
            `/damage` reports. Absent when the wire carried none.
        returnDamage:
          type: integer
          description: >
            What the victim dealt back to this killer over the `contestedMs`
            window before the kill: any weapon, same damage family. A value,
            not a flag — you own the threshold that calls a kill contested.
    ScoringRule:
      type: object
      additionalProperties: false
      required: [metric]
      description: >
        The scoring rule that produced every row's `score`. It sits on the
        ENVELOPE, not on each window: one query means one rule, so a per-row
        copy would repeat the same object up to `limit` times. It is also the
        only place the metric is echoed.
      properties:
        metric: { type: string, description: "The canonicalised metric that produced `score`." }
        weapons:
          type: array
          items: { type: string }
          description: Weapon tokens the scoring events were restricted to, if any (lowercased, sorted).
        dmg:
          type: string
          description: Damage family used, for the damage metrics.
    IntervalDmgFamily:
      type: string
      enum: [raw, bounded]
      description: |
        The damage family every `damageGiven` / `damageTaken` /
        `damageGivenTeam` / `damageGivenSelf` / `damageByWeapon` in this
        response was computed in, echoed exactly as `/damage` echoes its own
        `dmg`.

        It applies under EVERY metric, not only the damage ones — the stats
        block reports damage whichever metric selected the interval — so on
        `/top-windows` with `metric=frags` this is the only place the family
        is named (`scoredBy.dmg` carries the same value when the metric is a
        damage one, from the same single resolution).

        **Defaults.** The REST default is `bounded`, matching `/damage`; the
        in-process view default is `raw`. A DEFAULTED bounded on a demo with
        no bounded family falls back to `raw` and says so here, while an
        EXPLICIT `dmg=bounded` on such a demo is a 422 `bounded_unavailable`.

        Absent only on a demo with no damage stream at all, where
        `measured.damage` is `false` anyway.
    IntervalBoundedMode:
      type: string
      description: |
        Whether this demo's bounded damage family was reconstructed —
        `standard`, or a `skipped:*` reason when the server mode rewrites the
        take in ways the wire does not expose. Same field, same values, as
        `/damage`'s `boundedMode` (see the `Damage` schema for the enumerated
        reasons); it is the field that NAMES the state, so read it rather than
        inferring from `dmg`.

        Absent only on a demo with no damage stream at all.
    MeasuredSources:
      type: object
      additionalProperties: false
      required: [frags, damage, shots, locs, items, liveness]
      description: |
        Which underlying streams THIS DEMO carries. **Measuredness is read from
        here and never from a field's absence** — every numeric field of the
        stats block is emitted even at zero, so `damageGiven: 0` alone cannot
        tell a player who dealt none from a demo with no damage stream at all.

        `false` means the source is absent for the whole demo. `true` does not
        promise a non-zero row: within a measured source a zero is a
        measurement, and an absent MAP KEY (`byWeapon`, `victims`,
        `damageByWeapon`) means "none of that kind".
      properties:
        frags:
          type: boolean
          description: |
            Whether KILL ATTRIBUTION was observable — the stored demo-global
            verdict `/frags` publishes as `killsMeasured`, NOT merely "a frag
            log exists". It is `false` on a demo whose log is empty while the
            scoreboard shows deaths (every obituary went unmatched), which is
            precisely the case where the zeros below look like measurements.
            One verdict, decided once by the analyzer, so this flag and
            `/frags`' `killsMeasured` cannot drift.

            Gates `kills`, `deaths`, `teamKills`, `suicides`, `byWeapon`,
            `mainWeapon`, `victims`, `eventLocs`, and on `/lives` `killedBy` /
            `deathWeapon`.
        damage:
          type: boolean
          description: >
            Gates `damageGiven`, `damageTaken`, `damageGivenTeam`,
            `damageGivenSelf`, `damageByWeapon`.
        shots: { type: boolean, description: "Gates `shots` and `hits`." }
        locs:
          type: boolean
          description: |
            Gates `locs`, `eventLocs` and `/lives`' `spawnLoc` / `deathLoc`.
            True only when the demo has both a loc table and player loc streams.

            DEMO-WIDE, and computed as "at least one player has a loc stream".
            On a POV recording, where only players inside the recorder's PVS
            are written to the wire, an UNOBSERVED player's row can therefore
            carry empty `locs` under `measured.locs: true`. Read that as
            "unobserved", not as "spent no time anywhere" — a live player
            always occupied somewhere. Not reachable on server-side recordings,
            which write every player.
        items:
          type: boolean
          description: >
            Gates `/lives`' `itemsTaken` (where it is the same signal as
            `itemsTaken: null`). Always present on `/top-windows` too, which
            emits no item field, so one block covers both responses.
        liveness:
          type: boolean
          description: |
            Whether the spawn-to-death segmentation itself was measurable — at
            least one player carries liveness intervals. It distinguishes
            "liveness was not measurable on this demo" from "measured, and this
            player was never alive", which an empty `lives` array conflated.

            `/lives` 422s `lives_unavailable` when it is `false`, so a caller
            only ever observes `false` here on `/top-windows`, which does not
            need it. The flag is present on both responses because the block
            keeps ONE shape across them.
    Lives:
      type: object
      additionalProperties: false
      required: [measured, lives]
      properties:
        timeUnit: { $ref: "#/components/schemas/TimeUnit" }
        measured: { $ref: "#/components/schemas/MeasuredSources" }
        dmg: { $ref: "#/components/schemas/IntervalDmgFamily" }
        boundedMode: { $ref: "#/components/schemas/IntervalBoundedMode" }
        lives:
          type: array
          description: Every life, time-ordered per player, players in name order. Never null.
          items: { $ref: "#/components/schemas/Life" }
    Life:
      type: object
      required: [player, index, start, end, attrStart, attrEnd, endReason, itemsTaken]
      properties:
        player: { type: string, description: "The player whose life this is." }
        team: { type: string, description: "Their team at match end; absent in a demo with no team labels." }
        index: { type: integer, description: "0-based, per player, in time order." }
        start: { type: integer, format: int32, description: "Spawn, match-relative ms." }
        end: { type: integer, format: int32, description: "Death, or match end." }
        attrStart:
          type: integer
          format: int32
          description: >
            Start of the ATTRIBUTION WINDOW — the span every event field in the
            stats block was counted over. Always present: `0` is the legitimate
            value on every player's first life, so an omit-on-zero rule would
            delete the commonest one.
          maximum: 2.147483647e+09
          minimum: -2.147483648e+09
        attrEnd:
          type: integer
          format: int32
          description: |
            End of the attribution window. The window is the life PLUS the
            dead gap that follows it (and, for the first and last lives, out
            to the match edges), so that a posthumous rocket counts for the
            life that fired it.

            **It is wider than `durationMs`**, which measures `end − start` —
            ALIVE time — and this is the one asymmetry in the row: a
            per-second rate computed from a count over `durationMs` reads
            HIGH — slightly when summed across a whole match, by tens of
            percent on a single row where a short life is followed by a long
            dead gap — while `attrEnd − attrStart` is the span the counts
            actually cover.

            **Truncated recordings.** On a POV or otherwise truncated
            recording the final life's window can contain events its player
            earned while provably still present: liveness is clipped using the
            player's OWN samples, spawns and deaths, and a killer-side
            obituary is deliberately not counted as presence evidence.

            **Tiling.** One player's windows tile the match end to end
            (half-open at exactly one side apiece), so no instant belongs to
            two of them and none to none. On a demo with no match window the
            last life's `attrEnd` is the int32 ceiling: there is no match end
            to bound it, and everything after the last life belongs to it.
          maximum: 2.147483647e+09
          minimum: -2.147483648e+09
        endReason:
          type: string
          enum: [death, matchEnd, leftGame]
          description: >
            How the life ended. `death` — a recorded death. `matchEnd` — the
            player was still alive. `leftGame` — the player's position track
            ended (they quit, or the recording lost them); liveness is clipped
            to observed presence, so on a truncated recording this means "the
            recording stopped seeing them", not "they stopped playing".
            Always present: an absent `killedBy` used to conflate all three.
        spawnLoc: { type: string, description: "Loc the life began in; absent without loc data." }
        deathLoc: { type: string, description: "Loc the life ended in." }
        killedBy:
          type: string
          description: >
            Who ended it. Absent when `endReason` is not `death`, and also on a
            death seen only by the health/DF_DEAD detectors with no obituary
            naming it — read `endReason`, not this field, to tell those apart.
            That second state is REACHABLE (the two detectors exist precisely
            because each sees deaths the other misses) but is not currently
            observed: across the 42 cached corpus demos, 0 of the 11364
            death-ended lives — of 11558 lives in total — lack an obituary.
        deathWeapon: { type: string, description: "Weapon or cause that ended the life." }
        itemsTaken:
          type: [array, "null"]
          description: >
            Every item picked up in the life's attribution window, in time
            order. **`null` means the demo carries no item timeline** (the same
            signal as `measured.items: false`); `[]` means it has one and this
            life took nothing. With `summary=true` it is `null` on every row
            regardless of the demo — there, only `measured.items` speaks.
          items: { $ref: "#/components/schemas/LifeItem" }
        weaponsHeld:
          type: array
          items: { type: string }
          description: >
            Which of rl/lg/gl/ssg/sng the player held at any point while ALIVE
            in this life, in that order. Clipped to the alive interval, not to
            the attribution window: KTX does not clear the weapon bits on death,
            so a corpse still reads as armed. Absent means "held none of them",
            not "unknown".
      allOf:
        - $ref: "#/components/schemas/IntervalStats"
    LifeItem:
      type: object
      additionalProperties: false
      required: [item, kind, time]
      properties:
        item: { type: string, description: "Instance name, e.g. `ra_1` — the `name` used by /items." }
        kind: { type: string, description: "Item kind: `ra`, `mh`, `quad`, `rl`, ..." }
        time: { type: integer, format: int32, description: "Pickup, match-relative ms." }
    IntervalStats:
      type: object
      description: |
        The per-interval stats block, shared by `/top-windows` and `/lives` so
        one shape covers every segmentation. All times int32 ms.

        Every NUMERIC field below is always present, including a measured zero.
        Read the envelope's `measured` block to tell "this demo has no shot
        stream" from "this player fired nothing" — never a field's absence. The
        maps and lists are omitted when empty, where absence means "nothing to
        list".
      required:
        - durationMs
        - kills
        - deaths
        - teamKills
        - suicides
        - damageGiven
        - damageTaken
        - damageGivenTeam
        - damageGivenSelf
        - shots
        - hits
      properties:
        durationMs:
          type: integer
          format: int32
          description: |
            end − start.

            For a life this is ALIVE TIME ONLY, while every event field below
            covers the wider attribution window (the life plus the dead gap
            that follows it, so a posthumous rocket counts for the life that
            fired it) — the row's `attrStart` / `attrEnd` carry that window,
            so divide by `attrEnd − attrStart` for an exact rate. A rate
            derived from a count and this duration therefore reads HIGH:
            slightly when summed across a whole match, by tens of percent on a
            single row where a short life is followed by a long dead gap.

            For a top window the two coincide and there is no separate window
            to publish.
        kills: { type: integer, description: "Enemy kills. Suicides and teamkills excluded." }
        deaths:
          type: integer
          description: |
            Death rows from the FRAG LOG naming this player as victim inside
            the attribution window. Sums, over an unfiltered `/lives`
            response, to that player's rows in `/frags`' `frags[]` — not
            necessarily to `/frags`' `byPlayer` scoreboard, which counts
            deaths the log never recorded.

            **Not capped at 1 on `/lives`, and NOT a flag for `endReason`:**

            - it can be 0 on a life whose `endReason` is `death` (the death
              was seen only by the `DF_DEAD` / `STAT_HEALTH` detectors and no
              obituary named it — four such rows on
              `wipeout_3on2_red_vs_blue[q3dm6qw]`);
            - it can EXCEED 1, because a life carries both the death that
              ended it and any death recorded during the dead gap that
              followed (the KTX `dtTELE2` deflection — measured across 11558
              corpus lives: 12 rows with 2 deaths, one with 3).
        teamKills: { type: integer, description: "Teammates killed. Never scores, but shown for context." }
        suicides: { type: integer, description: "Self-inflicted deaths, a subset of `deaths`." }
        byWeapon:
          type: object
          additionalProperties: { type: integer }
          description: Kills by weapon.
        damageGiven:
          type: integer
          description: >
            To enemies. INCLUDES telefrag / stomp value, exactly as `/damage`
            does, so per-interval damage reconciles against that endpoint.
        damageTaken:
          type: integer
          description: >
            From ALL sources — enemy, team, self, world — and including telefrag
            / stomp value.
        damageGivenTeam: { type: integer, description: "Damage dealt to teammates." }
        damageGivenSelf: { type: integer, description: "Self damage, e.g. own rocket splash." }
        damageByWeapon:
          type: object
          additionalProperties: { type: integer }
          description: >
            Enemy damage given, by weapon. EXCLUDES telefrags and stomps (they
            carry no weapon), exactly as `/damage`'s `byWeapon` does, so this
            sums to at most `damageGiven` rather than to it.
        shots: { type: integer, description: "Weapon fires in the interval." }
        hits: { type: integer, description: "Fires that connected." }
        mainWeapon:
          type: string
          description: Weapon with the most kills; ties broken by name so the value is stable.
        victims:
          type: object
          additionalProperties: { type: integer }
          description: Kills per victim. Sums to `kills`.
        locs:
          type: array
          items: { $ref: "#/components/schemas/IntervalLoc" }
          description: >
            Where the TIME went — top loc residences by dwell. Absent when the
            demo carries no loc data; that never fails the request.
        eventLocs:
          type: array
          items: { $ref: "#/components/schemas/IntervalLoc" }
          description: >
            Where the KILLS happened. Routinely disagrees with `locs`, and the
            disagreement is the interesting part.
    IntervalLoc:
      type: object
      additionalProperties: false
      required: [loc]
      properties:
        loc: { type: string, description: "Loc name." }
        ms: { type: integer, format: int32, description: "Dwell, on `locs`." }
        count: { type: integer, description: "Event count, on `eventLocs`." }
    RegionControl:
      additionalProperties: false
      properties:
        timeUnit: { type: string, enum: [ms], description: "Constant \"ms\" self-description echo — in the v57 pure-ms model every time value is int32 milliseconds." }
        bucketStates:
          description: "Per-bucket per-region control codes (one ASCII char each) at the requested windowMs. Each bucket is a point-sample classification: the state of every player's last position at bucket-start. It shares the exact walk's classification point and therefore its liveness and staleness bounds (schema v64) — a dead player, a player whose last sample is more than 250 ms old, and a player past their end-of-track all contribute nothing, so a bucket with no qualifying player reads '_' (empty)."
          additionalProperties:
            type: string
          type: object
        regions:
          description: "Named control regions."
          items:
            additionalProperties: false
            properties:
              centroidX:
                description: "Region centroid X, map units."
                type: number
              centroidY:
                description: "Region centroid Y, map units."
                type: number
              locs:
                description: "Location nodes / membership list."
                items:
                  type: string
                type:
                  - "null"
                  - array
              name:
                description: "Region name."
                type: string
              points:
                description: "Rendering anchors (MapLocation points) for the region overlay."
                items:
                  additionalProperties: false
                  properties:
                    name:
                      description: "Region name."
                      type: string
                    x:
                      description: "X coordinate, map units."
                      type: number
                    "y":
                      description: "Y coordinate, map units."
                      type: number
                    z:
                      description: "Z coordinate, map units."
                      type: number
                  required:
                    - x
                    - "y"
                    - z
                    - name
                  type: object
                type:
                  - "null"
                  - array
            required:
              - name
              - locs
              - points
              - centroidX
              - centroidY
            type: object
          type:
            - "null"
            - array
        stats:
          description: "Per-region match-aggregate control shares. Computed as the exact time-weighted integral over the native position samples (no grid), independent of the request's windowMs, so the aggregate is not a sampling artifact of a display window. windowMs affects only bucketStates."
          additionalProperties:
            additionalProperties: false
            properties:
              byPlayer:
                description: "Per-player presence in this region, keyed by player name. Values are integer milliseconds of ALIVE, OBSERVED presence (exact time-weighted, independent of the display windowMs): dead time is excluded, sample-and-hold expires after 250 ms so unobserved holes are credited to nobody, and a player stops contributing at their end-of-track rather than holding their last region to match end (schema v64)."
                additionalProperties:
                  additionalProperties: false
                  properties:
                    armed:
                      description: "Milliseconds of alive, observed presence while carrying RL or LG."
                      type: integer
                    team:
                      description: "Team name."
                      type: string
                    unarmed:
                      description: "Milliseconds of alive, observed presence without RL/LG."
                      type: integer
                  required:
                    - team
                    - armed
                    - unarmed
                  type: object
                type: object
              contested:
                description: "Share of time contested (percent)."
                type: number
              empty:
                description: "Share of time empty — no living players (percent)."
                type: number
              teamAControl:
                description: "Share of time under team-A control (percent)."
                type: number
              teamAWeakControl:
                description: "Share of time under team-A weak control (percent)."
                type: number
              teamBControl:
                description: "Share of time under team-B control (percent)."
                type: number
              teamBWeakControl:
                description: "Share of time under team-B weak control (percent)."
                type: number
              weakContested:
                description: "Share of time weakly contested (percent)."
                type: number
            required:
              - teamAControl
              - teamAWeakControl
              - contested
              - weakContested
              - empty
              - teamBWeakControl
              - teamBControl
            type: object
          type: object
        teamA:
          description: "Team name mapped to control slot \"A\"."
          type: string
        teamB:
          description: "Team name mapped to control slot \"B\"."
          type: string
      required:
        - timeUnit
      type: object
    ProjectileStreams:
      additionalProperties: false
      description: >
        Columnar projectile (rl/gl, or nails) flight streams — parallel arrays
        indexed together, one entry per tracked projectile. Columns use a
        spawn/end prefix family: `s`,`sx`,`sy`,`sz` are the spawn (fire) time +
        origin and `e`,`ex`,`ey`,`ez` the end (impact/despawn) time + origin.
        This `s*`/`e*` prefix is a column-family grouping (a time column plus
        its x/y/z), unrelated to result.Interval's terse per-row `s`/`e`
        interval-bound keys.
      properties:
        e:
          description: "End (impact/despawn) time column, int32 ms — parallel array."
          items:
            maximum: 2.147483647e+09
            minimum: -2.147483648e+09
            type: integer
          type:
            - "null"
            - array
        ex:
          description: "End origin X column, map units."
          items:
            type: number
          type:
            - "null"
            - array
        ey:
          description: "End origin Y column, map units."
          items:
            type: number
          type:
            - "null"
            - array
        ez:
          description: "End origin Z column, map units."
          items:
            type: number
          type:
            - "null"
            - array
        s:
          description: "Spawn (fire) time column, int32 ms — parallel array."
          items:
            maximum: 2.147483647e+09
            minimum: -2.147483648e+09
            type: integer
          type:
            - "null"
            - array
        sx:
          description: "Spawn origin X column, map units."
          items:
            type: number
          type:
            - "null"
            - array
        sy:
          description: "Spawn origin Y column, map units."
          items:
            type: number
          type:
            - "null"
            - array
        sz:
          description: "Spawn origin Z column, map units."
          items:
            type: number
          type:
            - "null"
            - array
        w:
          description: "Weapon column (parallel array)."
          items:
            type: string
          type:
            - "null"
            - array
      required:
        - w
        - s
        - e
        - sx
        - sy
        - sz
        - ex
        - ey
        - ez
      type: object
    BeamStreams:
      additionalProperties: false
      description: >
        Columnar LG beam streams — parallel arrays indexed together, one entry
        per beam. Columns use the same spawn/end prefix family as
        ProjectileStreams: `sx`,`sy`,`sz` are the beam start origin, `ex`,`ey`,`ez`
        the beam end origin, and `t` the fire time. The `s*`/`e*` prefix is a
        column-family grouping, unrelated to result.Interval's terse per-row
        `s`/`e` interval-bound keys.
      properties:
        ex:
          description: "Beam end X column, map units."
          items:
            type: number
          type:
            - "null"
            - array
        ey:
          description: "Beam end Y column, map units."
          items:
            type: number
          type:
            - "null"
            - array
        ez:
          description: "Beam end Z column, map units."
          items:
            type: number
          type:
            - "null"
            - array
        sx:
          description: "Beam start X column, map units."
          items:
            type: number
          type:
            - "null"
            - array
        sy:
          description: "Beam start Y column, map units."
          items:
            type: number
          type:
            - "null"
            - array
        sz:
          description: "Beam start Z column, map units."
          items:
            type: number
          type:
            - "null"
            - array
        t:
          description: "Fire-time column, int32 ms — parallel array."
          items:
            maximum: 2.147483647e+09
            minimum: -2.147483648e+09
            type: integer
          type:
            - "null"
            - array
      required:
        - t
        - sx
        - sy
        - sz
        - ex
        - ey
        - ez
      type: object
    PointEffectStreams:
      additionalProperties: false
      description: >
        Columnar point-effect temp-entity stream — parallel arrays indexed
        together, one entry per svc_temp_entity broadcast. `ty` is the raw
        TE code; decode via the response envelope's `types` legend. `c` is
        the leading count byte carried only by the counted types
        (gunshot/blood) and 0 elsewhere — its packaging varies per server
        generation and mod (see mvd-reader/MVD_FORMAT.md svc_temp_entity),
        so do not read it as a damage magnitude; /damage is the damage
        answer.
      properties:
        t:
          description: "Effect-time column, int32 ms — parallel array."
          items:
            maximum: 2.147483647e+09
            minimum: -2.147483648e+09
            type: integer
          type:
            - "null"
            - array
        ty:
          description: "Raw TE type-code column (see the envelope `types` legend)."
          items:
            maximum: 2.147483647e+09
            minimum: -2.147483648e+09
            type: integer
          type:
            - "null"
            - array
        c:
          description: "Count-byte column; 0 when the type carries none."
          items:
            maximum: 2.147483647e+09
            minimum: -2.147483648e+09
            type: integer
          type:
            - "null"
            - array
        x:
          description: "Effect origin X column, map units."
          items:
            type: number
          type:
            - "null"
            - array
        y:
          description: "Effect origin Y column, map units."
          items:
            type: number
          type:
            - "null"
            - array
        z:
          description: "Effect origin Z column, map units."
          items:
            type: number
          type:
            - "null"
            - array
      required:
        - t
        - ty
        - c
        - x
        - y
        - z
      type: object
    LosTrack:
      additionalProperties: false
      properties:
        intervals:
          description: >
            Half-open [s, e) ms spans the looker saw the other player. Per-row
            keys stay terse (dense array).
          items:
            additionalProperties: false
            properties:
              e:
                maximum: 2.147483647e+09
                minimum: -2.147483648e+09
                type: integer
                description: Interval end, int32 ms (exclusive).
              s:
                maximum: 2.147483647e+09
                minimum: -2.147483648e+09
                type: integer
                description: Interval start, int32 ms (inclusive).
            required:
              - s
              - e
            type: object
          type: array
        other:
          maximum: 32767
          minimum: -32768
          type: integer
          description: Index into Streams.Players (the seen player).
      required:
        - other
        - intervals
      type: object
    MapEntities:
      additionalProperties: false
      properties:
        entities:
          description: "Static map entities (item spawns, spawnpoints, teleports, buttons)."
          items:
            additionalProperties: false
            properties:
              bounds:
                description: "Brush-entity volume in world coords."
                additionalProperties: false
                properties:
                  max:
                    description: "Maximum corner [x,y,z], world coords."
                    items:
                      type: number
                    maxItems: 3
                    minItems: 3
                    type: array
                  min:
                    description: "Minimum corner [x,y,z], world coords."
                    items:
                      type: number
                    maxItems: 3
                    minItems: 3
                    type: array
                required:
                  - min
                  - max
                type:
                  - "null"
                  - object
              class:
                description: "Raw BSP classname."
                type: string
              kind:
                description: "Item kind (items only)."
                type: string
              loc:
                description: "Nearest named location from the map .loc file."
                type: string
              name:
                description: "Loc-based label (disambiguated)."
                type: string
              spawnflags:
                description: "BSP spawnflags bitfield."
                type: integer
              target:
                description: "teleportSrc → destination targetname."
                type: string
              targetName:
                description: "teleportDst → its own targetname."
                type: string
              type:
                description: "Entity type (\"item\"/\"spawn\"/\"teleportDst\"/\"teleportSrc\"/\"button\"/\"door\")."
                type: string
              x:
                description: "X coordinate, map units."
                type: number
              "y":
                description: "Y coordinate, map units."
                type: number
              z:
                description: "Z coordinate, map units."
                type: number
            required:
              - type
              - class
              - name
              - x
              - "y"
              - z
            type: object
          type:
            - "null"
            - array
        map:
          description: "Map name."
          type: string
      required:
        - map
        - entities
      type: object
    TimelineAnalysis:
      additionalProperties: false
      properties:
        airgibs:
          description: "Airborne rocket-hit highlights, sorted by height descending."
          items:
            additionalProperties: false
            properties:
              attacker:
                description: "Attacker name (\"world\" for an environmental / non-player inflictor)."
                type: string
              attackerTeam:
                description: "Attacker team name."
                type: string
              attackerUserID:
                description: "Attacker UserID (Hub viewer link), resolved at the hit's own instant — the connection that player held then, not their demo-wide id."
                type: integer
              damage:
                description: "Raw rocket damage (unbound, incl. overkill)."
                type: integer
              height:
                description: "Victim feet above the floor at the hit, map units."
                type: number
              heightAboveAttacker:
                description: "Victim origin minus shooter origin at the hit, map units (negative = below)."
                type: number
              lethal:
                description: "The hit killed the victim (matching rocket frag within a short window)."
                type: boolean
              loc:
                description: "Nearest named location from the map .loc file."
                type: string
              time:
                description: "Event/sample time, int32 ms since match start."
                maximum: 2.147483647e+09
                minimum: -2.147483648e+09
                type: integer
              victim:
                description: "Victim (killed player) name."
                type: string
              victimTeam:
                description: "Victim team name."
                type: string
              victimUserID:
                description: "Victim UserID (Hub viewer link), resolved at the hit's own instant — the connection that player held then, not their demo-wide id."
                type: integer
            required:
              - time
              - attacker
              - victim
              - height
              - damage
            type: object
          type:
            - "null"
            - array
        deathEvents:
          description: "Per-player death events for the frags/deaths drill-down."
          items:
            additionalProperties: false
            properties:
              player:
                description: "Player display name."
                type: string
              team:
                description: "Team name."
                type: string
              time:
                description: "Event/sample time, int32 ms since match start."
                maximum: 2.147483647e+09
                minimum: -2.147483648e+09
                type: integer
            required:
              - time
              - player
              - team
            type: object
          type:
            - "null"
            - array
        demoMarkers:
          description: "Player-inserted `/demomark` bookmarks (KTX stufftext), in demo order. Not deduplicated."
          items:
            additionalProperties: false
            properties:
              label:
                description: "Optional argument tail of the mark (e.g. the HoonyMode \"0 round-07\" form); omitted for a plain mark."
                type: string
              playerName:
                description: "Marking player's name; empty when the mark was not slot-addressed."
                type: string
              playerSlot:
                description: "Marking player's wire slot — the only attribution channel; -1 when the mark was not slot-addressed."
                type: integer
              playerUserID:
                description: "Marking player's UserID (Hub viewer link); 0 when unattributed, and not a useful track target for a spectator's mark."
                type: integer
              spectator:
                description: "The mark was inserted by a spectator, not a player (KTX accepts /demomark from both); omitted when false."
                type: boolean
              team:
                description: "Marking player's team; usually empty for a spectator's mark."
                type: string
              time:
                description: "Event/sample time, int32 ms since match start; negative for a mark inserted during warmup (surfaced un-gated)."
                maximum: 2.147483647e+09
                minimum: -2.147483648e+09
                type: integer
            required:
              - time
              - playerName
              - playerSlot
              - playerUserID
              - team
            type: object
          type:
            - "null"
            - array
        fragEvents:
          description: "Frag events for the score timeline."
          items:
            additionalProperties: false
            properties:
              delta:
                description: "Frag-count change (+1 kill, -1 suicide/teamkill)."
                type: integer
              player:
                description: "Player display name."
                type: string
              team:
                description: "Team name."
                type: string
              time:
                description: "Event/sample time, int32 ms since match start."
                maximum: 2.147483647e+09
                minimum: -2.147483648e+09
                type: integer
            required:
              - time
              - player
              - team
              - delta
            type: object
          type:
            - "null"
            - array
        fragStreaks:
          description: "Top frag streaks (spawn-to-death runs) for Key Moments."
          items:
            additionalProperties: false
            properties:
              duration:
                description: "Duration, int32 ms."
                maximum: 2.147483647e+09
                minimum: -2.147483648e+09
                type: integer
              endTime:
                description: "End time, int32 ms since match start."
                maximum: 2.147483647e+09
                minimum: -2.147483648e+09
                type: integer
              ewep:
                description: "Effective weapon (most kills with)."
                type: string
              frags:
                description: "Frag count (net QW score)."
                type: integer
              playerName:
                description: "Player name."
                type: string
              playerUserID:
                description: "Player UserID (Hub viewer link), resolved at the run's own start — the connection that played it, not the player's demo-wide id."
                type: integer
              team:
                description: "Team name."
                type: string
              time:
                description: "Event/sample time, int32 ms since match start."
                maximum: 2.147483647e+09
                minimum: -2.147483648e+09
                type: integer
            required:
              - time
              - endTime
              - playerName
              - playerUserID
              - team
              - frags
              - duration
              - ewep
            type: object
          type:
            - "null"
            - array
        killEvents:
          description: "Per-player enemy-kill events for the frags/deaths drill-down."
          items:
            additionalProperties: false
            properties:
              player:
                description: "Player display name."
                type: string
              team:
                description: "Team name."
                type: string
              time:
                description: "Event/sample time, int32 ms since match start."
                maximum: 2.147483647e+09
                minimum: -2.147483648e+09
                type: integer
            required:
              - time
              - player
              - team
            type: object
          type:
            - "null"
            - array
        locTable:
          description: "Interned loc names; index 0 is the \"\" sentinel."
          items:
            type: string
          type:
            - "null"
            - array
        locationData:
          description: "Named .loc points for the map view."
          items:
            additionalProperties: false
            properties:
              name:
                description: "Loc name."
                type: string
              x:
                description: "X coordinate, map units."
                type: number
              "y":
                description: "Y coordinate, map units."
                type: number
              z:
                description: "Z coordinate, map units."
                type: number
            required:
              - x
              - "y"
              - z
              - name
            type: object
          type:
            - "null"
            - array
        playerUserIDs:
          description: "Player name → UserID map (Hub viewer links). Per connection SESSION, not per wire slot: for a player who reconnected this is the last session of theirs that had play — normally the id live at the end of the demo, though the ranking is by last play evidence, so an exact tie resolves to the lower slot rather than to the surviving connection (schema v66)."
          additionalProperties:
            type: integer
          type: object
        powerupEvents:
          description: "Powerup pickups for Key Moments."
          items:
            additionalProperties: false
            properties:
              duration:
                description: "Duration, int32 ms."
                maximum: 2.147483647e+09
                minimum: -2.147483648e+09
                type: integer
              endTime:
                description: "End time, int32 ms since match start."
                maximum: 2.147483647e+09
                minimum: -2.147483648e+09
                type: integer
              frags:
                description: "Frag count (net QW score)."
                type: integer
              playerName:
                description: "Player name."
                type: string
              playerSlot:
                description: "Player slot in the demo."
                type: integer
              playerUserID:
                description: "Player UserID (Hub viewer link)."
                type: integer
              powerupType:
                description: "Powerup type (\"quad\", \"pent\", or \"ring\")."
                type: string
              team:
                description: "Team name."
                type: string
              time:
                description: "Event/sample time, int32 ms since match start."
                maximum: 2.147483647e+09
                minimum: -2.147483648e+09
                type: integer
            required:
              - time
              - endTime
              - playerName
              - playerSlot
              - playerUserID
              - team
              - powerupType
              - duration
              - frags
            type: object
          type:
            - "null"
            - array
        regionControl:
          description: "Region-control statistics."
          additionalProperties: false
          properties:
            bucketStates:
              description: "Per-bucket per-region control codes (one ASCII char each)."
              additionalProperties:
                type: string
              type: object
            regions:
              description: "Named control regions."
              items:
                additionalProperties: false
                properties:
                  centroidX:
                    description: "Region centroid X, map units."
                    type: number
                  centroidY:
                    description: "Region centroid Y, map units."
                    type: number
                  locs:
                    description: "Location nodes / membership list."
                    items:
                      type: string
                    type:
                      - "null"
                      - array
                  name:
                    description: "Loc name."
                    type: string
                  points:
                    description: "Rendering anchors (MapLocation points) for the region overlay."
                    items:
                      additionalProperties: false
                      properties:
                        name:
                          description: "Loc name."
                          type: string
                        x:
                          description: "X coordinate, map units."
                          type: number
                        "y":
                          description: "Y coordinate, map units."
                          type: number
                        z:
                          description: "Z coordinate, map units."
                          type: number
                      required:
                        - x
                        - "y"
                        - z
                        - name
                      type: object
                    type:
                      - "null"
                      - array
                required:
                  - name
                  - locs
                  - points
                  - centroidX
                  - centroidY
                type: object
              type:
                - "null"
                - array
            stats:
              description: "Per-region match-aggregate control shares. Exact time-weighted integral over the native position samples (no grid)."
              additionalProperties:
                additionalProperties: false
                properties:
                  byPlayer:
                    description: "Per-player breakdown, keyed by player name. Values are integer milliseconds of ALIVE, OBSERVED presence (exact time-weighted; dead time, unobserved holes past 250 ms and time past a player's end-of-track are all excluded since schema v64)."
                    additionalProperties:
                      additionalProperties: false
                      properties:
                        armed:
                          description: "Milliseconds of alive, observed presence while carrying RL or LG."
                          type: integer
                        team:
                          description: "Team name."
                          type: string
                        unarmed:
                          description: "Milliseconds of alive, observed presence without RL/LG."
                          type: integer
                      required:
                        - team
                        - armed
                        - unarmed
                      type: object
                    type: object
                  contested:
                    description: "Share of time contested (percent)."
                    type: number
                  empty:
                    description: "Share of time empty — no living players (percent)."
                    type: number
                  teamAControl:
                    description: "Share of time under team-A control (percent)."
                    type: number
                  teamAWeakControl:
                    description: "Share of time under team-A weak control (percent)."
                    type: number
                  teamBControl:
                    description: "Share of time under team-B control (percent)."
                    type: number
                  teamBWeakControl:
                    description: "Share of time under team-B weak control (percent)."
                    type: number
                  weakContested:
                    description: "Share of time weakly contested (percent)."
                    type: number
                required:
                  - teamAControl
                  - teamAWeakControl
                  - contested
                  - weakContested
                  - empty
                  - teamBWeakControl
                  - teamBControl
                type: object
              type: object
            teamA:
              description: "Team name mapped to control slot \"A\"."
              type: string
            teamB:
              description: "Team name mapped to control slot \"B\"."
              type: string
          required:
            - regions
          type:
            - "null"
            - object
      type: object
    Match:
      additionalProperties: false
      properties:
        duration:
          description: "Duration, int32 ms."
          maximum: 2.147483647e+09
          minimum: -2.147483648e+09
          type: integer
        gameDir:
          description: "Game directory (mod)."
          type: string
        map:
          description: "Canonical map identifier — always the SHORT name (e1m2, dm2, aerowalk). Resolved from the KTX demoinfo map, else the serverinfo `map` key, else (schema v72) the `//finalscores` map, else the cleaned level title; `sources.map` says which. Join on this against searchGames rows, metadata serverinfo and map geometry keys."
          type: string
        mapTitle:
          description: "Level title announced in svc_serverdata (\"Castle of the Damned\" on e1m2), cleaned of a .bsp suffix and a trailing ` by <author>` hint. DISPLAY ONLY — never an identifier or file key. Omitted when svc_serverdata named no level."
          type: string
        mode:
          description: >
            KTX's own game-mode name (schema v72): the demoinfo block's
            (`duel`, `team`, `ffa`, `clan-arena`, …) or, where that block does
            not exist, the `//finalscores` one (`duel`, `team`, `FFA`,
            `Clan Arena`, …) — the two spellings are each source's own
            vocabulary, reported verbatim, so compare case-insensitively and
            read `sources.mode` for the source. Neither the serverinfo `mode`
            key (`1on1`, `4on4-midair`) nor the countdown table's `Duel`.
          type: string
        sources:
          description: >
            Where the resolved identity fields came from (schema v72), so a
            server-authoritative value is distinguishable from a
            pipeline-derived one. `//finalscores` never displaces a demoinfo
            value and never corrects a derived team row.
          additionalProperties: false
          properties:
            map:
              description: "`ktx` | `serverinfo` | `finalscores` | `levelTitle` (the degraded last resort — on a titled map that string is not a shortname)."
              type: string
            mode:
              description: "`ktx` (demoinfo) | `finalscores`."
              type: string
            teams:
              description: "`derived` (the per-player scoreboard summed by team, the normal case) | `finalscores` (the scoreboard produced no team rows, so the two sides come from KTX's end-of-match stuffcmd)."
              type: string
          type: object
        players:
          description: "Final scoreboard lines."
          items:
            additionalProperties: false
            properties:
              deaths:
                description: "Deaths (all causes)."
                type: integer
              frags:
                description: "Net QW frag score (svc_updatefrags scoreboard, frozen at match end)."
                type: integer
              kills:
                description: "Enemy kills (from the corrected frag log)."
                type: integer
              name:
                description: "Name / display label."
                type: string
              suicides:
                description: "Suicide deaths."
                type: integer
              team:
                description: "Team name."
                type: string
            required:
              - name
              - team
              - frags
              - kills
              - deaths
              - suicides
            type: object
          type:
            - "null"
            - array
        teams:
          description: "Per-team frag totals."
          items:
            additionalProperties: false
            properties:
              frags:
                description: "Net QW frag score (svc_updatefrags scoreboard, frozen at match end)."
                type: integer
              name:
                description: "Name / display label."
                type: string
            required:
              - name
              - frags
            type: object
          type:
            - "null"
            - array
      required:
        - map
        - gameDir
        - duration
        - players
        - sources
      type: object
    Messages:
      additionalProperties: false
      description: >
        /artifacts/messages body under the `messages` resultKey — the match
        message log (result.MessagesResult). The `messages` array is NOT
        chat-only: it interleaves `type:"frag"` obituary/notification lines
        with `type:"chat"` and `type:"teamsay"` lines, in match order.
      properties:
        messages:
          description: "Match message log — frag/chat/teamsay lines in match order."
          items:
            additionalProperties: false
            properties:
              message:
                description: "Chat text or frag description (ezQuake-markup-preserving)."
                type: string
              messageClean:
                description: "Same text with ezQuake markup stripped (plain text)."
                type: string
              player:
                description: "Player display name."
                type: string
              team:
                description: "Team name."
                type: string
              time:
                description: "Event/sample time, int32 ms since match start."
                maximum: 2.147483647e+09
                minimum: -2.147483648e+09
                type: integer
              type:
                description: "Type discriminator."
                type: string
              victim:
                description: "Victim (killed player) name."
                type: string
              weapon:
                description: "Weapon — lowercase KTX class name (axe/sg/ssg/ng/sng/gl/rl/lg)."
                type: string
            required:
              - time
              - type
              - player
              - team
              - message
            type: object
          type:
            - "null"
            - array
      required:
        - messages
      type: object
    Opening:
      additionalProperties: false
      properties:
        firstTakes:
          description: "First in-match take of each tracked item spawner."
          items:
            additionalProperties: false
            properties:
              entNum:
                description: "Server entity/edict number — stable id within a match."
                type: integer
              item:
                description: "Spawner item name (ItemTimeline naming, e.g. ya_1)."
                type: string
              kind:
                description: "Item kind — compact vocabulary (rl/lg/ra/mh/quad/…)."
                type: string
              loc:
                description: "Nearest named location from the map .loc file."
                type: string
              takenBy:
                description: "Picker display name."
                type: string
              team:
                description: "Team name."
                type: string
              time:
                description: "Event/sample time, int32 ms since match start."
                maximum: 2.147483647e+09
                minimum: -2.147483648e+09
                type: integer
            required:
              - item
              - kind
              - entNum
              - time
              - takenBy
            type: object
          type:
            - "null"
            - array
        players:
          description: "Per-player match-start spawn positions."
          items:
            additionalProperties: false
            properties:
              loc:
                description: "Nearest named location from the map .loc file."
                type: string
              name:
                description: "Name / display label."
                type: string
              team:
                description: "Team name."
                type: string
            required:
              - name
            type: object
          type:
            - "null"
            - array
      required:
        - players
        - firstTakes
      type: object
    PlayerStats:
      description: >
        Canonical per-player and per-team statistics, computed for EVERY
        demo. Each stat family carries `src` ("derived" | "ktx", plus "derived:unbounded" on damage, "reconstructed" on damage and accuracy, and the "mixed" team-row canary) naming
        where its numbers came from; `sources` is the roll-up. `src` is the
        evidence GRADE only — for accuracy, what the numbers COUNT is the
        per-weapon `hitsConvention`. /demoinfo
        remains the verbatim KTX pass-through this is diffable against.
      additionalProperties: false
      properties:
        timeUnit: { $ref: "#/components/schemas/TimeUnit" }
        players:
          description: "One row per participant."
          items: { $ref: "#/components/schemas/PlayerStatsRow" }
          type: array
        teams:
          description: "Per-team aggregate rows; omitted on duels and FFA."
          items: { $ref: "#/components/schemas/PlayerStatsRow" }
          type: array
        sources:
          description: >
            Per-family provenance roll-up, computed FROM THE ROWS this
            response carries, after any filtering: all rows agree -> that
            value, no row carries the family -> the key is omitted, rows
            disagree -> "mixed". Exception: score and hold are constants
            ("derived") whenever players are present — no overlay ever
            supplies either family. "mixed" is a canary and should never appear
            on healthy data — on a demo with a KTX block every roster row
            joins it, so a disagreement means a row the block has never
            heard of.
          additionalProperties: false
          properties:
            score: { description: "Provenance of the scoreboard family — a constant: the kill side is always the frag-log reconstruction.", type: string, enum: [derived] }
            damage: { description: "Provenance of the damage family. \"derived:unbounded\" means raw wire damage — see the per-row damage.src.", type: string, enum: [derived, "derived:unbounded", ktx, reconstructed, mixed] }
            accuracy: { description: "Provenance of the accuracy family: ktx when the demoinfo block carries it, else the fire-stream reconstruction — \"reconstructed\" when its hits came from the aim recon tier on a demo with no wire damage stream. The three are different measurements — see the per-row accuracy description.", type: string, enum: [derived, ktx, reconstructed, mixed] }
            pickups: { description: "Provenance of the pickup tallies.", type: string, enum: [derived, ktx, mixed] }
            hold: { description: "Provenance of the possession-time family — a constant: no other source carries it.", type: string, enum: [derived] }
          required: [score, hold]
          type: object
      required:
        - players
        - sources
      type: object
    PlayerStatsRow:
      description: >
        One player's canonical statistics. On a team row `name` is the team
        name and `team` / `ping` / `handicap` / `login` / `bot` are absent.
      additionalProperties: false
      properties:
        name: { description: "Player name, or team name on a team row.", type: string }
        team: { description: "Team name.", type: string }
        identity:
          description: |
            Reconnect-unification key: two rows carrying the SAME value are
            one human under two names — a player who reconnected while their
            old connection was still spawned is renamed `(N)<name>` by mvdsv
            and scored as two players by KTX, which this pipeline reproduces
            faithfully rather than merging. Different values are different
            humans as the server scored them.

            **Demo-local.** It is derived from the identity's first session
            (`s<slot>u<userId>`), both of which describe a connection to one
            server. Reproducible from the same demo, but NOT an id to persist
            and NOT comparable across demos — the cross-demo identity is
            `login`.

            Absent on TEAM rows and on a scoreboard-only player row (in the
            KTX block, produced no stream, so no occupancy to attribute).
          type: string
        sessions:
          description: |
            Every wire-slot occupancy behind this row, in time order — one
            entry per CONNECTION, so a player who timed out and rejoined has
            two, with two userids. This is the lossless form of the answer
            `/overview`'s `playerUserIDs` gives once per player: use `userId`
            for a `?track=` link at an instant inside the window. Absent with
            `identity` (team and scoreboard-only rows).

            **What is NOT listed:**

            - Occupancies the wire never gave a userid — KTX's ghost
              scoreboard row (a departed player's edict, userid hardcoded 0)
              and inferred occupancies are not connections, and the ghost
              carries the departed player's name.
            - A connection first attested at or after match end (a spectator
              connecting postgame, unified with a player who left mid-match):
              the published window closes at match end, so it would emit
              startMs > endMs, and it describes nothing trackable inside the
              match.
          items: { $ref: "#/components/schemas/PlayerSession" }
          type: array
        ping:
          description: "KTX-only: end-of-match ping. Absent without a demoinfo block (svc_updateping carries it on the wire but the parser skips it today)."
          type: [integer, "null"]
        handicap:
          description: "KTX-only: handicap, omitted by KTX at the default 100."
          type: [integer, "null"]
        login:
          description: "Authenticated login name: KTX's when present, else the *auth userinfo key off the wire."
          type: [string, "null"]
        bot:
          description: "KTX-only: present when the slot was held by a frogbot."
          oneOf:
            - additionalProperties: false
              properties:
                skill: { description: "Frogbot skill level.", type: integer }
                customised: { description: "True when the bot's settings were customised.", type: boolean }
              required: [skill, customised]
              type: object
            - type: "null"
        controlMs:
          description: "KTX-only: KTX's own map-control time in milliseconds. Not the same measure as the region-control view, and absent without a demoinfo block."
          type: integer
        speed:
          description: "KTX-only: per-player speed summary in Quake units per second. Absent without a demoinfo block."
          oneOf:
            - additionalProperties: false
              properties:
                max: { description: "Peak speed observed.", type: number }
                avg: { description: "Average speed.", type: number }
              required: [max, avg]
              type: object
            - type: "null"
        members:
          description: "TEAM rows only, and ALWAYS present there (including 0): how many players were folded in that were actually in the match (presentMs > 0) — exactly the count shareMatch's denominator (matchMs x members) rests on. A scoreboard-only row is summed into the team's totals but does not count here. Absent on player rows."
          type: integer
        window: { $ref: "#/components/schemas/PlayerStatsWindow" }
        score:
          description: >
            Scoreboard line. Always derived — the KTX stats over-count
            telefrags and reset after a reconnect. The KILL SIDE is OPTIONAL
            and absent AS A WHOLE: `kills`, `suicides`, `teamKills`,
            `efficiency`, `byWeapon`, `byEnemyWeapon`, `byWeaponVsEnemyWeapon`,
            `maxSpree` and `maxQuadSpree` are every field attributed from the
            obituary-derived frag log, and on a demo whose obituaries never
            matched they go together rather than being served as zeros beside
            a real death count. Test for `kills` and treat its absence as
            covering all of them. `frags`
            (the wire net score) and `deaths` (protocol death events) are
            measured on every demo and always present.
          additionalProperties: false
          properties:
            src: { description: "Provenance of this family — a constant \"derived\": the kill side is always the frag-log reconstruction.", type: string, enum: [derived] }
            frags: { description: "svc_updatefrags net score.", type: integer }
            kills: { description: "Frag-log-corrected kills. ABSENT when the frag log measured nothing on a demo where players died — kill attribution was not observable.", type: integer }
            deaths: { description: "Frag-log-corrected deaths.", type: integer }
            suicides: { description: "Frag-log-corrected suicides (KTX credits world-dealt ones to the world entity). Absent with `kills`.", type: integer }
            teamKills: { description: "Teammates this player killed. Absent with `kills`.", type: integer }
            efficiency:
              description: "kills / (kills + deaths) as a RATIO in [0,1], not a percentage. Absent with `kills`, since it is computed from it."
              type: number
            maxSpree: { description: "Longest run of kills between deaths, derived from the frag log and the death markers — the equivalent of the KTX demoinfo block's spree.max, which 54% of the archive has no block to carry. A run still alive at match end counts. Absent with `kills`; a 0 inside a present family is an observed zero. NOT overlaid from KTX: KTX bumps a player's own streak on their SUICIDE wherever teamplay is off, so on a duel with self-kills this reads lower by design.", type: integer }
            maxQuadSpree: { description: "Longest run of kills made while holding the quad, resetting on death AND on a fresh quad (KTX latches its counter in the pickup handler). Equivalent of the KTX block's spree.quad. Absent with `kills`.", type: integer }
            byWeapon:
              description: >
                Enemy kills split by the weapon that dealt them, commonly rl/lg/gl/sg/ssg/sng/ng/axe.
                The key set is the frag log's obituary vocabulary, which ALSO includes the
                non-weapon killers `tele`, `stomp` and `squish`, the catch-all `unknown` for a
                generic "was killed by" line, and `hook`/`rail` on mods that have them — `tele`
                appears in the committed corpus. Iterate the map; do not assume a fixed key list.
                From the corrected frag log, so it is on the same footing as `kills` — absent with
                it — and is never overlaid with KTX's per-weapon counts. Weapons the player never
                killed with are omitted, not zero-filled.
              additionalProperties: { type: integer }
              type: object
            byEnemyWeapon:
              description: >
                The SAME enemy kills split by what the VICTIM was holding when they died — the
                other axis from byWeapon, which keys on the weapon the KILLER used. This is the
                weapon-denial figure: "how many armed enemies did this player take out".

                Buckets are MUTUALLY EXCLUSIVE and PARTITION `kills`: `both` (victim held RL and
                LG), `rl` (RL, not LG), `lg` (LG, not RL), `mid` (ssg/sng/gl only), `sg` (nothing
                above the shotgun tier — the respawn loadout), and `unknown` (the victim produced
                no possession stream, so their loadout is unknowable). Enemies killed while
                holding an RL is therefore `rl + both`, NEVER `rl` alone; likewise `lg + both`.
                Those two overlap in `both` by construction, so adding them double-counts the
                fully armed victims.

                DERIVED on every demo carrying streams and never overlaid with KTX's `ekills`,
                which counts the same events INCLUSIVELY (a victim holding RL+LG bumps both
                counters) and is force-zeroed for axe/sg and for every bucket on `deathmatch >= 4`
                and `k_instagib`. Where KTX measures honestly the two agree exactly:
                `rl + both == ktx ekills.rl`, verified across the cached demo corpus. Unlike KTX's, this
                also classifies telefrags and stomps. On the same footing as `kills` — absent with
                it. Zero buckets are omitted.
              additionalProperties: { type: integer }
              type: object
            byWeaponVsEnemyWeapon:
              description: >
                The JOINT distribution byWeapon and byEnemyWeapon are the marginals of:
                killer weapon -> victim-weapon bucket -> kills. Outer keys are byWeapon's
                vocabulary, inner keys byEnemyWeapon's. Use it for what neither marginal can
                answer — "how many of my LG kills were against enemies carrying an RL", the
                question separating a weapon that wins fights from one that finishes off the
                disarmed.

                Summing it reproduces both maps EXACTLY: over inner keys it gives byWeapon,
                over outer keys byEnemyWeapon. That is guaranteed by construction (the marginal
                is summed from this table, not tallied separately), so the three maps can never
                disagree. Same measuredness as its marginals — absent exactly when `kills` is —
                and the same zero-dropping. This is the widest map in the response (up to
                weapons-used x 6); skip it when a scoreboard is all you need.
              additionalProperties:
                additionalProperties: { type: integer }
                type: object
              type: object
          required: [src, frags, deaths]
          type: object
        damage:
          description: "Damage under KTX bounded-scoreboard semantics. Absent when the demo carries no damage information at all — but a player who neither dealt nor took a point of damage on a demo that DOES carry the stream gets a zeroed family, since that is an observed zero rather than an unmeasurable one."
          oneOf:
            - additionalProperties: false
              properties:
                src:
                  description: >
                    Provenance of this family. "derived:unbounded" means the
                    numbers are RAW wire damage including overkill, because the
                    server mode (k_midair / k_instagib / k_dmgfrags) rewrites
                    T_Damage in ways the wire does not expose and no bounded
                    reconstruction was attempted — the same condition
                    /damage reports as boundedMode "skipped:*". Raw runs
                    38-44% above bounded on a normal 4on4 and an order of
                    magnitude on instagib's flat 5000/hit, so do not compare
                    these figures with a bounded demo's.
                  type: string
                  enum: [derived, "derived:unbounded", ktx, reconstructed, mixed]
                given: { description: "Dealt to enemies.", type: integer }
                givenTeam: { description: "Dealt to teammates.", type: integer }
                givenSelf: { description: "Dealt to self.", type: integer }
                enemyWeapons: { description: "Enemy damage dealt to victims holding RL and/or LG (KTX ewep).", type: integer }
                taken:
                  description: >
                    From ALL sources (enemy + team + self + env) — NOT the same
                    quantity as KTX dmg.taken. Only the per-hit fold measures
                    it, so it is ABSENT (not zero) when the demo has no damage
                    section at all (since v71 the reconstruction fills one on
                    most pre-instrumentation demos, src "reconstructed").
                  type: integer
                teamWeapons:
                  description: "KTX-only: damage dealt to TEAMMATES holding RL and/or LG (KTX twep) — the friendly-fire mirror of enemyWeapons. The reconstruction does not bucket team damage by the victim's inventory, so this is absent without a demoinfo block."
                  type: integer
                byWeapon:
                  description: >
                    Enemy damage GIVEN split by the attacker's weapon, keyed rl/lg/gl/sg/ssg/sng/ng/axe
                    plus the non-KTX residual keys unknown/stomp/tele/squish/explobox, which are real
                    measured damage the reconstruction contributes (the full vocabulary is
                    `DeathTypeToWeapon`; iterate the map). Follows src with the family: KTX's per-weapon
                    enemy damage wherever the block carries a `damage` sub-block — including an
                    `enemy: 0` there, which is a MEASURED zero (a weapon used for team splash only) —
                    else the bounded reconstruction. Weapons the player dealt no damage with are
                    omitted. There is no by-weapon split of damage TAKEN on either side.
                  additionalProperties: { type: integer }
                  type: object
                byWeaponTeam:
                  description: >
                    TEAM damage GIVEN split by the attacker's weapon, keyed exactly like byWeapon.
                    Follows src with the family: KTX writes `enemy` and `team` in the same
                    `damage` sub-block, so it is merged on the same presence rule and a `team: 0`
                    there is a MEASURED zero. MEASURED whenever this damage family is present.
                    Weapons the player dealt no team damage with are omitted.
                  additionalProperties: { type: integer }
                  type: object
                byWeaponSelf:
                  description: >
                    SELF damage GIVEN split by the attacker's weapon, keyed exactly like byWeapon.
                    Derived-only — KTX records no per-weapon self counter — so it is MEASURED only
                    where a damage stream was read, which is exactly what a present `taken` says:
                    absent on a demo with a KTX block but no damage stream, whatever the player did.
                    Weapons the player dealt no self damage with are omitted. Telefrags and stomps
                    are excluded — their damage folds into givenSelf only.
                  additionalProperties: { type: integer }
                  type: object
                byEnemyWeapon:
                  description: >
                    ENEMY damage GIVEN split by what the VICTIM was holding when each hit landed —
                    the other axis from byWeapon, which keys on the ATTACKER's weapon. Buckets are
                    MUTUALLY EXCLUSIVE: `both` (victim held RL and LG), `rl`
                    (RL, not LG), `lg` (LG, not RL), `mid` (ssg/sng/gl only), `sg` (nothing above
                    the shotgun tier). Damage into enemies holding an RL is therefore `rl + both`,
                    NEVER `rl` alone. `enemyWeapons` above is exactly `lg + rl + both`.

                    They partition the enemy damage THIS PIPELINE RECONSTRUCTED, which equals
                    `given` exactly on a `src: derived` row but NOT on a `src: ktx` one — there
                    `given` is KTX's own counter while this split stays ours, since KTX records no
                    per-tier equivalent. Measured residual across the corpus: 66 of 82 KTX rows,
                    worst 16 damage, 0.03% of total given. Do not expect a share-of-given computed
                    from these buckets to reach exactly 100% on a KTX row. The KILL-side sibling
                    `score.byEnemyWeapon` has no such gap — score is never overlaid.

                    DERIVED-only and finer than the server's own accounting — KTX keeps a single
                    dmg_eweapon scalar lumping RL and LG together — so it is MEASURED only where a
                    damage stream was read, which is exactly what a present `taken` says: absent on
                    a demo with a KTX block but no damage stream. Zero buckets are omitted.
                    Telefrags and stomps are excluded, as everywhere in this family.
                  additionalProperties: { type: integer }
                  type: object
                takenEnemy:
                  description: "Enemy-only damage taken (KTX dmg.taken). KTX's when present, else reconstructed from the per-hit log. Absent, not zero, when the demo carries no damage information."
                  type: integer
                takenToDie:
                  description: "Average damage absorbed per death (takenEnemy / deaths). Absent when the player never died — KTX's 99999 no-deaths sentinel is never served as a number."
                  type: integer
              required: [src, given, givenTeam, givenSelf, enemyWeapons]
              type: object
            - type: "null"
        accuracy:
          description: |
            Per-weapon shot accounting. Two sources:

            - **src=ktx** is KTX's server-side counter (attacks = PELLETS for
              sg/ssg, with a real/virtual split);
            - **src=derived** is reconstructed from the decoded fire stream
              (attacks = TRIGGER PULLS, no real/virtual, and hits absent when
              the demo has no damage stream to link against);
            - **src=reconstructed** is the same fire stream with `hits`
              recovered from the aim recon tier on a demo whose damage section
              is itself reconstructed — inference, not measurement. Only the
              weapons that tier validated (lg, sg, ssg, axe, rl, gl) carry
              `hits`; ng/sng keep it absent. rl/gl carry the DIRECT-impact
              count there (schema v74), i.e. KTX's own convention for those
              two, so an old demo's row and a block-carrying demo's row answer
              the same question.

            The three are NOT the same measurement, so check src before
            comparing accuracies across demos — but the family is emitted
            either way, so a demo with no KTX block degrades to a rougher
            number rather than to a missing field.

            `src` is the evidence GRADE and does not say what the numbers
            count; the per-weapon `hitsConvention` does, and is the field to
            gate a cross-demo comparison on. `derived` `hits` are always
            `anyDamage` — a fire that landed damage by any path.
            `reconstructed` `hits` are `anyDamage` too, EXCEPT on rl/gl, which
            carry `directImpact` since schema v74. KTX's are `anyDamage` for
            lg/axe/ng/sng, `directImpact` for rl/gl and `pellets` for sg/ssg.

            Scored against the verbatim block on 186 instrumented archive
            demos (cmd/qw-demoinfo-eval, withhold-and-compare): reconstructed
            rl agrees to **1.25%** in aggregate (46.5% of 632 rows exact,
            p90 error 2), gl to **3.55%** in aggregate at 89.6% of 424 rows
            exact (one-sided by design), lg to 0.9%.
            A `derived` rl/gl row does NOT agree — it counts any damage path
            and reads ~4x above the block on rl, ~1.5x on gl — and sg/ssg
            never do, being pellet counts on both sides of KTX's ratio and
            trigger pulls on ours.

            Deriving the direct-impact convention on a demo with no KTX block
            took two goes. The wire log's own splash flag reproduces KTX's rl
            count exactly (638 of 638 rows) but is absent on the
            pre-instrumentation half, and the first substitute — an explosion
            endpoint within 48 units of the victim — over-counted rl by 80%,
            since a rocket detonating on the wall BESIDE a player is
            endpoint-near without ever having touched them. v74 replaces it
            with the flight's trajectory against the player hull plus the
            flat-110 direct-damage magnitude, which is what the numbers above
            measure. See damagerecon/ACCURACY.md.

            When KTX carries an acc block the family is replaced WHOLESALE,
            never merged per weapon: mixing pellet counts with trigger pulls
            in one map under one src is the coercion this section exists to
            avoid. Measured lossless across the 42-demo cached corpus (228
            rows, zero weapons present in the reconstruction and missing from
            KTX).
          oneOf:
            - additionalProperties: false
              properties:
                src: { description: "Provenance of this family: \"reconstructed\" when `hits` came from the aim recon tier rather than a wire damage link; the members' shared source on a team row, or \"mixed\" — the phantom-roster canary — when they disagree. Player rows never carry \"mixed\".", type: string, enum: [derived, ktx, reconstructed, mixed] }
                byWeapon:
                  description: "Per weapon key (axe, sg, ssg, ng, sng, gl, rl, lg — KTX counts axe swings too). What the numbers mean depends on src — see the family description."
                  additionalProperties:
                    additionalProperties: false
                    properties:
                      attacks: { description: "PELLETS when src=ktx and the weapon is sg/ssg; TRIGGER PULLS otherwise. Read `hitsConvention` rather than re-deriving that rule.", type: integer }
                      hits: { description: "Hits landed — read `hitsConvention` for WHAT that counts before comparing two of these. ABSENT (not zero) when nothing could count them: a derived block on a demo with no WIRE damage stream, or a weapon (ng/sng) the aim recon tier withheld on a src=reconstructed block — a zero would read as 'shot and never hit'. On a TEAM row, absent unless every contributing member measured it.", type: integer }
                      hitsConvention:
                        description: >
                          What `hits` (and, for `pellets`, `attacks` too) COUNTS — the
                          machine-readable form of the comparability rule, present whenever
                          `hits` is. Two rows are comparable exactly when their weapon AND
                          this value match; gate any cross-demo aggregation on it.

                          `anyDamage` — one fire that landed damage by any path, splash
                          included. Every weapon of a `derived` family, lg/sg/ssg/axe of a
                          `reconstructed` one, and KTX's own counter for the weapons with a
                          single damage path (lg, axe, ng, sng).

                          `directImpact` — the projectile TOUCHED a player. KTX's rl/gl
                          counter (ktx/src/weapons.c:994, :1329) — a rocket that killed by
                          splash alone is not a hit to it — and, since schema v74, the rl/gl
                          rows of a `reconstructed` family, which reproduce it to 1.25% / 3.55%
                          aggregate (46.5% / 89.6% of rows exact) on 632 / 424 archive rows.
                          Measured on the same rows, the
                          any-path count runs ~4x above this on rl and ~1.5x on gl, so mixing
                          the two turns a definition change into a trend.

                          `pellets` — BOTH counters advance per pellet, not per trigger pull:
                          KTX's sg/ssg only, 6x and 14x a fire count.

                          It is per WEAPON because a single `src: "ktx"` row uses all three at
                          once. Absent, with `hits` present, only on a TEAM row whose members
                          disagreed — the phantom-roster canary `src: "mixed"` already flags.
                        type: string
                        enum: [anyDamage, directImpact, pellets]
                      real: { description: "Not aggregated on team rows (KTX omits the pair unless it recorded one). KTX-only, rl/gl only: victims who actually lost health or armor to a blast — one rocket splashing three players counts three. NOT a split of hits (which for rl/gl is the direct-impact count), and routinely larger than it.", type: integer }
                      virtual: { description: "KTX-only, rl/gl only: victims who WOULD have been damaged, measured before godmode / pentagram / teamplay damage-avoidance zeroed it out. Always >= real; the gap is damage prevented, not missed.", type: integer }
                    required: [attacks]
                    type: object
                  type: object
              required: [src, byWeapon]
              type: object
            - type: "null"
        pickups:
          description: "Per-kind pickup tallies, keyed by this repo's item-kind vocabulary (ra/ya/ga/mh/quad/rl/lg/…), not KTX's demoinfo keys."
          oneOf:
            - additionalProperties: false
              properties:
                src: { description: "Provenance of this family: the members' shared source on a team row, or \"mixed\" — the phantom-roster canary — when they disagree. Player rows never carry \"mixed\".", type: string, enum: [derived, ktx, mixed] }
                byKind:
                  description: >
                    Tally per item kind. With src=ktx the key set can additionally include
                    `axe` and `sg`, which KTX counts (its WpName vocabulary, ktx/src/stats.c:358)
                    and the derived path never emits — so the KEY SET depends on src, not just
                    the values. Iterate the map; do not assume a fixed key list.
                  additionalProperties: { $ref: "#/components/schemas/PlayerStatsPickup" }
                  type: object
              required: [src, byKind]
              type: object
            - type: "null"
        hold:
          description: "Possession time by item — the family no other source carries."
          additionalProperties: false
          properties:
            src: { description: "Provenance of this family — a constant: no other source carries it.", type: string, enum: [derived] }
            weapons:
              description: "Keyed rl/lg/gl/ssg/sng. Shotgun and axe are deliberately absent (held all match by everyone); the nailgun is absent because the streams track no NG possession interval."
              additionalProperties: { $ref: "#/components/schemas/HoldStat" }
              type: object
            armor:
              description: "Keyed ra/ya/ga plus `none`, the alive-time complement. ga+ya+ra+none == window.aliveMs."
              additionalProperties: { $ref: "#/components/schemas/HoldStat" }
              type: object
            powerups:
              description: "Keyed quad/pent/ring."
              additionalProperties: { $ref: "#/components/schemas/HoldStat" }
              type: object
          required: [src]
          type: object
      required:
        - name
        - window
        - score
        - hold
      type: object
    PlayerSession:
      description: >
        One contiguous occupancy of a wire client slot by ONE connection of
        this player — the validity window of a userid. A userid names a
        connection, not a person (mvdsv reissues them from a rotating pool),
        so the same human reconnecting draws a new one and the slot they left
        hands its old one to whoever takes it next.
      additionalProperties: false
      properties:
        startMs:
          description: >
            First userinfo that attested this connection, int32 ms on the
            match clock. NOT clamped to the match window: a player connected
            during the countdown reports a NEGATIVE value, the same policy as
            the timeline's demo markers and the pause anchors.
          maximum: 2.147483647e+09
          minimum: -2.147483648e+09
          type: integer
        endMs:
          description: >
            The drop broadcast, or the next connection's userinfo — half-open
            [startMs, endMs). Synthetic in TWO cases, both reading exactly
            match end: a client still connected when the recording ended (no
            wire event exists to report), and a drop broadcast that lands
            AFTER match end (the event exists, but the window is closed on
            the match clock every other time here lives on). So an endMs at
            match end does not by itself mean "still connected at the end".
          maximum: 2.147483647e+09
          minimum: -2.147483648e+09
          type: integer
        slot:
          description: "Wire client slot (0-based), the index svc_* messages address this connection by."
          type: integer
        userId:
          description: "The connection's userid — the value a hub `?track=` link must carry to follow this player inside this window, and only then. Always non-zero: an occupancy the wire gave no userid is not published as a session at all, so there is no 0 to interpret."
          type: integer
        name:
          description: >
            The netname this connection carried, where the row's `name` is
            the identity's canonical one. They differ after a rename or an
            mvdsv `(N)` duplicate-name prefix; this is the one to match
            against a live engine roster at an instant inside the window.
            Normalized through the same Quake character fold as every other
            name in this API, so it is not the raw wire bytes.
          type: string
      required: [startMs, endMs, slot, userId]
      type: object
    PlayerStatsWindow:
      description: >
        The denominators every rate in the row can be read against, int32 ms
        on the game clock (pauses already excluded). Nothing here has an
        implicit denominator.
      additionalProperties: false
      properties:
        matchMs:
          description: "The whole match window. Identical on every row."
          maximum: 2.147483647e+09
          minimum: -2.147483648e+09
          type: integer
        presentMs:
          description: "First to last activity, clipped to the match — separates a late joiner from someone present the whole time."
          maximum: 2.147483647e+09
          minimum: -2.147483648e+09
          type: integer
        aliveMs:
          description: "Time alive within presentMs."
          maximum: 2.147483647e+09
          minimum: -2.147483648e+09
          type: integer
        deadMs:
          description: "presentMs - aliveMs."
          maximum: 2.147483647e+09
          minimum: -2.147483648e+09
          type: integer
      required: [matchMs, presentMs, aliveMs, deadMs]
      type: object
    PlayerStatsPickup:
      description: "One item kind's tally for one player."
      additionalProperties: false
      properties:
        took:
          description: "Acquisitions that granted the item — for weapons, pickups where the player did not already hold it (KTX wpn.tooks)."
          type: integer
        totalTook:
          description: "Every touch including redundant ones (KTX wpn.ttooks). Weapons only."
          type: integer
        dropped:
          description: "Backpacks left on death carrying this weapon."
          type: integer
        xfer:
          description: >
            Pack transfers credited to this player AS THE DROPPER: a pack they
            dropped that a teammate took. ABSENT (not zero) when the demo
            carries no backpack hints, i.e. transfers are unobservable.
            Teamplay-only, like KTX's isTeam() gate.
          type: integer
        xferSelf:
          description: >
            Same, but the dropper took their own pack back. KTX's xferRL/xferLG
            is xfer + xferSelf — it has no other!=dropper check.
          type: integer
      type: object
    HoldStat:
      description: "One item's possession time for one player (or team)."
      additionalProperties: false
      properties:
        ms:
          description: "Possession time: an exact integral over native-rate possession intervals, clipped to the match window and intersected with alive intervals."
          maximum: 2.147483647e+09
          minimum: -2.147483648e+09
          type: integer
        runs:
          description: "Number of disjoint possession spells (summed over members on a team row)."
          type: integer
        longestMs:
          description: "Longest single spell (MAX over members on a team row, not summed)."
          maximum: 2.147483647e+09
          minimum: -2.147483648e+09
          type: integer
        shareAlive:
          description: "ms / window.aliveMs, a ratio in [0,1]. On a team row the denominator is the team's summed alive time."
          type: number
        shareMatch:
          description: "ms / window.matchMs, a ratio in [0,1]. On a team row the denominator is matchMs x member count."
          type: number
      required: [ms, runs, longestMs, shareAlive, shareMatch]
      type: object
    Events:
      type: object
      description: >
        /events body — a flat, time-ordered event list. detail keys vary
        by event type (the well-known keys are typed below and validated
        when present); detail is omitted for spawn/death without a loc
        sample. Event types: airgib, armor, chat, damage, death, demomark,
        frag, health, item, loc, pause, pickup, powerup, spawn, stomp,
        streak, telefrag, weapon.
      required: [timeUnit, events]
      additionalProperties: false
      properties:
        timeUnit: { type: string, enum: [ms], description: "Constant \"ms\" self-description echo — in the v57 pure-ms model every time value is int32 milliseconds." }
        events:
          description: "Tagged event list ([] when nothing matches)."
          type: array
          items:
            type: object
            required: [time, type]
            additionalProperties: false
            properties:
              time:
                type: integer
                description: Match-relative time, int32 ms.
              type: { type: string, description: "Type discriminator." }
              player: { type: string, description: "Player display name." }
              detail:
                description: "Type-specific detail fields for this event."
                type: object
                additionalProperties: true
                properties:
                  team: { type: string, description: "Team name." }
                  delta: { type: integer, description: frag delta (frag) or value change (health/armor) }
                  value: { type: integer, description: new value (health/armor/loc index) }
                  bounded: { type: integer, description: KTX-scoreboard bounded value — on damage events the stored per-hit reconstruction (present only when it differs from the raw damage), on telefrag/stomp events the reconstructed kill value; absent when the bounded reconstruction was skipped; 0 is a real value }
                  powerup: { type: string, description: "Powerup type." }
                  endTime: { type: integer, description: int32 ms (powerup/streak) }
                  frags: { type: integer, description: frags during the powerup run }
                  length: { type: integer, description: streak length }
                  duration: { type: integer, description: streak duration, int32 ms }
                  weapon: { type: string, description: "Weapon — lowercase KTX class name (axe/sg/ssg/ng/sng/gl/rl/lg)." }
                  victim: { type: string, description: "Victim (killed player) name." }
                  damage: { type: integer, description: "Raw/unbound wire damage amount, including overkill (see bounded for the KTX-scoreboard value)." }
                  isSplash: { type: boolean, description: "Indirect hit (e.g. rocket splash)." }
                  isEnv: { type: boolean, description: "Environmental / world-sourced." }
                  isSelf: { type: boolean, description: "Attacker == victim." }
                  isTeam: { type: boolean, description: "Same team, not self." }
                  victimWep: { type: string, description: "Victim weapon class at hit time (sg|mid|lg|rl|both)." }
                  gain: { type: string, description: weapon/item code gained (interval start) }
                  lose: { type: string, description: weapon/item code lost (interval end) }
                  item: { type: string, description: spawner instance name (pickup), e.g. ya_1 }
                  kind: { type: string, description: "Item kind — compact vocabulary (rl/lg/ra/mh/quad/…)." }
                  entNum: { type: integer, description: "Server entity/edict number — stable id within a match." }
                  source:
                    description: "Source."
                    type: string
                    enum: [world, backpack, unknown]
                  dropper: { type: string, description: "Backpack dropper name." }
                  loc: { type: string, description: "Nearest named location from the map .loc file." }
                  li: { type: integer, description: raw LocTable index (loc=index mode) }
                  text: { type: string, description: chat message }
                  clean: { type: string, description: chat message, color codes stripped }
                  attackerTeam: { type: string, description: "airgib attacker (rocketeer) team." }
                  victimTeam: { type: string, description: "airgib victim team." }
                  height: { type: number, description: "airgib victim feet above the floor at the hit (units)." }
                  heightAboveAttacker: { type: number, description: "airgib victim origin minus shooter origin at the hit (units; negative = victim below shooter); omitted when 0." }
                  lethal: { type: boolean, description: "airgib hit killed the victim (matching rocket frag near the hit)." }
                  durationMs: { type: integer, description: "pause real wall-clock ms consumed (pause events)." }
    BucketsRow:
      additionalProperties: false
      properties:
        timeUnit: { type: string, enum: [ms], description: "Constant \"ms\" self-description echo — in the v57 pure-ms model every time value is int32 milliseconds." }
        buckets:
          description: "Per-window bucket rows."
          items:
            additionalProperties: false
            properties:
              p:
                description: "Per-player reduced values, keyed by player name."
                additionalProperties:
                  additionalProperties: true
                  type: object
                type:
                  - object
                  - "null"
              partial:
                description: "This bucket is a short trailing window."
                type: boolean
              time:
                type: integer
                description: Bucket start, int32 ms.
              team:
                description: "Per-team reduced values, keyed by team name."
                additionalProperties:
                  additionalProperties: true
                  type: object
                type: object
            required:
              - time
              - p
            type: object
          type:
            - "null"
            - array
        windowMs:
          description: "Bucket width, int32 ms."
          type: integer
      required:
        - timeUnit
        - windowMs
        - buckets
      type: object
    BucketsColumnar:
      type: object
      description: >
        Column-major buckets (layout=column, the default). The time axis is
        implicit: time(i) = start + i*windowMs (int32 ms). Each player
        object inlines its dense columns at the top level keyed by field
        code; boolean columns (and the alive mask) serialize as 0/1.
      required: [timeUnit, windowMs, start, count]
      additionalProperties: false
      properties:
        timeUnit: { type: string, enum: [ms], description: "Constant \"ms\" self-description echo — in the v57 pure-ms model every time value is int32 milliseconds." }
        windowMs: { type: integer, description: "Bucket width, int32 ms." }
        start: { type: integer, description: First bucket start, int32 ms. }
        count: { type: integer, description: "Number of buckets." }
        partialLastMs:
          type: integer
          description: Shorter end of the final bucket when the match length is not a multiple of windowMs.
        locTable:
          type: array
          description: >
            Legend for the raw li columns — the demo's interned loc-name
            table (index 0 is the "" no-loc sentinel; identical to
            /loc-table). Present iff an li column is in the output, so
            columnar responses decode locally without a /loc-table call
            (v53).
          items: { type: string }
        players:
          description: "Per-player columnar bucket series, keyed by player name."
          type: object
          additionalProperties:
            type: object
            description: >
              One player's dense columns over their active span
              [first, first+n). Reserved keys first/n/alive/validFrom;
              every other key is a requested field-code column (or a
              position/view sub-column like x/y/z, vp/vya, vx/vy/vz) of
              length n — numeric, string (at), or 0/1 series.
            required: [first, n, alive]
            properties:
              first: { type: integer, description: "Index of the first populated column." }
              n: { type: integer, description: "Per-column sample counts." }
              alive:
                description: "Per-column alive flags."
                type: array
                items: { type: integer, minimum: 0, maximum: 1 }
              validFrom:
                type: object
                description: >
                  Absolute bucket index from which a late-starting column
                  is valid; leading slots hold a typed zero and must be
                  treated as absent.
                additionalProperties: { type: integer }
            additionalProperties:
              type: array
              items:
                type: [number, string]
        teams:
          description: "Per-team columnar bucket series, keyed by team name."
          type: object
          additionalProperties:
            type: object
            description: >
              One team's per-bucket aggregate counters as dense integer
              columns of length count, keyed by counter name; armor-by-type
              sub-columns nest under abt.
            properties:
              abt:
                description: "Per-column armed-both-teams flags."
                type: object
                additionalProperties:
                  type: array
                  items: { type: integer }
            additionalProperties:
              type: array
              items: { type: integer }
    StreamSlice:
      additionalProperties: false
      properties:
        timeUnit: { type: string, enum: [ms], description: "Constant \"ms\" self-description echo — in the v57 pure-ms model every time value is int32 milliseconds." }
        end:
          type: integer
          description: Window end, int32 ms (the exact bound used for slicing).
        players:
          description: >
            Per-player stream slices over the window (mini result.PlayerStream
            shapes). Deliberately WITHOUT the match-global `identity` /
            `sessions` fields that `streams.players[]` carries: they do not
            vary by window. Fetch them once from `/player-stats` and join on
            the player `name`.
          items:
            additionalProperties: false
            properties:
              a:
                description: "Armor change stream (t/v pairs)."
                items:
                  additionalProperties: false
                  properties:
                    t:
                      description: "Event/sample time, int32 ms since match start."
                      maximum: 2.147483647e+09
                      minimum: -2.147483648e+09
                      type: integer
                    v:
                      description: "Sample value at time t."
                      maximum: 32767
                      minimum: -32768
                      type: integer
                  required:
                    - t
                    - v
                  type: object
                type:
                  - "null"
                  - array
              alive:
                description: >
                  The player's LIVES, clamped to the window — the canonical
                  stored liveness (`streams.players[].alive`), not a
                  re-derivation of the `sp`/`d` markers beside it. Always
                  present and never field-gated, because `null` is a state:
                  `null` = liveness was not measurable, `[]` = measured and
                  never alive IN THIS WINDOW, `[…]` = the lives. A death
                  splits a life even when the respawn lands on the same
                  millisecond (the intervals touch), so this is also the
                  life-boundary list.
                items:
                  additionalProperties: false
                  properties:
                    e:
                      description: "Interval end, int32 ms since match start (terse per-row key)."
                      maximum: 2.147483647e+09
                      minimum: -2.147483648e+09
                      type: integer
                    s:
                      description: "Interval start, int32 ms since match start (terse per-row key)."
                      maximum: 2.147483647e+09
                      minimum: -2.147483648e+09
                      type: integer
                  required:
                    - s
                    - e
                  type: object
                type:
                  - "null"
                  - array
              at:
                description: "Armor-type change stream (t/v pairs)."
                items:
                  additionalProperties: false
                  properties:
                    t:
                      description: "Event/sample time, int32 ms since match start."
                      maximum: 2.147483647e+09
                      minimum: -2.147483648e+09
                      type: integer
                    v:
                      description: "Sample value at time t."
                      type: string
                  required:
                    - t
                    - v
                  type: object
                type:
                  - "null"
                  - array
              aw:
                description: >
                  Wielded-weapon change stream (t/v pairs), opt-in via
                  `fields=aw`. v is the weapon's IT_* bit — 1 SG, 2 SSG, 4 NG,
                  8 SNG, 16 GL, 32 RL, 64 LG, 4096 axe, 0 nothing held — read
                  straight from STAT_ACTIVEWEAPON. A different question from
                  the rl/lg/gl/ssg/sng interval streams, which are inventory.
                items:
                  additionalProperties: false
                  properties:
                    t:
                      description: "Event/sample time, int32 ms since match start."
                      maximum: 2.147483647e+09
                      minimum: -2.147483648e+09
                      type: integer
                    v:
                      description: "Sample value at time t."
                      maximum: 32767
                      minimum: -32768
                      type: integer
                  required:
                    - t
                    - v
                  type: object
                type:
                  - "null"
                  - array
              cl:
                description: "Cells-ammo change stream (t/v pairs)."
                items:
                  additionalProperties: false
                  properties:
                    t:
                      description: "Event/sample time, int32 ms since match start."
                      maximum: 2.147483647e+09
                      minimum: -2.147483648e+09
                      type: integer
                    v:
                      description: "Sample value at time t."
                      maximum: 32767
                      minimum: -32768
                      type: integer
                  required:
                    - t
                    - v
                  type: object
                type:
                  - "null"
                  - array
              d:
                description: "Death timestamps, int32 ms."
                items:
                  maximum: 2.147483647e+09
                  minimum: -2.147483648e+09
                  type: integer
                type:
                  - "null"
                  - array
              gl:
                description: "GL-held intervals."
                items:
                  additionalProperties: false
                  properties:
                    e:
                      description: "Interval end, int32 ms since match start (terse per-row key)."
                      maximum: 2.147483647e+09
                      minimum: -2.147483648e+09
                      type: integer
                    s:
                      description: "Interval start, int32 ms since match start (terse per-row key)."
                      maximum: 2.147483647e+09
                      minimum: -2.147483648e+09
                      type: integer
                  required:
                    - s
                    - e
                  type: object
                type:
                  - "null"
                  - array
              h:
                description: "Health change stream (t/v pairs)."
                items:
                  additionalProperties: false
                  properties:
                    t:
                      description: "Event/sample time, int32 ms since match start."
                      maximum: 2.147483647e+09
                      minimum: -2.147483648e+09
                      type: integer
                    v:
                      description: "Sample value at time t."
                      maximum: 32767
                      minimum: -32768
                      type: integer
                  required:
                    - t
                    - v
                  type: object
                type:
                  - "null"
                  - array
              hgt:
                additionalProperties: false
                description: >
                  Floor-height track: t plus the height above the floor (h). x/y/z are the
                  shared spine, null on this track. Present only when a BSP was provisioned.
                properties:
                  t:
                    description: per-sample time, int32 ms since match start (parallel to this track's value columns).
                    items:
                      maximum: 2.147483647e+09
                      minimum: -2.147483648e+09
                      type: integer
                    type:
                      - "null"
                      - array
                  h:
                    description: floor height beneath the player, map units (BSP-derived; a NoFloor sentinel marks samples with no floor to measure from).
                    items:
                      type: number
                    type:
                      - "null"
                      - array
                  x:
                    description: x position column, map units — populated only on the pos track; null on the sibling tracks that share the t/x/y/z spine.
                    items:
                      type: number
                    type:
                      - "null"
                      - array
                  y:
                    description: y position column, map units — populated only on the pos track; null on the sibling tracks that share the spine.
                    items:
                      type: number
                    type:
                      - "null"
                      - array
                  z:
                    description: z position column, map units — populated only on the pos track; null on the sibling tracks that share the spine.
                    items:
                      type: number
                    type:
                      - "null"
                      - array
                required:
                  - t
                  - x
                  - "y"
                  - z
                  - h
                type:
                  - "null"
                  - object
              lg:
                description: "LG-held intervals."
                items:
                  additionalProperties: false
                  properties:
                    e:
                      description: "Interval end, int32 ms since match start (terse per-row key)."
                      maximum: 2.147483647e+09
                      minimum: -2.147483648e+09
                      type: integer
                    s:
                      description: "Interval start, int32 ms since match start (terse per-row key)."
                      maximum: 2.147483647e+09
                      minimum: -2.147483648e+09
                      type: integer
                  required:
                    - s
                    - e
                  type: object
                type:
                  - "null"
                  - array
              li:
                description: "Loc-index change stream (t/v pairs); decode v via /loc-table."
                items:
                  additionalProperties: false
                  properties:
                    t:
                      description: "Event/sample time, int32 ms since match start."
                      maximum: 2.147483647e+09
                      minimum: -2.147483648e+09
                      type: integer
                    v:
                      description: "Sample value at time t."
                      maximum: 32767
                      minimum: -32768
                      type: integer
                  required:
                    - t
                    - v
                  type: object
                type:
                  - "null"
                  - array
              loc:
                description: "Loc-name change stream (t/v pairs)."
                items:
                  additionalProperties: false
                  properties:
                    t:
                      description: "Event/sample time, int32 ms since match start."
                      maximum: 2.147483647e+09
                      minimum: -2.147483648e+09
                      type: integer
                    v:
                      description: "Sample value at time t."
                      type: string
                  required:
                    - t
                    - v
                  type: object
                type:
                  - "null"
                  - array
              lq:
                additionalProperties: false
                description: >
                  Liquid-state track: t plus the liquid code (lq). x/y/z are the shared
                  spine, null on this track. Present only when a BSP was provisioned.
                properties:
                  t:
                    description: per-sample time, int32 ms since match start (parallel to this track's value columns).
                    items:
                      maximum: 2.147483647e+09
                      minimum: -2.147483648e+09
                      type: integer
                    type:
                      - "null"
                      - array
                  lq:
                    description: liquid state — 0 dry, else (type<<2)|level with type 1 water / 2 slime / 3 lava and submersion level 0-3 (decode via LqType / LqLevel).
                    items:
                      maximum: 127
                      minimum: -128
                      type: integer
                    type:
                      - "null"
                      - array
                  x:
                    description: x position column, map units — populated only on the pos track; null on the sibling tracks that share the t/x/y/z spine.
                    items:
                      type: number
                    type:
                      - "null"
                      - array
                  y:
                    description: y position column, map units — populated only on the pos track; null on the sibling tracks that share the spine.
                    items:
                      type: number
                    type:
                      - "null"
                      - array
                  z:
                    description: z position column, map units — populated only on the pos track; null on the sibling tracks that share the spine.
                    items:
                      type: number
                    type:
                      - "null"
                      - array
                required:
                  - t
                  - x
                  - "y"
                  - z
                  - lq
                type:
                  - "null"
                  - object
              name:
                description: "Player name."
                type: string
              nl:
                description: "Nails-ammo change stream (t/v pairs)."
                items:
                  additionalProperties: false
                  properties:
                    t:
                      description: "Event/sample time, int32 ms since match start."
                      maximum: 2.147483647e+09
                      minimum: -2.147483648e+09
                      type: integer
                    v:
                      description: "Sample value at time t."
                      maximum: 32767
                      minimum: -32768
                      type: integer
                  required:
                    - t
                    - v
                  type: object
                type:
                  - "null"
                  - array
              pe:
                description: "Pent-held intervals."
                items:
                  additionalProperties: false
                  properties:
                    e:
                      description: "Interval end, int32 ms since match start (terse per-row key)."
                      maximum: 2.147483647e+09
                      minimum: -2.147483648e+09
                      type: integer
                    s:
                      description: "Interval start, int32 ms since match start (terse per-row key)."
                      maximum: 2.147483647e+09
                      minimum: -2.147483648e+09
                      type: integer
                  required:
                    - s
                    - e
                  type: object
                type:
                  - "null"
                  - array
              pos:
                additionalProperties: false
                description: >
                  Position track: the t/x/y/z spine plus the per-sample loc index (li). This
                  is the only track that fills x/y/z; sibling tracks (view/hgt/lq/vel) share
                  the spine but null it.
                properties:
                  t:
                    description: per-sample time, int32 ms since match start (parallel to this track's value columns).
                    items:
                      maximum: 2.147483647e+09
                      minimum: -2.147483648e+09
                      type: integer
                    type:
                      - "null"
                      - array
                  li:
                    description: per-sample loc-table index; decode via /loc-table.
                    items:
                      maximum: 32767
                      minimum: -32768
                      type: integer
                    type:
                      - "null"
                      - array
                  x:
                    description: x position column, map units — populated only on the pos track; null on the sibling tracks that share the t/x/y/z spine.
                    items:
                      type: number
                    type:
                      - "null"
                      - array
                  y:
                    description: y position column, map units — populated only on the pos track; null on the sibling tracks that share the spine.
                    items:
                      type: number
                    type:
                      - "null"
                      - array
                  z:
                    description: z position column, map units — populated only on the pos track; null on the sibling tracks that share the spine.
                    items:
                      type: number
                    type:
                      - "null"
                      - array
                required:
                  - t
                  - x
                  - "y"
                  - z
                type:
                  - "null"
                  - object
              q:
                description: "Quad-held intervals."
                items:
                  additionalProperties: false
                  properties:
                    e:
                      description: "Interval end, int32 ms since match start (terse per-row key)."
                      maximum: 2.147483647e+09
                      minimum: -2.147483648e+09
                      type: integer
                    s:
                      description: "Interval start, int32 ms since match start (terse per-row key)."
                      maximum: 2.147483647e+09
                      minimum: -2.147483648e+09
                      type: integer
                  required:
                    - s
                    - e
                  type: object
                type:
                  - "null"
                  - array
              r:
                description: "Ring (invisibility) held intervals."
                items:
                  additionalProperties: false
                  properties:
                    e:
                      description: "Interval end, int32 ms since match start (terse per-row key)."
                      maximum: 2.147483647e+09
                      minimum: -2.147483648e+09
                      type: integer
                    s:
                      description: "Interval start, int32 ms since match start (terse per-row key)."
                      maximum: 2.147483647e+09
                      minimum: -2.147483648e+09
                      type: integer
                  required:
                    - s
                    - e
                  type: object
                type:
                  - "null"
                  - array
              rk:
                description: "Rockets-ammo change stream (t/v pairs)."
                items:
                  additionalProperties: false
                  properties:
                    t:
                      description: "Event/sample time, int32 ms since match start."
                      maximum: 2.147483647e+09
                      minimum: -2.147483648e+09
                      type: integer
                    v:
                      description: "Sample value at time t."
                      maximum: 32767
                      minimum: -32768
                      type: integer
                  required:
                    - t
                    - v
                  type: object
                type:
                  - "null"
                  - array
              rl:
                description: "RL-held intervals."
                items:
                  additionalProperties: false
                  properties:
                    e:
                      description: "Interval end, int32 ms since match start (terse per-row key)."
                      maximum: 2.147483647e+09
                      minimum: -2.147483648e+09
                      type: integer
                    s:
                      description: "Interval start, int32 ms since match start (terse per-row key)."
                      maximum: 2.147483647e+09
                      minimum: -2.147483648e+09
                      type: integer
                  required:
                    - s
                    - e
                  type: object
                type:
                  - "null"
                  - array
              sh:
                description: "Shells-ammo change stream (t/v pairs)."
                items:
                  additionalProperties: false
                  properties:
                    t:
                      description: "Event/sample time, int32 ms since match start."
                      maximum: 2.147483647e+09
                      minimum: -2.147483648e+09
                      type: integer
                    v:
                      description: "Sample value at time t."
                      maximum: 32767
                      minimum: -32768
                      type: integer
                  required:
                    - t
                    - v
                  type: object
                type:
                  - "null"
                  - array
              sng:
                description: "SNG-held intervals."
                items:
                  additionalProperties: false
                  properties:
                    e:
                      description: "Interval end, int32 ms since match start (terse per-row key)."
                      maximum: 2.147483647e+09
                      minimum: -2.147483648e+09
                      type: integer
                    s:
                      description: "Interval start, int32 ms since match start (terse per-row key)."
                      maximum: 2.147483647e+09
                      minimum: -2.147483648e+09
                      type: integer
                  required:
                    - s
                    - e
                  type: object
                type:
                  - "null"
                  - array
              sp:
                description: "Spawn timestamps, int32 ms."
                items:
                  maximum: 2.147483647e+09
                  minimum: -2.147483648e+09
                  type: integer
                type:
                  - "null"
                  - array
              ssg:
                description: "SSG-held intervals."
                items:
                  additionalProperties: false
                  properties:
                    e:
                      description: "Interval end, int32 ms since match start (terse per-row key)."
                      maximum: 2.147483647e+09
                      minimum: -2.147483648e+09
                      type: integer
                    s:
                      description: "Interval start, int32 ms since match start (terse per-row key)."
                      maximum: 2.147483647e+09
                      minimum: -2.147483648e+09
                      type: integer
                  required:
                    - s
                    - e
                  type: object
                type:
                  - "null"
                  - array
              vel:
                additionalProperties: false
                description: >
                  Velocity track: t plus the vx/vy/vz velocity columns. x/y/z are the shared
                  spine, null on this track.
                properties:
                  t:
                    description: per-sample time, int32 ms since match start (parallel to this track's value columns).
                    items:
                      maximum: 2.147483647e+09
                      minimum: -2.147483648e+09
                      type: integer
                    type:
                      - "null"
                      - array
                  vx:
                    description: velocity x column, Quake units per second (central difference).
                    items:
                      type: number
                    type:
                      - "null"
                      - array
                  vy:
                    description: velocity y column, Quake units per second.
                    items:
                      type: number
                    type:
                      - "null"
                      - array
                  vz:
                    description: velocity z column, Quake units per second.
                    items:
                      type: number
                    type:
                      - "null"
                      - array
                  x:
                    description: x position column, map units — populated only on the pos track; null on the sibling tracks that share the t/x/y/z spine.
                    items:
                      type: number
                    type:
                      - "null"
                      - array
                  y:
                    description: y position column, map units — populated only on the pos track; null on the sibling tracks that share the spine.
                    items:
                      type: number
                    type:
                      - "null"
                      - array
                  z:
                    description: z position column, map units — populated only on the pos track; null on the sibling tracks that share the spine.
                    items:
                      type: number
                    type:
                      - "null"
                      - array
                required:
                  - t
                  - x
                  - "y"
                  - z
                  - vx
                  - vy
                  - vz
                type:
                  - "null"
                  - object
              view:
                additionalProperties: false
                description: >
                  View-direction track: t plus view pitch (vp) and yaw (vya) as raw angle16
                  shorts. x/y/z are the shared spine, null on this track.
                properties:
                  t:
                    description: per-sample time, int32 ms since match start (parallel to this track's value columns).
                    items:
                      maximum: 2.147483647e+09
                      minimum: -2.147483648e+09
                      type: integer
                    type:
                      - "null"
                      - array
                  vp:
                    description: view pitch, raw angle16 wire short — degrees = ((v mod 65536) + 65536) mod 65536 x 360 / 65536 (>180 = looking up); contrast /aim's float-degree dpitch.
                    items:
                      maximum: 32767
                      minimum: -32768
                      type: integer
                    type:
                      - "null"
                      - array
                  vya:
                    description: view yaw, raw angle16 wire short — degrees = ((v mod 65536) + 65536) mod 65536 x 360 / 65536; contrast /aim's float-degree dyaw.
                    items:
                      maximum: 32767
                      minimum: -32768
                      type: integer
                    type:
                      - "null"
                      - array
                  x:
                    description: x position column, map units — populated only on the pos track; null on the sibling tracks that share the t/x/y/z spine.
                    items:
                      type: number
                    type:
                      - "null"
                      - array
                  y:
                    description: y position column, map units — populated only on the pos track; null on the sibling tracks that share the spine.
                    items:
                      type: number
                    type:
                      - "null"
                      - array
                  z:
                    description: z position column, map units — populated only on the pos track; null on the sibling tracks that share the spine.
                    items:
                      type: number
                    type:
                      - "null"
                      - array
                required:
                  - t
                  - x
                  - "y"
                  - z
                  - vp
                  - vya
                type:
                  - "null"
                  - object
            required:
              - name
              - alive
            type: object
          type: array
        start:
          type: integer
          description: Window start, int32 ms (the exact bound used for slicing).
      required:
        - timeUnit
        - start
        - end
        - players
      type: object
    StateAt:
      additionalProperties: false
      properties:
        timeUnit: { type: string, enum: [ms], description: "Constant \"ms\" self-description echo — in the v57 pure-ms model every time value is int32 milliseconds." }
        players:
          description: "Per-player entries."
          additionalProperties:
            additionalProperties: false
            properties:
              a:
                description: "Armor value at the instant."
                maximum: 32767
                minimum: -32768
                type:
                  - "null"
                  - integer
              alive:
                description: >
                  Whether the player was alive at the instant, read from the
                  canonical stored liveness (`streams.players[].alive`) and
                  not re-derived from the `sp`/`d` markers. Always present
                  and never field-gated, because `null` is a state of its
                  own: `true`/`false` = measured, `null` = liveness was not
                  measurable for this player.
                type:
                  - "null"
                  - boolean
              at:
                description: "Armor type at the instant."
                type:
                  - "null"
                  - string
              aw:
                description: >
                  Wielded weapon at the instant as its IT_* bit — 1 SG, 2 SSG,
                  4 NG, 8 SNG, 16 GL, 32 RL, 64 LG, 4096 axe, 0 nothing held.
                  Opt-in via `fields=aw`. A different question from the
                  rl/lg/gl/ssg/sng booleans, which are inventory.
                maximum: 32767
                minimum: -32768
                type:
                  - "null"
                  - integer
              cl:
                description: "Cells ammo at the instant."
                maximum: 32767
                minimum: -32768
                type:
                  - "null"
                  - integer
              gl:
                description: "Holding GL at the instant."
                type:
                  - "null"
                  - boolean
              h:
                description: "Health value at the instant."
                maximum: 32767
                minimum: -32768
                type:
                  - "null"
                  - integer
              hgt:
                description: "Height above the floor at the instant, map units (BSP-derived)."
                type:
                  - "null"
                  - number
              lg:
                description: "Holding LG at the instant."
                type:
                  - "null"
                  - boolean
              li:
                description: "Loc-table index; decode via /loc-table."
                maximum: 32767
                minimum: -32768
                type:
                  - "null"
                  - integer
              loc:
                description: "Nearest named location from the map .loc file."
                type:
                  - "null"
                  - string
              lq:
                description: "Liquid code at the instant (0 dry, else (type<<2)|level)."
                maximum: 127
                minimum: -128
                type:
                  - "null"
                  - integer
              nl:
                description: "Nails ammo at the instant."
                maximum: 32767
                minimum: -32768
                type:
                  - "null"
                  - integer
              pe:
                description: "Pent active at the instant."
                type:
                  - "null"
                  - boolean
              pos:
                description: "Position [x,y,z] at the instant, map units."
                additionalProperties: false
                properties:
                  x:
                    description: "X coordinate, map units."
                    type: number
                  "y":
                    description: "Y coordinate, map units."
                    type: number
                  z:
                    description: "Z coordinate, map units."
                    type: number
                required:
                  - x
                  - "y"
                  - z
                type:
                  - "null"
                  - object
              posAgeMs:
                description: |
                  Age of the position sample every positional field on this
                  row (`pos`, `view`, `hgt`, `lq`, `vel`) was snapped to:
                  `time` minus that sample's timestamp, int32 ms. Positive
                  means the state is carried forward from an earlier sample;
                  negative means the nearest sample is a LATER one.

                  The carry-forward itself is unbounded by design — this is
                  the evidence age, so a consumer can apply the same 250 ms
                  discipline the occupancy endpoints do (`/region-control`,
                  `/loc-graph` and `/loc-trails` drop a sample once its age
                  reaches 250 ms).

                  Present only when the request asked for at least one
                  positional field AND a sample resolved — a field set with no
                  positional field in it never carries this key, which
                  therefore says nothing about whether the demo has a position
                  track.
                maximum: 2.147483647e+09
                minimum: -2.147483648e+09
                type: integer
              q:
                description: "Quad active at the instant."
                type:
                  - "null"
                  - boolean
              r:
                description: "Ring (invisibility) active at the instant."
                type:
                  - "null"
                  - boolean
              rk:
                description: "Rockets ammo at the instant."
                maximum: 32767
                minimum: -32768
                type:
                  - "null"
                  - integer
              rl:
                description: "Holding RL at the instant."
                type:
                  - "null"
                  - boolean
              sh:
                description: "Shells ammo at the instant."
                maximum: 32767
                minimum: -32768
                type:
                  - "null"
                  - integer
              sng:
                description: "Holding SNG at the instant."
                type:
                  - "null"
                  - boolean
              ssg:
                description: "Holding SSG at the instant."
                type:
                  - "null"
                  - boolean
              vel:
                description: "Velocity [vx,vy,vz] at the instant, Quake units per second."
                additionalProperties: false
                properties:
                  vx:
                    description: "Velocity X, Quake units per second."
                    type: number
                  vy:
                    description: "Velocity Y, Quake units per second."
                    type: number
                  vz:
                    description: "Velocity Z, Quake units per second."
                    type: number
                required:
                  - vx
                  - vy
                  - vz
                type:
                  - "null"
                  - object
              view:
                additionalProperties: false
                description: View direction at the instant (raw angle16 shorts).
                properties:
                  vp:
                    description: view pitch, raw angle16 wire short — degrees = ((v mod 65536) + 65536) mod 65536 x 360 / 65536 (>180 = looking up); contrast /aim's float-degree dpitch.
                    maximum: 32767
                    minimum: -32768
                    type: integer
                  vya:
                    description: view yaw, raw angle16 wire short — degrees = ((v mod 65536) + 65536) mod 65536 x 360 / 65536; contrast /aim's float-degree dyaw.
                    maximum: 32767
                    minimum: -32768
                    type: integer
                required:
                  - vp
                  - vya
                type:
                  - "null"
                  - object
            required:
              - alive
            type: object
          type:
            - object
            - "null"
        time:
          type: integer
          description: Sample instant, int32 ms.
      required:
        - timeUnit
        - time
        - players
      type: object
    LocTrails:
      additionalProperties: false
      properties:
        timeUnit: { type: string, enum: [ms], description: "Constant \"ms\" self-description echo — in the v57 pure-ms model every time value is int32 milliseconds." }
        players:
          description: "Per-player loc trails."
          items:
            additionalProperties: false
            properties:
              name:
                description: "Name / display label."
                type: string
              sequence:
                description: "Ordered loc residences for this player. Alive-gated (truncated at a death, resumed at the respawn) and bounded at the player's end-of-track since schema v64; a minDwellMs fold never merges across those gaps, so a residence following one is returned even when shorter than minDwellMs."
                items:
                  additionalProperties: false
                  properties:
                    end:
                      type: integer
                      description: Residence end, int32 ms.
                    li:
                      description: "Loc-table index; decode via /loc-table."
                      maximum: 32767
                      minimum: -32768
                      type:
                        - "null"
                        - integer
                    loc:
                      description: "Nearest named location from the map .loc file."
                      type: string
                    start:
                      type: integer
                      description: Residence start, int32 ms.
                  required:
                    - start
                    - end
                  type: object
                type:
                  - "null"
                  - array
            required:
              - name
              - sequence
            type: object
          type: array
      required:
        - timeUnit
        - players
      type: object
    MapGeometry:
      additionalProperties: false
      properties:
        bounds:
          description: "Axis-aligned bounding box in world coords."
          additionalProperties: false
          properties:
            maxX:
              description: "Maximum X, map units."
              type: number
            maxY:
              description: "Maximum Y, map units."
              type: number
            minX:
              description: "Minimum X, map units."
              type: number
            minY:
              description: "Minimum Y, map units."
              type: number
          required:
            - minX
            - maxX
            - minY
            - maxY
          type: object
        liquids:
          description: "Liquid-surface triangles by kind."
          items:
            additionalProperties: false
            properties:
              kind:
                description: "Item kind — compact vocabulary (rl/lg/ra/mh/quad/…)."
                type: string
              tris:
                description: "Flattened triangle vertex buffer (float array)."
                items:
                  type: number
                type:
                  - "null"
                  - array
            required:
              - kind
              - tris
            type: object
          type:
            - "null"
            - array
        locs:
          description: "Location nodes / membership list."
          items:
            additionalProperties: false
            properties:
              name:
                description: "Loc name."
                type: string
              tris:
                description: "Flattened triangle vertex buffer (float array)."
                items:
                  type: number
                type:
                  - "null"
                  - array
              z:
                description: "Z coordinate, map units."
                type: number
            required:
              - name
              - z
              - tris
            type: object
          type:
            - "null"
            - array
        map:
          description: "Map name."
          type: string
        pruned:
          description: "Provenance of dropped geometry (empty-map pruning)."
          additionalProperties: false
          properties:
            demos:
              description: "Demos considered."
              type: integer
            facesDropped:
              description: "BSP faces dropped."
              type: integer
            points:
              description: "Rendering anchors (MapLocation points) for the region overlay."
              type: integer
          required:
            - demos
            - points
            - facesDropped
          type:
            - "null"
            - object
        submodels:
          description: "Brush submodel triangle sets."
          items:
            additionalProperties: false
            properties:
              id:
                description: "Identifier."
                type: integer
              tris:
                description: "Flattened triangle vertex buffer (float array)."
                items:
                  type: number
                type:
                  - "null"
                  - array
            required:
              - id
              - tris
            type: object
          type:
            - "null"
            - array
        version:
          description: "Version."
          type: integer
        walls:
          description: "Wall triangle vertex buffer (flat float array)."
          items:
            type: number
          type:
            - "null"
            - array
      required:
        - map
        - version
        - bounds
        - locs
      type: object
    # --- Wrappers over the generated struct schemas: bare-array bodies,
    # layout/summary unions, single-key stream envelopes, and the generic
    # artifact envelope union.
    Chat:
      type: object
      description: >
        /chat body — `{timeUnit, messages}`. messages are
        result.MatchEvent entries; each `time` is int32 ms (timeUnit is the
        constant `ms` echo). messages is [] when nothing matches.
      required: [timeUnit, messages]
      additionalProperties: false
      properties:
        timeUnit: { type: string, enum: [ms], description: "Constant \"ms\" self-description echo — in the v57 pure-ms model every time value is int32 milliseconds." }
        messages:
          description: "Match message log — frag/chat/teamsay lines in match order."
          type: array
          items: { $ref: "#/components/schemas/MatchEvent" }
    Backpacks:
      type: object
      description: >
        /backpacks body — `{timeUnit, backpacks}`; backpacks is
        [] when none. `time` is int32 ms (timeUnit is the constant `ms` echo).
      required: [timeUnit, backpacks]
      additionalProperties: false
      properties:
        timeUnit: { type: string, enum: [ms], description: "Constant \"ms\" self-description echo — in the v57 pure-ms model every time value is int32 milliseconds." }
        backpacks:
          description: "Dropped RL/LG backpack list."
          type: array
          items: { $ref: "#/components/schemas/BackpackDrop" }
    WeaponPickups:
      type: object
      description: >
        /weapon-pickups body — `{timeUnit, pickups}`; pickups is
        [] when none. `time`/`nextDeathTime`/`dropTime` are int32 ms (timeUnit
        is the constant `ms` echo).
      required: [timeUnit, pickups]
      additionalProperties: false
      properties:
        timeUnit: { type: string, enum: [ms], description: "Constant \"ms\" self-description echo — in the v57 pure-ms model every time value is int32 milliseconds." }
        pickups:
          description: "Weapon-pickup list."
          type: array
          items: { $ref: "#/components/schemas/WeaponPickup" }
    Airgibs:
      type: object
      description: >
        /airgibs body — `{timeUnit, preMs, airgibs}`; airgibs is sorted
        by height descending and [] on BSP-less maps. `time` is int32 ms
        (timeUnit is the constant `ms` echo).
      required: [timeUnit, preMs, airgibs]
      additionalProperties: false
      properties:
        timeUnit: { type: string, enum: [ms], description: "Constant \"ms\" self-description echo — in the v57 pure-ms model every time value is int32 milliseconds." }
        preMs:
          description: "Effective pre-hit look-back in ms the list was detected with (default 100; 0 = the pre-hit airborne gate was off)."
          type: integer
        airgibs:
          description: "Airborne rocket-hit highlights, sorted by height descending."
          type: array
          items: { $ref: "#/components/schemas/AirgibEvent" }
    Items:
      description: >
        /items body — per-item phase timelines, or per-item take
        aggregates when summary is set.
      anyOf: # not oneOf: an empty {"items": []} matches both branches
        - $ref: "#/components/schemas/ItemsTimelines"
        - $ref: "#/components/schemas/ItemsSummary"
    Buckets:
      description: >
        /buckets body — the compact columnar grid (layout=column, the
        default) or row objects (layout=row).
      oneOf:
        - $ref: "#/components/schemas/BucketsColumnar"
        - $ref: "#/components/schemas/BucketsRow"
    Los:
      type: object
      description: >
        /los body (and the lazy `los` artifact) — per-player LOS/PVS
        interval sets. `los`/`pvs` are omitted for a player with no
        sightlines. A map with no usable visibility BSP is a 422
        `los_unavailable`, not an empty body.
      required: [timeUnit, players]
      additionalProperties: false
      properties:
        timeUnit: { type: string, enum: [ms], description: "Constant \"ms\" self-description echo — in the v57 pure-ms model every time value is int32 milliseconds." }
        players:
          description: "Per-player line-of-sight tracks."
          type: array
          items:
            type: object
            required: [name]
            additionalProperties: false
            properties:
              name: { type: string, description: "Player name." }
              los:
                description: "Line-of-sight intervals to other players."
                type: array
                items: { $ref: "#/components/schemas/LosTrack" }
              pvs:
                description: "Potentially-visible-set intervals to other players."
                type: array
                items: { $ref: "#/components/schemas/LosTrack" }
    Projectiles:
      type: object
      required: [timeUnit, projectiles]
      additionalProperties: false
      properties:
        timeUnit: { type: string, enum: [ms], description: "Constant \"ms\" self-description echo — in the v57 pure-ms model every time value is int32 milliseconds." }
        projectiles:
          description: Rocket/grenade flight stream; null when the demo has none.
          oneOf:
            - $ref: "#/components/schemas/ProjectileStreams"
            - type: "null"
    Beams:
      type: object
      required: [timeUnit, beams]
      additionalProperties: false
      properties:
        timeUnit: { type: string, enum: [ms], description: "Constant \"ms\" self-description echo — in the v57 pure-ms model every time value is int32 milliseconds." }
        beams:
          description: LG bolt stream; null when the demo has none.
          oneOf:
            - $ref: "#/components/schemas/BeamStreams"
            - type: "null"
    Nails:
      type: object
      required: [timeUnit, nails]
      additionalProperties: false
      properties:
        timeUnit: { type: string, enum: [ms], description: "Constant \"ms\" self-description echo — in the v57 pure-ms model every time value is int32 milliseconds." }
        nails:
          description: Nail flight stream; null when the demo has none.
          oneOf:
            - $ref: "#/components/schemas/ProjectileStreams"
            - type: "null"
    PointEffects:
      type: object
      required: [timeUnit, pointEffects]
      additionalProperties: false
      properties:
        timeUnit: { type: string, enum: [ms], description: "Constant \"ms\" self-description echo — in the v57 pure-ms model every time value is int32 milliseconds." }
        types:
          description: >
            Legend: TE code (as a string key) → effect name, covering the
            codes present in the DEMO (pre-filter), so a `types=`-filtered
            response still shows what else it could ask for. Omitted when
            the demo carried no point effects. Codes without a name legend
            as "te<code>".
          type: object
          additionalProperties: { type: string }
        pointEffects:
          description: >
            Point-effect stream; null when the demo carried none. A
            `types=` filter that matches nothing returns empty columns,
            not null.
          oneOf:
            - $ref: "#/components/schemas/PointEffectStreams"
            - type: "null"
    LocTable:
      type: object
      required: [locTable]
      additionalProperties: false
      properties:
        locTable:
          type: array
          description: Interned loc names; index 0 is the "" no-loc sentinel.
          items: { type: string }
    Graph:
      type: object
      description: The analyzer DAG — analyzer.ExportGraph("json").
      required: [nodes, edges]
      additionalProperties: false
      properties:
        nodes:
          description: "Analyzer DAG nodes."
          type: [array, "null"]
          items:
            type: object
            required: [name, requires, provides, mutates, lazy, depth, cost, resultKey]
            additionalProperties: false
            properties:
              name: { type: string, description: "Name / display label." }
              requires:
                description: "Upstream nodes this node depends on."
                type: [array, "null"]
                items: { type: string }
              provides:
                description: "Result sections this node provides."
                type: [array, "null"]
                items: { type: string }
              mutates: { type: boolean, description: "Result sections this node mutates." }
              lazy: { type: boolean, description: "Materialised on demand (not in the base parse)." }
              depth: { type: integer, description: "DAG depth." }
              cost:
                description: "Materialisation cost class."
                type: string
                enum: [light, heavy]
              resultKey: { type: string, description: "Result JSON key this artifact serves under." }
        edges:
          description: "Directed loc-transition edges."
          type: [array, "null"]
          items:
            type: object
            required: [from, to, artifact]
            additionalProperties: false
            properties:
              from: { type: string, description: "Edge source." }
              to: { type: string, description: "Edge target." }
              artifact: { type: string, description: "Artifact name this edge carries." }
    ArtifactEnvelope:
      description: >
        GET /v1/demos/{id}/artifacts/{name} body — a single-key envelope
        keyed by the artifact's resultKey (see GET /v1/artifacts; the key
        is NOT the node name — e.g. node `frag` serves `{"frags": …}`).
        Raw list/object sections (match, messages, timeline, items,
        map-entities, backpacks, weapon-pickups) may be null at 200 for
        demos without the signal; capability-gated artifacts return their
        422 code instead. The lazy `los` artifact serves the /los body
        shape (no resultKey).
      oneOf:
        - type: object
          required: [demoInfo]
          additionalProperties: false
          properties:
            demoInfo: { $ref: "#/components/schemas/DemoInfo" }
        - type: object
          required: [frags, timeUnit]
          additionalProperties: false
          properties:
            frags: { $ref: "#/components/schemas/Frags" }
            timeUnit: { $ref: "#/components/schemas/TimeUnit" }
        - type: object
          required: [metadata]
          additionalProperties: false
          properties:
            metadata: { $ref: "#/components/schemas/Metadata" }
        - type: object
          required: [damage, timeUnit]
          additionalProperties: false
          properties:
            damage: { $ref: "#/components/schemas/Damage" }
            timeUnit: { $ref: "#/components/schemas/TimeUnit" }
        - type: object
          required: [shots, timeUnit]
          additionalProperties: false
          properties:
            shots: { $ref: "#/components/schemas/Shots" }
            timeUnit: { $ref: "#/components/schemas/TimeUnit" }
        - type: object
          required: [aim, timeUnit]
          additionalProperties: false
          properties:
            aim: { $ref: "#/components/schemas/Aim" }
            timeUnit: { $ref: "#/components/schemas/TimeUnit" }
        - type: object
          required: [locGraph, timeUnit]
          additionalProperties: false
          properties:
            locGraph: { $ref: "#/components/schemas/LocGraph" }
            timeUnit: { $ref: "#/components/schemas/TimeUnit" }
        - type: object
          required: [opening, timeUnit]
          additionalProperties: false
          properties:
            opening: { $ref: "#/components/schemas/Opening" }
            timeUnit: { $ref: "#/components/schemas/TimeUnit" }
        - type: object
          required: [match, timeUnit]
          additionalProperties: false
          properties:
            match:
              description: Match summary section (null on demos without it).
              oneOf:
                - $ref: "#/components/schemas/Match"
                - type: "null"
            timeUnit: { $ref: "#/components/schemas/TimeUnit" }
        - type: object
          required: [noMatch, timeUnit]
          additionalProperties: false
          properties:
            noMatch:
              description: >
                No-match marker (null on the 98% of demos that DO hold an
                analyzable match — that null is the answer).
              oneOf:
                - $ref: "#/components/schemas/NoMatch"
                - type: "null"
            timeUnit: { $ref: "#/components/schemas/TimeUnit" }
        - type: object
          required: [messages, timeUnit]
          additionalProperties: false
          properties:
            messages:
              description: Match message log section (null on demos without it).
              oneOf:
                - $ref: "#/components/schemas/Messages"
                - type: "null"
            timeUnit: { $ref: "#/components/schemas/TimeUnit" }
        - type: object
          required: [timelineAnalysis, timeUnit]
          additionalProperties: false
          properties:
            timelineAnalysis:
              description: Event-shaped derived timeline section (null on demos without it).
              oneOf:
                - $ref: "#/components/schemas/TimelineAnalysis"
                - type: "null"
            timeUnit: { $ref: "#/components/schemas/TimeUnit" }
        - type: object
          required: [items, timeUnit]
          additionalProperties: false
          properties:
            items:
              description: Per-item pickup-timeline section (null on demos without it).
              oneOf:
                - $ref: "#/components/schemas/ItemsTimelines"
                - type: "null"
            timeUnit: { $ref: "#/components/schemas/TimeUnit" }
        - type: object
          required: [mapEntities]
          additionalProperties: false
          properties:
            mapEntities:
              description: Static map-entity layout section (null when no corpus exists for the map).
              oneOf:
                - $ref: "#/components/schemas/MapEntities"
                - type: "null"
        - type: object
          required: [backpacks, timeUnit]
          additionalProperties: false
          properties:
            backpacks:
              description: Dropped RL/LG backpack list (null on demos without it).
              type: [array, "null"]
              items: { $ref: "#/components/schemas/BackpackDrop" }
            timeUnit: { $ref: "#/components/schemas/TimeUnit" }
        - type: object
          required: [weaponPickups, timeUnit]
          additionalProperties: false
          properties:
            weaponPickups:
              description: Slot-weapon pickup list (null on demos without it).
              type: [array, "null"]
              items: { $ref: "#/components/schemas/WeaponPickup" }
            timeUnit: { $ref: "#/components/schemas/TimeUnit" }
        - type: object
          required: [playerStats, timeUnit]
          additionalProperties: false
          properties:
            playerStats:
              description: >
                Canonical per-player statistics section. Never null, unlike most sibling artifact
                envelopes: a parse degraded enough to produce no player streams 422s with
                `playerstats_unavailable` instead. A missing KTX demoinfo block is NOT that case —
                the section is computed for every demo.
              $ref: "#/components/schemas/PlayerStats"
            timeUnit: { $ref: "#/components/schemas/TimeUnit" }
        - $ref: "#/components/schemas/Los"
