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