# VT Stats — Developer Guide & Technical Specification

## 1. Project Architecture

VT Stats is a **static-site dashboard** for Battlezone: Combat Commander match statistics. There is no backend framework, no build tools (Webpack/Vite), and no server-side rendering. The architecture has two parts:

1. **Python Data Pipeline** — reads raw `.binpb.gz` protobuf session data, aggregates stats, and outputs small pre-computed JSON files.
2. **Static Browser Dashboard** — loads the pre-computed JSON and renders interactive charts and tables.

### File Map

```
vt-stats/
├── index.html                  # Dashboard entry point (tabbed layout)
├── css/
│   ├── theme-system.css        # CSS variable foundation (--kb-*)
│   ├── themes.css              # 44 theme definitions (light + dark modes)
│   ├── main.css                # Component styles (cards, badges, tables, etc.)
│   ├── layout.css              # Page layout (sidebar, content, responsive)
│   └── vtstats-theme.css       # Glassmorphic theme layer (glass, depth, typography, animations)
├── js/
│   ├── theme.js                # Theme switching, search, persistence
│   ├── vtstats-fx.js           # Effects engine (counters, stagger, Chart.js shadow plugin)
│   ├── charts.js               # Chart.js renderers (theme-aware, glass tooltips)
│   └── app.js                  # Main application logic, lazy tab rendering, global player/team filter
├── vendor/
│   ├── bootstrap/css/          # Bootstrap 5.3.2 (minified)
│   ├── bootstrap/js/           # Bootstrap 5.3.2 JS bundle (minified)
│   ├── bootstrap-icons/        # Bootstrap Icons CSS + woff/woff2 fonts
│   ├── chartjs/                # Chart.js 4.4.7 UMD bundle
│   └── fonts/                  # Geist Sans 1.8.0 + Geist Mono 1.8.0 (variable woff2)
├── data/
│   ├── odf.min.json            # Weapon name resolution database (ODF)
│   ├── proto-docs.json         # Field-comment index (built by extract_proto_docs.py)
│   ├── map-registry.json       # Map metadata registry (built by build_map_registry.py)
│   ├── vsrmaplist.json         # Vendored upstream BZCC-Website map index
│   │                           # (refresh via scripts/refresh_vsrmaplist.py)
│   ├── maps/                   # Per-map assets (image + metadata JSON)
│   │   ├── havenvsr.png        # Top-down map image (from iondriver)
│   │   ├── havenvsr.json       # Per-map metadata (title, author, canonical_size,
│   │   │                       #   pools, loose, tags, formatted_size, netVars, ...)
│   │   └── ...
│   ├── sessions/               # Raw session data (organized by submitter)
│   │   └── VTrider/
│   │       ├── 2026-04-16-01-27-48.binpb.gz
│   │       └── ...
│   └── processed/              # Pipeline output (auto-generated)
│       ├── matches.json        # Match manifest
│       ├── 2026-04-16T01-27-48.json   # Per-match pre-computed stats
│       └── match_contributions.json   # Slim per-match contributions used by
│                                      # the client-side All Matches aggregator
│                                      # (replaces the legacy all_matches.json,
│                                      #  which is no longer written)
├── scripts/
│   ├── statsgate.proto         # Canonical protobuf schema
│   ├── statsgate_pb2.py        # Compiled proto (auto-generated by protoc)
│   ├── process_stats.py        # Data processing pipeline
│   ├── extract_proto_docs.py   # Proto field-comment extractor (invoked by process_stats)
│   ├── build_map_registry.py   # Map metadata/image fetcher (invoked by process_stats)
│   ├── refresh_vsrmaplist.py   # One-shot helper: re-fetch data/vsrmaplist.json from upstream
│   └── requirements.txt        # Python dependencies (protobuf>=4.25.0)
└── .cursor/rules/              # AI assistant rules
```

### Data Flow

```
data/sessions/<username>/*.binpb.gz → [process_stats.py] → data/processed/*.json → [browser] → dashboard
                                           ↑                         │
                                     data/odf.min.json               │
                                                                     ↓
                   [build_map_registry.py] → data/map-registry.json + data/maps/*
                            ↑
                   data/vsrmaplist.json (PRIMARY: vendored BZCC-Website index;
                                         Pools, Loose, Tags, formatted_size,
                                         Author, Description, Image URL)
                   iondriver.com/bzcc/getdata.php (per-map enrichment: netVars
                                                   + title/description fallback)
                   js/bz2api.js (LEGACY FALLBACK: VSR_MAP_DATA baked-in catalog)
```

The map registry is built at the end of every pipeline run. It's idempotent: per-map assets cached in `data/maps/<mapFile>.{png,json}` are reused on subsequent runs with zero network traffic; only new maps trigger a fetch.

### Pipeline Invocation

`python scripts/process_stats.py` is the single entrypoint. It runs in **sync-default + incremental** mode: pulls the sibling `statsgate/` git clone (`--ff-only`), additively mirrors any new `.binpb.gz` files into `data/sessions/`, then processes only matches whose source is new or has changed size since the last run. Flags:

| Flag | Effect |
|---|---|
| (none) | Sync upstream + process incrementally. Default for the "VTrider uploaded new data" workflow. |
| `--no-sync` | Skip git pull + mirror; process only what's already in `data/sessions/`. Use offline. |
| `--sync-only` | Sync then exit; don't process. |
| `--force` | Ignore the per-match cache; reprocess every match. Composes with sync flags. |
| `--no-prompt` | Skip the outcome-review prompts (automation/CI). Unreviewed matches publish with their interim resolution and prompt on the next interactive run. |
| `--force-prompt` | Prompt even without a TTY (testing aid: answers read from piped stdin; EOF defers the rest). Mutually exclusive with `--no-prompt`. |
| `--adjudicate-all` | Widen outcome review to the pre-v3 legacy corpus (optional winner backfill; default reviews proto v3+ matches only). |

`--no-sync` and `--sync-only` are mutually exclusive. Missing `statsgate/` is a soft-skip (supports the manual-drop-only workflow without `--no-sync`); failed `git pull` is a hard-fail with a `--no-sync` hint. Sync is strictly additive — never deletes from `data/sessions/`, never overwrites existing files (size-mismatch warns), so local-only submitter folders are preserved.

**The pipeline is interactive by default for new data** (v16): every proto v3+ match not yet in `data/match_outcome_adjudications.json` prompts once for a human outcome sign-off (see the Match Winner section + `scripts/adjudication.py`). Any automation invoking the pipeline must pass `--no-prompt` or it will appear to hang waiting on stdin when new matches arrive.

The cache key is `(match.submitter, match.source_file, match.source_size_bytes)` plus a top-level `PIPELINE_VERSION` constant (currently `1`). **Bump `PIPELINE_VERSION` whenever `process_match()` or any helper it transitively calls (positioning, highlights, weapon meta, rivalry matrices, `_extract_contribution`, etc.) changes output semantics.** Stale cache entries are reprocessed automatically. The version is orthogonal to `match.schema_version`: that one is a frontend contract (read by `js/app.js` to gate rendering); `pipeline_version` is an internal cache invalidator. If you ever forget to bump and notice stale data in the dashboard, run with `--force` once to rebuild from scratch.

---

## 2. Data Schema — Protobuf

The raw match data uses Protocol Buffers (protobuf). Four schema versions are supported simultaneously (three descriptors — v4 is purely additive over v3, so no v3 freeze was needed):

- **v4 (current)** — `scripts/statsgate.proto`. The definitive reference for new code. Adds commander economy + build telemetry, all additive: `ResourceState team1_resources = 3` / `team2_resources = 4` on `UpdateTick` (per-tick team scrap bank — `current_scrap`, `max_scrap`, derived `ScrapStatus scrap_status`, `pool_count`, `upgrade_count`), the `StatEvent` oneof arm `BuildEvent build_event = 9` (`tick`, `BuildEventType` QUEUE/CANCEL/BUILD, `ProducerType` FACTORY/CONSTRUCTOR/ARMORY, `teamnum`, `build_odf`), and the three enums. `StatHeader` untouched. Recycler builds arrive as `PRODUCER_TYPE_FACTORY` (the pipeline's producer reverse-map disambiguates); stack-cancels fire one CANCEL per unit; armory `build_odf` values are inconsistently `.odf`-suffixed on the wire (normalized pipeline-side).
- **v3** — replaced the v2 header identity maps (fields 6/7/9/10, now `reserved`) with `repeated PlayerInfo players = 22` — a roster the collector accumulates by scanning all 10 slots **every tick** (fixes the host-not-detecting-players bug; captures late joiners). Added `Outcome game_outcome = 21` (host-attested end-of-game dialog result) and the `PlayerInfo` message. Event messages unchanged from v2. Decoded by the current descriptor.
- **v2 (frozen, 2026-04..08 era)** — `scripts/statsgate_v2.proto` (package `statsgate_v2`). Unified `DamageDealt` (carries both sides on one event), `Race` enum on `StatHeader`, `shutdown_requested` / `team1_race` / `team2_race` header fields, `BulletHit.distance_to_target`, and the header identity maps. Frozen snapshot used by the fallback decode path so the v2-era corpus keeps its identity maps readable (the current descriptor drops them as reserved fields).
- **v1 (legacy)** — `scripts/statsgate_v1.proto` (package `statsgate_v1`). Frozen verbatim snapshot of the pre-Nomad schema (separate `DamageDealt`/`DamageReceived` pairs). Used only by the fallback path so pre-Nomad sessions still decode byte-identically.

Decode-time discrimination (**v2/v3/v4 have no wire-type conflicts in any direction, so parse success proves nothing — presence checks are mandatory, in this order**):
- **Python** (`scripts/process_stats.py::load_session`) parses with the current descriptor first; the **v4 marker scan runs BEFORE the `header.players` branch** — any `build_event` arm or any `update_tick` with `HasField("team1_resources")` / `team2_resources` → v4 (ordering matters: a degenerate v4 file with an empty roster must not fall through to the frozen-v2 re-parse, which would silently drop its resource/build data; the scan early-exits on the first UpdateTick since the collector writes resources unconditionally). Otherwise `header.players` non-empty → v3. Otherwise re-parses with the frozen v2 descriptor and runs the pre-existing dance: `header.team1_race != 0 || team2_race != 0` (v2-only signal) → v2, or any `WhichOneof('event_type') is None` event (v1's `damage_received` under `reserved 4`) → re-parse with the v1 descriptor.
- **JS** (`js/raw-browser.js::fetchAndDecodeBinpb`) tries the current descriptor first; protobufjs throws on the v1 wire-type collision, so try/catch handles v1. On success, same presence order: v4 markers → v4; `header.players` non-empty → v3; else lazy-load the frozen v2 descriptor and re-decode → v2 (accepted cost: one extra decode per legacy v2 match view).

Detected schema is stamped onto every output as `match.proto_schema_version` (`"v1"` | `"v2"` | `"v3"` | `"v4"`). The pipeline normalizes all four schemas to a single internal shape — the `DamageEvent` adapter for the v1/v2 damage layouts, the `_build_identity_maps()` shim for the v2-maps-vs-v3-roster identity split, and the additive v4 blocks simply absent pre-v4 — so downstream aggregators are schema-agnostic. UI never branches on the schema label.

**v4 downstream surfaces** (full reference: `docs/DATA_DICTIONARY.md` §5 `economy` / `builds` + `.cursor/rules/data-schema.mdc`): per-match `economy` + `builds` blocks (match-global, always-unfiltered; `has_resource_data` / `has_build_data` flags with absent-=-false defaults), the per-match **Economy tab**, highlights 12 → 16 (`the_tycoon` / Elon Musk scrap-income + `loose_collector` / Loose Collector + `war_machine` / Conveyor Belt combat-ship value + `first_upgrade` race to first live upgraded pool), the `commander_economy` contribution slice → aggregator career econ rollup → Commanders-tab Economy Leaders card + player-page commander econ block + `career_tycoon` / `career_loose_collector` / `career_war_machine` cards, the VTSR-C v2 econ composite (§13.12, inert), raw-browser `buildEvent` arm + reconcile rows, and the first-batch audit runbook `scripts/audit_build_events.mjs`.

**Thug supply + Economy card v2 (`match.schema_version` 18, `PIPELINE_VERSION` 33).** Purely additive telemetry, rating-inert by construction and by gate (`_investigation/golden_v18_no_drift.py` strips every v18 field from the corpus and requires byte-identical VTSR-T and VTSR-C output). Three parts: (1) a new **always-present** top-level `thug_supply` block — per-team `thug_ships_lost` / `thug_ship_value_lost` and `thug_pilot_sec` / `thug_at_base_pilot_sec` / `reship_median_sec` / `reship_spells` (positioning). It is **corpus-wide, not v4-gated**, since none of its inputs need proto v4; the two halves gate independently and null out rather than reporting a fake zero. (2) new `economy.teams` fields `peak_max_scrap`, `green_sec` / `yellow_sec` / `red_sec` and `upgrades_built`. (3) new positioning metrics `pilot_sec`, `reship_median_sec`, `reship_spells`.

**Income classifier + human-only thug losses (`match.schema_version` 19, `PIPELINE_VERSION` 34).** Three measured corrections, all display-only. The gate is `_investigation/golden_income_thug_inert.py`, which **perturbs** every income / `thug_supply` value (2090 fields corpus-wide) and requires byte-identical VTSR-T + VTSR-C — value-insensitivity, which is the property a value correction needs (the v18 gate only strips fields, proving presence-insensitivity). Note that only `elo_history.json` is free of a `computed_at` wall-clock stamp, so it is the file whose hash is quoted as the zero-drift proof after a reprocess; the other three rating files differ on every run by their timestamp alone.

*Refund-match floor.* `ECON_REFUND_MIN_MATCH = ECON_LOOSE_SIZE` (5). The refund-first rule used to claim any `r > 0`; every legitimate refund is at least 5 (Harvester 20 → 10, Artillery 65 → 32), but Service Pod costs 2, so `cost // 2` = 1 and a 1-scrap expectation matches almost any delta. That fabricated 74 of team 1's 114 "refund" scrap on the first v4 match by claiming plain +1 regen ticks, and clipped 1 off six clean +5 loose deliveries, stranding the leftover 4 in `income_unclassified`.

*Cap clamp and withheld overflow.* A loose pickup is 5 scrap; with under 5 headroom the engine credits what fits and **holds** the rest, releasing it on the first later tick that has room — measured at one tick (0.05 s) after a purchase, far too fast for a scavenger round trip, and 14 of 16 such deltas landed at exactly spend+1 tick. The release trigger is **headroom, not planting a scav**: of 62 pool plants, 61 moved the bank by 0 or +1, and the lone exception raised the cap 60 → 80 while the bank was pinned at 60. Both halves of a split pickup used to fall through to `income_unclassified` (127 of that bucket's 151 scrap); now an at-cap delta whose remainder is neither 1 nor a 5-multiple books entirely to `income_loose` (piece count +1) and carries `5 − rem % 5` in a per-side `pending_overflow` that a later sub-cap 2/3/4 remainder draws down (no piece-count bump — same pickup, second installment). Regen keeps priority via the `rem > 1` guard. `income_unclassified` is consequently a real anomaly detector near zero (0 and 4 scrap on the first v4 match), and the four income fields are a strict **partition** of `scrap_income`, gated on every match.

*Human-piloted thug losses.* The one non-additive change: `thug_ships_lost` / `thug_ship_value_lost` moved on existing matches. The old kill-feed scan gated only on `victim_team` landing in a thug slot, silently counting AI-piloted craft owned by that slot — usually an empty ship left behind after an eject, which carries a victim Steam64 of 0 and renders in `kills.feed[].victim` as the literal `"Team 1"` / `"Team 2"`. Corpus-wide 659 such rows sat at thug slots, i.e. 46% of team 1's figure on the first v4 match (54 → 29) and 3088 → 2921 corpus-wide. Losses now come from `leaderboard[].per_ship_combat[].deaths` filtered to `combat_ship_odfs`, which is human-only by construction — no fragile victim-label matching — and inherits the v2.9 pilot-victim exclusion for free. Alongside it, `thug_supply.{n}.thugs[]` carries the per-thug breakdown (`name` / `steam64` / `slot` / `ships_lost` / `ship_value_lost` / `pilot_sec` / `at_base_pilot_sec` / `reship_median_sec` / `reship_spells`), pipeline-sorted heaviest-attrition-first and rendered as indented sub-rows under the two team totals with `vtPlayerLinkHtml` profile links. The four summable team fields equal the sum over the array exactly; `reship_median_sec` stays a pooled median over raw spells and is deliberately not reconstructable from it. Beware the name collision: VTSR-C's `thug_supply` **axis** reads `builds.teams[n].ships_built` over leaderboard `deaths` and has nothing to do with this block.

**Build ledger: charge-at-START and the bulk cancel (`match.schema_version` 20, `PIPELINE_VERSION` 35).** The ledger assumed scrap is deducted when an order is QUEUED; the engine charges each unit when **it starts building**. For an idle producer those coincide, which is why the original audit (isolated queues, 92-96% showing the cost at the queue tick) could not separate them. The gate is `_investigation/golden_builds_inert.py`, which perturbs every affected field and requires byte-identical VTSR-T + VTSR-C, deliberately leaving `scrap_spent_units` / `ships_built` alone because VTSR-C's econ composite genuinely reads those and this change does not touch them.

Each producer — recycler, factory and armory, all one-per-team, since a faction factory upgrade replaces rather than supplements its predecessor — holds one order at a time, and that order may stack N units of the same ODF. A **bulk cancel clears the whole queue** and fires one CANCEL per queued unit, yet only the unit in production was ever paid for. Measured confirmation: burst size equals tracked queue depth in every clean case (Harvesters 3=3, 2=2, 1=1, 1=1; artillery 3=3; the other side's Harvesters 4=4; armory 1=1), a 3-Harvester stack cancel returned about 10 scrap rather than 30, production is strictly sequential (5 Harvesters built exactly 200 ticks apart, then 7 Service Pods exactly 71 ticks apart, no overlap), and only 205 of 784 Service Pod queue ticks show the charge at the queue tick.

The bookkeeping is two-level and that distinction matters. The existing per-`(side, stem)` FIFO still matches events to orders, because BUILD and CANCEL both carry the ODF and a Harvester completion must retire a Harvester order; a new per-`(side, lane)` mirror tracks which unit is actually in production. Re-keying the FIFO to lane and popping its head blindly was tried and is wrong — it mis-attributes as soon as tracking drifts, and it costs about 18 scrap of accuracy on the reference match.

One collector gap shapes the implementation: **bulk-cancel emission is capped at 10 CANCEL events.** Every case where depth exceeds burst has a burst of exactly 10, and all of them are pure Service Pod queues — pods being the only orders that exceed the engine's 10-slot queue, since no non-pod queue in the corpus passes 7 while pod depth reached 39. So `orders_trimmed` is derived from the tracked depth and never from the burst size, with the shortfall quantified in `cancel_events_truncated` (54 on the reference match). Resetting the tracked queue to empty on every burst is both the correct mechanic and what resyncs against those missing events; without it the offset accumulates monotonically and looks like a standing backlog that never actually existed.

Two smaller corrections ride along. `outflow_pending_at_end_cost` becomes the in-progress head unit per lane rather than every open order (186 to 20, since a deep pod stack was voided when the recycler died at 100:11 and only its head unit had been charged). And a new `outflow_clamped_cost` names the scrap destroyed when a dying pool drops `max_scrap` below the bank — 20 and 54 scrap, real gross outflow with no order behind it, previously absorbed silently by the residual. The outflow ledger is consequently a strict partition: the six named components plus `scrap_outflow_unaccounted` sum to `scrap_outflow_gross` exactly, on every match, and the residual moved from -552 to +24 and from -72 to -64. What remains is the inferred-mode constructor lane, left untouched because it has zero cancels and zero BUILD events corpus-wide and multiple constructors can coexist, so one-order-per-producer does not hold there.

**Build-feed economic context (`match.schema_version` 21, `PIPELINE_VERSION` 36).** Every `builds.feed[]` row now reports the state of its team's economy at its own tick: `scrap_at_event`, `max_scrap_at_event` and `pool_count_at_event`. It is purely additive display telemetry behind the Build Order Log's four plain columns (`Cst` / `Bnk` / `Cap` / `Pls`: cost, bank, cap, pools), and it needed no new gate — `golden_builds_inert.py` already blanks the entire feed, so the rating stack demonstrably cannot see any of it.

The values ride the same "latest wire value" join that has always stamped `scrap_status_at_*`: `econ_state[side]` gained `cur_bank` / `cur_cap` / `cur_pools` next to `cur_status`, written on every full-rate resource tick and read when the BuildEvent row is constructed. The income classifier's `prev_bank` / `prev_max_scrap` / `prev_pools` keys hold the same numbers at rest, but they are named for a per-tick delta comparison and are assigned partway through that comparison, so overloading them would invite a reordering bug for no gain. Because `UpdateTick` carries `ResourceState` every tick, the reading is exact to 0.05 s — `_investigation/probe_feed_context.py` replays the wire and confirms all three fields on rows sampled across the reference match with zero mismatches.

One semantic worth internalizing: **a queue row reports the pre-purchase bank.** The engine debits on the tick after a QUEUE (`t=3515` QUEUE, `t=3516` bank 80 → 60), so the number next to an order is what the commander actually had available to spend. **v24 (`match.schema_version` 24, `PIPELINE_VERSION` 42):** a matched BUILD copies that QUEUE-time triad onto the completion row so those columns are about the order, not the bank when the unit rolled off the line (orphan BUILD keeps the completion stamp; CANCEL stays on its own tick). The same bump adds `builds.teams.{n}.scavs_built` (mobile scavenger BUILD completions — Scavenger / Collector / Harvester) and `thug_supply.{n}.commander_row` (commander attrition sibling; team totals stay thug-only). Both are display-only and stay out of `_extract_contribution`. On a build-data-but-no-resource-data session and on any event preceding the first resource tick, all three context fields are `null` and those columns render an em dash — a missing reading must never render as `0`. Cost still shows when it is known. And `max_scrap_at_event` is the wire's own cap, already encoding `40·recycler_alive + 20·pools`, so it should never be recomputed from the pool count.

The same pass reworked the card's controls. The header composes three orthogonal things: a commander-scope group whose labels are rewritten per match from `builds.teams{n}.commander.name` (picking one commander drops the other column and renders the survivor at `col-12`; radio, one selection at a time), the durable type+kind filter pills, and a 120 ms-debounced find bar matching the *displayed* unit name — via a shared `econRowDisplayName()`, so rows whose pipeline `name` is null still match their `odf_map` fallback — plus the ODF stem and the resolved producer lane. A comma splits that query into independent terms (blank pieces dropped) and a row stays if any term matches, so `scav, spire` lists both. Each team heading (`Team N — name (count) · scrap`) sums `scrap_cost` over those same filtered rows, including ones still behind Show more. Null costs and `dedup_folded` queue duplicates are left out. A queue row and its build row both carry the order's cost, so turning Queues on beside Builds counts the order twice. The Sum pill (`#economy-buildlog-sum`) replaces the chronological table with one line per displayed unit name and event type, sorted by count then scrap: the count, that same scrap total, and an ODF link when every row in the group shares one stem. The event column appears only when that team's groups span more than one type, so a queue and its build stay separate lines. Sum persists across match switches with the type filter. The heading stays the event count and the full filtered scrap, and Show more does not apply. Type pills (Queues / Builds / Cancels) and kind pills (Combat ships / Structures) are independent checkbox axes ANDed together (empty axis = any); **All events** is an exclusive reset that clears every other pill. Combat ships is the pipeline `combat_ship_odfs` set minus constructor-lane rows (same set as `ships_built` / Conveyor Belt); Structures stays `producer === constructor`. The stem list is the tiny sidecar `data/combat_ship_odfs.json` (`schema_version: 1`), emitted by `write_combat_ship_odfs()` next to the existing `Combat-ship set:` log so ODF DB updates stay in sync — the dashboard lazy-fetches it rather than loading `odf.min.json`. Scope and query reset on every match switch since both are about the match that just left the screen; the type+kind filter persists as a durable lens. The input sits in the card header rather than the log body, so re-rendering cannot steal focus mid-keystroke. New build-log helpers belong at or after `buildlogFilterFn`, because `_investigation/check_econ_tooltips.mjs` slices `js/app.js` from `renderEconomyProductionCard` to that marker.

Re-ship time is measured **spell-based, not death-anchored**: `_reship_spells()` walks the 1 Hz pilot series for maximal on-foot runs and drops censored tails (open at match end), the index-0 run (opening build order — everyone starts on foot) and runs under `RESHIP_MIN_SPELL_SEC = 10` (powerup grabs, ground snipes, base ship-swaps). Per-team medians pool the raw spells because a median of per-player medians is not a median, which is why the positioning pass hands them out on a private `_reship_spells_by_name` key that `process_match` pops before serialization. This replaced `median_rebuild_latency_sec` on the per-match Economy card (the field is still emitted and career surfaces still read it): timing the commander's next combat-ship QUEUE after a loss is not attributable to the thug who died, is blind to a commander who re-ships from pre-built stock, and — since the implementation never consumes a matched queue — collapses a cluster of deaths onto one order. The card itself was reworked around peaks / averages / cumulatives (finals dropped as artifacts of when the game stopped), every row gained a plain-language tooltip, and the status bar now shows absolute band times while reading `red_share` / `red_sec` directly (the old `100 − green − yellow` derivation absorbed the other bands' rounding error and displayed 56% where the data said 55.4%). **Upstream asks (statsgate)**: BUILD timing semantics (factory-exit vs placement-complete — latency labeling only), engine-level confirmation of the cancel-refund policy (measured ~50% factory-lane / 0% armory on small n), optional `producer_odf` field on `BuildEvent` (disambiguates multi-producer instances + mods; `DamageType`-suggestion precedent).

### Top-Level Message: `ClientStatSession`

| Field | Type | Description |
|---|---|---|
| `header` | `StatHeader` | Match metadata |
| `event_stream` | `repeated StatEvent` | Ordered event stream |

### `StatHeader`

| Field # | Field | Type | Description |
|---|---|---|---|
| 1 | `map` | `string` | Map filename (e.g. `havenvsr.bzn`) |
| 2 | `start_time` | `google.protobuf.Timestamp` | Match start time (UTC) |
| 3 | `author_nickname` | `string` | Recording player's name. **v3 note:** set at last-tick and currently resolves to `'Unknown'` (upstream nit); the pipeline derives submitter identity from the sessions folder name instead. |
| 4 | `author_steam64` | `uint64` | Recording player's Steam64 ID (the statsgate client). When that player's `PlayerInfo.nickname` is the placeholder `'Unknown'` (known collector bug when the host is also the recorder), `in_game_nick_for` omits it; `match.roster[].nickname` stays wire-accurate. Do not key off `author_nickname` (v3+ it is `'Unknown'` even when `PlayerInfo.nickname` is correct). |
| 5 | `tick_rate` | `uint32` | Simulation tick rate (typically 20) |
| 6 | `s64_to_nick` | `map<uint64, string>` | **v1/v2 only — `reserved` in v3.** Steam64 → Nickname |
| 7 | `teamnum_to_s64` | `map<int32, uint64>` | **v1/v2 only — `reserved` in v3.** Slot (1-10) → Steam64 |
| 8 | `active_config_mod` | `string` | Server configuration mod identifier |
| 9 | `s64_to_teamnum` | `map<uint64, int32>` | **v1/v2 only — `reserved` in v3.** Steam64 → Slot (reverse of field 7) |
| 10 | `player_count` | `uint32` | **v1/v2 only — `reserved` in v3.** Number of players in the match |
| 11 | `last_tick` | `uint32` | Final game tick (0 if not populated by collector) |
| 12 | `terrain_min_x` | `float` | World-space minimum X (west edge). Axis convention: +X East, +Y Up, +Z North |
| 13 | `terrain_max_x` | `float` | World-space maximum X (east edge) |
| 14 | `terrain_min_y` | `float` | World-space minimum Y (lowest elevation) |
| 15 | `terrain_max_y` | `float` | World-space maximum Y (highest elevation) |
| 16 | `terrain_min_z` | `float` | World-space minimum Z (south edge) |
| 17 | `terrain_max_z` | `float` | World-space maximum Z (north edge) |
| 18 | `shutdown_requested` | `bool` | **v2+.** `true` when the stat session was cancelled mid-game via mission script or console command; `false` for normal end. v1 sessions emit default `false`. |
| 19 | `team1_race` | `Race` enum | **v2+.** Team 1's faction (`RACE_UNSPECIFIED` / `RACE_ISDF` / `RACE_SCION` / `RACE_HADEAN`). Authoritative when set; v1 always reports `RACE_UNSPECIFIED`. |
| 20 | `team2_race` | `Race` enum | **v2+.** Team 2's faction. Same semantic as team1_race. |
| 21 | `game_outcome` | `Outcome` enum | **v3-only.** Host-attested outcome from the end-of-game dialog: `OUTCOME_UNSPECIFIED = 0` (no answer), `TEAM1_WIN = 1000`, `TEAM2_WIN = 1001`, `DRAW = 1002`, `GAME_CANCELLED = 1003` (1000-series values double as Win32 TaskDialog button IDs upstream). Out-of-enum values possible (e.g. `IDCANCEL = 2` if the host ESCs the dialog) — treated as unattested. Combined with kill-feed inference via `resolve_match_outcome()`'s evidence-first trust ladder. |
| 22 | `players` | `repeated PlayerInfo` | **v3-only.** Per-tick-accumulated roster: `{steam64, teamnum, nickname}` per player observed in any slot at any tick (replaces the identity maps; captures late joiners; nickname = first-recorded). Hash-ordered on the wire (NOT first-seen order). May contain garbage entries with invalid Steam64s from EMPTY slots (collector scans without a player-handle guard) — the pipeline's `_valid_steam64` gate drops them. |

All six `terrain_*` fields are 0.0 when the collector does not populate them (pre-schema sessions, edition-2023 implicit field presence). The pipeline treats all-zero as "unset" and falls back to observed player extents for `map_bounds`. The v2+ fields (#18-20) emit defaults on v1 sessions and the v3-only fields (#21-22) emit defaults on v1/v2 sessions because those field numbers don't exist in the earlier descriptors (protobuf returns the type default after decode; Python code accesses `game_outcome` via `getattr(header, "game_outcome", 0)` since the v1/v2 descriptor classes lack the attribute entirely).

Team convention: slots 1-5 = Team 1, slots 6-10 = Team 2.

### Event Types

#### `BulletInit` (field 1)
A player fires a projectile.

| Field | Type | Description |
|---|---|---|
| `tick` | `uint32` | Game tick |
| `shooter` | `uint64` | Steam64 ID of player who fired |
| `ordnance_odf` | `string` | Weapon ODF identifier |

#### `BulletHit` (field 2)
A projectile connects with a target.

| Field | Type | Description |
|---|---|---|
| `tick` | `uint32` | Game tick |
| `shooter` | `uint64` | Steam64 ID of player who fired (0 if not a player) |
| `ordnance_odf` | `string` | Weapon ODF identifier |
| `victim` | `uint64` | Steam64 ID of the victim (0 if not a player) |
| `victim_odf` | `string` | ODF of the hit entity |
| `shooter_odf` | `string` | Shooter's vehicle ODF (not yet populated by collector) |
| `distance_to_target` | `float` | **v2-only.** Line-of-fire distance from shooter to victim at impact (world units). v1 has no such field; values default to 0.0. Capture-only -- see `distance_to_target.txt` at the repo root for planned future uses. |

#### `DamageDealt` (field 3) — v2 unified

**v2 (current).** A single event carries both shooter-side and victim-side data for a single in-game damage tick. Replaced the v1 `DamageDealt` + `DamageReceived` pair.

| Field | Type | Description |
|---|---|---|
| `tick` | `uint32` | Game tick |
| `shooter` | `uint64` | Steam64 ID of damage source player (0 = non-player entity) |
| `shooter_team` | `int32` | Shooter's slot (1-10), 0 = world prop |
| `shooter_odf` | `string` | Shooter's vehicle ODF at event time (more accurate than the UpdateTick-derived `_ship_key()` map across ship-switch moments) |
| `victim` | `uint64` | Steam64 ID of damage target player (0 = non-player entity) |
| `victim_team` | `int32` | Victim's slot (1-10), 0 = world prop |
| `victim_odf` | `string` | Victim's ODF at event time (used directly for structure-damage attribution -- replaces v1's bh_pending queue join) |
| `ordnance_odf` | `string` | Weapon ODF (may be null → "Unknown") |
| `amount` | `float` | Damage amount |

**v1 (legacy).** Two separate events emitted as adjacent pairs. The pipeline's v1 path in `_normalize_damage_events()` pairs them and produces the same unified `DamageEvent` shape consumed by the rest of the pipeline.

| v1 message | Field | Description |
|---|---|---|
| `DamageDealt` (v1) | `tick`, `shooter`, `team` (shooter team), `ordnance_odf`, `amount` | First half of the pair |
| `DamageReceived` (v1) | `tick`, `victim`, `team` (victim team), `ordnance_odf`, `amount` (always equal to the paired DD) | Second half of the pair |

v2 `StatEvent` reserves oneof field `4` so the v1 layout cannot be mistakenly re-introduced.

#### `PoolPlanted` (field 10, proto v5)

Written from the mission `AddObject` hook for `CLASS_EXTRACTOR` only. A plant is a base pool (Extractor / Harvester / Collector). The upgrade replaces that object, so it fires a second plant at the same spot. There is no ODF and no handle.

| Field | Type | Description |
|---|---|---|
| `tick` | `uint32` | Game tick. Plants before the match window are clamped to `tick_range[0]` |
| `teamnum` | `uint32` | Team slot 1-10. Slot 0 is dropped |
| `position` | `Vec3` | World meters |

`MagnetForce` is oneof field 11 (sonic wave / mits / mcurt). The collector callback is commented out, so no recording has it. Field 10 was empty before this arm, so v4 did not need a freeze. A file with no `pool_planted` event still stamps `proto_schema_version: "v4"`.

### Critical: Damage Event Semantics

The `team` field is **always the owning player's slot number (1-10)**, not a faction ID. Faction membership is determined by slot convention: 1-5 = Team 1, 6-10 = Team 2.

**Five damage scenarios:**

| Scenario | shooter | victim | team (dealt) | team (recv) | Attribution |
|---|---|---|---|---|---|
| Player → Player | >0 | >0 | shooter's slot | victim's slot | Personal dealt + personal received + rivalry matrix |
| Player → AI/Structure | >0 | 0 | shooter's slot | AI owner's slot | Personal dealt + asset received |
| AI/Structure → Player | 0 | >0 | AI owner's slot | victim's slot | Asset dealt + personal received |
| AI/Structure → AI | 0 | 0 | AI owner slot | AI owner slot | Asset dealt + asset received |
| World prop (team=0) | any | any | 0 | varies | **Shooter skipped** — victim side still attributed if DR has team > 0 |

**Key rules:**
- `dd.team == 0` or `dd.amount == 0.0` → skip **shooter attribution only** (no dealt credit); **victim received damage is still processed** from the paired DamageReceived event
- `dr.team == 0` → skip victim attribution (world prop victim)
- `shooter > 0` → personal dealt credit to that player
- `shooter == 0` → asset dealt credit to `team` (the owning slot)
- `victim > 0` → personal received credit to that player
- `victim == 0` → asset received credit to `team` (the owning slot)
- Both `shooter > 0` AND `victim > 0` (and shooter not skipped) → rivalry matrix entry

**Sentinel damage filter.** The pipeline drops any damage event whose `amount > 1e6`. These are engine-emitted sentinels from the BZCC `DAMAGE_TYPE_UNKNOWN` force-kill pathway (observed value exactly `268,435,456.0` = `2^28`), not real combat. Skipping happens before any accumulator and is mirrored in the timeline recompute loop. Per-match diagnostic at `match.sentinel_damage`; aggregate at `meta.total_sentinel_damage_dropped` + `meta.matches_with_sentinel_damage`. **Counts are logical-record counts** — one v1 DD+DR pair = 1, one v2 unified DamageDealt = 1. The on-the-wire shape differs but the in-game damage event count is identical, so `match.sentinel_damage.count` is comparable across schemas. Full evidence chain, struct layout, and the `misnexport2 + 0x1c` decompile are in [§7 Sentinel Damage Filter in docs/DATA_DICTIONARY.md](docs/DATA_DICTIONARY.md#7-sentinel-damage-filter). The Raw Browser Reconcile view applies the same filter (schema-agnostic helper) and surfaces the dropped total via an inline badge; the raw events table still shows sentinel values verbatim.

#### `UpdateTick` (field 5)
Per-tick state snapshot for all players. Pipeline currently skips these events.

| Field | Type | Description |
|---|---|---|
| `tick` | `uint32` | Game tick |
| `players` | `repeated PlayerState` | State of each player |

**PlayerState fields:** `player` (uint64 Steam64), `position` (Vec3), `speed` (float), `health` (float — actual HP, not ratio), `ammo` (float — actual ammo, not ratio), `odf` (string — current vehicle ODF), `has_target` (bool — player has a target lock active at this tick; T-key activates target mode, tap-to-toggle)

#### `UnitDestroyed` (field 6)
A unit (player vehicle, AI unit, or structure) was destroyed. Pipeline applies a **four-way classification** (Phase 3) before any kill aggregator touches the event — see "UnitDestroyed Classification" below.

| Field | Type | Description |
|---|---|---|
| `tick` | `uint32` | Game tick |
| `killer` | `uint64` | Steam64 of killer (0 if not a player) |
| `killer_team` | `uint32` | Killer's team slot. **`0` is the engine's signal that the destruction had no real attacker** — used by the classification flow to detect powerup pickups disguised as destructions. |
| `killer_odf` | `string` | Killer's vehicle ODF |
| `victim` | `uint64` | Steam64 of victim (0 if not a player) |
| `victim_team` | `uint32` | Victim's team slot |
| `victim_odf` | `string` | Destroyed unit's ODF |

#### `UnitSniped` (field 7)
A pilot snipe event. Phase 3 enriched the event with shooter / victim context (pre-Phase-3 sessions only carry `tick`).

| Field | Type | Description |
|---|---|---|
| `tick` | `uint32` | Game tick |
| `shooter` | `uint64` | Steam64 of sniper (collector-bug poisoned through ~2026-05-04 — see erratum) |
| `shooter_team` | `uint32` | Sniper's team slot — **authoritative for sniper identity** |
| `shooter_odf` | `string` | Sniper's vehicle ODF |
| `victim` | `uint64` | Steam64 of pilot victim (collector-bug always-zero through ~2026-05-04 — see erratum) |
| `victim_team` | `uint32` | Victim's team slot — **authoritative for victim identity** |
| `victim_odf` | `string` | Victim's vehicle ODF |

**Collector-bug erratum (Phase 3 through ~2026-05-04).** A copy-paste typo at `statsgate/statsgate/src/stat_client.cpp:273` (`set_shooter()` called twice in `record_snipe()` where the second call should have been `set_victim()`) caused the wire-level `shooter` Steam64 to be overwritten with the victim's Steam64 in nearly every event, while `victim` Steam64 was never written and stayed at protobuf default `0`. The four slot / ODF fields were always correct. The pipeline mitigates by slot-deriving identity in `process_stats.py` at the `unit_sniped` branch; the wire `shooter` / `victim` Steam64 fields are ignored. Forward-compatible with the upstream fix without code changes. Full evidence in [`_sniper_investigation/DIAGNOSIS.txt`](_sniper_investigation/DIAGNOSIS.txt) and the matching DATA_DICTIONARY entry.

#### `PickupPowerup` (field 8) — Phase 3
A player picked up a crate or pod. Pre-Phase-3 sessions do not contain this event; the engine emits a synthetic `UnitDestroyed` for the same tick (with `killer_team == 0`) regardless of schema version. The pipeline routes the synthetic destruction to the powerup-pickup branch (suppressed) and consumes this event for the rich `pickups.feed` data.

| Field | Type | Description |
|---|---|---|
| `tick` | `uint32` | Game tick |
| `picker` | `uint64` | Steam64 of picker (0 if AI) |
| `picker_team` | `uint32` | Picker's team slot |
| `picker_odf` | `string` | Picker's vehicle ODF |
| `powerup_team` | `uint32` | Team that owned the powerup spawn |
| `powerup_odf` | `string` | The collectible ODF (e.g. `apsnipvsr.odf`) |

### UnitDestroyed Classification (Phase 3)

Every `UnitDestroyed` event is routed into one of four buckets at the top of the pipeline's `unit_destroyed` branch. The classification runs **before** any existing accumulator (`player_kills`, `kill_feed`, `vehicle_destruction_count`, `kill_rivalry`) touches the event:

| Bucket | Trigger | Effect |
|---|---|---|
| Real vehicle | victim_odf NOT in either constant | Existing kill accumulators |
| Powerup pickup | victim_odf in `KNOWN_POWERUP_ODFS` AND `killer_team == 0` | Suppressed. New-schema gets full picker context from the real `PickupPowerup` event. |
| Powerup/crate destruction | victim_odf in `KNOWN_POWERUP_ODFS` AND `killer_team != 0` | Routed to `powerup_destructions` block (player shot the crate before pickup, effectively denying the enemy economy) |
| Deployable destruction | victim_odf in `KNOWN_DEPLOYABLE_ODFS` (regardless of killer_team) | Routed to `deployable_destructions` block (mines self-detonate, expire, or get shot) |

**Powerup classification** is built per pipeline run by `_load_known_powerup_odfs(odf_db)` from `data/odf.min.json -> Powerup` (currently 159 entries) plus synthesized `*vsr.odf` and `*_vsr.odf` variants for every base entry (covering VSR-mod ODFs that inherit from stock parents at runtime via `[GameObjectClass]\nbaseName`). Threaded into `process_match()` as a `known_powerup_odfs` parameter (symmetric with `resolve_weapon` / `resolve_unit`). The DB is the source of truth -- if a powerup is missing, extend the upstream DB rather than hand-curating a list.

**Display naming** is handled by the `powerup_display_name(odf)` closure in `process_match` (next to `wpn_name` / `unit_name`). Resolution order: `unit_name` -> stripped-vsr `unit_name` -> `wpn_name` -> stripped-vsr `wpn_name` -> title-cased stem fallback. Suffixed with " Powerup" to disambiguate from the same-named weapon ordnance (e.g. `apchainvsr.odf` -> "Chain Gun Powerup", vs `apchain.odf` weapon ordnance -> "Chain Gun"). Used by `pickups.feed[].powerup_name`, `pickups.by_odf[].name`, `powerup_destructions.feed[].powerup_name`, and `powerup_destructions.by_odf[].name`.

**Highest-volume edge case**: `apserv_vsr.odf` (110,589 pickup events) is absent from `Powerup` directly, but `_strip_vsr_suffix` resolves it via stock `apserv.odf` -> "Service Pod" -> "Service Pod Powerup".

`KNOWN_DEPLOYABLE_ODFS` (1 entry: `fball2c.odf`) stays hand-curated -- mines/utilities aren't in the Powerup bucket and need domain-knowledge curation. Lives as a module-level frozenset in [scripts/process_stats.py](scripts/process_stats.py). Audit script: [scripts/audit_pickup_powerup.mjs](scripts/audit_pickup_powerup.mjs) loads the same DB and surfaces "promotion candidates" if the corpus shows powerup-shaped ODFs the DB lacks. Full evidence chain + maintenance procedure: [§8 UnitDestroyed Classification & Powerup Economy in docs/DATA_DICTIONARY.md](docs/DATA_DICTIONARY.md#8-unitdestroyed-classification-powerup-economy).

The legacy chart-only `VEHICLE_DESTRUCTION_IGNORE_ODFS` filter (currently just `apserv_vsr.odf`) coexists with the new classification — even after the team-zero filter, the residual ~13% of real-combat service-pod destructions still flow into `powerup_destructions.feed` and would dominate the Vehicle Destruction Breakdown chart without the chart-level filter.

### Faction Resolution

Faction (Team 1 vs Team 2) is determined by slot convention: slots 1-5 = Team 1, slots 6-10 = Team 2. The pipeline's `slot_to_faction()` function encodes this.

### Statsgate Collector Reference

The raw data is produced by [statsgate](https://github.com/VTrider/statsgate), a C++ stats collection client. Key behaviors from the source code:

- **Collision damage excluded**: `DAMAGE_TYPE_COLLISION` events are filtered at the source — they never appear in the data.
- **Identical amounts**: `DamageDealt.amount` and `DamageReceived.amount` are always the same value (`dmg.value` in the engine).
- **Nullable ordnance_odf**: `ordnance_odf` can be null for water damage and other environmental effects.
- **Player-only bullet events**: `BulletInit` and `BulletHit` are only recorded for recognized players. v1/v2 gated on first-tick `s64_to_nick` membership; v3 gates on a non-zero Steam64 (late joiners' shots now record). AI/structure shots are not tracked.
- **Header roster snapshot (v1/v2 only)**: the v1/v2 header identity maps were captured at `first_tick` — player joins/leaves after that point are NOT reflected, and players the host's first-tick scan missed are permanently absent (the bug v3's per-tick scan fixes). v3's `players` roster accumulates across the whole match.
- **v3 collector data gaps (first batch, upstream exu2 hook regression)**: zero `BulletHit` events (bullets fire via `BulletInit` but no hits land on the wire — accuracy/range/structure-FIFO data unavailable; see `match.has_bullet_hit_data`), zero `pickup_powerup` events, and `unit_sniped` events with every field blank except `tick`. The pipeline degrades gracefully via availability flags; upstream has been notified.
- **File format**: Data is saved as `.binpb.gz` (gzip-compressed protobuf).

### Player Identity

Players are identified by `uint64` Steam64 IDs. The identity source depends on schema:

- **v1/v2**: the header provides three maps — `s64_to_nick` (Steam64 → nickname), `teamnum_to_s64` (slot → Steam64), `s64_to_teamnum` (reverse).
- **v3/v4**: the maps are `reserved`; working identity is the valid-Steam64 **union of `UpdateTick.players`** via `_build_identity_maps()` in [scripts/process_stats.py](scripts/process_stats.py). `header.players` is a nickname / first-seen-slot hint that is **always compared** (`match.roster_qa`; not gated on header length). The shim (a) drops entries failing the `_valid_steam64` gate (`(s64 >> 32) == 0x01100001` — the v3 collector emits garbage Steam64s for EMPTY slots because it scans without a player-handle guard), (b) drops header-only 0-tick ghosts (`reason: "no_tick_presence"` — Sept 7 2026 collector leak) and synthesizes tick-only ids (empty header / missed late joiners), (c) places every **evidenced** player at the majority-vote slot from the ENGINE's own event attributions (the `team` fields on `DamageDealt` / `UnitDestroyed` / `UnitSniped` / `PickupPowerup` are team slots 1-10 stamped per event), and (d) falls back for **no-evidence tick-union** players (campod spectators): roster hint honored when free, overflow to the lowest free same-side slot when taken and UpdateTick presence `>= ROSTER_OVERFLOW_MIN_SHARE = 0.25`, dropped below the floor; silent tick-only with no slot hint drop as `tick_only_no_slot`. Every correction/reassignment/drop is recorded in `match.roster_conflicts` (`resolution`, `reason`, `assigned_slot`, `presence_share`, `evidence_events`). The raw roster (including invalid entries, flagged `valid: false`) is preserved on `match.roster` for provenance.

Either way the shim yields the same three working dicts, and the pipeline builds `nick_map` (slot → name) by joining `teamnum_to_s64` with `s64_to_nick` — everything downstream is schema-blind.

**Account reroutes (shared-PC handling).** A module-level `ACCOUNT_REROUTES` table in [scripts/process_stats.py](scripts/process_stats.py) maps "owner" Steam64s to nick-pattern rules that rewrite the slot's identity to a target Steam64 at the top of `process_match()` (when a different real player borrows someone's Steam account). The rewrite touches the three local identity dicts above AND every player-id field across the proto event stream BEFORE any aggregator runs, so the per-match leaderboard, ELO, positioning, highlights, kill feed, contributions extractor, and career rollup all attribute to the rerouted player without per-consumer changes. Surfaced on the dashboard as a small `via dd` chip on the leaderboard row and as a provenance banner on the Raw Data Browser's processed tier (tiers 1 and 2 remain byte-accurate to the source recording). Full mechanism, regex semantics, schema, and corpus audit in [§10.3 of DATA_DICTIONARY.md](docs/DATA_DICTIONARY.md#103-account-reroutes-matchschema_version-7). Bumps `match.schema_version` to 7 and `PIPELINE_VERSION` to 17.

### Fullscreen Modal

Every dashboard card has an expand button (`data-expand="section-id"`) that opens the section in a Bootstrap fullscreen modal. For **chart sections**, a fresh `<canvas>` is created in the modal and the chart is re-rendered at full viewport size via registered renderer functions. For **non-chart sections** (tables, HTML content), the card body is cloned into the modal (with `id` attributes stripped to avoid duplicates, and `<canvas>` elements removed to avoid blank cloned charts). Modal charts are destroyed on close.

---

## 3. Schema Evolution

`scripts/statsgate.proto` is the **definitive reference** for the raw data schema. It is kept in sync with the upstream [statsgate](https://github.com/VTrider/statsgate) collector. The `statsgate/` folder in this repo contains a copy of the collector source for reference.

### When the Schema Changes

0. **If the new schema removes/reshapes fields the existing corpus depends on** (the v3 precedent), FIRST freeze the outgoing schema: copy it to `scripts/statsgate_vN.proto` with the package renamed to `statsgate_vN` (avoids descriptor-pool symbol collisions), generate `statsgate_vN_pb2.py` + `vendor/protobufjs/statsgate_vN.proto.json`, and extend the decode chains in `load_session()` / `js/raw-browser.js`.
1. Replace `scripts/statsgate.proto` with the new version (verbatim upstream mirror)
2. Recompile the **current** Python descriptor:
   - `python -m grpc_tools.protoc --proto_path=scripts --python_out=scripts statsgate.proto`
   - The frozen descriptors (`statsgate_v1.proto` / `statsgate_v1_pb2.py`, `statsgate_v2.proto` / `statsgate_v2_pb2.py`) only regenerate if you're backporting a bugfix to the legacy corpus
3. Regenerate the **current** protobufjs descriptor:
   - `npx -y -p protobufjs-cli pbjs -t json scripts/statsgate.proto > vendor/protobufjs/statsgate.proto.json` (invoke `protobufjs-cli` explicitly — a bare `npx pbjs` can resolve to an unrelated npm package; on PowerShell, run the redirect through `cmd /c` so the JSON isn't written as UTF-16)
   - Frozen JSONs (`statsgate_v1.proto.json`, `statsgate_v2.proto.json`) only when the corresponding frozen proto changes
4. Follow `.cursor/rules/schema-migration.mdc` checklist
5. Update `process_stats.py` to handle new/changed event types
6. Update `js/raw-browser.js` to handle new event arms (chips, Reconcile, etc.)
7. Re-run `scripts/verify_proto_decode.mjs` on one v1 file, one v2 file, AND one v3 file to confirm the triple-decode still agrees with the Python pipeline
8. Re-run the pipeline and verify output (byte-parity on the v1/v2 corpus, first-process on v3)
9. Update this document and `.cursor/rules/data-schema.mdc`

The triple-descriptor strategy means every v1/v2 session must continue to decode and produce byte-identical processed JSON. Add a parity test to your migration plan: reprocess the corpus after the schema change (`--force`) and `git diff data/processed` — the only differences must be the version bumps and any new optional fields you're intentionally adding. Remember detection ordering: parse-failure only identifies v1; v2-vs-v3 requires the `header.players` presence check (no wire conflicts in either direction).

**Proto v5 (2026-10-26)** adds `PoolPlanted` as oneof field 10. Detection is that arm's presence, after the v4 marker scan and before `header.players`. A recording with no plants stays v4. v5 uses the v4 roster, economy, and damage-calibration paths. `MagnetForce` moved to field 11 and is not recorded, so nothing already on disk used field 10 and no v4 freeze was required. Fixture gate: `_investigation/check_pool_structures.py`. First real batch: `_investigation/probe_pool_planted.py`.

### Future Schema Considerations

Items flagged for the upstream collector / proto, not blocking for current dashboard work:

- **`DamageType` enum on `DamageDealt`.** The engine's internal `DAMAGE` struct already carries a `type` byte (`DAMAGE_TYPE_UNKNOWN` / `ORDNANCE` / `EXPLOSION` / `COLLISION` / `WATER` / `UNDERWATER` / `SCRIPT`). Propagating it on the wire would (a) let us defensively drop `DAMAGE_TYPE_UNKNOWN` events without relying on the current amount-based sentinel heuristic, and (b) unlock source-type breakdowns (e.g. "damage by cause"). Backwards-compatible if added with default 0. See [§7 Sentinel Damage Filter in docs/DATA_DICTIONARY.md](docs/DATA_DICTIONARY.md#7-sentinel-damage-filter) "Future schema enhancement" for a proposed proto shape.

---

## 4. ODF Integration — Name Resolution

The `odf.min.json` file contains BZCC Object Definition File data used to resolve raw ODF strings into human-readable names. Two complementary resolvers cover the two categories of ODFs the pipeline encounters: ordnance/weapons and vehicles/structures. `scripts/odf/build_odf_db.py` builds it from the local install in the order the game mounts files: the VSR config mod, then its `assetDependencies` in INI order, then the base game, and the first copy of a file found wins (the game log's `AddWorkshopConfigs` / `AddGlobalConfigMod` lines show that order, and VSR's overrides of base-game files only work that way). The builder merges with later roots overriding, so its root list runs the other way: base game, dependencies last to first, config mod last. That is why the VSR copies of the Flame Mine family (`gmflamemin`, `fball2a`–`fball2c`, `xfbseek`), the Hadean and Cerberi pilots and some of their hand weapons beat the Forgotten Enemies copies the dependencies also ship. `scripts/build_fx_assets.py` (first match wins) and `_map-analysis/scripts/bz2_paths.py` (the models and map tools, last match wins) search in the same order.

### Weapon Resolution Chain (`build_weapon_name_resolver`)

Resolves ordnance ODFs (e.g. `chaingun_c.odf` → `Chain Gun`):

1. `WeaponClass.ordName` → `WeaponClass.wpnName`
2. `DispenserClass.objectClass` → `WeaponClass.wpnName`
3. `TargetingGunClass.leaderName` → `WeaponClass.wpnName`
4. `Vehicle.TorpedoClass.xplBlast` or `Vehicle.GameObjectClass.explosionName` → parent weapon name (via `DispenserClass.objectClass` → vehicle → explosion mapping)
5. **Child-ODF reverse map** (`build_child_odf_reverse_map`, `PIPELINE_VERSION` 49) — see below. Layers 1–5 name a weapon that only weapon mines carry after the player weapon that puts the mine down (`PIPELINE_VERSION` 55, below)
6. **Fallback**: Strip `.odf` extension and display the raw ODF key
7. **Null ordnance**: Display `"Unknown"`

#### Child-ODF reverse map (layer 5)

A weapon frequently does its damage through an object it *spawns* rather than through itself. The Scion Seeker (`gseekervsr`) drops a mine whose blast is `xseekvsrxpl`, and the wire attributes the damage to that blast — so `xseekvsrxpl` used to render as raw text in Weapon Meta. Corpus-wide, 88 such stems carried **~15% of all weapon damage** (13.9M) as unreadable stems.

`build_child_odf_reverse_map(odf_db)` inverts the relationship. It walks each named weapon's reference graph up to `CHILD_MAP_MAX_HOPS = 3` (the flattened DB inlines a weapon's ordnance and payload blocks, so most children are one hop away; `gmaggun_c → charge6_c → xmagcar6_c` is the deepest real chain) collecting stems named by `CHILD_REF_FIELDS`:

| Field family | Covers |
|---|---|
| `xplVehicle` / `xplBuilding` / `xplGround` / `xplExpire` | Per-target-class impact explosions |
| `xplBlast` / `xplPulse` / `xplEnter` / `xplExit` | Mine blasts, pulse shells, Blink in/out |
| `explosionName` / `payloadName` / `launchOrd` | Dispensed payloads and secondary launches |
| `ordName<N>` / `altName` | Charge-gun levels (`charge1_c`..`charge6_c` → MAG), alt-fire modes |
| `Expl*.ExplosionClass.classLabel` | Nested explosion blocks that name themselves rather than being pointed at |

**Exclusions.** A stem is skipped if it carries its own `GameObjectClass.unitName` (the unit resolver owns it, and `prettify_odf` asks the weapon resolver first, so a claim here would shadow the better name) or if it is itself a named weapon. A final pass then maps every Weapon-bucket stem to its own `wpnName`, because a hitscan weapon declares `ordName = NULL` and the wire reports the weapon's ODF directly — that is what recovers `garcvsr_a` → `Arc Stream`.

**Contested children.** Sibling weapons share explosion assets, so `xbazxpl_c` is claimed by both the Rocket and the Burst Gun EX. The winner is decided by a fixed chain — curated generic → single claimant name → fewest hops → longest common prefix against the weapon stem (falling back to the weapon's ordnance stem only when no weapon stem matches at all, which is what picks `bazooka_c`/Rocket over `eburst_a`) → majority claimant → alphabetical. Every step is order-independent; do not introduce a tie-break that depends on dict iteration.

**Generic wreck explosions.** `xvehxpl`, `xcarxpl`, `xsgnxpl`, `xpwrxpl`, their `_e` Hadean twins, and `kamixpl` are played for *any* dying object of that class. Hundreds of weapons reference them, so there is no honest parent — `GENERIC_EXPLOSION_NAMES` gives them curated labels (`Vehicle Explosion`, `Craft Explosion`, …) and is consulted **before** weapon claims so a spurious claimant cannot win.

The layer is **display-name only**. Explosion stems record zero shots and zero hits, so nothing reaching `thug_accuracy` or any other VTSR-T axis changes; `elo_history.json` hashed byte-identical across the v49 reprocess. Rows stay per-ODF — the map renames, it never merges statistics — so a child that now shares its parent's display name simply picks up the existing `Name (raw_stem)` disambiguation suffix (`FAF Msl (fafmsl_c)` alongside `FAF Msl (xfafmsl)`).

#### Weapon-mine weapons (`build_mine_weapon_owners`, `PIPELINE_VERSION` 55)

A weapon that only weapon mines carry is never in a player's hands, and its own `wpnName` misleads: VSR names the Flame Mine's fireball weapon (`fball2b`) "Wasp", which is also the name of a real torpedo, and the Snare Field's mine weapon "Snare Trap". `build_mine_weapon_owners(odf_db)` finds each weapon whose every carrying `GameObjectClass` has `classLabel = weaponmine` and names it after the player weapon whose own entry inlines that mine (`DispenserObj.GameObjectClass`, `…Payload.GameObjectClass`, at any depth). The shallowest inline wins; two different names at that depth keep the weapon's own name. Four weapons qualify: the Flame Mine (`fball2b`, plus its fireball `fball2c` / `xfbseek` / `xfbseeke`), the Spore mine (`mcsporminb`, `mcsporminc`, `sporexpl`, `sporexpir`), the Snare Field (`gmsnaremn`, `xsnarexpl`) and the Arc Mine (`gmboltmn`). The Jolt Cap's mine carries the Arc Cannon, which ships also mount, so it is left alone.

The owner name never moves a name another weapon shares. Layers 1–4 apply it only to a key (ordnance, dispensed object, leader) that nothing but that owner's mine weapons use. Layer 5 votes with each weapon's own name and renames a child only when every claimant carrying the winning name is that owner's mine weapon. So the shared Scion lightning impact (`xlgtcar` / `xlgtbld`, claimed by the sniper, the Lightning gun, the Spire and the Arc Mine) keeps `Arc Blast`, and the Arc Mine's own damage also stays `Arc Blast`: its bolt fires `mbolt`, the Spire tower's ordnance, and the wire records damage under the ordnance. The v55 reprocess changed only display names: "Fireball" left all 94 matches that showed it (now "Flame Mine"), "Snare Trap" became "Snare Field" in 8, and `elo_history.json` hashed byte-identical.

### Unit Resolution Chain (`build_unit_name_resolver`)

Resolves any in-game object ODF (vehicles, buildings, deployable powerups, pilots, etc.) to its `GameObjectClass.unitName`. The resolver indexes every top-level category in the ODF DB — `Vehicle.*`, `Building.*` (recyclers/factories/extractors like `ibscav_vsr.odf` → `Extractor`), `Powerup.*` (deployable weapon pods like `apeburst.odf` → `Burst Gun` and `apchainvsr.odf` → `Chain Gun`), `Pilot.*`, `Ordnance.*`. `unitName` lives on `GameObjectClass`, which every game-object ODF inherits regardless of which top-level bucket it ends up in. The flattened ODF DB carries each entry's `unitName` directly, so VSR-overridden variants whose `unitName` differs from the stock parent are picked up automatically. Returns `None` when no entry / no `unitName` is available so callers can fall through.

### Combined `prettify_odf` Chain

`prettify_odf` is the canonical pipeline-side entry point used to populate both `odf_map` and `kills.by_vehicle[].name`:

1. Try the weapon resolver. If it returns a real hit (anything other than the raw stem), use it.
2. Otherwise try the unit resolver (`Vehicle.*.GameObjectClass.unitName`). If it returns a name, use it.
3. Otherwise fall back to the title-cased stem (`ivscav_vsr.odf` → `Ivscav Vsr`).

Same-name collisions inside a match — including `_vsr` siblings of stock units — disambiguate as `Name (raw_stem)` via the shared `disambiguate_names` helper (e.g. `Pulse (epulse)` / `Pulse (fpulse)`, `Scavenger (ivscav)` / `Scavenger (ivscav_vsr)`). Disambiguation is scoped to the ODFs the match actually used so suffixes only appear when there is a genuine collision.

---

## 5. Pre-Computed Output Format

### `matches.json` (manifest)

```json
[
  {
    "id": "2026-04-16T01-27-48",
    "name": "Haven",
    "file": "2026-04-16T01-27-48.json",
    "map": "havenvsr.bzn",
    "date": "2026-04-16T01:27:48.000000+00:00",
    "duration_sec": 1130.7,
    "player_count": 10,
    "submitter": "VTrider",
    "team_leaders": { "1": { "name": "…", "s64": "…" }, "2": { "name": "…", "s64": "…" } },
    "players": ["blue", "VTrider", "…"],
    "has_position_data": true,
    "has_target_lock_data": false,
    "has_pickup_data": true
  }
]
```

`players` is a sorted list of unique display names from that match’s **`leaderboard[]`** (same nicknames as the dashboard). It is used only for **match picker free-text search** in `js/app.js` (`buildEntrySearchBlob`); the commander facet chips remain limited to **`team_leaders`** slot 1 and 2 names.

`name` is the display name shown in the match picker, dropdown, hero banner, and page title. It is resolved by `resolve_match_name()` in `scripts/process_stats.py` from `data/map-registry.json[<key>].title` (sourced from iondriver's `gamelistassets` API by `scripts/build_map_registry.py`), with leading `XYZ:\s*` tokens stripped iteratively so `"ST: VSR: TVD: Ebola"` becomes `"Ebola"` and `"VSR: Ancient Hills"` becomes `"Ancient Hills"`. Internal whitespace and special characters are preserved as-is (e.g. `vsrv8` resolves to `Island of *~V8~*+`). When the registry has no title for a map (e.g. iondriver returned 404 across all mod-id fallbacks), the manifest falls back to the raw map filename minus the trailing `.bzn`, case preserved. The pipeline reorders so the registry is built *before* the manifest is written; on the standalone CLI path (`python scripts/build_map_registry.py`) the builder still scans `matches.json` itself via `discover_map_files()`.

`has_position_data` mirrors the per-match `positioning.has_position_data` flag. Frontend uses it to gate Positioning-tab UI cues (e.g. the small-multiples grid only renders when true; otherwise the empty-state card is shown).

### Per-match JSON Structure

#### `match` object

```json
{
  "id": "2026-04-16T01-27-48",
  "source_file": "2026-04-16-01-27-48.binpb.gz",
  "submitter": "VTrider",
  "map": "havenvsr.bzn",
  "date": "2026-04-16T01:27:48.000000+00:00",
  "duration_sec": 1130.7,
  "tick_range": [283, 22897],
  "tick_rate": 20,
  "player_count": 10,
  "config_mod": "1325933293.cfg",
  "snipe_count": 0,
  "teams": {
    "1": [
      { "slot": 1, "player_id": "International ISDFism", "name": "International ISDFism", "steam64": "76561198284800778" }
    ],
    "2": [
      { "slot": 6, "player_id": "DesUxS?sU", "name": "DesUxS?sU", "steam64": "76561198025561228" }
    ]
  },
  "team_leaders": {
    "1": { "name": "International ISDFism", "s64": "76561198284800778" },
    "2": { "name": "DesUxS?sU", "s64": "76561198025561228" }
  },
  "team_factions": {
    "1": { "code": "i", "name": "ISDF" },
    "2": { "code": "e", "name": "Hadean" }
  },
  "has_position_data": true,
  "has_target_lock_data": false,
  "has_pickup_data": true,
  "schema_version": 5,
  "proto_schema_version": "v2",
  "shutdown_requested": false,
  "bullet_hit_distance": {
    "count": 4252,
    "with_distance": 4252,
    "mean": 67.74,
    "max": 597.014
  },
  "terrain_bounds": {
    "min": {"x": -1024.0, "y": 0.0, "z": -1024.0},
    "max": {"x": 1024.0, "y": 320.25, "z": 1024.0}
  },
  "base_to_base_distance": 756.96,
  "sentinel_damage": {
    "count": 0,
    "total_amount": 0.0,
    "first_tick": null,
    "last_tick": null
  }
}
```

`has_pickup_data` (Phase 3) is `true` iff the match contains at least one `PickupPowerup` event. `false` for pre-Phase-3 sessions captured before the proto added the event. Mirrored on `match`, `meta`, and the manifest entry.

`schema_version` (Phase 3) is the per-match output schema version. `1` = Phase 3 baseline; `2` adds the top-level `highlights` block (see "Match Highlights" below); `3` adds `match.team_factions` and `match.winner` (per-team faction labels + match outcome inference — see [§9 Team Faction Detection in docs/DATA_DICTIONARY.md](docs/DATA_DICTIONARY.md#9-team-faction-detection)); `4` adds the v2.3 PvP/PvE leaderboard fields and per-ship combat blocks; `5` adds the proto v2 capture fields (`proto_schema_version`, `shutdown_requested`, `bullet_hit_distance`); versions 6–14 are documented at their feature sections (exclusion flags, reroutes, self-damage carve-out, at-base lift, hp/ammo trails, range histograms, effective kills, pilot-victim exclusion); **`15` is the proto v3 migration** — extended `winner` shape (attestation fields + `attested`/`draw`/`cancelled` decided_by values), new `game_outcome` / `roster` / `roster_conflicts` / `has_bullet_hit_data` fields. **`23` is tick-union roster QA** — `match.roster_qa` + `roster_conflicts.reason`; working identity is the UpdateTick union. Absence means legacy data. Bump when an output-shape-breaking change ships.

The schema_version=5 fields are capture-only — UI never branches on them:
- **`proto_schema_version`** — debugging telemetry, `"v1"` / `"v2"` / `"v3"`, detected by `load_session()`. Stamped here so the Raw Browser / verify scripts can cross-reference which descriptor was used.
- **`shutdown_requested`** — `true` when the match ended because the stat session was cancelled mid-game. v1 sessions always report `false` (the proto field doesn't exist).
- **`bullet_hit_distance`** — per-match summary of v2+'s `BulletHit.distance_to_target` (in world units). `count` is total BulletHits seen; `with_distance` is the subset with `distance_to_target > 0`; `mean`/`max` are `null` on v1 sessions (no distance data on the wire). See `distance_to_target.txt` at the repo root for planned future uses.

The schema_version=16 fields (human outcome adjudication):
- **`winner.adjudicated`** — `true` once the pipeline operator signed off on this match's outcome via the console review prompt (every proto v3+ match prompts once; answers persist in the committed `data/match_outcome_adjudications.json`, keyed by match id). Confirming the current resolution keeps its `decided_by`; overriding or supplying one sets the fourth team-win value **`decided_by: "adjudicated"`**; draws/cancellations reuse the v15 values + the flag; `u` (Unknown) signs off while leaving the block `unclear`. Application/reconciliation are pure winner-block rewrites in `scripts/adjudication.py` — provenance fields are never touched. Manifest mirror: `winner_adjudicated`.

The schema_version=15 fields (proto v3 migration):
- **`winner`** (extended) — now resolved by `resolve_match_outcome()`, which combines the v3 host attestation with the kill-feed inference through an **evidence-first trust ladder**: attested `GAME_CANCELLED` always wins (validity statement; ELO-excluded) → a `clean_win` inference beats a contradicting attestation (`disputed: true`, `agreement: false`, warning glyph in the UI) → attested team wins / draws resolve contested/unclear inferences → no usable attestation (pre-v3, `UNSPECIFIED`, out-of-enum like ESC's `IDCANCEL=2`) passes the inference through verbatim. `decided_by` ∈ `attested` / `clean_win` / `contested` / `unclear` / `draw` / `cancelled`; new sibling fields `attested` / `disputed` / `agreement` / `inferred` (raw inference preserved whenever an attestation existed). `team`/`loser` are `null` for unclear/draw/cancelled so no-winner outcomes naturally stay out of win-rate denominators.
- **`game_outcome`** — raw attestation telemetry (Outcome enum name string or `null`). The interpreted result lives on `winner`; the UI reads this only for the disputed-badge tooltip.
- **`roster`** — raw v3 PlayerInfo passthrough `[{steam64, slot, nickname, valid}]` including invalid empty-slot garbage entries (`valid: false`) for wire-accurate provenance; pre-`ACCOUNT_REROUTES`; `null` on v1/v2 matches.
- **`roster_conflicts`** — identity-shim audit (`[]` when header and ticks agree and no slot correction fired). v23 adds `reason`.
- **`roster_qa` (v23)** — always-on header-vs-tick-union QA (`header_valid`, `tick_union`, `ghosts_dropped`, `tick_only_added`, `max_tick_players`, `agreed`). `null` pre-v3. Console stays quiet when `agreed`. Working identity is the tick union; header-only 0-tick ghosts are dropped. Gated by `_investigation/check_roster_tick_union.py`.
- **`has_bullet_hit_data`** — collector-gap availability flag (`false` when the v3 collector recorded zero BulletHit events). Drives em-dash accuracy rendering, the Sharpshooter gate, aggregator accuracy-sum skips, and the ELO `thug_accuracy` axis availability. Mirrored on manifest entries + contributions.

`team_leaders` is `{ "1": { name, s64 }, "2": { name, s64 } }` capturing the slot-1 / slot-6 commander identities. Drives the picker's Commander/Thug Role facet; the faction scoreboard marks the commander as the first roster chip (shield pip). Match-global, always-unfiltered.

`team_factions` (schema v3+) is `{ "1": { code, name } | null, "2": { code, name } | null }` — derived faction per team. `code` is `"i"` (ISDF) / `"e"` (Hadean) / `"f"` (Scion); `name` is the human label. `null` when the team produced no faction signal (sandbox / pure-AI / corrupt match). Computed by `detect_team_factions()` in `scripts/process_stats.py`, combining Algorithm A (starting ship: first non-empty `PlayerState.odf` per slot) with Algorithm B (event-stream votes from every team-attributed vehicle/structure/pilot ODF). Match-global, always-unfiltered. Faction is fixed per match — the global player filter never reshapes it. Renderer: `renderFactionScoreboard()` injects `.vt-faction-badge[data-faction-code]` next to **Team N**; the commander is the first roster chip.

Match-level spatial context fields:

- `terrain_bounds` — `{min, max}` of 3D world-space terrain extents from `StatHeader.terrain_*` (new-schema only). `null` for pre-schema sessions. Mirrored from `positioning.terrain_bounds` for convenience on non-positioning renderers. Match-global, always-unfiltered.
- `base_to_base_distance` — raw horizontal distance (units, horizontal-plane only) between Team 1 and Team 2 spawn centroids. `null` when either team has zero players. Distinct from `positioning.base_separation`, which is a floored internal scaling value used by the `R_base` / `time_in_base_pct` heuristics. Mirrored from `positioning.base_to_base_distance`. Match-global, always-unfiltered.
- `sentinel_damage` — telemetry for engine sentinel events dropped by the `> 1e6` pipeline filter. `count` is the number of DD+DR pairs dropped (not individual events); `total_amount` is the sum of DD-side amounts (same as DR-side; double-counting is avoided for clarity). `first_tick` / `last_tick` are `null` on clean matches, set on affected matches. Always present (zeros on clean matches). Match-global, always-unfiltered. See [§7 in docs/DATA_DICTIONARY.md](docs/DATA_DICTIONARY.md#7-sentinel-damage-filter).

##### Hero banner + Map Info Modal surface

`js/app.js` `renderBanner()` populates the hero with Duration, Players, Submitted by, and the 70x70 map thumbnail (`#info-map-thumb-btn` with a `bi-zoom-in` corner overlay). All map-dimension fields live inside the **Map Info Modal** (`#map-info-modal`) — opened by clicking the thumbnail — built by `renderMapInfoModal()` from `getMapMeta()` plus the raw `mapRegistry[key]` entry. The modal re-renders on every banner update so it stays in sync across match switches. Merge precedence — highest first:

- **Canonical size** — `mapRegistry[key].formatted_size` (vsrmaplist's `Size.formattedSize` string, e.g. `"1024x1024"`) when present; `~${Math.round(canonical_size)}m` as fallback for legacy entries with no vsrmaplist coverage.
- **Map size (this match)** — `match.terrain_bounds` (edge = max − min on x / z, rendered as `N × Nm`) when present; row omitted otherwise.
- **Elevation** — `match.terrain_bounds.max.y - min.y` when present; row omitted otherwise.
- **Base-to-base** — `match.base_to_base_distance` (empirical, always populated) shown in the "This match" section; `mapRegistry[key].canonical_b2b` ∪ `VSR_MAP_DATA[key].baseToBase` shown in the upper "Map" section.
- **Author** — `mapRegistry[key].author` (vsrmaplist `Author` primary) ∪ `VSR_MAP_DATA[key].author` (legacy fallback).
- **Pools / Loose scrap / Tags** — `mapRegistry[key].pools` (vsrmaplist `Pools`, integer), `mapRegistry[key].loose` (vsrmaplist `Loose`, integer; `< 0` rendered as `Unlimited`), `mapRegistry[key].tags` (vsrmaplist `Tags` comma-string split + trimmed; rendered as `.vt-map-info-tag` chips). Rows omitted when the underlying field is absent (e.g. for maps falling through to the iondriver-only path).
- **Description / Mod / Team names** — `mapRegistry[key].description` (BOM-stripped, line-broken; iondriver primary, vsrmaplist `Description` fallback), `mod_resolved` (linked to Steam Workshop when numeric), `net_vars.svar1` / `svar2`.
- **Thumbnail** — `mapRegistry[key].image_path` → `data/maps/<map_file>.png` (downloaded from iondriver primary, vsrmaplist `Image` URL fallback — both content-addressed `assets/<sha>.png` URLs on the same iondriver host). Hidden when absent (the modal trigger is also hidden).

Per the filter contract these are all match-global / always-unfiltered; no filter narrowing or recomputation paths involved. See `getMapMeta()` in `js/app.js` for the unified merge, `renderMapBannerFields()` for the hero DOM wiring, and `renderMapInfoModal()` for the modal-population helper.

#### `leaderboard[]` entry

```json
{
  "player_id": "VTrider",
  "name": "VTrider",
  "slot": 4,
  "steam64": "76561197974548434",
  "faction": 1,
  "kills": 0,
  "deaths": 0,
  "kd_ratio": null,
  "personal": {
    "dealt": 75489.3,
    "received": 33462.1,
    "pvp_dealt": 35058.7,
    "pve_dealt": 40430.6,
    "pvp_received": 31936.1,
    "pve_received": 1526.0,
    "net": 42027.2,
    "ratio": 2.26,
    "shots_fired": 3700,
    "shots_hit": 3116,
    "accuracy": 0.842,
    "fav_weapon": "Minigun",
    "weapons_used": 12
  },
  "assets": {
    "dealt": 0,
    "received": 550.0
  },
  "weapon_breakdown": {
    "Minigun": { "dealt": 20576.0, "received": 10288.0, "shots": 958, "hits": 651, "accuracy": 0.68 }
  },
  "hit_targets": {
    "DesUxS?sU": { "hits": 2220, "damage": 6639.8 },
    "scion nationalist": { "hits": 334, "damage": 11556.2 }
  }
}
```

Note: `ratio` is `null` when received is 0 (infinite ratio). JS displays this as "∞". `kd_ratio` is `null` when kills and deaths are both 0 or deaths is 0. `hit_targets` maps victim display names to `{ hits, damage }` — hit count from `BulletHit` events and total player-on-player damage from the rivalry matrix. The dashboard derives Dmg/Hit (average damage per projectile hit) from these two values.

**PvP/PvE split (derived):** `pvp_dealt` is the player-on-player subset of `dealt` — i.e. the row sum of `rivalry_matrix[name]` (shooter > 0 AND victim > 0, includes friendly-fire between human teammates). `pve_dealt = dealt − pvp_dealt` lumps damage to AI units and world props into a single PvE bucket. `pvp_received` / `pve_received` are the symmetric split of `received`, derived from the column sum of the rivalry matrix for this victim. Each field is rounded independently to 1 decimal, so `pvp + pve` may differ from the combined total by up to ±0.1.

#### `faction_totals`

```json
{
  "1": {
    "player_dealt": 179320.3,
    "pvp_dealt": 111047.7,
    "pve_dealt": 68272.6,
    "asset_dealt": 3.0,
    "total_dealt": 179323.3,
    "player_received": 137305.9,
    "pvp_received": 132414.2,
    "pve_received": 4891.7,
    "asset_received": 144744.7,
    "total_received": 282050.6,
    "shots": 12615,
    "hits": 7492,
    "accuracy": 0.594
  }
}
```

#### `rivalry_matrix`

```json
{
  "Domakus": { "Lithium": 5850.3, "VTrider": 8428.7 }
}
```

Outer key = shooter name, inner key = victim name, value = total damage dealt.

#### `top_rivalries[]` entry

```json
{ "a": "VTrider", "b": "Domakus", "a_to_b": 18900.1, "b_to_a": 6893.9, "total": 25794.0 }
```

Top 5 bidirectional pairs sorted by total.

#### `weapon_meta[]` entry

```json
{ "weapon": "Chain Gun", "odf": "chainvsr_c.odf", "total_damage": 164756.0, "total_shots": 8172, "total_hits": 5637, "accuracy": 0.69, "users": 10 }
```

#### `timeline`

```json
{
  "bucket_seconds": 10,
  "labels": ["0:00", "0:10", "0:20"],
  "by_player": {
    "VTrider": [0, 0, 0, 0, 589.0]
  },
  "by_faction": {
    "1": [0, 0, 0, 352.0, 3853.9]
  }
}
```

#### `engagements` (`match.schema_version` 27)

Coalesced player-vs-player `DamageDealt` intervals for the 3D replay's attack-line overlay (red beam shooter -> victim + red reticle on players under attack). Match-global, display-only, rating-inert (not in contributions / ELO).

```json
{
  "window_sec": 2.0,
  "min_damage": 40.0,
  "pairs": [
    { "s": "VTrider", "v": "Lithium", "t0": 812.35, "t1": 816.90, "dmg": 1240.0 }
  ]
}
```

Consecutive same-`(shooter, victim)` hits within `window_sec` merge into one interval; intervals under `min_damage` are dropped. `s`/`v` are canonical nicks (join the leaderboard / kill feed / replay actors); `pairs` is sorted by `t0`. Pre-v27 matches omit the block, and `buildEngagementIndex()` in `_map-analysis/render/js/replay-data.js` falls back to synthesizing kill-feed lead-in intervals so the overlay works everywhere.

The same `timeline` structure drives the **Combat tab** static stacked-area chart (`renderTimeline` in `js/charts.js`).

The **Replay tab** is a separate surface: `renderReplayTab()` in `js/app.js` embeds `_map-analysis/render/replay.html` in an iframe (`?match=<id>`, plus `?t=<sec>` when a tick deep link is present). The iframe owns playback, cameras, and the HUD. A match switch rebuilds it. The player filter does not narrow the 3D world.

### Positioning Tab

The `positioning` JSON block drives a dedicated tab that visualizes where players spent the match. The renderers live in `js/positioning-charts.js`:
   - **Movement Leaderboard** (`renderMovementLeaderboard`) — per-player activity score bar, band pill, mean / max / path / time-in-base columns; sortable; row-hover tooltip reveals Area Covered, First Leave, Returns, P95.
   - **Distance-from-Spawn Timeline** (`renderDistanceTimeline`) — Chart.js line chart, one series per player, `x = match time (seconds)`, `y = horizontal distance from personal spawn`. Gaps between segments so teleports don't draw straight flyovers.
   - **Combined Top-down Heatmap** (`renderCombinedHeatmap`) — imperative 2D canvas showing summed `heatmap_grid_xz` across all players over the shared world-space viewport, with spawn markers, faction-tint halves (gated by `base_separation / map_diagonal > 0.3`), and compass rose.
   - **Per-player Small-multiples Heatmap Grid** (`renderHeatmapGrid`) — one card per player using **shared viewport** (all cards use the same world-space extent fitted to everyone's p95 positions) and **shared p95 intensity scale** (one cell's brightness means the same thing on every card). A compact legend strip above the grid explains the visual language: diamond = spawn, color-intensity gradient = visit density, compass + per-match `~u per cell` scale.
   - **Ring Histogram** (`renderRingHistogram`) — stacked horizontal bar per player: time spent in Inner Base / Outer Base / Front Line / Deep Push bands, where band thresholds derive from `base_separation`.

Animated trail playback lives in the 3D Replay iframe, not on this tab.

**Filter integration:** `renderPositioningTab` narrows `positioning.players` to the filtered leaderboard for the Movement Leaderboard + small-multiples highlighting, but always passes the full `positioning` block so the combined heatmap backdrop, team centroids, and opposing-team spawn markers still render for spatial context.

**Empty state:** when `match.has_position_data === false`, `renderPositioningTab` toggles `#section-positioning-empty` visible and hides every other card. Legal to click the tab on any match regardless of data availability.

#### `asset_damage`

```json
{
  "by_player": {
    "Lithium": { "dealt": 1500.0, "received": 56441.3 }
  },
  "by_faction": {
    "1": { "dealt": 3.0, "received": 144744.7 }
  }
}
```

#### `kills`

```json
{
  "leaderboard": [
    { "player_id": "VTrider", "name": "VTrider", "kills": 5, "deaths": 2, "kd_ratio": 2.5 }
  ],
  "feed": [
    { "tick": 5000, "killer": "VTrider", "killer_odf": "ivtank_vsr.odf", "victim": "Domakus", "victim_odf": "ivscout_vsr.odf" }
  ],
  "by_vehicle": [
    { "odf": "ivscout_vsr.odf", "name": "Ivscout Vsr", "count": 8 }
  ],
  "kill_rivalry_matrix": {
    "VTrider": { "Domakus": 3, "Lithium": 2 }
  }
}
```

Note: `kills.feed` excludes powerup pickups, powerup/crate destructions, and deployable destructions — those are routed by the **UnitDestroyed Classification** (Section 2) into the dedicated `pickups`, `powerup_destructions`, and `deployable_destructions` blocks below. `kills.by_vehicle` additionally excludes `apserv_vsr.odf` via the legacy chart-only `VEHICLE_DESTRUCTION_IGNORE_ODFS` filter.

#### `pickups` (Phase 3)

Crate / pod pickups, sourced from `PickupPowerup` events (new-schema only). Always emitted; empty for old-schema sessions (`has_pickup_data: false`).

```json
{
  "has_pickup_data": true,
  "feed": [
    {
      "tick": 1234,
      "picker": "VTrider",
      "picker_in_game_nick": null,
      "picker_odf": "ivtank_vsr.odf",
      "powerup_odf": "apsnipvsr.odf",
      "powerup_name": "ISDF Pulse Rifle Powerup",
      "powerup_team": 6
    }
  ],
  "by_player": [{ "name": "VTrider", "count": 17 }],
  "by_odf": [{ "odf": "apsnipvsr.odf", "name": "ISDF Pulse Rifle Powerup", "count": 8 }],
  "totals": { "total": 42, "team_1": 18, "team_2": 23, "ai": 1 }
}
```

`powerup_name` is built by `powerup_display_name(odf)` (closure in `process_match`) -- see Section 2 "UnitDestroyed Classification" for the resolution chain. Disambiguates the powerup pod from the same-named weapon ordnance.

#### `powerup_destructions` (Phase 3)

Powerups/crates destroyed in real combat (not picked up). Effectively denying the enemy economy by removing the pickup before someone could grab it. Sourced from `UnitDestroyed` events whose `victim_odf` is in the DB-derived powerup set AND `killer_team != 0`. Populated for both old and new schema (the team-zero filter discriminates regardless of source).

```json
{
  "feed": [
    {
      "tick": 5000,
      "killer": "VTrider",
      "killer_in_game_nick": null,
      "killer_odf": "ivscoutm_vsr.odf",
      "powerup_odf": "apserv_vsr.odf",
      "powerup_name": "Service Pod Powerup",
      "powerup_team": 6
    }
  ],
  "by_player": [{ "name": "VTrider", "count": 9 }],
  "by_odf": [{ "odf": "apserv_vsr.odf", "name": "Service Pod Powerup", "count": 5 }],
  "totals": { "total": 12, "team_1": 7, "team_2": 5 }
}
```

#### `deployable_destructions` (Phase 3)

Mines / deployable utility destructions. No `feed` (too noisy); just per-player + per-ODF counts.

```json
{
  "by_player": [{ "name": "Domakus", "count": 14 }],
  "by_odf": [{ "odf": "fball2c.odf", "name": "Flame Mine", "count": 21 }],
  "totals": { "total": 21 }
}
```

#### `snipes` (Phase 3)

Pilot snipe events. Phase 3 enriched `UnitSniped` to carry sniper / victim context; pre-Phase-3 sessions show `sniper_odf == ""` and `victim_odf == ""` (protobuf defaults), but the events still appear in `feed`.

**Identity is slot-derived.** Both `feed[].sniper` / `feed[].victim` and the `by_player[].name` rollup come from `header.teamnum_to_s64[shooter_team]` / `[victim_team]` -> `s64_to_nick`, **not** from the wire-level `UnitSniped.shooter` / `UnitSniped.victim` Steam64 fields. Those fields were collector-bug poisoned through ~2026-05-04 and are ignored by the pipeline. See the [`UnitSniped` erratum](#unitsniped-field-7) for details.

```json
{
  "feed": [
    { "tick": 6789, "sniper": "VTrider", "sniper_in_game_nick": null, "sniper_odf": "isuser_m.odf", "victim": "Domakus", "victim_in_game_nick": null, "victim_odf": "fsuser_m.odf" }
  ],
  "by_player": [{ "name": "VTrider", "count": 3 }],
  "totals": { "total": 5, "team_1": 3, "team_2": 2 }
}
```

#### `positioning`

Player movement analytics derived from `UpdateTick` events. Downsampled to 1 Hz sparse samples per player. Axis convention: **+X East, +Y Up, +Z North** (left-handed). All distance math is horizontal-plane only (`sqrt(dx² + dz²)`).

Top-level shape:

```json
{
  "has_position_data": true,
  "has_target_lock_data": true,
  "sample_rate_hz": 1,
  "match_sample_count": 848,
  "map_bounds": { "min": {"x": -1024.0, "z": -1024.0}, "max": {"x": 1024.0, "z": 1024.0} },
  "map_bounds_source": "terrain",
  "terrain_bounds": {
    "min": {"x": -1024.0, "y": 0.0, "z": -1024.0},
    "max": {"x": 1024.0, "y": 320.25, "z": 1024.0}
  },
  "map_diagonal": 2896.3,
  "base_separation": 756.0,
  "base_to_base_distance": 756.0,
  "observed_max_range": 1055.1,
  "p99_speed": 57.6,
  "teleport_threshold": 300.0,
  "team_base": {
    "1": { "centroid": {"x": 288.5, "z": 256.7}, "radius": 18.0 },
    "2": { "centroid": {"x": -283.5, "z": -237.6}, "radius": 18.0 }
  },
  "players": {
    "VTrider": { /* per-player block, see below */ }
  }
}
```

Top-level fields worth calling out:

- `map_bounds` — 2D `{min:{x,z}, max:{x,z}}` box used as the reference frame for `heatmap_grid_xz` binning and all canvas projections. Derived from `terrain_bounds` when available (new-schema sessions), otherwise from observed player extents. Always non-null when `has_position_data: true`.
- `map_bounds_source` — `"terrain"` when `map_bounds` came from header terrain fields, `"observed"` when it was derived from player extents, `null` when `has_position_data: false`. Frontends can use this to label tooltips / axis ticks as absolute vs. match-relative.
- `terrain_bounds` — full 3D `{min:{x,y,z}, max:{x,y,z}}` from `StatHeader.terrain_*`. `null` for pre-schema sessions. Mirrored onto the `match` object.
- `base_separation` — floored internal scaling value `max(computed_centroid_dist, 500, observed_max_range × 0.3)`. Drives `R_base` and `time_in_base_pct` heuristics. Not a user-facing measurement.
- `base_to_base_distance` — raw horizontal distance between Team 1 and Team 2 spawn centroids, **no floor applied**. `null` when either team has zero players. This is the "how far apart are the bases" measurement; use this, not `base_separation`, for display.

`has_target_lock_data` is a match-global availability flag: `true` iff any `PlayerState.has_target=true` sample was observed in the match. It's `false` for pre-schema matches (the proto field didn't exist yet) and also for new-schema matches where no player ever activated target mode. Combined with per-player `metrics.target_lock_pct`, it distinguishes "no data" (pre-schema) from "0% lock" (field present, never pressed).

Per-player block:

```json
{
  "spawn": {"x": -600.0, "y": 52.0, "z": -525.0},
  "personal_base_radius": 100.0,
  "sample_count": 848,
  "first_seen_sec": 0,
  "last_seen_sec": 847,
  "metrics": {
    "mean_dist": 475.0,
    "max_dist": 1024.0,
    "p50_dist": 418.0,
    "p90_dist": 912.0,
    "p95_dist": 960.0,
    "time_in_base_pct": 0.256,
    "time_to_first_leave_sec": 5,
    "path_length": 17154.2,
    "path_length_per_sec": 20.25,
    "convex_hull_area": 623670.8,
    "bounding_box_area": 832450.0,
    "return_to_base_count": 4,
    "activity_score": 84,
    "movement_band": "Aggressive",
    "target_lock_pct": 0.237
  },
  "trail": {
    "t": [0, 1, 2, 5, 6, 8, ...],
    "x": [...], "z": [...], "y": [...],
    "segments": [[0, 412], [413, 678], [679, 847]]
  },
  "ship_timeline": {
    "t": [0.1, 144.3, 162.2, ...],
    "odf": ["fvscout_vsr.odf", "fsuser_m.odf", "fvscoutm_vsr.odf", ...]
  },
  "heatmap_grid_xz": [[...32×32 bin counts over map_bounds...]],
  "heatmap_polar": [[...16×8 angular×radial bin counts around spawn...]]
}
```

Key behaviors:

- `trail.t[]` is **sparse** — values may skip (player absent from an `UpdateTick` due to death/disconnect/out-of-scope). All time math iterates `t[i]` as authoritative seconds, never array index.
- `trail.segments[]` splits the trail at teleport detections (death/respawn warps). Frontends draw one polyline per segment; renderers must not interpolate across gaps.
- `ship_timeline` (**`match.schema_version` 25**, `PIPELINE_VERSION` 45) is the full-rate `UpdateTick.PlayerState.odf` step function (transitions only, 0.1 s precision, verbatim wire ODF strings). The 3D replay (`replay-ship-tracker.js`) prefers it over the pre-v25 sparse kill/pickup/snipe reconstruction, which lagged re-ships until the player's next attesting event. Display-only; not in contributions. Pre-v25 JSON has no field (tracker falls back).
- **v26 / `PIPELINE_VERSION` 46:** `trail.target[]` (0/1 T-lock) and `trail.speed[]` (authored `PlayerState.speed`, 1 dp) ride parallel to `t/x/y/z`. `builds.feed[].position` is the wire `BuildEvent.build_position` Vec3 on BUILD rows (`null` otherwise). Top-level `structures[]` is the 3D replay's building layer: starting recyclers + constructor BUILD xyz; deaths from `UnitDestroyed` only; turret-class instances stay `death_reason: "untracked"` while `TURRET_DEATHS_RELIABLE` is False and are not rendered. Rating-inert (`_investigation/golden_replay_v26_inert.py`). Not in contributions.
- **v34 / `PIPELINE_VERSION` 59** (58 mis-read a replant as an upgrade): `PoolPlanted` (proto v5) becomes timed extractor instances on `structures[]`. A plant is an upgrade only when an unpaired constructor `*bscup` BUILD for the same team is within `UPGRADE_OBJECT_SEC` (2 s) and `POOL_SITE_M` (8 m): that BUILD is the mesh, its spawn moves to the plant tick, and the pool on the site dies `death_reason: "upgraded"`. Every other plant is the faction base (`ibscav_vsr` / `ebscav_vsr` / `fbscav_vsr`), including a replant after the upgrade was killed. A `*bscup` BUILD with no plant still retires the living pool. Plants and pool-stem kills are one tick-ordered pass (kills first on a shared tick). A kill hits a pool of that stem that has already spawned; the site's next plant ranks it (later base plant = site was emptied, later upgrade = still standing), then the killer's trail, then the oldest. A pool the census loses for `CENSUS_GRACE_SEC` with no kill retires `death_reason: "census"`. `pool_census` / `pool_plant_count` are omitted when there were no plants. Pre-v5 `*bscup` rows gain `site` and `kind: "extractor_upgrade"`. `match.has_pool_plant_data` is mirrored on the manifest and is not in contributions. Display-only. Gate: `_investigation/check_pool_structures.py`.
- **v27 / `PIPELINE_VERSION` 48:** top-level `engagements` block (coalesced player-vs-player `DamageDealt` intervals; `{window_sec, min_damage, pairs:[{s,v,t0,t1,dmg}]}`) for the 3D replay's attack-line overlay. Coalesced in the normalized `de_*` damage loop (covers v1-v4), keyed by canonical nick. Display-only, rating-inert (`_investigation/golden_replay_v26_inert.py`), not in contributions. Pre-v27 matches fall back to kill-feed-synthesized lead-in intervals client-side (`buildEngagementIndex()`).
- When no `UpdateTick` data is present, the block emits with `has_position_data: false`, empty `players: {}`, and nulls/zeros elsewhere. Frontend gates Positioning-tab UI off this flag.
- Full schema with field tables, derivations, and known limitations lives in [docs/DATA_DICTIONARY.md](docs/DATA_DICTIONARY.md) under "Positioning Block".

#### Activity Score / Movement Profile

A single 0-100 number per player summarizing how active they were across the map. Higher = more active / more map coverage. Lower = stayed at base.

Formula (second pass in `_compute_positioning`):

```
activity_score = round(100 × (
  0.5 × (1 − time_in_base_pct)
  + 0.3 × normalized_max_dist
  + 0.2 × normalized_path_per_sec
))
```

Both normalizers are **match-relative p95** values (computed across all players in the match), so the score self-calibrates per match — a "Balanced" score means the same thing on any map regardless of scale or pace.

Movement bands:

| Score | Band | Meaning |
|---|---|---|
| 0-20 | Defensive | Stays at or near spawn almost the entire match |
| 21-40 | Territorial | Orbits the base, short pushes |
| 41-60 | Balanced | Mix of defense and offense |
| 61-80 | Mobile | Regular rotations, meaningful time out of base |
| 81-100 | Aggressive | Pushes deep, rarely returns, covered most of the map |

Supporting details:

- **Returns-count hysteresis gate**: `return_to_base_count` only increments when a player stays outside `R_base × 1.2` for ≥ 5 seconds before re-entering `R_base × 0.8`. Filters boundary-noise oscillations.
- **Career aggregation** in the All Matches aggregate (built client-side from `match_contributions.json` by `js/all-matches-aggregator.js`):
  - `mean_movement_score` — average across matches with positioning data. Note: this is an average of match-relative (p95-normalized) scores, so it carries a minor approximation — the Career Radar surfaces it anyway for rough Mobility comparison.
  - `movement_score_stddev` — exposes bimodal players whose mean looks "balanced" but who alternate between camp and rotate
  - `movement_band_dominant` — modal band
  - `movement_band_distribution` — count per band
  - `matches_with_positioning` — denominator (< `matches_played` when older sessions lack `UpdateTick` data)
  - `mean_target_lock_pct` — direct average of per-match `target_lock_pct` (absolute ratio), across matches with `has_target_lock_data=true`. `null` when the player has no such matches.
  - `matches_with_target_lock_data` — denominator for `mean_target_lock_pct`. Always `<= matches_with_positioning`.
- Full derivation: [docs/DATA_DICTIONARY.md](docs/DATA_DICTIONARY.md) "Positioning Block" section.

### Match Highlights (per-match, schema_version 2)

Per-match award catalog rendered below the Faction Scoreboard. A fixed slate of 12 always-on cards — The Bully, Grim Reaper, Bullet Sponge, The Hustler, Sharpshooter, Gunner, Puppeteer, Frenemies, Roadrunner, Pod Goblin, Chris Kyle, The Locksmith — plus four proto-v4 commander-economy cards (Elon Musk / Loose Collector / Conveyor Belt / First Upgrade) that self-omit pre-v4. Each card emits unconditionally as long as its data gate passes; cards whose underlying data is unavailable (positioning / pickups / T-key gates, missing snipes, no resource/build telemetry, etc.) are simply omitted, and the grid reflows around the gap.

Built by `compute_highlights()` in [scripts/process_stats.py](scripts/process_stats.py) from already-built per-match blocks (`leaderboard`, `top_rivalries`, `pickups`, `powerup_destructions`, `snipes`, `positioning`, `economy`, `builds`). No event re-walking. The block is **match-global and always-unfiltered** — the dashboard reads `currentData.highlights`, never the filtered `data` view, and the renderer is pure formatting.

**Bully/Sponge asymmetry (intentional).** The Bully ranks by `personal.pvp_dealt` (humans-only — "bullying" is a thing you do *to people*; reserved as the PvP-flavor card so a future Domination card can take the directional argmax-rivalry slot). Bullet Sponge ranks by `personal.received` (total — humans + AI / turrets / scavs / mines / world; "sponging" is what you do to *any* incoming damage). Don't reflexively "fix" this to be symmetric — it's load-bearing for the cards' stories.

There are **two independent schema versions** in play:

- **Per-match `meta.schema_version`** — `2` once the pipeline added the `highlights` block. Pre-v2 matches have no block; the UI hides the section.
- **Inner `highlights.schema_version`** — `1` was the initial bare-scalar shape; `2` adds a per-card `value_breakdown` payload (always present), pivots `the_bully.value` to `personal.pvp_dealt`, and pivots `bullet_sponge.value` to `personal.pvp_received`. Bumped independently when card definitions / payload shape change in a renderer-breaking way.

Top-level shape (schema v2):

```json
"highlights": {
  "schema_version": 2,
  "cards": [
    {
      "category": "the_bully",
      "label": "The Bully",
      "icon": "bi-emoji-angry",
      "winner": { "type": "player", "name": "VTrider", "steam64": "7656119..." },
      "value": 60200.4,
      "value_format": "damage",
      "value_breakdown": { "top_victim": "Viv", "top_victim_damage": 38420.5 },
      "runner_up": { "name": "Domakus", "value": 49120.0 },
      "delta_pct": 0.226,
      "narrative": "clear"
    },
    {
      "category": "the_hustler",
      "label": "The Hustler",
      "icon": "bi-graph-up-arrow",
      "winner": { "type": "player", "name": "VTrider", "steam64": "7656119..." },
      "value": 4.0,
      "value_format": "kd",
      "value_breakdown": { "kills": 12, "deaths": 3 },
      "runner_up": { "name": "Domakus", "value": 2.5 },
      "delta_pct": 0.6,
      "narrative": "dominant"
    },
    {
      "category": "frenemies",
      "label": "Frenemies",
      "icon": "bi-people-fill",
      "winner": { "type": "pair", "a": "VTrider", "b": "Domakus" },
      "value": 38420.5,
      "value_format": "damage",
      "value_breakdown": { "a_to_b": 21100.0, "b_to_a": 17320.5 },
      "runner_up": { "name": "GenosseGeneral vs Sporkinator", "value": 19200.0 },
      "delta_pct": 1.001,
      "narrative": "dominant"
    }
  ]
}
```

`narrative` is a discrete bucket keyed off `delta_pct` — `"dominant"` if `delta_pct >= 0.50` (winner ≥ 1.5× runner-up), `"clear"` if `delta_pct >= 0.15`, `"close"` otherwise; `null` `delta_pct` (solo standout) reads as `"clear"`. The renderer picks one of three deterministic copy variants per `(category, narrative)` via a small FNV-1a hash over `match.id + category`. It then walks the bucket forward from the seeded index until it finds a variant whose `{token}` placeholders all have non-empty values in the interpolation context — so a `{top_victim}`-flavored line is skipped on a legacy match without kill data, while still producing byte-identical output for matches where the data is present.

`value_format` drives `formatHighlightValue()` in `js/app.js` — `"damage"`/`"count"`/`"score"`/`"distance"`/`"scrap"` are locale-formatted integers, `"ratio"` and `"kd"` use two decimals, `"percent"`/`"accuracy"` render as `(value * 100).toFixed(1) + '%'`, `"duration"` renders as `Nm:Ss` (e.g. `1m:30s`). The `"kd"` format is functionally identical to `"ratio"` at the value-formatting layer; the distinct enum value tells `formatHighlightBreakdown()` to render `{kills}K / {deaths}D` (with `(perfect)` when `deaths == 0`).

`value_breakdown` is always present in v2 and carries category-specific contextual data (top victim/tormentor for narrative cards; raw kills/deaths/shots/etc. for context cards; directional rivalry split for Frenemies; band + path coverage for Roadrunner; absolute seconds for Locksmith). The renderer surfaces it as a sub-line below the headline value via `formatHighlightBreakdown()`. Per-key shape for each category lives in [docs/DATA_DICTIONARY.md](docs/DATA_DICTIONARY.md) §5 `highlights`.

Renderer + section UI live in [js/app.js](js/app.js) (`renderHighlights()`, `formatHighlightBreakdown()`, `HIGHLIGHT_COPY` template dict, `HIGHLIGHT_UNITS` per-category unit-suffix map) and [css/vtstats-theme.css](css/vtstats-theme.css) (`.vt-highlights-card` + `.vt-highlight-tile` blocks, including the `.vt-highlight-tile-breakdown` rule and the `.vt-highlight-tile-value-unit` sub-span). Section markup is in [index.html](index.html) at `#section-highlights`, immediately below `#section-faction` inside `#tab-overview`. The card grid uses Bootstrap `row-cols-1 row-cols-sm-2 row-cols-md-3 row-cols-lg-4 row-cols-xl-6 g-3` so 12 cards lay out 6×2 on xl, 4×3 on lg, 3×4 on md, 2×6 on sm, 1×12 on xs. xl tiles drop the inline flavor copy (Bootstrap tooltip carries it) but keep the breakdown line visible — clamped to a single line with ellipsis at xl, wrapping below.

Headline values render with a category-specific unit suffix from `HIGHLIGHT_UNITS` in `js/app.js` (`"dmg"`, `"kills"`, `"shots"`, `"K/D"`, `"mvnt"`, `"snipes"`). The unit is a small muted sub-span beside the big mono number; cards whose `value_format` already self-labels (`sharpshooter` / `the_locksmith` show `"%"`) and `crate_pod_goblin` (breakdown line supplies the context) render bare. Adding a new card requires a one-line edit to `HIGHLIGHT_UNITS` — units are renderer-side only, not part of the JSON payload, so no pipeline rerun is needed when iterating on labels.

Pre-`meta.schema_version=2` matches have no `highlights` key — the renderer hides the section in that case (no crash, no placeholder tiles). Full per-card formula table, gates, tiebreakers, and `value_breakdown` keys live in [docs/DATA_DICTIONARY.md](docs/DATA_DICTIONARY.md) §5 `highlights` and the matching glossary entries in §6.

### All Matches Aggregate (in-memory, built from `match_contributions.json`)

The pipeline writes `data/processed/match_contributions.json`, a dict keyed by `<match_id>.json` with the slim per-match shape produced by `_extract_contribution()` in `scripts/process_stats.py`:

```json
{
  "2026-04-16T01-27-48.json": {
    "id": "2026-04-16T01-27-48",
    "map": "havenvsr.bzn",
    "date": "2026-04-16T01:27:48.447651+00:00",
    "duration_sec": 847.8,
    "submitter": "VTrider",
    "player_count": 10,
    "has_position_data": true,
    "has_target_lock_data": false,
    "has_pickup_data": false,
    "sentinel_damage_count": 0,
    "leaderboard": [
      {
        "player_id": "...",
        "name": "VTrider",
        "dealt": ..., "received": ...,
        "pvp_dealt": ..., "pve_dealt": ..., "pvp_received": ..., "pve_received": ...,
        "asset_dealt": ..., "shots_fired": ..., "shots_hit": ...,
        "kills": ..., "deaths": ..., "pickups": ...,
        "weapon_breakdown": { "<wpn>": { "dealt": ..., "shots": ..., "hits": ...,
          "range_hist": { "pvp": [counts...], "pve": [counts...] },
          "range_pct_hist": { "pvp": [counts...], "pve": [counts...] } } },
        "distance_buckets": { "pvp": { "close": .., "mid": .., "long": .., "extreme": .. },
                              "pve": { ... } },
        "activity_score": 72, "movement_band": "Mobile", "path_length": 25000.0,
        "target_lock_pct": null
      }
    ],
    "weapon_meta":    [{ "weapon": "...", "total_damage": ..., "total_shots": ..., "total_hits": ... }],
    "distance_bin_edges": [10, 20, 30, 40, 50, 75, 100, 150, 200, 300, 400, 600, 800, 1200],
    "distance_pct_bin_edges": [10, 20, 30, 40, 50, 60, 70, 80, 90, 100, 120],
    "weapon_ranges": { "<wpn>": 170.0 },
    "rivalry_matrix": { "<shooter>": { "<victim>": damage } }
  }
}
```

**Weapon engagement range (`match.schema_version` 11, v2-only).** `BulletHit.distance_to_target` (raw world units == in-game meters) is bucketed per `(player, weapon, channel)` into fixed-bin histograms over the shared `match.distance_bin_edges` (emitted once; `null` on v1 / no-distance matches). Bin `i` covers `[edges[i-1], edges[i])`; the trailing bin is the overflow `[edges[-1], inf)`. Each `weapon_breakdown[w].range_hist` carries a `pvp` and/or `pve` array (a channel is omitted when empty; the whole `range_hist` key is omitted when neither channel recorded distance). `personal.distance_buckets` is the per-player coarse fingerprint (`close`/`mid`/`long`/`extreme` per channel, exact sub-sums of the fine histogram since `50`/`150`/`400` are edge members). All of this is **purely descriptive playstyle/meta — never a skill signal or VTSR-T axis** (weapon/ship access drives range far more than skill). Histograms are additive, so the dashboard sums them across the player-filtered roster and across channels (`Both` = `pvp`+`pve`) before estimating percentiles. The contributions slice deliberately drops `range_hist` (no career range rollup). Consumed by the Weapons & Accuracy tab's Weapon Engagement Range strip + Shot Accuracy range fingerprint, gated on `match.bullet_hit_distance.with_distance > 0`.

**Weapon-fair engagement % (`match.schema_version` 12, v2-only).** Each ordnance ODF declares `OrdnanceClass.shotSpeed * lifeSpan` = the projectile's theoretical max range in meters. Dividing each hit's distance by its ordnance's max range yields a weapon-**fair** `% of envelope` that removes the weapon-choice confound raw meters has. Per `(player, weapon, channel)` percentage histograms ride on `weapon_breakdown[w].range_pct_hist` over the shared `match.distance_pct_bin_edges` (`[10..100,120]`; trailing bin = `>120%` over-range); a representative hit-weighted book range per weapon display name is emitted once on `match.weapon_ranges` (drives the meters-mode theoretical-max reference marker + tooltips); `match.bullet_hit_distance.with_max_range` reports `%`-coverage. Lobbed/timed ordnance (`lifeSpan ~ 1e30` → ~1e31 m) is excluded via the `MAX_REASONABLE_RANGE = 2000` cap (such hits still count in the meters histogram but not the `%` view). Drives the strip's `Meters | % of max` toggle and the Shot Accuracy table's per-player **Engagement Envelope** column (weapon-weighted mean `%` of max, sortable, with `Point-blank`/`Mid`/`Edge`/`Over-range` band labels). Still **descriptive playstyle/meta only — not a VTSR-T axis** (ship access + role still confound). `null`/absent on v1 / no-distance matches; the contributions slice drops `range_pct_hist` too.

**Death-tick (0,0,0) position placeholder (`match.schema_version` 28, `PIPELINE_VERSION` 50).** On the tick a unit is destroyed the engine emits its `PlayerState` with `position == (0,0,0)` (world origin) and a negative `health`, for exactly one tick — the ejected pilot appears at the real spot on the next. **A `PlayerState` with `health < 0` is never a valid position sample.** Across four sampled matches the two signals coincided 1:1 (48/48, 35/35, 83/83, 45/45), but neither is documented upstream, so the pipeline tests both.

A `BulletHit` landing at that instant has `distance_to_target` measured against the ORIGIN rather than the victim, which on VSR maps reads 300-800 m. Traced on `2026-05-22T03-21-34` tick 14385: reported 398.6 m while shooter and victim were 30.2 m apart, with the shooter standing at `(-391.9, 72.5)` where `sqrt(391.9² + 72.5²) = 398.5`. Burst Gun surfaced it (90 m book ceiling, multi-pellet, frequent killing blows) as a bimodal histogram with an empty gap at 150-200 m and a phantom 300-800 m mode; **84.7%** of all corpus hits beyond 1.15x book max matched shooter→origin exactly. The same samples planted 729 phantom trail points across 134 matches and booked 59,232 fake `hp_lost` in a single match.

Two independent guards reject the range sample. **The hit still counts** — `weapon_breakdown[w].hits`, `shots_hit` and every accuracy figure are untouched; only the range is unknowable, and `_dt` is zeroed so it skips all downstream histograms uniformly.

| Guard | Rule | Rationale |
|---|---|---|
| A (`DIST_SANITY_BOOK_FACTOR = 1.5`) | reject when `distance > book_max * 1.5` | `shotSpeed * lifeSpan` is a hard physical ceiling. Legit overshoot tops out near +33% (downhill shots), artifacts run 3-8x. Covers 99.8% of corpus hits. |
| B (`DIST_ORIGIN_REL_TOL = 0.02`, `DIST_ORIGIN_ABS_TOL = 5.0`) | reject when the distance matches `dist(shooter, origin)` within tolerance **and** already exceeds the book envelope (or no book range exists) | The second clause makes a false positive impossible for book-covered ordnance. |

Guard B reads `last_known_pos`, refreshed at FULL rate from every non-placeholder `PlayerState` — deliberately ahead of the 1 Hz positioning gate, because a hit needs the shooter's position at hit time, not at the last kept trail sample. Placeholder samples never enter it (a zeroed shooter would compare the origin against itself and match everything). The positioning skip sits **before** the downsample gate so `position_last_kept_tick` does not advance; otherwise the following real sample is lost too.

New telemetry `match.bullet_hit_distance.rejected_implausible` (1,976 corpus-wide, 0.40% of distance-bearing hits) is NOT subtracted from `count` — a spike there means the guard is misfiring or the collector regressed. Post-fix the corpus holds zero `(0,0)` trail samples and zero samples beyond 1.5x book max, and Burst Gun's highest non-empty bin fell from `[600,800)` to `[100,150)`. Because dropping trail samples nudges `time_in_base_pct` → `activity_score` → the VTSR-T `mobility` axis, `ELO_SCHEMA_VERSION` bumped 10 → 11 and **pre-v11 `peak_vtsr` is no longer comparable** (measured drift: max 1.00 ELO, mean 0.20, leaderboard order unchanged).

**Player-laid mine damage and kills (`match.schema_version` 35, `PIPELINE_VERSION` 60).** A laid mine detonates as its own entity, so its damage arrived with `shooter = 0` and booked to `asset_dealt`, which `enemy_damage_share` does not read. v2+ credits the slot's human when `shooter_odf` is a `Mine`-bucket stem and they had already piloted a ship that mounts that mine's dispenser. v1 has no `shooter_odf`, so it uses ordnance stems only a mine dispenser can produce (`build_mine_ordnance_odfs_v1`). Kills whose `killer_odf` is a Mine entity follow the same carrier guard, on every schema, and the kill feed flags them `killer_via_mine`. `engagements` stays on the raw shooter. **Invariant: `Σ personal.dealt + Σ assets.dealt` per match is unchanged.** The 312-match reprocess moved 346,964.5 damage and 20 kills; 4,832.8 damage had no eligible owner. VTSR-T drift was max 7.40, mean 1.40, top-10 order unchanged, so `ELO_SCHEMA_VERSION` 18 → 19 and **pre-v19 `peak_vtsr` is not comparable**. The thug-mean handicap moves VTSR-C (schema 11 → 12, max 0.43) and VTSR-2v2T performance (schema 2 → 3, max 0.05).

`js/all-matches-aggregator.js` exposes `window.VTAggregate.build(contributions, fileIds)`. Pure summation; produces the `{meta, career_stats, global_weapon_meta, global_rivalries}` shape below. The All Matches view fetches `match_contributions.json` once per session, caches it on `window.__vtContributions`, and rebuilds the aggregate over whatever `fileIds` subset the active picker filter resolves to (when no filter is engaged, the subset is every key in the contributions dict — i.e. the unfiltered career view).

**Why the browser does this.** Statistical aggregation is the pipeline's job everywhere else in the project; this is the documented exception, made so the picker's facet filters (player count, duration band, players, role, etc.) can scope the aggregate without re-fetching per-match JSONs or pre-computing every facet combination. The slim contributions slice keeps the heavy work — building each per-match leaderboard / weapon_meta / rivalry_matrix — pipeline-side; only summation moves client-side. The legacy `data/processed/all_matches.json` is no longer written, and `scripts/process_stats.py`'s `build_all_matches_aggregate()` Python function is kept as reference code only.

**5-match minimum.** After summation, the aggregator drops any `career_stats[]` row whose `matches_played < MIN_CAREER_MATCHES` (currently `5`) and cascade-filters `global_rivalries[]` to the kept names. `global_weapon_meta` and `meta.*` totals are unaffected — the threshold is exclusively about cross-match player identity. Per-match views are unaffected. The picker filters narrow the scope first; the threshold applies after, so a player with 30 career matches who only appears in 3 of the filtered subset is hidden in that view (semantics: "show me players with meaningful presence in this view"). The threshold is surfaced via `meta.min_career_matches` (live value, single source of truth for UI labels) and `meta.players_dropped_by_min_matches` (count of pruned rows in the current scope). When the count is non-zero, `js/app.js` `renderAggMeta()` adds a "Career Roster: 5+ matches, N hidden" tile and `updateAllMatchesFilterBanner()` appends a parenthetical hidden-count when picker filters are active.

#### `meta`

```json
{
  "match_count": 4,
  "total_duration_sec": 3827.1,
  "maps_played": ["havenvsr.bzn", "vsrremnant.bzn"],
  "date_range": ["2026-04-16", "2026-04-16"],
  "submitters": ["VTrider"],
  "matches_with_positioning": 4,
  "matches_with_target_lock_data": 2,
  "total_sentinel_damage_dropped": 5,
  "matches_with_sentinel_damage": ["2026-04-22T01-58-26"],
  "min_career_matches": 5,
  "players_dropped_by_min_matches": 3
}
```

#### `career_stats[]` entry

```json
{
  "player_id": "VTrider",
  "name": "VTrider",
  "matches_played": 4,
  "total_dealt": 356440.3,
  "total_received": 146729.3,
  "total_pvp_dealt": 201000.0,
  "total_pve_dealt": 155440.3,
  "total_pvp_received": 120000.0,
  "total_pve_received": 26729.3,
  "total_asset_dealt": 1197.0,
  "overall_accuracy": 0.678,
  "total_kills": 0,
  "total_deaths": 0,
  "fav_weapon": "Minigun",
  "best_match": { "id": "2026-04-16T02-11-20", "map": "vsrremnant.bzn", "dealt": 99267.3 },
  "weapon_breakdown": {
    "Chain Gun": { "dealt": 78400.0, "shots": 3200, "hits": 2100, "accuracy": 0.656 }
  },
  "mean_movement_score": 72.5,
  "movement_score_stddev": 8.2,
  "movement_band_dominant": "Mobile",
  "movement_band_distribution": { "Mobile": 2, "Aggressive": 2 },
  "total_path_length": 125000.0,
  "matches_with_positioning": 4,
  "mean_target_lock_pct": 0.192,
  "matches_with_target_lock_data": 2
}
```

The All Matches **Career Leaderboard** renders these fields in the same column order as the per-match Player Leaderboard (plus **Matches**); **Net**, **Ratio**, and **# Wpns** are derived in the browser from totals and `weapon_breakdown` (distinct weapons with `dealt > 0`). **Team** is shown as unavailable for aggregates.

**Per-match average columns** (headers like PvP/m, Dealt/m, K/m) are computed only in the browser as **career total ÷ `matches_played`** (matches with zeros still count). **Accuracy** remains **career hits ÷ career shots** (`overall_accuracy` from the pipeline), not an average of each match’s accuracy percentage.

The Career Leaderboard card header **Per match / Totals / All** control (`careerColumnView` in `js/app.js`, persisted as `vt-career-cols-view`) toggles CSS classes `vt-career-col-total` vs `vt-career-col-avg` on `#career-table`; **shared** columns (Ratio, Accuracy, Movement, etc.) stay visible in every mode.

#### `global_weapon_meta[]` — same structure as per-match `weapon_meta`

#### `global_rivalries[]` — same structure as per-match `top_rivalries`, top 10

---

## 6. Styling Standards

### Bootstrap-First

All UI components use Bootstrap 5.3.2 classes. Never write custom CSS for something Bootstrap already provides.

### Theme System

The project uses a CSS variable system prefixed with `--kb-*`. All 44 themes are defined in `themes.css` with both light and dark mode variants.

**Variable categories:**
- `--kb-primary`, `--kb-accent`, `--kb-info`, `--kb-success`, `--kb-warning`, `--kb-danger` — semantic colors
- `--kb-primary-fg`, `--kb-accent-fg`, etc. — foreground colors for solid backgrounds
- `--kb-bg-body`, `--kb-bg-card`, `--kb-bg-subtle` — background levels
- `--kb-text-primary`, `--kb-text-secondary`, `--kb-text-muted` — text hierarchy
- `--kb-border-subtle` — borders

**Rules:**
- **Zero hardcoded colors** in HTML or JS. Always use CSS variables.
- For muted backgrounds, use `*-muted` variants, never `bg-*-opacity-*`.
- For solid backgrounds, pair with `text-*-fg`. For muted backgrounds, use `text-*`.
- Chart.js colors are read from CSS variables via `getComputedStyle()` at render time.
- **Zero inline `<style>` blocks** in HTML. All dashboard-specific styles live in `css/vtstats-theme.css`.

### Typography — Geist

The project uses **Geist** (Vercel's typeface, v1.8.0, vendored in `vendor/fonts/`):
- **Geist Sans** (variable woff2) — body text, headings, UI elements
- **Geist Mono** (variable woff2) — stat values, table number columns, accuracy figures. Always paired with `font-variant-numeric: tabular-nums`

### Glassmorphic Theme Layer

`css/vtstats-theme.css` is an additive visual layer loaded after `layout.css`. It provides:
- Glass surfaces (`backdrop-filter: blur()`, translucent backgrounds) on cards, navbar, modals, dropdowns
- Multi-layer depth shadows (`--vt-shadow-elevation-1/2/3`)
- Ambient background (soft gradient washes using theme colors)
- Card entrance animations and animated number counters
- `--vt-*` custom properties control visual effects. `--kb-*` controls colors. Both have `[data-mode="light"]` overrides.
- `@media (prefers-reduced-motion: reduce)` disables all animation.

### Theme Switching

`theme.js` exposes `window.KBTheme` with methods for `setTheme()`, `setMode()`, `toggleMode()`. State is persisted in `localStorage` keys `kb-theme` and `kb-mode`.

HTML attributes: `<html data-theme="default" data-mode="dark">`.

### Tab Navigation

Dashboard content is organized into Bootstrap nav-pills tabs:
- **Per-match:** Overview, Economy (v4 matches only), Elo, Combat, Rivalries, Weapons & Accuracy, Assets, Movemint, Storyline (v4 matches only), 3D Replay
- **All Matches:** Overview, Weapons & Rivalries, Commanders, Meta

Tabs use **lazy rendering**: only the active tab renders charts on match load. Other tabs render their content on first activation via the Bootstrap `shown.bs.tab` event. Match switch resets all tab render flags. Global filter changes also trigger the same lazy re-render pipeline — `destroyAllCharts()` + `resetTabState()` + re-register deferred renderers + re-register fullscreen chart renderers.

### Storyline Tab (match.schema_version 22)

The Storyline tab (`#tab-storyline`, nav pill immediately left of 3D Replay, hidden unless the match carries a `storyline` block) is the per-match bird's-eye narrative: it fuses economy, builds, positioning, kill-feed and timeline telemetry into one story. Rendered entirely by [`js/storyline.js`](js/storyline.js) (`window.VTStoryline = { render, destroy }`) from the pipeline-computed `storyline` block — match-global, always unfiltered (highlights passthrough contract).

Three cards (top → bottom):

1. **The Verdict** — six stat cells with team-colored name attribution and a trophy marker on each stat's winner: scrap income, extractor war, field control (front-line means, em-dash without positioning), materiel lost (all hulls incl. AI-owned — tooltip states the deliberate difference from `thug_supply`'s human-only accounting), structure investment, and result (tooltip explains `decided_by` provenance, mirroring `applyWinnerBadge` copy).
2. **Match Timeline** — four Chart.js lanes sharing one x-domain with a synced crosshair, per-lane explainer lines, glass tooltips, and **x-only drag-zoom synced across all lanes** (vendored `chartjs-plugin-zoom`, mirroring the Economy pools-lane link pattern; shared Reset-zoom button; the scrap-status **band strips** under the Momentum lane re-clip to the zoom window). Lanes: Momentum (net combat-ship value bars + pool-advantage line + beat-flag scatter with native tooltips; gold vertical lines mark weight-5 beats on every lane), Map control (smoothed front lines + base-intruder bars — the rush detector), War machine (fielded/lost cumulatives), Combat intensity.
3. **Key Moments** — the curated beats rail: Bootstrap icons per kind, team chips, detail sub-lines; rows with payloads (kill-burst unit lists) expand via a chevron; recycler/factory destruction rows are title-only (no killing-blow subtext or expander); hovering locates the beat on all lanes via the crosshair; **clicking a row opens the 3D Replay at that moment** through `window.vtOpenReplayAtTick(tick)` (exposed by `app.js`: sets `pendingReplayTick`, clears the pane's rendered flag, shows the tab — the replay iframe reboots with `?t=<sec>`). Same-second `result` beats sort last so a host-attested win sits after the recycler/factory kill that is the win condition.

The auto-generated **The Story** paragraph (the `facts` + `archetype` clause system) was removed; the Verdict and Key Moments already say what it said. The pipeline still emits `facts` and `archetype` (rating-inert, unchanged) for future use.

Deep link: `?match=<id>&tab=storyline`. On pre-v4 matches the pill is hidden and the deep link bounces to Overview (same mechanism as the Economy tab). Copy iteration requires no pipeline rerun — all English lives in `js/storyline.js`'s `BEAT_COPY` / `DECIDED_BY_COPY` / `LANE_TITLES` tables, template-gated by `_investigation/check_story_templates.mjs`. Block schema: `docs/DATA_DICTIONARY.md` §5 `storyline`; pipeline mechanics + invariants: `.cursor/rules/data-schema.mdc` "Storyline block".

### Elo Tab (per-match VTSR-T)

The Elo tab (`#tab-elo`, always-visible pill after Economy / after Overview when Economy is hidden) explains how **this match** moved VTSR-T, with a compact VTSR-C addendum when a commander duel exists. It is the digestible companion to the Overview Player Leaderboard's Δ column. Rendered entirely by [`js/match-elo.js`](js/match-elo.js) (`window.VTMatchElo = { render, destroy }`) from `elo_history.json` (already fetched on dashboard boot via `ensureEloLoaded()`) joined to `currentData.match.id`, plus unfiltered `currentData.leaderboard` for team / commander / campod / partial badges. VTSR-C rows come from a lazy 404-safe fetch of `elo_commander_history.json`.

Four cards, commander first:

1. **Commander ladder this match** — compact two-row VTSR-C strip (experimental) when `elo_commander_history.json` has a `duels[]` row for this match: signed Δ, `before → after`, one favorite/underdog sentence, and the Balonce **main reason** line (`VTBalonce.mainReasonHtml`: commander edge / thug edge / both / even / stacked, with the corpus record for that class in the tooltip). **Self-hides** when there is no duel (historical undetermined corpus). Not a cloned P-vs-E scatter; economy axes are recorded-not-scored and stay off this card.
2. **Lobby rating moves** — CSS diverging bars of every rated player's Δ, sorted descending, team-colored, click-to-select. **Default selection is the biggest gainer** (`rated[0]`), not the largest `|Δ|`. A single-player filter still preselects that player. Campod / Partial rows sit in a muted "Not rated this match" group.
3. **Selected player** — signed Δ + `before → after`, a one-sentence P-vs-E verdict ("Played better than this lobby expected") with an (i) tooltip explaining Played vs Expected, a `MATCH_ELO_COPY` helped/hurt sentence, commander-cushion note when `axis_contributions_meta` is present, and the bipolar 6-axis bar grid with plain-language readings (`Above lobby` / `Average` / `Below lobby`). Snipes and T-key usage are display stats (the snipe feed, the Chris Kyle highlight, and the Weapons & Accuracy T-Key column), not axes (v2.11).
4. **Over / under vs expectation** — Chart.js scatter of the rated lobby (X = expected, Y = performance, `y = x` "played as expected"). Click a point to select. Glass tooltip sits **above** the point (`vtAlign: 'above'`) so it does not cover the hit target.

**Match-global, always unfiltered** (highlights passthrough). The per-match player filter only **preselects** a row when exactly one player is selected. Empty states cover history 404, `match_excluded` (too few players / too short / cancelled), and an empty rated lobby. Footer links to `elo/?tab=how`; the VTSR-C card links to `elo/?tab=vtsr-c`. No pipeline / schema bump.

Deep link: `?match=<id>&tab=elo`.

### Global Player Filter

**Scope.** The global filter applies to the **per-match view only**. The All Matches view consumes the in-memory aggregate produced by `VTAggregate.build()` from `match_contributions.json` (see [`js/all-matches-aggregator.js`](js/all-matches-aggregator.js)). `career_stats[]` fields render unfiltered with respect to `filterState`; the Career Radar's A/B picker (`careerRadarState` in `app.js`) is a chart-local selection mechanism with no interaction with `filterState`.

**Picker filter (separate axis).** The match picker (`#match-picker-modal`) carries facets — duration band, faction (unordered Any vs Any; `?fac=` / `?vs=`, codes `i` / `f` / `e`), player count, submitter, full-roster Players (with Match-mode `any of these` / `all of these` and Role `any` / `Commander` / `Thug`; the chip cloud starts collapsed). When the user is on the All Matches view and toggles a chip, `applyPickerFilters()` schedules a debounced `loadAllMatches()` re-aggregate via `scheduleAllMatchesReaggregate()`, so the career table / radar / global weapon meta / global rivalries reflect only the filtered subset. The All-Matches-view banner above the aggregate surfaces the current scope ("Aggregate of K of N matches matching the active filters") and a Clear-filters shortcut. State lives in `pickerState` and is persisted in sessionStorage under `vt.picker.filters.v2` (one-shot v1 migration on first read).

**Canonical filter contract.** For the full filter contract and the six-question checklist that every new pipeline output field must satisfy, see `.cursor/rules/filter-contract.mdc`. That rule is the single source of truth; this section is the human-readable companion.

The match info banner contains a filter bar that lets users switch between three modes:

- **All Players** (default) — no filtering, all visuals show the full match data
- **Team** — select Team 1 or Team 2; all visuals scope to that faction's players
- **Player** — select one or more players via checkbox dropdown; all visuals scope to the selection. When exactly one player is selected, a dedicated profile card replaces the faction scoreboard.

The filter is implemented entirely client-side in `app.js`. `getFilteredData(data, filter)` derives a filtered data object from the loaded JSON. The filtered object replaces `leaderboard`, `rivalry_matrix`, `top_rivalries`, `kills` (feed/kill_rivalry_matrix/leaderboard), `timeline.by_player`, `asset_damage`, and `faction_totals`.

`weapon_meta` is recomputed client-side by aggregating `leaderboard[].weapon_breakdown` across the filtered player set (summing dealt/shots/hits, recalculating accuracy). The pre-aggregated `weapon_meta` array is only used for the unfiltered "All Players" view. `kills.by_vehicle` always passes through unchanged (match-global aggregate, not per-player attributed).

**Match-global identity / outcome blocks** — `match.team_leaders`, `match.team_factions` (schema v3+), and `match.winner` (schema v3+) all pass through unchanged. These describe properties of the match itself (who commanded, what faction each team picked, who won) that are fixed at match end and never reshape under a player narrowing. Renderers read them directly from `currentData.match.*`, never from the filtered `data.match.*`. The kill-feed-derived winner inference is computed once on the **unfiltered** `kills.feed` so a player filter that hides the loser's recycler/factory destruction events cannot corrupt the result. Pre-v3 matches have no `team_factions` / `winner` block; the UI hides the badge gracefully.

`kill_feed[].killer_team` and `kill_feed[].victim_team` (schema v3+) are raw uint slot ints (1-10 or 0) carried alongside the rest of `kills.feed` — narrowed upstream as a single block, no separate filter logic.

**Positioning block (including `activity_score` and `target_lock_pct`).** The `positioning` block passes through `getFilteredData` unchanged; `positioning.players` is narrowed at render-time by `renderPositioningTab`. This lets spatial renderers (combined heatmap, spawn markers) keep their full-match context while per-player charts still scope to the focused player.

- `positioning.players[].metrics.activity_score` is **match-relative** (p95-normalized across the full match roster). Its career aggregate `career_stats[].mean_movement_score` is an average-of-relatives — labeled as an approximation, not a true cross-match metric.
- `positioning.players[].metrics.target_lock_pct` is **absolute** (0-1 ratio — fraction of 1 Hz positioning samples where the player had a target lock active). Cross-match comparable. Its career aggregate `career_stats[].mean_target_lock_pct` is a valid direct average and renders on the Career Radar's 8th "T-Key Usage" axis. The match-global `positioning.has_target_lock_data` / `match.has_target_lock_data` / `career_stats[].matches_with_target_lock_data` flags distinguish "no data" (pre-schema match, or no player ever activated target mode) from "0% lock" in tooltips.

The filtered data is stored in `currentFilteredData` — all renderers and the timeline toggle reference this instead of `currentData`. `renderMatchData(data)` is the shared render function called by both `loadMatch()` and `applyFilter()`.

A "Persist" toggle saves the filter selection across match switches. When enabled, the filter is reconciled with the new match's roster — players that don't exist in the new match are dropped.

### URL Parameters

Any combination of match, filter, and tab is shareable via query parameters.

**History.** The first load and `setLiveSync(true)` use `history.replaceState` on the entry you already landed on. After that, a user change to match, tab, or the per-match filter **pushes** when Live Sync is on, so Back and Forward walk those views. `popstate` applies the URL with writes suppressed. Picker facets always write (Live Sync off included) and push the same way; the first committed search query pushes and later commits while that field stays focused replace. Replay fullscreen still pushes a same-URL sentinel, and Back on that entry only exits fullscreen.

**URL writes for match / tab / filter are gated by the Live Sync toggle** (Settings gear, `bi-broadcast`). When OFF (default), `syncUrl()` is a no-op and those clicks leave the URL alone, so Back leaves the page. When ON, every state change writes to the URL. The preference persists in `localStorage` under the `vt-url-sync` key. Share includes the Has VOD facet (`vod`), matching the picker URL. All Matches is the exception: opening it writes `?match=all` even with Live Sync off (Overview is that URL with no `tab`; other All Matches tabs still add `tab`). Leaving All Matches for a single match with Live Sync off removes `match=all` so a refresh does not jump back.

The Share button (Settings gear, `bi-link-45deg`) copies a URL representing the current state to the clipboard regardless of the Live Sync toggle — it calls `buildShareUrl()` directly. The button briefly flips to a checkmark on success or a warning on failure. The Clipboard API is used when available (`navigator.clipboard.writeText` under a secure context), with a hidden-textarea + `document.execCommand('copy')` fallback for `file://` and plain `http://` contexts.

| Param | Values | Notes |
|---|---|---|
| `match` | match ID (e.g. `2026-04-16T01-27-48`) or `all` | Omitted -> load first match in manifest |
| `filter` | `all` \| `team` \| `player` | Omitted or `all` means no filter |
| `team` | `1` \| `2` | Only valid when `filter=team` |
| `players` | comma-separated canonical names or Steam64 IDs | Only valid when `filter=player`; tokens are resolved case-insensitively |
| `tab` | see slug tables below | Omitted when on the default Overview tab |
| `t` | raw tick (uint32) | One-shot Replay seek target. Produced by the Raw Data Browser's events-table row click. Forces `tab=replay` when no explicit `tab` is provided. Consumed once on initial load: `js/app.js` converts the tick to seconds and boots the 3D replay iframe at `?t=<sec>`. |

**Valid tab slugs (per-match):** `overview`, `economy`, `elo`, `combat`, `rivalries`, `weapons`, `assets`, `positioning`, `storyline`, `replay`
**Valid tab slugs (all-matches):** `overview`, `weapons-rivalries`, `commanders`, `meta`

Slug-to-button mappings are defined in `MATCH_TAB_SLUGS` and `ALL_TAB_SLUGS` at the top of `js/app.js`.

The 3D replay's transport state (play position, speed, camera) is **not** URL-synced, except the one-shot `?t=` seek. Keeping URLs small means sharing a match + filter + tab slug is enough context; detailed playback scrubbing is an in-session interaction.

#### Player Identifier Resolution

`leaderboard[].name` is already canonical per the pipeline (it applies `data/steamid_to_name.txt` before JSON emission). The browser does not load `steamid_to_name.txt`.

- **Write:** `syncUrl()` always emits `leaderboard[].name` — readable and canonical.
- **Read:** for each token in `players=`, `resolvePlayerTokens()` matches against the current match's leaderboard:
  1. Tokens matching `/^\d{16,}$/` are matched against `leaderboard[].steam64` (Steam64 fallback for players not in `steamid_to_name.txt`)
  2. Otherwise, case-insensitive match against `leaderboard[].name`
  3. Unresolved tokens are dropped silently
- Mixed tokens are allowed: `?players=VTrider,76561198025561228`

#### Load Flow

On page init, `parseUrlState()` reads all params once and the boot block branches:

- `match=all` -> `loadAllMatches(urlState)`
- `match=<valid ID>` -> `loadMatch(file, urlState)`
- `match=<unknown ID>` -> `showMatchNotFound(badId)` renders an error panel with a match-picker dropdown built from the manifest (mirrors the topnav selector). Selecting from it triggers a normal load.
- No `match` param -> load first match, still pass `urlState` so filter/tab hydrate correctly.

Inside the loader, `hydrateFilterFromUrl()` applies the URL's filter state. **URL wins over the persist preference on initial load** — URL is explicit user intent. The `vt-filter-persist` checkbox stays at whatever the user set; the preference governs subsequent in-session match switches but not the initial URL-driven load.

#### Two Independent Preferences

`vt-url-sync` and `vt-filter-persist` are separate preferences with no interaction:

- **`vt-url-sync`** (Live Sync toggle, Settings gear): governs whether state changes write to the browser URL. Defaults to off.
- **`vt-filter-persist`** (Persist checkbox in the filter bar): governs whether the filter carries across in-session match switches. Defaults to off.

A user can have either, both, or neither enabled.

#### Live Sync Gate Bypasses

Two specific paths bypass the `liveSyncEnabled` gate and write to the URL unconditionally:

1. **`setLiveSync(true)`** — when the user turns Live Sync ON mid-session, we write the current state immediately so the URL catches up. (Turning it OFF does not touch the URL; the current URL is left intact so a refresh still preserves state, and the user can still hit Share.)
2. **Match-not-found recovery** — when a user visits `?match=<bogus-id>` and picks a valid match from the error panel, the stale bad-match URL is cleared via `history.replaceState(null, '', pathname)` before loading. Clearing a misleading URL is the user's unambiguous intent.

#### Edge Cases

| Input | Behavior |
|---|---|
| Default home visit, Live Sync off | URL stays clean (no params); preloader shows, first match loads silently |
| Default home visit, Live Sync on | First load `replaceState`s `?match=<newest>` onto the landed entry (no extra Back step) |
| Later match / tab / filter change, Live Sync on | `pushState`; Back restores the previous view without reloading past it |
| Picker chip or search | `pushState` even when Live Sync is off (search refines with `replaceState` while the field stays focused) |
| Replay fullscreen, then Back | URL unchanged, so Back only exits fullscreen |
| Shared URL opened, Live Sync off | Applied on load, URL preserved verbatim (even if some players unresolvable) |
| Shared URL opened, Live Sync on | Applied, then rewritten cleanly by `syncUrl()` at end of load |
| `?match=all&filter=team` | Filter ignored on All Matches view; `buildShareUrl()` omits it |
| `?filter=team` missing `team=` | Falls back to `all` |
| `?filter=player` with empty/unresolved players | Falls back to `all` |
| Some `players=` tokens unresolved, some valid | Valid ones kept; if list empties, falls back to `all` |
| `?tab=bogus` | Invalid slug falls back to overview via `slugMap[slug]` guard |
| Insecure context (`file://`, plain `http://`) | Share button uses textarea + `execCommand` fallback |
| Share clicked before first match loads | `buildShareUrl()` returns the bare pathname; harmless |

### CSS Load Order (required)

```html
<link rel="stylesheet" href="css/theme-system.css">    <!-- Variable foundation -->
<link rel="stylesheet" href="css/themes.css">           <!-- 44 theme definitions -->
<link rel="stylesheet" href="css/main.css">              <!-- Component styles -->
<link rel="stylesheet" href="css/layout.css">            <!-- Layout/responsive -->
<link rel="stylesheet" href="css/vtstats-theme.css">    <!-- Glass theme layer -->
```

### JS Load Order

```html
<script src="js/theme.js"></script>              <!-- Theme system (must be first) -->
<script src="js/vtstats-fx.js"></script>         <!-- Effects engine (registers Chart.js plugin) -->
<script src="js/charts.js"></script>             <!-- Chart renderers -->
<script src="js/positioning-charts.js"></script> <!-- Positioning tab renderers (heatmaps, movement leaderboard) -->
<script src="js/app.js"></script>                <!-- Main application -->
```

---

## 7. Chart Architecture

Charts use Chart.js 4.4.7 (vendored locally). Key patterns:

1. **Theme-aware colors**: Read `--kb-primary`, `--kb-accent`, etc. via `getComputedStyle()` at render time.
2. **Destroy on switch**: Call `destroyAllCharts()` before rendering new match data.
3. **Player palette**: A fixed 15-color palette for consistent player coloring within a match.
4. **Chart types used**: Line (stacked area for the Combat Timeline), Bar (horizontal for weapon meta, stacked for player weapons, horizontal for vehicle kills), Doughnut (rivalry cards, player profile dealt/received), Bar (horizontal for weapon accuracy). The Replay tab is the 3D iframe, not a Chart.js chart.
5. **Partial data handling**: Chart renderers handle filtered/partial data gracefully. When a single player is selected, the timeline renders as a non-stacked line with point markers. Empty `weapon_meta` after filtering shows a placeholder message. Heatmaps accept the full player names array for axes while the matrix may contain only one row (single-player mode).
6. **Shadow plugin**: `vtstats-fx.js` registers a global Chart.js plugin that adds subtle `shadowBlur` to chart datasets using `--vt-chart-shadow-blur` and `--kb-primary`.
6. **Glass tooltips**: Custom external tooltip renderer replaces Chart.js defaults with translucent, blur-backed tooltip panels that respect the active theme.
7. **Animation**: Default duration 1000ms with `easeOutQuart` easing.
8. **Imperative canvas for spatial layouts**: The combined top-down heatmap and the per-player small-multiples heatmaps are drawn directly on a 2D canvas rather than through Chart.js — Chart.js doesn't cleanly express a fitted world-space viewport with shared cross-card intensity. These renderers live in `js/positioning-charts.js` and read the same `--kb-*` CSS variables at draw time so theme swaps still apply on re-render.

---

## 8. Adding New Matches

1. Place the session file in `data/sessions/<username>/`:
   - Format: `.binpb.gz` (gzip-compressed protobuf)
   - Filename: timestamp-based (e.g. `2026-04-16-01-27-48.binpb.gz`)
   - The `<username>` folder identifies who submitted the session
2. Run: `cd scripts && python process_stats.py`
3. The pipeline automatically discovers all `.binpb.gz` files across all user folders and outputs to `data/processed/`.
4. Refresh the browser.

### Adding New Event Types

1. Update `scripts/statsgate.proto` with the new message definition.
2. Recompile: `python -m grpc_tools.protoc --proto_path=. --python_out=. statsgate.proto`
3. Add processing logic in `process_stats.py` (the main event loop).
4. Add new fields to the output JSON.
5. Update `js/app.js` to render the new data.
6. Update this document and `.cursor/rules/data-schema.mdc`.

---

## 9. Dependencies

All dependencies are vendored locally. No CDN usage.

| Dependency | Version | Location |
|---|---|---|
| Bootstrap CSS | 5.3.2 | `vendor/bootstrap/css/` |
| Bootstrap JS | 5.3.2 | `vendor/bootstrap/js/` |
| Bootstrap Icons | — | `vendor/bootstrap-icons/` |
| Chart.js | 4.4.7 | `vendor/chartjs/` |
| Geist Sans | 1.8.0 | `vendor/fonts/GeistVF.woff2` |
| Geist Mono | 1.8.0 | `vendor/fonts/GeistMonoVF.woff2` |
| protobufjs (light build) | 7.4.0 | `vendor/protobufjs/protobuf.min.js` + generated descriptor `vendor/protobufjs/statsgate.proto.json` |
| Python protobuf | >=4.25.0 | `scripts/requirements.txt` |
| grpcio-tools | (dev only) | For `protoc` compilation — **not** in `requirements.txt`; install manually with `pip install grpcio-tools` |
| protobufjs-cli (dev only) | 1.1.3 | For regenerating `vendor/protobufjs/statsgate.proto.json` after schema changes — run `npx pbjs -t json scripts/statsgate.proto > vendor/protobufjs/statsgate.proto.json` |
| yt-dlp / opencv-python / easyocr / ffmpeg (**operator-only**) | — | **Not a pipeline dependency.** Required only by the standalone `scripts/map_match_video.py` YouTube VOD mapper (openpyxl / `import_f9_ledger.py` precedent). `ffmpeg` may be on PATH or bundled via `pip install imageio-ffmpeg`. Never imported by `process_stats.py`. |

---

## 10. Map Assets & Overlays

Top-down map images from iondriver's `gamelistassets` API are fetched at pipeline time (`scripts/build_map_registry.py`) and consumed by the frontend as visual context for the match-info hero thumbnail, the Positioning tab's heatmap background, the Replay tab's trail background, and the match-picker modal card thumbnails.

### Projection contract

Every overlay (image background + data drawn on top) uses the **same** world-to-screen projection so images and data stay aligned:

```
imageBounds = registry.image_calibration.image_bounds_world  // hand-tuned override
           ?? match.terrain_bounds (2D xz slice)              // from StatHeader (new-schema)
           ?? match.map_bounds                                // from positioning (pre-schema)

px = (worldX - imageBounds.min.x) / (imageBounds.max.x - imageBounds.min.x) * canvasW
py = (imageBounds.max.z - worldZ) / (imageBounds.max.z - imageBounds.min.z) * canvasH
```

Axis convention: +X East, +Z North, image top = north. `py` is inverted so world +Z renders up on screen.

Single source of truth: `getMapMeta(match)` in `js/app.js` computes `imageBounds` once per match and exposes it plus the image `HTMLImageElement` (cached by `mapFile` key) via `window.VTMapRegistry`. `js/positioning-charts.js` reads through this helper.

### Calibration workflow

iondriver's images are content-addressed (SHA-named), high-quality, and stable forever — but their exact world-space extents aren't always `terrain_bounds`. Some images have a visual border around the playable area; some are cropped inside it. To compensate, each per-map JSON at `data/maps/<mapFile>.json` carries an optional `image_calibration`:

```json
{
  "image_calibration": {
    "image_bounds_world": {
      "min": { "x": -1200, "z": -1200 },
      "max": { "x":  1200, "z":  1200 }
    },
    "note": "image shows ~12% buffer around terrain; widen projection to compensate"
  }
}
```

When `null` (default), the frontend falls back to `match.terrain_bounds`. When populated, `image_bounds_world` is treated as the authoritative image-to-world mapping. Calibration is preserved across `scripts/build_map_registry.py` reruns (the builder reads the existing value and passes it through rather than clobbering with null).

**Tuning loop**:

1. Open `_investigation/output/alignment_test.html` (regenerate via `python _investigation/build_alignment_page.py`).
2. For each new-schema match, verify the Team 1 / Team 2 spawn centroid dots land on plausible recycler/base locations. Pre-schema matches have no terrain_bounds so their cards render without overlay.
3. If a dot is off, edit `data/maps/<mapFile>.json` → set `image_calibration.image_bounds_world` to a wider or shifted rectangle.
4. Re-run `python _investigation/build_alignment_page.py` to confirm visually.
5. Reload the dashboard — `getMapMeta()` picks up the new calibration on next match load.

No iondriver re-fetches are required during tuning; calibration is purely a local override.

### Per-surface integration

| Surface | Renderer | Image use |
|-|-|-|
| Hero thumbnail | `js/app.js` `renderMapBannerFields()` | 70×70 `<img>` (wrapped in `#info-map-thumb-btn` with a `bi-zoom-in` corner overlay; click opens the Map Info Modal) directly from `registry.image_path` |
| Map Info Modal image | `js/app.js` `renderMapInfoModal()` | Full-size `<img id="map-info-modal-image">` from the same `registry.image_path`; description (BOM-stripped, `<br>`-rendered) sits below the image in the left column of the modal body |
| Match picker card | `js/app.js` `buildMatchPickerCardHtml()` | 96×96 `<img>` per card; placeholder icon when map not in registry |
| Combined heatmap | `js/positioning-charts.js` `renderCombinedHeatmap()` | `_drawMapImageLayer()` at `globalAlpha: 0.45` between the solid backdrop and heatmap cells |
| Per-player heatmap grid | `js/positioning-charts.js` `renderPlayerHeatmap()` | Same pattern; image re-used via `VTMapRegistry.getMapImage()` cache |

All renderers gracefully no-op when `imagePath` is absent (map not in registry, registry fetch failed, pre-schema match) — the pre-overlay rendering is preserved.

### Filter-contract classification

- `registry[key].image_path`, `registry[key].image_calibration`, `registry[key].canonical_*` — external reference data; match-global; always-unfiltered.
- `match.terrain_bounds` — already classified (C1).
- No per-player narrowing; no aggregation.

See `.cursor/rules/filter-contract.mdc` for the complete table.

### Known limitation

First-render spawn centroids on the 4 new-schema maps currently show a consistent ~10% centripetal compression relative to the map images' visible base structures. This is a semantic difference between our measurement (player spawn centroid from first UpdateTick) and iondriver's image framing — not a code bug. The calibration field above is the fix; ship default (`null`) on every map, tune per-map as needed.

---

## 11. Raw Data Browser (`raw/index.html`)

The Raw Data Browser is a standalone, isolated page for inspecting per-match data at every layer. It is **not** part of the main dashboard — it lives at `/raw/` with its own CSS + JS and has no shared state with `index.html`.

### Three tiers

| Tier | What | Where surfaced |
|---|---|---|
| **1. Raw binpb** | Gzipped protobuf wire format, exactly as produced by `statsgate` | Header card (metadata + download button). No tree view — bytes aren't useful as a tree. |
| **2. Decoded JSON** | Faithful 1-to-1 JSON mirror of `scripts/statsgate.proto`. Every `StatEvent` in `event_stream` fully deserialized. Nothing summed or attributed. | View tab "Decoded JSON". Rendered as a virtualized tree. |
| **3. Processed JSON** | The pre-aggregated `data/processed/<match_id>.json` the main dashboard consumes | View tab "Processed JSON". Rendered as a virtualized tree. |

Tier 2 does not exist as an on-disk artifact — it is materialized on demand in the browser from tier 1.

### Decode pipeline

1. `fetch('../data/sessions/<submitter>/<basename>.binpb.gz')` — the binpb is served statically, same as the processed JSON (page lives at `/raw/`, so assets are one directory up).
2. Gunzip via the native `DecompressionStream('gzip')` API (no vendored lib).
3. **Triple-descriptor decode.** Try the current v3 descriptor first (`vendor/protobufjs/statsgate.proto.json` → `statsgate.ClientStatSession.decode(bytes)`); on a wire-type error from protobufjs (which is strict), fall back to v1 (`vendor/protobufjs/statsgate_v1.proto.json` → `statsgate_v1.ClientStatSession.decode(bytes)`). On success, check `header.players`: non-empty → v3; empty → lazy-load the frozen v2 descriptor (`vendor/protobufjs/statsgate_v2.proto.json` → `statsgate_v2.ClientStatSession`) and re-decode so the identity maps (reserved under v3) are readable. The detected schema is stamped on `state.protoSchemaVersion` (`"v1"` | `"v2"` | `"v3"`). The Steam64 resolver builds from `header.s64ToNick` (v1/v2) or `header.players[]` (v3).
4. `ClientStatSession.toObject(msg, { longs: String, defaults: false, oneofs: true, bytes: String, enums: String })` → plain JS object suitable for the tree renderer.
   - `longs: String` is lossless only after `installSteam64Long()` in `js/raw-browser.js`. The light protobuf build ships no Long library, so a bare uint64 becomes a JS number and rounds past 2^53. The shim turns the two 32-bit halves back into the decimal Steam64 the pipeline writes, then `protobuf.configure()` rebinds the reader. Without it, Reconcile cannot match a player.
   - `defaults: false` omits zero-valued scalars — matches the Edition 2023 implicit-presence wire format and the proto's `// undefined if not a player` comment semantics.
   - `oneofs: true` adds an `eventType` discriminator on `StatEvent` identifying which arm is active.

### URL schema

```
raw/?match=<id>
        &view=decoded|processed|reconcile
        &mode=tree|events        (only meaningful when view=decoded)
        &path=<json-pointer>     (tree mode only)
        &q=<search>              (tree and events; see "Search" + "JSONPath" below)
        &regex=1                 (Words vs Regex; omit for Words)
        &types=<csv>|none       (events mode only; arm names, or none for zero rows)
        &tick=<lo>-<hi>          (events mode only; inclusive tick range)
        &player=<steam64>        (events mode only; filter to rows involving this player)
```

- Absent `match` → match picker UI.
- Absent `view` → defaults to `decoded`.
- Absent `mode` → defaults to `events`. `mode=tree` opens the JSON tree.
- On `view` change, `path` and `q` reset (paths don't translate across tiers).
- `path` uses RFC 6901 JSON Pointer format (e.g. `/eventStream/5/damageDealt/amount`).
- `types` accepts any subset of the schema's event arms. Omit the param for every arm. `types=none` shows zero rows.

### Domain-aware resolvers

Resolvers augment the tree at render time — they never mutate the decoded object.

- **Steam64 → nickname**: any `uint64`-shaped string (`/^\d{15,20}$/`) that exists as a key in `header.s64_to_nick` renders with the player's nickname chip.
- **ODF → pretty name**: any string matching a key in the match's top-level `odf_map` (from the processed JSON) renders with the prettified weapon/unit name. See `odf_map` in `.cursor/rules/data-schema.mdc` for scope.

### Virtualization contract

Fixed 28px row height (`ROW_HEIGHT` in `js/raw-browser.js`, `.vt-raw-tree-row` in `css/raw-browser.css` — these must stay in sync). Rows are absolutely positioned inside a scroll container; only `firstVisible - OVERSCAN` through `lastVisible + OVERSCAN` are rendered. The tree model is lazy-expanded: collapsed subtrees exist only as a single summary row (`{ n items }` / `{ n keys }`) in the visible list. Default expansion depth on load is 2.

### Search

One find bar, shared by Tree and Events. Default mode is **Words**: case-insensitive AND of whitespace-separated tokens (`Gun Spire` matches that unit name; `sev spire` matches a row that has both). A visible **Regex** control (JS `RegExp` with the `i` flag) replaces the old Ctrl+Enter toggle. Invalid patterns show `bad regex` and do not run. Jump on Enter / Shift+Enter / the arrows, not on every keystroke.

Haystacks include raw strings **and** resolved nicknames / ODF pretty names (`resolveSteam64` / `resolveOdf`), so the name on screen is the name you can search. Tree walks are chunked (a few thousand nodes per frame, capped at 2,000 stored hits) with a `Searching…` status so a 100k-event object cannot freeze the tab. Large arrays such as `eventStream` stay collapsed under the size cap; the summary row offers **Open in Events**.

### Entry surfaces

- **Main dashboard**: "View raw" button on the match-info banner (`#info-raw-link` in `index.html`), href updated per-match by `renderBanner()` in `js/app.js` (`raw/?match=<id>`).
- **Docs page**: docs/index.html mentions the Raw Data Browser; open it from the dashboard match-info **View Raw Data** button (`raw/?match=<id>`).
- **Direct URL**: `raw/?match=<id>` works without going through either.

### Schema-migration verify tool

`scripts/verify_proto_decode.mjs` is a one-off Node script that decodes a real `*.binpb.gz` with protobufjs + the generated descriptor and prints the header summary + per-oneof event counts. Compare its output against the Python pipeline's printed event count for the same file. Required after any change to `scripts/statsgate.proto`; see the script header for one-off setup (`npm i --no-save protobufjs@7`).

### Events-mode view (`view=decoded&mode=events`)

A virtualized alternate rendering of `event_stream`. Row for each event with columns: `Tick · Time (sec, derived from match.tick_rate) · Type · Shooter · Actor ODF · Victim · Victim ODF · Ordnance · Amount`. Pre-extracts hot columns into a packed array once per match load; filter/sort/render are cheap from that array.

Actor ODF is the acting ship's stem (`shooterOdf`, else `killerOdf`, else `pickerOdf`). Victim ODF is `victimOdf`. Ordnance stays the weapon, powerup, or built unit. Each ODF cell shows the resolved name plus a stem link to `../odf/?odf=<stem>`. BulletHit rows with no damage amount show `distance_to_target` in the Amount cell as meters. UpdateTick player ODFs and BuildEvent positions stay in the tree.

**Filters** (all URL-synced):

- **Find bar** — the same Words / Regex control as the tree. Filters the packed row haystack (type label, sub-type, both players, every ODF field raw and pretty, amount, hit distance). The card's row count is the result (`842 of 129,730`).
- **Event-type chips** — multi-select, one per oneof arm, each with a checkbox mark. Every chip can be turned off; zero selected types renders 0 rows (`types=none`).
- **Tick range slider** — dual-handle; seconds labels are derived from `match.tick_rate`.
- **Player cell click** — clicking any Steam64 cell narrows to rows where that s64 is the shooter or victim. A dismissable badge above the table shows the active filter.
- **Reset** — a single button that restores type / tick / player filters to their initial state (the find-bar query is independent).

**Pair highlight** — hovering a `DamageDealt` row outlines the adjacent `DamageReceived` row (and vice versa) per the [adjacent-pair rule](.cursor/rules/data-schema.mdc). A one-pass precompute (`pairIdx: Int32Array`) indexes the pairs at load time so hover is O(1). The pair-index is only built on v1 sessions — on v2 the unified `DamageDealt` carries both sides on the same event, so `pairIdx` stays filled with `-1` and the adjacent-pair hover hint silently disables itself.

**Schema-aware chips** — the events filter chips read from `currentEventArms()`, which returns the schema-filtered subset of `EVENT_ARMS_ALL`. On v2 sessions the `damageReceived` chip is omitted because the arm never appears in the stream.

**Schema-aware Reconcile** — `computePersonalReceived(s64)` reads from `damageReceived` rows on v1 and from `damageDealt` rows (with `r.victim == s64`) on v2. `computePersonalPvpDealt(s64)` reads paired-DR victim on v1 and direct `r.victim` on v2. `computeSentinelSummary()` is schema-agnostic — one logical record per matching `damageDealt` row regardless of schema.

**Cross-link to Replay** — clicking a row navigates to `../index.html?match=<id>&tab=replay&t=<tick>`. On load, `js/app.js` converts that tick to seconds and boots the 3D replay iframe at `?t=<sec>`. The jump is consumed once; later scrubs stay inside the iframe.

### Search + JSONPath subset (tree mode)

The search input accepts three kinds of query:

1. **Words** (default) — every whitespace-separated word must appear in the node's haystack (key + raw value + resolved nickname / ODF pretty name). Enter = next hit; Shift+Enter = prev. The walk is chunked; jumping expands collapsed ancestors.
2. **Regex** — one JavaScript pattern with the `i` flag (e.g. `gun|spire`). The Words / Regex pills replace Ctrl+Enter. Invalid patterns show `bad regex` and do not run.
3. **JSONPath** (when the query starts with `$`) — tree-only; evaluated against the current tier's root. Explicit subset, **not** a full JSONPath spec:

```
path     = '$' segment*
segment  = ('.' name)
         | ('[' integer ']')
         | ('[' '*' ']')            # wildcard — all array elements / object keys
         | ('[?(' predicate ')]')   # filter expression
name     = identifier (letters / digits / _ / $)
predicate = @[.name | [n]]* OP (number | 'str' | "str" | true | false | null)
          | @[.name | [n]]*          # truthy test
OP       = == | != | < | <= | > | >=
```

Examples:

- `$.leaderboard[*].name` — every name in the leaderboard
- `$.leaderboard[?(@.kills >= 5)].name` — names of all players with 5+ kills
- `$.leaderboard[?(@.faction == 1)].personal.pvp_dealt` — PvP damage dealt by Team 1 players
- `$.eventStream[?(@.eventType == 'damageDealt')]` — every damageDealt event (decoded tier)

Unsupported on purpose: deep descent (`..`), unions (`[1,2,3]`), slices (`[1:3]`), function calls, multi-predicate boolean ops (`&&`/`||`). Syntax errors surface as "bad path" in the result count; the input is tinted danger-red.

### Reconciliation view (`view=reconcile`)

Checks that dashboard totals match a recount of the raw events. A red row is a real disagreement. The page shows a one-line result (`N checks, M mismatches`). Adding a new mapping is an explicit plan update, not a freeform feature.

Initial set:

| Field | Rule |
|---|---|
| `match.snipe_count` | `count(unitSniped)` |
| `leaderboard[i].personal.dealt` | `Σ damageDealt.amount where shooter == s64 ∧ team > 0 ∧ amount > 0` |
| `leaderboard[i].personal.received` | `Σ damageReceived.amount where victim == s64 ∧ team > 0 ∧ amount > 0` |
| `leaderboard[i].personal.pvp_dealt` | `Σ damageDealt.amount where shooter == s64 ∧ team > 0 ∧ amount > 0 ∧ paired dr.victim > 0` |
| Raw destructions | `count(unitDestroyed where killer == s64)` — informational, not pass/fail |
| Counted kills | `pvp_kills + pve_kills` vs `unitDestroyed` excluding self-kills and pilot victims (`user_m` in the victim ODF). Powerup and deployable exclusions need the ODF category DB, which this page does not load — remaining gap vs dashboard kills. |

Delta tolerance: `±0.1` for float fields (rounding slop per `.cursor/rules/data-schema.mdc`), exact match required for integer fields. Rows that exceed the tolerance are highlighted in danger-red and the Δ column carries the offset — those represent either pipeline bugs or a schema change that the reconcile rules haven't caught up to yet.

Both `shooter`/`victim` and `team` checks follow the Edition 2023 implicit-presence semantics (unset scalars are absent after `toObject(defaults: false)`), so `(team > 0)` correctly rejects unset / zero team, matching the pipeline's `skip_shooter = (dd.team == 0 or dd.amount == 0.0)` logic.

### Proto schema tooltips

Field name tooltips in the Decoded tier come from `data/proto-docs.json`, generated by `scripts/extract_proto_docs.py` (invoked as part of `scripts/process_stats.py`). The extractor parses inline `//` comments from `scripts/statsgate.proto` into a flat `{ "MessageName.fieldName": "comment" }` dict keyed in camelCase (matching protobufjs's `toObject` output). Message-level comments (e.g. the `// Either shooter or victim must be a player...` block above `message BulletHit`) render in the events-table type-tag tooltip.

Documented fields render with a dotted underline in the Decoded tree (`.vt-raw-tree-key--has-doc`). Lookup is scoped to the Decoded tier only — the Processed tier uses snake_case field names that don't come from the proto.

Field-to-type resolution is a static map in `js/raw-browser.js` (`PROTO_TYPE_MAP`) because protobufjs's runtime reflection would need significantly more plumbing to navigate nested oneofs correctly. If a new message is added to the schema, the map needs an entry — this is listed as a step in `.cursor/rules/schema-migration.mdc`.

### Replay deep link

`?t=<tick>` is a raw tick. `renderReplayTab()` divides it by `match.tick_rate` and appends `&t=<sec>` to the iframe URL (`_map-analysis/render/replay.html`). When `?t=` is present and `?tab=` is not, the dashboard opens the Replay tab so the seek is visible. The value is cleared after that first boot.

---

## 12. ODF Browser (`odf/index.html`)

The ODF Browser is the project's fourth standalone page, sibling to `index.html` / `docs/index.html` / `raw/index.html`. It's a read-only reference for browsing the BZ2 Object Definition File (ODF) database — the same `data/odf.min.json` the dashboard uses for weapon-name resolution, but presented as an interactive browser rather than a lookup table.

### Files

| File | Purpose |
|---|---|
| `odf/index.html` | Page shell. Sidebar + content layout, topnav (shared cluster + Shortcuts in the Settings gear, plus the page-local ODF count, VSR switch and Compare button), shortcuts modal. All asset paths use `../`. |
| `js/odf-compare.js` | `window.VTOdfCompare`: the compare model (row alignment, value normalization, views, pins, presets, `?compare=` resolution). No DOM, so the gate `_investigation/check_odf_compare.mjs` loads it in Node. Loaded before `odf-browser.js`. |
| `js/odf-browser.js` | Single-class `ODFBrowser` implementation. Reads `../data/odf.min.json` (and, after first paint, `../data/odf-guide.json` for property docs), plays `../data/audio/<file>.wav`. The compare tray, builder modal and compare view live in its Compare section. Exposes `window.browser` for inline `onclick` handlers. |
| `css/odf-browser.css` | Structural rules (height calc, scrollbars, list-item interaction states) + `.vt-odf-*` scoped classes (popover, alert variants, sub-header surface, etc.) + the 3-up CSS column layout for property cards + the `.vt-odf-cmp-*` compare matrix, phone reflow and builder modal. |
| `data/audio/*.wav` | 633 vendored audio clips referenced by `.wav`-valued ODF properties. Loaded on demand at runtime; nothing else in the project consumes them. |

### Features

- **Sidebar**: Bootstrap `nav-underline` category tabs (Vehicle / Weapon / Pilot / Building / Ordnance / Powerup) + fuzzy search input + scrollable `list-group` of named/unnamed ODFs (named items first, alpha-sorted by display name). The search row is the field plus borderless Clear (`bi-x-lg`) and Reset (`bi-arrow-counterclockwise`) icons.
- **Detail view**: 3-column layout of one card per ODF class block, distributed via CSS `column-count: 3` (drops to 2 below 1200px, 1 below 768px). Per-property fuzzy filter inside the detail view header. Group tabs (`All` + per-class) appear when an ODF declares multiple class blocks.
- **Compare mode**: up to 5 ODFs of one top-level category in a spec-sheet matrix. See [Compare](#compare) below.
- **Property docs**: after first paint the page fetches `data/odf-guide.json` once and indexes its `property` entries by name. Every documented property name (single view and compare) gets a small book link to `guide/?section=<id>#<anchor>` whose tooltip is the guide's description plus its documented default. When a name appears in several guide sections, the section whose id starts with the lowercased class name wins.
- **Audio preview**: any property whose value ends in `.wav` gets an inline play button that streams from `../data/audio/`. Only one audio plays at a time; "Sound file not found" surfaces inline via `.vt-odf-text-danger`.
- **Popovers**: `subAttackClass` and `engageRange` (Vehicle category) carry context-aware popovers. All popovers initialised with `customClass: 'vt-odf-popover'` so they render against project theme tokens (mirrors the `js/raw-browser.js` line 2756 pattern).
- **Right-click context menu**: any ODF list item right-click opens a custom menu with "Open in new tab".
- **Keyboard shortcuts**: `↑/↓` cycle ODF list, `←/→` cycle category tabs, `Ctrl+K` focus search, `Esc` clear search, `Esc Esc` (within 750ms) reset view, `C` open the compare builder. While comparing, the arrows are left to the page (they used to cycle the hidden list and silently leave the compare) and `Esc` clears the compare filter. An open modal owns the keyboard. Shortcuts modal accessible from the Settings gear.
- **Phones and tablets**: below `lg` the list is capped at 45vh and picking an item scrolls the detail into view.

### Compare

Up to 5 ODFs of the same top-level category (`VTOdfCompare.MAX_UNITS`), properties as rows and units as columns.

- **Entry points.** The topnav **Compare** button (page-local, like the VSR switch; icon-only below 576px and from 768px to 1199px; count badge) and `C` open the builder. The detail header's **Add to compare** toggle and the list's right-click **Add to compare** add the current ODF to that category's tray. **vs parent** in the detail header appears when `inheritanceChain[0]` is an ODF of the same category (588 of 701 VSR ODFs) and opens `?compare=<child>,<parent>&missing=1` (Diff with Include missing), which shows the values the child changed plus the properties it added or dropped (`ivtank_vsr` vs `ivtank`: 5 + 18 rows). All of these are hidden in `?embed=1`.
- **Tray.** `sessionStorage` `vt.odf.compare.v1` = `{ <Category>: { units: [stem], pins: [key] } }`. The badge and builder follow the category being viewed; switching category in the builder switches trays, so nothing is discarded. An empty tray is seeded with the ODF on screen. While comparing, the active category's tray is the compare.
- **Builder modal** (`#odfCompareModal`, built once; fullscreen below `md`). Step 1 Units: category pills with tray counts, five slots, a search ranked exact > prefix > substring > fuzzy with VSR-playset units first, a mirrored VSR switch, a checkbox list with lazy model thumbnails (`data/models/thumbnails/<geometry stem>.png`, category icon when missing). Step 2 Properties: category presets, the pinned list in table order (up / down / remove), and a searchable checklist grouped like the table (component groups load their rows when opened). **Compare** needs at least 2 units.
- **Row alignment** (`buildModel`). Rows are keyed by section + property, case-insensitively, because the engine reads ODF keys without case and the database mixes `renderName` / `rendername`. A unit with both casings in one section keeps the second as its own row, so neither value is hidden. Then, within one section prefix, rows for the same property merge when both sections are engine classes (leaf ends in `Class`) and no unit has a value in both. So hover, tracked and walker `velocForward` share a row, while a morph tank's `MorphTankClass.maxHealth` (its deployed value, beside `GameObjectClass.maxHealth`) stays separate. Component sections (`Render`, `Light`, `Smoke`, ...) are arbitrary labels and never merge. A merged row lives in its largest section's group, and the group header names the other sections.
- **Views.** `Pinned` (flat, pin order, section caption on each row), `Diff` (the default), `All` (every row). Diff keeps a row when any two units that set the property hold different values, and shows the whole row. Units are compared to each other, not to the baseline, so re-basing never changes which rows show. The **Include missing** switch (Diff only, off by default, on for vs parent) also counts a unit that lacks the property as a difference. It stays off because class-specific sections make it nearly All on cross-archetype sets. Diff / Diff with missing / All: five mixed VSR ships 121 / 232 / 235, four Xares + Tank 64 / 111 / 163, five weapons 201 / 1035 / 1035. Common and the Hide identical switch were removed; Diff replaces both. Values compare unquoted, trimmed and case-folded, with numbers compared as numbers, so `5.0f` equals `5`. Groups whose leaf does not end in `Class` start collapsed (unless every group is one) and render their rows only when expanded. The filter matches property, section and value text.
- **Pins.** A pin is a qualified key (`GameObjectClass.scrapCost`), a bare name (`velocForward`, every row with that property) or either with `*` that never crosses a dot (`Ordnance.OrdnanceClass.damageValue(*)`). Presets store their own patterns, which keeps links short and survives unit swaps. Unpinning a row covered by a bare or wildcard pin replaces it with the rows it still covers. Pins live in the URL and the tray.
- **Cells.** A left accent marks rows that differ (`--kb-warning`) or are missing on some units (`--kb-text-info`). Column 1 is the baseline: the first stem of `?compare=`, which is the builder's pick order, the child for **vs parent**, or whichever unit the column menu's **Set as baseline** / **Move left** / **Move right** put there. In a row that differs, each other numeric cell shows its difference from the baseline under the value (`+15 (+37.5%)`), and any cell holding the baseline's value (number or text) is dimmed. The delta is green (`--kb-success`) when the change is better and red (`--kb-danger`) when worse, for the properties in `VTOdfCompare.POLARITY` where the direction is clear: higher is better for health, ammo, movement speeds and turn rates, scan and weapon range, shot speed, salvo count and damage; lower is better for cost and build time, power cost, shot / salvo / lock delays, ammo cost, shot variance and the three signatures. Everything else (e.g. `setAltitude`, `scrapValue`, `mass`, `lifeSpan`, `engageRange`) stays neutral grey. The sign stays, the tooltip names the direction ("lower is better"), and a visually hidden "better" / "worse" word carries it for screen readers. Nothing is dimmed and no delta shows on identical rows, rows the baseline lacks, or non-numeric values (no delta). There are no magnitude bars: drawn from zero they made 3800 and 3600 look the same, and many ODF values have no meaningful zero. The cell tooltip names the true section on merged rows. `formatValue()` renders the cells, so ODF links, audio play buttons and the Vehicle popovers work in place.
- **Toolbar.** Units, Properties, Copy link, **Copy as text** (the visible rows as an aligned table in a code block, for Discord; differing numbers carry their baseline delta, e.g. `3500 (-300)`), Exit (back to the first unit).
- **Layout.** The sidebar is hidden while comparing. The table scrolls inside its own box with a sticky header row and a sticky property column. The property column is a plain length (11.5rem, 14rem from 1200px), because table layout treats a `clamp()` that mixes `%` and `rem` as `auto`. Below 576px the same table reflows into stacked property cards, each value labelled by its unit (`td::before { content: attr(data-unit) }`), with the unit cards in a horizontal strip.
- **Gate.** `node _investigation/check_odf_compare.mjs` (gitignored) loads `js/odf-compare.js` against `data/odf.min.json` and checks the alignment cases above, normalization, pin and URL round-trips (`lightColor.r`, `Explosion.Explosion.*`, `can Snipe`), wildcard unpinning, that every preset pattern matches a VSR ODF of its category, and the 5-unit and same-category rules.

### URL routing

| Param | Effect |
|---|---|
| `?odf=<name>` | Deep-link a single ODF by basename (no `.odf` extension). Case-insensitive. |
| `?compare=<a>,<b>[,...]` | Deep-link a comparison of up to 5 ODFs. The first known stem's category wins; unknown stems, other categories and anything past 5 are dropped with an inline "Left out" notice, and the URL is rewritten to what was kept. Fewer than 2 usable stems shows the danger alert. Old 2-way links keep working. |
| `&props=<pin>,<pin>` | Pinned properties for the compare (qualified key, bare name or `*` pattern; see Pins). |
| `&view=pinned\|diff\|all` | Compare view. Omitted when it is the default: `pinned` when `props` is set, otherwise `diff`. Old `view=common` links open Diff. |
| `&missing=1` | Include missing: in Diff, a unit that lacks the property also counts as a difference. |
| `?vsr=1` | Turn on the VSR playset filter. Absent on a fresh visit; remembered in `localStorage` `vt.odf.vsrOnly` after the user toggles it. |

`window.history.pushState` updates the URL on every selection and whenever the compared unit set changes; pin, view, Include missing and VSR changes use `replaceState`. Commas and parentheses stay readable in the query. The `popstate` handler re-renders through `handleODFState` / `openCompareFromUrl` with a restoring flag that turns pushes into replaces, so Back / Forward no longer wipe the forward history. Bad ODF names render the themed `.vt-odf-alert-danger` "Could not find ODF" inline alert.

### VSR playset

The **VSR only** switch, to the right of the nav ODF count, filters the sidebar to ODFs a VSR match can build or mount. The set is computed once in the browser from `data/odf.min.json`: walk `ibrecy_vsr`, `ebrecym_vsr`, and `fbrecy_vsr` through factory, constructor, and armory `buildItem`s, `upgradeName`, hardpoint `weaponName`s, powerup `weaponName`, weapon `altName` / `ordName`, the composition refs in `scripts/odf/build_odf_db.py` (`xpl*`, `explosionName`, `payloadName`, `objectClass`, and the rest of that table), and `lightName`s. A value is followed only when that stem exists in the database. `inheritanceChain` is not followed, so a VSR child does not pull its stock parent back in (`gchainvsr_a` does not keep `gchain_a`). `weaponConfig` and the Explosion `class*` fanout are skipped for the same reason. The six on-foot pilots (`isuser`, `isuser_m`, `esuser`, `esuser_m`, `fsuser`, `fsuser_m`) are extra seeds because the recycler graph never names them and VSR still uses the stock files. Stock Minigun stays because `ivtank_vsr` mounts `gminigun_c` and the armory builds `apmini`. A category with nothing in the set hides its tab. `?odf=` and `?compare=` still open a file outside the set; the detail header says so. Reset clears the search and keeps the toggle (and `embed`). Default off. While the switch is on, the nav count is the playset size; while it is off, it is the full database.

### Performance

`odfIndex` Map (`Map<filename, { category, data }>`) is built once after `loadData()` resolves. `findODFCategory()`, `selectODFByName()` and the compare URL resolver all consult this index for O(1) lookups instead of iterating the per-category dicts.

### Theming

Every Bootstrap utility class and inline-style color the seed used has been swapped for a scoped `.vt-odf-*` class so the page picks up the project's `--kb-*` palette instead of stock Bootstrap colors. The full mapping is documented in the port plan (`port_odf_browser_5997731a.plan.md` section 3e). The `nav-underline` sidebar gets a scoped `--bs-nav-underline-*` override since `.nav-underline` isn't themed project-wide. Info-colored text (ODF cross-links, the inheritance line, the compare's partial-row accent) uses `--kb-text-info`, not `--kb-info`: some themes (Clean Slate) make `--kb-info` a dark fill that disappears as text on a dark card.

The ODF page puts four page-local controls in the canonical bar (ODF count, VSR switch, Compare, View ODF Guide). From 768px to 1199px the count pill hides and the Guide and Compare buttons go icon-only so the bar fits; below about 880px the canonical destination links alone still overflow the expanded bar, a pre-existing limit of the shared nav.

### Cross-page integration

- **Topnav**: every production shell carries an ODF dropdown (ODF Browser, Build Trees, ODF Guide, Weapons) in the shared topnav cluster.
- **Kill-feed cross-link**: each killer/victim ODF chip in `js/app.js` `renderKillFeed()` is wrapped in `<a class="vt-odf-link" href="odf/index.html?odf=<basename>" target="_blank" rel="noopener">`. Basename is the raw `entry.killer_odf` lowercased + `.odf`-stripped + `encodeURIComponent`'d. The `.vt-odf-link` / `.vt-odf-link-fallback` styles live in `css/vtstats-theme.css` (NOT `css/odf-browser.css`) so they apply on the dashboard, which doesn't load the ODF browser stylesheet.

### VSR Faction Build Trees

The seed's fullscreen build-tree modal lives as its own page at `build/index.html` (`js/build-tree.js`, `css/build-tree.css`). It walks `data/odf.min.json` from the three VSR recyclers. Faction roots are the `FACTION_ROOTS` constant in `js/build-tree.js` (the Bug 7 fix — retarget that table for a non-VSR mod). Column headers use `--kb-faction-*` instead of the seed's logo PNGs. **View full data** iframes `odf/?odf=<stem>&embed=1` (navbar hidden) and links out to the full ODF page. Corpus-wide, picker-unaware, not in the pipeline cache key.

### ODF Guide + the Console commands group

`odf/guide/index.html` (`js/odf-guide.js`, `css/odf-guide.css`) is a searchable reader over `data/odf-guide.json`, the vendored GenBlackDragon / Ultraken Steam guide parsed only by `scripts/build_odf_guide.py` from `docs/reference/odf-properties-guide.md`; its sections stay the Steam guide's own chapters. The reader is a generic engine (`{groups[], sections[{id, title, group, blocks[]}], entries[]}`, block kinds `para` / `subhead` / `list` / `code` / `note` / `property`, `?section=` routing, TOC filter, search index), and the boot `Promise.all` loads a second, 404-safe book, **`data/console-reference.json`**, whose groups / sections / entries are appended to the guide (`mergeConsoleBook`) as one more top-level TOC group, **Console commands**: `Using the console` first, then the 36 `ls` namespaces ordered by theme (`sky sun fog dome stars sprites rain splat chrome terrain bump mesh vid` · `game mission script ai scrap control status view editor` · `network` · `options gameprefs keybind inputbind audio iface shell` · `console sys debug profile multilanguage iam`). Console section ids are prefixed `console-` so they cannot collide with guide ids; the sections carry `linkify: false` (honoured by `inline()`, so `ivar1`-style names never become ODF-browser links); every command / variable is a `property` block whose explanation carries an **evidence tier** badge (`verified` in-game · `engine` text from the exe / `GamePrefs.ini` / editor panel labels · `usage` from the shell `.cfg` `Cmd(...)` calls · `inferred` from the name — nothing reads as a fact without one); the credit aside swaps to the console provenance when a console section is active.

The book is built by the **standalone** `scripts/extract_console_reference.py` (read-only over the install; not pipeline-invoked; corpus-wide, not in the pipeline cache key). It parses the in-game `console.log` session dumps into the committed, **scrubbed** `data/reference/console-dump.json` (names and value types only for `options`, `gameprefs`, `inputbind`, `network`, `keybind`, `audio`; the raw logs carry the Steam UID and machine details and are never committed), merges `battlezone2.exe` dotted identifiers and output format strings, the `.cfg` `Cmd` / `UseVar` usage, the `GamePrefs.ini` comment blocks, the editor panel labels and the curated `scripts/console_reference_notes.json`, and writes `data/console-reference.json` plus the markdown twin `docs/reference/bzcc-console-reference.md` (with the exe version, SHA-256 and harvest dates in its provenance header). Gate: `_investigation/check_console_reference.py` (every dump name in exactly one property, every property tiered, block kinds limited to the six the reader renders, `console-` ids, single group, `linkify: false`, the privacy allowlist / denylist, markdown parity), registered in `_investigation/run_all_gates.py`. The `sky.*` / `sun.*` / `terrain.*` entries are the ones the 3D atmosphere work depends on (§12.2).

### Known limitations carried over from the seed

- `initializeCompareModal` rebinds event listeners on every `displayODFData()` call. Each rebind is benign because the underlying DOM elements are also recreated, but it would be cleaner to attach listeners once. Out of scope for v1.
- Inline `onclick` handlers on dynamically-emitted HTML depend on the global `window.browser` reference. Refactoring these to `addEventListener` was deemed out of scope; the global is preserved.
- No ARIA focus traps in the compare modal, no `aria-live` on search results, no explicit `role` on ODF list buttons beyond Bootstrap's defaults. Out of scope for v1.

### Weapons Lab (`weapons/index.html`)

A standalone, purely client-side calculator, 4th item of the ODF dropdown. It answers "what does weapon X do to target Y when fired from ship Z": per hit, burst DPS, time to kill, ammo per second, damage per ammo, regen-limited DPS, shots per tank, range, splash, pulses and charge levels, each labelled with how directly it follows from the ODF. Corpus-wide, picker-unaware, NOT in the pipeline cache key.

| File | Role |
|---|---|
| `weapons/index.html` | Shell (canonical topnav, ODF dropdown active), Scenario / Damage matrix / Shooting range pills |
| `js/weapons-calc.js` | Engine, `window.VTWeaponsCalc`. Pure, no DOM at load |
| `js/weapons.js` | Page controller: pickers, result card, matrix, URL state. Lazy-imports the range |
| `js/weapons-range.js` | Shooting range scene: ObjectViewer shooter, target prop, cameras, HUD, volume, reset |
| `js/fx/weapon-profile.js` | Archetype classification. No DOM, no three.js |
| `js/fx/weapon-sim.js` | Per-archetype state machine; weapon-switch mode shares the trigger between groups |
| `js/weapons-weave.js` | Weapon-switch schedule (`decide()`) and planned kill time (`planWeave()`). Pure, no DOM, no three.js |
| `js/fx/odf-fx.js` | `renderBase` / `simulateBase` interpreter |
| `js/fx/odf-audio.js` | Fire, lock, flight and impact sounds |
| `css/weapons.css` | `.vt-wpn-*` styles (`--kb-*` / `--vt-*` only) |
| `scripts/build_hud_assets.py` | Standalone. Lifts reticles + hardpoint icons from the local BZ2R install |
| `data/ui/reticles/*.png` + `index.json` | 100 reticle frames (65 stems), committed |
| `data/ui/hud/hp_<cat>.png` | The 8 in-game weapon-slot icons, committed |
| `_investigation/check_weapons_calc.mjs` | Gitignored gate: `node _investigation/check_weapons_calc.mjs` |
| `_investigation/check_weapon_weave.mjs` | Gitignored gate for the weapon switch: `node _investigation/check_weapon_weave.mjs` |

#### Data joins

All values in `data/odf.min.json` are strings with inheritance pre-merged; the engine reads every section and key case-insensitively (`addhealth` / `addHealth`) and parses with `parseFloat` (`"170e-3"`, `"1e30"`, `"250.0f"`).

| Need | Source |
|---|---|
| Weapon identity | `WeaponClass.wpnName`, `wpnCategory` (4-char prefix, so the legacy `CANNON` reads as `CANN`), `isAssault`, `altName` |
| Fire rate | First present of `CannonClass` / `LauncherClass` / `TargetingGunClass` / `DispenserClass` / ... (`shotDelay`, `salvoCount`, `salvoDelay`, `shotAlternate`, `firstDelay`, `lockDelay`); `InitialShotDelay` from `CannonClass` or `DispenserClass` only, the two loaders whose fire gate was read |
| Damage | The `Ordnance` bucket entry named by `WeaponClass.ordName` (identical to the inlined `Ordnance.*` sections, and it carries the ordnance `inheritanceChain`): `OrdnanceClass.damageValue(N..A)`, `ammoCost`, `shotSpeed`, `lifeSpan`; splash `ExplVehicle` / `ExplBuilding` `ExplosionClass`; pulses `PulseShellClass` + `ExplPulse.ExplosionClass`; stuck damage `LeaderRoundClass` |
| Charge guns | `ChargeGunClass.ordNameN`, `salvoCountN`, `salvoDelayN`, `shotDelayN` (the hold time), `holdRate` |
| Droppers | `DispenserClass` / `SatchelPackClass.objectClass` → the object in any bucket (the Wasp missile lives in `Misc`) → `MineClass.xplBlast` or `TorpedoClass.xplBlast` → `Explosion` bucket |
| Shooter | `GameObjectClass.weaponHardN` (category = the node name after an optional `HP_`), `weaponNameN`, `weaponAssaultN`, `maxAmmo`, `addAmmo`. Deployed: `MorphTankClass.maxAmmo` / `addAmmo`, and `switchMask` (right to left, hardpoint 1 = last char, default `11111`) flips a hardpoint to assault and mounts its stock weapon's `altName` |
| Target | `armorClass` (default `N`), `maxHealth`, `addhealth`; deployed: `MorphTankClass.maxHealth` / `addhealth`. Shield = the `ShieldUpgradeClass.shieldClass` of a mounted `shieldup` weapon (the Dread carries Stasis), or the user's pick for an empty shield slot |

**Damage column:** `S` / `D` / `A` when the target's shield is up, otherwise its `armorClass` `N` / `L` / `H`. Match telemetry agrees: a Snare Trap hit on an armor-N ship with an Absorption shield records `damageValue(A)`.

#### Scope and families

- **Ships and targets**: every Vehicle, Building, and Pilot entry except stems starting with `virtual_class` (placeholders, including silo classes also named Extractor). Constructors build the Power plant, not an extractor, so the extractor ODFs (`ibscav`, `ibscav_vsr`, `fbscav`, `fbscav_vsr`, `extract`) are found by search. There is no Hadean unit named Extractor. **VSR only** drops those extractors from the Scenario target list, because the recycler walk never names a deployed extractor.
- **Weapons**: the armory powerups of the ship's faction (`ibarmo_vsr` / `ebarmo_vsr` / `fbstro_vsr`), `weaponName` plus the `altName` twin, plus any weapon that ship mounts. No ship, or a ship with no faction armory, uses the union of the three. An undeployed Warrior's cannons are the stronghold's four: Plasma Cannon, Sonic Blast, Quill, Arc Cannon. Every other named weapon in the Weapon bucket (a real `wpnName`; the unnamed class shells stay out) is a separate **Not in the VSR armory** group: not fit-filtered, narrowed by the same search and category chips, and omitted when the current ship already mounts it. Community packs stay in their own group. The Damage matrix includes that group (Source `Not in armory`) and a chip filters to it.
- **Build-tree walk**: from `ibrecy_vsr` / `ebrecym_vsr` / `fbrecy_vsr` using `js/build-tree.js`'s `numbered()` / `armoryItems()` verbatim (case-sensitive `buildItemN`; `ebfact2_vsr` carries both `buildItem7` and `builditem7`), but following `upgradeName` on every entry. Ships = Vehicle entries reached, buildings = Building entries reached, weapons = armory powerups + reached ships' `weaponNameN` + their `altName` links, pilots = Vehicle entries with chain terminal `person` and `vsr` in the stem. Result: 56 ships, 28 buildings, 3 pilots, 123 weapons, 85 families. The navbar **VSR only** switch limits the Scenario ship, target, and weapon lists to this set. Default off. `?vsr=1` turns it on, and a visit with no `vsr` param uses `localStorage` `vt.wpn.vsrOnly`. A selection already in the URL stays and still computes; its summary says "Not in the VSR build tree." when that pick is outside the set. Community packs and library weapons outside the set leave the Scenario list, including under Show all weapons. A weapon a reached ship mounts but an armory does not sell stays under **Not in the VSR armory**. The Damage matrix and the shooting-range loadout stay on the full catalog. The page catalog does not add the ships' `weaponNameN` unless that weapon is mounted on the ship being viewed.
- **Families** are altName pairs whose `isAssault` flags differ, with mutual links paired first (`gminidm` points one-way at `gminigun_a`, which pairs with `gminigun_c`). Twins with different names form one family (Quill / Fang). The key is the combat stem minus `_c` unless that collides with another weapon stem (`giongn_c`, `gslicer_c`, `gtagcr_c`, `gshellgun_c`, `gguardgn_c` keep the suffix); assault-only families use the assault stem. `family()` also accepts any variant stem.

#### Variant and firing group

With a shooter, the matching hardpoints are those of the family's category. The assault twin is used when a matching hardpoint is assault and the twin exists, otherwise the combat one. The Combat / Assault buttons stay enabled only for a twin that has a matching hardpoint, so an override can never produce an impossible mount. With no matching hardpoint the page warns and computes as a hypothetical single mount. The firing group `g` is every matching hardpoint with the chosen variant's flag (same-type hardpoints fire together; `shotAlternate` splits the same total), forced to 1 for pilots and HAND / PACK, and always that full count. Picking a ship selects `defaultWeapon()`: the first hardpoint that has a stock weapon and is not a shield, pinning `v` to that exact stem when the family would otherwise resolve to the other twin. The Tank opens on AT-Stab; the Scout opens on Minigun, both guns; the Archer opens on the Howitzer. An empty URL runs the same selection, so the landing weapon is the ship's, never a constant; a URL that names a weapon keeps it. Deploy keeps the current family when a twin still fits that loadout, and otherwise selects the deployed loadout's first weapon. `weaponMask` is the AI preference and is not used.

**Weapon list filter.** The armory block is the faction armory above. With a ship picked, the Weapon picker lists that faction first, then a muted `Other factions` divider and the other two armories' weapons that fit the same hardpoints (a crate steal or a snipe). `Show all weapons` stays on the home armory and does not add those. A family in that block is listed only when at least one variant fits a hardpoint: `fittingVariants(family, shooter)` applies the same category and combat / assault match as `resolveVariant`, so a listed armory weapon never lands on the hypothetical-mount warning. When only one twin fits, the row becomes that variant (its name, stem, reticle and a single C / A tag; the Assault Tank sees `Fang` / `gquill_a`); when both fit (pilot hand slots), the row stays the family. Morph tanks follow the Deployed toggle. Category chips for slot types the ship lacks are disabled, except an active one so it can be cleared. A sticky note at the top of the list counts the home-armory fits and carries the `Show all weapons` opt-out, which resets on every new ship. Below that, **Not in the VSR armory** lists `weaponStemsFor().library` (Flash Beam is the worked example: `gflash_c` / `gflash_a` require the Armory but neither `ibarmo` nor `ibarmo_vsr` sells them). That group ignores the fit filter, so a search finds it on any ship, and the same category chips narrow it. The ship card groups hardpoints the way the range loadout does (same category and combat/assault flag; the Tank's two guns are one `HP_GUN ×2` chip). Exactly one group is selected whenever a ship is shown; clicking it again does not clear it, and the highlight is only that group. The selection filters every weapon section to that mount, a picked weapon is written onto that slot's loadout, and **the Weapon summary and the result card follow it** (`shownScenario()`). The group holding `w` is the Scenario weapon itself, so the variant pills and charge stages keep working there. Any other group shows what it carries, fired by every hardpoint in the group: `computeGroup(stem, shooter, target, key)` in the engine, the same `g` rule the weapon switch's `weaveInput()` uses, so the Tank's `HP_GUN ×2` reads Minigun ×2 at 100 DPS against the Scavenger. Its variant pills are read-only (`Set by HP_GUN ×2`), and an empty group says so. The card header carries the group as a tag with its HUD icon. A missing or stale selection falls back to the group of the same category (a deploy that flips the flag), then the group holding `w`, then the first group; a weapon-list pick with no group selected, a Damage matrix row and the variant pills move the selection onto `w` (`focusScenarioSlot()`). When `w` has no group on the ship (a matrix row, a link), a dashed **Not mounted** chip appears, selected, and the card shows the hypothetical single mount with its warning, the way the range fires it from a virtual slot; pressing a real chip leaves it. The shooting range still fires `w`, so inspecting or editing another slot does not take over the active gun. View state only (not in the URL): a reload opens on the group holding `w`. The Damage matrix category chips still apply; its pack-style **Not in armory** chip filters to the library.

#### Formulas

| Quantity | Formula |
|---|---|
| Per hit | `damageValue(<column>)` of the resolved ordnance (charge guns: the selected Scenario stage, or the last stage with an ordnance when none is selected and on the Damage matrix; droppers: the detonation; splash-only ordnance: the splash value) |
| Cycle | `max(shotDelay, salvoCount × salvoDelay + firstDelay)`; `firstDelay` applies to TAG-style targeting guns only |
| Hits per second | `salvoCount / cycle`, per hardpoint. DPS uses this rate. A weapon with no per-hit number that still spends ammo per round or per drop (mines, flares, poppers, EMP rounds) keeps the same cycle, so its ammo per second and time to empty fill in; per-use items (blink, jetpacks) and shields stay unrated |
| Shots per second | `1 / interval`. `interval` is the cycle, except when `shotAlternate` is set and `g > 1`: `max(shotDelay / g, salvoCount × salvoDelay + firstDelay)` |
| DPS | `per hit × (salvoCount / cycle) × g` |
| Ammo per second | `ammoCost × (salvoCount / cycle) × g`; continuous weapons `ammoCost × g` |
| Rounds / shots per tank | `floor(maxAmmo / ammoCost)` / `floor(maxAmmo / (ammoCost × roundsPerShot))`. The second row is shown only when it differs |
| Time to empty | `maxAmmo / (ammo per second − addAmmo)`; never when regen covers the drain. A `SpecialItemClass` toggle (Phantom VIR, RED Field, SITE Camera, force field; `variant.toggle`) drains `ammoCost` every second it is on |
| Burst DPS | Full fire-rate DPS while the ammo tank lasts (Damage panel). Renamed from plain "DPS" when a ship is selected so it is not confused with the regen ceiling |
| Damage per ammo | `per hit / ammoCost` (Ammo panel). How much punch each ammo point buys |
| Regen-limited DPS | Ammo panel (not Damage). `min(burst DPS, addAmmo / ammoCost × per hit)`; continuous `burst DPS × min(1, addAmmo / ammo per second)`. Long-run rate if you only fire as fast as regen allows — high ammoCost weapons fall well below burst |
| Hits / shots to kill | `ceil(maxHealth / per hit)` / `ceil(hits / roundsPerShot)`. `roundsPerShot = salvoCount × barrels`, and `barrels` is 1 when `shotAlternate` and `g > 1`, otherwise `g`. Hits stay the technical damage-event count; a shot is one trigger pull. The hits row is hidden when the two counts match |
| Time to kill | `InitialShotDelay + (shots − 1) × interval` (`InitialShotDelay` defaults to 0); continuous weapons `maxHealth / DPS` |
| Ammo used | `hits × ammoCost / maxAmmo` (a warning above 100%) |
| Net of target repair | `DPS − addhealth` (a warning when the target out-repairs the DPS) |
| Range | `shotSpeed × lifeSpan`; above 2000 m it reads as lobbed and `aiRange` is shown |
| Pulses | `floor((lifeSpan − pulseDelay) / pulsePeriod)` × the `ExplPulse` value within its radius, labelled as potential |
| Arc Cannon | per hit `damageValue × salvoDelay`; DPS `per hit × ARC_HITS_PER_SEC (30) × g` |
| Damage field | `damageValue` as damage per second within `damageRadius` |

Worked examples the gate pins: Blast (`gblast_c`, Tank) vs Scavenger = 300 per hit, 150 DPS, 10 shots (one round each), 18 s, 11 rounds per 2200 tank; Chain Gun ×2 (`gchainvsr_c`, Tank) vs Warrior + Absorption = 16 per hit, 320 DPS, 50 ammo/s; Blast on the Assault Tank resolves `gblast_a` = 400 vs H, 10 rounds per 3000 tank; a deployed Warrior mounts `gsplasma_a` + `giongn_a` ×2 with `maxAmmo` 2200, `addAmmo` 0, `maxHealth` 3000, `addhealth` 12. Shot counts on a 3,000 HP heavy Scavenger: Arc Cannon on the Scout is 36 hits / 8 shots (a salvo of 5), on the Titan 36 / 4 (both cannons, 10 bolts); Salvo Rkt 34 / 4; TAG Cannon on the Missile Scout 56 / 5 (one pull is 6 missiles from each of 2 hardpoints, 12 hits); Gauss on the Warrior 86 / 86 in 21.25 s at 140 DPS (each barrel is its own shot; the old volley count was 43); Sprinkler Msl on the Zeus 75 / 19 (alternating, salvo of 4); Walker Laser 400 / 400. Combat MAG (`gmaggunvsr_c`) vs that Scavenger from one cannon: stage 1 (`charge_c`) is 5 per hit, 600 hits, 200 shots; stage 6 (`charge5_c`) is 450 per hit, 7 hits, 2 shots; stage 7 (`charge6_c`) is 125 per hit, 24 hits, 3 shots. Assault MAG stage 1 has no ordnance; stage 7 is 2250 vs H, 2 hits. Laser Stream stages 3 and 7 are both 250 vs H, salvo 2 then 6 (6 shots, then 2). Per hardpoint group on the Tank (`computeGroup`, agreeing with the Scenario weapon path on every armed VSR stock group): Minigun ×2 is 100 DPS, 300 shots, 25 ammo/s; Mortar ×1 is 150 DPS. Specials on the Tank's 2200 tank with 5/s regen: RED Field drains 14/s and runs 2200 / 9 = 244.4 s, Phantom VIR 16/s runs 200 s, SITE Camera (2/s) never empties; M-Curtain drops every 2.5 s for 18 ammo/s, 169.2 s, 48 drops per tank; Solar Flare 50 ammo/s, 48.9 s. Blink on the Warrior is 7 uses per tank and unrated.

#### Arc Cannon calibration

The guide describes `ArcCannonClass.salvoDelay` as the time between shots, which suggests DPS = `damageValue / salvoDelay` (Arc Stream vs H = 4,000). Match telemetry disagrees: every recorded Arc Stream `DamageDealt` against heavy armor is exactly 40 = `damageValue(H)` 400 × `salvoDelay` 0.1 (Snare Trap events are likewise 0.4 / 0.5 / 0.3), and bursts against a single mobile Scavenger arrive at about 30 events per second on 20 Hz recordings (alternating 1 and 2 per tick). The engine therefore uses per hit = `damageValue × salvoDelay` and `ARC_HITS_PER_SEC = 30`, giving Arc Stream vs H = 1,200 DPS. It stays in the `estimated` tier because the engine timing is not published.

#### Damage kinds and tiers

| Kind | When | Tier |
|---|---|---|
| `direct` | cannon / machinegun / mortar / detonator / launchers / targeting whose ordnance carries direct damage, splash or stuck damage | `exact` |
| `pulse` | ordnance with `PulseShellClass` | `exact` |
| `charge` | `chargegun`; every stage listed, including a stage with no ordnance. The Scenario headline follows the selected stage. The Damage matrix uses the last stage with an ordnance | `exact` |
| `blast` | dispenser or satchel pack whose object has a `MineClass` / `TorpedoClass` `xplBlast` | `exact` |
| `arc` / `field` | `arccannon` / `damagefield` | `estimated` |
| `components` | ordnance terminal `spraybomb` / `popper` / `radarpopper` / `laserpopper` / `seismic`; trip, flare and weapon mines; drones; torpedo launchers. Each part with damage is listed with its own values, including weapons fired by a dropped object | `components` |
| `utility` | shields, jetpacks, blink, phantom / RED field / SITE camera, magnet guns, force fields, day wreckers, zero-damage ordnance (EMP lockdown) | `none` |

The Scenario card for a `none`-tier weapon (any kind except a charge stage) drops the damage group and the target, and its ammo group leads with the number the item is read by: time to empty for a toggle or a held drain, drops / uses / rounds per tank for the rest, `Ammo · none` for a shield. Rate rows it has no number for are left out. Toggles add `Trigger · Toggle`, and per-use items with a `shotDelay` add a `Cooldown` row. A `components` weapon keeps its damage group, footnoted to the parts table.

Satchel packs resolve like dispensers (their object has a real detonation). Torpedo launchers stay `components` and list the launched object's detonation and blast.

#### Engine class defaults

Applied only when an ODF omits the key, from `docs/reference/odf-properties-guide.md`; every default used surfaces as an assumption.

| Class | Defaults |
|---|---|
| Weapons (all) | `salvoCount 1`, `salvoDelay 0`, `shotAlternate false` |
| `cannon` / `machinegun` / `salvo` | `shotDelay 0.2` (`machinegun` / `mortar` / `chargegun` / `detonator` inherit from `cannon`) |
| `mortar` | `shotDelay 1.0` |
| `targeting` | `shotDelay 1.0`, `salvoCount 10`, `salvoDelay 0.2`, `firstDelay 1.0` |
| Launchers | `shotDelay 0`, `lockDelay 5.0` |
| `dispenser` / `satchelpack` | `shotDelay 0` / `1.0` |
| `arccannon` | `salvoDelay 0.1`, `finishDist 100` |
| `jetpack` / special items / `magnetgun` / `blink` | `ammoCost 1` + `burnTime 10` / `ammoCost 100` per second / `ammoCost 10` per second / `ammoBase 100` + `ammoDist 10` |
| Ordnance base / `bullet` / `beam` / `grenade` | `ammoCost 0, lifeSpan 1e30, shotSpeed 0` / `1, 5, 200` / `1, 200e-6, 1e6` / `10, 1e30, 50` |
| Missiles / `pulse` / `magnetshell` / `leader` | `ammoCost 10` / `10, 1e30, 50` + `pulseDelay 1.0`, `pulsePeriod 0.5` / `10, 1e30, 50` / `stickTime 2.0` |
| Objects | `armorClass N`, `shieldClass N`, `addAmmo 0`, `addhealth 0`, `weaponAssaultN false`, `switchMask 11111` |

#### Reticles and HUD art

`python scripts/build_hud_assets.py [--bz2r PATH] [--force]` parses the HUD sprite table (`bz2r_res/interface/sprite.txt`: `"name" sheet U V W H TW TH 0xFLAGS`), keeps the rows on the ten reticle sheets (`ir_cann ir_mag ir_mort ir_horn ir_shad ir_com ir_rckt sr_cann sr_mort sr_rcksp`), decodes each 512×512 DX10 BC3 sheet with `scripts/object-render/dds_decode.py`, and crops each rect scaled by `sheet width / TW` (×4) into `data/ui/reticles/<name>.png` (lowercased, dot kept: `gmaggun.0.png`). It also converts `baked/HUD/hp/hp_<cat>.dds` into `data/ui/hud/`. Existing PNGs are skipped unless `--force`; `index.json` is rewritten deterministically, so an unchanged install leaves no diff. The VSR config mod ships no sprite overrides, so the stock art is what VSR players see.

`index.json`: `{schema_version: 1, source, table_px: 128, frames: {name: {file, sheet, u, v, w, h, px_w, px_h, stem, frame}}, stems: {stem: [frame names in table order]}}`. A weapon's reticle is `WeaponClass.wpnReticle` lowercased (the malformed `gbolt" / "octagon` on the snare ODFs sanitizes to `gbolt`), resolved as `name` or `name.0`. Its frame strip is every frame of that stem plus any frame named by `lockingReticle` / `lockedReticle` / `armedReticle` / `busyReticle` / `targetReticle` / `wpnReticle1..7`. The field that names a frame becomes its label: MAG levels read Level 1..7, launcher `targetReticle` frames read Stage 1..N (`LauncherClass.targetCount`), and `lockingReticle` / `lockedReticle` are labelled with those ODF keys — they are square target markers, not crosshair stages. A missing index or image renders a placeholder, never an error.

#### URL contract

All state is written with `history.replaceState`. An empty query string opens `?s=ivtank_vsr&t=ivscav_vsr` and then names the ship's own first weapon, so the Tank lands on `?w=gatstabvsr&s=ivtank_vsr&t=ivscav_vsr` with its stock AT-Stab, Minigun and Mortar. Unknown stems are dropped with a status line, and a mount the engine would refuse gets its own.

| Param | Meaning |
|---|---|
| `w` | Weapon family key (any variant stem is accepted and normalized). The Scenario weapon, mounted on the hardpoint group it fits |
| `v` | `c` / `a`: picks the twin without a ship; with a ship it applies only when that twin has a matching hardpoint |
| `s`, `sd=1` | Shooter stem, deployed |
| `lo` | One per hardpoint group carrying something other than the ship's stock weapon: `CAT.flag.stem`, or `CAT.flag.-` for a group stripped bare. Repeatable (`&lo=GUN.c.gchainvsr_c&lo=MORT.c.gsplintvsr`) |
| `t`, `td=1` | Target stem, deployed |
| `sh` | Target shield `N` / `S` / `D` / `A` (`N` forces no shield on a shielded ship) |
| `g` | Ignored. The firing group is always every matching hardpoint |
| `vsr=1` | VSR only. Scenario ship, target, and weapon lists are the recycler build tree. Absent on a fresh visit; remembered in `localStorage` `vt.wpn.vsrOnly`. |
| `scope` | Ignored. The Scenario catalog filter is `vsr`. |
| `tab=matrix` | Damage matrix tab |
| `tab=range` | Shooting range tab (the slug stays `range`). Weapon, ship, target, shield and loadout stay the Scenario params |
| `cat` | Weapon category filter, shared by the weapon picker and the matrix |

**Loadout links.** `w` and `lo` together are the whole loadout, so a tank carrying a Chain Gun and a Splinter shares as `?w=gatstabvsr&s=ivtank_vsr&lo=GUN.c.gchainvsr_c&lo=MORT.c.gsplintvsr&t=ivscav_vsr&tab=range`. The mount order is stock, then `lo`, then `w` on its own group; `normalizeLoadout()` keeps `lo` off that group and keeps out anything that merely repeats stock, so the calculator and the range can never disagree and a default link stays short. Only `*-._` and alphanumerics survive `URLSearchParams` unescaped, which is why each group is its own `lo` rather than one delimited value. Editing the firing group in the range moves `w` (the calculator follows the mount); stripping it moves `w` to whatever is still mounted, and stripping everything leaves a weaponless link that reopens exactly that way. Picking from the Weapon list, the variant pills or the Damage matrix wins on its own group. Changing ships, and any deploy that flips a group to its assault twin, drops the overrides that no longer have a hardpoint.

#### Assumptions shown on the page

Splash is the maximum value within its radius (no falloff); pulse counts are potential; lock-on time is not in the fire rate; TAG cycles add `firstDelay` and ignore leader flight time, and the leader round's ammo is not included; dropper ammo per drop is the dropped object's `maxAmmo` (engine convention, unverified); mine damage is the full detonation at its centre; charge-gun Scenario numbers follow the selected stage (a stage with no ordnance has no per-hit); the target's repair rate is not in the kill time; snipes (cockpit kills) are not modeled; arc and field timing are estimates.

#### Shooting range

Third pill, `?tab=range` (the URL slug stays `range`; the pill reads "Shooting range"). three.js loads only when the pill is opened (import map on `weapons/index.html`). The scene is `ObjectViewer` with the shooter's GLB, the target as a second prop, and `js/fx/odf-fx.js` playing the weapon entry's inlined renders. Nothing on screen is invented art: muzzle flash, projectile, trail, impact, shield bubble and death explosion are the `renderBase` sections the ODF names, textured with the files it names.

**Views are fixed; there is no mouse control.** Chase (default) sits behind the hull looking down range; Orbit circles the ship slowly (`ORBIT_RATE`); First person puts the camera on the shooter's eyepoint, hides the hull, and shows the cockpit mesh when the toggle is on. `ObjectViewer.eyepointWorld()` takes the `hp_eyepoint` node, else a numbered variant (the Lancer, the Mauler and the pilot and soldier skeletons name it `hp_eyepoint_1`, which an exact-name lookup misses). The cockpit mesh is authored around that eye. Only a model with no eyepoint node at all falls back to a point above the model origin; no armed VSR shooter is one. The turret tracks the target on its own (`ObjectViewer.aimAtWorldPoint`, the frame-correct yaw math from point-to-aim plus elevation pitch), so the reticle in first person sits on the target. Fire is the button or Space; keys 1-5 pick a slot.

**Loadout.** One card per hardpoint **group** — every `weaponHardN` sharing a `wpnCategory` and an `isAssault` flag (`buildSlots()` keys them `CAT:c` / `CAT:a`), because in-game a weapon powerup replaces the whole group and the group fires together. The ISDF Tank's two guns are one `Gun ×2` card; the Rocket Tank's two combat rocket pods and one assault pod are `Rocket ×2` + `Rocket · assault`. Each card is two rows: a header (key `1`–`5`, hardpoint icon, the category word from `HP_LABELS`, the assault suffix, the `×N` badge) over a full-width `<select>` of every weapon that fits the group (armory, community packs, and the non-armory library, same `wpnCategory` and `isAssault`, via `weaponStemsFor` + `variant`). The firing-slot radio is `visually-hidden`; the card border + key badge mark the active slot and the whole card is its click target. **The page owns the loadout** — `buildSlots()` reads `snap.loadout` (the effective mount per group, `hp.mounted` only as the fallback for a snapshot without one) and reports every change back through `shared.onLoadout`, which is what puts it in the URL. Every `<select>` edit therefore round-trips through `js/weapons.js`; the firing slot follows a new Scenario weapon and otherwise stays where the user left it, and picking a slot with keys `1`–`5` changes nothing in the link. `firingNodes()` returns the active group's nodes; ammo is charged per round per hardpoint (`ammoCost`), and the ship's `addAmmo` regenerates it. With no ship, a virtual slot fires the Scenario weapon from the origin; with every group stripped there is no armed slot at all.
**Trigger semantics.** Every CannonClass-derived archetype (`AUTOFIRE` in `js/fx/weapon-sim.js`: direct fire, mortar, popper, spray, missile, beam, tag, torpedo, dispenser) keeps firing at its `shotDelay` while Fire is held; `HOLD_ARCHETYPES` (lock-on, multi-lock, charge, arc, fields, blink, the MDM detonator, the toggles) keep their own semantics. Lock-on and multi-lock acquire while Fire is held and fire on release; the first-person crosshair walks the `targetReticle` stages (see **Multi-Lock**). The gate asserts every archetype is in exactly one set. A fresh press first waits `InitialShotDelay` (read by the `CannonClass` and `DispenserClass` loaders, clamped at 0, default 0): the engine counts it down only while the trigger is held, fires the frame it runs out, and sets it back to full on every frame the trigger is up, so a release restarts the wait (CannonClass update `0xac9eaa`, DispenserClass `0xacbccc`). The VSR Acid Cloud mortar (`ggasmortvsr`) waits 1.5 s and the Wasp that the Flame Mine's weapon mine fires waits 0.5 s; no other ODF declares it. The weapon-switch planner does not model it, and none of those weapons can join the switch (their damage is not one direct hit). `shotDelay` is read from `CannonClass`, else `LauncherClass`, else `TargetingGunClass`. Charge guns arm stage N at `shotDelayN` seconds of hold (MAG full charge is 7.5 s; the minimum gap into a stage-N salvo sits just above that threshold, 0 of 264 combat pairs short), fire `salvoCountN` rounds at `salvoDelayN` on release with no cooldown and no new charge until that salvo ends, and drain ammo from the press by sliding between each stage's `salvoCount × ammoCost` and the next (read from chargegun.cpp; the opening frame also pays `salvoCount` times that cache), then a flat `holdRate` per second once the last stage is armed. A frame the tank cannot pay stalls the charge instead of firing. The round's `ammoCost` is not charged again on release. The hold loops `fireSound` at `startRate + deltaRate × seconds`, and that clock stops at the last stage's `shotDelay` (read from the engine: a full MAG is 11025 + 4000 × 7.5 s = 41025 Hz, then flat); volume is the loader's 0.01-scaled `startVolume + deltaVolume × seconds`, clamped to 0–1. `fireSoundN` is the per-round discharge sound at native pitch.

`ExtractorClass` properties the engine actually reads are `ScrapDelay`, `initDelay`, `scrapHold` and `detectRange` (internal table from the decrypted loader). The Economy tab's regen bands are the wire `scrap_status` enum, not those ODF fields.

**Rave screen flash.** `CannonClass.raveFlash` (Rave! `gtechno`, Yule! `gyule`) is an engine screen wash, not a `renderBase`. The ODF names no colour and no duration. Each volley `fireMuzzles` actually fires calls `onRaveFlash` once (one wash per shot, not per barrel), and the range paints a full-stage overlay under the HUD in that weapon's `Ordnance.Light`, `TrailR` and `TrailB` start colours, fading from `RAVE_PEAK` to clear across `shotDelay`. A flag with none of those colours uses the Rave! triad (magenta, yellow, cyan). `prefers-reduced-motion: reduce` skips it. Reset and a weapon change clear a wash already on screen.

**Salvos and alternation.** One trigger pull is `startSalvo()`: the first round now, `salvoCount − 1` more at `salvoDelay` through the salvo timer (the salvo completes even after the trigger is released), then `shotDelay` before the next pull. `salvoDelay 0` is one tick: the rest of the salvo leaves with the first round, so the Burst Gun (`geburst_c`, 10 pellets) and the Pummels put every pellet out on the same tick. A zero-delay salvo plays the fire sound once per pull; each later same-tick pellet is silent. Spaced salvos still play once per round. The salvo timer used to read a declared 0 as 0.05 s, which spread a two-gun Burst's 20 pellets over 0.45 s. The cooldown rule lives in `pullCooldown()` (`js/weapons-weave.js`) so the weapon switch plans with the same numbers. Hitscan beams (`BoltClass` ordnance: laser, gauss, arc) take the same `fireMuzzles()` path with `extra.beam`, so the CannonClass numbers apply to them exactly as to projectiles. The Arc Cannon (`garcvsr_c`) is the case that made this matter: it is a plain `cannon` whose ordnance is `arcbolt` — **not** an `ArcCannonClass` weapon like the Arc Stream — with `salvoCount 5`, `salvoDelay 0.07`, `shotDelay 2.0`, so one pull is five 85-damage bolts (vs H) inside 0.28 s and the calculator's 212.5 DPS vs H; firing one bolt per pull was a fifth of that. The Scenario card calls that pull one shot (8 shots / 36 hits to kill a 3,000 HP heavy Scavenger from the Scout's single cannon; 4 shots / 36 hits from the Titan's two cannons, which fire together). `shotAlternate` (`CannonClass`, on the weapon ODF — `ggauss_c` declares `1`, its assault twin `ggauss_a` `0`; the ship ODF has no say) fires one grouped hardpoint per pull, rotating, with the cooldown `shotDelay / hardpoints` per the guide's "ShotDelay is divided evenly between the amount of Hard Points" — the Gauss Gun on a two-gun ship fires left, 0.25 s, right, 0.25 s, the same total rate. Each of those is its own shot: 86 shots and 86 hits to kill that Scavenger, in 21.25 s, at 140 DPS. Counting the two barrels as one volley (the old figure, 43) was wrong. A weapon that alternates and also salvos keeps the salvo on the one barrel: Sprinkler Msl (`gsprink_c`, `salvoCount 4`, two rocket hardpoints on the Zeus) is 19 shots, not 10.

**Spread.** Every ODF variance scales entries of the game's random table, which the engine fills at startup with `−1 + 0.2 × (ten uniforms on [0, 1))` (mean 0, σ 0.183; `tableRand()` in `js/fx/odf-fx.js`). `applyCone()` deflects each round of a CannonClass weapon: yaw by `sample × shotVariance` radians about world up, then a climb of `shotPitch − sample × shotVariance` (a fresh sample; a lob builds its own elevation, so it takes only the sample term). `shotVariance` scales the sample and is not the half-angle of a uniform cone, so Fang (`gquill_a`, 0.5 rad) puts a typical pellet about 5° off the aim line and 99% of them within 16°. Hitscan beams take the same cone and are traced along it: the target when the line passes within its radius (the bolt ends where the line passes closest to the centre), else the floor, else `shotSpeed × lifeSpan` down range with no impact and no damage. A ChargeGun stage fires with its own `shotVarianceN` in place of the CannonClass value (MAG: 0.25 for stages 1–4, then 0.2, 0.1 and 0 at full charge) plus CannonClass `shotPitch`. A popper's second stage uses the popper round's own `ShotVariance` (Popper / RadarPopper / LaserPopperClass: no ODF sets it, the engine default is 0, and the engine applies it only above 1e-4), never the weapon's spread or `shotPitch`.

**Payload emitters.** Flare mines and spray buildings bank elapsed time once `triggerDelay` has passed and fire while the bank is at least 0, one `shotDelay` per round, so a `shotDelay` shorter than a frame fires several rounds in that frame and each late round starts that far down its path. A FlareMineClass round goes straight up, tilted off vertical by `sample × shotVariance`, at a uniform heading (defaults `shotDelay` 0.05, `shotVariance` 0.5, `triggerDelay` 0), for the mine's whole `lifeSpan`; dispensed flare mines (VSR Flare, Flare S, Fountain, Tornado, Snare, Meteor) fire their payload as well. A SprayBuildingClass round leaves at pitch `sample × anglePitch` around level and heading `omegaSpin × t + sample × anglePitch`, or a uniform heading when `shotDelay` is under 1e-4 (defaults `anglePitch` 0.25, `omegaSpin` 0, `shotDelay` 0, `triggerDelay` 1). It pays the payload's `ammoCost` from its own `maxAmmo` each round and retires when it cannot: the Splinter fires 384 splinters 0.01 s apart, and Resin, with no `shotDelay`, fires its one round at once. A spray building hovers at `setAltitude` (default 2). A flare mine with a `damageRadius` R also burns what is near it once `triggerDelay` has passed: `damageValue` per second, full inside 0.2 R and falling to 0 at R as `(R² − d²) / (R² − 0.04 R²)` with `d² = dist² − objR² / 2` (`flareFieldFactor()`; Solar Flare: 20 m, 100 vs N; its kick is not modeled).

**Effects and waver.** Render emitters add `emitVariance × sample` to the fixed `emitVelocity` and `emitPosVariance × sample` to `emitPosBias`, and schedule each emission `emitDelay + emitDelayVar × sample` after the one before; explosion particles take `particleVeloc × sample + particleBias` and a `particlePosVar × sample` offset, and an explosion spawns its full `particleCountN`. A spinning particle turns at `rotationBias + rotationRate × sample` rad/s from a random starting angle when `rotationRate` is set. A MissileClass round with `omegaWaver` turns at `sin(phase) × omegaWaver` rad/s about each of its pitch and yaw axes; each phase starts at 0 and advances by `rateWaver × dt × (two uniforms)`. Steering (homing and waver together) starts at `delayTime` and scales up from 0 over `rampTime`. The engine never reads `lifeSpanVariance` (16 ordnance) or `particleVarianceN` (90 explosion rows), so the range ignores them too.

**Class defaults.** A key an ODF omits takes its class default, walked up the class chain from the calculator's own tables (`WEAPON_DEFAULTS` / `ORD_DEFAULTS`, mirrored in `js/fx/weapon-profile.js`; the gate fails if they drift): a bullet is `ammoCost` 1, `lifeSpan` 5, `shotSpeed` 200, a beam 1 / 200e-6 / 1e6, a grenade 10 / 1e30 / 50, a missile `ammoCost` 10; a cannon's `shotDelay` is 0.2, a mortar's 1.0, a TAG cannon's 1.0 with `salvoCount` 10, and launchers, dispensers and the blink 0. Where the engine and the guide disagree the engine wins: an omitted LauncherClass `lockRange` is the round's own reach, `shotSpeed × lifeSpan` (the guide says 0). Lock and lose cones default to π/2, thermal seek cones to 0.314. Authored values stand as written, with no floor (a 0.05 rad lock cone, a 10 m trip trigger, an `ammoCost` of 0), and a round lives its `lifeSpan`; one whose life is infinite is dropped only 5 km out, past the far plane (`OFFSCREEN_CULL_M`). Aiming is the gunner's: a lob takes the elevation that lands on the target (out of reach, the one that throws farthest), an authored `shotPitch` adds to the aim (the poppers' steep lob), and lead on a moving target is bounded by the round's `LeadPositionMinTime` / `LeadPositionMaxTime` (0 / 60 s). A bolt's head reaches its end at `shotSpeed`.

**Mines, fields and torpedoes.** Proximity and trip mines arm at once (neither class reads a `triggerDelay`) and trigger on a target whose origin is within `triggerRadius` (default 20) of theirs. A triggered mine detonates with its MineClass `xplBlast`; one whose `lifeSpan` runs out (default 60 s, 1e30 for proximity and trip mines) uses `xplBlast` only with `detonateExpire`, otherwise its death explosion. A sprung trip mine releases its payload object (seeker, magnet mine or weapon mine) under that object's own class defaults. A SeekerClass object floats at `setAltitude` (2) and drifts at `floatVeloc` (10) onto a target inside `searchRange` (50), bursting on contact. A DamageFieldClass weapon (Static Charge) deals `damageValue × dt` every frame to a target whose origin is inside `damageRadius` of the ship's, with no falloff. TorpedoClass objects (the TAG torpedo, the Wasp dispenser) are homing rounds with their own speed, turn and life (defaults 25, 1.0, 60) and no delay, and they detonate with `xplBlast`: the TAG torpedo bursts for 800 vs H. A popper pops once it is falling and has a target: `salvoCount` ≤ 0 (the default) launches at once, otherwise that many after `initDelay`, `salvoDelay` apart; only `launchXpl` plays. The Arc Stream ticks at ArcCannonClass `salvoDelay` (0.1) and deals telemetry's 30 hits a second of `damageValue × salvoDelay`, the calculator's rate, inside its own `coneAngle`. Field damage is summed and shown every 0.12 s (`FIELD_READOUT_SEC`, display only). A WeaponMineClass object (what the VSR Flame Mine drops, the Snare's mines) takes a target whose `dx² + dy² + heightScale × dz²` is inside `searchRadius²` (the engine weights the world z axis by `heightScale`, which the guide calls an emission height), aims its weapon at it and holds the trigger: its `weaponName1` fires by its own ODF rules from the mine (`runWeaponMine()`, a second range sim), paid from the mine's `maxAmmo` (the Flame Mine's 300 buys two 120-ammo fireballs), and what it fired keeps flying when the mine expires. The mine's weapon keeps its own `InitialShotDelay`, counted from the moment the mine takes the target, so the Flame Mine's fireballs (the Wasp, `fball2b`) leave at 0.5 s and 3.5 s and burst for 96 vs H. Bounce and spray bombs bounce the engine's way: each contact reflects the velocity and scales it by `bounceRatio`, and a bounce that leaves the round under 1 m/s stops it, with no cap on the count (`BOUNCE_REST_SPEED`). A spray bomb's contact ends the bounce at once when its `HitExplodeTypes` mask names it (16 the ground, 1 | 2 | 4 the three object kinds); it then detonates only with `ExplodeOnHit` and, with `BuildSprayOnHit` (default on), leaves its payload where it stopped. So the Splinter hops at the default `bounceRatio` 0.1 before it builds, Resin (23) and Hellfire (31) build on first contact, and a spray bomb with no object bit bounces off a ship. The magnet gun keeps its muzzle flash showing the engine's way: every active frame it makes a new one when none is left. The Distance slider starts where the two models would touch (ship radius + target radius, rounded up), so the 20 m fields and short-trigger mines can reach the target. Still approximated: the target is a sphere (a shell that would come to rest on it slides off at 1 m/s), a weapon mine fires from its origin rather than its weapon hardpoint, the Seeker snaps to `setAltitude` where the engine springs it there, and the three object bits of `HitExplodeTypes` are not told apart (every VSR mask sets all three or none).

**TAG cannon.** `TargetingGunClass` fires a leader round from every *free* hardpoint of the group at once; each leader carries its hardpoint node. A leader that lands (on the target or terrain — `LeaderRoundClass` sticks to both) starts a tag field bound to that node with its own `firstDelay` / `salvoDelay` / `salvoCount` timers, and the salvo leaves *that* hardpoint (looked up by node each round so it tracks the turret), homing on the tag point. Two fields started on the same frame fire in pairs; a hardpoint is busy while its leader flies or its salvo runs, so a later pull re-tags only the hardpoints whose leaders expired, which start their own salvo mid-way through the other's. A leader that expires in flight tags nothing (`tagMissed`, surfaced in the HUD hint). There is no `shotAlternate`, so one pull is one shot: `gtaggunvsr_c` on the Missile Scout's two rocket hardpoints sends a salvo of 6 from each, 12 missile hits, and the pairs are why both guns stay inside that shot (56 hits / 5 shots vs the same Scavenger). The leader's stick damage stays out of the hit count.

**Multi-Lock.** `MultiLauncherClass` releases exactly one missile per lock, each its own launcher shot gated by `LauncherClass.shotDelay` (0.4 s VSR / 1.0 s stock; the guide gives no separate release cadence), alternating barrels on a multi-hardpoint ship, queued through the salvo timer. The HUD reads `Releasing k of N` on the idle crosshair until the queue drains. The two `targetCount`s are different quantities: `MultiLauncherClass.targetCount` is how many locks the weapon holds (5 on the VSR Multi-Lock; a plain launcher holds 1), and `LauncherClass.targetCount` is how many `targetReticle` crosshair stages there are (3 on the Shadower and the Multi-Lock). The on-disk `battlezone2.exe` `.text` is Steam-DRM ciphertext; the lock rule below was read from the decrypted image of the running process (`_investigation/bz2_memdump.py`). The launcher constructor sets the stage interval to `lockDelay / targetCount` (`0061E6CD`) and the lock completes after `targetCount - 1` of those stages, so the Shadower (1.5 s, 3 stages) locks at 1.0 s: `gshadow.1` for the first 0.5 s, `gshadow.2` for the next 0.5 s, `gshadow.3` once locked, back to `wpnReticle` `gshadow.0` on release. A separate screen-space circle is queued on the target (`0061E41B`, flushed by `005C42C4` as a 32-segment loop after the world-to-screen projection). Its radius is `128 - 112 * progress` pixels while locking and 16 px once locked; the colours passed in are the D3DCOLOR globals `0xFFFFFF00` (yellow) and `0xFFFF0000` (red). Multi-Lock keeps `LauncherClass.targetCount` at the stage count and stores its own lock count at a different member (`+7F0`, default 5). Each completed lock adds one circle (`006214F4`) at `16 + 3k` px, k the 1-based number of locks already on that same target, so five locks on one ship are concentric rings at 19, 22, 25, 28 and 31 px, and the in-progress lock is the shrinking yellow circle on top. The square `lockingReticle` / `lockedReticle` sprites are not what that loop draws. The circle is centred on the target's `hp_com` hardpoint (`00615A9C("hp_com")` → `005661EC`, posit `+0x30`), else on the object's own transform; the Scavenger's origin is at ground level and its `hp_com` sits ~4.3 m up. A lock-on weapon opens the range at 90% of `lockRange` (Shadower 180 m, capped at the 400 m slider) rather than 45% of the missile's flight range.

**Audio.** `js/fx/odf-audio.js` plays every clip with a world position through a `PannerNode` (`equalpower`, inverse distance, `AUDIO_REF_DISTANCE` 15 m, `AUDIO_ROLLOFF` 1, `AUDIO_MAX_DISTANCE`), with the active camera as the listener (`setListener(camera)` every tick after `placeCamera`). Fire, shot and leader sounds sit at the muzzle, `explSound` at the impact, bounces at the shell, looping ship sounds (machine-gun loop, arc stream, charge whine, field hum) ride the ship; lock tones stay 2D like cockpit cues. `SoundPerShot` 0 is the guide's "Looping" mode (MachineGun's default, also set by the Flash, Crapier, Slicer and Mag Focus beams and the lobbed machine guns): the weapon's `fireSound` loops from its first round until the next round is overdue (its pull cooldown plus `FIRE_LOOP_GRACE_SEC`), another weapon fires, or Fire is released. It never starts on the trigger alone, so a held Fire with nothing in reach, a dry tank, or a weapon switch waiting on another weapon is silent. The ODFs carry no per-sound range, so the rolloff constants are viewer tunables. Every clip, positioned or 2D, ends in one master `GainNode`; the control bar's **Volume** slider drives `audio.setVolume(0..1)` (1 = the tuned `AUDIO_GAIN` loudness), scales sounds already playing, and persists as `localStorage` `vt.wpn.range.volume` (0..100; missing = 100).

**Refill ammo / Reset.** Refill only tops up the active slot's ammo. Reset (`sim.resetEngagement()`) releases the trigger, kills in-flight rounds, tag fields, armed ordnance and looping sounds, refills ammo, and the range restores the target's hull to `maxHealth` and cancels a pending respawn. Ship, target, equipped weapons, active slot, distance, view, cockpit and moving-target settings all stay as they are; a weapon-switch approach goes back to the Distance slider's start.

**Weapon switch.** The VSR tactic of firing a slow weapon and filling its cooldown with a faster one (Xares Burst Gun with Dragon Blast, ISDF Tank Blast with Chain Gun, Warrior Arc Cannon with Gauss Gun). The `Weapon switch` checkbox beside Fire turns each loadout card into an `In switch` tick (the card's click toggles it; keys 1–5 do nothing while it is on) and turns the time-to-kill panel above the loadout into the switch planner. One held trigger then serves every ticked group. Every mounted group starts ticked except a mortar (`MORT`) group, which joins the switch only once the user ticks it (`isPicked()`); the user's ticks last until the ship changes. `decide()` in `js/weapons-weave.js` picks each pull: a weapon's **cycle** is `max(pull cooldown, (salvoCount − 1) × salvoDelay)`, the pull cooldown being `shotDelay` (÷ hardpoints under `shotAlternate`, floor 0.05 s, `pullCooldown()`), and the longest cycle has priority. A lower-priority weapon fires at its earliest time only when its salvo plus the switch back leaves every higher-priority weapon that would fire on time; equal cycles follow the user's order (drag within the tie, or the arrow buttons). Ammo gates only a weapon's own pull, so a dry tank never holds a cheap filler back to save rounds for an expensive cannon. Changing weapon takes `switchSec`, default `TURN_SEC` = 1/20 s: BZCC simulates in lockstep turns at 20 per second (`BZCC_DEFAULT_TPS 20` in `statsgate/include/ScriptUtils.h`; every recorded match has `tick_rate` 20), so a new weapon fires a turn after the last at the earliest. No ODF property or guide entry sets a switch delay; the panel lets the user raise it to 1 s for their own key presses. On the Xares in range that gives Burst at 0, 1, 2 s with 3 or 4 Dragon Blasts between, and 2 at a 0.25 s switch. A switch waits for a salvo to finish. Reach is `shotSpeed × lifeSpan` (Burst Gun 300 × 0.30 = 90 m, Dragon Blast 200 × 0.72 = 144 m), lobbed rounds use `aiRange` capped at the ballistic `shotSpeed² / SIM_GRAVITY`, measured to the target's centre. Players report the Burst landing at 100–110 m; the guide documents no carry-over of the ship's velocity into its rounds, so the range keeps the ODF's 90 m. **Closing** drives the ship toward the target at its top speed while Fire is held: `shooter().velocForward` / `velocSource` from `HoverCraftClass` / `TrackedVehicleClass` / `WalkerClass` (guide defaults 20 / 10 / 4), `MorphTankClass` when deployed (falling back to `HoverCraftClass`), `PersonClass.velocForwardRun` for pilots (Xares 30.5, ISDF Tank 28.5, Scout 40), constant with no acceleration, stopping at `CLOSE_STOP_M` 20 m. The target stays put on the ground. The range carries the ship `startDistance − distance` ahead of its home pivot through the viewer's `setRangeShipOffset()`, which reuses drive mode's floor follow: the grid moves in whole cells so its lines stay fixed in the world and stream under the ship, and the shadow catcher and the sun's shadow frustum follow it. The floor is sized from the start distance, so an approach never rebuilds it. Rounds fly at their own `shotSpeed` toward a fixed target, matching the planner's reach; when the target slid in toward a parked ship, every round gained the closing speed (a Burst pellet carried about 99 m). The Distance slider is the start, and a respawn, Reset or a new ship (or a morph deploy) returns to it. A weapon out of range waits, so from 135 m the Xares fires Dragon Blast alone until the target reaches 90 m at 1.48 s.

`planWeave()` replays `decide()` as discrete events from Fire at t = 0 until the hull reaches 0: rounds land after their flight (hitscan at once, else distance / `shotSpeed`, lobs on the range's flat-ground low arc at `SIM_GRAVITY` or the ODF's fixed `shotPitch`), from one shared tank (`maxAmmo`, `addAmmo`, starting full). Every round counts as a hit (the live range scatters spread weapons, and the notes say which), the target's repair stays out of the kill time (a warning), and pulse extras are not added. One weapon alone reproduces the Scenario kill time plus the last round's flight (Blast vs Scavenger 18 s). The readout gives the kill time with a tier (`estimated` when a lobbed or homing round is in the set, else `exact`), the switch order as a ribbon of runs, each weapon's landed damage and its time alone, and, while closing, the time if Fire were held until every ticked weapon is in range. A live line times real kills from a full hull in simulated seconds (every step is capped at 0.05 s, so a slow frame rate slows the range down rather than skipping time). Scheduled archetypes are direct fire, beams, mortars, dumb-fire missiles and the MDM, with one per-hit damage; lock-on, multi-lock, charge, arc stream, fields, TAG, torpedoes, poppers, payloads, dispensers and utilities stay out with a reason on their card. Live, `sim.setWeave({ slots, switchSec, closing }, db, ammo, keep)` keeps each group's cooldown and barrel, calls `decide()` every frame, passes the group to `getMuzzles(key)`, emits `onEvent('weave-fire', { key })`, follows the current weapon with `onRaveFlash(profile)` (a machine gun's loop plays only while its own rounds leave, per **Audio**), and keeps rounds in flight when the set changes; entering the mode clears the engagement like a weapon swap. All of it is range view state, not in the URL.

**Time to kill with one weapon.** With the switch off, the same panel times the firing slot alone: `planWeave()` over that slot's one description (exactly the weave panel's "alone" figure), the same result card, the notes without the switch-only lines, and one comparison line built from `weaveEntries()` when two or more mounted, timeable, ticked groups exist ("Weapon switch (Dragon Blast + Burst Gun): 5.36 s, 2.16 s faster", or "is no faster here" when switching does not help). A weapon the planner cannot time shows its exclusion reason and keeps the live kill line. Closing and its speed work in both modes; Switch time shows only with the switch on. While Closing is on, `sim.setReachGate(desc.reach)` holds a single timed weapon's trigger (its first pull, AUTOFIRE refire and MDM lobbing) until the target is within its reach. That is the planner's rule, so live and planned kills agree (Dragon Blast from 160 m: 7.53 s planned, 7.63 s live). The HUD reads "Closing · fires inside R m" while it waits; detonating armed MDM shells is never gated, `setWeapon()` and `setWeave()` clear the gate, and with Closing off a single weapon fires at once. The live kill timer runs in both modes, and keys 1–5, the Distance slider, Closing and its speed replan the readout.

**MDM.** `RemoteDetonatorClass.maxCount` caps armed shells (guide default 4, max 8; `gmdmgun` omits it). Holding Fire lobs another shell every `shotDelay` (2.0) while fewer than `maxCount` are armed; a fresh press with shells armed bursts them all where they lie (their ground explosion, splash within `damageRadius`), and so does the Detonate button. A shell that touches a ship or turret bursts as a direct hit (`damageValue`, 400 vs H) and frees its slot; a building bounces it at `BounceBombClass.bounceRatio` 0.5 and it stays armed. On the ground it bounces the same way and lies armed once a bounce leaves it under 1 m/s. That is `GameObjectClass.allowMDMCollisionDetonation` −1 (auto: a hit on a non-building), which no ODF in the database overrides; the target's own value is read (`target().mdmRule`, `mdmDetonates()`). Gun towers, spires, Defenders and Spikes are Vehicle-bucket craft, so a shell bursts on them; Factory, Kiln and Extractor are buildings. A resting shell still bursts on a ship that touches it, and an armed shell expires at the ordnance `lifeSpan` (300 s) with its `xplExpire`. Changing weapon bursts every armed shell where it is, with the same ground explosion (`detonateArmed()` in `js/fx/weapon-sim.js`): a weapon-switch pull of another group bursts the last group's shells (`fireSlot()`), and so do unticking the MDM or mounting another weapon on its group (`setWeave()`), turning the switch on, and a single-mode slot change (`setWeapon()`, keys 1–5 and the slot cards). Re-applying the same MDM (`sameDetonator()`) and Reset clear the shells without a burst. The planner bursts each shell at the other weapon's pull: `shellGap()` places it on the same flat-ground lob as `flightTime()`, and `burstDamage()` counts the ground explosion's `damageValue` inside its `damageRadius` plus the target radius (`weaveInput().burst`, read from the inlined `ExplGround` section, else the `xplGround` Explosion entry). One turn after its pull a shell is still about 57 m from a target 60 m out, outside the 50 m explosion, so a weaved MDM rarely deals damage (5 vs H when it does). A lone MDM never changes weapon, so its planned kill time is unchanged; against a building that plan still leaves the MDM out and says so. The Fire Detonator (`hgpackbomb2`) is the other `RemoteDetonatorClass` weapon and follows the same rules. The range has one target, so a shell that bounces off a building into another ship is not modelled.

**Damage.** The target card shows the ODF `maxHealth` as a hull bar. An impact follows the calculator's per-hit rule: a **direct hit is the round's own `damageValue(<letter>)` for the target's shield-or-armor class and nothing more**; the impact explosion's `damageValue` reaches the target only when the round carries no direct value at all (splash-only ordnance) or when the round landed elsewhere — ground, near miss — inside its `damageRadius` (maximum within the radius, no falloff). The range used to add the explosion on top of a direct hit, which made the Rocket Tank's Salvo Rkt (10 rockets of `damageValue(H) 90` whose `ExplVehicle` also carries 75) kill a 3,000 HP Scavenger in two salvos; in-game it takes four (900 a salvo), and the calculator already said so (`hitsToKill 34 → shotsToKill 4`). Pulses, arcs and damage fields use their own heads. Floating `-N` numbers project the hit point. A shielded target plays the `ShieldUpgradeClass` bubble (`textureName`, `scaleStart -> scaleFinish`, start / middle / finish colours over `animateTime`) on each hit. At zero hull the target's `GameObjectClass.explosionName` plays, the prop hides for `RESPAWN_SEC`, and it comes back full.

`js/fx/weapon-profile.js` classifies every armory weapon into one archetype: direct fire (including pulse, anchor, magnet-shell and sniper flags), beam, arc stream, mortar, homing missile, lock-on launcher, multi-lock, steered torpedo, tag-then-salvo, popper, spray payload, remote detonation, charge, dispenser, shield, blink, image refract, radar damper, terrain expose, push field, thrust pack, damage field. Flight numbers (`shotSpeed`, `lifeSpan`, `lockDelay`, `omegaTurn`, `salvoCount`, `shotPitch`) are read from the ODF. Homing, lock acquisition and mortar gravity (`SIM_GRAVITY`, default 12.5 m/s², the lobby gravity setting rather than a per-map value) are demo-grade and labelled **Approximated**. Renders, sounds and straight-line ballistics that follow the ODF are labelled **Data-driven**. The chip is never hidden.

**Render fidelity rules** (`js/fx/odf-fx.js`; the guide is `docs/reference/odf-render-guide.md`):

- ODF colour bytes are display values (BZ2 has no colour management), so they enter three.js as **sRGB** (`setRGB(..., SRGBColorSpace)`) and round-trip to the same pixels; feeding them as linear brightened everything (`232 155 64` rendered as `#f5cd89`).
- **A `file.section` name reads that ODF's section.** BZCC resolves `renderName = "shellgun_c.render"` from `shellgun_c.odf`, while `odf.min.json` inlines only a weapon's own ordnance and explosions. `mergeCrossRefs()` (weapon-profile.js; run by `buildProfile(entry, ordDb, db)`, `ordnanceEntry()` and `explosionEntry()`) copies a render, flash, emitter, particle or effect section the weapon's tree cannot resolve (`effectName1 = "hfire2.render"` is the `[render]` of `hfire2.odf`; the VSR Hellfire mine does not inline its ground fire), plus everything it names in turn, from the named file under `xref.<file>.`. `sectionByRef()` reaches a borrowed section only through its own file name, and its children resolve inside it like an inlined ordnance; names that already resolve are untouched. Before this, 360 names on 66 weapons resolved to nothing, so they drew nothing while their rounds still flew and hit: 20 rounds (every non-SGS Hedoux gun, `gshellgun`, `gslicer`, `gcphcg_a`, plus both Crapier VSR beams and the assault Burst Gun), 30 muzzle flashes (among them the Hedoux SGS's own `gslagstab_a`) and 298 impact-explosion particles and render children. `profileAssets()` preloads the borrowed sections' textures, and `scripts/build_fx_assets.py` follows the same names (`section_ref_files()`). The FX gate requires every such name to resolve, apart from two sections that exist in no ODF (`techno.sparkb`, `yule.sparkb`). A file-less `effectName` (`dusttrail`, `emit_redblink`) is not a cross-file ref and is left untouched. A `draw_multi` reads `renderName1` to `renderName<renderCount>` only, so the gate skips names past the count (VSR's `fball2a` render lists a `renderName5 = xfball1b.trailer2` beyond its count of 4, and no `xfball1b.odf` exists).
- **Nothing is drawn untextured while its texture loads.** `profileAssets(entry, db)` (weapon-profile.js) walks the weapon plus the Ordnance / Explosion / Mine entries it names but does not inline (charge `ordName#`, `leaderName`, `launchOrd`, `objectClass`, `xpl*`), and `fx.preload()` warms the file caches when a slot is applied (plus the target's death explosion and the shield texture). `fx.warm()` then uploads those images and compiles the effect programs (a mapped sprite, a mapped basic mesh, and a vertex-coloured ribbon). The prototype materials are kept for the life of the runtime — disposing them would drop the program and put the hitch back on the next shot. The compile sees only those prototypes, lit by the scene's lights (light counts are part of every program key, so the first shots compile nothing new): three's `compileAsync` polls each material it compiled and throws, never settling, when one is disposed meanwhile, so live rounds stay out of it. A node whose stem is still loading stays hidden until its map lands; a section that names no texture draws untextured, as the game would. The same pass prefetches the weapon's wavs; they decode on the next gesture, when the `AudioContext` is allowed to run.
- **Loading visuals.** A chip at the top center of the stage (`Loading visuals`) is up while a ship, target, or effect warm is in flight. Ship and target programs compile via `ObjectViewer.warmGpu()` after their GLBs are in the scene; the animation loop is not stopped. The chip does not disable Fire, Space, or the slot keys. A newer scenario sync drops the previous jobs so the chip cannot stick. Below 640px the ammo and hull cards stack full-width at the bottom of the stage (they overlap when each keeps a 16rem minimum) and the reticle sits higher so it stays in the open view above them.
- `animateTime` defaults to the guide's `1e30` (hold the start values); `draw_bolt` defaults to 0.1 and trails age over `segmentTime`. Lifetime: `lifeTime` (or `staticTime`) when given; a free particle with a `simulateBase` (explosion `particleClass`, emitted item) lives the `ParticleSimulateClass` default 1.0 s; a render attached to a round lives with it (`fx.attach` passes `free: false`); a muzzle-flash burst lives `WeaponClass.flashTime + 0.1 s` (`FLASH_EXTRA_SEC` — the guide's `flashTime = 0.0f` entry: "time for the flash effect to play … + 0.1 seconds"), capped by the flash section's own `lifeTime`, never extended by it: `garc_c.flash` declares a `draw_sphere` growing 0.5 → 10 m over a 5 s `lifeTime`, and the game shows the first tenth of a second of it (≈0.7 m); `glaser_c.flash` likewise declares a 3.5 s, 20 m light that is a 0.1 s pulse in play. `maxCount` (default 128) culls the oldest of the same section.
- `draw_trail` ages **each cross-section** over `segmentTime` (head = start radius / colour, tail = finish) through RGBA vertex colours; `textureRate` repeats the texture along the trail at `TRAIL_SEGMENT_HZ` (nominal 20 segments/s — the guide gives the rate per segment, not the cadence) and `textureSpeed` scrolls it. On `shot.kill()` the round's trail is `release()`d and drains on its own instead of popping; tracers, sprites and geoms go with the round.
- `draw_bolt` is **laid once per render and only fades** (`boltPath()`, cached on the node; the game lays a bolt and fades it — re-rolling the zigzag every frame made the Arc Cannon's five-bolt salvo writhe like a stream). The path is laid the way the engine lays it: a vertex every `segmentLength` along the straight line from muzzle to impact, each offset by `segmentVariance × sample` per axis in its segment frame (x side, y up, z along), with the muzzle and impact points exact. The offset is the same size along the whole bolt, so `2 2 0.5` (Arc) keeps every vertex within about 2.8 m of the line and 0.5 m of its step. `0 0 0` (Gauss, Blast) is a straight beam; `segmentLength 0.2 / variance 2 2 2` sparks get their crinkle and the service truck's `2.0 / 0.5` repair bolt its gentle wobble. Width fade and `textureSpeed` still animate from the cached path.
- `draw_geom` scales the mesh by `startRadius` directly (FX meshes are authored at unit size: `g_sflash` 1 m, `fireballORD` 6.9 m at `startRadius 0.1`) and points its local −Z along the emitter's forward / the round's velocity; Initial/Add pitch-yaw-roll apply in an inner group.
- `draw_emit` draws nothing itself (EmitRender is not a ColorRender). It emits on its first update, then every `emitDelay + emitDelayVar × sample` (the range doubles that delay, the guide's particle-LOD step). A child named by `emitName` is spawned as its own class — its own `renderBase`, `simulateBase`, `lifeTime` and emitter. Only a `draw_twirl_trail`'s self-emission is a puff lasting `emitLife`. An `emitDelay` longer than the emitter's `lifeTime` is the ODF emit-once idiom (Hellfire's `fire2` is 1.1 s on a 1.0 s `sim_dust`; building-collapse smoke uses `1e6` on a 0.1 s puff): one child, then the emitter dies. A `draw_multi` that carries a `simulateBase` is one particle: the group steps that sim and the children ride it until it dies (Hellfire's `fire3` flame, smoke and sparks; a mortar blast's `secondaryrender`). `sim_dust` snaps a free particle onto the ground at birth. The particle budget drops the oldest free sprite, never a host-attached emitter, so the mine's own chain stays up. Forcing a nested `draw_emit` into an untextured twirl is what drew Hellfire's ground fire as white squares. `draw_static` emits triangles into the `innerRadius`..`outerRadius` shell every `emitDelay + emitDelayVar × sample`, each at a uniform heading and an elevation of `sample × π` (the Static Charge's `flashName`, attached for the hold; the spark's place between the radii and its turn are not confirmed and stay uniform); `draw_wave` is a flat sector of `waveSegments` quads over ±`waveAngle` (π = full circle) about the emitter's forward, `waveDepth` of the radius deep (read as a fraction — the only reading under which `1.0` is the fireball's disc and `0.3` the sonic wave's ring), `waveColorCenter` → `waveColorEdge` across the band; `draw_light` with `lensFlare` draws both `lightflare.tga` and `lighthalo.tga` (`FX_HALO_RATIO` sizes the halo against the core — the guide names the pair, not the ratio). The real point light is one of a fixed pool of `FX_LIGHT_POOL` (8) slots created with the effect runtime, so a burst cannot change `numPointLights` and recompile the ship. A full pool retargets the oldest slot; the flare sprites still draw. Reach is not the raw `startRadius`: BZCC forces the light to 0 at that radius on top of `1/(Kc + Kl*d + Kq*d^2)`, and the pool light cuts off at half brightness of that curve (`decay` 0, so it does not blow up as `1/d^2` on the hardpoint). Fang's flash (`R` 20, `Kq` 15, red alpha 300) is therefore a small muzzle flash, and the flare sprites are capped to that same reach. `draw_twirl` `BottomInteractsWithTerrain` defaults true and squashes the sprite so its bottom rests on the ground.
- `RENDER_BASES` lists every class drawn by name; an unknown `renderBase` warns once and falls back to a sprite, and the gate fails if the corpus names a class outside the list.

`python scripts/build_fx_assets.py` walks the VSR scope (123 weapons) plus `library_weapon_stems()` (the same named-but-not-sold set as `weaponStemsFor().library`) and the separate ODFs those weapons name (`named_refs`: dispensed mines and payloads, leader rounds, charge ordnances, poppers' second stages, non-inlined explosions — the same set as `profileAssets`), decodes effect textures to `data/fx/textures/` (≤256 px), copies missing wavs into `data/audio/`, and writes FX meshes that are not already model GLBs to `data/fx/geometry/`. `data/fx/index.json` records the files plus workshop credits (Forgotten Enemies packs). Some `shotGeometry` names were never baked to `.msh`; those stay in `missing.geometry` and the shot falls back to its sprite or trail render. The FX gate still asserts the VSR scope only; a library weapon whose art is not in the install is listed missing and the range hides that render until the map loads.

Cockpits: the toggle loads `data/models/cockpits/<stem>.glb` when `python scripts/object-render/convert_msh.py --cockpits` has emitted one and parents it to the camera **at the origin**. The mesh is authored around the pilot's eye: `ivtank_cockpit` spans x ±1.4, y −0.84..0.89, z −2.33..0.05 with the dash piece below and ahead of the eye, and a raycast from the eye clears every direction except the dash below and one overhead strut — so the bbox center recorded in the index is informational, not an offset. Declared bank clip names are stored; the baked cockpit meshes do not carry those clips, so the range does not pretend to play them. Model textures come through `ObjectViewer._loadTexture`, with `needsUpdate` set after the lazy assignment (a `map` swapped in after material creation is otherwise ignored by three.js).

Gate: `node _investigation/check_weapon_fx.mjs` — every VSR weapon classifies and builds, every texture / sound / geometry reachable through `profileAssets` is indexed (or listed missing), every archetype is in exactly one of `AUTOFIRE` / `HOLD_ARCHETYPES`, and every `renderBase` the corpus names is in `RENDER_BASES`. It then runs the simulator headlessly (`_investigation/three-node-hooks.mjs` is a `node:module` resolve hook mapping the import map's `three` / `three/addons/` to `vendor/three/`; `fx` and `audio` are stubs that record volleys and bursts) and asserts the ODF trigger rules: the Arc Cannon's five 85-damage bolts vs H inside 0.35 s per pull, 100 ammo, a second salvo only after `shotDelay` 2.0, the salvo completing after an early release, flash bursts carrying `life 0.1`; the Gauss Gun's alternating barrels 0.25 s apart on a two-gun ship (and `ggauss_a` firing both together); Blast staying one bolt per pull; the Salvo Rkt's ten rockets each landing exactly `damageValue(H) 90` (no explosion added) and a mortar shell 300 direct or splash below it. Spread decides hits, so the headless runs draw from a seeded generator and every count is the same on every run: the table sample holding mean 0 and σ 0.183 and `randVec` scaling it per axis; Fang reading `shotVariance 0.5` and 15 rounds with a yaw spread about 0.091 rad, and `shotPitch` raising a round by exactly its angle; Flash Burst landing some bolts and missing with others at 90 m on a 1 m target, every miss ending on the floor or at its 100 m reach; MAG stage 7 (spread 0) landing all 8 rounds on a 0.5 m target at 20 m while stage 4 (0.25) scatters; the Medusa round's launch spread at the engine default 0; VSR Flare firing about 50 puffs in the second after `triggerDelay`, none more than 0.2 rad off vertical; 384 Splinter rounds over about 3.83 s and a single Resin round; and the waver phases starting at 0 with turn rates bounded by `omegaWaver`, the Swarm still homing onto the target. The class-default block checks that the range's default tables match the calculator's, fails on any invented fallback or floor creeping back into `weapon-sim.js`, and runs the laws: the Solar Flare field burning 100 vs N per second from 2 s to its 10 s life (800 in all); the Static Charge 200 vs H in 2 s at 15 m and nothing at 25 m whatever the target's size; the Arc Stream about 2,400 vs H in 2 s; the Prox Mine bursting at once for 550 vs N at 20 m and staying quiet at 24 m; the Wasp and the TAG torpedo each bursting once for 800 vs H; the Seeker drifting 36 m at 30 m/s to burst near 1.2 s; a 0.05 rad lock cone still locking a target dead ahead; an MDM shell bouncing past four times before it rests; Resin and Hellfire ending on first contact while the Splinter hops before it builds; the Flame Mine's weapon mine firing two 96-vs-H fireballs at 45 m, the first no earlier than the Wasp's 0.5 s `InitialShotDelay` and the second 3 s later, firing at 170 m but holding fire at 200 m (its `heightScale` 2 search); `InitialShotDelay` declared only on `CannonClass` / `DispenserClass` sections, the Acid Cloud's first round at 1.5 s and at 2.7 s after a release at 1.0 s and a press at 1.2 s, and the Wasp held as a weapon dropping at 0.5 s and 3.5 s; and the Snare's mines arcing the target. A database block pins the game's file order (the Flame Mine drops `fball2a` with a 20 s life, `xfbseek` deals 96 vs H, the Hadean pilot carries `egsnip_vsr_c`), so a rebuild in the old order fails. It then drives the renderer headlessly too (`createFxRuntime(new THREE.Scene(), { textures: {}, geometry: {} })` — unknown stems resolve to `null` without touching the DOM) and reads the ribbon's centreline: the Arc bolt has ~13 vertices for 80 m at `segmentLength 7`, starts on the muzzle and ends on the impact point, is byte-stable across frames, keeps every vertex within `segmentVariance 2 2 0.5` of its 7 m step on the line, two bolts never share a path, and the Gauss beam is perfectly straight with 17 vertices. Corpus-wide, picker-unaware, not in the pipeline cache key.

Tunables (all viewer-side, none published by the ODFs): `AUDIO_GAIN` / `AUDIO_REF_DISTANCE` / `AUDIO_ROLLOFF` / `AUDIO_MAX_DISTANCE` (`js/fx/odf-audio.js`); `VOLUME_KEY` / `RAVE_PEAK` (`js/weapons-range.js`); `TRAIL_SEGMENT_HZ`, `FX_HALO_RATIO`, `FX_FLARE_BASE` / `FX_FLARE_MAX`, `FX_LIGHT_SCALE`, `FX_LIGHT_POOL`, `SIM_SMOKE_DRAG` / `SIM_SMOKE_RISE` / `SIM_EMBER_DRAG` / `SIM_BOUNCE`, `EMIT_LOD_SCALE`, `FX_PARTICLE_BUDGET` (`js/fx/odf-fx.js`); `CHARGE_STEP_SEC`, `PROX_TRIGGER_M`, `FLASH_EXTRA_SEC`, `FIRE_LOOP_GRACE_SEC` (`js/fx/weapon-sim.js`; `FLASH_EXTRA_SEC` is the guide's published `+ 0.1 s` and `FIRE_LOOP_GRACE_SEC` one capped 0.05 s sim step, neither a tuning); `MIN_COOLDOWN_SEC`, `CLOSE_STOP_M` (`js/weapons-weave.js`; `TURN_SEC` there is the SDK's turn rate, not a tuning); `SWITCH_MAX_SEC`, `RIBBON_RUNS` (`js/weapons-range.js`).

Weapon-switch gate: `node _investigation/check_weapon_weave.mjs` — every VSR weapon either schedules (direct-fire archetype, finite cycle and reach, a per hit) or carries a reason, with TAG, MAG and Arc Stream refused; the Xares weave in range at one-turn switching is exactly Burst 0, Dragon Blast 0.05 / 0.35 / 0.65 / 0.95, Burst 1, … with Burst pulls of 20 pellets every 1.00 s and 3 or 4 Dragon Blasts between (2 at a 0.25 s switch); from 135 m closing at 30.5 m/s only Dragon Blast fires until the first Burst at 90 m and 1.4754 s, the kill comes later than from 60 m and earlier than holding fire until both are in range; the ISDF Tank's Blast fires every 2.0 s with 20 Chain Gun pulls between, and Blast, Mortar, Chain at 0, 0.05, 0.10 (Mortar first when listed first); the MDM stops at 4 armed shells and 0 damage against the Factory but lands 400 a shell and keeps lobbing against a Scavenger; with the Chain Gun in the set every MDM shell bursts when the Chain takes the trigger one turn later, about 57 m out (0 damage on the Factory from 60 m, and the MDM keeps lobbing past 4), bursts for its 5 (H) splash from 40 m, and lands 400 at a 1 s switch; one weapon alone matches the Scenario kill time (plus flight); a dry tank lets the Chain Gun fire on regen; and the headless simulator in weave mode fires the planned order within one 10 ms step, keeps rounds in flight when the set changes, puts 20 Burst pellets out on one tick, enforces `maxCount`, detonates on a fresh press, bursts the armed MDM shell when the Chain Gun fires, when the MDM is unticked and on a single-mode change to the Chain Gun (5 vs H from 30 m, nothing from 60 m), clears it without a burst on a re-apply of the same MDM and on Reset, and expires an armed shell after 300 s. A recording audio stub pins the fire sounds to real rounds: the Tank's Chain Gun + Blast held at 300 m plays nothing, the Chain loop starts with its first round, stops at every Blast pull and when the target leaves its reach, one Chain Gun alone plays one unbroken loop (at 10 ms and 50 ms steps), a tank under one round's cost stays silent, a tank running dry ends the loop within one cycle, and the Crapier beam (`gcrapiervsr_c`) loops while it fires. The reach gate: one Chain Gun gated at 135 m stays idle and silent at 300 m and fires on the first step inside its reach, `setWeapon()` clears the gate, an ungated Chain Gun fires at once at 300 m, and a gated MDM lobs nothing beyond its reach.

#### Community weapon packs

Weapons that are not in the stock BZCC / VSR database ship as a per-creator pack. The Weapons Lab merges them at load time so they can be mounted on stock ships and fired at stock targets. The ODF Browser does not list them. Corpus-wide, picker-unaware, not in the pipeline cache key. `scripts/process_stats.py`, `scripts/elo.py`, `scripts/elo_commander.py` and `js/all-matches-aggregator.js` never read a pack.

| Path | Role |
|---|---|
| `contrib/README.md` | How to drop a pack |
| `contrib/<id>/contrib.json` | Manifest: `schema_version` 1, `id` (must match the folder, `^[a-z0-9][a-z0-9_-]*$`), `name`, optional `url`, `description`, `received` |
| `contrib/<id>/weapons/` | The pack as received. Any subfolders. Never rewritten by the builder |
| `scripts/build_contrib_weapons.py` | Standalone builder. Not invoked by `process_stats.py` |
| `data/contrib/index.json` | `{schema_version, built_at, contributors[]}` with paths relative to `data/` |
| `data/contrib/<id>/odf.min.json` | Pack ODFs only, same 12-bucket shape as `data/odf.min.json`, inheritance and composition refs already expanded |
| `data/contrib/<id>/fx.json` | Texture / geometry / sound map plus `missing` |
| `data/contrib/<id>/textures/` , `audio/` | Assets that came from the pack itself |

`python scripts/build_contrib_weapons.py` (`--only <id>`, `--force`, `--bz2r`, `--workshop`) parses the pack with `scripts/odf/build_odf_db.py` (`parse_odf_text`, `process_inheritance`, `expand_refs`, `apply_powerup_push`, `categorize_corpus`) on top of a read-only flatten of `data/odf.min.json`. Stock names are pre-seeded as already expanded, so a pack `ordName` / `xpl*` reference inlines the stock sections and the stock file is never rewritten. An ODF basename that already exists in the stock database is a hard failure. The builder globs `weapons/` recursively and is case-insensitive; when two files share a stem, the later sorted path wins.

**`.dxtbz2` is not bzip2.** `decode_dxtbz2()` in `scripts/object-render/dds_decode.py` reads a 24-byte header — magic `!HSG`, `format` (1 = DXT1/BC1, 5 = DXT5/BC3, 3 treated as BC3 the same way DDS DXT3 is), average colour, mip count, width, height — then mip 0 as raw DXT blocks sized by a leading `u32`. It returns an RGBA image, downscaled when `max_dim` is set (the builder uses 256, the same cap as `build_fx_assets.py`).

**Asset resolution**, for every texture, sound and mesh stem the pack's weapons reach (`walk_refs` + `follow_dispensed`, the same walk as `scripts/build_fx_assets.py`):

1. A file in the pack (`.dxtbz2` / `.dds` / `.tga` / `.png`, or `.wav`) is written to `data/contrib/<id>/textures/<stem>.png` or `audio/<stem>.wav`.
2. Else an existing `data/fx/textures/<stem>.png` or `data/audio/<stem>.wav` is referenced, not copied.
3. Else the local BZ2R install and workshop tree are searched and the stock asset is written into those shared directories.
4. Else the stem is listed under `fx.json` `missing` and printed. The weapon still ships; the range draws or plays without that one asset.

Geometry is never decoded from the pack (packs ship `.xsi` names, not meshes). A stem is recorded only when `data/models/geometry/<stem>.glb` or `data/fx/geometry/<stem>.glb` already exists.

**Page merge.** `js/weapons.js` fetches `data/contrib/index.json` (404-safe: a missing index leaves the lab stock-only) and each pack's `odf.min.json` + `fx.json`. Buckets merge into the stock database with **stock winning** on a name collision (`console.warn`, the pack entry is dropped). `VTWeaponsCalc.init(db, reticles, { packs })` then exposes `packOf(stem)` and a third set on `weaponStemsFor()`, `community`. The VSR build-tree counts (56 / 28 / 3 / 123 / 85) do not move, because pack weapons are not reachable from the recycler roots. The Scenario picker adds a "Community weapons · <name>" group, fit-filtered the same way as the armory, with a creator chip (a link only when the manifest `url` is set). Pack stems render as plain text, not ODF Browser links. The Damage matrix appends the same weapons, adds a sortable Source column, and shows one toggle chip per pack. The shooting range receives the already-merged database; `mergeFxIndex` keeps stock `data/fx/index.json` entries and lets each pack `fx.json` fill gaps only; `createAudio({ urls })` plays a pack wav from `data/contrib/<id>/audio/` and leaves stock clips on `data/audio/`. The range credits line names the contributors.

Lamper (`contrib/lamper/`) is the first pack. Both gates cover the merge: `node _investigation/check_weapons_calc.mjs` (VSR counts unchanged, no stock-name collision, every pack weapon computes, the Lamper Machine Gun vs a heavy target is 7.5) and `node _investigation/check_weapon_fx.mjs` (every pack weapon classifies into exactly one trigger set, and every texture / sound / mesh it reaches is indexed or listed missing).

## 12.1 Game Explorer (`explorer/`)

A standalone page that puts a driven ship on the 3D map. It is corpus-wide, picker-unaware, and not in the pipeline cache key. Nothing it does changes a rating.

| File | Role |
|---|---|
| `explorer/index.html` | Shell, canonical topnav, import map pointing at the repo-root `vendor/three` |
| `js/explorer/main.js` | Boot, URL (`map`, `ship`, `team`, `spawn`), frame loop |
| `js/explorer/world.js` | Terrain, tiles, props, liquids and sky from `_map-analysis/render/js/` |
| `js/explorer/ship-controller.js` | Catalog mesh, turret, bank poses, team colour, lights, snipe point |
| `js/explorer/physics.js` | Hover spring, tracked / walker / pilot kinematics, collision damage |
| `js/explorer/camera.js` | Pointer-lock input, weapon-cycle queue, chase (arrow / right-drag orbit, PageUp/Down zoom), first person, free look |
| `js/explorer/combat.js` | One shooting-range sim per armed unit; aim target vs hit list |
| `js/explorer/effects.js` | ODF `effectNameN` renders, `flame_N` thrust flames, and the engine / thrust sound loops |
| `js/explorer/units.js` | Placement, hull, gun-tower slew, sphere collisions |
| `js/explorer/palette.js` | Add-object drawer: single click arms for crosshair placement, double-click / + quick-adds ahead |
| `js/explorer/economy.js` | Scrap bank and one build order per producer |
| `js/explorer/hud.js` | Radar dish, oval segmented hull / ammo "C" gauge, weapon pill rows, reticle, toast chip |
| `js/explorer/nav-search.js` | Topnav map and ship finders (combobox, pin-to-default) |
| `scripts/build_explorer_hud_assets.py` | HUD sheets, flame `splash` / `flame` art, the `bzgame_init_color` palette, and the ship sound wavs from the install |

**Frame.** The world group is mirrored on Z the same way the replay is, so ships are parented in raw engine metres. The catalog wrapper puts the nose on rig-local +X, so `applyBody` uses rotation order `YZX`: heading about Y, pitch about Z (positive = nose up), roll about X (positive = left side down; raw +Z is the craft's left after the mirror). Thrust dips the nose by `pitchThrust`; a turn banks by `rollSteer`; a strafe leans by `rollStrafe`.

**Input.** Clicking the canvas takes pointer lock; while locked the canvas carries `.is-locked` (`cursor: none`) and `js/cursor-settings.js` adds `.is-suppressed` to the BZCC cursor sprite, so the reticle is the only pointer. Horizontal mouse motion moves a desired heading and the physics turns toward it at `omegaTurn` / `omegaSpin` through `alphaSteer`, so the ship, not the camera, follows the mouse; vertical motion is aim pitch. W/S throttle; A/D strafe on hover and morph craft, steer on tracked / walker / pilot; Space jumps; LMB fires; 1-5 pick weapon groups directly. **Wheel down / up** steps to the next / previous weapon (one step per notch, `WHEEL_STEP_MS` debounce) and a **right click** (a press that moves under `RIGHT_CLICK_PX`) is next weapon; both cycle over the groups that hold a weapon (`combat.stepSlot`), so the Tank's empty Special slot is skipped. A **right drag** orbits the chase camera, as do the **arrow keys** (held, rate-based); **Home** recentres; **PageUp / PageDown** zoom the chase distance (the wheel no longer does). Arrows never steer. **`-` / `=`** step the master volume by 10%, **M** mutes and restores the previous level; the level persists in `localStorage` `vt.xp.volume` (0..100, default 50) and shows as a transient HUD chip. Q and B open the Add-object and Build drawers (which release the pointer); V enters a friendly empty hull; C cycles chase / first person / free; R respawns; Esc releases the mouse, closes a drawer, or cancels an armed placement.

**Navbar finders.** Two comboboxes in the topnav (`js/explorer/nav-search.js`, mounted at `[data-xp-finder]`). **Map** lists every stem in `data/render/_manifest.json` (the maps that have 3D terrain) joined to `data/map-registry.json` for the stripped title, author, pools, size and thumbnail. Picking one navigates (`location.assign`) with `?map=<stem>`, keeping the current `ship` and `team` and dropping `spawn`, so Back returns to the previous map. **Ship** lists every Vehicle ODF that has a model and a `drive` block of archetype hover, tracked, morph or walker (pilots and static hulls are out; VSR stems rank first, the ODF stem is the secondary line, faction chips filter). Picking one spawns that hull at the driven unit's position and heading, removes the old hull (or the pilot, if on foot), and writes `?ship=`. Each row's pin stores a default in `localStorage` `vt.xp.map` / `vt.xp.ship` (Shift+Enter does the same). Boot precedence is the URL param, then that saved default, then the site defaults `vsreuronig` / `ivtank_vsr`. A saved default that fails to load is cleared and the site default is used. Focusing a finder releases pointer lock; a ship pick re-locks in the same click.

**Placing units.** The palette works two ways. A **single click** on a card arms it, closes the drawer and re-locks the pointer; a ring (`PLACE_RING_M`) follows the reticle's terrain point, the chip reads "Click to place at the crosshair", and the next LMB spawns the unit there facing the player without firing (`rig.onPrimary` swallows the press). Esc, Q, or losing the lock disarms. A **double-click** or the card's **+** button is a quick add: the unit spawns `player radius + unit radius + QUICK_ADD_GAP_M` ahead along the nose at the same heading, terrain-snapped, and the drawer stays open. There is no free-cursor placement path any more; the cursor seen before locking was never where the ship was aiming.

**Physics.** Hover vertical acceleration is `LIFT_SPRING * (setAltitude - height) - LIFT_DAMP * vy` with gravity 12.5 cancelled at the set height. Cliff cells and steps steeper than 2 m are blocked. Unit contacts separate the mesh bounding spheres (45% for hulls, 30% for buildings) and deal the guide's `(DAMAGE_SCALE x massDifference x closingSpeed - 10) x armor x shield` once per contact, capped at `COLLISION_MAX_FRAC` (30%) of the victim's hull because the uncapped formula one-shots a Tank that taps a Gun Tower; the cap is a tunable until the engine's collision path is read.

**Weapons.** Each armed unit owns a shooting-range sim. Its hit list is every live unit (`getTargets`); its aim target (`getAimTarget`) is the enemy inside `AIM_ASSIST_RAD` of the reticle ray, else a ghost point where the ray meets the terrain, so a shot goes where the player points and lock-on weapons only ever aim at real units. **Lobbed rounds follow the reticle ray.** The player's unit carries `directLob`, which `combat.arm` passes to the sim as `lobDirect`; for a gravity round the sim then fires along `normalize(target - muzzle)` lifted by `shotPitch` instead of solving the lob (`lobAngle`, whose out-of-reach fallback was the "mortar shoots into the sky" of the first pass), and for the lobbed archetypes (`LOBBED`: mortar, popper, spray) `aimTargetFor` hands it a ghost `AIM_RAY_FAR` metres down the reticle ray rather than the terrain hit, so pointing up lobs far and pointing down drops short, as in the game. The range and gun towers do not set `lobDirect` and still solve the lob onto their target. A direct hit uses the round's own `damageValue`; a sniper round inside `killRadius` of `hp_eyepoint` ejects the pilot. Gun towers slew at `omegaTurret` with an `alphaTurret` ramp inside `yawMin` / `yawMax` and fire inside `TURRET_FIRE_ERR`.

**Effects and sound.** `effects.js` reads `GameObjectClass.effectNameN / effectHardN / effectFlagsN / effectMin|MaxVelocityN / effectMin|MaxAltitudeN` and attaches the named render (`dusttrail` under ISDF hovers, `engglow_e.small` on the Hadean tank's `flame_N` nodes, `dusttrail3` on tracked hulls) through `fx.attach`, following the hardpoint each frame. A cleared bit 9 means "only while a movement input is pressed", the way every hover dust trail is authored. `createFxRuntime` takes an optional `groundY(x, z)` so `sim_dust` lands on the terrain. `soundThrust` loops at `(11025 + 5000 x thrust) / nativeRate` with gain `0.4 + 0.4 x thrust` (the guide's `THRUST_PITCH_*` / `THRUST_VOLUME_*`); tracked hulls loop `engineSound` and `treadSound`. Native sample rates come from `data/ui/explorer/index.json` (`sound_rates`), written by the asset script, which also vendors the ship wavs into `data/audio/`.

**Engine flames.** The engine draws thrust flames itself, not through `effectNameN`, which is why the first pass showed dust but no fire. `parseFlameDefs` reads `flameNameN` / `flameTextureNameN` / `flameSpriteNameN` (N = 1..16) from any class section with the guide defaults `flame_N` / `trail.tga` / `splash.0`; every catalog GLB keeps its `flame_N` nodes (the ISDF Tank has `flame_1` / `flame_2` under the nozzles, the Hadean tank four). Each resolved node gets a streak of two crossed additive quads along node-local +Z (the rear), tapered by `FLAME_TAPER` and faded by vertex colour, textured with the vendored `trail.png`, plus a `splash.png` sprite at the nozzle; the asset script decodes `splash.dds` / `flame.dds` from `baked/Effects/Textures` into `data/ui/explorer/` (`files.splash` / `files.flame` in the index). Length is `FLAME_LENGTH_RADII` nozzle radii at full forward throttle, `FLAME_REVERSE_FRAC` in reverse, `FLAME_STRAFE_FRAC` on a strafe, `FLAME_IDLE_FRAC` idle, nothing on an unpiloted hull, smoothed at `FLAME_SMOOTH` with `FLAME_FLICKER`. The nozzle radius comes from the node's parent mesh clamped to `FLAME_NOZZLE_MAX_FRAC` of the model radius (a flame node parented to the whole hull must not read the hull as its nozzle). Tints per faction letter in `FLAME_TINT` are game VFX colouring. All `FLAME_*` are viewer tunables; the ODF carries no lengths.

**HUD.** Laid out from `bz2r_res/config/game/bzgame_weapon.cfg`: a 260 x 140 heading-up radar dish bottom-left; the StatusPanel 138 px in from the right holds the **left half of a wide oval** (`GAUGE_RX` 108 x `GAUGE_RY` 40 in the `GAUGE_W` 164 x `GAUGE_H` 90 box — with the 13 px stroke the outer box is about 1.3x as wide as tall, an elongated "C" rather than the game's taller 90 x 90 one; `GAUGE_OPEN` 90 so the arc ends at 12 and 6 o'clock, `ellipseArcPath`, `stroke-dasharray` tick pattern; `.vt-xp-gaugepanel` is the same box in scaled px, so one viewBox unit is one px) with hull on the top half in the HULLGAUGE green / yellow / red states and ammo on the bottom half in AMMOGAUGE blue, both filling from 9 o'clock out to the open ends, the numbers just right of the open ends (hull above ammo); the 128 x 70 WeaponPanel at the right edge is four 16 px **pill rows** (radius half the row height, 3 px apart) with a circular hardpoint-icon badge overlapping the left edge, the weapon name and shots left — the active row is filled gauge green with dark text, inactive rows translucent dark, an empty hardpoint dimmed with a circled-cross glyph and no name. Colours are the harvested palette as `--vt-xp-*` custom properties with theme fallbacks. `hud.toast(text)` is the transient centre chip (volume, quick add, placement). `window.__xp` is a read-only debug handle for headless checks (`units`, `world`, `combat` incl. `shotsOf(id)`, `rig`, `palette`, `economy`, `effects`, `hud`, `setVolume` / `getVolume`).

**World lighting and fog.** `world.js` lights and fogs the map the way the engine does, from the `.sky` sidecar through the shared `_map-analysis/render/js/atmosphere.js` (§12.2): `resolveAtmosphere(mapData)` → `applyEngineLights` (flat ambient + the sun on its east-west arc, `mirrorZ` for the reflected world group) → `applyTerrainMaterial` on the game-tile floor only → `attachSky` → `applyEngineFog` (exact `fogstart → fogend` linear ramp, `camera.far` at `visibilityrange`). The explorer is always in-world, so the fog is always on (`opts.fog === false` is the headless escape hatch); `syncSky()` advances the sun with wall-clock time (`updateSun`) and moves the dome's sun sprite with it. The old extent-based fog fallback and the `.TRN` fog colour are gone. **Sun shadows** (`_map-analysis/render/js/shadows.js`) are on unless `?shadows=0`: four cascades out to `camera.far`, terrain and props and ships casting. First person (`C`) hides the hull from the view and leaves it casting, so the ship still shades the ground.

`node _investigation/check_explorer_physics.mjs` checks settle height, top speed, coast-down, the cliff block, the orientation axes (thrust pitches, never rolls), analog steering, the collision formula and relative-velocity rule, the effect-flag gating and resolution of the ISDF dust trail and Hadean engine glow, the thrust sound laws, that one target through `getTargets` matches `getTarget`, that a `lobDirect` mortar round leaves within 2 degrees of a ghost 30 degrees up while the default sim lobs above it, and that the flame defaults resolve to `flame_1` / `trail.tga` / `splash.0` with `flame_1` / `flame_2` present in the Tank GLB. Browser behaviour (weapon cycling, right click vs drag, arrow orbit, volume keys, flame growth under W, quick add, crosshair placement, the HUD shapes) was verified headless over CDP against `http://127.0.0.1:8765/explorer/` with a faked `pointerLockElement`.

## 12.2 Engine-true world lighting and fog (`_map-analysis/render/js/atmosphere.js`)

**`.SKY` is the atmosphere source of truth.** Each map's `.SKY` carries the fog colour and ranges, the visibility clip, the sun's colour / intensity / hour on its arc / period, the flat ambient, the `sky.color` layer tint (+ alpha), the cloud layer, the dome assets, the star field, the sprite slots and the `sky.flags` layer switches; the `.TRN` `[NormalView]` section supplies only the terrain material (diffuse default `178 178 178`, console-confirmed), and the `.TRN` `[Light]` / fog keys are legacy data the engine does not read (the `.3d.json` `lighting` block built from them is a fallback only). `scripts/_wat_sky.py::parse_sky()` decodes the full `SKY1` block, the `FOG ` chunk, the `STAR` chunk, the `DOME` extras and the `SPRT` header + records (incl. the alpha bytes); `scripts/extract_sky.py` writes the `atmosphere` block into `data/render/<stem>.sky.json` (**schema 5**; `loader.js::loadSkySidecar` fetches it with the `?v=sky-atmo5` key). Every value was read back from the running game's console (`sky.fogcolor`, `sky.fogrange`, `sky.visibilityrange`, `sky.fogmode` 3 / `sky.fogbreak` 0.5, `sun.color`, `sun.angle`, `sun.period`, `sky.ambientcolor`, `terrain.diffusecolor`) and the sun arc was probed live (`sun.angle` 6 = east horizon, 12 = zenith, 18 = west). The byte layout, the sidecar example and the calibration numbers live in [`_map-analysis/render/README.md`](_map-analysis/render/README.md#atmosphere-stemskyjson-schema-5-and-the-lighting--fog-model); the console queries are in [`docs/reference/bzcc-console-reference.md`](docs/reference/bzcc-console-reference.md).

**Model.** `resolveAtmosphere(mapData)` resolves `.sky` atmosphere → legacy `lighting` → defaults and hands every renderer the same inputs. `applyEngineLights()` adds one `AmbientLight(sky.ambientcolor, alpha)` and one `DirectionalLight(sun.color, alpha)` — no hemisphere term — with both intensities × π because three.js r170 normalises Lambert by 1/π; `engineColor()` feeds the shader-side colour bytes (sun, ambient, dome / cloud / sprite tints) in as **linear** values (`LinearSRGBColorSpace`, no decode) because the game's textures are sRGB DDS and its shaders carry no gamma code, while `displayColor()` sets the fog colour as sRGB so it displays as its raw bytes, which is what the game shows (Remnant's fogged horizon reads `120 109 78` for `120 110 80`, Europa Night's `13 18 24` for `20 25 30`). **The fog colour is also the clear colour** (`resolveAtmosphere().clearColor`, `scene.background` on every surface): wherever no sky layer draws the game shows it — Europa Night's dome-off sky reads exactly the fully fogged value between its cloud wisps right up to 30° — and `sky.color` is only the tint of the dome and the cloud layer (its alpha the layer's opacity), never a backdrop. `sunDirectionAt()` is `θ = 2π·(angle + elapsed/3600)/period`, `dir = (sin θ, −cos θ, 0)` (Z negated for mirrored world groups; `SUN_ORBIT_AZIMUTH_DEG` = 0 is engine-true); `updateSun()` advances it per frame (match time in the replay, wall-clock in the explorer and viewer) and fades the sun out below the horizon. `applyTerrainMaterial()` multiplies the **game-tile** floor by the `.trn` diffuse (the minimap drape and height ramp are already-shaded fallbacks and keep their colour). `installLinearFog()` (runs on import) swaps the three.js smoothstep `fog_fragment` chunk for the engine's linear ramp; `applyEngineFog()` is `Fog(fogColor, start, end)` + `camera.far = max(visibility, end) × 1.02`, `clearFog()` restores 8000 m. `applyStudioLights()` keeps the pre-atmosphere stack (ambient 0.9 + hemisphere 0.85 + directional 2.0 at the TRN elevation).

**Sky layers (`sky-dome.js`, sidecar schema 5).** What the sky contains is decided by **`sky.flags`**, decoded into `atmosphere.layers` by `scripts/extract_sky.py` — one bit per editor toggle in `bzeditor_sky.cfg`, named the same way by the engine's `sky.toggle` print-out (`DOME= STARS= FLAT= CLOUDS= SPRITES= SUN=`): `1` Dome, `2` Stars, `4` Flat (the `sky.texturename` cloud plane — the legacy `.TRN` `[Sky] SkyTexture / SkyHeight / SkyColor` section), `8` Clouds (the legacy `[Clouds]` cloud-billboard system; `Count = 0` on every VSR `.TRN`, so nothing to draw), `16` Sprites, `32` Sun. The stock template every VSR `.SKY` descends from names a dome, a cloud texture, 44 sprite slots and a star field on *every* map, so the renderer gates on the flags, never on an asset being present: Remnant is `49` (dome + sprites + sun), Europa Night `54` (stars + flat + sprites + sun) with **no dome** — its template `miredome` is never drawn and its sky is the fog colour with 128 star points, the nebula sprite, the full-moon "sun" and faint cloud wisps above ~17°. **Engine verification.** The game's compiled DX11 shaders (`bz2r_res/baked/shaders/dx11_default_psh_*.fxc`, disassembled with `d3dcompiler_47.dll`; the sky has no shader of its own, every layer uses the `default` family) are `out = lerp(texel × g_MaterialDiffuse, g_FogColor, fog)`, alpha `texel.a × diffuse.a`, **no gamma step**, range fog on `|viewPos|` through the `fogbreak` two-segment ramp (0.5 = linear) plus an off-by-default height-fog term, and for lit materials `texel × diffuse × (g_LightAmbient + Σ light × (1 − (d/range)²)/(a0 + a1 d + a2 d²) × spot × sat(N·L))`; the sky textures are sRGB DDS. With the fog constant known to display as its bytes, a `texel × constant` product is a display-space product on screen, which pins the **cloud plane** tint (`displayColor(sky.color)` in `addFlatClouds`; Europa Night's wisps +11 at the 95th percentile over the fog at 28-30° against the game's +20, where the raw byte-as-linear tint gave +70). What the shaders do not settle stays a frame-matched calibration and is labelled so in the code: the dome's constants (its material is white and the DOME chunk carries `dome.ambient` / `dome.light`, so `sky.color` is probably not its tint; the raw-as-linear tint is kept for matching the in-game Remnant average), the sprite gain, and star fogging. Full derivation: README "What the engine's shaders say". `attachSky(state)` sets `scene.background` to the fog colour and builds one layer per switch: the dome GLB × `sky.color` raw-as-linear (calibrated, see above; opaque pass, depth test off so the terrain overwrites it; fullbright when the material ambient is 0; `dome.uspeed` / `vspeed` drift applied, `dome.height` decoded but not applied — `DOME_HEIGHT_SCALE` 0 until its sign is verified; the mesh's own group texture is the fallback when no `.material` or sibling `.dds` exists, which is how `banedome` gets `banesky.dds`; the game also lights the dome by `dome.light` — Remnant's east sky reads `193 184 121` against `118 121 89` west — decoded, not applied yet). Everything else needs real blending, which three.js only enables on `transparent` materials, so it draws in the transparent pass, depth-tested against the terrain, single-pass (`forceSinglePass` — a DoubleSide additive quad would be added twice), at the far edge of the rig (`SKY_FAR_FRACTION` 0.995, shells 0.96-0.99, so every hill inside the visibility range is in front): `THREE.Points` for the `STAR` chunk (`count` points of `size` metres at `distance`, per-map seeded positions since the engine rolls them at load, point size floored so the texture core covers 2 px, additive when `modulate` is 1, `stars.colora` opacity); a camera-locked, world-anchored cloud disc for `sky.texturename` at `sky.height` with `tilesize` tiling, `uspeed` / `vspeed` scroll, `sky.modulate` 1 = Add / 0 = Blend, tinted by `sky.color` as a display-space product (shader-verified) and alpha × `sky.colora` (255 on 106 skies, 50-200 on the rest); the SPRT billboards with **`size` in metres at `sprite_distance` (100 m)** — Remnant's size-40 moon spans 22.6° — on **compass azimuths** (0 north, 90 east; the scene reflects the world on Z, so `state.mirrorZ` — true in the replay and explorer, `embed || topdown` in the map viewer — points north at `-Z`) with their B,G,R,A tint bytes read correctly (the moon is `255 220 180`, not blue), **"Add" (`modulate` 1) truly additive** — the black-backed moon texture's body adds nothing and only the crescent shows, scaled by `SPRITE_ADDITIVE_GAIN` 0.135 because three.js encodes the fragment and adds it on the display canvas and the game's crescent adds `+100 +85 +80` to the `135 133 92` dome (ours `+91 +82 +75`; a plain add saturates to yellow-white) — and "Blend" (0) an alpha disc, both × `sprites.colora`; the `sun.size`-degree sun sprite (additive, full strength — Europa Night's full moon is bright in both); and a **fog skirt** reproducing the engine's sky fog — distance fog on a layer `sky.height` up, `(h / sin e − fogstart) / (fogend − fogstart)`, so the layers vanish into the fog colour below ~14° on Europa Night and are clear above ~53°, 11.5°-24° on Remnant (the cloud plane carries no fog of its own, so it is fogged once). **DDS V flip**: DDS rows are stored top-first and `CompressedTexture.flipY` is false, so sprite / sun / star textures get `repeat.y = -1` (`flipDdsV`) — without it the upside-down moon plus the authored `roll` read as a left/right mirror of the game (the lit edge is on the right in-game; the dome's MSH UVs were authored for that row order and are left alone). `syncSky()` scales the rig into `camera.far`, holds the cloud plane at its metre height, sizes the star points for the viewport and advances the texture drift. Layouts of the `STAR` / `SPRT` / `DOME` chunks and the remaining unknowns (bit 64, `dome.type` Dome vs Planet, star positions, the sprite attenuation's cause) are in the README's "Sky layers" section.

**Fog follows the camera** (decision: exact engine fog only where the camera is in the world):

| Surface | Lights | Fog |
|---|---|---|
| Game Explorer | engine | exact engine fog + visibility clip, always |
| 3D replay — chase camera | engine (In-Game Lighting on) / studio (off) | engine fog + clip while the **Fog** quality setting is on (`applyCameraFog()` in `setCameraMode`) |
| 3D replay — free / cinema / top-down | same | none, far plane 8000 m |
| Map viewer — orbit and embed | engine, sun shadows on (checkbox, `?shadows=0` off) | none |
| Map viewer — `?topdown=1` capture / batch | studio | none — the committed `data/render/topdown/*.png` stay **byte-stable** (verified: identical PNG bytes against a HEAD baseline) |

**Sun shadows** (`_map-analysis/render/js/shadows.js`, vendored three.js r170 CSM at `vendor/three/addons/csm/`). The lit DX11 shaders (`*z*` / `BZ_WANT_SHADOW`) sample four cascaded maps, pick a cascade by view depth with no blend, and multiply the factor into the sun only. `createSunShadows()` hides the atmosphere directional light (the sky sprite still follows it) and drives four cascade lights from it. Every lit material must be `prepare`d — an unprepared one would add the sun four times (`?shadowdebug=1` warns). The game-tile floor groups same-size DDS files into compressed texture arrays so the program stays within 16 samplers: five atlases plus at most four groups plus four cascades. Any new sampler on that shader must fit `5 + groups + 4 <= 16`. Terrain, props, ships and buildings cast and receive; liquids receive only. The explorer (`followFar`) shadows out to `camera.far`. The replay and the map page fit the cascades to the visible map. The map page's embed and HUD have a Shadows checkbox (`setEnabled`); `?shadows=0` and the `?topdown=1` capture do not build shadows. `SHADOW_CASCADES` and `SHADOW_FADE` match the shader; map size, the practical split, bias and the light margin are calibration.

**Pools and building cuts.** Props sidecars are schema 4. A deposit (`inheritanceChain` ending in `deposit`) is a terrain owner: the pad is flattened, the bowl cell is cut, and `props.js` draws the pad over the coplanar ground (`POOL_DEPTH_OFFSET` −2). The yellow cylinder is only the fallback (`unmeshedPools`, no deposit row within 2 m). In the replay a living catalog extractor hides that pool and sits on its row (`applyPoolOccupancy`); a box or a LEGO body leaves the pool showing, and a census or kill death brings it back. `data/render/building_cuts.json` lists the open cells of every other building ODF that declares some. `terrain-cuts.js` discards terrain inside those rects while a catalog model of that building is alive (replay only; the Explorer does not cut placed buildings). The rects are uniforms, capped at 64, so they add no sampler. The terrain's depth material discards the same fragments. A deployed building with a `terrainPatch` is seated on model y = 0; ships and mobile hulls stay on the lowest vertex. There is no runtime flatten for these buildings, so a cut on a slope may not meet the floor exactly. No pipeline, match-schema or rating change. Detail: [`_map-analysis/render/README.md`](_map-analysis/render/README.md#building-cuts-building_cutsjson).

**Replay quality settings** (`js/replay-quality.js`, `vt.replay.quality.v1`): **In-Game Lighting** (`lighting: 'engine' | 'studio'`, default `engine`), **Fog** (default **on**, chase camera only) and **Shadows** (default **on** in the High preset, **off** in Low and Medium; part of `matchPreset`, so toggling it alone reads Custom). Settings saved before the lighting switch have no `lighting` key and migrate to both defaults once (`normalize()`); settings with no `shadows` key take the matching preset's value, and a custom mix takes High's (on). `writeSettings()` must always carry `lighting` and `shadows` or a stored draft would re-migrate. All three reload the replay through the existing `onQualityStorage` / Apply path; the dashboard Settings gear mounts the same panel.

**Calibration (2.0.206 screenshots at mission time < 3 min).** Remnant: dome `137 138 103` vs in-game `146 143 100`, flat ground within 5-10% in sRGB, lit / shadowed mountain faces comparable, props visible at 300-400 m and the dome showing through at 600 m. Europa Night: the tank's cast shadow is 5-6× darker than the lit snow in linear light in both, which pins the ambient : sun ratio and confirms **sun alpha = intensity** (ignoring it would read ~175 against the game's 95-100); the ground still reads ~20% brighter than the game in sRGB, a uniform factor left unattributed rather than tuned. Lunar (black sun, white ambient) comes out flat-lit, Know Thyself under its 1.96-intensity sun, Aussault / Bolt fogged from `-30` / `-50` m — the game's look. Out of scope: local / ground fog volumes (decoded, not drawn), `sky.flags` cloud / star toggles, `STAR` starfields, terrain specular. No `PIPELINE_VERSION` / `match.schema_version` / rating interaction.

## 13. VTSR-T Methodology {#vtsr-methodology}

**VTSR-T** (VT Stats Rating — *Thug*) is the thug-focused rating: since v2.14 a **single axis, the player's share of the lobby's enemy damage** (four axes through v2.13, eight in v2.3), plus fine-tuned ELO-style updates (`scripts/elo.py`), with a per-match commander role adjustment (v2.4). The published headline **VTSR-T** is the linear blend below — with $\alpha = 0$ (current ship), **VTSR-T equals Thug ELO** for every player; the JSON wire field stays `vtsr` for a stable contract.

The rating system is pipeline-emitted, full-corpus, and time-ordered — the dashboard reads `data/processed/elo_current.json` once per session and passes ratings through the All Matches aggregator unchanged. The picker filter narrows the displayed roster only; ratings are corpus-wide.

> **v2.3 architectural rename**: the rating's combat-skill component, previously called *Combat ELO*, is now called **Thug ELO** ($R^T_i$). Sets up future VTSR-C (Commander) as a sibling rating with its own commander-axis composite. Math notation $R^C \to R^T$ throughout this section. JSON field rename `ratings[].combat_elo` → `ratings[].thug_elo` rides the existing `ELO_SCHEMA_VERSION` 3 → 4 bump.

> **v2.4 — commander role adjustment.** A single unified VTSR-T leaderboard remains the contract, but per-match commander rows now get a per-axis additive shift in **post-clip space** so commanders aren't penalized for their role. 4 audit-derived priors ride a shrunk rolling baseline; 2 hand-tuned priors are **locked** at their seed values; 2 axes are role-blind. `ELO_SCHEMA_VERSION` 4 → 5 and `PIPELINE_VERSION` 14 → 15. **Pre-v2.4 `peak_vtsr` values are no longer comparable** — full corpus re-rated. Full subsection at §13.7.1.

> **v2.5 — row-level exclusion gates.** Two new boolean flags on every per-match `leaderboard[]` row, set by [scripts/process_stats.py](scripts/process_stats.py):
> - **`is_campod`** — player spent > `CAMPOD_MAX_SHARE` (currently 25%) of match wall-clock in a camera-pod ship (any of the four player-flyable `*camr_vsr.odf` / `camerapod_vsr.odf` variants). Catches spectator-style appearances where the player effectively didn't participate.
> - **`is_low_activity`** — player's event-stream presence window (`last_event_tick - first_event_tick`) covered less than `LOW_ACTIVITY_MIN_PRESENCE` (currently 75%) of match duration. Catches late joiners AND mid-match disconnects with one window check.
>
> Rows where either flag is true are **omitted** from the rated lobby in `compute_performance_index` before z-scoring: no per-axis contribution, no `delta` entry in `elo_history`, no `matches_played` increment, no rating change at all for the affected player on that match. **Pure omission, zero penalty** — the match simply did not happen for that player rating-wise. Two new pool-level counters on `elo_current.json` (`rows_excluded_campod` / `rows_excluded_low_activity`) make the gates auditable. **No algorithm changes** — axis weights, priors, K-factor, loss aversion, floor taper, shrinkage strengths, alpha-blend all unchanged. `ELO_SCHEMA_VERSION` 5 → 6, `PIPELINE_VERSION` 15 → 16, `match.schema_version` 5 → 6. **`peak_vtsr` shifts only for the small set of players who had at least one excluded appearance** on the v2.5 re-rate; everyone else sees no change. Both thresholds are tunable module-level constants — future re-tuning is a one-line change with no schema bump required. Full per-row schema in [docs/DATA_DICTIONARY.md §10.2](docs/DATA_DICTIONARY.md#102-spectator-style-exclusion-flags-matchschema_version-6).

> **v2.7 — retired.** A parallel `elo_*_thugs_only.json` pair plus an `[All matches | Thug-only]` toggle scored VTSR-T after dropping commander rows. Product and emit are gone: one canonical `compute_elo()` pass, alt files deleted, `exclude_commanders` removed from `scripts/elo.py`, `/elo` and the dashboard always read `elo_current.json` / `elo_history.json`. Boot nukes the orphaned `vt.elo_mode` localStorage key. Canonical files may still carry leftover `excludes_commanders: false` / `rows_excluded_commander_mode: 0` until the next normal `process_stats.py` run. The v2.7-era lobby-index alignment fix in `compute_elo` (filter `lobby` with the same campod/low-activity predicate as `compute_performance_index`) remains. No `PIPELINE_VERSION` / `ELO_SCHEMA_VERSION` / `match.schema_version` bump for this retirement.

> **v2.8 — low-tier at-base lift.** Stops over-penalizing established-low-tier thugs for time spent shipless **at base** (commander didn't rebuild them). For eligible rows only, `thug_kill_rate` is recomputed over *effective* time (match minus the player's at-base on-foot "ship-denied" seconds) and the improvement is applied as an additive post-clip z-shift on that row only — the lobby mean/std stay canonical so every non-eligible player's `P_i` is byte-identical (mirrors the v2.4 commander shift). Eligibility is keyed on the **canonical (no-lift) rating** so the gate can never sustain itself: `eligibility = clip((LOWTIER_LIFT_CUTOFF − canonical_vtsr) / LOWTIER_LIFT_TAPER, 0, 1)` (defaults 1460 / 60). `compute_elo` runs two passes (canonical → eligibility → lifted). The lift is **global** — baked into `elo_current.json`, so it surfaces on the dashboard, Player Profile pages, and Tools Team Balonce. Self-closing: as a player's *genuine* skill climbs out of the low band the buffer tapers smoothly to 0 (and retroactively across their whole history, since the corpus full-re-rates each run). Defaults: `LOWTIER_LIFT_ENABLED`, `LOWTIER_LIFT_CUTOFF=1460`, `LOWTIER_LIFT_TAPER=60`, `LOWTIER_LIFT_AXIS="thug_kill_rate"`, `LOWTIER_LIFT_MIN_SHIP_MIN=2.0` (small-sample guard) — all tunable without a schema bump; `LOWTIER_LIFT_ENABLED=False` is the single-constant kill switch. Schema bumps: `PIPELINE_VERSION 20 → 21`, `match.schema_version 8 → 9` (positioning metrics gain `at_base_pilot_sec` / `at_base_pilot_share`), `ELO_SCHEMA_VERSION 7 → 8` (additive `lowtier_lift` block + per-rating `lowtier_lift_factor`). **Pre-v8 `peak_vtsr` no longer comparable** (corpus re-rated). Full derivation in §13.7.3.
>
> **v2.9 — pilot-victim kill/death exclusion.** On-foot pilot kills no longer count. A victim-only gate (`is_pilot_odf(victim_odf)`) in the `UnitDestroyed` handler drops the event from kills / deaths / `kill_rivalry` / `effective_pvp_kills` / `per_ship_combat` / `kills.by_vehicle`, so farming near-harmless ejected pilots earns nothing and dying as a pilot (artillery spam) costs nothing. The gate inspects only the victim, so a pilot who *destroys a ship* keeps full kill credit. Rows stay in `kills.feed[]` flagged `is_pilot_victim` (muted `pilot` badge). No `scripts/elo.py` axis-math change — corrected per-row `personal.*` values feed `thug_kill_rate` (effective kills) and the K/D surfaces automatically; snipes / damage / `net_damage_share` are unaffected. Schema bumps: `PIPELINE_VERSION 25 → 26`, `match.schema_version 13 → 14`, `ELO_SCHEMA_VERSION 8 → 9`. **Pre-v9 `peak_vtsr` no longer comparable** (corpus re-rated). Full derivation in §13.7.4.

> **v2.10 — snipe/T-key de-weight.** `snipe_bonus` and `target_lock_pct` cut from 0.05 / 0.04 to **0.005 each** so the two luxury/preview axes stay visible (leaderboard detail + radar still show who's dominating them) but no longer handicap strong no-frills thugs who skip them — each axis is lobby-z-scored, so *not* sniping / *not* using the T-key used to mean a negative contribution. The six core axes are untouched, so the raw `THUG_WEIGHTS` sum is now ≈0.92 (always renormalized over present axes at runtime — see §13.4 / §13.7.5). Frontend: the two axes are dropped from the Player Profile coaching cards and the Strengths & weaknesses panel is reordered heaviest-weight-first. `ELO_SCHEMA_VERSION 9 → 10` (re-rate signal only; no `PIPELINE_VERSION` / `match.schema_version` bump). **Pre-v10 `peak_vtsr` no longer comparable** (corpus re-rated). Full derivation in §13.7.5. Superseded by v2.11 (§13.7.6), which removes the axes entirely.
>
> **v2.11 — luxury axes retired.** `snipe_bonus` and `target_lock_pct` leave the composite. Keeping them at 0.005 still put them on every Elo bar and radar, and players read that as "snipes and T-key move my rating." Snipes stay on the per-match snipe feed and the Chris Kyle highlight. T-key usage is a column on Weapons & Accuracy and a career table on All Matches → Weapons & Rivalries. The Player Performance Radar keeps its T-Key spoke (that radar is not the Elo composite). `ELO_SCHEMA_VERSION 14 → 15`. **Pre-v15 `peak_vtsr` is no longer comparable.** Measured drift (2026-10-06, 45 players): max |Δ| 3.6, mean |Δ| 1.1, three adjacent leaderboard swaps (Nomad/F9bomber, Lamper/Darkvale, M.S/The_Bylarge). Spearman ρ 0.496 → 0.492; clean-win mean accuracy unchanged. Full derivation in §13.7.6.
>
> **v2.14 — enemy-damage rating.** One axis, `enemy_damage_share` = the player's `personal.enemy_dealt` over the lobby's total (`match.schema_version` 33), replaces net damage share, thug kill rate, PvE share and mobility. Every enemy target is paid the same per point of damage; friendly units, loose scrap and props no longer count. Promoted by the pre-registered rule in `critique/decisions/vtsr-t-enemy-damage.md` (no formula predicted winners from thug ratings, so the simplest one that was no worse and more consistent won). `ELO_SCHEMA_VERSION 17 → 18`. **Pre-v18 `peak_vtsr` is not comparable.** Full derivation in §13.7.9.
>
> **v19 — laid mines count.** A mine that detonates as its own entity used to book to `asset_dealt`, so `enemy_dealt` missed it (`match.schema_version` 35). It is now the layer's damage, and the kill is theirs too. `ELO_SCHEMA_VERSION 18 → 19`. **Pre-v19 `peak_vtsr` is not comparable.** Measured drift (312 matches, 48 players): max |Δ| 7.40, mean |Δ| 1.40, top-10 order unchanged. The thug-mean handicap moves VTSR-C by at most 0.43 (`CMDR_ELO_SCHEMA_VERSION` 12) and VTSR-2v2T performance by at most 0.05 (`ELO_2V2_SCHEMA_VERSION` 3).

### 13.1 Final equation

The published rating is a linear blend of two components — a **Wins ELO** ($R^W_i$) and **Thug ELO** ($R^T_i$, i.e. **VTSR-T**):

$$
\boxed{\;\mathrm{VTSR\text{-}T}_i \;=\; \alpha \cdot R^W_i \;+\; (1 - \alpha) \cdot R^T_i\;}
$$

v1 ships with $\alpha = 0$. Wins ELO is stubbed at the league anchor 1500 for every player so the blend math runs through unchanged — the day the in-game winner-attestation UI lands in `statsgate`, $\alpha$ bumps to ~0.55 (CS2 Premier-flavored split) and the dashboard text doesn't change.

Linearity is the only blend that preserves the anchor: with $R^W = R^T = 1500$ and any $\alpha$, $\mathrm{VTSR\text{-}T} = 1500$. Geometric and harmonic blends break at zero or have no closed-form interpretation in points.

### 13.2 Thug ELO derivation

> **v2 (Phase 12)** — replaces the v1 lobby-median performance baseline ($P_{\text{med}}$) with an opponent-strength-weighted expected performance ($E_i$) from a fine-tuned ELO-family logistic. Same 7-axis composite, same K-decay, same hope mechanics. See §13.7 for the migration note.
>
> **v2.1** — same algorithm shape; the rating-logistic scale was widened from $S_R = 400$ to $S_R = 800$ to restore a leaderboard-friendly spread (the v2.0 ship over-compressed every player into Tiers 3–4 only).
>
> **v2.2 (Phase 13)** — thug-axis rebalance. Dropped `asset_multiplier` (damage by player-owned AI — that's a build/route signal, reserved for a future VTSR-C commander rating), added `structure_share` (player-dealt damage to enemy buildings) and `target_lock_pct` (T-key situational awareness). Pre-v2.2 `peak_vtsr` values were no longer comparable.
>
> **v2.3** — alpha-blended thug composite + Combat ELO → Thug ELO rename.
>
> Three changes to axis math:
> 1. **Alpha-blended thug axes** ($\alpha_{\mathrm{PvE}} = 0.5$). The three "thug" axes (`thug_kill_rate`, `thug_accuracy`, `thug_efficiency`) credit PvE work at fractional weight rather than zero — a role player doing economy/utility work isn't penalized for the role choice. Locked module constant `ALPHA_PVE` in `scripts/elo.py`; surfaced as `alpha_pve` in `elo_current.json` for transparency. Tunable post-ship without a schema bump (only `PIPELINE_VERSION` to force re-rating).
> 2. **Weapon-normalized accuracy**. `thug_accuracy` replaces the flat `shots_hit/shots_fired` ratio with a per-weapon ratio against the lobby's per-weapon baseline, weighted by player's shot-share. Numerator counts "thug hits" (`pvp_hits + α·pve_hits`). Robust to weapon-mix bias — sniper mains aren't punished for the rifle's natural lower hit rate.
> 3. **Broader `pve_share` axis** replaces the narrower `structure_share`. Captures damage to all enemy non-human assets (structures + mobile AI like Scavengers, Producers, Extractors). Sources from `personal.pve_dealt`, which already excludes player-owned-AI damage by construction. Rewards both base-busters AND scrap-killers symmetrically.
>
> Architectural rename: **Combat ELO → Thug ELO** ($R^C \to R^T$) reflects that VTSR-T's combat-skill component is specifically thug-flavored. JSON field `ratings[].combat_elo` renamed to `ratings[].thug_elo`. Future VTSR-C (commander) follows the same blend shape with its own commander-axis composite.
>
> Per-axis attribution: `compute_performance_index` now returns per-axis z-scores per player, threaded into each `elo_history.deltas[]` as `axis_contributions` and aggregated into per-player `axis_means` on `elo_current.ratings[]`. Powers the VTSR-T leaderboard's per-axis breakdown popover and "Strong axes" tooltip.
>
> `ELO_SCHEMA_VERSION` bumped 3 → 4 (axis renames, JSON field rename, new attribution blocks). **Pre-v2.3 `peak_vtsr` values are no longer comparable** — the $P_i$ definition changed.

Thug ELO updates per match by

$$
\Delta R^T_{i,\text{raw}} \;=\; K_i \cdot S_O \cdot (P_i - E_i)
$$

where the **expected performance** $E_i$ is an opponent-strength-weighted logistic (fine-tuned ELO-style curve), rescaled to match the composite-performance range $[-1, +1]$:

$$
E_i \;=\; \frac{2}{1 + 10^{(\bar{R}_i - R^T_i) / S_R}} \;-\; 1
$$

and $\bar{R}_i$ is the **median** rating of player $i$'s opponents at the start of the match:

$$
\bar{R}_i \;=\; \mathrm{median}\{\, R^T_j \,:\, j \neq i \,\}
$$

| Symbol | Meaning | Value |
|---|---|---|
| $K_i$ | Per-player K-factor (decays with experience) | $\in [12, 52]$ |
| $S_O$ | Per-match outcome scale (`ELO_RATING_SCALE`) | $2.5$ |
| $S_R$ | Rating-logistic scale (`ELO_LOGISTIC_SCALE`) | $800$ |
| $P_i$ | Player $i$'s performance index in this match | $\in [-1, +1]$ |
| $E_i$ | Expected performance for player $i$ vs this lobby | $\in [-1, +1]$ |
| $\bar{R}_i$ | Median rating of player $i$'s opponents | $\geq F = 1000$ |

**Why median (not mean) for $\bar{R}_i$**: in a stratified lobby that contains a single very-high-rated outlier (e.g. VTrider at ~2700 in a lobby otherwise around 1500), the mean is pulled up so far that *every other player* looks like an underdog. Median is robust to that one outlier; the ELO update for everyone else lands close to where it would in a balanced lobby.

**Why $S_O = 2.5$ unchanged from v1**: the practical magnitude of $(P_i - E_i)$ is in the same range as v1's $(P_i - P_{\text{med}})$ once $S_R$ is set to a value where typical rating gaps map to typical performance gaps. We tested $S_R \in \{200, 300, 400, 600, 800, 1200\}$ on the corpus. $400$ was our v2.0 ship value but over-compressed the spread to ~200 pts, collapsing the leaderboard into Tiers 3 and 4 only. $800$ (the v2.1 calibrated value) lands the spread at ~440 pts so all four populated tiers (1 through 4) appear. Classic binary-outcome ELO formulations often pair a ~400-pt logistic denominator with win/loss scores; our continuous $P_i$ composite behaves differently (noisier, bounded by ~$\pm 0.7$ in practice), which makes 400 too aggressive for a ~25-player corpus. Iterating $S_R$ does NOT require a schema bump — only `PIPELINE_VERSION` to force re-rating.

**$S_R = 800$ intuition**: a 200-pt rating advantage maps to $E \approx +0.28$; a 400-pt advantage to $E \approx +0.52$; an 800-pt advantage to $E \approx +0.80$. Compare $S_R = 400$: a 200-pt advantage there already maps to $E \approx +0.52$, which over-claims the certainty of the rating signal for our small population.

**Cold start**: when every player is at the anchor ($R = 1500$), $E_i = 0$ for everyone and the formula degrades to $\Delta R = K_i \cdot S_O \cdot P_i$. No special-case handling needed.

### 13.3 K-factor decay

$$
K_i \;=\; K_{\text{base}} \cdot \left(1 - \frac{n_i}{n_i + n_{\text{prior}}}\right) + K_{\text{floor}}
$$

with $K_{\text{base}} = 40$, $K_{\text{floor}} = 12$, $n_{\text{prior}} = 10$. New players have $K = 52$; after 10 matches K halves toward 32; by 50 matches K $\approx 18.7$.

| Career stage | $K_i$ | Typical $\lvert\Delta R\rvert$ gain | Typical $\lvert\Delta R\rvert$ loss ($R \geq 1150$) |
|---|---|---|---|
| 0 matches  | 52   | ~52 | ~44 |
| 5 matches  | 38.7 | ~39 | ~33 |
| 20 matches | 25.3 | ~25 | ~22 |
| 50+ matches | ~18.7 | ~19 | ~16 |

The rookie curve ($K \approx 52$ decaying toward $K \approx 12$–16 settled) mirrors conventional provisional ladder setups used across ranked multiplayer titles — without pinning implementation to any single external sanctioning body. A rookie at $\sim 0.4$ above median climbs about 250 points in their first 5 matches; a settled vet swings $\sim 20$ pts/match.

### 13.4 Performance index

One thug axis (exported as `THUG_WEIGHTS` in `scripts/elo.py`, v2.14):

| Axis | Weight | Per-match metric (before z-score) |
|---|---|---|
| `enemy_damage_share` | 1.0 | $\text{enemy\_dealt} / \max(1, \sum_{\text{lobby}} \text{enemy\_dealt})$ — damage to enemy players, scavengers, extractors, AI units and buildings, every point the same |

`compute_performance_index` builds only the axes named in `THUG_WEIGHTS`, through the `AXIS_BUILDERS` registry. The retired axes (`net_damage_share`, `thug_kill_rate`, `pve_share`, `mobility`, `thug_accuracy`, `thug_efficiency`) and the unshipped candidate `enemy_net_damage_share` stay registered so `scripts/vtsr_t_variants.py` can replay them. `ENEMY_DAMAGE_TRIM_LATE_STRUCTURE` (default `False`) would leave out `enemy_structure_dealt_late`; the memo's trim rule kept it off. Through v2.13 the composite was net damage share 0.20, thug kill rate 0.20, PvE share 0.12 and mobility 0.08 (raw sum 0.60, renormalized at runtime); §13.7.9 explains the change.

**`ALPHA_PVE = 0.5`** (module constant, surfaced as `alpha_pve` in `elo_current.json`) now matters only to the retired `thug_kill_rate` axis and the variant harness.

**`thug_accuracy`** left the composite in v2.12. The weapon-normalized formula is recorded in §13.7.7. `_thug_accuracy_lobby` stays in `scripts/elo.py` for the reconstructed-hits candidate and is not called from `compute_performance_index`.

**`thug_efficiency`** left the composite in v2.13. The ratio was $(\text{pvp\_dealt} + \alpha_{\mathrm{PvE}} \cdot \text{pve\_to\_AI}) / \max(1, \text{total\_dealt} - \text{structure\_dealt})$, with `pve_to_AI ≈ pve_dealt - structure_dealt`. Sign agreement and the decision not to re-admit it are in §13.7.8 and `critique/decisions/thug-efficiency-retirement.md`. `_thug_efficiency_lobby` stays uncalled.

**`pve_share`** (was `structure_share` in v2.2): broadened from buildings-only to all enemy non-human damage — covers structures + mobile AI like Scavengers, Producers, Extractors. Sources from `personal.pve_dealt`, which is already `total_dealt - pvp_dealt` and excludes player-owned-AI damage by construction. Returns `None` when no player in the lobby dealt PvE damage (axis-missing → weight redistribution).

**`asset_multiplier`** was removed in v2.2 and reserved for a future **VTSR-C** (Commander) rating. Damage by player-owned AI tracks build/route quality (commander signal) rather than dogfight skill (thug signal). The underlying `assets.dealt` field still flows into `match_contributions.json` so career highlights like Puppeteer continue to work — only the ELO axis was retired.

`pickup_economy` is intentionally **not** an ELO axis (low signal, map-dependent) — pickup + destruction totals stay on `match_contributions.json` so the Career Highlights' Pod Goblin card still has data.

Each axis is computed per-player-per-match, **z-scored within the lobby** (population stdev, ddof=0), clipped to $[-2, +2]$, divided by 2 to land in $[-1, +1]$. The composite is

$$
P_i \;=\; \sum_{a \in \mathcal{A}_{\text{available}}} \frac{w_a}{\sum_{a' \in \mathcal{A}_{\text{available}}} w_{a'}} \cdot \frac{\mathrm{clip}_{[-2,+2]}(z_a(x_{i,a}))}{2}
$$

When an axis is missing for the entire lobby (e.g. pre-positioning matches lack `mobility`), its weight redistributes pro-rata to the surviving axes so $\sum w = 1$ always.

**Z-score edge cases**: when every player ties on an axis ($\sigma = 0$), the z-score is undefined; we set every player's contribution on that axis to 0. Same when $\sigma < 10^{-9}$ (numerical noise).

### 13.4.1 Assist-aware effective kills (v2.9)

Raw last-hit kills are a noisy performance signal. A high-tier player who is focus-fired and does the bulk of the damage to a target often doesn't land the killing blow — a teammate who shows up at the end "steals" full credit. An empirical pass over the corpus (100 rated matches) confirmed the scale of the problem: in **46%** of victim cases the top damage-dealer to that victim was *not* the player credited with the most kills on them, and the median finisher had personally dealt only **~29%** of their victim's received damage.

To fix this at the source (rather than by blunt-force down-weighting the axis), `thug_kill_rate` reads **`personal.effective_pvp_kills`** (`match.schema_version` 13) instead of raw `pvp_kills`. For each PvP `UnitDestroyed` of victim $V$ by finisher $K$ at tick $T$, kill credit is distributed among the shooters who damaged $V$ within the window $[T - W, T]$ (where $W = $ `EFFECTIVE_KILL_WINDOW_SEC` $\times$ `tick_rate`, default 10s):

$$
\text{credit}_s \;=\; (1 - F)\cdot\frac{\text{dmg}_{s \to V}^{[T-W,\,T]}}{\sum_{s'} \text{dmg}_{s' \to V}^{[T-W,\,T]}}, \qquad \text{credit}_K \mathrel{+}= F
$$

with finisher floor $F = $ `EFFECTIVE_KILL_FINISHER_FLOOR` (default 0.30). Credit per kill sums to 1.0, so the per-match invariant **$\sum_{\text{lobby}} \text{effective\_pvp\_kills} = \sum_{\text{lobby}} \text{pvp\_kills}$** holds — only the *distribution* changes. When no in-window damage is attributable (crush / mine / environmental), credit falls back to pure last-hit ($\{K: 1.0\}$).

Design notes:

- **Finisher floor**, not pure proportional: closing the kill still matters (the engine reports the finisher; securing a removal before the enemy repairs is a real skill in BZCC). $F = 0.30$ guarantees the finisher a meaningful slice even when they dealt little in-window damage.
- **PvE kills stay raw** — kill-stealing is a PvP-only concern, and the `rivalry_matrix` that drives the windowed credit is human-only.
- **Computed in the pipeline** (`scripts/process_stats.py::_compute_effective_kills`), not in `elo.py` — the rating module just prefers the new per-row field. Both constants are tunable without a schema bump (bump `PIPELINE_VERSION`).
- **Weights unchanged.** This release fixes attribution only; any retune of the `thug_kill_rate` *weight* is a separate, validation-gated follow-up (the original rationale for down-weighting kills — bad attribution — is now addressed at the source). `ELO_SCHEMA_VERSION` is **not** bumped (input-only change), but ratings shift so **pre-v2.9 `peak_vtsr` is no longer comparable** (corpus re-rated).

### 13.5 Hope mechanics — loss aversion + soft floor

The raw update is asymmetric: when $\Delta R^T_{i,\text{raw}} < 0$, multiply by a loss-aversion factor $L = 0.85$ AND a soft-floor taper $\varphi(R)$:

$$
\varphi(R) \;=\; \mathrm{clamp}\!\left(0,\;1,\;\frac{R - F}{W}\right)
$$

with floor $F = 1000$ and taper window $W = 150$. Final per-match update:

$$
\boxed{\;\Delta R^T_i \;=\; \begin{cases}
\Delta R^T_{i,\text{raw}} & \text{if } \Delta R^T_{i,\text{raw}} \geq 0 \\
\Delta R^T_{i,\text{raw}} \cdot L \cdot \varphi(R^T_i) & \text{otherwise}
\end{cases}\;}
$$

So a player at $R = 1000$ losing $-30$ raw drops $0$. At $R = 1075$ they drop $-30 \cdot 0.85 \cdot 0.5 = -12.75$. At $R \geq 1150$ they drop the full asymmetric $-30 \cdot 0.85 = -25.5$. Gains are unaffected.

**Loss aversion**: anchored in Kahneman & Tversky 1979 prospect theory; operational precedent in Marvel Rivals SR (~0.83), Overwatch role queue (~0.85), League of Legends demotion shielding (~0.7). Acceptable cost: ~5–8 pts/player/year of league-wide drift at our cadence. Under v2, $E_i$ self-corrects for league-wide drift because it's anchored on absolute ratings (and the median-of-opponents reference moves with the league), so $(P_i - E_i)$ stays centered without needing a separate normalization.

**Soft floor**: anchored in common ranked-floor conventions across ladder-based multiplayer titles (explicit numeric floors + gradual taper vs abrupt cliffs). The $1000$ floor is two expected steady-state stdevs below the $1500$ anchor — about one player at our scale. The $150$-pt taper covers the bottom of the wide Tier 5 band so the floor approach is gradual rather than abrupt.

A defensive `R ← max(F, R)` clamp catches float-edge drift below $F$. The taper math drives losses asymptotically to zero at the floor; the clamp is belt-and-suspenders.

### 13.6 Worked example

Real numbers from `data/processed/elo_history.json`, match `2026-05-04T03-45-41` — Lamper's 9th rated match. Re-rated under VTSR-T v2.2.

**Inputs:**

- Player: **Lamper** at $R^T = 1500.82$ with $n = 8$ rated matches played.
- Lobby opponents (sorted by `before`): $1333 / 1373 / 1385 / 1450 / \mathbf{1456} / 1483 / 1499 / 1543 / 1767$. The bolded value is the median of the *other 9 players* (Lamper himself excluded). Exact median: $1455.77$.
- Performance index: $P = +0.5435$ (top of the lobby on net damage, PvP share, and accuracy).

**Step 1 — K-factor:**

$$
K_i = 40 \cdot \left(1 - \frac{8}{8 + 10}\right) + 12 = 40 \cdot \frac{10}{18} + 12 \approx 34.22
$$

**Step 2 — opponent reference (median of the other 9 ratings):**

$$
\bar{R}_i = \mathrm{median}\{1333.32,\, 1372.60,\, 1385.28,\, 1449.74,\, \mathbf{1455.77},\, 1482.74,\, 1499.38,\, 1543.11,\, 1767.39\} = 1455.77
$$

**Step 3 — expected performance** ($S_R = 800$):

$$
E_i = \frac{2}{1 + 10^{(1455.77 - 1500.82) / 800}} - 1 = \frac{2}{1 + 10^{-0.05631}} - 1 \approx +0.0647
$$

(Lamper out-rates the lobby median by ~45 pts; with $S_R = 800$ the model expects him to score about $+0.065$ in lobby-z-score units — a soft claim, appropriate for a small rating gap in a small-population corpus.)

**Step 4 — raw delta:**

$$
\Delta R^T_{i,\text{raw}} = 34.22 \cdot 2.5 \cdot (0.5435 - 0.0647) \approx +40.96
$$

**Step 5 — gain case (no loss aversion / floor taper):**

$$
\Delta R^T_i = +40.96 \quad\Rightarrow\quad R^T_i = 1500.82 + 40.96 = 1541.78
$$

> **Algorithm progression for this match**: v1 (lobby-median baseline) produced $\approx +49$. v2.0 ($S_R = 400$, opponent-strength-weighted) compressed it to $\approx +33$. v2.1 ($S_R = 800$) produced $\approx +38$. v2.2 (8-axis composite with `structure_share` + `target_lock_pct` replacing `asset_multiplier`) lands at $+40.96$ here — Lamper's lobby had no structure damage and no T-key data, so those axes self-omit and the weights redistribute to the remaining six, slightly amplifying his net-damage / PvP / accuracy lead. The full corpus was re-rated chronologically under v2.2, so $\bar{R}_i$ (and Lamper's own pre-match rating) differ from the v2.1 worked example.

**Loss-case example** — a different player at $R^T = 1075$ posts $P = -0.30$ in a peer lobby ($K = 22$, $\bar{R} = 1075$ so $E = 0$):

$$
\Delta R^T_{i,\text{raw}} = 22 \cdot 2.5 \cdot (-0.30 - 0) = -16.5
$$
$$
\varphi(1075) = \mathrm{clamp}(0, 1, (1075 - 1000) / 150) = 0.5
$$
$$
\Delta R^T_i = -16.5 \cdot 0.85 \cdot 0.5 = -7.0
$$

Half the loss they'd take above $1150$. New rating $\approx 1068$ — still safely above floor.

**Top-player plateau example** — a $R^T = 2620$ player (v1-era VTrider rating) in a lobby with $\bar{R} = 1510$ posts $P = +0.73$ (top of leaderboard, 11 kills, 5 deaths). With the v2.1 widened logistic ($S_R = 800$):

$$
E_i = \frac{2}{1 + 10^{(1510 - 2620) / 800}} - 1 \approx +0.921
$$

The model expects them to dominate. They do dominate ($P = +0.73$), but not as completely as the rating predicted ($E \approx +0.92$):

$$
\Delta R^T_{i,\text{raw}} = K \cdot 2.5 \cdot (0.73 - 0.921) \approx -0.48 \cdot K
$$

Even on a top-fragger game, this player loses a small amount of rating. (Under v2.0's $S_R = 400$, $E$ pinned at $+0.997$ and the same match produced a slightly larger $-0.67 \cdot K$ drop.) Across many matches their rating plateaus where typical $P_i \approx E_i$ — i.e. where their rating matches their actual performance. This fine-tuned ELO-style opponent expectation prevents top players from gaining rating indefinitely just by farming soft lobbies; v2.1 lets the plateau sit ~300 pts above the median lobby instead of ~140, restoring a leaderboard-friendly spread.

### 13.7 Migration note — v1 → v2 → v2.1 → v2.2 → v2.3 → v2.4 → v2.5

| Aspect | v1 | v2.0 | v2.1 | v2.2 | v2.3 (alpha-blended thug composite + Combat ELO → Thug ELO rename) | v2.4 (commander role adjustment) | v2.5 (row-level exclusion gates) |
|---|---|---|---|---|---|---|---|
| Per-match comparison baseline | Lobby median performance ($P_{\text{med}}$) | Opponent-strength-weighted expected ($E_i$) | unchanged | unchanged | unchanged | unchanged | unchanged |
| Rating component name | Combat ELO ($R^C$) | unchanged | unchanged | unchanged | **Thug ELO** ($R^T$) | unchanged | unchanged |
| Lobby reference rating | n/a | Median of opponents' Combat ELO | unchanged | unchanged | Median of opponents' Thug ELO | unchanged | unchanged (computed across the *post-exclusion* lobby) |
| Logistic scale ($S_R$) | n/a | $400$ | $\mathbf{800}$ | $800$ | $800$ (unchanged) | $800$ (unchanged) | $800$ (unchanged) |
| Outcome scale ($S_O$) | $2.5$ | $2.5$ | $2.5$ | $2.5$ | $2.5$ (unchanged) | $2.5$ (unchanged) | $2.5$ (unchanged) |
| Composite axes | 7 | 7 | 7 | 8 | **8** (rename `kill_rate`→`thug_kill_rate`, `accuracy`→`thug_accuracy`, `pvp_share`→`thug_efficiency`, `structure_share`→`pve_share`; broaden `pve_share` to all enemy non-human dmg) | 8 (unchanged) | 8 (unchanged) |
| Alpha-blended PvE in thug axes | n/a | n/a | n/a | n/a | **$\alpha_{\mathrm{PvE}} = 0.5$** (locked default; tunable post-ship) | unchanged | unchanged |
| Weapon-normalized accuracy | flat ratio | flat ratio | flat ratio | flat ratio | **per-weapon ratio vs lobby baseline, shot-share weighted** | unchanged | unchanged |
| Commander row per-axis shift | n/a | n/a | n/a | n/a | n/a | **post-clip-space additive shift on 6 of 8 axes; re-clipped to $[-1, +1]$** | unchanged |
| Commander baseline drift | n/a | n/a | n/a | n/a | n/a | **shrunk rolling mean (strength 30) for 4 audit-derived priors; LOCKED at seed for 2 hand-tuned priors** | unchanged |
| Per-row exclusion gates | n/a | n/a | n/a | n/a | n/a | n/a | **`is_campod` (>25% campod-share) + `is_low_activity` (<75% presence window) → row omitted from rated lobby; zero penalty** |
| Per-rating `matches_as_commander` / `matches_as_thug` | absent | absent | absent | absent | absent | **added** | unchanged |
| Per-delta `axis_contributions_meta` field | absent | absent | absent | absent | absent | **added (commander rows only)** — `{axis: {z_pre_shift, shift, z_post_shift}}` | unchanged |
| Top-level `commander_axis_prior` / `commander_baseline_shrinkage` / `commander_baseline_locked_axes` / `commander_baseline_observed` | absent | absent | absent | absent | absent | **added** | unchanged |
| Top-level `rows_excluded_campod` / `rows_excluded_low_activity` | absent | absent | absent | absent | absent | absent | **added** (auditability counters on `elo_current.json`) |
| Per-row `is_campod` / `is_low_activity` / `campod_share` / `presence_window_sec` | absent | absent | absent | absent | absent | absent | **added** to `match.leaderboard[]` rows |
| K-factor decay | unchanged | unchanged | unchanged | unchanged | unchanged | unchanged | unchanged |
| Loss aversion + soft floor | unchanged | unchanged | unchanged | unchanged | unchanged | unchanged | unchanged |
| Tier ranges | unchanged | unchanged | unchanged | unchanged | unchanged | unchanged | unchanged |
| Wins ELO blend ($\alpha$) | $0.0$ | $0.0$ | $0.0$ | $0.0$ | $0.0$ (still stubbed) | $0.0$ (still stubbed) | $0.0$ (still stubbed) |
| `ELO_SCHEMA_VERSION` | $1$ | $2$ | $2$ | $3$ | $4$ (axis renames; `combat_elo`→`thug_elo` JSON field; new `axis_contributions` per delta + `axis_means` per rating; `alpha_pve` constant in top-level constants block) | $5$ (commander block on `elo_current.json`; `axis_contributions_meta` on commander deltas; `matches_as_commander` / `matches_as_thug` per rating) | $\mathbf{6}$ (`rows_excluded_*` counters; per-row exclusion fields) |
| `PIPELINE_VERSION` | $7$ | $8$ | $9$ | $10$ | $11$ (forced full re-process: new pipeline fields incl. per-weapon `pvp_hits`, `loadout`, `per_class_combat`) | $15$ (forced full re-rate to apply commander shift) | $\mathbf{16}$ (forced full re-rate to apply exclusion gates) |
| `match.schema_version` | $1$ → $2$ → $3$ | $3$ | $3$ | $3$ | $4$ (per-match leaderboard rows gain `personal.pvp_kills`/`pve_kills`/etc., `weapon_breakdown[w].pvp_hits`, `loadout`, `per_class_combat`) | $4$ (no per-match shape change; the role adjustment is purely inside the rating math) | $\mathbf{6}$ (per-row exclusion fields added to `leaderboard[]`) |
| Per-delta `axis_contributions` field | absent | absent | absent | absent | added (per-axis z-score after clip / 2) | unchanged shape; values for commander rows are now POST-shift | unchanged |
| Per-rating `axis_means` field | absent | absent | absent | absent | added (career-average axis z-scores per player) | unchanged shape; for commander-heavy players the career means now reflect post-shift z | unchanged |

**Empirical effect (v2)**: compresses the high tail (top players plateau where their typical $P_i$ matches their $E_i$, instead of climbing forever in soft lobbies) and lifts the floor (mid- and low-rated players who play in heavyweight lobbies stop bleeding rating for "average" performances they were never expected to exceed). Existing `peak_vtsr` values from v1 are no longer comparable — every player's rating history was recomputed from scratch on the first v2 pipeline run.

**Empirical effect (v2.1)**: the v2.0 ship over-compressed the spread for our small-population corpus — every established player landed within a 200-pt band (Tiers 3 and 4 only on the leaderboard). v2.1 doubles $S_R$ (400 → 800), flattening the expected-performance curve so top players plateau ~300 pts above the median lobby instead of ~140. Predicted spread widens from ~200 pts to ~440 pts, populating Tiers 1 through 4. `peak_vtsr` values from v2.0 are also no longer comparable to v2.1 — same reason.

**Empirical effect (v2.2)**: the axis swap sharpens what counts as thug work. `asset_multiplier` (damage by player-owned AI — a build/route signal) leaves the thug rating; `structure_share` (player-dealt damage to enemy buildings) and `target_lock_pct` (T-key situational awareness) enter. Direct-dogfight axes still total 0.78 of the composite, so the core fighting signal is preserved. Players who consistently bust enemy economy and play with target lock active gain a small but real boost; players who relied on AI-asset damage lose that subsidy. **All `peak_vtsr` values from v2.1 are no longer comparable to v2.2** — the composite shape changed, so historical peaks were recomputed from scratch on the v2.2 re-rate.

**Why we changed it (v1 → v2)**: working through real numbers (Lamper +49 / +45 in soft lobbies, econchump -32 / -26 in heavyweight lobbies) made the lobby-stratification failure mode visible. The v1 within-lobby z-score baseline rewarded "be the best of whoever showed up" rather than "perform above expectation for your rating." Strength-of-schedule via opponent-strength weighting is the standard ELO-family answer — that's what v2 implements.

**Why we tuned it (v2.0 → v2.1)**: shipping at $S_R = 400$ assumed our continuous $P_i$ scoring would tolerate the same logistic steepness as classic binary win/loss formulations. It doesn't — our composite is bounded at $\sim \pm 0.7$ in practice (not $\pm 1$), and our ~25-player league means even small rating gaps already pin $E_i$ near its limit. The result was a tiny 200-pt spread on the leaderboard with VTrider sitting at 1644 — mathematically defensible (he'd plateaued where his typical performance matched expectations) but visually unhelpful (only Tiers 3 and 4 populated). $S_R = 800$ is honest about the small-population uncertainty without giving up the v2 mechanism. Iterating $S_R$ does NOT require a schema bump because the JSON shape is identical (just a constant value flips); only `PIPELINE_VERSION` bumps to force the cached re-rating.

**Why we rebalanced (v2.1 → v2.2)**: VTSR-T is "individual dogfighter skill." `asset_multiplier` was always borderline — damage dealt by AI units a player owns measures how well their commander built/routed for them, not how well they dogfight. Pulling it out of VTSR-T and reserving it for a future VTSR-C (Commander) rating cleans up the signal. The freed weight went to two thug-relevant axes: `structure_share` (does the player threaten the enemy's economy?) and `target_lock_pct` (do they play with awareness of nearby threats?). `snipe_bonus` was trimmed one point alongside three other small adjustments to keep $\sum w = 1.00$. The `assets.dealt` field still flows into `match_contributions.json` so career highlights like Puppeteer keep working — only the ELO axis was retired.

**Empirical effect (v2.3)**: VTSR-T is a *thug* rating, and a thug can be effective in more ways than one. Community feedback flagged that role players in defensive/utility ships were systematically downweighted because the v2.2 composite assumed everyone has equal opportunity to do PvP. Three v2.3 changes recognize role-player effectiveness:
1. **Alpha-blended thug axes** ($\alpha_{\mathrm{PvE}} = 0.5$) — the three "thug" axes (`thug_kill_rate`, `thug_accuracy`, `thug_efficiency`) credit PvE work at fractional weight rather than zero. A pure economy-cripple now gets credit for AI kills + PvE damage they land on enemy structures.
2. **Weapon-normalized accuracy** — `thug_accuracy` compares each weapon's hit rate against the lobby's per-weapon baseline. Sniper mains stop bleeding accuracy z-score for the rifle's natural lower hit rate.
3. **Broader `pve_share`** — covers all enemy non-human damage (structures + mobile AI like Scavengers, Producers, Extractors), not just buildings. Base-busters AND scrap-killers both get credit symmetrically.

Worked example: in a 10-player lobby, a fragger doing 5k PvP / 5 PvP kills now compares evenly with an economy-cripple doing 0 PvP / 13k PvE / ~20 AI kills — the latter scores +1.5σ on `net_damage_share` and +2.0σ on `pve_share`, which combined with comparable `thug_kill_rate` performance lets them narrowly out-rate the fragger. Lobby z-scoring is the "exceeded expectations" mechanism: when one player does dramatically more PvE work than peers, it shows up across multiple axes simultaneously.

Architectural rename: **Combat ELO → Thug ELO** ($R^C \to R^T$) and **`COMBAT_WEIGHTS` → `THUG_WEIGHTS`** internally, plus JSON field rename `ratings[].combat_elo` → `ratings[].thug_elo`. Reflects that VTSR-T's combat-skill component is specifically thug-flavored. Sets up future VTSR-C as a sibling rating with its own commander-axis composite. **All `peak_vtsr` values from v2.2 are no longer comparable to v2.3** — the $P_i$ definition changed; historical peaks were recomputed from scratch on the v2.3 re-rate.

**Why we rebalanced (v2.2 → v2.3)**: A thug rating that ignores PvE work systematically penalizes role players. The minimal alpha-blend approach (`α = 0.5`) preserves PvP as the primary signal while crediting PvE work at half-weight — guards against pure AI-farming (a player doing 100% PvE with no PvP still scores lower than an equally-skilled fragger) while letting role players be measured fairly. `pve_share` (12% of composite weight) gives any "asset disruption" role its own dedicated lane. Tunability: `ALPHA_PVE` is exposed in `elo_current.json` so the constant can be re-iterated from community feedback without a schema bump (only `PIPELINE_VERSION` to force re-rating). Per-axis attribution emit (`axis_contributions` + `axis_means`) makes the composite auditable from the dashboard's VTSR-T leaderboard popover, so the community can see exactly which axes drove each player's rating.

### 13.7.1 v2.4 — commander role adjustment

VTSR-T is "individual thug skill", but the rating is computed against per-lobby z-scoring with no awareness of in-match role. The audit ([_investigation/audit_commander_bias.py](_investigation/audit_commander_bias.py)) confirmed a systematic bias: commander match-rows score $-0.115$ lower on composite $P_i$ than thug rows (~8 ELO/match), with consistent within-player evidence (median gap $+0.103$ across 13 dual-role players who play both roles in our corpus). Without intervention, frequent commanders eat a structural rating penalty for the role they're being asked to play. v2.4 fixes this with a per-axis additive shift on commander match-rows; the leaderboard contract stays a single unified VTSR-T rating with commanders included as "thugs" on the days they fragged.

**Math.** For commander row $i$ and shifted axis $a \in \mathrm{COMMANDER\_AXIS\_PRIOR}$:

$$
z'_{i,a} \;=\; \mathrm{clip}\!\left(\frac{\mathrm{clip}(z_{i,a},\, -2,\, +2)}{2} \;-\; \mathrm{baseline}[a],\; -1,\; +1\right)
$$

For thug rows (or any axis omitted from the prior), the un-shifted formula applies:

$$
z'_{i,a} \;=\; \frac{\mathrm{clip}(z_{i,a},\, -2,\, +2)}{2}
$$

The composite $P_i = \sum_a w'_a \cdot z'_{i,a}$ then feeds the unchanged ELO update. The shift is applied in **post-clip space** (after the $\div 2$ scaling) because that's the space the audit measured the priors in — applying them as raw-z shifts would be half-strength.

**Shrinkage formula (rolling baseline).** For audit-derived axes, the baseline blends the seed prior with a running mean of past commander rows' pre-shift post-clip z-scores:

$$
\mathrm{baseline}[a] \;=\; \frac{n \cdot \overline{z}^{\,\mathrm{pre}}_a \;+\; s \cdot \mathrm{prior}[a]}{n + s}, \qquad s = 30
$$

With $n = 0$ (no commander rows yet observed) the baseline equals the prior; as $n$ grows, the baseline tracks live empirical reality. Helper: `commander_shrunk_baseline(axis, running_sum, running_count)` in `scripts/elo.py`. The snapshot is built **before** each match's `compute_performance_index()` call so the running mean uses prior matches only (no in-match leakage).

For **locked** axes (`COMMANDER_BASELINE_LOCKED_AXES = {"target_lock_pct", "pve_share"}`):

$$
\mathrm{baseline}[a] \;=\; \mathrm{prior}[a] \quad\text{(exactly, no shrinkage)}
$$

The running mean is still tracked for locked axes (visibility only — surfaced via `commander_baseline_observed[a].running_mean`) so you can see when reality has diverged enough from intent to warrant a seed-value revisit.

**Asymmetric design rationale (eight axes, three groups).**

1. **4 audit-derived priors with shrinkage** — `mobility (-0.488)`, `thug_kill_rate (-0.164)`, `net_damage_share (-0.131)`, `thug_efficiency (-0.106)`. These are real structural shortfalls of the commander role (tied to base / building / not in dedicated combat ships). The seed values are the audit's empirical means; the rolling shrinkage lets live data take over the prior smoothly as the corpus grows.
2. **2 hand-tuned priors LOCKED** — `target_lock_pct (-0.10)` and `pve_share (-0.05)`.
   - `target_lock_pct`: audit said $-0.466$ (n=116), but T-key is universally available and commanders are often common targets. We pin a small cushion that deliberately under-encodes the empirical penalty — design intent is "commanders should be locking nearly as much as thugs". Locked so this intent doesn't drift toward the empirical $-0.466$ over time.
   - `pve_share`: audit said $+0.111$ (commanders naturally do more PvE because of where they spend time). We **invert the sign** to a $-0.05$ baseline so the shift becomes $+0.05$ on commander rows — hitting enemy assets is the work commanders SHOULD be doing, so we actively reward it instead of dampening it. Locked so the boost intent doesn't fade — and worse, silently flip into a dampener — as the running mean drifts. **Honesty note (June 2026 fable review (git history)):** the live empirical commander mean on this axis is now $+0.049$ ($n = 214$) — commanders already out-PvE the lobby unaided, so the lock's practical effect is a *deliberate double-reward* on an axis commanders already lead, not drift protection. Kept: the Phase 2B priors ablation measured the total cost of both locks at $\le 3.9$ ELO per player against a ~30 ELO bootstrap noise floor, and the normative intent stands. Revisit if the commander-cohort delta from the locks ever exceeds the noise floor.
3. **2 axes role-blind (omitted from the prior dict)** — `thug_accuracy` and `snipe_bonus`. `thug_accuracy`'s empirical commander mean is $+0.069$, below the noise floor at current corpus size (std $0.46$, SE ~0.04). `snipe_bonus`'s $+0.28$ is unreliable on $n = 22$ commander rows. Treat both as role-blind until the data clearly warrants an adjustment.

**Worked example — typical-commander mobility.** Take a commander posting empirical-mean mobility (raw z = $-0.976$):

| Step | Value |
|---|---|
| Lobby z-score $z_{i,\text{mobility}}$ | $-0.976$ |
| After $\mathrm{clip}(z, -2, +2) / 2$ → `z_pre_shift` | $-0.488$ |
| `commander_baseline_snapshot["mobility"]` (audit-derived, near-corpus-end) | $\approx -0.488$ |
| `shift = -baseline` | $+0.488$ |
| Post-shift = `z_pre_shift + shift` | $0.000$ |
| Re-clip to $[-1, +1]$ → `z_post_shift` | $0.000$ |
| Net impact on $P_i$ | $0 \cdot 0.08 = 0$ (was $-0.488 \cdot 0.08 = -0.039$) |

Net: typical-commander mobility now contributes $0$ to $P_i$ instead of $-0.039$. A commander who actually moves around — say `z_pre_shift = +0.2` (above-typical for a commander) — earns a post-shift $+0.688$, capped at $+1.0$; that's the "fightin' commander" extra credit the design was after.

**Path-dependence note.** Rolling baselines accumulate chronologically as `compute_elo()` walks matches in `(date, id)` order. For audit-derived priors, the baseline gradually moves from the seed value toward the live empirical commander mean as the corpus grows. Two consequences worth knowing:
- Early matches in the corpus see a slightly different effective shift than late matches; this is benign because the seed values were calibrated to the late-corpus empirical means, not zero.
- Running the pipeline with `--force` reproduces the same ratings byte-for-byte (the baseline computation is purely a function of prior matches' commander rows in chronological order). Locked priors don't drift.

**Forensic emit.** Each commander delta in `elo_history.json` carries an optional `axis_contributions_meta` sibling block alongside the existing `axis_contributions`:

```json
"axis_contributions": {
  "mobility": 0.0,
  "thug_kill_rate": -0.05,
  ...
},
"axis_contributions_meta": {
  "mobility":       {"z_pre_shift": -0.488, "shift": 0.488,  "z_post_shift": 0.0},
  "thug_kill_rate": {"z_pre_shift": -0.214, "shift": 0.164,  "z_post_shift": -0.05},
  ...
}
```

Audit invariant: for each shifted axis $a$ on a commander row, `axis_contributions[a] == axis_contributions_meta[a].z_post_shift` (because the post-shift value IS what feeds the rating math). The flat `axis_contributions` shape is unchanged on the wire, so existing JS rendering (per-axis bars, "Strong axes" tooltip) keeps working without changes.

**Why we changed it (v2.3 → v2.4)**: VTSR-T was systematically punishing players for being chosen / volunteering as commander, against the explicit design goal of "thug rating, role-fair where the role lets you fight". The audit confirmed both magnitude (~8 ELO/match) and consistency (within-player evidence across 13 dual-role players). The role-baseline z-shift ("Option A" from the algorithm-selection conversation) is the lightest-touch fix that lands the commander mean back at $\approx 0$ without requiring a separate VTSR-C rating to be designed and shipped first. VTSR-C is reserved for future work and will incorporate VTSR-T as one of its inputs.

**Empirical effect (v2.4)**: post-shift, the commander mean $P_i$ lands at $\approx 0$ (within $\pm 0.02$, with a small positive residual from the `pve_share` boost). The 4 audit-derived shifted axes' commander means converge to $\approx 0$. `target_lock_pct` keeps a residual ~$-0.366$ commander mean by design (post-clip empirical $-0.466$ + locked shift $+0.10$). `pve_share` lifts to ~$+0.161$ commander mean by design (post-clip empirical $+0.111$ + locked shift $+0.05$). `thug_accuracy` and `snipe_bonus` are unchanged at empirical means (role-blind by design). Frequent commanders gain ~50–150 ELO; thug-pure players are essentially flat. **All `peak_vtsr` values from v2.3 are no longer comparable to v2.4** — the $P_i$ definition changed for commander rows; historical peaks were recomputed from scratch on the v2.4 re-rate.

### 13.7.2 v2.5 — row-level exclusion gates

VTSR-T's per-lobby z-scoring assumes every player on the leaderboard had a chance to play. Two structural cases violate that: (1) **"campod" rows** where a player spent most of the match in a fly-cam observer ship (zero damage / zero kills against a 9-player baseline reads as the lobby floor on every axis simultaneously), and (2) **"low-activity" rows** where a player joined late or DC'd mid-match (full-match denominators against partial-match presence inflates every "share" metric in the wrong direction). Both deform z-scores for everyone else in the lobby, AND the affected player gets a deflated rating change for circumstances they didn't choose. v2.5 omits these rows from the rated lobby entirely.

**Gates** (set per-row in [scripts/process_stats.py](scripts/process_stats.py); thresholds are tunable module constants):

- `is_campod` — `True` when player spent more than `CAMPOD_MAX_SHARE = 0.25` of match wall-clock in any of `{evcamr_vsr, fvcamr_vsr, ivcamr_vsr, camerapod_vsr}.odf` (the four player-flyable camera-pod ODFs). The exact share is surfaced as `campod_share ∈ [0.0, 1.0]` for UI tooltips.
- `is_low_activity` — `True` when the event-stream presence window covers less than `LOW_ACTIVITY_MIN_PRESENCE = 0.75` of match duration. Computed as `(last_event_tick - first_event_tick) / total_ticks`. Catches both late joiners and mid-match disconnects in a single window check. The raw window in seconds is surfaced as `presence_window_sec`.

**Behavior.** Rows where either flag is true are dropped from the rated lobby in `compute_performance_index()` **before z-scoring** (see [scripts/elo.py](scripts/elo.py) at the top of `compute_performance_index`). For the affected player on that match: no per-axis z-score is computed, no contribution to lobby z-score denominators for other players, no `delta` entry in `elo_history.deltas[]`, no `matches_played` increment, no rating change at all. **Pure omission, zero penalty** — the match did not happen for that player rating-wise. The match itself is still rated (other players' deltas are computed normally against the post-exclusion lobby).

**Visibility.** The dashboard's per-match Player Leaderboard still shows excluded rows with `Campod` / `Partial` badges (`.vt-campod-badge` / `.vt-partial-badge`) and 55% opacity (`.vt-row-campod` / `.vt-row-partial`) for transparency — only the rating math skips them. The badges carry hover tooltips reading the underlying `campod_share` / `presence_window_sec` so reviewers can audit the gate's decision. The Faction Scoreboard roster omits `is_campod` players entirely (chips only — team totals, leaderboard, kill feed, and every other surface still include them).

**Auditability.** Two new pool-level counters land on `elo_current.json`:

- `rows_excluded_campod` — count of rated-match rows omitted by the campod gate (currently 13 rows across the corpus).
- `rows_excluded_low_activity` — count of rated-match rows omitted by the low-activity gate (currently 6 rows across the corpus).

Both thresholds (`CAMPOD_MAX_SHARE`, `LOW_ACTIVITY_MIN_PRESENCE`) are module-level constants in [scripts/process_stats.py](scripts/process_stats.py) — re-tuning is a one-line change with no schema bump (only `PIPELINE_VERSION` to force a cached re-rate).

#### 13.7.2.1 Worked example (v2.5 Domakus)

Real numbers from `data/processed/elo_history.json`, match `2026-05-08T23-46-02` — Domakus's rated match in mid-corpus. Recent, 10-player rated lobby, no excluded rows.

**Inputs:**

- Player: **Domakus** at $R^T = 1689.81$ (high Tier 2), $n \approx 28$ prior rated matches.
- Lobby reference: $\bar{R}_i \approx 1500$ (median of the other 9 players' Thug ELO).
- Performance index: $P_i = +0.5665$ (top of the lobby — strong on net damage, kill rate, accuracy, efficiency, mobility, and T-key).

**Step 1 — K-factor:**

$$
K_i = 40 \cdot \left(1 - \frac{28}{28 + 10}\right) + 12 \approx 22.53
$$

(Settled-veteran K — half the rookie K.)

**Step 2 — expected performance** ($S_R = 800$, Domakus out-rates the lobby by ~190 pts):

$$
E_i = \frac{2}{1 + 10^{(1500 - 1689.81) / 800}} - 1 \approx +0.2667
$$

The rating system expects Domakus to score about $+0.27$ in lobby-z-score units. He scored $+0.567$ — clearly above expectation but not blowout-above (the bar was high to start with).

**Step 3 — delta:**

$$
\Delta R^T_i = 22.53 \cdot 2.5 \cdot (0.5665 - 0.2667) \approx +16.89
$$

**Result:** $R^T = 1689.81 \to 1706.70$.

Domakus is a thug-row in this match (his career split is $43$ thug / $4$ commander), so the v2.4 commander shift doesn't apply. v2.5's row-level exclusion gates also don't apply — every row in this lobby cleared both gates. The same K / $E_i$ / blend math from §13.1–§13.5 applies; v2.5's contribution is purely *which rows enter the lobby z-score denominators in the first place*. If a campod row had been present in this lobby, it would simply not appear in the median (Step 2's $\bar{R}_i$) or in any axis's z-score denominators (Step 1's $P_i$).

**Why we changed it (v2.4 → v2.5).** Two regressions in the v2.4 corpus pointed at the same root cause: (1) campod rows scoring extreme z-scores on every axis (zero damage / zero kills against a 9-player baseline reads as the lobby floor across the board), polluting other players' z-scores by deforming the per-axis means and stdevs; (2) late-joiners taking large losses for "underperforming" against full-match denominators (a player who joined at the 60% mark naturally lands near zero on every share-of-total-match metric). Adding row-level gates is strictly less invasive than per-axis adjustment: the affected player gets exact rating-preservation rather than a synthetic correction, and the unaffected players' z-scores are computed against a cleaner baseline. The two thresholds (25% campod-share, 75% presence window) are deliberately conservative — they catch the obvious cases without false-positiving short partial campod usage or a player who dropped for the last 10% of a long match.

**Empirical effect (v2.5)**: 13 campod + 6 low-activity rows omitted across the 84-match rated corpus (~2% of rated rows). `peak_vtsr` shifts only for the small set of players who had at least one excluded appearance (their corpus is shorter, and their adjacent matches' lobby compositions changed); the rest of the corpus is byte-identical to v2.4. **`peak_vtsr` values from v2.4 are no longer comparable to v2.5 for affected players** — the v2.5 re-rate recomputed every player's history chronologically.

### 13.7.3 v2.8 — low-tier at-base lift

**Problem.** Low-tier players are frequently left shipless by their commander ("not worth the economy"). They sit on foot at base, unable to deal damage or get kills, yet `thug_kill_rate` and the share axes still divide by the *full* match — so denied bench-time deflates their score for a circumstance they don't control. This is a self-reinforcing trap (low rating → denied ships → can't show skill → stays low). Validated end-to-end in `_investigation/` (Audits A/C/D/E) and `_validation/`.

**At-base denial signal.** The pipeline's positioning pass ([scripts/process_stats.py](scripts/process_stats.py) `_compute_positioning`) emits, per player, `metrics.at_base_pilot_sec` = seconds spent **on foot (pilot ODF) AND within `personal_base_radius` of spawn** (the same Garden-Gnome base region used by the mobility axis). On-foot time *out in the field* (walking back from a death) is deliberately NOT counted — only "positioned to be rebuilt a ship but still shipless" time. `is_pilot_odf()` flags samples via the `user_m` ODF substring.

**The lift (additive z-shift, eligible thug rows only).** In [scripts/elo.py](scripts/elo.py) `compute_performance_index`, after the canonical per-axis z is computed, for each non-commander row with `eligibility > 0` and `at_base > 0`:

$$\text{kr}_{\text{eff}} = \frac{\text{pvp\_kills} + \alpha_{\text{PvE}}\cdot\text{pve\_kills}}{\text{minutes} - \text{at\_base}/60}, \quad z_{\text{eff}} = \frac{\text{clip}(\frac{\text{kr}_{\text{eff}} - \mu}{\sigma}, -2, +2)}{2}$$

where $\mu, \sigma$ are the **canonical (full-time)** lobby kill-rate mean/std. Since v2.14 `LOWTIER_LIFT_AXIS` is `enemy_damage_share` and the effective value is the player's share scaled by $\text{minutes} / (\text{minutes} - \text{at\_base}/60)$ against the unchanged lobby total; the kill-rate form above stays in the code for the v17 replay. The row's post-clip z becomes $z' = \text{clip}(z_{\text{full}} + (z_{\text{eff}} - z_{\text{full}})\cdot\text{factor}, -1, +1)$. Because $\mu, \sigma$ and every other row are untouched, non-eligible players' $P_i$ is byte-identical to canonical. A small-sample guard (`LOWTIER_LIFT_MIN_SHIP_MIN = 2.0` effective minutes) prevents a benched-all-match player with one lucky kill from minting an outlier rate.

**Eligibility (two-pass, gated on canonical skill).** `compute_elo` factors the rating loop into `_rating_pass` and runs it twice:

1. Pass 1 — `lowtier_eligibility=None` → canonical (unbuffered) ratings → `final_map`.
2. `eligibility[key] = clip((1460 − canonical_vtsr) / 60, 0, 1)`.
3. Pass 2 — re-rate with that eligibility → published ratings.

Keying on the *canonical* rating (not the lifted one) is the load-bearing stability property: the buffer can never sustain its own eligibility (no feedback loop / no oscillation). As a player's genuine skill rises, eligibility tapers linearly to 0 by VTSR 1460 and the buffer vanishes smoothly — retroactively across their whole history, since eligibility is one scalar applied to all their thug rows and the corpus full-re-rates every run. The buffer is never "banked." Mid/high players are never eligible (factor 0); their only residual movement is a sub-noise (≤~1.3 ELO) second-order shift from facing a boosted opponent in the `E_i` median.

**Scope + outputs.** Global (baked into canonical `elo_current.json`): dashboard, Player Profile pages, Tools Team Balonce. `elo_current.json` gains a top-level `lowtier_lift` block (`enabled` / `cutoff` / `taper` / `axis` / `min_ship_minutes` / `eligible_count`) and a per-rating `lowtier_lift_factor`. Composes orthogonally with locked-priors and expected-performance modes.

**Empirical effect.** On the validated corpus: ~10 eligible bottom players gain meaningfully (Monkey / judgeguns ≈ +15–19 ELO), every mid/high player moves ≤ ~1.3 ELO, leaderboard order is preserved (rank corr ≈ 0.999), and predictive quality dips mildly and acceptably (validator Spearman ρ 0.469 → ~0.452, synthetic-winner agreement unchanged at 90.3%). The lift is *productivity-bounded*: a player who pilots a lot but does little with the ship he gets (e.g. Darkvale ≈ +7) gains less than a more lethal peer (Monkey), because the lift only de-dilutes a rate that still has to be earned. **Pre-v8 `peak_vtsr` is no longer comparable** — the corpus is re-rated.

**Scope note (June 2026 fable review (git history)).** At the current rating distribution the 1460/60 gate captures **10 of 35 rated players (~29% of the league)** — in practice a *below-median assistance band* rather than a bottom-tier safety net. Broader than the original "established low-tier" framing, but kept as-is: the lift is small, self-closing, and feedback-proof via the two-pass canonical gate. Revisit triggers: re-examine `LOWTIER_LIFT_CUTOFF` when the eligible share exceeds ~1/3 of rated players or falls below ~1/10 (either direction means the band has drifted from the population it was tuned against).

### 13.7.4 v2.9 — pilot-victim kill/death exclusion

**Problem.** Ejected on-foot pilots (`*suser_m.odf`) are near-harmless, so two distortions crept into the kill ledger: (1) players could farm trivial-but-real kill credit on enemy pilots, and (2) dying as a pilot — frequently to uncontrollable artillery spam during the ejection window — imposed unearned death penalties. Both inflate / deflate `thug_kill_rate` and every K/D-derived surface.

**The gate (victim-only, pure omission).** In [scripts/process_stats.py](scripts/process_stats.py) the `UnitDestroyed` real-vehicle branch applies one more check before any accumulator runs: if `is_pilot_odf(victim_odf)` (substring `user_m`), the event is dropped from `player_kills`, `player_deaths`, `kill_rivalry`, `per_ship_combat`, `pvp_kill_log` (so it never enters `effective_pvp_kills`), and `vehicle_destruction_count` (so it never appears in `kills.by_vehicle`). Killing and dying are the *same event* viewed from two sides, so the single gate fixes both at once.

**Crucially the gate inspects only the victim.** A pilot who *destroys a ship* (pulse rifle, or a low-damage sniper finish during the enemy's ejection window) produces an `UnitDestroyed` whose `victim_odf` is a **vehicle** — it flows through normally and the on-foot killer keeps full credit (attributed to their pilot ODF in `per_ship_combat`, i.e. their Loadout). This is the intended dogfight-finish scenario. Pilot-vs-pilot kills are excluded (victim is a pilot regardless of killer).

**Transparency.** The event is still appended to `kills.feed[]` with `is_pilot_victim: true`; the dashboard's kill feed renders a muted `pilot` badge (`.vt-pilot-badge`). ODF registration (`odf_map`) and faction-detection votes still run.

**No axis-math change.** `scripts/elo.py` is untouched except the version bump + comment — the corrected per-row `personal.{kills,deaths,pvp_kills,pve_kills,effective_pvp_kills}` values flow into the existing `thug_kill_rate` (effective kills) and K/D-derived axes automatically. Snipes (`UnitSniped`, separate event, vehicle `victim_odf`), all damage buckets, and `net_damage_share` are deliberately unaffected (damage to/from/as a pilot remains fully counted — pilots can chip the last HP off a ship, and a suspected-sniper pilot is a legit target). Schema bumps: `PIPELINE_VERSION 25 → 26`, `match.schema_version 13 → 14`, `ELO_SCHEMA_VERSION 8 → 9`. **Pre-v9 `peak_vtsr` is no longer comparable** — the corpus is re-rated. Full classification context: [docs/DATA_DICTIONARY.md §8.1](docs/DATA_DICTIONARY.md).

### 13.7.5 v2.10 — snipe/T-key de-weight

**Problem.** `snipe_bonus` (0.05) and `target_lock_pct` (0.04) are convenience/luxury behaviors, not core thug skill. Because each axis is z-scored against the lobby, a player who simply *doesn't snipe* or *doesn't toggle the T-key* lands below the lobby mean and eats a **negative** contribution — i.e. strong no-frills thugs (e.g. Sev, blue, who dominate the heavy axes) were quietly handicapped for skipping the two least-important signals. A smoke-test re-rate confirmed dropping the two axes lifts exactly those players (Sev +12.6, blue +15.5, econchump +21) with no top-of-board inversions.

**The change (config only).** Cut both weights to **0.005** each in `THUG_WEIGHTS` ([scripts/elo.py](scripts/elo.py)); the six core axes are untouched. The axes stay in the composite — and in `elo_current.json.weights`, the leaderboard detail panel, and the Player Profile radar — so the *dominance preview* ("who's sniping / locking the most") survives, but at ~0.5% effective weight each (~1% combined) they can no longer make or break a rating. A genuine no-frills thug is no longer penalized for performing well only on the bigger axes.

**Weights no longer sum to 1.00 — and that's fine.** Leaving the six core axes alone makes the raw dict sum to **≈0.92**. The composite has *always* renormalized over the axes present in each lobby (`weights[a] = THUG_WEIGHTS[a] / \sum_{\text{available}} w`), so this is mathematically identical to the analyzed smoke test: each axis's effective weight is `raw / 0.92` with all eight present (the six core axes pick up a proportional ~8.7% uplift as a pure side-effect; snipe / T-key land at ~0.5% effective each). Editing only the two knobs is the most honest representation of "we only de-weighted the luxuries."

**Frontend follow-through.** The two axes are removed from the Player Profile "Performance observations" coaching cards (nothing actionable to coach at ~0.5% weight) and the "Strengths & weaknesses" panel is reordered by **axis weight** descending (heaviest signal first) so `Snipe Bonus` / `T-Key Usage` always sit at the bottom — see [js/player.js](js/player.js).

**No axis-math, priors, or output-shape change.** `ELO_SCHEMA_VERSION 9 → 10` is purely a re-rate / comparability signal (the `weights` field *values* change and the corpus is re-rated); the JS reader never branches on it. **No** `PIPELINE_VERSION` / `match.schema_version` bump — ELO recomputes unconditionally every pipeline run and the per-match JSON is unchanged. **Pre-v10 `peak_vtsr` is no longer comparable.**

### 13.7.6 v2.11 — luxury axes retired

**Problem.** v2.10 left `snipe_bonus` and `target_lock_pct` in the composite at 0.005 so the bars could still show them. Players kept reading those bars as rating causes. The match already has a snipe feed and a Chris Kyle highlight, and T-key usage is a plain percentage that does not need a z-score.

**The change.** Both keys leave `THUG_WEIGHTS`, `compute_performance_index`, and the locked commander prior (`COMMANDER_BASELINE_LOCKED_AXES` is now `{"pve_share"}`; `thug_accuracy` stays role-blind). The six remaining raw weights are unchanged and sum to **0.91**; runtime renormalization still applies, so each core axis gains about 1.1% effective weight versus the v2.10 eight-axis mix. `axis_means` and `axis_contributions` carry 6 keys. `ELO_SCHEMA_VERSION 14 → 15`. No `PIPELINE_VERSION` or `match.schema_version` bump — per-match JSON is untouched, and ratings recompute every run. **Pre-v15 `peak_vtsr` is no longer comparable.**

**Where the stats went.** Snipes stay on the per-match snipe feed and the Chris Kyle highlight. T-key usage is a column on the per-match Weapons & Accuracy table (`#accuracy-table`, joined from the unfiltered `positioning` block) and a sortable Career Accuracy & T-Key table on All Matches → Weapons & Rivalries. The Player Performance Radar (`js/charts-radar.js`) keeps its T-Key spoke; that radar is not the Elo composite.

**Measured drift (2026-10-06, 45 players).** Max |ΔVTSR-T| 3.6 (Darkvale 1529.3 → 1532.9), mean |Δ| 1.1, mean signed +1.0. Three adjacent leaderboard swaps: Nomad 1617.2 → 1616.4 with F9bomber 1616.1 → 1618.9; Lamper 1530.8 → 1531.1 with Darkvale; M.S 1456.0 → 1456.9 with The_Bylarge 1455.6 → 1457.5. Validator: Spearman ρ 0.496 → 0.492, self-consistency 0.917 → 0.916, clean-win mean accuracy unchanged.

### 13.7.7 v2.12 — accuracy axis retired, hits reconstructed

**Problem.** `record_bullet_hit` returns unless both the shooter and the victim are players. `BulletInit` still counts every round a player fires. A chain gun or Plasma Stream fired at an extractor is a fired round, a `DamageDealt` row, and not a `BulletHit`. `thug_accuracy` divided human hits by every round fired, so economy fire was a miss. Sign agreement on determined matches was 39.5%.

**The rating change.** `thug_accuracy` leaves `THUG_WEIGHTS`. Five axes remain and the raw dict sums to **0.76**. Runtime renormalization still applies. `ELO_SCHEMA_VERSION 15 → 16`. **Pre-v16 `peak_vtsr` is no longer comparable.** `_thug_accuracy_lobby` stays in `scripts/elo.py` for the candidate in `critique/decisions/accuracy-recon-axis.md`. It is not called from `compute_performance_index`.

**Damage normalization.** Before collector commit `103284d` (2026-08-30), each damage event was written once per game world. `r = DD_pvp / BH_pvp` is 1.0 on later v4 matches and about 2.15 before that. `match.event_calibration.damage_scale` is `1/r` when `r >= 1.5`. It is applied after the sentinel filter and in the timeline recompute. See `critique/decisions/damage-multiworld-dedup.md`.

**Reconstructed hits.** `pve_hits_est = min(non-human damage events / r, shots − human hits − self hits)`. `accuracy_est` is that total over rounds fired. Human hits stay exact. The UI marks the estimate with `~`. `PIPELINE_VERSION 55 → 56`, `match.schema_version 31 → 32`.

### 13.7.8 v2.13 — efficiency axis retired

**Problem.** `thug_efficiency` is `(pvp_dealt + 0.5 * (pve_dealt − structure_dealt)) / max(1, dealt − structure_dealt)`. It was 0.16 of the raw weight sum 0.76, about 21% of the index. Spending damage on scavengers and bases lowers the ratio versus pure player damage, so the winning side scored lower on it. Stored sign agreement on 188 determined matches was 38.8% (Wilson CI 32.2–46.0%, mean post-clip gap −0.069). A raw-z recomputation on the same lobbies matches the rate and puts the mean gap at −0.14. On v4, where `structure_dealt` is real, agreement is 38.6% on 101 matches (CI 29.7–48.4%). On v1, where `structure_dealt` is always 0, it is 26.8% on 41 matches. The ranking is unrelated to volume (Spearman versus net damage −0.04).

**The rating change.** `thug_efficiency` leaves `THUG_WEIGHTS`, `compute_performance_index`, and `COMMANDER_AXIS_PRIOR` (the −0.106 shrinking prior leaves with it). Four axes remain and the raw dict sums to **0.60**. Runtime renormalization gives net damage and kill rate 33.3% each, PvE share 20%, and mobility 13.3%. `ALPHA_PVE` stays 0.5 and applies only to `thug_kill_rate`. `pve_share` still credits every point of non-human damage. `ELO_SCHEMA_VERSION 16 → 17`. No `PIPELINE_VERSION` or `match.schema_version` bump. **Pre-v17 `peak_vtsr` is no longer comparable.** `_thug_efficiency_lobby` stays in `scripts/elo.py` and is not called. It is not a re-admit candidate. Memo: `critique/decisions/thug-efficiency-retirement.md`.

**Damage per minute was not added.** Lobby z-scoring cancels the shared match clock, so damage per minute is damage dealt. That ranking agrees with the winner 88.3% and correlates 0.89 with net damage share inside a lobby. Net damage already carries the volume signal and also subtracts damage taken.

**Measured drift (2026-10-12, 47 players, 247 matches cached).** Max |ΔVTSR-T| 82.0 (dd 1497.9 → 1579.9). Next largest: blue +77.0, Totokomo +72.8, judgeguns +59.0, AussieLag +54.5, Lamper −41.6, Nomad −41.1. Mean |Δ| 22.7, mean signed +8.9. Mean rating 1566.6 → 1575.5. 43 of 47 players changed rank; the largest moves were Lamper 22 → 34 and dd 32 → 20. Validator versus the v2.12 run earlier the same day: Spearman ρ 0.412 → 0.275, self-consistency 0.787 → 0.672, calibration MAE 0.015 → 0.030, clean-win mean accuracy 57.2% → 55.1%, clean-win hard-max 57.8% → 58.3%, log-loss 0.682 → 0.682, synthetic-winner agreement unchanged at 92.0%. The Spearman drop trips the validator's 0.03 warning. The four remaining axes still agree with the winner: mobility 88.8%, net damage 87.8%, kill rate 85.4%, PvE share 78.7%.

### 13.7.9 v2.14 — enemy-damage rating

**Problem.** The four-axis composite read as anti-PvE to players: PvE kills counted at half on kill rate, and players with a PvE-heavy damage mix rated lower (career Spearman −0.81 between PvE share and VTSR-T). Measured in the real scorer the opposite held per unit: damage to AI earned about 2× the score of damage to a player (through `pve_share`), and a scav kill scored 1.21× a tank kill. The ranking pattern came from volume: every thug's PvE is a similar 6–9% of lobby damage, and extra PvP is what separated players. Meanwhile `dealt` / `pve_dealt` never checked the victim's side, so 16% of "PvE" was friendly AI and buildings, loose scrap and props. Kill rate correlated 0.72 with damage dealt and missed bail-outs and snipes (about 6.5% of ship losses produce no kill or death); mobility correlated 0.62 with damage dealt and was unrelated to PvE share (r = +0.01).

**Input (rating-inert, `PIPELINE_VERSION 56 → 57`, `match.schema_version 32 → 33`).** `_damage_category()` in `scripts/process_stats.py` buckets every player-dealt damage event by shooter side vs victim side, and `leaderboard[].personal` (and `bench_prefix.personal`) gains `enemy_dealt`, `enemy_received`, `enemy_pvp_dealt`, `enemy_pve_dealt`, `enemy_structure_dealt_late`, `friendly_pvp_dealt`, `friendly_ai_dealt`, `noteam_scrap_dealt`, `noteam_other_dealt`. The six `*_dealt` categories plus `self_dealt` partition `dealt`. Gate `_investigation/golden_enemy_damage_inert.py` (ratings byte-identical, partition holds on every row) passed before the rating change. Corpus split: enemy AI and structures 53.4%, enemy players 34.9%, loose scrap 5.0%, friendly AI 3.1%, props / no team 2.2%, self 0.8%, friendly players 0.6%.

**Decision (pre-registered).** `scripts/vtsr_t_variants.py` replays `compute_elo` in memory per variant and scores each on 191 decided matches with pre-match ratings only: thug-gap AUC with a paired bootstrap against v17, log-likelihood gained over the commanders' VTSR-C gap, and split-half reliability. No variant predicted winners (AUC 0.51–0.54). The memo's rule walked the candidates simplest-first and promoted **enemy damage dealt only**: AUC 0.527 (diff −0.000, 95% CI −0.034 to +0.033), gain over commanders 1.65 vs 1.34, reliability 0.951 vs 0.878. Late building damage was 8.3% of the winners' enemy damage, under the 10% trim threshold. Forward check: after 40 decided matches dated after 2026-10-24, run `python scripts/vtsr_t_variants.py --since 2026-10-25` and revert if the shipped AUC falls more than 0.05 below the v17 replay.

**The rating change.** `THUG_WEIGHTS = {"enemy_damage_share": 1.0}`. `COMMANDER_AXIS_PRIOR = {"enemy_damage_share": -0.171}` (the observed commander running mean over 508 rows, shrinkage 30); `COMMANDER_BASELINE_LOCKED_AXES` is empty (the locked `pve_share` prior left with its axis). `LOWTIER_LIFT_AXIS = "enemy_damage_share"` (§13.7.3). `axis_means`, `axis_contributions` and `weights` carry 1 key. `ELO_SCHEMA_VERSION 17 → 18`. **Pre-v18 `peak_vtsr` is not comparable.**

**Measured drift (2026-10-24, 47 players).** Mean |ΔVTSR-T| 62.6, mean signed +30.3, mean rating 1585.5 → 1615.8, spread (std) 108.7 → 176.9. Largest moves: VTrider +309.9 (1844.6 → 2154.5), Domakus +199.5, Cloaket +170.3, Snake +157.0; DesUxSAsU −122.1, Totokomo −81.0, The_Bylarge −71.5. 41 of 47 players changed rank. Tiers 1/2/3/4 (then 150 points wide) held 2/12/19/14 players, now 7/9/11/20; the same day the ladder was re-spaced to six 200-point tiers (§13.8). The change correlates −0.79 with career PvE share: dropping `pve_share` removes the PvE bonus it carried, so PvE-leaning, lower-volume players move down. Validator vs the v17 run: Spearman ρ 0.290 → 0.423, self-consistency 0.734 → 0.851, calibration MAE 0.026 → 0.023, clean-win mean accuracy 57.0% → 56.1%, hard-max 57.8% → 54.8%, log-loss 0.681 → 0.678, synthetic-winner agreement unchanged at 92.6%, bootstrap σ 51 → 68 points (wider spread, same relative noise). `enemy_damage_share` agrees with the winner in 92.2% of 231 decided matches (measured inside the match, not a prediction).

**PvE premium.** `scripts/study_pve_denial.py` measured the pool income an enemy loses per kill on 144 telemetry matches (regen 12.8 scrap per pool per minute over 31.4 base). An extractor kill costs about 28 scrap in 2 minutes and up to 79 in 5 (an upper bound), against the 1.43× tank credit its 5000 HP earns under 1:1 pay; a scavenger kill costs 8–43 scrap of income on top of its 20-scrap price, against 0.86×. No premium above 1:1 is proposed.

**Frontend.** Axis lists render from `elo_current.weights`. Player-page radars fall back to bars below three axes (`VTSR_RADAR_MIN_AXES`, `.vt-axis-bar-median` marks the corpus median). The ELO page's axes pill is now **What counts**; the explainer (`js/vtsr-explainers.js` `AXES` / `RETIRED_AXES_NOTE`) and every axis label map carry the one key.

### 13.8 Tier ladder

Numeric labels (Tier 0 — Tier 5), no flavor names. Six tiers, **200 points wide**, anchored at 1500 (the start rating is the bottom of Tier 3). Tier 0 is open above 2100; Tier 5 runs from the 1000 floor. The ladder had five 150-point tiers until 2026-10-24: the v2.14 single axis widened the rating spread (std 109 → 177) and pinned the top player past the end of the Tier 1 bar, so the lines were re-spaced (display-only; ratings untouched). If a rating passes 2300 the Tier 0 bar pins again (`TIER_0_TRACK_TOP` in `scripts/vtsr_bands.py`); re-space then.

| Tier | Badge | VTSR-T range | Width | What it takes |
|---|---|---|---|---|
| 0 | 0   | $\geq 2100$  | open  | Top of the ladder. Sustained dominance of the lobby's enemy damage across many matches. |
| 1 | I   | 1900 – 2099 | 200   | Consistently well above the lobby. |
| 2 | II  | 1700 – 1899 | 200   | Above-median performance. |
| 3 | III | 1500 – 1699 | 200   | League anchor. Every fresh player starts at the bottom of this band. |
| 4 | IV  | 1300 – 1499 | 200   | Below median, still rated. Loss aversion buffers the slide. |
| 5 | V   | 1000 – 1299 | 300   | Training band. Floor at 1000; losses taper to zero as you approach it. |
| — | ?   | Provisional | —    | $5 \leq \texttt{matches\_played} < 10$. Shown but flagged "rating still settling". Marked `tier: null` / `data-tier="provisional"` (Tier 0 is a real tier). |

**Display: bands, never numbers.** VTSR-T is a **thug form rating**, not a win/loss Elo: each match grades four axes against that lobby, and the rating moves by how far the grade beat or missed the prediction for the player's current rating. One game moves it by 10–25 points against a bootstrap noise σ of about 51, so **no user surface shows a VTSR-T number** (absolute rating, peak, team mean, team gap, sum, before → after, or per-game change). The math is untouched — Balonce, Tools Suggest and every sort keep reading the raw values.

- **Bands.** Each tier splits at its midpoint into an **Upper** and **Lower** half (splits 1150 / 1400 / 1600 / 1800 / 2000 / 2200; Tier 0 split as if 200 wide), twelve bands in all. Every page reads the tier list from `band_config` through `VTSRDisplay.tiers()` / `tierFor()`, and `scripts/generate_player_pages.py` from `vtsr_bands.tiers()`; nothing else hardcodes tier lines. `scripts/vtsr_bands.py` (pure, `inactivity.py` pattern) replays each player's chronological post-match ratings through a sticky walk: a player enters an adjacent band only after clearing its edge by `BAND_BUFFER` (20) points, tier lines included, so the displayed tier is sticky too. On the 2026-10-22 corpus the buffer cut label changes from 413 to 173. `elo.py` emits per-rating `band_index` / `band_tier` / `band_half` / `band_label` plus top-level `band_config` on the canonical pass only (alpha-override files publish a blend the thug sequence does not describe). Display-only and additive: no `ELO_SCHEMA_VERSION` / `PIPELINE_VERSION` bump, and `elo_history.json` stays byte-identical. Check: `python _investigation/check_vtsr_bands.py`.
- **Shared renderer.** `js/vtsr-display.js` (`window.VTSRDisplay`) owns every VTSR-T display: `bandFor(row)` (pipeline band, raw fallback), `rawBandFor(vtsr)` for historical values (a match's `before`, a match-log `after`, a peak — these have no stored band), `bandChipHtml`, `positionBarHtml` (the tier track with the current half lit and a soft marker ±σ wide — no digits), `deltaVerdict` / `verdictChipHtml` (**Beat expectation / On par / Below expectation**, `|Δ| < on_par_delta_pts` = 3 is On par), `gapToWinShift` (a team VTSR-T gap shown as its effect on win chance, the Balonce logistic without the commander term) and `applyTierAxis` (Chart.js y-axis labeled by tier). Loaded on the dashboard, `/elo/`, `/player/` (+ stubs) and `/tools/`.
- **Surfaces.** The `/elo/` ladder groups by tier with Upper / Lower sub-groups. **Position is the default sort** (`vtsrSortState = { key: 'position', asc: false }` in `js/elo.js`): it orders players by the white pin (the rating clamped onto that tier’s track) inside each half, closest to promotion first, and leaves the halves separate; a second click reverses it. Band sorts names A–Z inside a half. Other columns reorder the whole tier. Columns are Band · Player · Position · Win% · PvP K/D · Acc · Peak (band) · Matches. The Last game column is gone (the expandable detail panel still shows the last match). The position column is wider (`min-width: 8rem`) with a 6px compact bar, and the Peak chip renders muted (`bandChipHtml(..., { muted: true })` → `.vt-band-chip--muted`, one subtle color for badge and caret) so it never competes with the current band. Compact half carets take the band color (`--vt-band-color`), so every caret matches its badge. The banner reads: "VTSR-T is a performance rating, not a win/loss Elo. It moves when you play better or worse than expected, and winning the game does not move it." The σ `≈` tie chips and the wins-weighted preview toggle are retired (a legacy `?rating=wins` link lands on the thug pane and the param is dropped; the alpha files are still emitted for the validator). Charts (player Rating tab, compare view, Experimental form chart) keep the line on a tier-labeled axis. VTSR-C keeps its numbers everywhere. The topnav page name remains **ELO**.

### 13.9 Match exclusion + provisional rules

- **Excluded** matches (fewer than 6 non-campod leaderboard rows, OR `duration_sec < 240`, OR a host-attested cancellation) do not increment `matches_played` and contribute no deltas to ratings. Camera-pod spectators do not count toward the 6; idle thugs and partials do (they are omitted from the rated lobby, and the match stays rated). Raw `player_count` is the slot count and is not the gate. They appear in `elo_history.json` with `match_excluded: true` and an empty `deltas` array so the exclusion counters reconcile. Cancelled matches additionally carry a never-applied `shadow` block (§13.9.1).
- **Provisional** badge: rated rows with `matches_played < 10` (`ELO_PROVISIONAL_THRESHOLD`) display a `?` chip. Provisional players ARE rated. The chip signals "rating is still moving fast — interpret with caution". Independent of the ladder-eligibility gate: a 12-match player has a real tier badge and is still under **Not on the ladder yet**.
- **Ladder eligibility** (`ELO_LADDER_MIN_MATCHES = 25` **and** globally active): a player appears on the VTSR-T ladder when `matches_played >= 25` and `inactive_status == "active"` (any corpus `leaderboard[]` or F9-duel appearance within 90 days of the newest corpus match, or 3 games after a gap). Lookup miss → inactive. Everyone else stays visible under **Not on the ladder yet** (no place numbers; chips `Inactive · Nd ago` / `Returning · N of 3` / match-count; never-seen = `Inactive` with no `· 0d ago`). Display-only (`leaderboard_eligible` / top-level `inactivity_window_days` / `comeback_games_required` / `corpus_latest_date`); no `ELO_SCHEMA_VERSION` bump. Helper: `scripts/inactivity.py`. Memos: `critique/decisions/vtsr-t-ladder-eligibility.md`, `critique/decisions/vtsr-inactivity-threshold.md`. Do not retune 25 / 90 / 3 to chase a name. Distinct from the K-factor inactivity boost (`K_INACTIVITY_BOOST_*`). Tools / Balonce consume the rating, not ladder membership.
- **Leaderboard visibility floor** (`MIN_CAREER_MATCHES = 5`): players with fewer than 5 rated matches in the current scope are hidden from `career_stats[]` and from both leaderboard tables entirely.
- **Ratings are corpus-wide; the picker filter narrows display only.** A picker filter that narrows to one submitter or one duration band changes which rows appear in the VTSR-T ladder but does NOT recompute ratings against the filtered subset — that would change the meaning of every player's rating depending on which filter they happened to be looking at.

#### 13.9.1 Shadow scoring for cancelled matches (display-only)

A host-cancelled game is a real, fully-recorded match whose *result* was lost to a crash — the corpus has one that ran 2h23m with the full proto-v4 telemetry suite. It must never rate (`resolve_match_outcome` found no winner; the kill-feed inference independently returned `unclear`), but the eight-axis composite still measured everything that happened. So `_shadow_score_match()` in [scripts/elo.py](scripts/elo.py) runs the SAME `compute_performance_index()` and the SAME delta arithmetic the rated path uses, and labels the answer as never applied:

```json
"shadow": {
  "applied": false,
  "reason": "cancelled",
  "deltas": [
    { "name": "Domakus", "steam64": "...", "is_commander": true,
      "before": 1685.93, "performance": -0.3669, "expected": 0.2023,
      "would_delta": -20.51, "axis_contributions": { } }
  ]
}
```

`deltas` on the entry stays `[]`, so `elo_commander.py`'s `match_excluded` skip and every other existing consumer are untouched. The field is called `would_delta`, never `delta`, so no consumer can read it by accident.

**Scope**: `SHADOW_EXCLUSION_REASON = "cancelled"` only. The match-level gate is an if/elif chain testing player count, then duration, then cancellation, so a cancelled match has already cleared both minimums — and a 30-second rage-quit lands in `short_duration` and is correctly scored not at all.

**Read-only by construction.** Every piece of rating state reaches the scorer as a plain-dict SNAPSHOT (the caller passes `dict(thug_elo)`, `dict(matches_played)`, `dict(last_match_dt)`), so a stray write cannot reach `_rating_pass`'s live dicts. That matters because the mutations the rated path performs are precisely what must not happen here: no `thug_elo` advance, no `matches_played` bump (it would shift every later K-factor), no `last_match_dt` stamp (it would shift every later inactivity boost), no `axis_running_*` / `commander_axis_running_*` accumulation, no `win_history` append. The one thing it does write is `canonical_before_out[match_id]`, which is keyed by match id and only ever read back for the match it belongs to, so it cannot reach a rated match's arithmetic.

**Gate**: `_investigation/golden_shadow_inert.py` proves three things — PRESENCE (the block is actually emitted, so the inertness test can't pass vacuously), STRIP (re-run with `SHADOW_EXCLUSION_REASON` disabled; `elo_history` must be byte-identical once `shadow` is removed and every rating must match), and PERTURB (corrupt every shadow value, including flipping `applied` to `true`, and VTSR-C's duel stream must stay byte-identical). Verified across `elo_current.json`, `elo_history.json`, all seven forensic variant pairs, and `elo_commander_history.json`.

**No version bumps.** `elo_current.json` / `elo_history.json` sit in the `load_cache_index()` skip set and regenerate every run, so no `PIPELINE_VERSION` bump is needed; per-match JSON is untouched, so no `match.schema_version`; nothing re-rates, so no `ELO_SCHEMA_VERSION` — the same call the R^W wins ladder made while inert.

Consumer: the Balonce Meter's **How it was going** zone (§13.14).

### 13.10 File-format reference

- `data/processed/elo_current.json` — current-state per-player ratings. Schema documented in [docs/DATA_DICTIONARY.md](docs/DATA_DICTIONARY.md). v2.3 fields: `alpha_pve` constant in the top-level constants block; per-rating `thug_elo` (was `combat_elo`) and `axis_means: {axis_name: career_avg_z, ...}`. v2.4 fields: top-level `commander_axis_prior` / `commander_baseline_shrinkage` / `commander_baseline_locked_axes` / `commander_baseline_observed`; per-rating `matches_as_commander` / `matches_as_thug`.
- `data/processed/elo_history.json` — per-match rating deltas, chronological. One entry per processed match (excluded matches have empty `deltas`). v2.3 fields: per-delta `axis_contributions: {axis_name: clipped_z, ...}` carrying each axis's per-match contribution after clip-and-divide-by-2. v2.4 fields: optional per-delta `axis_contributions_meta: {axis_name: {z_pre_shift, shift, z_post_shift}}` on commander rows only — forensic breakdown of the per-axis shift.

Source-of-truth implementation: [scripts/elo.py](scripts/elo.py). Constants are exported at module top so they're trivially auditable.

### 13.11 Loadout Profile + per-class combat (v2.3)

Display-only data shipped on every leaderboard row alongside the existing combat fields. Powers the per-match Player Profile's Loadout Profile card + Per-Ship Combat table, plus the VTSR-T leaderboard's expandable detail panel. Sourced entirely from `update_tick.players[].odf` ticks + `data/odf.min.json` `unitName` resolution — **per-ship organization, no class taxonomy**.

**Ship name resolution.** Each ODF observed in `update_tick.players[].odf` (e.g. `ivtank.odf`, `ivscout.odf`, `ispilo.odf`) is resolved to a pretty display name at pipeline emit time via `prettify_odf()` in `process_match()`. The chain is `unit_name(odf)` → stripped-VSR `unit_name(odf)` → `wpn_name(odf)` → title-cased stem fallback. The `unit_name` resolver consumes the `GameObjectClass.unitName` field from `data/odf.min.json`, the same field that resolves weapons elsewhere in the pipeline. Concrete examples:

```
ivtank.odf       -> Tank
ivscout.odf      -> Scout
ivatank.odf      -> Assault Tank
ivmbike.odf      -> Mortar Bike
ivwalk.odf       -> Walker
fvsent.odf       -> Sentry
ispilo.odf       -> Pilot
```

ODFs the DB does not recognize (no `unitName` and not in any other resolver) fall through to a title-cased stem (e.g. `unknownship.odf` -> `Unknownship`). Events that fire before the player's first `update_tick` bucket to literal `"unknown"` so the `per_ship_combat` row renders explicitly rather than silently merging.

**Per-player `loadout` block** (sibling to `weapon_breakdown` on each leaderboard row):

```json
{
  "ships": {
    "ivtank.odf":  { "name": "Tank",  "share": 0.582, "seconds": 348.5 },
    "ivscout.odf": { "name": "Scout", "share": 0.418, "seconds": 250.2 }
  },
  "primary_ship":   { "odf": "ivtank.odf",  "name": "Tank",  "share": 0.582 },
  "secondary_ship": { "odf": "ivscout.odf", "name": "Scout", "share": 0.418 },
  "ship_diversity": 2,
  "active_seconds": 598.7
}
```

`secondary_ship` is `null` when the player only flew one ship. `ship_diversity` counts distinct ships with > 0 ticks. `active_seconds` is the sum of all `ships[*].seconds` (i.e. total time the player was actually piloting a ship — may be less than match duration if the player joined late or quit early).

**Per-player `per_ship_combat` block** (list, one row per ship with `time_seconds > 0`, sorted by time desc, ties broken alphabetically on ODF):

```json
[
  {
    "ship": "ivtank.odf",
    "ship_name": "Tank",
    "time_seconds": 348.5,
    "kills": 8, "deaths": 4,
    "pvp_kills": 7, "pvp_deaths": 3,
    "pve_kills": 1, "pve_deaths": 1,
    "dealt": 4830.0,
    "shots": 612, "hits": 287, "pvp_hits": 245,
    "accuracy": 0.469, "pvp_accuracy": 0.400,
    "dpm": 832.0,
    "kd": 2.33
  }
]
```

All numeric fields are tick-joined to the player's active ship at event time via the running `s64_to_current_odf` map. Edge case: events that fire before the player's first `update_tick` (typically the first 1–2 events) bucket to ship `"unknown"`. At 20 Hz this is negligible noise; the `unknown` row renders explicitly so any user audit can see the magnitude.

**Career rollup** (in `js/all-matches-aggregator.js`): `career_loadout` and `career_per_ship_combat` blocks on each `career_stats[]` row, summed across the picker-filtered match subset. Primary/secondary ship is rederived from summed `ship_seconds` (cached via `ship_seconds[odf]` in the per-player accumulator) to avoid double-rounding from per-match strings. Ship pretty names are cached on first encounter from the per-match `loadout.ships[odf].name` so the rollup emits names without re-resolving.

### 13.12 VTSR-C — Commander Rating v2 (experimental; econ composite inert)

**Module:** `scripts/elo_commander.py` (mirrors the `elo.py` contract; the pipeline passes combat-ship stems, so the rating walk does no I/O). `compute_commander_elo(all_match_data, elo_history) -> (current, history)`, wired into `scripts/process_stats.py` immediately after the canonical VTSR-T emit. **Outputs:** `data/processed/elo_commander_current.json` + `elo_commander_history.json` (`schema_version: 6`; ratings comparable with schema 5; both in the `load_cache_index()` skip set). **Corpus-wide, picker-unaware, NOT in the pipeline cache key** (separate ladder from VTSR-T). No `PIPELINE_VERSION` / `ELO_SCHEMA_VERSION` / `match.schema_version` interaction — VTSR-C recomputes every run from in-memory data, exactly like VTSR-T.

**Design (v1 core, still true):** outcome-pure (`α_c = 1`) — the mirror image of VTSR-T (`α = 0`, performance-pure). A verified match outcome is a clean 1v1 label between exactly two commanders.

**v2 (proto v4 overhaul): the economy-performance composite, shipped structurally inert.** Every duel where the match carries BOTH `has_resource_data` and `has_build_data` records an economy composite from the `economy` + `builds` blocks (constructor-free by design — era-mixed structure-completion quality must never feed a rating axis). **Since the 2026-10-23 amendment every axis is measured over the whole game** (every telemetry game carries a recorded outcome, so its end is the decision). Scored candidates (higher is the hypothesized better): `pool_lead` (mean `pool_count`), `non_green_share` (share of live-recycler samples with `scrap < 20 × pool_count`, i.e. red or yellow; green, the cap included, is the slow band), `combat_conversion` (combat-ship BUILD scrap with the starter ships in `STARTER_SHIP_STEMS` excluded, over positive 1 Hz income), `upgrade_time` (mean `upgrade_count`). Weight 0: `replacement_ratio` (combat ships built / human vehicle deaths, cap 3), `replacement_ratio_real` (the same without starter ships built), and `loose_share` (`income_loose / max(scrap_income, 1)`, whole match). Weights `COMMANDER_ECON_WEIGHTS` = 0.25 on each scored axis, 0 on the three readouts (priors, not fitted). Each telemetry duel also records `performance.prefix_diffs` (every axis diff re-measured over 0–240 / 0–480 / 0–720 s; diagnostic only, never in P, and the promote rule's guard against whole-game values that only read the scoreboard). The outcome-blind preflight `_investigation/cmdr_axes_preflight.py` set the std priors and recorded the overlaps (pool_lead vs non_green_share r = 0.91). **Within-match differential normalization** (n=2 makes lobby z-scores degenerate; the opponent diff controls for map/patch/lobby size): `z = clip(d / shrunk_std(axis), −2, 2) / 2` where `shrunk_std` is a rolling RMS of team-1-perspective diffs with seed prior + shrinkage (`CMDR_ECON_STD_SHRINKAGE = 10` pseudo-duels; snapshot-before-duel / update-after — `commander_shrunk_baseline` mechanics); `P₁ = Σw·z / Σw` over available axes with weight > 0, `P₂ = −P₁`. **Blend** `S' = α_c·S + (1−α_c)·(P+1)/2` applies only on telemetry duels, and the code **branches on `CMDR_ALPHA_C < 1.0`** so the inert path never routes through blend arithmetic (provable by inspection on top of the golden byte-identity gate `_investigation/golden_vtsrc_v2.py` — every duel byte-identical to v1). Fallback duels (all pre-v4 forever; collector gaps) score outcome-pure S at ANY α_c — a determined outcome is always a valid duel signal (RATIFIED 2026-09-03). W/L/D records always tally the RAW outcome. Formulas, seed priors, and the promote rule are FROZEN in `critique/decisions/vtsr-c-v2-composite.md` (2026-10-23 amendment: everything earlier is discovery only; flip below 1.0 only on duels dated after that day, with ≥ 25 of them, a Wilson lower bound > 0.55 on ≥ 2 of 4 scored axes, each such axis above 0.50 in at least one prefix, close-game agreement not below 0.50 once that subset has ≥ 15 duels — close = `contested` or the duel's pre-match expected score within [0.40, 0.60] — and log-loss improved by ≥ 0.01). `CMDR_ELO_SCHEMA_VERSION` 11; ratings comparable with schema 10. Fields: top-level `alpha_c` / `econ_weights` / `econ_std_prior` / `econ_std_shrinkage` / `thug_supply_cap` / `econ_std_observed` / `econ_prefix_secs` / `starter_ship_stems` / `econ_semantics_amended_on`; per-rating `duels_with_telemetry`; per-duel `performance {available, p, axes{axis: {values{"1","2"}, diff, std, z, weight}}, prefix_diffs{"240"|"480"|"720": {axis: diff}}}` + per-side `score_blend {alpha_c, s_raw, s_blended}`.

**Rated set:** canonical rated matches that are **determined** — present in `elo_history` with non-empty deltas (inherits the ≥6-player / ≥240 s / non-cancelled gates for free), `winner.team ∈ {1, 2}` via `decided_by ∈ {adjudicated, attested, clean_win, contested}`, both commanders identified from `leaderboard[].is_commander` (fallback `match.team_leaders`). Attested/adjudicated **draws** (`decided_by == "draw"`) score S = 0.5 for both. Undetermined matches skip and are counted (`matches_skipped_undetermined`; `matches_skipped_missing_commander` covers roster gaps + join failures).

**Update rule** (classic chess constants — W/L semantics differ from VTSR-T's performance semantics):

$$E_A = \frac{1}{1 + 10^{-\left((R_A - R_B) + \lambda\,(T_A - T_B)\right)/400}} \qquad \Delta R_A = K_A\,(S_A - E_A)$$

- \(T\) = mean **pre-match VTSR-T** of each team's non-commander rated rows, read from the canonical `elo_history` deltas' `before` values (historically accurate at that point in the walk — zero leakage). Either side with zero joinable thug rows → handicap term 0 (defensive).
- \(\lambda\) = `CMDR_LAMBDA_TEAM_HANDICAP = 1.0` — a 100-point average-thug advantage counts like 100 rating points. Validator-ablated over {0, 0.5, 1.0, 1.5} so the dial becomes empirical as the labeled corpus grows.
- Logistic scale **400** (not VTSR-T's 800 — that scale is tuned for lobby-median performance expectations; head-to-head win probability is the textbook regime).
- **Symmetric K** (no loss aversion — duels are zero-sum; asymmetry would inflate the pool), **no rating floor** (tiny pool, no ladder-flight psychology to manage). `K = K_FLOOR + (K_BASE − K_FLOOR) · max(0, 1 − games/PRIOR)` with `CMDR_K_BASE = 40`, `CMDR_K_FLOOR = 20`, `CMDR_PROVISIONAL_PRIOR = 5.0`. Anchor `CMDR_ELO_ANCHOR = 1500`. Provisional badge below `CMDR_PROVISIONAL_THRESHOLD = 5` rated games.

| Constant | Value | Meaning |
|---|---|---|
| `CMDR_ELO_ANCHOR` | 1500.0 | Debut rating |
| `CMDR_K_BASE` / `CMDR_K_FLOOR` | 40.0 / 20.0 | K decay endpoints |
| `CMDR_PROVISIONAL_PRIOR` | 5.0 | K decay horizon (games) |
| `CMDR_PROVISIONAL_THRESHOLD` | 5 | Provisional badge floor |
| `CMDR_LADDER_MIN_V4` | 8 | Display-only: proto-v4 games to occupy a ranked `#` |
| `CMDR_LADDER_MIN_NON_V4` | 25 | Display-only: older games (F9 + pre-v4) to occupy a ranked `#` |
| `INACTIVITY_WINDOW_DAYS` | 90 | Display-only: any-appearance idle window vs newest corpus match |
| `COMEBACK_GAMES_REQUIRED` | 3 | Display-only: games (command games on VTSR-C) to rejoin after a gap |
| `CMDR_STALE_WINDOW_DAYS` | 90 | Display-only: commander grace while still thugging |
| `CMDR_LOGISTIC_SCALE` | 400.0 | Expected-score logistic scale |
| `CMDR_LAMBDA_TEAM_HANDICAP` | 1.0 | Thug-strength handicap weight |
| `DETERMINED_DECIDED_BY` | adjudicated · attested · clean_win · contested | Rated-outcome gate |

**Output shapes.** `elo_commander_current.json`: top-level constants + `computed_at` + `rated_match_count` + skip counters + `ratings[]` (`{name, steam64, vtsr_c, matches_commanded_rated, wins, losses, draws, win_pct, peak_vtsr_c, peak_at, peak_date, last_match_id, last_delta, provisional}`, sorted `vtsr_c` desc). `elo_commander_history.json`: same header + `duels[]` — per rated duel `{match_id, date, decided_by, adjudicated, outcome, commanders: {"1": {steam64, name, before, after, delta, expected, k, score}, "2": {…}}, team_handicap: {t1_thug_mean, t2_thug_mean, diff, lambda}}` (full audit transparency, same spirit as `axis_contributions`; the two `expected` values always sum to 1).

**Validator sections** (`scripts/validate_elo.py` v1.2, report schema 2 → 3, strictly additive): §10 replays the ladder chronologically from `elo_commander_history.json` alone (prediction accuracy + log-loss; draws excluded from the accuracy denominator; replay cross-checked against emitted ratings) and ablates λ over {0, 0.5, 1.0, 1.5}; §11 is the **axis-vs-outcome sign-agreement study** — per-axis team-mean `axis_contributions` difference (winner − loser) across all determined rated matches, the empirical check on `THUG_WEIGHTS`. Both blocks land in `report.md` / `report.json` / the committed `validation_summary.json` (`latest_detail.vtsr_c` + `latest_detail.axis_outcome`; absent-safe for UI readers). **Validator v1.7 (critique v4, report schema 7 → 8, additive)** adds five descriptive sections that never touch `validation_summary.json`: §15 performance-vs-wins ladder (per-player `thug_elo` vs the inert `wins_elo`), §16 team-outcome dependence of thug `P_i` (the "stomp effect": winner/loser means, sign-flip shares, η²), §17 ship-denial gradient (positioning `at_base_pilot_share` bands vs Δ / P / win share, split by outcome to expose the confound), §18 commander selection + v2.4 role-adjustment audit, and §19 faction effect — a pure-Python offset logistic regression on the VTSR-C duel stream (canonical `expected` as the offset; Hadean-vs-ISDF and Scion-vs-ISDF contrasts; factions from `matches.json` `team_factions` and `data/external/f9_ledger.json`). §19 also applies the FROZEN candidate term from `critique/decisions/phase-6-faction-advantage-term.md` (+60 Hadean / 0 Scion / 0 ISDF rating points in the expected score, never refit) and prints its promote verdict on the confirmation sample (non-mirror duels dated after 2026-10-04; ≥60 required). The narrative lives in `elo/analysis/index.html` §5 and §7. **Validator v1.8 (report schema 8 → 9, additive)** adds §20 — the format-gate audit: the one section that loads the EXCLUDED matches' per-match files (loaded separately in `main()`, never merged into `per_match`) and reports the excluded-entry profile by `exclusion_reason` / team shape / month, small-format (≤2 non-campod rows per side) vs rated full-format medians of duration, kills / deaths / damage per player-minute, PvE share and peak pools (games under 240 s dropped), and a transfer test in which pre-match team-mean VTSR-T and leader VTSR-C — reconstructed from the rated histories, exact because ratings only move on rated appearances — predict the determined small-format winners (Wilson CIs). It is the size-agnostic test the lobby-relative composite cannot run itself (two rated rows → every axis ±0.5 by construction). Descriptive; not in `validation_summary.json`; narrative in `elo/analysis/index.html` §10.

**Validator v1.3 additions (report schema 3 → 4, additive):** **#12 — VTSR-C econ-axis sign agreement** (mirror of #11 over telemetry duels only: per econ axis, did the axis-leading commander win? THE promote-rule gate) and **#13 — α_c ablation** ({1.0, 0.9, 0.8, 0.5}; the replay walks EVERY duel — non-telemetry duels stay outcome-pure per the fallback policy — but accuracy/log-loss count only telemetry duels so the gate is never diluted; the α_c = 1.0 row doubles as a replay-integrity check against the emitted ladder). Both surface in `validation_summary.json` at `latest_detail.vtsr_c_perf.{econ_axes, alpha_ablation}` and render absent-safely on the ELO page's Does-it-work tab.

**Consumers:** ELO page **VTSR-C Elo pill** (`#elo-tab-vtsr-c` — the `#section-vtsr-c` ladder card, table-only; `?tab=vtsr-c`; ranked table + one Unranked table), How-it-works (the `commanderLadderHtml()` section from `js/vtsr-explainers.js`, which is where the ladder card's "How It's Calculated" button lands), Commanders & fairness (two cards), Does-it-work (accuracy cards + λ table + axis-outcome bar list); dashboard Commander Cohort card (top-5 **eligible** VTSR-C strip via lazy `ensureCommanderEloLoaded()`); player page **Commander** pill (`?tab=commander` — VTSR-C headline, time-series, opposing commanders, favorite maps, faction when commanding, rated duel log; opponent tallies computed client-side in `js/player.js` from the contributions walk because the aggregator's `head_to_head` is capped at top-10 globally). All consumers are 404-safe (missing files → surfaces hide / em-dash). Tools / Balonce consume the rating, not the rank.

**v5 — inactivity + commander-stale clocks (display-only; `CMDR_ELO_SCHEMA_VERSION 4 → 5`).** A commander occupies a ranked `#` only when the v4 duel-count OR-gate still holds **and** they are globally active (any corpus `leaderboard[]` or F9-duel appearance within 90 days of the newest corpus match, or 3-game comeback) **and** command-recent (commanded within 90 days — corpus `is_commander` / `team_leaders` or F9 `commanders.{1,2}` — or 3 commander games after going stale while still thugging). Lookup miss → inactive + stale. Ratings, K, and `duels[]` are byte-identical to schema 4 aside from the new fields. Helper: `scripts/inactivity.py`. Memo: `critique/decisions/vtsr-inactivity-threshold.md` (global window amended 30 → 90 on 2026-09-24; no schema bump). Do not retune 90 / 3 to chase a name. No `PIPELINE_VERSION` / `ELO_SCHEMA_VERSION` / `match.schema_version` bump.

**v4 — ranked-ladder eligibility (display-only; `CMDR_ELO_SCHEMA_VERSION 3 → 4`).** A commander occupies a ranked `#` when `duels_with_telemetry >= CMDR_LADDER_MIN_V4` (8) **OR** `duels_non_v4 >= CMDR_LADDER_MIN_NON_V4` (25). Older = F9 ledger + pre-v4 corpus (`matches_commanded_rated − duels_with_telemetry`). Ratings, K, and duel history are byte-identical to schema 3 aside from the new fields (`duels_non_v4`, `leaderboard_eligible`, top-level `leaderboard_min_v4` / `leaderboard_min_non_v4`). Unranked commanders stay visible (no `#`; progress chip). Memo: `critique/decisions/vtsr-c-ladder-eligibility.md`. Do not retune the mins to chase a name. No `PIPELINE_VERSION` / `ELO_SCHEMA_VERSION` / `match.schema_version` bump.

**v3 — external community duels (F9bomber ledger; `CMDR_ELO_SCHEMA_VERSION 2 → 3`).** The ladder's history additionally walks the pre-gated community duels from `data/external/f9_ledger.json` (one-shot import of F9bomber's hand-kept match spreadsheet by the standalone `scripts/import_f9_ledger.py`; decision memo `critique/decisions/f9-external-duels.md`; ~505 duels, 2025 →). `compute_commander_elo(all_match_data, elo_history, external_duels=None, external_overlap_ids=None)`:

- **Merged chronological walk** — telemetry entries keep exact history order via sort key `(day, 0, history_index)`; externals interleave at `(day, 1, sheet_row)` (day precision — F9 logs calendar days; same-day externals sort AFTER telemetry; documented approximation, bounded by K).
- **Scoring** — outcome-pure S ∈ {1, 0} from `winner_side` at `k_factor(games) · CMDR_K_EXTERNAL_SCALE` (`1.0` = full K; operator ratified the outcomes as definitive — 28/28 agreement with telemetry on Steam64-paired overlapping games). Externals bump `games` (K decay + provisional math) and the headline W-L-D; `rated_match_count` stays telemetry-only, externals counted in top-level `external_duels_rated` + per-rating `duels_external`.
- **Team handicap** — a running `vtsr_t_now` snapshot (per-player VTSR-T `after`, folded from every telemetry history entry as the walk passes it) supplies mean thug strength over each side's **resolved** thugs; a side with none → `None` → `expected_score` zeroes the term. Pre-corpus duels see an empty snapshot → handicap 0, structurally identical to our own earliest telemetry matches.
- **Runtime overlap guard** — an external is skipped (WARN + `external_skipped_overlap_runtime`) when a corpus match within ±1 day carries the same commander-Steam64 pair and ≥ 60% of the external's resolved participants, EXCEPT matches already consumed by the importer's one-to-one pairing (`external_overlap_ids` from the ledger's `overlaps[]`) — a consumed match must not also block a sibling rematch row. Protects future binpb backfills from double-counting.
- **Provenance** — external duels carry `{match_id: "", source: "f9", external_row, map, decided_by: "external", performance: {available: false}}`; telemetry duels now carry `source: "telemetry"`. Per-rating `last_match_id` / `peak_at` use the `f9:<row>` sentinel. `external_provider {name, url}` powers the UI credit line ("Includes N community match records from F9bomber"). Pre-v3 `vtsr_c` / `peak_vtsr_c` are **not comparable** (re-rate signal).
- **VTSR-T is not rated from the ledger** — `scripts/elo.py` does not score F9 rows. It *does* pass gated `duels[]` into `compute_activity` so F9 appearances (commanders + thugs) refresh the display-only inactivity clocks; `elo_history.json` stays byte-identical. Gate: `_investigation/check_f9_ledger.py` (funnel partition, duel shape, alias validity, forbidden-consumer scan against `f9_ledger` / `f9_community` / `f9stats` string refs, ladder cross-checks).
- **Dual-recording dedup** — same-id dual recordings (two submitters' files landing on one match id) produce two `elo_history` entries; the telemetry walk rates the FIRST and skips the rest (`matches_skipped_duplicate_recording` counter) so one physical game is exactly one duel. **Batch adjudication** — the 50 unclear ledger-overlap matches passing the pre-registered six-gate rule (jaccard 1.0, duration ≤ 120s, two-sided pairing uniqueness) were auto-adjudicated by the one-shot `scripts/apply_f9_adjudications.py`; see the decision memo's addendum.

**Roadmap:** empirical λ fitting when the validator's ablation separates from noise; α_c promotion strictly through the pre-registered rule as the telemetry corpus grows.

### 13.13 VTSR-T Wins-ELO ladder — R^W (Stage E, real machinery, inert at ALPHA = 0)

The blend line `vtsr = ALPHA · wins_elo + (1 − ALPHA) · thug_elo` (§13.1) now has a **real left operand**. Inside `_rating_pass` (so all alt-mode passes inherit it, and CRITICALLY so the v2.8 lowtier two-pass structure gets fresh state per pass — leaked state would double every wins delta while the byte-identity gate, blind to wins at ALPHA = 0, kept passing):

- **Rated set:** rated matches (all §13.9 gates already applied) whose outcome is DETERMINED — `decided_by ∈ WINS_DETERMINED_DECIDED_BY = {adjudicated, attested, clean_win, contested}` with `winner.team ∈ {1, 2}`; `decided_by == "draw"` scores S = 0.5 for every rated row. Undetermined matches skip the wins update (counted in `wins_matches_skipped_undetermined`) while rating the thug side in full — `matches_excluded_no_winner` stays 0 by design. Defensive `wins_matches_skipped_one_sided` counter for lobbies whose rated rows all sit on one side.
- **Update rule:** per-row `ΔR^W = K_w(n) · (S − E_side)` with `E_side` from the logistic on **team-MEAN R^W difference** (scale `WINS_LOGISTIC_SCALE = 400`). Mean, never MAX/softmax — Phase 2C's decisive negative result; MAX-flavored aggregations live in the validator only. Role-blind (commanders included); campod / low-activity rows excluded by the shared lobby predicate.
- **K:** symmetric `wins_k_factor(n) = WINS_K_FLOOR + (WINS_K_BASE − WINS_K_FLOOR) · max(0, 1 − n / WINS_PROVISIONAL_PRIOR)` — 24 → 12 over the first 10 wins-rated games. No loss aversion (zero-sum), no floor, no inactivity boost (thug-calibrated; revisit if ALPHA ships > 0).
- **Emissions (additive; `ELO_SCHEMA_VERSION` NOT bumped while inert — kboost precedent):** per-rating `wins_elo` (real values; anchor 1500 with zero wins games), `wins_games`, `wins_record {w,l,d}`; per-delta `wins {before, after, delta, s, e}`; top-level `wins_*` constants + counters + `alpha` (effective) + `alpha_overridden` sentinel. Two JS consumers already read `wins_elo` and now show real values (ELO-page rating tooltip, Tools resolver) — intended transparency; the "Wins dial idle at α = 0" explainer copy stays accurate.
- **Forensic ALPHA sweep:** `compute_elo(alpha_override ∈ {0.10, 0.25, 0.50})` emits `elo_current_alpha{10,25,50}.json` (+ histories; all in the cache skip set). A display-only `alpha_override=1.0` pair, `elo_current_alpha100.json`, is the wins ladder with the axes turned off. No page reads the side files any more: the ELO-page wins-weighted preview was retired with the move to bands (§13.8), and a legacy `?rating=wins` link just opens the thug pane. They remain validator inputs. `last_delta` on an alpha file is still the performance step. At α > 0 the side file's `vtsr` is the real blend and `peak_vtsr` tracks the **blended** value per match inside the walk (at α = 0 the blend is bit-identical to `thug_elo`, so canonical peaks are untouched). The promote grid is still only {0.10, 0.25, 0.50}, scored via `validate_elo.py --elo-mode alpha{10,25,50}` against the **pre-registered promote rule** in `critique/decisions/phase-5-wins-blend.md`: promote-candidate iff some α improves determined-outcome accuracy ≥ +5pp AND improves log-loss AND holds Spearman within −0.01 AND calibration MAE within +0.005 AND mean drift < 50 above anchor; discard any α dropping Spearman > 0.03; else HOLD. **First sweep (2026-09-04): HOLD** — accuracy/log-loss flat at every α (39 wins-rated matches move nobody far enough to flip a team-mean comparison), while rho/self-consistency/MAE improve monotonically with α — promising trajectory, insufficient evidence. (The sweep was run twice the same day: the first pass was scored while two synthetic v4 fixtures were still on disk, the second after purging them. Same verdict, so the HOLD was not a contamination artifact — see the memo's "Corrected sweep" section, which is the binding record.)
- **Golden gates** (`_investigation/golden_wins_elo.py`): Gate A — baseline-vs-modified `compute_elo` on the identical manifest-reconstructed corpus, every pre-existing field byte-identical (the committed files are NOT bit-reproducible outside the pipeline: the duplicate-id match exists as two different client recordings in pipeline memory but only one on disk). Gate B — an independent single-pass replay from emitted artifacts reproduces every `wins_elo` / `wins_games` exactly (the leaked-state detector).

### 13.14 Balonce Meter — the shared balance + prediction surface

`js/balonce-meter.js` (`window.VTBalonce`) is the **display layer** for the rating system's predictive claim. It renders on two surfaces — the Tools Team Balonce card and the per-match dashboard **Outcome** card (`#section-faction`, read slots `#outcome-read` / `#outcome-after`) — and is **entirely display-only**: no pipeline code, no schema bump, no new emission. Every number is read from already-committed JSON.

**The model is not new.** The meter runs `scripts/elo_commander.py::expected_score` verbatim:

$$P(\text{T1}) = \frac{1}{1 + 10^{-\left((R^C_1 - R^C_2) + \lambda\,(T_1 - T_2)\right) / S}}$$

with $R^C$ = commander VTSR-C (anchor when unrated), $T$ = **mean THUG VTSR-T** per side (commanders excluded, mirroring `_team_thug_means`), and $\lambda$ / $S$ read from the emitted `lambda_team_handicap` / `logistic_scale` rather than hardcoded. `computeWinProb()` reproduces the pipeline's stored per-duel `expected` to four decimals — that exactness is what lets the Tools honesty footer quote the validator's headline accuracy (§10 of `validate_elo.py`). The Outcome card does not print that corpus figure. Ship the formula the number was earned on, or omit the number.

**Commander VTSR-T is displayed, never scored.** Each commander carries two ratings. On the Outcome roster the commander chip shows VTSR-C (the term in the gauge) and VTSR-T beside it; each thug chip shows pre-match VTSR-T; the team heading shows **avg T**, the mean thug VTSR-T with the commander excluded — the same handicap the gauge uses. Only VTSR-C enters the probability. VTSR-C is outcome-pure, so everything a commander contributes *including their own flying and shooting* is already priced into the wins that built it; adding their VTSR-T double-counts it, and the two ratings are correlated enough that a third term on a 625-sample model buys variance rather than accuracy. The honest status is "principled but untested" — no validator mode has ever scored an inclusive variant — so the promotion path is a **pre-registered ablation** (team-mean-inclusive / provisional-conditional / three-term), not a silent change.

**Bands** key off the FAVORITE's probability and use the community vernacular for a lopsided lobby. The band label is the **one** status string, and it renders in exactly one place: the in-track text of `meterHtml()`. There is no separate band pill beside a caller's headline (it printed the same status twice) and no shorter/longer variant of the text (the old `compact` mode and the `defaultStatusText` / `shortStatusText` exports are gone). Which side is disadvantaged is carried by the surrounding copy — the caller's own `Team N favored X%` headline, the meter's `Team N disadv` end labels, and on Tools the `Disadvantaged` team-header badge (still keyed on `DISADVANTAGE_PROB`):

| Favorite probability | Band | Token |
|---|---|---|
| under 60% | Good game | `green` |
| 60–70% | Slight edge | `yellow` |
| 70–80% | PLAYEDathon | `orange` |
| 80%+ | PLAYEDalocalypse | `red` |

`DISADVANTAGE_PROB` is 0.60, the end of Good game, so Tools does not badge a side Disadvantaged inside a good game. The Outcome verdict uses the same edges: under 60% the chip names the winner either way; a 60–70% favorite who loses is a **Turn**; **Upset** starts only at 70%. A favorite who wins a slight edge is **Edge held**; a favorite who wins from 70% up is **Favorite held**. Draws stay **Draw**. No recorded winner stays **Outcome unrecorded**. Surprise in bits lives in the verdict tooltip, using that same word.

**Tools side.** The gauge is probability-driven. The ΔΣVTSR readout, team sums and per-row VTSR-T numbers were removed with the move to bands (§13.8): rows show a band chip, commanders also show their numeric VTSR-C, and the thug term reads as `Thugs: Team N +X%` (its effect on win chance). `findBestPartition` gains a second objective: with both commanders set it minimizes $|P - 0.5|$, tie-broken by $|\Sigma_1 - \Sigma_2|$; otherwise the legacy sum objective is untouched. This is the substantive upgrade — because the commander gap is a fixed term inside $P$, the search compensates a weaker commander with stronger thugs instead of treating the two jobs as interchangeable. Also: real `Cmdr ΔVTSR-C` banner chip with provisional badges (the old "commander ability and thug VTSR are different skills" disclaimer is retired — the model now uses the rating that measures commanding), probability-keyed Disadvantaged badge, count-based confidence chip, the Tools-only **empty-seat rule** for uneven columns (the validated handicap uses **means**, which reads a 5v4 as fair; Tools pads the short side's thug list with phantom thugs at `elo_current.rating_floor` (1000) before its mean — in the live meter and inside Suggest — and labels the `NvM` chip as a house rule: only 8 uneven rated duels exist, so it is unvalidated and the footer's accuracy figure is flagged as even-team; the dashboard Outcome card never applies it, since it must reproduce the pipeline's stored `expected`), and an honesty footer that also stamps `Ratings as of <computed_at>` from `elo_current.json` — ratings change after every pushed match, the Suggest objective is a plateau (about half of all commander pairings sit within half a point of 50%), so two screens loaded across a push can legitimately show different, equally fair picks; the stamp makes that visible instead of mysterious. With 0 or 1 commanders set the commander term is **dropped entirely** rather than pitting a known rating against an unknown one.

**Dashboard side** (the Outcome card `#section-faction`, ahead of Match Highlights). Match-global and ALWAYS unfiltered (highlights passthrough contract — the renderer takes `currentData`, never the filtered view). A single-player filter hides the team panels and keeps the read. The card is one surface:

1. **Team panels** — roster, combat, and scrap, always first. Thug chips show pre-match VTSR-T. Commander chips show VTSR-C and VTSR-T. The heading shows avg T, via `rosterRatings()`.
2. **Balonce & Elo** (`#outcome-elo-fold`) — a `<details>` that starts closed. The summary is the label plus a chevron (down when closed, up when open). Items 3 and 4 live inside it. The disclosure hides when both slots are empty. Opening it survives a match switch in the same visit; a refresh starts closed again.
3. **Read** (`#outcome-read`) — favored headline, then the **main reason** line, gauge, commander-gap and thug-gap chips, then the verdict chip (provenance beside it).
4. **How it actually played out** (`#outcome-after`) — **commander first.** A *Commander duel* sub-section: one line saying whether the pre-match call held (winner, favorite or underdog, and whether the main reason held: "the commander edge held" / "the stacked thugs carried it" …), the commander VTSR-C movement (`before → after` and the signed change), and both commanders' whole-game economy telemetry (pools held, time out of green scrap, real-ship spending and upgrades held — both sides' values, Team 1 first, and who led each axis in words: even / narrow / clear / commanding by |z|), framed as *recorded, not scored* ($\alpha_c = 1$). Then a *Thugs* sub-section: per-team mean performance vs expected and the top axis gaps (winner − loser) annotated with corpus sign-agreement from `validation_summary.latest_detail.axis_outcome`. The gaps are the four VTSR-T axes; snipes and T-key are not among them (v2.11).

**Main reason.** `VTBalonce.mainReason({cmdrGap, thugGap, lambda, scale, teamLabel, cmdrName, validation})` names why the favorite is favored by comparing the two terms of the logit: `cmdrTerm = R^C_1 − R^C_2` and `thugTerm = λ(T_1 − T_2)`. Classes: **even** (both under `DRIVER_EVEN_PTS` 25), **commander edge** (thug term under 25, or the terms oppose and the commander term is at least as large), **thug edge** (commander term under 25, or they oppose, the thug term is larger and the commander gap is under `DRIVER_STACK_GAP_MIN` 50), **both** (same sign), and **stacked** (they oppose, the thug term is larger, and the commander gap is at least 50 — a clearly better commander against much stronger thugs). `mainReasonHtml()` renders a label pill (`.vt-balonce-reason[data-reason]`) plus one sentence; its tooltip quotes the corpus record for that class. The record comes from the validator: `duel_drivers()` in `scripts/validate_elo.py` classifies every non-draw duel with both pre-match ratings and thug means using the same constants and writes `{n, favored_wins, favored_win_rate, favored_win_ci}` per class (plus `commander_edge.vs_stronger_thugs`) to `report.json` `vtsr_c.drivers` and `validation_summary.json` `latest_detail.vtsr_c.drivers`. The constants exist on both sides (`DRIVER_*_FALLBACK` in the module); change them together. On the 2026-10-22 corpus (293 duels) the favorite won 102/153 on a commander edge, 49/76 when that edge faced stronger thugs, 62/88 with both edges, 19/29 on a thug edge, 8/14 when even, and only 4/9 when stacked. The same line appears on Tools (`renderPlayedMeter`), the per-match Elo tab's commander card, and the ELO page's Does-it-work tab renders the full table ("Why the favorite was favored").

The per-commander stakes block ("What was on the line") and the "Does it work?" receipts are not on this card. Corpus accuracy stays on the ELO page.

**Degradation ladder**: match absent from `elo_history` → the read stays empty and the team panels still render, without ratings; determined outcome with a duel row → the full card; rated but **undetermined** (no duel row) → still renders, with VTSR-C **reconstructed** from each commander's last prior duel `after` and an `Outcome unrecorded` chip; `match_excluded` with `exclusion_reason == "cancelled"` → the **what-if** rungs below; any other `exclusion_reason` (`low_player_count` / `short_duration`) → read empty, team panels remain; `elo_commander_history.json` 404 → thug-only meter.

**The cancelled-match what-if.** A host-cancelled game is a real match whose result was lost, and nothing about the *pre-match* state is affected by the crash — so the read renders rather than staying empty. Scope is deliberately `cancelled` and nothing else: the match-level gate in `scripts/elo.py` is an if/elif testing player count, then duration, then cancellation, so `cancelled` already implies the lobby and length minimums were met and a 30-second rage-quit lands in `short_duration` instead. The gauge is fully real, gaining a `Not rated · cancelled` chip plus a dashed `vt-balonce-hypothetical` treatment so it can never be mistaken for a scored match. There is no verdict chip. **What was at stake** (under the gauge, above How it was going) shows both branches per commander (`had they won 1468 → 1482 +13.8` / `had they lost 1468 → 1462 −6.2`). Copy never says Upset, because there is no outcome to score. Ratings come from the same reconstruction the undetermined rung uses (`reconstructVtsrC`, plus a `reconstructVtsrT` sibling for the thug handicap — exact, because VTSR-T only moves when a player appears in a rated match; the inactivity mechanism boosts K, not the rating), and K from `cmdrKFactor()` reading `k_base` / `k_floor` / `provisional_prior` off the emitted JSON. **How it was going** then renders from the never-applied `shadow` block (§13.13.1): per-team played-vs-expected, which side led each axis, and the per-player VTSR-T that *would* have moved. Because shadow rows carry the same `{before, performance, expected, axis_contributions}` shape as real deltas, `teamMeans()` / `teamAxisMeans()` consume them unchanged. Absent a shadow block that zone self-omits and the card still shows the gauge and What was at stake.

**Conventions.** Shared CSS is the `.vt-balonce-*` block in `css/vtstats-theme.css` (loaded by both pages); the legacy `.vt-tools-balonce-played-meter-*` rules in `css/tools.css` are retained deliberately as the Tools card's outer wrapper, following the `.vt-active-game-modal-*` class-stability precedent. The module owns tooltip init for the Outcome read slots, the header info icon, and the What-if pills. `js/match-elo.js` delegates its `elo_commander_history.json` fetch to `ensureCmdrHistoryLoaded()` so both consumers share one request behind the `window.__vtCmdrEloHistory` sentinel.

**What-if lobby.** The card header's **What-if** control opens Tools in a new tab with this match's rated lobby already placed. **As played** links to `tools/index.html?from=<match id>&ratings=then#vt-tools-balonce`; **Today** uses `ratings=now`. Tools (`js/tools/match-seed.js`) loads the match plus `elo_history.json` and `elo_commander_history.json`, sets roster mode to Manual, and seats each player on their real team in their real role (commander or thug). The roster is the rated set only: rows with a pre-match `before` on that match's `deltas` (or `shadow.deltas` when the match was cancelled). Camera-pod, partial, and idle rows are left out so As played opens on the same people as the card. As played freezes VTSR-T at that `before` and VTSR-C at the duel's commander `before` (otherwise the last prior duel `after`, else the anchor). Today uses the resolver's current ratings. The same two pills on the Team Balonce card flip the era without reloading and without clearing team swaps; the reset icon restores the match's teams and commanders. Refresh replays the URL. **Reset all** strips `from` and `ratings`. Display-only — nothing is written to localStorage, and neither basis feeds a rating.

### 13.15 VTSR-1v1 — the separate head-to-head ladder

**Module:** `scripts/elo_1v1.py` (pure, no I/O). `compute_1v1_elo(all_match_data, match_names=None) -> (current, history)`, wired into `scripts/process_stats.py` right after VTSR-C in its own soft-fail block; `match_names` is the manifest's registry-resolved map title per match id. **Outputs:** `data/processed/elo_1v1_current.json` + `elo_1v1_history.json` (`ELO_1V1_SCHEMA_VERSION = 1`, both in the `load_cache_index()` skip set). **Corpus-wide, picker-unaware, NOT in the pipeline cache key**; no `PIPELINE_VERSION` / `match.schema_version` / `ELO_SCHEMA_VERSION` / `CMDR_ELO_SCHEMA_VERSION` interaction. Memo: `critique/decisions/vtsr-1v1-ladder.md`.

**Why separate.** A 1v1 is two commanders and nothing else. VTSR-T needs a six-player lobby and VTSR-C carries a thug handicap, and both already drop every 1v1 (`low_player_count`; VTSR-C only rates what VTSR-T kept). The module reads neither `elo_history.json` nor anything under `data/external/`, and `scripts/elo.py` / `scripts/elo_commander.py` / `js/all-matches-aggregator.js` / `js/balonce-meter.js` never read its files. Gate: `_investigation/check_1v1_separation.py` (purity — VTSR-T + VTSR-C recomputed around a 1v1 run are identical; forbidden consumers; no F9 source; every rated game a true 1v1; zero-sum; continuous before/after chains; current file agrees with the replay).

**Recorded games only.** F9bomber's spreadsheet is excluded: every row before 2025 lists only the two commanders, so a 1v1 and a team game look the same, and none of the ~19 later roster-less rows can be confirmed (evidence in the memo).

**Eligible game.** Not `void`; exactly one leaderboard row per side with `is_campod` false (`one_v_one_players()`, shared with the gate and validator); `duration_sec >= 240`; `winner.decided_by` in `adjudicated / attested / clean_win / contested` with `team` 1 or 2, or `draw` (0.5 each). Unclear, cancelled, short and same-id dual recordings are skipped and counted. A low-activity row does not void the game.

$$E_A = \frac{1}{1 + 10^{-(R_A - R_B)/400}} \qquad \Delta R_A = \bar K\,(S_A - E_A) = -\Delta R_B$$

$\bar K$ is the mean of both players' scheduled K (`40 → 20` linear over the first 5 rated 1v1s), so every game is exactly zero-sum. Anchor 1500, no floor, provisional below 5 games. Chronological by `match.date`. Steam64-keyed, with `identity_aliases` pinned names.

| Constant | Value | Meaning |
|---|---|---|
| `ONE_V_ONE_ANCHOR` | 1500.0 | Debut rating |
| `ONE_V_ONE_K_BASE` / `ONE_V_ONE_K_FLOOR` | 40.0 / 20.0 | K schedule endpoints (shared per game as the mean) |
| `ONE_V_ONE_PROVISIONAL_PRIOR` | 5.0 | K decay horizon (games) |
| `ONE_V_ONE_PROVISIONAL_THRESHOLD` | 5 | Provisional badge floor |
| `ONE_V_ONE_LOGISTIC_SCALE` | 400.0 | Expected-score scale |
| `ONE_V_ONE_MIN_DURATION_SEC` | 240 | Duration gate |
| `ONE_V_ONE_LADDER_MIN_GAMES` | 5 | Display-only: rated 1v1s for a ranked `#` |
| `INACTIVITY_WINDOW_DAYS` / `COMEBACK_GAMES_REQUIRED` | 90 / 3 | Display-only global activity clock (`scripts/inactivity.py`, recorded corpus only) |

**Ranked (display only):** `leaderboard_eligible` = at least 5 rated 1v1s AND globally active. Everyone else is in the unnumbered Unranked table. Do not lower the bar to fill the table (on 2026-10-24 only one player clears it).

**Validator:** §22 `metric_vtsr_1v1` in `scripts/validate_elo.py` (v1.11, report schema 11 → 12, additive) replays the games from `elo_1v1_history.json` and reports accuracy, Wilson CI, log-loss, coin-flip count and a replay-integrity diff. Forensic only — never in `validation_summary.json`, no UI card.

**Consumers (all 404-safe):** ELO page **1v1** pill (`?tab=1v1`, `#elo-tab-1v1` / `render1v1Ladder()` in `js/elo.js` — Ranked + Unranked tables, expandable game logs with map, factions, Δ and expected); How-it-works section (`oneVOneLadderHtml()` in `js/vtsr-explainers.js`); player page Commander tab 1v1 block (`render1v1BlockHtml()` in `js/player.js` — rating, record, peak, standing, rating chart, head-to-head, game log; self-hides without a rated 1v1); per-match Elo tab card `#section-match-elo-1v1` (`paint1v1()` in `js/match-elo.js`, shown on rated 1v1s alongside the VTSR-T exclusion note).

### 13.16 VTSR-2v2 — the separate 2v2 ladders, and the shared format lines

**Module:** `scripts/elo_2v2.py` (pure, no I/O). `compute_2v2_elo(all_match_data, match_names=...) -> (current, history)`, wired into `scripts/process_stats.py` right after VTSR-1v1 in its own soft-fail block. **Recorded games only**: it reads neither `elo_history.json` nor `data/external/`. **Outputs:** `data/processed/elo_2v2_current.json` + `elo_2v2_history.json` (`ELO_2V2_SCHEMA_VERSION = 2`, both in the `load_cache_index()` skip set). **Corpus-wide, picker-unaware, NOT in the pipeline cache key**; no `PIPELINE_VERSION` / `match.schema_version` / `ELO_SCHEMA_VERSION` / `CMDR_ELO_SCHEMA_VERSION` / `ELO_1V1_SCHEMA_VERSION` interaction. Memo (math frozen there, plus the pre-registered pick-one rule): `critique/decisions/vtsr-2v2-ladders.md`. Experimental posture on every surface.

**The format lines (`scripts/match_format.py`).** One classifier decides which ladder *considers* a match; each ladder still applies its own outcome / duration / void / cancellation gates. Shape = non-campod leaderboard rows per side (slots 1-5 / 6-10), so camera-pod spectators never count, however many there are.

| `match_format` | Shape | Ladder |
|---|---|---|
| `1v1` | 1 and 1 | VTSR-1v1 (§13.15) |
| `2v2` | 2 and 2, each side's commander row among them, all four active (no `is_low_activity` row, no `is_zero_damage` thug) | VTSR-2v2 |
| `team` | at least 3 per side (`TEAM_MIN_PER_SIDE`) and at least 6 total | VTSR-T, then VTSR-C |
| `other` | anything else (2v1, 3v1, 3v2, 4v2, 1v0, an inactive 2v2) | none |

The stamp is written onto every `matches.json` entry and every `match_contributions.json` entry as `match_format`. It is a display / filter field only, never a rating input. **Main-ladder rule:** `scripts/elo.py` gained `ELO_MIN_PER_SIDE = match_format.TEAM_MIN_PER_SIDE` (3) and the exclusion reason `short_side`, tested after `low_player_count`; `elo_current.json` reports `min_per_side` and `matches_excluded_short_side`. No rated match had a side below 3 when it landed, so `elo_history.json` stayed byte-identical and `ELO_SCHEMA_VERSION` was not bumped (inert-change precedent).

**Three ratings, three ladders** (`ladders.commander` / `ladders.thug_wl` / `ladders.thug_perf`):

- **VTSR-2v2C** — commander, win/loss.
- **VTSR-2v2T (win/loss)** — thug, win/loss, in the **same walk** as 2v2C:

$$E_1 = \frac{1}{1 + 10^{-((C_1 - C_2) + \lambda (T_1 - T_2))/400}} \qquad \Delta C = K_c (S - E) \qquad \Delta T = K_t (S - E)$$

  This is VTSR-C's formula with the thug term drawn from the 2v2 thug ladder, `λ = TWO_V_TWO_LAMBDA = 1.0`. Within a side the commander and thug move by the same amount whenever their K matches.
- **VTSR-2v2T (performance)** — thug vs thug damage duel, **recorded games only**: \(S_A = e_A / (e_A + e_B)\) with \(e\) = `personal.enemy_dealt` (VTSR-T's own axis), \(E_A = 1/(1 + 10^{-(R_A - R_B)/400})\), \(\Delta R_A = K (S_A - E_A)\). It needs no winner, so an undecided game still rates; skipped when both thugs dealt 0.

Both thug ratings are published side by side by operator decision; the memo's pick-one rule opens at 30 recorded determined 2v2s (`TWO_V_TWO_REVIEW_GAMES` in the validator and the Tools footer). Do not retune λ, K or the scale to produce a winner. Every role uses one K per game (the mean of both players' scheduled K, `40 → 20` linear over their first 5 games in that ladder), so each role is exactly zero-sum. Anchor 1500, no floor, no seeding, provisional below 5 games. **All three show numbers** (like VTSR-1v1): the never-a-number rule belongs to the main VTSR-T ladder only.

**Eligible games.** Win/loss walk: a recorded strict 2v2, not void, `duration_sec >= 240`, `winner.decided_by` in `adjudicated / attested / clean_win / contested` with team 1 or 2, or `draw` (0.5 each). Dual recordings sharing one match id rate once. Each game also stores, recorded but not scored, each commander's `combat_share` of the side's enemy damage.

**No F9bomber rows.** The first version also rated F9bomber's 2v2 rows (a 2v2 pass of `scripts/import_f9_ledger.py` writing `data/external/f9_ledger_2v2.json`). The operator removed them the same day (memo amendment, 2026-10-27): the ledger file, that importer pass and `scripts/study_2v2_transfer.py` are gone, the importer is back to its pre-2v2 form, and `f9_ledger.json` / VTSR-C are unaffected. That took the win/loss walk from 71 games to the 8 recorded ones.

| Constant | Value | Meaning |
|---|---|---|
| `TWO_V_TWO_ANCHOR` | 1500.0 | Debut rating, every ladder |
| `TWO_V_TWO_K_BASE` / `TWO_V_TWO_K_FLOOR` | 40.0 / 20.0 | K schedule endpoints (shared per role per game as the mean) |
| `TWO_V_TWO_PROVISIONAL_PRIOR` | 5.0 | K decay horizon (games in that ladder) |
| `TWO_V_TWO_PROVISIONAL_THRESHOLD` | 5 | Provisional badge floor |
| `TWO_V_TWO_LOGISTIC_SCALE` | 400.0 | Expected-score scale (all three) |
| `TWO_V_TWO_LAMBDA` | 1.0 | Thug-gap weight in the win/loss walk |
| `TWO_V_TWO_MIN_DURATION_SEC` | 240 | Duration gate |
| `TWO_V_TWO_LADDER_MIN_GAMES` | 5 | Display-only: games in that ladder for a ranked `#` |
| `INACTIVITY_WINDOW_DAYS` / `COMEBACK_GAMES_REQUIRED` | 90 / 3 | Display-only global activity clock (recorded corpus only, as VTSR-1v1) |

**Evidence (historical, memo §Evidence):** measured on the F9 rows before they were removed, so it can no longer be rerun. The main VTSR-C gap alone called F9's 2v2 winners 75% of 52 times, while adding the thugs' VTSR-T gap made the call worse as λ grew. Commander skill transfers; the main thug rating does not, which is why the handicap reads the 2v2 thug ladder. Seeding 2v2C from VTSR-C is a follow-up candidate that needs its own memo.

**Separation gate:** `_investigation/check_2v2_separation.py` — forbidden consumers (`scripts/elo.py`, `scripts/elo_commander.py`, `scripts/elo_1v1.py`, `js/all-matches-aggregator.js`, `js/players-tab.js`) never name the 2v2 files; `scripts/elo_2v2.py` imports no other ladder and does no I/O; a recompute matches the committed history; every rated game is a strict 2v2 and every eligible one is rated; zero-sum per role; continuous before/after chains; the validator's replay agrees; recorded-only (no `f9_ledger_2v2.json`, every game a recorded match, no F9 counts, no script or JS naming that ledger); `f9_ledger.json`, `f9_community.json` and `elo_history.json` unchanged vs git HEAD. **Validator:** §23 `metric_vtsr_2v2` in `scripts/validate_elo.py` (v1.12; report schema 14 after the recorded-only change) replays the history: win/loss accuracy, performance sign agreement and mean |S − E|, and a replay-integrity diff. Only the win/loss headline reaches `validation_summary.json` (`latest.vtsr_2v2_n` / `vtsr_2v2_accuracy`, for the Tools footer).

**Consumers (all 404-safe):**

- **ELO page**: two top-level pills, `?tab=2v2c` (commander ladder) and `?tab=2v2t` (thug ladders with a `Performance | Win/loss | Compare` view, `&view=`, Performance default); `?tab=2v2` is an alias for `2v2c`. Rows expand to a game log with links back to each match. The How tab carries the 2v2 section (`twoVTwoLaddersHtml()`, a real worked example) and a **Which ladder rates my game?** box (`formatLinesHtml()`) in `js/vtsr-explainers.js`.
- **Player page** Commander tab: `#vt-player-2v2` block (`render2v2BlockHtml()` in `js/player.js` — the three ratings and a game log with partner, opponents, both deltas and map; self-hides without a 2v2).
- **Dashboard per-match Elo tab**: `#section-match-elo-2v2` card (`paint2v2()` in `js/match-elo.js`) on rated recorded 2v2s.
- **Dashboard Outcome card**: a 2v2 branch in `js/balonce-meter.js`. A 2v2 is unavailable to the main join by construction, so `render()` falls back to the shared `ensure2v2HistoryLoaded()` (`window.__vtElo2v2History`, one request shared with `js/match-elo.js`). It shows the gauge + headline + verdict from the stored `wl.expected`, the main-reason line (`mainReason({cmdrLabel: 'VTSR-2v2C', thugNoun: 'thug', validation: {}})` — empty validation, because the corpus record per reason class is a team-game record), the commander and thug win/loss moves, and the thugs' performance duel; an undecided game shows the performance part only, and there is no what-if. `rosterRatings()` returns `{format: '2v2', ...}` so the team panels show `2v2C N` on commanders and `W/L N · Perf N` on thugs. Unlike VTSR-1v1, the meter is allowed to read 2v2 data (the memo records why).
- **Tools Balonce**: `Team | 2v2` format pills (`#vt-tools-balonce-format`). In 2v2 the meter uses 2v2C + 2v2T (win/loss) with the 2v2 file's own λ / scale; the performance rating shows on thug rows as context. It needs exactly 4 playing players in a 2-2 layout. The lobby API cannot tell a spectator from a player, so the user ignores spectators by right-click; hidden lobby members are auto-ignored while 2v2 is on, can be restored, and come back when leaving 2v2. A Team-mode hint offers 2v2 when 4 are counted. The empty-seat rule is off in 2v2. Suggest keeps commanders that are set. The footer quotes the validator's 2v2 call rate over recorded games and the review threshold. `?from=` seeding only works for VTSR-T-rated lobbies, so 2v2 numbers are always today's.
- **Match picker**: a **Format** facet (`1v1 · 2v2 · 3+v3+ · Other` chips with counts) from `manifest[].match_format`, state `pickerState.formats`, URL `?fmt=`, an optional key under `vt.picker.filters.v2` (no storage-key bump). A missing stamp reads as `other`.
- **All Matches → Experimental** (`js/players-tab.js`): recorded games count only when `match_format === 'team'`; the F9 ledger stays size-based (3v3+). The old `3+ a side / All rated` Schedule toggle was retired, since with the 3-per-side rule both settings gave the same list.

## 14. Player Profile Pages (`player/`)

The Player Profile Pages are the project's **fifth standalone page**, sibling to `index.html` / `docs/index.html` / `raw/index.html` / `odf/index.html`. The page system is **dual-runtime**:

1. **Pre-generated stubs** at `player/<slug>/index.html` — one HTML file per player with `matches_played >= 5`, rendered by `scripts/generate_player_pages.py` from `scripts/player_template.html`. Each stub carries per-player Open Graph meta tags in `<head>` for rich Discord / Slack / Twitter unfurls, plus a tiny `window.__vtPlayerBoot` script block that hints the client-side renderer at the player's identity before fetch.
2. **Runtime fallback** at `player/index.html` — same client-side JS (`js/player.js`) renders unstubbed players (`?p=<steam64>` / `?slug=<slug>`), the **Directory** landing (no params), and the **Compare view** (`?compare=<csv-of-slugs>`, capped at 4 players).

### 14.1 Files

| File | Purpose |
|---|---|
| `player/index.html` | Triple-duty shell: Directory landing / runtime single-player / compare view. Loads Chart.js + zoom plugin + the project's standard theme stack. |
| `js/player.js` | Single bundle that dispatches on URL params (`?p`, `?slug`, `?compare`, none). Renders all seven single-player tabs (Overview · Rating · Commander · Axes · Highlights · Rivals · Loadout) plus the compare view plus the directory. Self-contained — no shared state with the dashboard. |
| `css/player.css` | Player-page-specific styles (hero card, tier badges, sparklines, compare grid, axis cards). Sibling of `css/odf-browser.css`. |
| `scripts/generate_player_pages.py` | Python generator: slug allocator (sticky), stub renderer, idempotent file writer. Wired into `scripts/process_stats.py::main()` after `elo_current.json` is written. |
| `scripts/player_template.html` | Mustache-style `{{...}}` template for the per-player stubs. Substitutes name, tier, VTSR-T, peak, matches, OG title/description/url/image, canonical link, boot-hint. |
| `scripts/build_og_card.py` | One-shot Pillow script that builds the single universal 1200x630 `data/og/player-card.png` from `img/isdf-logo.png`. Not invoked from the pipeline (run manually when the design changes). |
| `data/processed/player_slugs.json` | Sticky `{steam64: slug}` map (schema_version 1). Generator never re-allocates an existing entry — once a player has a slug, the URL is stable forever. |
| `data/og/player-card.png` | Single universal 1200x630 OG image referenced by every stub. |

### 14.2 Slug allocation

`scripts/generate_player_pages.py::sanitize_to_slug(name)` produces a URL-safe slug from a player display name:

1. NFKD-normalize + strip combining marks.
2. Lowercase, replace any non-`[a-z0-9]` run with `-`, strip leading/trailing dashes, collapse repeated dashes.
3. Truncate to 48 characters.
4. If the result is empty or in `RESERVED_SLUGS` (`{"", "index", "compare", "search", "new", "edit", "api", "_", "-", "null", "undefined"}`), fall back to `player-<steam64-low16>`.

`allocate_slug(name, steam64, existing_map)` is the sticky wrapper:

- If `steam64` already has an entry in `existing_map`, return that entry verbatim — even if the player's display name has since changed. **Stability is the contract.**
- Otherwise compute `base = sanitize_to_slug(name)`. If `base` collides with an already-allocated slug (for a different `steam64`), append `-<short-hash>` derived from the new `steam64`.
- Persist the new entry to `existing_map` and return it.

`data/processed/player_slugs.json`:

```json
{
  "schema_version": 1,
  "generated_at": "2026-05-17T19:02:14Z",
  "slugs": {
    "76561197960287930": "vtrider",
    "76561198013527887": "f9bomber"
  }
}
```

**Never bump `schema_version` to indicate slug changes** — slug churn is forbidden by design. Schema bumps would only happen if the JSON shape itself changes.

### 14.3 Template + generation

`scripts/player_template.html` carries `{{...}}` substitution markers. Generator pass:

1. Iterate `elo_current.ratings[]` filtered to `matches_played >= PREGEN_MIN_MATCHES = 5`.
2. Resolve the player's slug via `allocate_slug()`.
3. Compute derived fields: tier label (from `VTSR_TIERS` — mirrored from `js/app.js:1448-1454`), peak VTSR-T, peak date, primary ship name, OG-description text (e.g. `"Veteran II • VTSR-T 1623 • 47 matches"`).
4. Substitute into the template, write to `player/<slug>/index.html`.
5. **Idempotency**: skip the write if the new content byte-for-byte matches the existing file. Print a one-line summary at the end (`X stubs written, Y unchanged, Z removed`).
6. **Pruning**: any `player/<existing-slug>/index.html` not in this pass's output set is deleted only if its `<!-- vtstats-player-stub:vN -->` marker matches the current `PLAYER_TEMPLATE_VERSION`. Stub files without the marker are left alone (manual edits, sanity check).

`PLAYER_TEMPLATE_VERSION` (currently `22`; v22 dropped the VTSR-T directory sorts, added `js/vtsr-display.js`, and took VTSR-T numbers out of the OG description, which now reads e.g. `Tier 2 · Upper · 111 career matches · Thug`) is bumped whenever the rendered stub HTML shape changes (e.g. topnav additions, OG tag tweaks). Bumping forces a full regeneration even when the per-player numbers haven't moved. Does **NOT** require bumping `PIPELINE_VERSION` — the stub HTML is downstream of the JSON contract.

### 14.4 Pages — three modes

**Directory** (`player/index.html` with no params): card grid of **ranked players only** — a player is listed when they are on the VTSR-T ladder (`leaderboard_eligible`: 25 matches and globally active) **or** on the VTSR-C ladder (the commander OR-gate plus active and command-recent); `buildPlayerRows()` drops everyone else, so an inactive or provisional player no longer appears just because they have a ratings row (their profile page still works by direct link). Toolbar: free-text name search + tier multi-chips + role-bias chips (Commander / Mixed / Thug-only, derived from `matches_as_commander / matches_played`) + faction chips (most-flown via `career_loadout.primary_ship`'s first letter) + activity bucket (Active / Rusty / Inactive, based on `last_match_date` distance) + sort dropdown + compare-mode toggle. Each card carries name, VTSR-T band + position bar, a numeric VTSR-C chip when the player has one, role split chip, primary ship, and a **two-row sparkline block** (`.vt-player-card-sparks`): a `T` row of the last ten VTSR-T changes (`win_history` on `elo_current.json`; shape only, no digits) and, for commanders, a `C` row of the last ten VTSR-C changes (`ratings[].win_history` on `elo_commander_current.json`, appended by `_push_history()` in `scripts/elo_commander.py` at both the telemetry and F9 duel sites). List view keeps both rows at 18px; mobile hides the block. View-profile click target.

**Single profile** (pre-gen stub or `player/index.html?p=<steam64>` / `?slug=<slug>`): seven tabs. The address bar tracks the open pill: Overview omits `tab`, and the others are `?tab=rating|commander|axes|highlights|rivals|loadout` (a redundant `overview` or unknown slug is stripped). Pill clicks `pushState`, so Back returns to the previous pill. The same query works on `/player/<slug>/`.

Directory filters are shareable when they differ from the defaults: `q`, `tier` (CSV), `role` (`commander`|`thug`; `any` omitted), `activity` (`any`|`veteran`|`pillar`; `active` omitted), `sort` (`name-asc` omitted; the others are `vtsrc-desc`, `matches-desc`, `last-desc` — VTSR-T is never a sort key, and an old `vtsr-*` / `peak-desc` value falls back to the default). Compare adds `x=matches` when the progression chart is not by date. Search commits push once, then replace while the field stays focused.

| Tab | Contents |
|---|---|
| Overview | Hero card (name, tier, VTSR-T big, peak chip, sparkline, corpus rank). Career snapshot stats. 8-axis radar (`renderVtsrRadar` in `js/player.js` over `rating.axis_means` — the VTSR-T axes, including PvE Share) with a dashed corpus-median ghost. Strengths/weaknesses ranking panel. Coaching cards (static per-axis copy dict triggered when z<median) with +0.5σ ΔVTSR quick-wins projection. The All Matches Career Radar stays the descriptive `renderPlayerRadar(mode='career')` spider in `js/charts-radar.js`. |
| Rating | Chart.js time-series of `elo_history.history[].deltas[].after` filtered by steam64. Tier-band background via inline plugin, anchor (1500) + floor (1000) reference lines, peak annotation, wheel/pinch zoom (vendored `chartjs-plugin-zoom`), preset zoom chips (`All` / `Last 50` / `Last 20` / `Last 10`), click-point-to-scroll. Virtualized sortable match-log table below with expandable per-row axis-contribution detail (reads `delta.axis_contributions`). |
| Commander | VTSR-C rating, ladder rank, corpus W-L-D, time-series, vs-the-call and squad-gap lines, opposing commanders, favorite maps, faction when commanding, rated duel log, and v4 economy tiles (recorded, not scored). Empty state when the player has no commanded games and no rated duels. |
| Axes | Per-axis time-series mini-cards (8 cards in a responsive grid). Each card is a tiny inline-SVG sparkline of the player's `axis_contributions[axis]` over time. |
| Highlights | `career_highlights.cards[]` filtered to entries where this player is `winner` or `runner_up`. Renders via `renderHighlights(mode='career')` from `js/app.js`. |
| Rivals | Top-10 two-way damage rivals, walked from each match's `rivalry_matrix`. |
| Loadout | `career_loadout` ship-share donut. Per-ship combat table from `career_per_ship_combat`. |

**Compare view** (`player/index.html?compare=<slug1>,<slug2>,...`, capped at `COMPARE_MAX = 4`): hero strip of mini-cards (name, tier, VTSR-T, remove `×`, "Add player" slot when under cap), then four panels:

1. Overlaid 8-axis radar — one polygon per player, distinct colors.
2. Overlaid VTSR-T time-series with `By date` ↔ `By matches played` toggle.
3. Transposed stat grid — rows are stats (VTSR-T, K/D, accuracy, etc.), columns are players, with best/worst cell highlighting.
4. Common-matches table — rows are matches all selected players appeared in.

The same push / popstate contract is used on the other standalone pages. Map directory: `q`, `pools`, `size`, `tag`, `played`, `author`, `sort` (`played-desc` omitted); `?file=` is not rewritten to a stub. Models directory: `q`, `faction` (omit `ISDF`; `all` when unfiltered), `cat` (omit `Building,Vehicle`; `all` when empty), `sort` (omit `name`); opening a model keeps those params. LEGO directory: `q`, `faction` (omit `all`), `sort` (omit `name`); a model opens on HQ, so `view` is omitted for HQ and set to `standard` or `photos` otherwise. Tools randomizer pills: `?tab=coinflip|maproll` (`shitwheel` omitted). ODF browser: `?cat=` when the sidebar category is not Vehicle and no ODF or compare is selected. Build trees: `?faction=hadean|scion` (`isdf` omitted), and Back restores the faction. ELO pills push `?tab=` the same way the player pills do (`vtsr-t` / `leaderboard` stay omitted).

### 14.5 Aggregator extension — `opts.minMatchesThreshold`

`js/all-matches-aggregator.js::build()` historically applied a hard-coded `MIN_CAREER_MATCHES = 5` floor. The player page needs to surface stats for fresh players (1–4 matches) on their own profile, so the function now accepts an optional second-call argument:

```js
window.VTAggregate.build(contributions, fileIds, elo, opts)
//   opts.minMatchesThreshold: number | undefined
//   - undefined → MIN_CAREER_MATCHES (5)
//   - 0         → no threshold (player page uses this)
//   - N         → apply threshold N
```

The dashboard's All Matches view still passes `undefined` (preserves the 5-match floor). `js/player.js` passes `0` so fresh players still get a career row. `meta.min_career_matches` echoes the effective threshold so downstream UI can label it correctly.

### 14.6 Cross-link helpers in `js/app.js`

Three globals power the dashboard-wide cross-linking, all attached to `window` in `js/app.js`:

| Helper | Purpose |
|---|---|
| `vtPlayerHref(steam64)` | Returns `player/<slug>/` if the slug map has an entry for `steam64`; otherwise returns `player/index.html?p=<steam64>`. Slug map is cached on `window.__vtSlugMap` (`{[steam64]: slug}`), loaded once in `ensureEloLoaded()` from `data/processed/player_slugs.json`. 404-safe. |
| `vtPlayerLinkHtml(name, steam64, opts)` | Returns `<a class="vt-player-link" href="...">${name}</a>` when `steam64` is truthy; falls back to a plain `<span class="vt-player-link-fallback">${name}</span>` when no Steam64 is available (e.g. legacy match data). `opts.title` overrides the tooltip; otherwise uses the player name. |
| `vtSteam64FromName(name)` | Reverse-lookup helper. Iterates `currentData.header.s64_to_nick` to find the Steam64 for a given display name. Used by `renderKillFeed()` because kill-feed entries carry display names only. Returns `null` on miss. |

The Steam64-bearing aggregator fields (`commander_stats.head_to_head[].a_steam64` / `.b_steam64` / `global_rivalries[].a_steam64` / `.b_steam64`) were added so the cross-link helpers can resolve names back to canonical URLs without per-call reverse-lookups. The aggregator builds a `nameToSid` map from `careerStatsKept` and joins it onto the rivalry rows before emit.

Six cross-link locations in `js/app.js`:

1. `renderLeaderboard()` — per-match leaderboard
2. `renderKillFeed()` — killer + victim names (with `vtSteam64FromName` reverse lookup)
3. `renderVtsrLeaderboard()` — VTSR-T leaderboard
4. `renderCareerTable()` — All Matches career table
5. `renderHighlights()` — winner + runner-up names on both match and career mode
6. `renderCommanderH2H()` + per-match + global `renderGlobalRivalries()` — name pairs

`.vt-player-link` + `.vt-player-link-fallback` styles live in `css/vtstats-theme.css` (loaded by every dashboard page); they mirror the `.vt-odf-link` pattern.

### 14.7 OG image

`data/og/player-card.png` (1200x630) is the single universal Open Graph image referenced by every stub. Built once from `img/isdf-logo.png` via `scripts/build_og_card.py` (Pillow dependency, not invoked by the pipeline). When opened in Discord / Slack / Twitter, the unfurl carries the universal image + the per-player OG title (`"<Name> — VT Stats"`) + OG description (`"<Tier> • VTSR-T <rating> • <matches> matches"`). Per-player custom images were deferred to keep generation time low and reduce repo churn; if added later, the per-player URL would slot into the `og:image` template marker without further changes.

### 14.8 Production host

`SITE_URL = "https://vtstats.bz"` (module constant in `scripts/generate_player_pages.py`, per the project `CNAME`). Used for absolute OG URLs and the canonical link in each stub's `<head>`. If the host ever changes, this is the single place to update.

### 14.9 Picker filter contract

Player profile pages are **picker-unaware** — they ignore the dashboard's match-picker filter entirely. The directory and per-player views always run against the **full corpus**. This is intentional: a player profile is a stable identity surface that shouldn't shift under filter chips chosen elsewhere. (The dashboard's All Matches view remains picker-aware as before.) The player-page directory has its own toolbar with chips that filter the displayed cards, but those chips never re-aggregate the underlying career stats.

## 15. Map Browser Pages (`map/`)

The Map Browser is the project's **sixth standalone page**, sibling to `index.html` / `docs/index.html` / `raw/index.html` / `odf/index.html` / `player/index.html`. Mirrors the player-pages architecture with deliberate simplifications: maps already have URL-safe slugs (the lowercased `map_file` stem from `build_map_registry.map_key()`) so there's no allocator and no stickiness layer. The page system is **dual-runtime**:

1. **Pre-generated stubs** at `map/<mapfile>/index.html` — one HTML file per map in `data/map-registry.json` (~143 once Phase 1's universe-extension lands in production), rendered by `scripts/generate_map_pages.py` from `scripts/map_template.html`. Each stub carries per-map Open Graph meta tags pointing at the map's actual top-down screenshot for rich Discord / Slack / Twitter unfurls, plus a tiny `window.__vtMapBoot` script block that hints the client-side renderer at the map identity before fetch.
2. **Runtime fallback** at `map/index.html` — same client-side JS (`js/maps.js`) renders the **Directory** landing (no params), single maps via `?file=<slug>` for the rare uncovered map, and degrades gracefully when `map_stats.json` is missing.

### 15.1 Files

| File | Purpose |
|---|---|
| `map/index.html` | Triple-duty shell: Directory landing / runtime single-map / error fallback. Loads only Bootstrap + theme + `js/maps.js` (no Chart.js — v1 single-map view has no live charts). |
| `js/maps.js` | Single bundle that dispatches on URL params (`?file`) or the `window.__vtMapBoot` hint set by stubs. Renders the directory grid (search + pools/size/tags chips + played-status radio + author dropdown + sort) and the single-map view (hero strip + match summary + top commanders + recent matches + `Coming soon` placeholders). Self-contained — no shared state with the dashboard. |
| `css/maps.css` | Map-page-specific styles (hero card, card grid, single-view sections, placeholder cards). Sibling of `css/player.css`. |
| `scripts/generate_map_pages.py` | Pure module: `compute_map_stats(all_match_data, registry)` aggregator + `_render_map_stubs()` template renderer + `run(...)` entrypoint. Wired into `scripts/process_stats.py::main()` after the player-slug block (soft-fail). |
| `scripts/map_template.html` | Mustache-style `{{...}}` template for the per-map stubs. Substitutes title, file slug, OG title/description/url/image, canonical link, boot-hint, template-version. |
| `data/processed/map_stats.json` | Per-map roll-up keyed by `map_file` (`schema_version: 1`). Fields: `match_count`, `total_duration_sec`, `avg_duration_sec`, `first_played`, `last_played`, `top_commanders[]` (top 10 by `matches_commanded`, capped at `MAX_TOP_COMMANDERS = 10`, excludes `is_campod` / `is_low_activity`), `recent_matches[]` (10 most recent, full chronology, capped at `MAX_RECENT_MATCHES = 10`). |
| `data/og/map-card.png` | Generic 1200×630 OG image used as fallback when a map's per-map screenshot is missing. v1 is a copy of `data/og/player-card.png`; a maps-themed variant is a future polish. |

### 15.2 Slug derivation (no allocator)

Map slugs are the lowercased `map_file` stem from `build_map_registry.map_key()` (e.g. `havenvsr`, `stredslopevsr`, `vsr-training`). Already URL-safe by VSR-map naming convention. No sticky allocation, no schema_version on a slug map — the map filename IS the slug.

`scripts/generate_map_pages.map_slug(map_field)` is the unified resolver: strips `.bzn`, lowercases, runs `SLUG_SAFE_RE = ^[a-z0-9_-]+$` for filesystem safety, and applies the `RESERVED_MAP_SLUGS` collision rename (`<slug>-map` suffix). Currently zero hits on either guard against the 143-map vsrmaplist corpus; both are defensive.

### 15.3 Catalog completeness contract

`compute_map_stats()` emits an entry for **every key in `registry`**, not just maps with sessions. Unplayed maps get the schema with all stat fields zeroed (`match_count: 0`, `top_commanders: []`, `recent_matches: []`, `first_played: null`, `last_played: null`). The directory's hero counter ("X with match data") and the **Unplayed** filter chip both depend on this.

Aggregation rules:
- `match_count` and friends count every match the map appears in (no exclusion gates — the match itself happened).
- `top_commanders` excludes per-row `is_campod` and `is_low_activity` (mirrors VTSR-T exclusion contract). Sort key: `(-matches_commanded, -last_appearance_iso)` so ties break to the more-recently-active commander.
- `recent_matches` is straight `sorted(date desc)[:10]` regardless of exclusion gates — users want the actual chronology, not a sanitised subset. `commanders["1"]` / `commanders["2"]` is `null` when that team-leader slot was unfilled; renderer maps to em-dash. `winner_team` is `null` for `winner_decided_by == "unclear"`.

### 15.4 OG strategy (per-map images)

Each pre-gen stub references `https://vtstats.bz/data/maps/<slug>.png` as `og:image` when that file exists on disk at stub-render time. Falls back to the generic `https://vtstats.bz/data/og/map-card.png` when the per-map PNG is missing (the existence check is critical — registry rows can carry an `image_path` while the actual file is missing if a download failed mid-run). `_resolve_og_image_url()` in `generate_map_pages.py` is the gate.

`og:description` format (`_build_og_description`):
```
"<author> · <pools>p / <loose> loose · <formatted_size> · <match_count> matches recorded"
```
Em-dashes for missing fields. When `match_count == 0`, the suffix becomes `"no matches recorded yet"` so unfurls don't carry a misleading "0 matches". Truncated to ~280 chars to fit Discord's display limit. A registry blurb prefix is prepended when the core stat row is short enough to leave headroom.

### 15.5 Pre-gen invariants

- `MAP_TEMPLATE_VERSION = 1` (module constant in `scripts/generate_map_pages.py`). Bump only when the rendered stub HTML shape changes — a forced re-render of every stub on the next pipeline run, orthogonal to `PIPELINE_VERSION` (per-match cache).
- Stub HTML is byte-compared against on-disk content; we only write when the rendered string differs (idempotent — no churn on no-delta runs). Same posture as `_render_player_stubs()`.
- `map_stats.json` writes use the `_stable_equals` pattern (mirrors `generate_player_pages._stable_equals`) so empty-delta runs don't gratuitously rewrite the file. `generated_at` is excluded from the equality check.
- Stale-stub cleanup: do **not** delete stubs even when a map disappears from the registry (matches the player-pages stale-stub posture). URLs stay valid forever; the runtime fallback handles the case where the map no longer has data.

### 15.6 Picker filter contract

Map browser pages are **picker-unaware** (mirrors VTSR-T leaderboard). The picker is for narrowing a single match-set; the map browser is for catalog browsing. Per-map stats are computed corpus-wide in the pipeline. `js/maps.js` does no client-side aggregation — it consumes `map_stats.json` + `map-registry.json` directly.

### 15.7 Cross-links

| Site | Wiring |
|---|---|
| Topnav `Maps` link | Added on `index.html`, `docs/index.html`, `raw/index.html`, `odf/index.html`, and the player template (which forces `PLAYER_TEMPLATE_VERSION` 5 → 6 to re-render every player stub with the new nav). |
| Map Info Modal title | `#map-info-modal-title-link` wraps the title text; clicks open `/map/<slug>/` in a new tab (`target="_blank" rel="noopener"`) so the modal stays open. Wired in `renderMapInfoModal()` in `js/app.js`. |
| Map Info Modal footer | New `#map-info-modal-page-link` `View full map page` button next to Close. Same `target="_blank"` semantics. Toggled `d-none` together with the title link when `meta.key` is empty. |
| Match-info banner | New `#info-map-link` button next to `#info-raw-link`, wired in `renderMapBannerFields()`. Hidden via `d-none` when the match's map isn't in the registry (parallel to the `#info-map-thumb-btn` toggle). |
| All Matches → Meta tab | `renderMetaFactionMaps()` in `js/app.js` links each map name to `map/<slug>/` in a new tab. |

`.vt-map-title-link` styling lives in `css/vtstats-theme.css` (mirror of `.vt-odf-link`) so the dashboard picks it up without loading the map-browser stylesheet.

### 15.8 Title-resolver triplication caveat

The iterative `XYZ: ` prefix-stripping logic for map display titles (`"ST: VSR: TVD: Ebola"` → `"Ebola"`) lives in **four** places:

1. Python `resolve_match_name()` in `scripts/process_stats.py` (manifest emission).
2. Python `map_title_resolver()` in `scripts/generate_map_pages.py` (stub rendering).
3. JS `mapNameResolver` in `js/app.js` (All Matches → Meta tab labels).
4. JS `stripTitlePrefixes()` in `js/maps.js` (gallery + single-map title).

Touch all four when the rule changes. Mirrors the `VTSR_TIERS` duplication caveat between `js/app.js` and `scripts/generate_player_pages.py`.

### 15.9 Production host

Same `SITE_URL = "https://vtstats.bz"` module constant as the player generator (per the project `CNAME`). Used for absolute OG URLs + canonical link.

## 16. Lobby Tools Page (`tools/`)

The Lobby Tools page is the project's **seventh standalone page**, sibling to the dashboard / docs / raw browser / ODF browser / player pages / map browser. Unlike every other page, it's **purely client-side** with no pipeline output of its own — it composes existing artifacts (`elo_current.json`, `player_slugs.json`, `known-hosts.json`, `steamid_to_name.txt`, `vsrmaplist.json`, `map-registry.json`, `matches.json`) into a screen-share-friendly five-utility surface for in-lobby decisions (commander picks, team balance, map rolls, coinflips).

### 16.1 Files

| File | Purpose |
|---|---|
| `tools/index.html` | Page shell: topnav, header action row, six section cards in a 2-col CSS grid (above 1280px) / single-col stack (below), reset/discard modals, Bootstrap toast container. No pre-gen stubs — single page. |
| `css/tools.css` | All page-specific styling: grid layout, card chrome, wheel canvas, slot machine reels, coinflip selector, team-balonce columns + the Tools-only Balonce Meter chrome, toast container, etc. The gauge itself is styled by the shared `.vt-balonce-*` block in `css/vtstats-theme.css`. |
| `js/live-session-card.js` | Stateless renderer factored out of the legacy `js/active-game-indicator.js`. Exposes `window.VTLiveSessionCard.{renderTitle, renderBody, renderFooter, renderInto}`. Keeps `.vt-active-game-modal-*` CSS class names verbatim for stability. |
| `js/tools/player-resolver.js` | 4-tier Steam64 resolver: `player_slugs` → `elo_current.ratings[].name` → `steamid_to_name.txt` → lobby nickname. Eager loaders for known-hosts + elo + slugs + vsrmaplist; lazy loader for steamid-to-name. Exposes `window.VTToolsResolver.{ready, resolve, resolveCustom, searchByName, getKnownHosts, getKnownHostNames, getVsrMapByFile, getCanonicalNames, getEloMeta}`. |
| `js/tools/live-session.js` | Polls `BZ2API.fetchSessions()` at 60s idle / 15s when a known-host VSR lobby is live (60s floor while the tab is hidden, 120s error cap), filters by `known-hosts.json` allowlist + `gameBalance === 'VSR'`, renders into the Live Session card via `VTLiveSessionCard`. Lock-lobby toggle freezes the surfaced snapshot; ignore-live toggle stops polling entirely. |
| `js/tools/toast-manager.js` | Thin Bootstrap-toast wrapper. `showJoin(name, count)` / `showLeave(name, count)`. Suppressed by callers when `lobbyLocked \|\| ignoreLive \|\| mode === 'manual'`. |
| `js/tools/wheel.js` | Canvas player wheel with theme-reactive alternating `--kb-primary`/`--kb-secondary` slices (read via `getComputedStyle` + `MutationObserver` on `<html>`). Spin physics via `requestAnimationFrame` with ease-out cubic (4–6s; `prefers-reduced-motion` → 800ms snap). Result modal with Steam + VTstats deep-link icons + Remove-from-wheel + Spin again. Wheel-local `removedSteam64s` set persists across roster updates. |
| `js/tools/coinflip.js` | Horizontal-shuffle team selector. Pre-computed winner via `Math.random() < 0.5`. Decel animation over ~2s; `prefers-reduced-motion` → 500ms snap. Team labels from live session's `teamNames.svar1`/`svar2` when present. |
| `js/tools/map-roll.js` | Three-reel slot machine. Reel 1 (Popular) reads `vsrmaplist.json` entries where `Tags` contains `popular`. Reel 2 (Played) reads `matches.json` map_files. Reel 3 (Unplayed) reads `map-registry.json` minus Played. Pool-count pills (7+/6+/All) filter all three reels. Staggered reel deceleration 3s/4s/5s. Reveal cards link to per-map `map/<file>/`. Lazy-loads `matches.json` + `map-registry.json` on first interaction. |
| `js/tools/team-balonce.js` | Commander configurator + odd-lobby-aware partition + drag-to-swap + the Balonce Meter (gauge itself comes from the shared `js/balonce-meter.js`; see §13.14). Intentional community-in-joke misspell (`Balonce`); applies everywhere — file name, section title, CSS class names. |
| `js/tools/main.js` | Page bootstrap, state machine, Mode/Ignore/Lock/Reset wiring, beforeunload guard. Awaits `VTToolsResolver.ready` before initialising sub-modules. Broadcasts the active roster via a `CustomEvent('vt-tools:roster')` and component reset via `CustomEvent('vt-tools:reset-all')`. |

### 16.2 Page state machine

```
pageState = {
  mode:         'auto' | 'manual',
  ignoreLive:   bool,                    // master kill-switch
  lobbyLocked:  bool,                    // mirrored from VTLiveSession
  liveRoster:   ResolvedPlayer[],        // populated by VTLiveSession
  manualRoster: ResolvedPlayer[],        // populated by Active Roster picker
  activeRoster: ResolvedPlayer[],        // derived: mode==='auto' ? live : manual
  lastSessionId: string | null,
  hostName:     string | null,
  components: {
    wheel:    { lastWinner, removedSteam64s: Set, isSpinning, method },
    coin:     { lastResult, mode },
    mapRoll:  { lastResults: [r1,r2,r3], poolFilter, isRolling },
    balonce:  { commanderSetup: {team1, team2}, manualSwaps: Set, partition },
  },
}
```

Cross-toggle interactions (enforced in `main.js::setIgnoreLive` and `setMode`):

| Trigger | Effect |
|---|---|
| `ignoreLive` flips ON | Polling stops; `mode` force-set to `manual`; Auto radio disabled with tooltip; `lobbyLocked` force-cleared. |
| `ignoreLive` flips OFF | Polling resumes; Auto radio re-enabled. Mode stays Manual — user must opt back in via the Mode toggle. |
| Mode Auto → Manual | Snapshot current `liveRoster` into `manualRoster`; auto-unlock if locked. |
| Mode Manual → Auto | Confirm-discard modal if `manualRoster.length > 0`; clear `manualRoster`; resume polling-driven roster. |
| `lobbyLocked` flips ON | Polling continues silently; surfaced session/roster frozen; join/leave toasts suppressed. |
| `lobbyLocked` flips OFF | Latest polled state applied immediately; toast baseline reset (no spurious join/leave spam). |
| `Reset all` confirm | Wipe every component state + restore defaults (`mode: 'auto'`, `ignoreLive: false`, `lobbyLocked: false`). Trigger immediate poll. |

### 16.3 Beforeunload (dirty) flag

`window.beforeunload` is guarded by `VTToolsMain.isDirty()` returning `true` when ANY of these conditions holds:

- `mode === 'manual' AND manualRoster.length > 0`
- `wheel.lastWinner !== null` (wheel has been spun)
- `wheel.removedSteam64s.size > 0` (any player removed)
- `coin.lastResult !== null` (coin has flipped)
- `mapRoll.lastResults.some(r => r !== null)` (any reel has landed)
- `balonce.partition !== null` (balance has been computed)
- `balonce.manualSwaps.size > 0` (any drag-swap)

### 16.4 Identity resolution

The 4-tier `VTToolsResolver.resolve(steam64, lobbyNick)` returns a `ResolvedPlayer` with this shape:

```js
{
  steam64,               // string or null (custom entries)
  displayName,           // resolved per the priority chain
  lobbyNick,             // pass-through, null when same as displayName (case-insens)
  slug,                  // VTstats slug or null
  steamProfileUrl,       // https://steamcommunity.com/profiles/<steam64>/ or null
  vtstatsUrl,            // ../player/<slug>/ or ../player/index.html?p=<steam64>
  vtsr,                  // 1500 anchor when provisional/unknown/custom
  thugElo, winsElo,      // numbers or null
  matchesPlayed,         // number or 0
  matchesAsCmdr,         // number or 0
  matchesAsThug,         // number or 0
  cmdrShare,             // matches_as_cmdr / matches_played
  tier,                  // 1..7 from VTSR_TIERS (mirrored)
  cmdrHint,              // 'strong' | 'curious' | 'rare' | null
  isProvisional,         // bool
  isUnknown,             // bool — not in any registry
  isCustom,              // bool — user-typed entry, no Steam64
}
```

Priority chain for `displayName`: `player_slugs.json[steam64].name` → `elo_current.ratings[].name` → `steamid_to_name.txt[steam64]` → lobby nickname → `"Unknown player"`.

Provisional anchoring rules (used by team-balonce sums + roster row chips):
- **Unrated** (no entry in `elo_current.ratings[]`): VTSR `1500`, `isUnknown=true`, `isProvisional=true`, renders `provisional` chip.
- **Rated but provisional** (`elo_current.ratings[i].matches_provisional=true`): actual VTSR carries through, `isProvisional=true`, renders `provisional` chip.
- **Custom** (user-typed, no Steam64): VTSR `1500`, `isCustom=true`, `isProvisional=true`, renders `custom` chip.

### 16.5 Topnav scope decision ("Reading C")

The Lobby Tools rollout **deleted** the elaborate LIVE pill widget from every non-dashboard shell. The dashboard (`index.html`) keeps a compact `#vt-active-game` widget (LIVE pill + Join + GameWatch) whose click-through `#gamewatch-modal` iframes `/gw/?embed=1`. Full live-session interaction (roster, lock, ignore) lives at `/tools`.

- `js/active-game-indicator.js` is **dashboard-only**: `init()` early-returns unless `#vt-active-game` exists. It polls every 60s (presence-only; no fast path; 60s floor while the tab is hidden; 120s error cap), filters by `known-hosts.json`, renders the LIVE pill, and flips `data-vt-tools-live="0|1"` on the dashboard `[data-vt-tools-link]`.
- Game Watch (`js/gw/poller.js`) polls at 60s idle / 15s when an of-interest INGAME/PREGAME lobby is live (15s only while visible; 60s hidden floor; 120s error cap). `/gw/` does not load the indicator.
- Tools live-session uses the same 60s / 15s / 60s-hidden / 120s-error cadence. Ignore-live still pauses polling entirely.
- The `Tools` topnav link (icon `bi-controller`) is the pulse target on the dashboard. CSS keyframes in `css/vtstats-theme.css` drive a subtle pulse animation when the attribute is `1`.
- Other standalone shells no longer load `js/active-game-indicator.js`. Pre-gen templates drop it at `PLAYER_TEMPLATE_VERSION` 11 → 12 and `MAP_TEMPLATE_VERSION` 6 → 7 (existing stubs keep the tags until the next generator run; the JS early-return stops their polls immediately).

### 16.6 Balonce Meter (win-probability gauge)

A horizontal pill-shaped track below the Team Balonce columns, driven by the **shared** `js/balonce-meter.js` model — see §13.14 for the formula, the band table, and why commander VTSR-T is displayed but not scored. The chevron sits at `P(Team 1 wins) × 100%`, so chevron-right still means Team 1 is the stronger side (same orientation as the legacy sum-delta meter it replaces) and the centre tick is a true coin flip.

Markup and styling are shared with the dashboard's per-match section (`VTBalonce.meterHtml()`, `.vt-balonce-*` in `css/vtstats-theme.css`), so the two surfaces cannot drift. The Tools card adds its own chrome: a headline row (`Team 2 favored 69%` — the band label itself lives inside the gauge), the component readout (`Cmdr gap` in VTSR-C points and `Thugs: Team N +X%` as win chance), a count-based confidence chip, the uneven-columns empty-seat chip (§16.7), and the honesty footer quoting the validator's accuracy for this formula (lazy 404-safe read of `validation_summary.json`; the figure is omitted, never guessed, when the file is missing) plus a `Ratings as of <computed_at>` stamp from `elo_current.json` (omitted until the resolver has loaded).

The disadvantaged team gets a `Disadvantaged` badge on its column header when the favorite's probability reaches `VTBalonce.DISADVANTAGE_PROB` (0.60) — read from the module rather than duplicated, so the Tools badge and the dashboard copy always agree.

> **Why this replaced the raw-sum gauge.** ΔΣVTSR knows nothing about who is commanding. A real lobby measured during this work sat at ΔΣVTSR −72 — comfortably inside the old green "Well balanced" band — while the commander gap was 168 points; the model correctly called it `PLAYEDathon` at 69%. The legacy `MAX_PLAYED_METER_DELTA` / `CMDR_BAND_*` constants are gone, and the ΔΣVTSR readout was later removed with the move to VTSR-T bands (§13.8); Suggest still uses ΣVTSR internally as its tie-break.

### 16.7 Partition algorithm (even-first, empty-seat aware)

`findBestPartition(thugs, cmdr1, cmdr2)` in `team-balonce.js` exhaustively enumerates ALL `2^M` non-trivial subsets of the thug pool (skip empty + full). Each subset is one team's thugs; complement is the other. Enforce `≤5 players per team` (commander + thugs) AND `|team1Size − team2Size| ≤ MAX_TEAM_SIZE_GAP` (1): an even lobby can only split evenly (6 → 3v3, 10 → 5v5) and an odd lobby differs by exactly one (7 → 4v3). Never 4v2. The header button that drives it is labeled **Suggest** (not "Auto balance") so players read the output as one fair layout rather than a verdict; after a Suggest the card shows an info note (`renderSuggestNote()`, state `suggestionActive`) with a `How Suggest works` modal (`#vt-tools-balonce-suggest-modal`) until any user edit or a roster membership change clears it.

The objective has two modes:

- **Both commanders set** — minimize `|P − 0.5|` under the shared win-probability model, tie-broken by `|ΣVTSR_team1 − ΣVTSR_team2|`. Because the commander gap is a fixed term inside `P`, the search compensates a weaker commander with stronger thugs. Measured example: mort (VTSR-C 1468) vs VTrider (VTSR-C 1616) → Suggest deliberately stacked team 1's thugs to `+137` mean / `+146` sum and landed the lobby at **52%, Good game**. The old objective would have driven the sum toward zero and left VTrider's commanding edge uncompensated.
- **Otherwise** — the legacy `|ΣVTSR_team1 − ΣVTSR_team2|` objective, unchanged. With fewer than two commanders set there is no commander term to balance against.

Odd lobbies (3v2, 4v3, 5v4) are the only uneven layouts Suggest will propose, and inside them the **empty-seat rule** applies: the validated handicap compares thug **means**, which reads a 4v3 as fair, so `padShortSide()` pads the short side's thug list with one phantom thug per missing body at the VTSR-T rating floor (`elo_current.rating_floor`, 1000, via the resolver's `eloMeta.rating_floor`) before its mean is taken. Headcount is bodies (commander included), so the rule behaves the same with 0, 1 or 2 commanders set. Worked example at equal commanders, 4v3 with ~1550 thugs: short mean (3×1550 + 1000)/4 = 1412 → −138 → ~69% for the full side. The same padding runs in `balanceState()` for the live meter, so a hand-dragged 4v3 is priced too. This is a **Tools-only house rule**, not the pipeline formula: only 8 of 174 determined telemetry duels were uneven (and the F9 ledger is even-only), so nothing can be fitted; the `NvM` chip says "empty seat scored as a 1000-rated thug" with an unvalidated-rule tooltip, the footer claim flags the accuracy figure as even-team, and the dashboard Outcome card never applies it (it must reproduce the pipeline's stored `expected`). Promotion path: a `critique/decisions/` memo + a `validate_elo.py` ablation over the seat value once ≥ 30 uneven determined duels exist. Manual drags remain unconstrained by the size rule.

Worst case: 10-player lobby with 0 commanders set → thug pool = 10 → `2^10 = 1024` subsets to enumerate. Trivially fast.

### 16.8 Candidacy ranking (commander auto-suggestion)

For unset commander slots, `rankCandidates(pool)` in `team-balonce.js` computes:

```
candidacy = vtsr_zscore + 1.5 * cmdr_experience_zscore
cmdr_experience = matches_as_commander / max(matches_played, 1)
```

Z-scores are computed against the current `activeRoster` pool (not the corpus mean — this is roster-local). The `1.5×` weight on commander experience handles the "Domakus problem" — a high-VTSR low-cmdr-count player won't get auto-suggested unless nobody in the lobby has any commander experience. Tie-break by raw `matches_as_commander` DESC.

Three-scenario banner (in addition to the auto-suggestion):

- **0 commanders set** → orange banner: *"Commander picks suggested from VTSR-T + commander match count. VTSR-T measures thug skill — it's not a perfect proxy for commander ability. Consider setting commanders manually for best results."*
- **1 commander set** → yellow banner: *"Suggesting the second commander from VTSR-T + commander match count. Same caveat applies."*
- **2 commanders set** → green/informational banner with `Cmdr ΔVTSR: ±N` chip.

### 16.9 Reset all + dirty flag

The `Reset all` button (top of page) opens a confirm modal listing what's about to be wiped. On confirm:

- `mode → 'auto'`, `ignoreLive → false`, `lobbyLocked → false`
- `manualRoster → []`
- `wheel.lastWinner → null`, `wheel.removedSteam64s.clear()`
- `coin.lastResult → null`
- `mapRoll.lastResults → [null, null, null]`, `mapRoll.poolFilter → '7'`
- `balonce.commanderSetup → {team1: null, team2: null}`, `balonce.manualSwaps.clear()`, `balonce.partition → null`
- Dirty flag cleared
- Immediate `VTLiveSession.refreshNow()` if not ignoring

`window.dispatchEvent(new CustomEvent('vt-tools:reset-all'))` notifies each component module to reset its own UI.

### 16.10 Disabled pill convention

The plan locked in a strict UX rule: **never** add "Coming soon" chips, labels, or tooltip teasers to the disabled pills (Plinko / Sniper on the wheel; Best 3 of 5 on coinflip). Disabled state alone signals future-only. The convention applies to any future disabled pills as well.

### 16.11 Viewport-fit grid

Above 1280px, the six section cards arrange in a CSS Grid 2-col layout:

- Data trio (left col): Live Session · Active Roster · Team Balonce
- Tools trio (right col): Player Wheel · Coinflip · Map Roll

Each card carries `max-height: calc((100vh - var(--vt-tools-chrome-offset)) / 3)` so the three rows distribute vertical space evenly. `--vt-tools-chrome-offset` accounts for sticky topnav (~56px) + page header action row (~64px) + grid gaps (~24px) + breathing space. Internal `overflow-y: auto` on each card's body region for long content.

Below 1280px, the grid collapses to single-col and page scrolls. The 1280px breakpoint is tunable post-ship via `--vt-tools-grid-breakpoint`.

### 16.12 Picker filter contract

The Lobby Tools page is **picker-unaware** (mirrors VTSR-T leaderboard and the player/map browser pages). The dashboard's match picker has no influence on tools-page roster, balonce, wheel slices, or map-roll pools — tools is a live-data + manual-curation surface, not a corpus-aggregate view.

### 16.13 Discord Time (`time/`)

Sibling of Lobby Tools, opened from the Tools dropdown. A static page: the browser's own date and time fields (so a US, Canadian, German, or Australian visitor gets that system's date order and clock), one `<t:UNIX:F>` code to copy, and three quieter codes (`t` short time, `D` long date, `R` relative). The dashboard world-clock bar, under the result, shows that same instant in Los Angeles · Vancouver, Chicago, New York · Toronto, Berlin (`de-DE`, 24-hour), and Sydney (`en-AU`, day/month). `?t=<unix>` reopens the instant. Not in the pipeline cache key, picker-unaware.

The Tools dropdown button keeps `data-vt-tools-link` and `vt-nav-icon-btn--tools`. The dashboard poller still pulses that button when a known-host lobby is live, and the Tools menu row tints with it. Discord Time does not.

## 17. Models Browser (`models/`)

The project's **eighth standalone page**, sibling to `index.html` / `docs/index.html` / `raw/index.html` / `odf/index.html` / `player/index.html` / `map/index.html` / `tools/index.html`. An interactive three.js viewer for the BZCC 3D model corpus (~700 units / buildings / projectiles), promoted from the `_object-render/` proof-of-concept.

### 17.1 Files

- `models/index.html` — themed standalone shell (Bootstrap + theme system + Geist + theme dropdown + Tools-link pulse poller), importmap mapping `three` → `../vendor/three/`.
- `js/models.js` — directory grid (committed static thumbnails, search + faction/category chips + sort + Prefer-HQ), `?model=<stem>` router, and viewer wiring. Asset base `../data/models/` resolves relative to the page (one level deep).
- `js/models-viewer.js` — the `ObjectViewer` three.js class.
- `css/models.css` — fully on `--kb-*` theme tokens; mobile rules (scrollable control bar, bottom-sheet light panel, `touch-action: none` canvas).
- three.js r170 vendored at `vendor/three/` (`three.module.js` + `addons/{controls/OrbitControls,loaders/GLTFLoader,loaders/DDSLoader,utils/BufferGeometryUtils}.js`).

### 17.2 Assets + pipeline

Reads `data/models/`: `geometry/<stem>.glb` (geometry + UVs + per-primitive materials named by their lowercased diffuse stem), `textures/perf/<stem>.png` (512px, low-VRAM default), `textures/hq/<stem>.dds` (native BC-compressed, max fidelity), `thumbnails/<stem>.png`, `shots/<stem>/<angle>.png`, and `index.json` (manifest + odf index). These are emitted by the **standalone** conversion pipeline at `scripts/object-render/` (`convert_msh.py` orchestrator + `msh_parser.py` + `glb_writer.py` + `dds_decode.py` + `msh_thumbnail.py`), decoding the game's baked `.msh` + `.dds`. The pipeline is NOT invoked by `process_stats.py`; run `python scripts/object-render/convert_msh.py`. Reverse-engineering notes live in `_object-render/FORMAT.md`; diagnostic scripts in `_object-render/spike/`.

### 17.3 Viewer features

- Full 360 `OrbitControls` orbit (unrestricted azimuth + full polar), damping, zoom, pan.
- **Dual textures** assigned at runtime by material name: Performance (PNG via `TextureLoader`) ↔ HQ (`.dds` via `DDSLoader`, degrades to the perf PNG when a `.dds` is absent). Per-object segmented toggle + a global Prefer-HQ preference (`localStorage vt.obj.quality`).
- **Lighting**: a fixed world-space sun key light with `PCFSoftShadowMap` cast shadows onto an invisible `ShadowMaterial` ground plane, plus hemisphere + ambient fill (boosted when the sun is off). Top-bar toggle + a panel with intensity / azimuth / elevation sliders, persisted in `localStorage vt.obj.light.*`.
- **Free spin**: a camera-locked mode (rotate + pan disabled, zoom kept) where pointer drags spin the model about its bounding-box-centered pivot with drag-relative momentum and friction decay — a nod to the in-game fidget-spin bug. Mutually exclusive with Auto-rotate.
- **HQ Capture**: renders the 7 canonical angles at supersampled HQ with a canonical sun + identity orientation, downloading reproducible PNGs.
- **Collision overlays** (Scene pane, both off by default, hidden during HQ Capture). They show two unrelated quantities:
  - `Collision bounds` is the engine's collision sphere and the box it is built from. Every `.msh` block header stores `Sphere { radius, matrix, width, height, breadth }`: `matrix.posit` is the center of the box around the visible geometry (hidden `__h` and collision `__c` nodes excluded), `width` / `height` / `breadth` are its half-extents, and `radius` is half its diagonal. That radius is the shipped manifest `radius`, exact against the rest-pose geometry on all 638 parseable models. The ODF does not control it (confirmed by BlackDragon in community discussion, 2026-09-23). The overlay hangs under the spin pivot, so it follows free spin, flight and drive mode with the hull. Pref `vt.obj.collision`.
  - The viewer applies that construction to the hull it displays (the box around the loaded GLB and the sphere through its corners) rather than drawing the shipped `radius`. A corpus-wide comparison of the GLB boxes against the headers found them equal on 615 of 638 models. Of the other 23, 11 are multi-node models whose node-hierarchy GLB applies the header's `scale` field to the root nodes while the header record stays unscaled (`mogarg01c` is shown at 2.0x, so the shipped radius would sit at half the hull's size). The other 12 are displayed at a different pose or geometry than the header was baked from: 7 animated buildings (`ebrecy00` is 45% shorter as displayed than its rest pose), 3 signs and 2 powerups. On those models the readout tooltip names the header's own value.
  - In Ultra, both overlay groups carry `userData.vtOverlay` and are hidden from the GTAO prepass, which otherwise drew the translucent shell into its depth buffer in front of the hull and replaced the model's AO with the sphere's. The collision setters also restart TAA accumulation, because a converged still frame is held until something moves.
  - `AI pathing radius` is ODF `GameObjectClass.collisionRadius`, drawn as a flat circle on the floor. The vendored ODF guide documents it as the AI avoidance and path-planning radius with default `Bounding Sphere * 0.75f`, and declared values are often far smaller than the hull (`ibrecy.odf` declares 4 m on a 64 m footprint). The circle follows the Loadout variant, uses the default for ODFs that do not declare it, draws nothing for a declared `0`, and hides in drive mode. Because a building's circle usually lies under its own floor, the outline band is sized by the model's framing radius and a faint copy draws through the model. Pref `vt.obj.pathing`.
- **Engine shading** (read from `dx11_default_psh`): team colour is `mix(diffuse, teamColor.rgb * mask.rgb, mask.a * mix)` with no gain; normal maps flip green (`n`); ship lights use the LightClass attenuation triple (quadratic term → decay 2). Index `schema_version` 20 adds `motion` (`recoilDist`, `rollSteer`, `omegaTurret`, `alphaTurret`) from the ODF. Schema 21 adds `terrainPatch`: the hidden `terrain__h` surface rasterized on a 2 m grid in engine-local metres, consumed only by `scripts/extract_props.py` for the 3D replay's tunnel rendering (`_map-analysis/render/README.md`). True-lighting roughness and emissive alpha are baked by `convert_msh.py` on the next forced texture emit (`2^(gloss·specularPower)` and `emissive.rgb * emissive.a`).

### 17.4 Picker filter contract

Picker-unaware (mirrors ODF / Map / Tools). Corpus-wide, NOT in the pipeline cache key, no `getFilteredData` path. The Models topnav dropdown (`bi-box`) sits immediately after ODF on every shell + both pre-gen templates (`PLAYER_TEMPLATE_VERSION` 15 / `MAP_TEMPLATE_VERSION` 9).

## 18. LEGO Models Browser (`lego/`)

Standalone three.js viewer for community modeler **Darkvale's** Battlezone-inspired LEGO builds, rendered in-browser directly from his BrickLink Studio `.io` files via three.js `LDrawLoader`. Mirrors the `/models` page architecture (directory grid + single-model viewer + Ultra rendering) but is a distinct, slimmer implementation — LEGO has none of the BZCC model machinery (textures, team colors, loadouts, drive, collision, animation). Files: [lego/index.html](lego/index.html), [js/lego.js](js/lego.js), [js/lego-viewer.js](js/lego-viewer.js), [css/lego.css](css/lego.css).

### 18.1 Build pipeline (`scripts/build_lego.py`)

Standalone, single-command, incremental — NOT wired into `process_stats.py` (mirrors `scripts/object-render/`). `python scripts/build_lego.py` is the ONLY step to add models or images.

- For each `data/lego/*.io` (a Stud.io ZIP): extract `model.ldr` (STANDARD official LDraw part numbers — never `model2.ldr`, which uses Stud.io-internal numbering), `thumbnail.png`, and `.info`.
- BFS-fetch the ENTIRE part / sub-part / primitive closure from the LDraw library (official `parts` → official `p` → unofficial `parts` → unofficial `p`), throttled + retry-on-transient, cached to the gitignored `_lego_cache/parts/` shared across models and runs.
- **Flatten every path ref to a unique bare name** (`s/3070bs01.dat` → `s__3070bs01.dat`, `48/4-4cyli.dat` → `48__4-4cyli.dat`) in both `0 FILE` names and every type-1 reference, and inline the whole closure — producing a 100%-self-contained `.ldr` with zero runtime fetches. This is load-bearing: `LDrawLoader` only resolves inlined MPD sub-files by **bare** name; any subfolder-prefixed ref is fetched externally and silently dropped, so a part whose geometry lives entirely in an `s\` sub-part would render nothing.
- Detect a pre-existing `0 FILE` header (some exports carry one — e.g. Titan) and do NOT double-wrap, else the wrapper becomes an empty main model → 0 geometry.
- Write `data/lego/<slug>/model.ldr` + `thumbnail.png` + a `renders/` scaffold dir; refresh `data/lego/index.json` + `data/lego/LDConfig.ldr`.
- **Incremental**: per-model cache key `(io_stem, sha256(io_bytes), LEGO_BUILD_VERSION)` recorded per entry (`source_hash` + `build_version`); an unchanged `.io` with existing output and a matching build version is SKIPPED. `renders/` is ALWAYS rescanned (so adding PNGs is instant). Flags: `--force`, `--model <slug>`, `--no-network`, `--prune`. Bump `LEGO_BUILD_VERSION` to force a full rebuild.
- **Completeness gate**: fails loudly (non-zero exit, `missing=[...]`) if any referenced part is unresolvable in the LDraw library (a genuinely custom / flexible Stud.io element), rather than shipping partial geometry. A Python triangle counter (type-3 = 1 tri, type-4 quad = 2 tris, recursively memoized) cross-checks the geometry and is stored as `triangles` (it matches three.js's `geometry.index.count / 3` exactly).

`data/lego/index.json`: `{build_version, generated, count, models:[{slug, name, faction, faction_code, version, parts, triangles, ldr, thumb, renders[], studio_version, source_file, source_hash, build_version}]}`. Paths are relative to `data/lego/`. The `.io` sources are committed (source of truth, like `data/sessions/*.binpb.gz`).

### 18.2 Viewer (`js/lego-viewer.js`)

`LegoViewer` class: `WebGLRenderer` (ACES tone mapping, `PCFSoftShadowMap`, `preserveDrawingBuffer` for capture), `OrbitControls`, sun `DirectionalLight` + hemisphere + ambient, and an invisible `ShadowMaterial` ground. Load path: `preloadMaterials('../data/lego/LDConfig.ldr')` once, then per model `fetch(...).text()` + `LDrawLoader.parse()` (NOT `load()`, which calls `setMaterials([])` and wipes the palette → magenta), a null-material/null-child sanitize sweep (LDrawLoader emits conditional-line `LineSegments` with `null` material that crash `renderer.render`), a Y-flip (`rotation.x = π`, LDraw is -Y up), and a center/sit-on-ground/frame pass. Toggles: auto-rotate, wireframe, edges, background (dark/light), grid, and sun on/off + intensity/azimuth/elevation. **HQ (Ultra)** ports the `/models` composer chain verbatim — `EffectComposer` with TAA (idle-time 32-sample supersampling gated by `_isSceneStill()`) → GTAO (screen-space-radius ambient occlusion) → UnrealBloom → OutputPass (sRGB) → SMAA, plus a 4096 shadow map + uncapped DPR; `_ultraActive()` bypasses it under wireframe. `capture()` renders canonical angles at supersampled resolution and returns data URLs for download.

### 18.3 Directory + three view modes (`js/lego.js`)

No `?model=` → directory: committed-thumbnail card grid with search, faction chips, and sort (name / faction / parts). `?model=<slug>` → single-model viewer with a 3-way segmented **View** control honoring the three displays: **Standard** (interactive, Ultra off), **HQ** (same canvas, Ultra on, reveals the HQ Capture button), and **Photos** (hides the canvas, shows a gallery of `renders/*.png` with a friendly empty-state placeholder until Darkvale's studio renders are uploaded). The viewer is paused in Photos mode.

### 18.4 Credit + attribution

Darkvale is credited as sole modeler in four places — directory hero (`Designed & built by Darkvale`), per-card `by Darkvale`, viewer `Modeled by Darkvale` chip, and the page footer — all deep-linked to his Steam profile (`steamcommunity.com/profiles/76561198136459671`). The footer additionally carries required LDraw Parts Library (CC BY 4.0) + BrickLink Studio attribution.

### 18.5 Picker filter contract

Picker-unaware (mirrors Models / ODF / Map / Tools). Corpus-wide, NOT in the pipeline cache key, no `getFilteredData` path. LEGO lives in the Models dropdown. Templates at `PLAYER_TEMPLATE_VERSION` 15 / `MAP_TEMPLATE_VERSION` 9.

### 18.6 Replay ODF map

`data/lego/odf-map.json` (`schema_version: 1`) is the hand-maintained join from a stock ODF stem to one Studio `.io` (`source_file`, `unit_name`, `kind`, `yaw_deg`). Slug and `model.ldr` come from `data/lego/index.json` at load time. `_vsr` and a trailing `vsr` (`apwrckvsr`) are not duplicated; the replay strips them the same way it resolves game meshes. `scripts/build_lego.py` fails the run when a mapped file is missing, the ODF is absent, or `unit_name` disagrees with `data/odf.min.json`. It does not invent links.

The 3D replay Models menu stores `lego` in `vt.replay.textureSet`, beside Stock / Enhanced / Redux. While that choice is on, a mapped ODF draws the brick model (scaled to the stock mesh's horizontal extent, hull bottom on the ground, `yaw_deg` before the usual nose wrapper) and every other ODF keeps the stock game mesh. Brick meshes are not team-tinted, and a morph deploy pose is not applied. Gun Spire and Gun Tower stay in the map; the replay still skips turret-class structures. Day Wrecker (`apwrck.odf`) is mapped and is not drawn until the replay instantiates powerups. Extractor (`ibscav.odf`) is drawn on proto v5 matches from `PoolPlanted` (`structures.instances` `kind: "extractor"`); older matches still have no base-pool row, because scavengers deploy them without a constructor `BUILD`. Extractor+ (`ibscup.odf`) is a constructor build and draws from its `BUILD` tick, which a v5 upgrade plant reuses. Power maps to `ibpgen.odf` because matches record the plant as `ibpgen_vsr`; `ibpgen01.odf`, the label in Darkvale's list, is the separate Power Generator building. Credit Darkvale on the LEGO row.

## 19. YouTube VOD Timestamp Sync

Map dashboard match-seconds onto YouTube video-seconds so kill feed / build log / storyline / 3D replay can open the exact VOD moment. Full schema: [docs/DATA_DICTIONARY.md](docs/DATA_DICTIONARY.md) §16.

### 19.1 Store + posture

Committed `data/external/match_videos.json` (`schema_version: 1`) is the processed store the frontend reads. Sibling inbox `data/external/match_video_queue.json` is operator-edited (match id → channel key → URL); `scripts/process_match_videos.py` syncs new pairs into the store the way new session files sit in front of `process_stats.py`. Sibling of the F9 ledger files — external, community-sourced, standalone-tool-written, human-editable. **Zero pipeline interaction** (no schema bumps). **Forbidden consumers** `process_stats.py` / `elo.py` / `elo_commander.py` / `all-matches-aggregator.js` (gate: `_investigation/check_match_videos.py`). Display-only, 404-safe, picker-unaware, rating-inert by construction. Credit every channel wherever a link renders.

Time base: `match_sec = (tick − tick_range[0]) / tick_rate`. Mapping = rate-1.0 `{video_sec, match_sec, duration_sec}` segments. HUD clock skew is the module constant `MISSION_CLOCK_SKEW_SEC = 0.0` (measured on the Egypt × `2sLbGfx3rXQ` pair; stored segments already fold it in).

### 19.2 Operator runbook — a new VOD appeared

Operator-only deps (never required by the pipeline): `pip install yt-dlp opencv-python easyocr numpy` plus `ffmpeg` on PATH (or `pip install imageio-ffmpeg` for a bundled binary).

**Inbox (default).** Add the YouTube URL to `data/external/match_video_queue.json` under the match id (dashboard `?match=`), keyed by channel name. That key is also `--pov` when it matches the match roster. A string value is just the URL; an object may add `offset` / `anchor` / `force_identity` / `notes` for VODs OCR cannot finish alone. Then:

```
python scripts/process_match_videos.py
python scripts/process_match_videos.py --dry-run
```

The batch script skips `(match_id, video_id)` pairs already in `match_videos.json` and subprocesses `map_match_video.py` for new ones (no `--yes`). Answer `y` at the existing QA prompt to sign off (`verified: true`). One bad row does not drop the rest of the queue; a non-zero exit means at least one new row failed. `--force` re-runs pairs already in the store. Removing a queue row does not delete a store entry. Then `python _investigation/check_match_videos.py` and commit both JSON files.

**Channel scan (new uploads).** When a configured channel posts a VOD and you do not yet know the match id:

```
python scripts/process_extvids.py
python scripts/process_extvids.py --dry-run --limit 3
python scripts/process_extvids.py --channel F9bomber
python scripts/process_extvids.py --self-test
```

`scripts/process_extvids.py` lists every row in `data/external/video_channels.json` (`{key, url, parser, pov}`), skips video ids already in `match_videos.json`, and keeps a title only when that row's parser recognizes it. For F9 (`parser: "f9"`) the title must contain ` vs ` and `VSR`; commanders are the `A vs B` segment wherever it sits (a prefix such as `Adding a Ship Overlay!` is fine); the map is the last `|` segment; the description date is `Recording date: M.D.YY` (US month/day, local play date). Commander spellings resolve through `data/external/f9_name_map.json` (`muerte` → mort, `blue banana` → blue, `x-triage` → Totokomo, `xpi` → Monkey). Map titles also use `data/external/f9_map_aliases.json`. A match is a candidate when both commanders are its `team_leaders`, the map title matches the manifest name (or an alias), the UTC match date is the recording date or the next day, and `pov` is on the roster.

Candidates are confirmed on the last in-game HUD frame: the script seeks backward from the end in 15s steps (up to 3 minutes of credits) until `Mission Time` OCRs, then requires that clock within 20s of `duration_sec` and a passing `identity_gate` (the scoreboard crop is the upper band, full width, because F9 pins the roster on the right). Exactly one match id writes a mapping by calling `map_match_video.py --yes` (no QA prompt; discovery notes record the clock and the matched names). Anything else is a row in gitignored `_investigation/output/extvids/report.json` and is retried on the next run. `--limit N` caps how many candidate titles are confirmed. Description metadata is cached under that same directory so a rerun does not re-fetch titles it already parsed.

When the telemetry shortlist is empty, the same scan tries non-overlap duels in `data/external/f9_ledger.json` (commanders, map, F9's calendar date ±1 day, F9bomber on the roster). A hit also needs the last HUD clock within 30 seconds of the sheet's `duration_sec` and a passing scoreboard `identity_gate` on the commander and thug names. One such duel writes `data/external/f9_video_links.json`, keyed by ledger row: video id, URL, clock, duration delta, and matched names. No segments and no `match_videos.json` row — a ledger-only game has no tick stream, so there is no kill-feed `&t=` link. Overlap rows stay on the telemetry path.

**Add a channel.** Append a row to `video_channels.json` and add a parser function to `PARSERS` in `scripts/process_extvids.py`. The parser receives `(title, description)` and returns `{commanders, map_title, record_date}` or `None`. Calling it with an empty description must return `None` when the title is not one of that channel's match videos (that is the cheap skip before any description fetch). Do not fork the HUD OCR or the store writer. Only `f9` is implemented.

**Single-video CLI (debugging).** Identify the match id and the YouTube URL. Optional: whose cockpit (`--pov <name|steam64>`). Dry-run the mapper so you can read the proposed segments and QA links without writing:

```
python scripts/map_match_video.py --match 2026-09-13T02-32-33 --video https://www.youtube.com/watch?v=2sLbGfx3rXQ --pov F9bomber --dry-run
```

Uncut VODs take the sparse presence-gated OCR path (~1–2 min). Edited VODs (`--mode edited`, or auto when residuals exceed 2 s) stream the whole file at 1 fps. `--debug-frames` dumps annotated stills to `_investigation/output/video_sync/<match>/<video_id>/` (gitignored). If OCR cannot run (missing EasyOCR, scoreboard never up): supply `--offset SEC` (video = match + offset) or repeatable `--anchor MM:SS@VIDEO_SEC`. `--force-identity` bypasses the roster-name gate and is logged in `notes`. Open the printed QA `&t=` links; confirm they land within ~2 s of the named kill. Pass `--yes` to sign off non-interactively, or answer `y` at the prompt. Re-run is idempotent for the same `(match_id, video_id)` — the entry is replaced. `verified_at` is preserved when segments + `mapping_kind` are unchanged. Then `python _investigation/check_match_videos.py` and commit `data/external/match_videos.json`.

`--input local.mp4` skips yt-dlp (offline / a channel that blocks extraction). `--self-test` runs the synthetic two-offset assemble (cut at 20 s, ±2 s) and, when ffmpeg + EasyOCR are present, an ffmpeg-spliced OCR round-trip.

### 19.3 Frontend helper

`js/video-links.js` → `window.VTVideoLinks` (loaded on `index.html` immediately before `js/storyline.js`; also on `player/index.html`, `scripts/player_template.html` at `PLAYER_TEMPLATE_VERSION` 15, and the 3D replay iframe as a classic script with a relative store URL).

- `ensureLoaded(opts?)` — 404-safe fetch → `window.__vtMatchVideos` (`{}` on 404). Fire-and-forget at boot; awaited at the top of `renderMatchData()`.
- `videosFor(matchId)` — verified first, then coverage desc, then `uploaded_at`.
- `linkForMatchSec(matchId, sec)` → `{url, channel, title, approx} | null`. Covering segment, else snap-forward ≤ 90 s (`approx: true`).
- `linkForTick(matchId, tick, tickRate, minTick)` — wrapper; **always pass the real `tick_rate`** (the replay iframe defaults `Number(matchMeta.tick_rate) || 10` — do not copy that fallback).

Surfaces: `#info-video-link` Watch VOD (dropdown `#info-video-dropdown` when multiple videos), kill/snipe feed row icons, storyline rail icons (`e.target.closest('a')` so the row-click replay seek is untouched), Build Order Log hover icons (`vtVideoAnchorHtml` lives **at or after** `buildlogFilterFn`), 3D replay HUD `#btn-watch-vod`, picker Has-VOD facet (`pickerState.hasVod`, URL `?vod=`, default `'any'`, no `vt.picker.filters.v2` key bump), player match-log VOD column. Deleting the store file leaves every surface hidden with zero console errors.

