Explorar el Código

Initial commit

nosoop hace 1 semana
commit
53f2c6e2e4

+ 1 - 0
.gitignore

@@ -0,0 +1 @@
+*.vprof

+ 136 - 0
README.md

@@ -0,0 +1,136 @@
+# vprof-viewer
+
+Tools for reading Source Engine **VProf** profiling captures (`.vprof`) and
+turning them into JSON.
+
+* [`VPROF_FORMAT.md`](VPROF_FORMAT.md) — reverse-engineered binary format
+  documentation (with references to the leaked engine source).
+* [`vprof_to_json.py`](vprof_to_json.py) — standalone Python 3 parser /
+  converter.
+* [`index.html`](index.html) — self-contained browser viewer (no build step,
+  no network access required).
+
+## Requirements
+
+For the Python tool: Python 3.8+ (uses `dataclasses` and `statistics.fmean`).
+No third-party packages. The browser viewer needs only a modern browser.
+
+## Web viewer
+
+Open `index.html` directly from disk or serve it as a static file. Choose (or
+drag & drop) a `.vprof` capture and the page parses it locally in a Web
+Worker — nothing is uploaded. It shows:
+
+* recording metadata and a warning when a capture contains no timing data;
+* a per-frame timeline (time, or call counts when times are all zero);
+* a sortable, filterable, collapsible call tree with total/self/avg/max time,
+  call counts and percentages;
+* budget-group and top-node breakdowns; and
+* a draggable frame range so statistics can be scoped to part of a capture.
+
+For very large captures the per-frame samples are not retained; the viewer
+falls back to whole-recording aggregates and says so.
+
+## Python usage
+
+```bash
+# Print JSON to stdout (compact)
+python vprof_to_json.py capture.vprof
+
+# Write pretty JSON to a file
+python vprof_to_json.py capture.vprof -o capture.json --indent 2
+
+# Metadata, budget groups and the summary only (small, fast)
+python vprof_to_json.py capture.vprof --summary-only --indent 2
+
+# Only the first 100 frames
+python vprof_to_json.py capture.vprof --max-frames 100 -o first100.json
+
+# Drop the per-frame samples or the node table
+python vprof_to_json.py capture.vprof --no-frames
+python vprof_to_json.py capture.vprof --no-nodes
+```
+
+Full options are available via `python vprof_to_json.py --help`.
+
+## Output schema
+
+```jsonc
+{
+  "format": "source-vprof",
+  "source": {
+    "file": "capture.vprof",
+    "size_bytes": 1310867,
+    "version": 1,
+    "root_node_id": 0
+  },
+
+  // All budget groups, including the 31 built-in defaults (is_default=true).
+  "budget_groups": [
+    { "id": 0, "name": "Unaccounted", "flags": 6,
+      "flags_decoded": ["SERVER", "OTHER"], "is_default": true }
+  ],
+
+  // Node table. `children` gives the final tree order (child IDs).
+  "nodes": [
+    { "id": 100, "name": "CEngine::Frame", "parent_id": 0,
+      "budget_group_id": 0, "budget_group": "Unaccounted", "children": [] }
+  ],
+
+  // One entry per recorded frame.
+  "frames": [
+    {
+      "index": 0,
+      "tick": 54863,
+      "total_microseconds": 0,          // time of the root node
+      "samples": {                      // node id -> timing for this frame
+        "0":   { "calls": 1, "microseconds": 0 },
+        "100": { "calls": 0, "microseconds": 0 }
+      },
+      "added_nodes": [100],             // nodes first seen in this frame
+      "added_budget_groups": [31]       // groups first seen in this frame
+    }
+  ],
+
+  // Aggregate statistics.
+  "summary": {
+    "frame_count": 1303,
+    "node_count": 352,
+    "budget_group_count": 38,
+    "tick_first": 54863,
+    "tick_last": 56165,
+    "tick_span": 1302,
+    "total_recorded_microseconds": 0,
+    "frame_time_microseconds": {
+      "min": 0, "max": 0, "mean": 0.0, "median": 0, "total": 0, "p95": 0
+    },
+    "top_nodes_by_total_microseconds": [
+      { "node_id": 0, "name": "Root", "budget_group": "Unaccounted",
+        "total_calls": 1303, "total_microseconds": 0,
+        "avg_microseconds": 0.0, "max_microseconds": 0,
+        "percent_of_total": 0.0 }
+    ]
+  }
+}
+```
+
+## Notes
+
+* Timings are **per-frame deltas**, not cumulative totals. Times are stored in
+  multiples of 4 microseconds.
+* A node's `children` list is the *final* order after all frames. Because
+  `AddNode` prepends children, the per-frame sample values are keyed by node ID
+  rather than nested, so the tree can always be rebuilt from `nodes`.
+* Budget group IDs 0–30 are the fixed defaults registered by `CVProfile`; the
+  parser seeds them so files that only record the groups added *after*
+  construction still resolve correctly.
+* **Captures may legitimately contain zero timing values.** In the engine's
+  snapshot path (`PreUpdateProfile` → `MarkFrame()` → `VProfRecord_Snapshot()`),
+  `MarkFrame()` copies the current frame's counters to the *previous* frame and
+  clears them before the recorder runs, so the writer reads cleared
+  `m_CurFrameTime` / `m_nCurFrameCalls` values. Such captures still contain the
+  full call tree and per-frame call counts.
+* The parser handles non-zero timings and the `255` (extended calls) and
+  `0xFFFF` (extended time) escape codes.
+* Very large captures can produce very large JSON. Use `--summary-only`,
+  `--no-frames`, `--max-frames`, or `-o` to manage output size and memory.

+ 265 - 0
VPROF_FORMAT.md

@@ -0,0 +1,265 @@
+# Source Engine VProf (`.vprof`) File Format
+
+This document describes the binary file format produced by the Source Engine's
+**VProf** profiler when recording a capture with `vprof_record_start`.
+
+The reference implementation lives in `engine/vprof_record.cpp`
+(class `CVProfRecorder`) with supporting definitions in `public/tier0/vprof.h`
+and `tier0/vprof.cpp`. A `.vprof` file is a *frame-by-frame recording* of the
+VProf tree: the tree structure (nodes, their parent, budget group) is written
+incrementally as it changes, and after each structural update a set of timing
+samples is written for the whole tree.
+
+Everything is **little-endian**. Integers are fixed-width; the code compiles
+with `long` == 4 bytes (the writer explicitly uses `unsigned long` only for the
+extended microsecond field, handled as a `uint32`). Strings are
+NUL-terminated, typically ASCII/Windows-1252; UTF-8 is accepted on read.
+
+---
+
+## 1. File layout
+
+```
++------------------------------+
+| Header                       |
++------------------------------+
+| Frame 0                      |
+| Frame 1                      |
+| ...                          |
+| Frame N-1                    |
++------------------------------+
+| EOF (end of file)            |
++------------------------------+
+```
+
+### 1.1 Header
+
+| Offset | Type      | Field           | Notes                                |
+|-------:|-----------|-----------------|--------------------------------------|
+| 0      | `int32`   | `version`       | Format version. Currently always `1` (`VPROF_FILE_VERSION`). |
+| 4      | `int32`   | `root_node_id`  | Unique ID of the tree's root node (usually `0`). |
+
+After the header the file is a stream of byte **tokens**. The first token of
+each frame must be `Token_StartFrame` (`0`). A token value of `5`
+(`TOKEN_FILE_FINISHED`) is reported by the reader when a read runs off the end
+of the file; it is not physically present in a well-formed file.
+
+### 1.2 Token values
+
+| Value | Name                | Meaning                                     |
+|------:|---------------------|---------------------------------------------|
+| `0`   | `Token_StartFrame`  | Begins a frame; followed by a `int32` tick. |
+| `1`   | `Token_AddNode`     | Declares a new tree node.                   |
+| `2`   | `Token_AddBudgetGroup` | Declares a new budget group.             |
+| `3`   | `Token_Timings`     | Followed by recursive timings for the whole tree. |
+| `4`   | `Token_EndOfFrame`  | Ends the current frame.                     |
+| `5`   | `TOKEN_FILE_FINISHED` | Pseudo-token; never written, returned at EOF. |
+
+---
+
+## 2. Frame structure
+
+Each frame is written by `CVProfRecorder::Record_Snapshot()` as:
+
+```
+Token_StartFrame            (uint8, value 0)
+tickcount                  (int32)
+
+[Token_AddBudgetGroup ...]  (zero or more)
+[Token_AddNode ...]         (zero or more)
+
+Token_Timings               (uint8, value 3)
+<recursive timings>
+Token_EndOfFrame            (uint8, value 4)
+```
+
+The order of the structural sections matters:
+
+1. The tick is written first.
+2. **Budget groups** are written before any node that might reference them.
+   Only groups that did not already exist in the recorder's own profile are
+   written, so indices are stable across frames.
+3. **New nodes** are written (depth-first), then:
+4. **Timings** for every node currently in the tree, in pre-order
+   (node, then its children in sibling order).
+
+### 2.1 `Token_AddBudgetGroup`
+
+```
+uint8  token = 2
+char[] name          NUL-terminated
+int32  flags         combination of BUDGETFLAG_* (see section 3)
+```
+
+A budget group's numeric ID is its insertion index into the recorder's budget
+group table. The base `CVProfile` constructor pre-registers 31 default groups
+(see section 4), so a file recorded by that build only emits groups *after*
+those 31 positions. Other builds emit all groups from index 0. Consumers
+should therefore treat the default groups as always present, and append
+unknown names after them (matching by name for known defaults).
+
+### 2.2 `Token_AddNode`
+
+```
+uint8  token = 1
+int32  parent_node_id
+char[] node_name        NUL-terminated
+int32  budget_group_id
+int32  node_id          unique node ID (cvprof node ID)
+```
+
+`parent_node_id` always refers to a node that already exists (the root was
+declared in the header, or the parent was added earlier in the stream).
+Nodes are never removed during a recording.
+
+**Child ordering.** `CVProfNode::GetSubNode()` inserts a new child at the
+*head* of the parent's child list, and the recorder writes new nodes in an
+order that compensates for this. To reconstruct the tree exactly as the
+engine sees it, a new `AddNode` must be **prepended** to the parent's children
+list.
+
+### 2.3 `Token_Timings`
+
+```
+uint8  token = 3
+<timing for root>
+```
+
+Timings are written recursively by `Record_WriteTimings_R` / read by
+`Playback_ReadTimings_R`. For each node, in pre-order:
+
+```
+uint8       calls_token
+if calls_token == 255:
+    uint16  cur_frame_calls
+else:
+    cur_frame_calls = calls_token
+
+uint16      time_token
+if time_token == 0xFFFF:
+    uint32  microseconds_div4
+    cur_frame_time_us = microseconds_div4 * 4
+else:
+    cur_frame_time_us = time_token * 4
+```
+
+Then the same records are written for each child, in sibling order.
+
+Notes:
+
+* `calls` is 0–254 in the single byte; `255` is an escape for a 16-bit count
+  (up to 65535).
+* Time is stored in **units of 4 microseconds**. Values below `0xFFFF` units
+  (≈262 ms) fit in the 16-bit token; `0xFFFF` escapes to a 32-bit count of
+  the same quarter-microsecond units.
+* Both `calls` and time are per-frame deltas ("cur frame" values), not
+  cumulative totals.
+
+---
+
+## 3. Budget group flags
+
+`BUDGETFLAG_*` bit values (from `public/tier0/vprof.h`):
+
+| Bit       | Name             | Value  |
+|-----------|------------------|-------:|
+| `1 << 0`  | `BUDGETFLAG_CLIENT` | `1`  |
+| `1 << 1`  | `BUDGETFLAG_SERVER` | `2`  |
+| `1 << 2`  | `BUDGETFLAG_OTHER`  | `4`  |
+| `1 << 15` | `BUDGETFLAG_HIDDEN` | `32768` |
+|           | `BUDGETFLAG_ALL`    | `0xFFFF` |
+
+Examples: `Unaccounted = SERVER | OTHER = 6`, `World Rendering = CLIENT = 1`.
+
+---
+
+## 4. Default budget groups
+
+`CVProfile::CVProfile()` registers these 31 groups in this exact order, so
+IDs 0–30 are stable. A parser that sees only higher-numbered
+`Token_AddBudgetGroup` records should still map IDs 0–30 to these names.
+
+| ID | Name                          | Flags | Decoded            |
+|---:|-------------------------------|------:|--------------------|
+| 0  | `Unaccounted`                 | 6     | SERVER, OTHER      |
+| 1  | `World Rendering`             | 1     | CLIENT             |
+| 2  | `Displacement_Rendering`      | 1     | CLIENT             |
+| 3  | `Game`                        | 6     | SERVER, OTHER      |
+| 4  | `Player`                      | 6     | SERVER, OTHER      |
+| 5  | `NPCs`                        | 6     | SERVER, OTHER      |
+| 6  | `Server Animation`            | 6     | SERVER, OTHER      |
+| 7  | `Client_Animation`            | 1     | CLIENT             |
+| 8  | `Physics`                     | 6     | SERVER, OTHER      |
+| 9  | `Static_Prop_Rendering`       | 1     | CLIENT             |
+| 10 | `Other_Model_Rendering`       | 1     | CLIENT             |
+| 11 | `Fast Path Model Rendering`   | 1     | CLIENT             |
+| 12 | `Light_Cache`                 | 1     | CLIENT             |
+| 13 | `Brush_Model_Rendering`       | 1     | CLIENT             |
+| 14 | `Shadow_Rendering`            | 1     | CLIENT             |
+| 15 | `Detail_Prop_Rendering`       | 1     | CLIENT             |
+| 16 | `Particle/Effect_Rendering`   | 1     | CLIENT             |
+| 17 | `Ropes`                       | 1     | CLIENT             |
+| 18 | `Dynamic_Light_Rendering`     | 1     | CLIENT             |
+| 19 | `Networking`                  | 6     | SERVER, OTHER      |
+| 20 | `Sound`                       | 6     | SERVER, OTHER      |
+| 21 | `VGUI`                        | 6     | SERVER, OTHER      |
+| 22 | `FileSystem`                  | 6     | SERVER, OTHER      |
+| 23 | `Prediction`                  | 1     | CLIENT             |
+| 24 | `Interpolation`               | 1     | CLIENT             |
+| 25 | `Swap_Buffers`                | 1     | CLIENT             |
+| 26 | `Occlusion`                   | 1     | CLIENT             |
+| 27 | `Overlays`                    | 1     | CLIENT             |
+| 28 | `Tools`                       | 5     | CLIENT, OTHER      |
+| 29 | `Texture_Cache`               | 1     | CLIENT             |
+| 30 | `Replay`                      | 2     | SERVER             |
+
+---
+
+## 5. Playback semantics (why the format works)
+
+`CVProfRecorder` supports random access by re-reading the stream from the
+header:
+
+* Seeking **forward** reads frames until the wanted tick is reached.
+* Seeking **backward** clears the tree and replays from the first frame,
+  relying on the fact that `AddBudgetGroup` / `AddNode` records are
+  idempotent (a node already present is reused by name and re-assigned its
+  ID).
+* Because structure changes are only ever additions, the union of all
+  `AddNode` records yields the final tree. A parser can simply keep a
+  `node_id → node` map while streaming.
+
+`Playback_ReadTick` also relies on the exact frame envelope: it reads
+`Token_StartFrame`, the tick, then loops over inner tokens until
+`Token_EndOfFrame`.
+
+---
+
+## 6. Quick reference (pseudo-struct)
+
+```c
+struct VProfHeader {
+    int32 version;        // 1
+    int32 root_node_id;
+};
+
+// Stream of frames:
+struct VProfFrame {
+    uint8  start_frame;   // 0
+    int32  tick;
+
+    // repeated until timings:
+    uint8  token;         // 1 = AddNode, 2 = AddBudgetGroup
+    union {
+        struct { int32 parent; cstring name; int32 budget_group; int32 node; } node;
+        struct { cstring name; int32 flags; } group;
+    };
+
+    uint8  timings;       // 3
+    // recursive pre-order:
+    //   uint8 calls (255 => uint16)
+    //   uint16 time_div4 (0xFFFF => uint32), microseconds = value * 4
+
+    uint8  end_of_frame;  // 4
+};
+```

+ 55 - 0
fix-vprof-timings.patch

@@ -0,0 +1,55 @@
+--- a/engine/vprof_engine.cpp
++++ b/engine/vprof_engine.cpp
+@@ -246,11 +246,15 @@
+ 	g_VProfCurrentProfile.ResetCounters( COUNTER_GROUP_DEFAULT );
+ 	g_VProfCurrentProfile.ResetCounters( COUNTER_GROUP_TEXTURE_PER_FRAME );
+ 
++#ifdef VPROF_ENABLED
++	// Record the frame that just completed. Must run before
++	// VProfExport_SnapshotVProfHistory(), which consumes and clears the
++	// previous-frame data that Record_Snapshot() now reads.
++	VProfRecord_Snapshot();
++#endif
++
+ 	// This MUST come before GetVProfPanel()->UpdateProfile(), because UpdateProfile uses the data we snapshot here.
+ 	VProfExport_SnapshotVProfHistory();
+-#ifdef VPROF_ENABLED
+-	VProfRecord_Snapshot();
+-#endif
+ 
+ #ifndef SWDS
+ 	// Update the vgui panel
+--- a/engine/vprof_record.cpp
++++ b/engine/vprof_record.cpp
+@@ -211,7 +211,7 @@
+ 
+ 	void Record_WriteTimings_R( const CVProfNode *pIn )
+ 	{
+-		unsigned short curCalls = min( pIn->m_nCurFrameCalls, (unsigned)0xFFFF );
++		unsigned short curCalls = min( pIn->m_nPrevFrameCalls, (unsigned)0xFFFF );
+ 		if ( curCalls >= 255 )
+ 		{
+ 			unsigned char token = 255;
+@@ -226,7 +226,7 @@
+ 		}
+ 
+ 		// This allows us to write 2 bytes unless it's > 256 milliseconds (unlikely).
+-		unsigned long nMicroseconds = pIn->m_CurFrameTime.GetMicroseconds() / 4; 
++		uint32 nMicroseconds = (uint32)( pIn->m_PrevFrameTime.GetMicroseconds() / 4 );
+ 		if ( nMicroseconds >= 0xFFFF )
+ 		{
+ 			unsigned short token = 0xFFFF;
+@@ -481,11 +481,11 @@
+ 
+ 		if ( microsecondsToken == 0xFFFF )
+ 		{
+-			unsigned long nMicroseconds;
++			uint32 nMicroseconds;
+ 			if ( g_pFileSystem->Read( &nMicroseconds, sizeof( nMicroseconds ), m_hFile ) != sizeof( nMicroseconds ) )
+ 				return false;
+ 
+-			pNode->m_CurFrameTime.SetMicroseconds( nMicroseconds * 4 );
++			pNode->m_CurFrameTime.SetMicroseconds( (unsigned long)nMicroseconds * 4 );
+ 		}
+ 		else
+ 		{

+ 993 - 0
index.html

@@ -0,0 +1,993 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>Source VProf Viewer</title>
+<style>
+  /* Layout only. A visual design pass will replace this. */
+  * { box-sizing: border-box; }
+  body { font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
+         font-size: 13px; margin: 0; color: #111; background: #fafafa; }
+  header { padding: 8px 12px; background: #1a202c; color: #fff; }
+  header h1 { font-size: 16px; margin: 0 0 6px 0; }
+  header .row { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
+  header label { font-size: 12px; }
+  main { padding: 12px; display: flex; flex-direction: column; gap: 12px; }
+  section { background: #fff; border: 1px solid #ddd; padding: 8px; }
+  h2 { font-size: 14px; margin: 0 0 6px 0; }
+  h3 { font-size: 13px; margin: 10px 0 4px 0; }
+  table { border-collapse: collapse; width: 100%; }
+  th, td { border: 1px solid #ddd; padding: 2px 6px; text-align: left;
+           font-size: 12px; vertical-align: top; }
+  th { background: #f0f0f0; cursor: pointer; white-space: nowrap; }
+  th.nosort { cursor: default; }
+  td.num, th.num { text-align: right; font-variant-numeric: tabular-nums; }
+  .muted { color: #666; }
+  .warn { background: #fff5e6; border: 1px solid #e0a800; padding: 6px 8px; }
+  .notice { background: #e8f0fe; border: 1px solid #6b9bd2; padding: 6px 8px; }
+  .summary-grid { display: flex; flex-wrap: wrap; gap: 12px 24px; }
+  .summary-grid div { min-width: 120px; }
+  .summary-grid .k { color: #666; font-size: 11px; }
+  .summary-grid .v { font-size: 14px; font-weight: 600; }
+  .controls { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
+  .controls input[type=number] { width: 90px; }
+  #timeline-wrap { width: 100%; }
+  #timeline { display: block; width: 100%; height: 120px; background: #fff;
+              border: 1px solid #ddd; cursor: crosshair; }
+  .grid2 { display: grid; grid-template-columns: minmax(0,1fr) 360px; gap: 12px; }
+  @media (max-width: 900px) { .grid2 { grid-template-columns: 1fr; } }
+  .toggle { cursor: pointer; user-select: none; display: inline-block; width: 12px; }
+  .tree-name { white-space: nowrap; }
+  .bar { display: inline-block; height: 8px; background: #6b9bd2; vertical-align: middle; }
+  .progress { height: 6px; background: #ddd; width: 200px; }
+  .progress > div { height: 100%; background: #2b6cb0; width: 0%; }
+  .scroll { max-height: 520px; overflow: auto; }
+  .hidden { display: none; }
+  footer { padding: 8px 12px; color: #666; font-size: 11px; }
+</style>
+</head>
+<body>
+
+<header>
+  <h1>Source VProf Viewer</h1>
+  <div class="row">
+    <label>Choose a .vprof file: <input type="file" id="file"></label>
+    <span id="drop-hint" class="muted">or drag &amp; drop it onto this page</span>
+    <div id="progress-wrap" class="hidden">
+      <div class="progress"><div id="progress-bar"></div></div>
+      <span id="status" class="muted"></span>
+    </div>
+  </div>
+</header>
+
+<main>
+  <section id="error-box" class="warn hidden"></section>
+
+  <section id="summary-section" class="hidden">
+    <h2>Recording summary</h2>
+    <div id="notices"></div>
+    <div id="summary" class="summary-grid"></div>
+  </section>
+
+  <section id="timeline-section" class="hidden">
+    <h2>Frame timeline</h2>
+    <div class="controls">
+      <button id="range-all">All frames</button>
+      <label>From frame <input type="number" id="range-start" min="0" step="1"></label>
+      <label>to <input type="number" id="range-end" min="0" step="1"></label>
+      <button id="range-apply">Apply range</button>
+      <button id="frame-prev">&#9664; prev</button>
+      <button id="frame-next">next &#9654;</button>
+      <span id="frame-label" class="muted"></span>
+    </div>
+    <div id="timeline-wrap"><canvas id="timeline"></canvas></div>
+    <div id="timeline-caption" class="muted"></div>
+  </section>
+
+  <div class="grid2">
+    <section id="tree-section" class="hidden">
+      <h2>Call tree</h2>
+      <div class="controls">
+        <label>Filter <input type="search" id="filter" placeholder="name contains..."></label>
+        <button id="expand-all">Expand all</button>
+        <button id="collapse-all">Collapse all</button>
+        <span id="tree-count" class="muted"></span>
+      </div>
+      <div class="scroll">
+        <table id="tree-table">
+          <thead><tr id="tree-head"></tr></thead>
+          <tbody id="tree-body"></tbody>
+        </table>
+      </div>
+    </section>
+
+    <div>
+      <section id="budget-section" class="hidden">
+        <h2>Budget groups</h2>
+        <div class="scroll">
+          <table id="budget-table">
+            <thead><tr><th class="nosort">Group</th><th class="nosort num">Calls</th>
+              <th class="nosort num">Total</th><th class="nosort num">%</th></tr></thead>
+            <tbody id="budget-body"></tbody>
+          </table>
+        </div>
+      </section>
+
+      <section id="top-section" class="hidden">
+        <h2>Top nodes</h2>
+        <div class="scroll">
+          <table id="top-table">
+            <thead><tr><th class="nosort">Scope</th><th class="nosort num">Total</th>
+              <th class="nosort num">Self</th><th class="nosort num">Calls</th></tr></thead>
+            <tbody id="top-body"></tbody>
+          </table>
+        </div>
+      </section>
+    </div>
+  </div>
+</main>
+
+<footer>
+  VProf binary format documentation: see <code>VPROF_FORMAT.md</code> in the repository.
+</footer>
+
+<!-- ===================== Parsing worker (inline, self-contained) ===================== -->
+<script id="worker-src" type="text/js-worker">
+"use strict";
+
+// The 31 budget groups registered by CVProfile::CVProfile(), in fixed ID order.
+var DEFAULT_BUDGET_GROUPS = [
+  ["Unaccounted", 6],
+  ["World Rendering", 1],
+  ["Displacement_Rendering", 1],
+  ["Game", 6],
+  ["Player", 6],
+  ["NPCs", 6],
+  ["Server Animation", 6],
+  ["Client_Animation", 1],
+  ["Physics", 6],
+  ["Static_Prop_Rendering", 1],
+  ["Other_Model_Rendering", 1],
+  ["Fast Path Model Rendering", 1],
+  ["Light_Cache", 1],
+  ["Brush_Model_Rendering", 1],
+  ["Shadow_Rendering", 1],
+  ["Detail_Prop_Rendering", 1],
+  ["Particle/Effect_Rendering", 1],
+  ["Ropes", 1],
+  ["Dynamic_Light_Rendering", 1],
+  ["Networking", 6],
+  ["Sound", 6],
+  ["VGUI", 6],
+  ["FileSystem", 6],
+  ["Prediction", 1],
+  ["Interpolation", 1],
+  ["Swap_Buffers", 1],
+  ["Occlusion", 1],
+  ["Overlays", 1],
+  ["Tools", 5],
+  ["Texture_Cache", 1],
+  ["Replay", 2]
+];
+
+var TOKEN_START_FRAME = 0;
+var TOKEN_ADD_NODE = 1;
+var TOKEN_ADD_BUDGET_GROUP = 2;
+var TOKEN_TIMINGS = 3;
+var TOKEN_END_OF_FRAME = 4;
+var TOKEN_FILE_FINISHED = 5;
+
+self.onmessage = function (e) {
+  var msg = e.data;
+  if (!msg || msg.cmd !== "parse") return;
+  try {
+    var out = parseVprof(msg.buffer, msg.opts || {});
+    self.postMessage({ type: "done", result: out.result }, out.transfer);
+  } catch (err) {
+    self.postMessage({ type: "error", message: (err && err.message) || String(err) });
+  }
+};
+
+function parseVprof(buffer, opts) {
+  var t0 = (self.performance && self.performance.now) ? self.performance.now() : Date.now();
+  var dv = new DataView(buffer);
+  var totalBytes = buffer.byteLength;
+  var pos = 0;
+  var decoder = new TextDecoder("utf-8");
+
+  function u8() { return dv.getUint8(pos++); }
+  function u16() { var v = dv.getUint16(pos, true); pos += 2; return v; }
+  function u32() { var v = dv.getUint32(pos, true); pos += 4; return v; }
+  function i32() { var v = dv.getInt32(pos, true); pos += 4; return v; }
+  function cstr() {
+    var start = pos;
+    while (dv.getUint8(pos) !== 0) pos++;
+    var s = decoder.decode(new Uint8Array(buffer, start, pos - start));
+    pos++;
+    return s;
+  }
+
+  if (totalBytes < 8) throw new Error("File is too small to be a .vprof recording.");
+
+  var version = i32();
+  var rootNodeId = i32();
+
+  // ---- Budget groups (seed the 31 defaults so sparse recordings still resolve) ----
+  var budgetGroups = [];
+  var groupIdByName = Object.create(null);
+  for (var i = 0; i < DEFAULT_BUDGET_GROUPS.length; i++) {
+    var g = DEFAULT_BUDGET_GROUPS[i];
+    budgetGroups.push({ id: i, name: g[0], flags: g[1], isDefault: true });
+    groupIdByName[g[0]] = i;
+  }
+  function addBudgetGroup(name, flags) {
+    var id = groupIdByName[name];
+    if (id !== undefined) { budgetGroups[id].flags = flags; return id; }
+    id = budgetGroups.length;
+    budgetGroups.push({ id: id, name: name, flags: flags, isDefault: false });
+    groupIdByName[name] = id;
+    return id;
+  }
+
+  // ---- Nodes (sparse array indexed by unique node id) ----
+  var nodesById = [];
+  function makeNode(id, name, parentId, budgetGroupId) {
+    var n = { id: id, name: name, parentId: parentId, budgetGroupId: budgetGroupId, children: [] };
+    nodesById[id] = n;
+    return n;
+  }
+  makeNode(rootNodeId, "Root", null, 0);
+
+  // ---- Aggregates (sparse array indexed by unique node id) ----
+  var agg = [];
+  function aggFor(id) {
+    var a = agg[id];
+    if (!a) { a = { calls: 0, us: 0, maxUs: 0, frames: 0 }; agg[id] = a; }
+    return a;
+  }
+
+  // ---- Per-frame storage budget ----
+  var maxSamples = (opts.maxSamples && opts.maxSamples > 0) ? opts.maxSamples : 4000000;
+  var allowSamples = opts.noSamples !== true;
+  var storing = allowSamples;
+  var overflow = false;
+  var totalSamples = 0;
+  var samples = [];
+
+  var frameTicks = [];
+  var frameTotals = [];
+  var frameCalls = [];
+  var addedNodesByFrame = [];
+
+  var anyTime = false;
+  var anyCalls = false;
+
+  // Outer variables reused by readTimings (avoids allocating a closure per frame)
+  var curSamples = null;
+  var frameTotalUs = 0;
+  var frameTotalCalls = 0;
+
+  function readTimings(nodeId) {
+    var callsToken = u8();
+    var calls;
+    if (callsToken === 255) calls = u16(); else calls = callsToken;
+
+    var timeUnits = u16();
+    var us;
+    if (timeUnits === 0xFFFF) us = u32() * 4; else us = timeUnits * 4;
+
+    if (calls > 0) anyCalls = true;
+    if (us > 0) anyTime = true;
+    totalSamples++;
+
+    if (storing) {
+      curSamples.ids.push(nodeId);
+      curSamples.calls.push(calls);
+      curSamples.us.push(us);
+      if (totalSamples > maxSamples) {
+        // Too large to keep every frame; fall back to whole-recording aggregate.
+        storing = false;
+        overflow = true;
+        samples.length = 0;
+        curSamples = null;
+      }
+    }
+
+    frameTotalUs += us;
+    frameTotalCalls += calls;
+
+    var a = aggFor(nodeId);
+    a.calls += calls;
+    a.us += us;
+    if (us > a.maxUs) a.maxUs = us;
+    a.frames++;
+
+    var kids = nodesById[nodeId].children;
+    for (var k = 0; k < kids.length; k++) readTimings(kids[k]);
+  }
+
+  var frameCount = 0;
+  var progressAt = 0;
+  var totalFramesApprox = -1;
+
+  while (pos < totalBytes) {
+    var token = u8();
+    if (token === TOKEN_FILE_FINISHED) break;
+    if (token !== TOKEN_START_FRAME) {
+      throw new Error("Expected start-of-frame token at offset " + (pos - 1) + ", got " + token + ".");
+    }
+    var tick = i32();
+    frameTicks.push(tick);
+
+    frameTotalUs = 0;
+    frameTotalCalls = 0;
+    curSamples = storing ? { ids: [], calls: [], us: [] } : null;
+    var added = [];
+
+    while (true) {
+      var inner = u8();
+      if (inner === TOKEN_END_OF_FRAME) break;
+      if (inner === TOKEN_ADD_BUDGET_GROUP) {
+        var bgName = cstr();
+        var bgFlags = i32();
+        addBudgetGroup(bgName, bgFlags);
+      } else if (inner === TOKEN_ADD_NODE) {
+        var parentId = i32();
+        var nodeName = cstr();
+        var bgId = i32();
+        var nodeId = i32();
+        var parent = nodesById[parentId];
+        if (!parent) throw new Error("AddNode references unknown parent " + parentId + " at offset " + (pos - 4) + ".");
+        if (nodesById[nodeId]) throw new Error("AddNode re-declares node " + nodeId + " at offset " + (pos - 4) + ".");
+        makeNode(nodeId, nodeName, parentId, bgId);
+        parent.children.unshift(nodeId); // CVProfNode::GetSubNode() prepends
+        added.push(nodeId);
+      } else if (inner === TOKEN_TIMINGS) {
+        readTimings(rootNodeId);
+      } else {
+        throw new Error("Unexpected token " + inner + " inside frame " + frameCount + " at offset " + (pos - 1) + ".");
+      }
+    }
+
+    if (storing && curSamples) samples.push(curSamples);
+    frameTotals.push(frameTotalUs);
+    frameCalls.push(frameTotalCalls);
+    addedNodesByFrame.push(added);
+    frameCount++;
+
+    if (frameCount - progressAt >= 500) {
+      progressAt = frameCount;
+      self.postMessage({ type: "progress", frames: frameCount, pos: pos, total: totalBytes });
+    }
+  }
+
+  // ---- Compact and collect transferables ----
+  var transfer = [];
+  var frameTicksOut = Int32Array.from(frameTicks);
+  var frameTotalsOut = Float64Array.from(frameTotals);
+  var frameCallsOut = Uint32Array.from(frameCalls);
+  transfer.push(frameTicksOut.buffer, frameTotalsOut.buffer, frameCallsOut.buffer);
+
+  var addedOut = [];
+  for (var f = 0; f < addedNodesByFrame.length; f++) {
+    var arr = Int32Array.from(addedNodesByFrame[f]);
+    transfer.push(arr.buffer);
+    addedOut.push(arr);
+  }
+
+  var samplesOut = null;
+  if (storing) {
+    samplesOut = [];
+    for (var s = 0; s < samples.length; s++) {
+      var fr = samples[s];
+      var ids = Int32Array.from(fr.ids);
+      var callsArr = Uint32Array.from(fr.calls);
+      var usArr = Float64Array.from(fr.us);
+      transfer.push(ids.buffer, callsArr.buffer, usArr.buffer);
+      samplesOut.push({ ids: ids, calls: callsArr, us: usArr });
+    }
+  }
+
+  var t1 = (self.performance && self.performance.now) ? self.performance.now() : Date.now();
+
+  return {
+    result: {
+      version: version,
+      rootNodeId: rootNodeId,
+      budgetGroups: budgetGroups,
+      nodes: nodesById,
+      aggregates: agg,
+      frameCount: frameCount,
+      frameTicks: frameTicksOut,
+      frameTotals: frameTotalsOut,
+      frameCalls: frameCallsOut,
+      addedNodesByFrame: addedOut,
+      samples: samplesOut,
+      samplesStored: storing,
+      samplesOverflow: overflow,
+      totalSamples: totalSamples,
+      anyTime: anyTime,
+      anyCalls: anyCalls,
+      parseMs: t1 - t0
+    },
+    transfer: transfer
+  };
+}
+</script>
+
+<!-- ===================== UI ===================== -->
+<script>
+"use strict";
+
+/* ------------------------------- helpers ------------------------------- */
+function $(id) { return document.getElementById(id); }
+function esc(s) {
+  return String(s).replace(/[&<>"']/g, function (c) {
+    return ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c];
+  });
+}
+function fmtUs(us) {
+  if (!us) return "0";
+  var ms = us / 1000;
+  if (Math.abs(ms) >= 1000) return (ms / 1000).toFixed(3) + " s";
+  if (Math.abs(ms) >= 1) return ms.toFixed(3) + " ms";
+  return us.toFixed(1) + " \u00b5s";
+}
+function fmtInt(n) {
+  return (n === undefined || n === null) ? "0" : Number(n).toLocaleString();
+}
+function fmtBytes(n) {
+  if (n < 1024) return n + " B";
+  if (n < 1024 * 1024) return (n / 1024).toFixed(1) + " KiB";
+  if (n < 1024 * 1024 * 1024) return (n / 1048576).toFixed(1) + " MiB";
+  return (n / 1073741824).toFixed(2) + " GiB";
+}
+function flagsDecoded(flags) {
+  var out = [];
+  if (flags & 1) out.push("CLIENT");
+  if (flags & 2) out.push("SERVER");
+  if (flags & 4) out.push("OTHER");
+  if (flags & 32768) out.push("HIDDEN");
+  return out;
+}
+
+/* ------------------------------- state ------------------------------- */
+var state = null;
+
+/* ------------------------------- loading ------------------------------- */
+var worker = null;
+var lastFile = null;
+
+function setError(msg) {
+  var box = $("error-box");
+  if (!msg) { box.classList.add("hidden"); box.textContent = ""; return; }
+  box.classList.remove("hidden");
+  box.textContent = msg;
+}
+
+function startParse(file) {
+  lastFile = file;
+  setError("");
+  $("progress-wrap").classList.remove("hidden");
+  $("progress-bar").style.width = "0%";
+  $("status").textContent = "Reading " + file.name + " (" + fmtBytes(file.size) + ")...";
+
+  file.arrayBuffer().then(function (buf) {
+    if (worker) { worker.terminate(); worker = null; }
+    var src = $("worker-src").textContent;
+    var url = URL.createObjectURL(new Blob([src], { type: "text/javascript" }));
+    worker = new Worker(url);
+    worker.onmessage = onWorkerMessage;
+    worker.onerror = function (e) {
+      setError("Worker error: " + (e.message || "unknown"));
+    };
+    $("status").textContent = "Parsing...";
+    worker.postMessage({ cmd: "parse", buffer: buf, opts: { maxSamples: 4000000 } }, [buf]);
+  }).catch(function (err) {
+    setError("Could not read file: " + err);
+  });
+}
+
+function onWorkerMessage(e) {
+  var msg = e.data;
+  if (msg.type === "progress") {
+    var pct = msg.total ? Math.floor(100 * msg.pos / msg.total) : 0;
+    $("progress-bar").style.width = pct + "%";
+    $("status").textContent = "Parsing... " + fmtInt(msg.frames) + " frames (" + pct + "%)";
+  } else if (msg.type === "error") {
+    setError("Parse error: " + msg.message);
+    $("status").textContent = "Failed.";
+  } else if (msg.type === "done") {
+    $("progress-bar").style.width = "100%";
+    $("status").textContent = "Done.";
+    $("progress-wrap").classList.add("hidden");
+    buildState(msg.result, lastFile);
+    renderAll();
+  }
+}
+
+/* ------------------------------- state build ------------------------------- */
+function buildState(result, file) {
+  var nodeById = result.nodes;
+  var nodes = [];
+  for (var i = 0; i < nodeById.length; i++) if (nodeById[i]) nodes.push(nodeById[i]);
+
+  var groupName = Object.create(null);
+  for (var g = 0; g < result.budgetGroups.length; g++) groupName[result.budgetGroups[g].id] = result.budgetGroups[g].name;
+
+  // Collapse everything except the root unless the tree is small.
+  var collapsed = new Set();
+  for (var k = 0; k < nodes.length; k++) collapsed.add(nodes[k].id);
+  collapsed.delete(result.rootNodeId);
+  if (nodes.length <= 200) {
+    // small tree: expand fully
+    collapsed.clear();
+  }
+
+  state = {
+    r: result,
+    file: file,
+    nodeById: nodeById,
+    nodes: nodes,
+    groupName: groupName,
+    maxId: nodeById.length,
+    frameCount: result.frameCount,
+    rangeStart: 0,
+    rangeEnd: Math.max(0, result.frameCount - 1),
+    metricTime: result.anyTime,
+    sortKey: result.anyTime ? "total" : "name",
+    sortDir: result.anyTime ? -1 : 1,
+    collapsed: collapsed,
+    filter: "",
+    agg: result.aggregates,
+    self: new Float64Array(nodeById.length),
+    rootTotal: 0,
+    percentBase: 1
+  };
+  updateRangeInputs();
+}
+
+/* ------------------------------- aggregation ------------------------------- */
+function getAggregate(start, end) {
+  var s = state;
+  if (!s.r.samples || (start <= 0 && end >= s.frameCount - 1)) return s.r.aggregates;
+
+  var agg = [];
+  function get(id) {
+    var a = agg[id];
+    if (!a) { a = { calls: 0, us: 0, maxUs: 0, frames: 0 }; agg[id] = a; }
+    return a;
+  }
+  var endClamped = Math.min(end, s.frameCount - 1);
+  for (var f = Math.max(0, start); f <= endClamped; f++) {
+    var fr = s.r.samples[f];
+    if (!fr) continue;
+    var ids = fr.ids, calls = fr.calls, us = fr.us;
+    for (var i = 0; i < ids.length; i++) {
+      var id = ids[i];
+      var a = get(id);
+      a.calls += calls[i];
+      a.us += us[i];
+      if (us[i] > a.maxUs) a.maxUs = us[i];
+      a.frames++;
+    }
+  }
+  return agg;
+}
+
+function recompute() {
+  var s = state;
+  var agg = getAggregate(s.rangeStart, s.rangeEnd);
+  s.agg = agg;
+
+  var self = new Float64Array(s.maxId);
+  for (var id = 0; id < agg.length; id++) if (agg[id]) self[id] = agg[id].us;
+  for (var id2 = 0; id2 < agg.length; id2++) {
+    if (!agg[id2]) continue;
+    var n = s.nodeById[id2];
+    if (!n) continue;
+    var kids = n.children;
+    for (var c = 0; c < kids.length; c++) if (agg[kids[c]]) self[id2] -= agg[kids[c]].us;
+  }
+  s.self = self;
+
+  var rootAgg = agg[s.r.rootNodeId];
+  s.rootTotal = rootAgg ? rootAgg.us : 0;
+  if (s.metricTime) {
+    s.percentBase = s.rootTotal || 1;
+  } else {
+    s.percentBase = (rootAgg && rootAgg.calls) || 1;
+  }
+}
+
+/* ------------------------------- rendering ------------------------------- */
+function renderAll() {
+  recompute();
+  renderSummary();
+  drawTimeline();
+  renderTree();
+  renderBudgets();
+  renderTop();
+  ["summary-section", "timeline-section", "tree-section", "budget-section", "top-section"]
+    .forEach(function (id) { $(id).classList.remove("hidden"); });
+
+  // Range scoping needs per-frame samples; disable it when they were dropped.
+  var rangeDisabled = !state.r.samples;
+  ["range-start", "range-end", "range-apply", "frame-prev", "frame-next"].forEach(function (id) {
+    $(id).disabled = rangeDisabled;
+  });
+}
+
+function renderSummary() {
+  var s = state, r = s.r;
+
+  var notices = $("notices");
+  notices.innerHTML = "";
+  if (!r.anyTime) {
+    var d = document.createElement("div");
+    d.className = "warn";
+    d.innerHTML = "<strong>No timing data in this recording.</strong> Every sampled " +
+      "microsecond value is zero, so time/self/percentage columns are not meaningful. " +
+      "This is common for captures made by the engine's snapshot path, which clears the " +
+      "current frame's counters before writing them. The call tree, call counts and " +
+      "structure below are still shown.";
+    notices.appendChild(d);
+  }
+  if (r.samplesOverflow) {
+    var d2 = document.createElement("div");
+    d2.className = "notice";
+    d2.textContent = "This recording is very large (" + fmtInt(r.totalSamples) +
+      " samples), so per-frame samples were not retained. Statistics cover the whole " +
+      "recording and frame-range selection is disabled.";
+    notices.appendChild(d2);
+  }
+
+  var rows = [
+    ["File", s.file ? s.file.name : "(unknown)"],
+    ["Size", s.file ? fmtBytes(s.file.size) : "-"],
+    ["Format version", r.version],
+    ["Frames", fmtInt(r.frameCount)],
+    ["Nodes", fmtInt(s.nodes.length)],
+    ["Budget groups", fmtInt(r.budgetGroups.length)],
+    ["Samples", fmtInt(r.totalSamples)],
+    ["Total time", fmtUs(Math.round(s.rootTotal))],
+    ["Parse time", Math.round(r.parseMs) + " ms"]
+  ];
+  $("summary").innerHTML = rows.map(function (row) {
+    return '<div><div class="k">' + esc(row[0]) + '</div><div class="v">' + esc(row[1]) + "</div></div>";
+  }).join("");
+}
+
+/* ---- timeline ---- */
+var drag = null;
+
+function useCallsForTimeline() {
+  return !state.r.anyTime;
+}
+function timelineData() {
+  return useCallsForTimeline() ? state.r.frameCalls : state.r.frameTotals;
+}
+function frameAt(clientX) {
+  var c = $("timeline");
+  var rect = c.getBoundingClientRect();
+  var x = clientX - rect.left;
+  var f = Math.floor((x / rect.width) * state.frameCount);
+  if (f < 0) f = 0;
+  if (f > state.frameCount - 1) f = state.frameCount - 1;
+  return f;
+}
+function drawTimeline() {
+  var c = $("timeline");
+  var wrap = c.parentElement;
+  var w = wrap.clientWidth || 600;
+  var h = 120;
+  var dpr = window.devicePixelRatio || 1;
+  c.width = Math.round(w * dpr);
+  c.height = Math.round(h * dpr);
+  c.style.width = w + "px";
+  c.style.height = h + "px";
+  var ctx = c.getContext("2d");
+  ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
+  ctx.clearRect(0, 0, w, h);
+
+  var s = state;
+  var data = timelineData();
+  var max = 0;
+  for (var i = 0; i < s.frameCount; i++) if (data[i] > max) max = data[i];
+
+  var selA = s.rangeStart, selB = s.rangeEnd;
+  if (drag && drag.active) {
+    selA = Math.min(drag.start, drag.end);
+    selB = Math.max(drag.start, drag.end);
+  }
+
+  var padTop = 4, padBottom = 16;
+  var plotH = h - padTop - padBottom;
+  var bw = s.frameCount ? (w / s.frameCount) : w;
+
+  for (var f = 0; f < s.frameCount; f++) {
+    var v = data[f];
+    var bh = max ? (v / max) * plotH : 0;
+    var x = f * bw;
+    var inSel = (f >= selA && f <= selB);
+    ctx.fillStyle = inSel ? "#2b6cb0" : "#b9c6d6";
+    ctx.fillRect(x, padTop + plotH - bh, Math.max(1, bw - 0.25), bh);
+  }
+
+  // selection background edges
+  ctx.strokeStyle = "#e0a800";
+  ctx.lineWidth = 1;
+  if (selA > 0) { var xa = selA * bw; ctx.beginPath(); ctx.moveTo(xa, padTop); ctx.lineTo(xa, padTop + plotH); ctx.stroke(); }
+  if (selB < s.frameCount - 1) { var xb = (selB + 1) * bw; ctx.beginPath(); ctx.moveTo(xb, padTop); ctx.lineTo(xb, padTop + plotH); ctx.stroke(); }
+
+  ctx.fillStyle = "#333";
+  ctx.font = "11px system-ui, sans-serif";
+  var unit = useCallsForTimeline() ? "calls" : "time";
+  ctx.fillText("max " + (useCallsForTimeline() ? fmtInt(max) : fmtUs(max)) + " per frame (" + unit + ")", 4, h - 4);
+  ctx.fillText("frame 0", 4, h - 4 - 0);
+  var lastLabel = "frame " + (s.frameCount - 1);
+  ctx.fillText(lastLabel, Math.max(4, w - ctx.measureText(lastLabel).width - 4), h - 4);
+
+  $("timeline-caption").textContent = useCallsForTimeline()
+    ? "Showing per-frame total call counts (no timing data available). Drag to select a frame range."
+    : "Showing per-frame total microseconds. Drag to select a frame range.";
+  $("frame-label").textContent = "selected frames " + selA + "-" + selB;
+}
+function applyRange(a, b) {
+  var s = state;
+  a = Math.max(0, Math.min(a, s.frameCount - 1));
+  b = Math.max(0, Math.min(b, s.frameCount - 1));
+  if (a > b) { var t = a; a = b; b = t; }
+  s.rangeStart = a; s.rangeEnd = b;
+  updateRangeInputs();
+  renderAll();
+}
+function updateRangeInputs() {
+  $("range-start").value = state.rangeStart;
+  $("range-end").value = state.rangeEnd;
+}
+
+/* ---- tree ---- */
+function compareIds(aId, bId) {
+  var s = state;
+  if (s.sortKey === "name") {
+    return s.nodeById[aId].name.localeCompare(s.nodeById[bId].name) * s.sortDir;
+  }
+  var a = s.agg[aId] || { calls: 0, us: 0, maxUs: 0, frames: 0 };
+  var b = s.agg[bId] || { calls: 0, us: 0, maxUs: 0, frames: 0 };
+  var av, bv;
+  switch (s.sortKey) {
+    case "calls": av = a.calls; bv = b.calls; break;
+    case "self": av = s.self[aId] || 0; bv = s.self[bId] || 0; break;
+    case "avg": av = a.frames ? a.us / a.frames : 0; bv = b.frames ? b.us / b.frames : 0; break;
+    case "max": av = a.maxUs; bv = b.maxUs; break;
+    default: av = a.us; bv = b.us;
+  }
+  if (av === bv) return s.nodeById[aId].name.localeCompare(s.nodeById[bId].name);
+  return (av < bv ? -1 : 1) * s.sortDir;
+}
+function computeMatchSet(text) {
+  if (!text) return null;
+  var s = state;
+  var t = text.toLowerCase();
+  var set = new Set();
+  for (var i = 0; i < s.nodes.length; i++) {
+    var n = s.nodes[i];
+    if (n.name.toLowerCase().indexOf(t) !== -1) {
+      set.add(n.id);
+      var p = n.parentId;
+      while (p !== null && p !== undefined) { set.add(p); p = s.nodeById[p].parentId; }
+    }
+  }
+  return set;
+}
+function renderTree() {
+  var s = state;
+  var matchSet = computeMatchSet(s.filter);
+  var orderCache = Object.create(null);
+  function order(id) {
+    var c = orderCache[id];
+    if (c) return c;
+    c = s.nodeById[id].children.slice();
+    c.sort(compareIds);
+    orderCache[id] = c;
+    return c;
+  }
+
+  var rows = [];
+  function visit(id, depth) {
+    var n = s.nodeById[id];
+    if (!n) return;
+    if (matchSet && !matchSet.has(id)) return;
+    var a = s.agg[id] || { calls: 0, us: 0, maxUs: 0, frames: 0 };
+    var totalUs = a.us;
+    var selfUs = s.self[id] || 0;
+    var pct = s.percentBase ? ((s.metricTime ? totalUs : a.calls) / s.percentBase) * 100 : 0;
+    rows.push({ id: id, depth: depth, n: n, a: a, totalUs: totalUs, selfUs: selfUs, pct: pct });
+    var collapsed = !matchSet && s.collapsed.has(id);
+    if (!collapsed) { order(id).forEach(function (c) { visit(c, depth + 1); }); }
+  }
+  visit(s.r.rootNodeId, 0);
+
+  // Header
+  var cols = [
+    { key: "name", label: "Scope", num: false },
+    { key: "calls", label: "Calls", num: true },
+    { key: "total", label: "Total", num: true },
+    { key: "self", label: "Self", num: true },
+    { key: "avg", label: "Avg", num: true },
+    { key: "max", label: "Max", num: true },
+    { key: "_pct", label: "%", num: true }
+  ];
+  $("tree-head").innerHTML = cols.map(function (c) {
+    var sortable = c.key !== "_pct";
+    var marker = "";
+    if (sortable && s.sortKey === c.key) marker = s.sortDir < 0 ? " \u25bc" : " \u25b2";
+    return '<th class="' + (sortable ? "" : "nosort") + (c.num ? " num" : "") +
+      '" data-key="' + (sortable ? c.key : "") + '">' + esc(c.label) + marker + "</th>";
+  }).join("");
+  Array.prototype.forEach.call($("tree-head").children, function (th) {
+    var key = th.getAttribute("data-key");
+    if (!key) return;
+    th.onclick = function () {
+      if (state.sortKey === key) state.sortDir = -state.sortDir;
+      else { state.sortKey = key; state.sortDir = key === "name" ? 1 : -1; }
+      renderTree();
+    };
+  });
+
+  var maxMetric = 0;
+  rows.forEach(function (r) { maxMetric = Math.max(maxMetric, s.metricTime ? r.totalUs : r.a.calls); });
+
+  var html = rows.map(function (r) {
+    var hasKids = r.n.children.length > 0;
+    var collapsed = !matchSet && s.collapsed.has(r.id);
+    var toggle = hasKids ? (collapsed ? "\u25b6" : "\u25bc") : "\u00a0";
+    var indent = "padding-left:" + (4 + r.depth * 14) + "px";
+    var metric = s.metricTime ? r.totalUs : r.a.calls;
+    var barW = maxMetric ? Math.round(60 * metric / maxMetric) : 0;
+    var avg = r.a.frames ? r.a.us / r.a.frames : 0;
+    var groupName = s.groupName[r.n.budgetGroupId] || ("group " + r.n.budgetGroupId);
+    return '<tr data-id="' + r.id + '">' +
+      '<td class="tree-name" style="' + indent + '"><span class="toggle">' + toggle + "</span>" +
+      esc(r.n.name) + ' <span class="muted">[' + esc(groupName) + "]</span>" +
+      '<div class="bar" style="width:' + barW + 'px"></div></td>' +
+      '<td class="num">' + fmtInt(r.a.calls) + "</td>" +
+      '<td class="num">' + (s.metricTime ? fmtUs(r.totalUs) : "-") + "</td>" +
+      '<td class="num">' + (s.metricTime ? fmtUs(r.selfUs) : "-") + "</td>" +
+      '<td class="num">' + (s.metricTime && r.a.frames ? fmtUs(avg) : "-") + "</td>" +
+      '<td class="num">' + (s.metricTime ? fmtUs(r.a.maxUs) : "-") + "</td>" +
+      '<td class="num">' + (s.metricTime ? r.pct.toFixed(2) + "%" : "-") + "</td>" +
+      "</tr>";
+  }).join("");
+  $("tree-body").innerHTML = html;
+  $("tree-count").textContent = rows.length + " rows shown";
+
+  Array.prototype.forEach.call($("tree-body").querySelectorAll("tr"), function (tr) {
+    tr.onclick = function (ev) {
+      var id = Number(tr.getAttribute("data-id"));
+      var n = s.nodeById[id];
+      if (!n || !n.children.length) return;
+      if (s.collapsed.has(id)) s.collapsed.delete(id); else s.collapsed.add(id);
+      renderTree();
+    };
+  });
+}
+
+/* ---- budget groups ---- */
+function renderBudgets() {
+  var s = state;
+  var perGroup = Object.create(null);
+  for (var id = 0; id < s.agg.length; id++) {
+    var a = s.agg[id];
+    if (!a) continue;
+    var n = s.nodeById[id];
+    if (!n) continue;
+    var gid = n.budgetGroupId;
+    var e = perGroup[gid];
+    if (!e) e = perGroup[gid] = { us: 0, calls: 0 };
+    e.us += a.us; e.calls += a.calls;
+  }
+  var rows = [];
+  for (var gi = 0; gi < s.r.budgetGroups.length; gi++) {
+    var g = s.r.budgetGroups[gi];
+    var e2 = perGroup[g.id];
+    if (!e2 && !g.isDefault) continue;
+    rows.push({ id: g.id, name: g.name, us: e2 ? e2.us : 0, calls: e2 ? e2.calls : 0 });
+  }
+  var metric = s.metricTime ? "us" : "calls";
+  rows.sort(function (a, b) { return b[metric] - a[metric]; });
+
+  $("budget-body").innerHTML = rows.map(function (r) {
+    var pct = s.percentBase ? (r[metric] / s.percentBase) * 100 : 0;
+    return "<tr><td>" + esc(r.name) + "</td><td class='num'>" + fmtInt(r.calls) + "</td>" +
+      "<td class='num'>" + (s.metricTime ? fmtUs(r.us) : "-") + "</td>" +
+      "<td class='num'>" + (s.metricTime ? pct.toFixed(2) + "%" : "-") + "</td></tr>";
+  }).join("");
+}
+
+/* ---- top nodes ---- */
+function renderTop() {
+  var s = state;
+  var rows = [];
+  for (var id = 0; id < s.agg.length; id++) {
+    var a = s.agg[id];
+    if (!a) continue;
+    var n = s.nodeById[id];
+    if (!n) continue;
+    rows.push({ id: id, name: n.name, us: a.us, self: s.self[id] || 0, calls: a.calls });
+  }
+  if (s.metricTime) rows.sort(function (a, b) { return b.us - a.us; });
+  else rows.sort(function (a, b) { return b.calls - a.calls; });
+  rows = rows.slice(0, 100);
+
+  $("top-body").innerHTML = rows.map(function (r) {
+    return "<tr><td>" + esc(r.name) + "</td>" +
+      "<td class='num'>" + (s.metricTime ? fmtUs(r.us) : "-") + "</td>" +
+      "<td class='num'>" + (s.metricTime ? fmtUs(r.self) : "-") + "</td>" +
+      "<td class='num'>" + fmtInt(r.calls) + "</td></tr>";
+  }).join("");
+}
+
+/* ------------------------------- events ------------------------------- */
+$("file").addEventListener("change", function (e) {
+  var f = e.target.files && e.target.files[0];
+  if (f) startParse(f);
+});
+document.addEventListener("dragover", function (e) { e.preventDefault(); });
+document.addEventListener("drop", function (e) {
+  e.preventDefault();
+  var f = e.dataTransfer && e.dataTransfer.files && e.dataTransfer.files[0];
+  if (f) startParse(f);
+});
+
+$("range-all").onclick = function () { applyRange(0, state.frameCount - 1); };
+$("range-apply").onclick = function () {
+  applyRange(Number($("range-start").value), Number($("range-end").value));
+};
+$("frame-prev").onclick = function () {
+  var f = Math.max(0, state.rangeStart - 1); applyRange(f, f);
+};
+$("frame-next").onclick = function () {
+  var f = Math.min(state.frameCount - 1, state.rangeEnd + 1); applyRange(f, f);
+};
+$("expand-all").onclick = function () { state.collapsed.clear(); renderTree(); };
+$("collapse-all").onclick = function () {
+  state.collapsed.clear();
+  for (var i = 0; i < state.nodes.length; i++) state.collapsed.add(state.nodes[i].id);
+  state.collapsed.delete(state.r.rootNodeId);
+  renderTree();
+};
+var filterTimer = null;
+$("filter").addEventListener("input", function (e) {
+  clearTimeout(filterTimer);
+  filterTimer = setTimeout(function () { state.filter = e.target.value.trim(); renderTree(); }, 120);
+});
+
+/* timeline drag selection */
+(function () {
+  var c = $("timeline");
+  c.addEventListener("mousedown", function (e) {
+    if (!state || !state.r.samples) return;
+    e.preventDefault();
+    drag = { active: true, start: frameAt(e.clientX), end: frameAt(e.clientX) };
+    drawTimeline();
+  });
+  window.addEventListener("mousemove", function (e) {
+    if (!drag || !drag.active) return;
+    drag.end = frameAt(e.clientX);
+    drawTimeline();
+  });
+  window.addEventListener("mouseup", function (e) {
+    if (!drag || !drag.active) return;
+    drag.end = frameAt(e.clientX);
+    var a = drag.start, b = drag.end;
+    drag = null;
+    applyRange(a, b);
+  });
+  window.addEventListener("resize", function () { if (state) drawTimeline(); });
+})();
+</script>
+</body>
+</html>

+ 172 - 0
server-patch/README.md

@@ -0,0 +1,172 @@
+# VProf timing fix — Source Scramble memory patches
+
+Runtime fix for the Source Engine `.vprof` recorder bug that produces captures
+with a correct call tree but **all-zero timings**. No engine recompile is
+required; this is a small set of byte patches applied by
+[Source Scramble](https://github.com/nosoop/SMExt-SourceScramble).
+
+This lives alongside the viewer in this repository, but it is installed only on
+the game server.
+
+## The bug
+
+The recorder writes the *current*-frame counters:
+
+```cpp
+// engine/vprof_record.cpp
+unsigned short curCalls = min( pIn->m_nCurFrameCalls, 0xFFFF );
+unsigned long nMicroseconds = pIn->m_CurFrameTime.GetMicroseconds() / 4;
+```
+
+but `PreUpdateProfile()` calls `VProfRecord_Snapshot()` **after**
+`CVProfile::MarkFrame()` has rotated those counters into the previous-frame
+fields and cleared the current ones:
+
+```cpp
+// public/tier0/vprof.h  CVProfile::MarkFrame()
+m_Root.ExitScope();
+m_Root.MarkFrame();   // m_nPrevFrameCalls = m_nCurFrameCalls; m_nCurFrameCalls = 0; (same for time)
+m_Root.EnterScope();  // root current calls becomes 1
+```
+
+So the writer sees `root.calls == 1`, every other node `calls == 0`, and every
+time `0`. This affects 32-bit and 64-bit Linux (and Windows) server builds.
+
+The export path runs first and calls `CVProfNode::ClearPrevTime()`, so the
+previous-frame data would be wiped before the recorder even if the recorder
+read it.
+
+## What the patches do
+
+1. **Read the previous-frame fields in the recorder.**
+   `CVProfRecorder::Record_WriteTimings_R()` reads `m_nCurFrameCalls` and
+   `m_CurFrameTime`; the patches redirect those reads to `m_nPrevFrameCalls`
+   and `m_PrevFrameTime`.
+2. **Keep the previous-frame data alive until the recorder runs.**
+   `VProfRecord_IsPlayingBack()` is forced to return `true`, which makes
+   `CVProfExport::CalculateBudgetGroupTimes_Recursive()` skip
+   `ClearPrevTime()`. The export path still computes its budget-group history;
+   it just no longer clears the data first.
+
+After the patches, each recorded frame contains the just-completed frame's
+timings. (The tick label remains the snapshot frame's tick, so it is one frame
+ahead of the data — the timing values themselves are correct.)
+
+## Requirements
+
+* Team Fortress 2 dedicated server, **build 11068238**, Linux x86 or x86_64.
+  The offsets are build-specific; see *Updating* below.
+* Metamod:Source and SourceMod (1.11/1.12) installed.
+* The [Source Scramble](https://github.com/nosoop/SMExt-SourceScramble)
+  extension (`sourcescramble.ext`) and its `sourcescramble_manager` plugin.
+
+The signatures use SourceMod's `@mangled` symbol form. TF2 reports its symbols
+as hidden, so SourceMod resolves them from the unstripped engine's ELF
+`.symtab` and adds the load base at runtime, which makes the patches immune to
+ASLR / relocation (unlike raw byte signatures containing absolute addresses).
+
+## Installation
+
+Copy the `addons/` tree over the server's `tf/` directory:
+
+```
+addons/sourcemod/gamedata/vprof_timings.txt
+addons/sourcemod/configs/sourcescramble/vprof_timings.cfg
+```
+
+Then restart the server (or `sm plugins reload sourcescramble_manager`).
+
+On load the server console should print one line per patch:
+
+```
+[sourcescramble] Enabled patch "CVProfRecorder::Record_WriteTimings_R()::UsePrevCalls" from "vprof_timings"
+[sourcescramble] Enabled patch "CVProfRecorder::Record_WriteTimings_R()::UsePrevTimeHigh" from "vprof_timings"
+[sourcescramble] Enabled patch "CVProfRecorder::Record_WriteTimings_R()::UsePrevTimeLow" from "vprof_timings"
+[sourcescramble] Enabled patch "VProfRecord_IsPlayingBack()::SkipClearPrev" from "vprof_timings"
+```
+
+If any line instead says `Failed to verify patch`, the server binary does not
+match the supported build. **Do not force it** — regenerate the offsets for the
+new build (below) or remove the patch.
+
+## Verifying the fix
+
+1. Enable profiling and record a short capture:
+   ```
+   vprof_on
+   vprof_record_start mycapture
+   ... let the server run ...
+   vprof_record_stop
+   ```
+   The file is written under the server's working directory.
+2. Parse it with the tools in this repository:
+   ```bash
+   python vprof_to_json.py mycapture.vprof --summary-only --indent 2
+   ```
+   or open it in `index.html`. The viewer's summary should now report
+   `"anyTime": true`, and nodes should have non-zero `total_microseconds`.
+   Previously every time value was `0`.
+
+## Patches applied
+
+`linux` = `engine_srv.so`, `linux64` = `bin/linux64/engine_srv.so`.
+
+| Patch | Module | Offset | Original | Patched | Purpose |
+|---|---|---|---:|---|---|---|
+| `...Record_WriteTimings_R()::UsePrevCalls` | engine | `1Dh` / `1Eh` | `38` / `4C` | `44` / `58` | read `m_nPrevFrameCalls` instead of `m_nCurFrameCalls` |
+| `...::UsePrevTimeHigh` | engine | `53h` / `66h` | `40` / `50` | `4Ch` / `60h` | read `m_PrevFrameTime` instead of `m_CurFrameTime` |
+| `...::UsePrevTimeLow` | engine | `5Fh` (32-bit only) | `3Ch` | `48h` | low dword of the 32-bit `int64` time read |
+| `VProfRecord_IsPlayingBack()::SkipClearPrev` | engine | `7h` | `0F 94 C0` | `B0 01 90` | make the predicate return `1`, skipping `ClearPrevTime()` |
+
+The 64-bit build has a single 64-bit time load, so `UsePrevTimeLow` has no
+`linux64` block. On a 64-bit server that entry resolves but is a no-op (the
+manager will still log it as enabled).
+
+## Caveats
+
+* These are position/byte-specific patches. A TF2 update that touches
+  `engine_srv.so` will make `verify` fail; re-derive the offsets rather than
+  disabling verification.
+* `VProfRecord_IsPlayingBack()` is also consulted by the VProf VGUI panels.
+  Dedicated servers have no such UI, so there is no practical effect there. On
+  a listen server the VProf UI may treat the session as if a recording were
+  being played back.
+* Existing all-zero captures cannot be repaired; re-record after installing.
+* The viewer (`index.html`, `vprof_to_json.py`) needs no changes: it already
+  handles both zero and non-zero timing data.
+
+## Updating for a new TF2 build
+
+Symbols are present in the unstripped `engine_srv.so`, so the offsets can be
+re-derived with `nm` + `objdump`:
+
+```bash
+nm engine_srv.so | grep -E \
+  '_ZN14CVProfRecorder21Record_WriteTimings_REPK10CVProfNode|_Z25VProfRecord_IsPlayingBackv'
+```
+
+Disassemble just the relevant ranges (do not `objdump -d` the whole module on a
+small VM) and locate:
+
+* the `mov`/`imul`/`mul` displacements reading `m_nCurFrameCalls` and
+  `m_CurFrameTime` (cross-check against `CVProfNode::MarkFrame()` in
+  `libtier0_srv.so`, which shows the current and previous field offsets);
+* the `setX al` in `VProfRecord_IsPlayingBack()`.
+
+Then update only the `offset`/`verify`/`patch` values in
+`gamedata/vprof_timings.txt`. The signatures themselves are `@mangled` symbols,
+so they do not depend on the load base (ASLR) and normally only change if Valve
+renames or removes the functions.
+
+Validate offline with the bundled tool (pass the two `engine_srv.so` binaries):
+
+```bash
+python3 server-patch/tools/verify_gamedata.py \
+  server-patch/addons/sourcemod/gamedata/vprof_timings.txt \
+  engine_srv.so engine_srv.x64.so
+```
+
+`verify_gamedata.py` parses the ELF `.symtab` to resolve the `@symbol`
+signatures the same way SourceMod's `MemoryUtils::ResolveSymbol` does, then
+applies Source Scramble's offset/verify/patch semantics, so a passing run means
+the gamedata will resolve and apply on the server.

+ 21 - 0
server-patch/addons/sourcemod/configs/sourcescramble/vprof_timings.cfg

@@ -0,0 +1,21 @@
+/**
+ * Source Scramble patch loader configuration for vprof_timings.txt.
+ *
+ * Install as:
+ *   addons/sourcemod/configs/sourcescramble/vprof_timings.cfg
+ *
+ * The gamedata file (vprof_timings.txt) must be installed as:
+ *   addons/sourcemod/gamedata/vprof_timings.txt
+ *
+ * `sourcescramble_manager` reads every file in configs/sourcescramble/ and
+ * applies each listed patch.  Duplicate keys are intentional: SMCParser's
+ * OnKeyValue fires once per pair, so each patch gets its own handle.
+ */
+
+"Patches"
+{
+	"vprof_timings"		"CVProfRecorder::Record_WriteTimings_R()::UsePrevCalls"
+	"vprof_timings"		"CVProfRecorder::Record_WriteTimings_R()::UsePrevTimeHigh"
+	"vprof_timings"		"CVProfRecorder::Record_WriteTimings_R()::UsePrevTimeLow"
+	"vprof_timings"		"VProfRecord_IsPlayingBack()::SkipClearPrev"
+}

+ 146 - 0
server-patch/addons/sourcemod/gamedata/vprof_timings.txt

@@ -0,0 +1,146 @@
+// vprof_timings.txt — Source Scramble memory patches that make Source Engine
+// VProf captures record real per-frame timing data.
+//
+// Target: Team Fortress 2 build 11068238 (Linux x86 and x86_64),
+//         addons/sourcemod/extensions/sourcescramble.ext.
+//
+// Background
+// ----------
+// The VProf recorder writes the *current*-frame counters:
+//     CVProfRecorder::Record_WriteTimings_R()  reads  pIn->m_nCurFrameCalls
+//                                                    and pIn->m_CurFrameTime
+// but engine/vprof_engine.cpp calls VProfRecord_Snapshot() after
+// CVProfile::MarkFrame() has already rotated those counters into the
+// *previous*-frame fields (m_nPrevFrameCalls / m_PrevFrameTime) and cleared
+// the current ones.  The result is that every .vprof capture contains a
+// correct call tree with all-zero times and call counts.
+//
+// CVProfExport::CalculateBudgetGroupTimes_Recursive() additionally calls
+// CVProfNode::ClearPrevTime() before the recorder runs, which would wipe the
+// previous-frame data even if the recorder read it.
+//
+// This patch set:
+//   1. redirects the three current-frame field reads in
+//      Record_WriteTimings_R() to the matching previous-frame fields, and
+//   2. forces VProfRecord_IsPlayingBack() to return true so that
+//      CalculateBudgetGroupTimes_Recursive() skips ClearPrevTime().
+//
+// The recorder still runs after the export, but the previous-frame data now
+// survives until it does.  The recorded frame carries the tick of the frame
+// it was snapshotted in (a harmless one-frame label offset; the timing values
+// themselves are correct).
+//
+// The signatures are resolved from the unstripped engine binary's ELF
+// .symtab via SourceMod's `@symbol` support (TF2 reports hidden symbols, so
+// SourceMod uses MemoryUtils::ResolveSymbol rather than dlsym).  This keeps
+// them independent of ASLR / load-base relocation.  The byte offsets are
+// relative to the function entry and are build-specific; regenerate them
+// whenever the engine is updated.
+
+"Games"
+{
+	"tf"
+	{
+		"Signatures"
+		{
+			// CVProfRecorder::Record_WriteTimings_R(CVProfNode const*)
+			"CVProfRecorder::Record_WriteTimings_R()"
+			{
+				"library"	"engine"
+				"linux"		"@_ZN14CVProfRecorder21Record_WriteTimings_REPK10CVProfNode"
+				"linux64"	"@_ZN14CVProfRecorder21Record_WriteTimings_REPK10CVProfNode"
+			}
+
+			// bool VProfRecord_IsPlayingBack()
+			"VProfRecord_IsPlayingBack()"
+			{
+				"library"	"engine"
+				"linux"		"@_Z25VProfRecord_IsPlayingBackv"
+				"linux64"	"@_Z25VProfRecord_IsPlayingBackv"
+			}
+		}
+
+		"MemPatches"
+		{
+			// pIn->m_nCurFrameCalls (0x38) -> pIn->m_nPrevFrameCalls (0x44).
+			// 32-bit: mov eax,[esi+0x38] at +0x1B, displacement at +0x1D.
+			// 64-bit: mov eax,[rsi+0x4C] at +0x1C, displacement at +0x1E.
+			"CVProfRecorder::Record_WriteTimings_R()::UsePrevCalls"
+			{
+				"signature"	"CVProfRecorder::Record_WriteTimings_R()"
+				"linux"
+				{
+					"offset"	"1Dh"
+					"verify"	"\x38"
+					"patch"		"\x44"
+				}
+				"linux64"
+				{
+					"offset"	"1Eh"
+					"verify"	"\x4C"
+					"patch"		"\x58"
+				}
+			}
+
+			// pIn->m_CurFrameTime -> pIn->m_PrevFrameTime.
+			//
+			// 32-bit CCycleCount is an unaligned int64 and the compiler reads
+			// its high dword for the 64x32 multiplication:
+			//   imul ecx,[esi+0x40],1000000  (high, 0x40 -> 0x4C, +0x53)
+			//   mul       [esi+0x3C]          (low,  0x3C -> 0x48, +0x5F)
+			// 64-bit is a single 64-bit load:
+			//   imul rax,[rbx+0x50],1000000  (0x50 -> 0x60, +0x66)
+			"CVProfRecorder::Record_WriteTimings_R()::UsePrevTimeHigh"
+			{
+				"signature"	"CVProfRecorder::Record_WriteTimings_R()"
+				"linux"
+				{
+					"offset"	"53h"
+					"verify"	"\x40"
+					"patch"		"\x4C"
+				}
+				"linux64"
+				{
+					"offset"	"66h"
+					"verify"	"\x50"
+					"patch"		"\x60"
+				}
+			}
+
+			// 32-bit only: low dword of m_CurFrameTime.
+			"CVProfRecorder::Record_WriteTimings_R()::UsePrevTimeLow"
+			{
+				"signature"	"CVProfRecorder::Record_WriteTimings_R()"
+				"linux"
+				{
+					"offset"	"5Fh"
+					"verify"	"\x3C"
+					"patch"		"\x48"
+				}
+			}
+
+			// Force VProfRecord_IsPlayingBack() to return true so the export
+			// path does not clear the previous-frame data before the recorder
+			// reads it:
+			//   cmp dword [...], 2 ; sete al ; ret
+			// becomes
+			//   cmp dword [...], 2 ; mov al,1 ; nop ; ret
+			"VProfRecord_IsPlayingBack()::SkipClearPrev"
+			{
+				"signature"	"VProfRecord_IsPlayingBack()"
+				"linux"
+				{
+					"offset"	"7h"
+					"verify"	"\x0F\x94\xC0"
+					"patch"		"\xB0\x01\x90"
+				}
+				"linux64"
+				{
+					"offset"	"7h"
+					"verify"	"\x0F\x94\xC0"
+					"patch"		"\xB0\x01\x90"
+				}
+			}
+		}
+	}
+}

+ 292 - 0
server-patch/tools/verify_gamedata.py

@@ -0,0 +1,292 @@
+#!/usr/bin/env python3
+"""
+verify_gamedata.py — offline validator for vprof_timings.txt.
+
+Mimics the parts of SourceMod's GameConfig signature resolution and Source
+Scramble's MemPatch application that matter, so the gamedata can be checked
+against a target binary without running a server:
+
+  * KeyValues/SMC parse of the gamedata (comments, nested sections, duplicate
+    keys preserved).
+  * `@mangled` signatures resolved from the ELF `.symtab` (the same thing
+    MemoryUtils::ResolveSymbol does when a game reports hidden symbols, as
+    TF2 does).  Byte-pattern signatures with `\\x2A` wildcards are still
+    supported for completeness.
+  * Source Scramble's `offset`/`verify`/`patch` semantics.
+
+Usage:
+    python3 verify_gamedata.py vprof_timings.txt engine_srv.so engine_srv.x64.so
+
+The two binaries are matched to the `linux` and `linux64` blocks respectively.
+"""
+
+import struct
+import sys
+
+
+# --------------------------------------------------------------------------
+# Minimal SMC/KeyValues parser
+# --------------------------------------------------------------------------
+
+def tokenize(text):
+    i, n = 0, len(text)
+    while i < n:
+        c = text[i]
+        if c in " \t\r\n":
+            i += 1
+            continue
+        if c == "/" and i + 1 < n and text[i + 1] == "/":
+            while i < n and text[i] != "\n":
+                i += 1
+            continue
+        if c == "{":
+            yield "{"
+            i += 1
+            continue
+        if c == "}":
+            yield "}"
+            i += 1
+            continue
+        if c == '"':
+            i += 1
+            buf = []
+            while i < n and text[i] != '"':
+                if text[i] == "\\" and i + 1 < n:
+                    buf.append(text[i])
+                    buf.append(text[i + 1])
+                    i += 2
+                else:
+                    buf.append(text[i])
+                    i += 1
+            i += 1
+            yield ("str", "".join(buf))
+            continue
+        j = i
+        while j < n and text[j] not in " \t\r\n{}":
+            j += 1
+        yield ("str", text[i:j])
+        i = j
+
+
+def parse_block(it):
+    entries = []
+    while True:
+        try:
+            t = next(it)
+        except StopIteration:
+            return entries
+        if t == "}":
+            return entries
+        if t == "{":
+            continue
+        key = t[1]
+        nxt = next(it)
+        if nxt == "{":
+            entries.append((key, parse_block(it)))
+        else:
+            entries.append((key, nxt[1]))
+
+
+def sub(entries, key):
+    if entries is None:
+        return None
+    for k, v in entries:
+        if k == key:
+            return v
+    return None
+
+
+# --------------------------------------------------------------------------
+# ELF symbol table (`@symbol` resolution)
+# --------------------------------------------------------------------------
+
+def elf_symbols(path):
+    data = open(path, "rb").read()
+    if data[:4] != b"\x7fELF":
+        raise ValueError("%s is not an ELF file" % path)
+    ei_class, ei_data = data[4], data[5]
+    if ei_data != 1:
+        raise ValueError("only little-endian ELF is supported")
+
+    if ei_class == 1:
+        e_shoff = struct.unpack_from("<I", data, 0x20)[0]
+        e_shentsize = struct.unpack_from("<H", data, 0x2E)[0]
+        e_shnum = struct.unpack_from("<H", data, 0x30)[0]
+        e_shstrndx = struct.unpack_from("<H", data, 0x32)[0]
+        sh_fmt = "<IIIIIIIIII"
+        sym_fmt = "<IIIBBH"
+        sym_entsize = 16
+    elif ei_class == 2:
+        e_shoff = struct.unpack_from("<Q", data, 0x28)[0]
+        e_shentsize = struct.unpack_from("<H", data, 0x3A)[0]
+        e_shnum = struct.unpack_from("<H", data, 0x3C)[0]
+        e_shstrndx = struct.unpack_from("<H", data, 0x3E)[0]
+        sh_fmt = "<IIQQQQIIQQ"
+        sym_fmt = "<IBBHQQ"
+        sym_entsize = 24
+    else:
+        raise ValueError("unknown ELF class %d" % ei_class)
+
+    def section(i):
+        return struct.unpack_from(sh_fmt, data, e_shoff + i * e_shentsize)
+
+    shstr = section(e_shstrndx)
+    shstr_off = shstr[4]
+
+    def section_name(name_off):
+        end = data.index(b"\0", shstr_off + name_off)
+        return data[shstr_off + name_off : end].decode("ascii", "replace")
+
+    symtab = strtab = None
+    for i in range(e_shnum):
+        s = section(i)
+        name = section_name(s[0])
+        if name == ".symtab":
+            symtab = s
+        elif name == ".strtab":
+            strtab = s
+
+    symbols = {}
+    if symtab is None or strtab is None:
+        return symbols
+
+    sym_off, sym_size, sym_entsize_hdr = symtab[4], symtab[5], symtab[9]
+    entsize = sym_entsize_hdr or sym_entsize
+    str_off = strtab[4]
+    count = sym_size // entsize
+
+    for i in range(count):
+        fields = struct.unpack_from(sym_fmt, data, sym_off + i * entsize)
+        if ei_class == 1:
+            st_name, st_value, _st_size, _st_info, _st_other, st_shndx = fields
+        else:
+            st_name, _st_info, _st_other, st_shndx, st_value, _st_size = fields
+        if st_name == 0 or st_shndx == 0:
+            continue
+        end = data.index(b"\0", str_off + st_name)
+        name = data[str_off + st_name : end].decode("ascii", "replace")
+        symbols.setdefault(name, st_value)
+    return symbols
+
+
+# --------------------------------------------------------------------------
+# Byte-pattern signatures (legacy fallback)
+# --------------------------------------------------------------------------
+
+def decode_pattern(s):
+    out = bytearray()
+    i = 0
+    while i < len(s):
+        if s[i] == "\\" and i + 1 < len(s) and s[i + 1] == "x":
+            out.append(int(s[i + 2 : i + 4], 16))
+            i += 4
+        else:
+            out.append(ord(s[i]))
+            i += 1
+    return bytes(out)
+
+
+def find_pattern(hay, pat):
+    for off in range(0, len(hay) - len(pat) + 1):
+        ok = True
+        for i in range(len(pat)):
+            if pat[i] != 0x2A and pat[i] != hay[off + i]:
+                ok = False
+                break
+        if ok:
+            return off
+    return None
+
+
+def parse_offset(s):
+    if s.lower().endswith("h"):
+        return int(s[:-1], 16)
+    return int(s, 0)
+
+
+# --------------------------------------------------------------------------
+# Main
+# --------------------------------------------------------------------------
+
+def main(argv):
+    if len(argv) != 4:
+        sys.stderr.write(__doc__)
+        return 2
+
+    gamedata, bin32, bin64 = argv[1], argv[2], argv[3]
+    text = open(gamedata, encoding="utf-8", errors="replace").read()
+    root = parse_block(iter(tokenize(text)))
+
+    tf = sub(sub(root, "Games"), "tf")
+    sigs = sub(tf, "Signatures")
+    patches = sub(tf, "MemPatches")
+
+    targets = {
+        "linux": {
+            "bin": bin32,
+            "bytes": open(bin32, "rb").read(),
+            "syms": elf_symbols(bin32),
+        },
+        "linux64": {
+            "bin": bin64,
+            "bytes": open(bin64, "rb").read(),
+            "syms": elf_symbols(bin64),
+        },
+    }
+
+    failures = 0
+    for plat in ("linux", "linux64"):
+        tgt = targets[plat]
+        hay, syms = tgt["bytes"], tgt["syms"]
+        print("=== %s (%s) ===" % (plat, tgt["bin"]))
+        for name, body in patches:
+            sig_name = sub(body, "signature")
+            sigdef = sub(sigs, sig_name)
+            library = sub(sigdef, "library")
+            sig = sub(sigdef, plat)
+            block = sub(body, plat)
+
+            if sig.startswith("@"):
+                symname = sig[1:]
+                addr = syms.get(symname)
+                how = "@%s" % symname
+            else:
+                addr = find_pattern(hay, decode_pattern(sig))
+                how = "pattern"
+
+            if addr is None:
+                print("  [FAIL] %-62s %s not found" % (name, how))
+                failures += 1
+                continue
+            if not isinstance(block, list):
+                print("  [skip] %-62s no %s block" % (name, plat))
+                continue
+
+            off = parse_offset(sub(block, "offset"))
+            verify = decode_pattern(sub(block, "verify"))
+            replace = decode_pattern(sub(block, "patch"))
+            at = addr + off
+            current = hay[at : at + len(verify)]
+            ok = all(v == 0x2A or v == c for v, c in zip(verify, current))
+
+            if not ok:
+                failures += 1
+            print(
+                "  [%s] %-62s lib=%s addr=0x%x off=0x%x verify=%s patch=%s"
+                % (
+                    "OK" if ok else "VERIFY-MISMATCH",
+                    name,
+                    library,
+                    addr,
+                    off,
+                    current.hex(),
+                    replace.hex(),
+                )
+            )
+
+    print("\n%s" % ("ALL PATCHES VERIFIED" if failures == 0 else "FAILURES: %d" % failures))
+    return 1 if failures else 0
+
+
+if __name__ == "__main__":
+    raise SystemExit(main(sys.argv))

+ 585 - 0
vprof_to_json.py

@@ -0,0 +1,585 @@
+#!/usr/bin/env python3
+"""
+vprof_to_json.py - Parse a Source Engine VProf recording (*.vprof) and emit JSON.
+
+The binary format is documented in VPROF_FORMAT.md. It is produced by the
+engine's ``vprof_record_start`` command (class CVProfRecorder in
+engine/vprof_record.cpp).
+
+Usage:
+    python vprof_to_json.py test.vprof > test.json
+    python vprof_to_json.py test.vprof -o test.json --indent 2
+    python vprof_to_json.py test.vprof --summary-only
+    python vprof_to_json.py test.vprof --no-frames --top 50
+"""
+
+from __future__ import annotations
+
+import argparse
+import json
+import statistics
+import struct
+import sys
+from dataclasses import dataclass, field
+from typing import Dict, List, Optional
+
+# ---------------------------------------------------------------------------
+# Constants
+# ---------------------------------------------------------------------------
+
+VPROF_FILE_VERSION = 1
+
+TOKEN_START_FRAME = 0
+TOKEN_ADD_NODE = 1
+TOKEN_ADD_BUDGET_GROUP = 2
+TOKEN_TIMINGS = 3
+TOKEN_END_OF_FRAME = 4
+TOKEN_FILE_FINISHED = 5
+
+BUDGETFLAG_CLIENT = 1 << 0
+BUDGETFLAG_SERVER = 1 << 1
+BUDGETFLAG_OTHER = 1 << 2
+BUDGETFLAG_HIDDEN = 1 << 15
+
+_BUDGET_FLAG_NAMES = (
+    (BUDGETFLAG_CLIENT, "CLIENT"),
+    (BUDGETFLAG_SERVER, "SERVER"),
+    (BUDGETFLAG_OTHER, "OTHER"),
+    (BUDGETFLAG_HIDDEN, "HIDDEN"),
+)
+
+# The 31 budget groups registered by CVProfile::CVProfile() in a fixed order.
+# A recording may either rewrite all of them (index 0..30) or only the groups
+# added after construction; either way IDs 0..30 map to these names.
+DEFAULT_BUDGET_GROUPS = [
+    ("Unaccounted", BUDGETFLAG_SERVER | BUDGETFLAG_OTHER),
+    ("World Rendering", BUDGETFLAG_CLIENT),
+    ("Displacement_Rendering", BUDGETFLAG_CLIENT),
+    ("Game", BUDGETFLAG_SERVER | BUDGETFLAG_OTHER),
+    ("Player", BUDGETFLAG_SERVER | BUDGETFLAG_OTHER),
+    ("NPCs", BUDGETFLAG_SERVER | BUDGETFLAG_OTHER),
+    ("Server Animation", BUDGETFLAG_SERVER | BUDGETFLAG_OTHER),
+    ("Client_Animation", BUDGETFLAG_CLIENT),
+    ("Physics", BUDGETFLAG_SERVER | BUDGETFLAG_OTHER),
+    ("Static_Prop_Rendering", BUDGETFLAG_CLIENT),
+    ("Other_Model_Rendering", BUDGETFLAG_CLIENT),
+    ("Fast Path Model Rendering", BUDGETFLAG_CLIENT),
+    ("Light_Cache", BUDGETFLAG_CLIENT),
+    ("Brush_Model_Rendering", BUDGETFLAG_CLIENT),
+    ("Shadow_Rendering", BUDGETFLAG_CLIENT),
+    ("Detail_Prop_Rendering", BUDGETFLAG_CLIENT),
+    ("Particle/Effect_Rendering", BUDGETFLAG_CLIENT),
+    ("Ropes", BUDGETFLAG_CLIENT),
+    ("Dynamic_Light_Rendering", BUDGETFLAG_CLIENT),
+    ("Networking", BUDGETFLAG_SERVER | BUDGETFLAG_OTHER),
+    ("Sound", BUDGETFLAG_SERVER | BUDGETFLAG_OTHER),
+    ("VGUI", BUDGETFLAG_SERVER | BUDGETFLAG_OTHER),
+    ("FileSystem", BUDGETFLAG_SERVER | BUDGETFLAG_OTHER),
+    ("Prediction", BUDGETFLAG_CLIENT),
+    ("Interpolation", BUDGETFLAG_CLIENT),
+    ("Swap_Buffers", BUDGETFLAG_CLIENT),
+    ("Occlusion", BUDGETFLAG_CLIENT),
+    ("Overlays", BUDGETFLAG_CLIENT),
+    ("Tools", BUDGETFLAG_CLIENT | BUDGETFLAG_OTHER),
+    ("Texture_Cache", BUDGETFLAG_CLIENT),
+    ("Replay", BUDGETFLAG_SERVER),
+]
+
+
+class VProfError(Exception):
+    """Raised when a .vprof stream is malformed."""
+
+
+def decode_flags(flags: int) -> List[str]:
+    return [name for bit, name in _BUDGET_FLAG_NAMES if flags & bit]
+
+
+# ---------------------------------------------------------------------------
+# Binary reader
+# ---------------------------------------------------------------------------
+
+
+class Reader:
+    """Small cursor over an in-memory byte buffer, little-endian."""
+
+    __slots__ = ("data", "pos", "size")
+
+    def __init__(self, data: bytes):
+        self.data = data
+        self.pos = 0
+        self.size = len(data)
+
+    @property
+    def remaining(self) -> int:
+        return self.size - self.pos
+
+    def _take(self, n: int) -> bytes:
+        if self.pos + n > self.size:
+            raise VProfError(
+                f"unexpected end of file at offset {self.pos} (wanted {n} bytes)"
+            )
+        chunk = self.data[self.pos : self.pos + n]
+        self.pos += n
+        return chunk
+
+    def u8(self) -> int:
+        return self._take(1)[0]
+
+    def i32(self) -> int:
+        return struct.unpack("<i", self._take(4))[0]
+
+    def u16(self) -> int:
+        return struct.unpack("<H", self._take(2))[0]
+
+    def u32(self) -> int:
+        return struct.unpack("<I", self._take(4))[0]
+
+    def cstring(self, max_len: int = 4096) -> str:
+        end = self.data.find(b"\x00", self.pos)
+        if end < 0:
+            raise VProfError(f"unterminated string at offset {self.pos}")
+        if end - self.pos > max_len:
+            raise VProfError(f"string too long at offset {self.pos}")
+        raw = self.data[self.pos : end]
+        self.pos = end + 1
+        # Source writes narrow strings; Windows-1252 is a safe superset of
+        # the ASCII that is normally used.
+        return raw.decode("utf-8", "replace")
+
+
+# ---------------------------------------------------------------------------
+# Data model
+# ---------------------------------------------------------------------------
+
+
+@dataclass
+class BudgetGroup:
+    id: int
+    name: str
+    flags: int
+    is_default: bool = False
+
+
+@dataclass
+class Node:
+    id: int
+    name: str
+    parent_id: Optional[int]
+    budget_group_id: int
+    children: List[int] = field(default_factory=list)
+
+
+@dataclass
+class Frame:
+    index: int
+    tick: int
+    samples: Dict[int, tuple] = field(default_factory=dict)  # node_id -> (calls, us)
+    added_nodes: List[int] = field(default_factory=list)
+    added_budget_groups: List[int] = field(default_factory=list)
+
+
+@dataclass
+class Recording:
+    version: int
+    root_node_id: int
+    budget_groups: List[BudgetGroup]
+    nodes: Dict[int, Node]
+    frames: List[Frame]
+
+
+# ---------------------------------------------------------------------------
+# Parser
+# ---------------------------------------------------------------------------
+
+
+def parse_vprof(data: bytes) -> Recording:
+    r = Reader(data)
+
+    try:
+        version = r.i32()
+        root_node_id = r.i32()
+    except VProfError as e:
+        raise VProfError(f"truncated header: {e}") from e
+
+    if version != VPROF_FILE_VERSION:
+        # Do not fail outright: other revisions may be readable.
+        sys.stderr.write(
+            f"warning: vprof file version is {version}, "
+            f"expected {VPROF_FILE_VERSION}; attempting to parse anyway\n"
+        )
+
+    # Pre-seed the default budget groups so IDs stay correct whether or not
+    # the recording rewrites them.
+    groups: List[BudgetGroup] = [
+        BudgetGroup(id=i, name=name, flags=flags, is_default=True)
+        for i, (name, flags) in enumerate(DEFAULT_BUDGET_GROUPS)
+    ]
+    group_by_name: Dict[str, int] = {g.name: g.id for g in groups}
+
+    def add_budget_group(name: str, flags: int) -> tuple:
+        """Return (id, was_new)."""
+        existing = group_by_name.get(name)
+        if existing is not None:
+            groups[existing].flags = flags
+            return existing, False
+        gid = len(groups)
+        groups.append(BudgetGroup(id=gid, name=name, flags=flags, is_default=False))
+        group_by_name[name] = gid
+        return gid, True
+
+    nodes: Dict[int, Node] = {
+        root_node_id: Node(
+            id=root_node_id, name="Root", parent_id=None, budget_group_id=0
+        )
+    }
+    frames: List[Frame] = []
+
+    def read_timings(node_id: int, samples: Dict[int, tuple]) -> None:
+        node = nodes[node_id]
+
+        calls_token = r.u8()
+        if calls_token == 255:
+            calls = r.u16()
+        else:
+            calls = calls_token
+
+        time_units = r.u16()
+        if time_units == 0xFFFF:
+            microseconds = r.u32() * 4
+        else:
+            microseconds = time_units * 4
+
+        samples[node_id] = (calls, microseconds)
+
+        for child_id in node.children:
+            read_timings(child_id, samples)
+
+    while r.remaining:
+        token = r.u8()
+
+        if token == TOKEN_FILE_FINISHED:
+            break
+        if token != TOKEN_START_FRAME:
+            raise VProfError(
+                f"expected Token_StartFrame at offset {r.pos - 1}, got {token}"
+            )
+
+        tick = r.i32()
+        frame = Frame(index=len(frames), tick=tick)
+
+        while True:
+            inner = r.u8()
+
+            if inner == TOKEN_END_OF_FRAME:
+                break
+            elif inner == TOKEN_ADD_BUDGET_GROUP:
+                name = r.cstring()
+                flags = r.i32()
+                gid, is_new = add_budget_group(name, flags)
+                if is_new:
+                    frame.added_budget_groups.append(gid)
+            elif inner == TOKEN_ADD_NODE:
+                parent_id = r.i32()
+                name = r.cstring()
+                budget_group_id = r.i32()
+                node_id = r.i32()
+
+                if parent_id not in nodes:
+                    raise VProfError(
+                        f"AddNode references unknown parent {parent_id} "
+                        f"at offset {r.pos}"
+                    )
+                if node_id in nodes:
+                    raise VProfError(
+                        f"AddNode re-declares existing node {node_id} "
+                        f"at offset {r.pos}"
+                    )
+
+                nodes[node_id] = Node(
+                    id=node_id,
+                    name=name,
+                    parent_id=parent_id,
+                    budget_group_id=budget_group_id,
+                )
+                # CVProfNode::GetSubNode() prepends, so mirror that here.
+                nodes[parent_id].children.insert(0, node_id)
+                frame.added_nodes.append(node_id)
+            elif inner == TOKEN_TIMINGS:
+                read_timings(root_node_id, frame.samples)
+            else:
+                raise VProfError(
+                    f"unexpected token {inner} inside frame {frame.index} "
+                    f"at offset {r.pos - 1}"
+                )
+
+        frames.append(frame)
+
+    return Recording(
+        version=version,
+        root_node_id=root_node_id,
+        budget_groups=groups,
+        nodes=nodes,
+        frames=frames,
+    )
+
+
+# ---------------------------------------------------------------------------
+# JSON conversion
+# ---------------------------------------------------------------------------
+
+
+def _group_name(recording: Recording, group_id: int) -> str:
+    if 0 <= group_id < len(recording.budget_groups):
+        return recording.budget_groups[group_id].name
+    return f"<unknown group {group_id}>"
+
+
+def build_summary(recording: Recording, top_n: int) -> dict:
+    totals = [
+        f.samples.get(recording.root_node_id, (0, 0))[1] for f in recording.frames
+    ]
+
+    # Per-node aggregates.
+    agg: Dict[int, dict] = {}
+    for frame in recording.frames:
+        for node_id, (calls, us) in frame.samples.items():
+            entry = agg.get(node_id)
+            if entry is None:
+                entry = agg[node_id] = {
+                    "total_calls": 0,
+                    "total_microseconds": 0,
+                    "max_microseconds": 0,
+                    "frames_present": 0,
+                }
+            entry["total_calls"] += calls
+            entry["total_microseconds"] += us
+            entry["frames_present"] += 1
+            if us > entry["max_microseconds"]:
+                entry["max_microseconds"] = us
+
+    grand_total = sum(totals) or 1
+    top_nodes = []
+    for node_id, entry in sorted(
+        agg.items(), key=lambda kv: kv[1]["total_microseconds"], reverse=True
+    )[:top_n]:
+        node = recording.nodes.get(node_id)
+        frames_present = entry["frames_present"] or 1
+        top_nodes.append(
+            {
+                "node_id": node_id,
+                "name": node.name if node else f"<node {node_id}>",
+                "budget_group": _group_name(
+                    recording, node.budget_group_id if node else 0
+                ),
+                "total_calls": entry["total_calls"],
+                "total_microseconds": entry["total_microseconds"],
+                "avg_microseconds": round(
+                    entry["total_microseconds"] / frames_present, 3
+                ),
+                "max_microseconds": entry["max_microseconds"],
+                "percent_of_total": round(
+                    100.0 * entry["total_microseconds"] / grand_total, 4
+                ),
+            }
+        )
+
+    def stat(values: List[int]) -> Optional[dict]:
+        if not values:
+            return None
+        ordered = sorted(values)
+        result = {
+            "min": ordered[0],
+            "max": ordered[-1],
+            "mean": round(statistics.fmean(values), 3),
+            "median": round(statistics.median(values), 3),
+            "total": sum(values),
+        }
+        if len(ordered) > 1:
+            idx = min(len(ordered) - 1, int(round(0.95 * (len(ordered) - 1))))
+            result["p95"] = ordered[idx]
+        return result
+
+    ticks = [f.tick for f in recording.frames]
+    return {
+        "frame_count": len(recording.frames),
+        "node_count": len(recording.nodes),
+        "budget_group_count": len(recording.budget_groups),
+        "tick_first": ticks[0] if ticks else None,
+        "tick_last": ticks[-1] if ticks else None,
+        "tick_span": (ticks[-1] - ticks[0]) if ticks else None,
+        "total_recorded_microseconds": sum(totals),
+        "frame_time_microseconds": stat(totals),
+        "top_nodes_by_total_microseconds": top_nodes,
+    }
+
+
+def recording_to_dict(
+    recording: Recording,
+    *,
+    source_name: str,
+    size_bytes: int,
+    include_frames: bool,
+    include_nodes: bool,
+    top_n: int,
+) -> dict:
+    out = {
+        "format": "source-vprof",
+        "source": {
+            "file": source_name,
+            "size_bytes": size_bytes,
+            "version": recording.version,
+            "root_node_id": recording.root_node_id,
+        },
+    }
+
+    out["budget_groups"] = [
+        {
+            "id": g.id,
+            "name": g.name,
+            "flags": g.flags,
+            "flags_decoded": decode_flags(g.flags),
+            "is_default": g.is_default,
+        }
+        for g in recording.budget_groups
+    ]
+
+    if include_nodes:
+        out["nodes"] = [
+            {
+                "id": n.id,
+                "name": n.name,
+                "parent_id": n.parent_id,
+                "budget_group_id": n.budget_group_id,
+                "budget_group": _group_name(recording, n.budget_group_id),
+                "children": n.children,
+            }
+            for n in recording.nodes.values()
+        ]
+
+    if include_frames:
+        frames_out = []
+        for frame in recording.frames:
+            total_us = frame.samples.get(recording.root_node_id, (0, 0))[1]
+            frames_out.append(
+                {
+                    "index": frame.index,
+                    "tick": frame.tick,
+                    "total_microseconds": total_us,
+                    # node_id (string key) -> {"calls": int, "microseconds": int}
+                    "samples": {
+                        str(node_id): {
+                            "calls": calls,
+                            "microseconds": us,
+                        }
+                        for node_id, (calls, us) in frame.samples.items()
+                    },
+                    "added_nodes": frame.added_nodes,
+                    "added_budget_groups": frame.added_budget_groups,
+                }
+            )
+        out["frames"] = frames_out
+
+    out["summary"] = build_summary(recording, top_n)
+    return out
+
+
+# ---------------------------------------------------------------------------
+# CLI
+# ---------------------------------------------------------------------------
+
+
+def main(argv: Optional[List[str]] = None) -> int:
+    parser = argparse.ArgumentParser(
+        description="Parse a Source Engine .vprof recording and emit JSON.",
+        formatter_class=argparse.ArgumentDefaultsHelpFormatter,
+    )
+    parser.add_argument("input", help="path to the .vprof file")
+    parser.add_argument(
+        "-o", "--output", help="output file (default: stdout)", default=None
+    )
+    parser.add_argument(
+        "--indent",
+        type=int,
+        default=None,
+        help="JSON indentation; omit for compact output",
+    )
+    parser.add_argument(
+        "--no-frames",
+        action="store_true",
+        help="omit the per-frame timing samples",
+    )
+    parser.add_argument(
+        "--no-nodes",
+        action="store_true",
+        help="omit the node definition table",
+    )
+    parser.add_argument(
+        "--summary-only",
+        action="store_true",
+        help="emit only metadata, budget groups, and the summary",
+    )
+    parser.add_argument(
+        "--top",
+        type=int,
+        default=25,
+        help="number of hottest nodes to include in the summary",
+    )
+    parser.add_argument(
+        "--max-frames",
+        type=int,
+        default=None,
+        help="stop after this many frames (useful for huge captures)",
+    )
+
+    args = parser.parse_args(argv)
+
+    try:
+        with open(args.input, "rb") as fh:
+            raw = fh.read()
+    except OSError as e:
+        sys.stderr.write(f"error: cannot read {args.input}: {e}\n")
+        return 2
+
+    try:
+        recording = parse_vprof(raw)
+    except VProfError as e:
+        sys.stderr.write(f"error: {e}\n")
+        return 1
+
+    if args.max_frames is not None and args.max_frames >= 0:
+        recording.frames = recording.frames[: args.max_frames]
+
+    include_frames = not (args.no_frames or args.summary_only)
+    include_nodes = not args.no_nodes
+
+    result = recording_to_dict(
+        recording,
+        source_name=args.input,
+        size_bytes=len(raw),
+        include_frames=include_frames,
+        include_nodes=include_nodes,
+        top_n=args.top,
+    )
+
+    text = json.dumps(result, indent=args.indent, ensure_ascii=False)
+    if args.output:
+        with open(args.output, "w", encoding="utf-8") as fh:
+            fh.write(text)
+    else:
+        try:
+            sys.stdout.write(text)
+            sys.stdout.write("\n")
+            sys.stdout.flush()
+        except BrokenPipeError:
+            # e.g. `vprof_to_json.py file.vprof | head`
+            try:
+                sys.stdout.close()
+            except OSError:
+                pass
+            return 0
+
+    return 0
+
+
+if __name__ == "__main__":
+    raise SystemExit(main())