A Rust toolkit that parses VALORANT replay files (.vrf, Unreal Engine network
replay format) and exports them to Parquet. A workspace of 10 crates (plus a
test-only dev/vrf-testkit and the extract-component-classes tool) and a
Python tools/ validation suite. #![forbid(unsafe_code)] is in every crate;
there is no unsafe block anywhere in the workspace. The only native FFI the
parser depends on is Oodle decompression, and that lives entirely in the
external oozextract crate. Edition 2024, MSRV 1.86, Apache-2.0.
Derived from ValorantReplayParser
(MIT), last referenced at
2e4d3ad
(2026-09-30); see NOTICE.md. Not affiliated with, endorsed by,
or approved by Riot Games.
Test suites (2026-10-16): 738 Rust tests (Windows), 737 Rust tests (non-Windows) and 1074 Python tests. The historical audit of the 24 builds through 13.06 used the same verification on 1,018 unique replays; all 1,018 meet every strict criterion. 13.08 was checked the same way on 2 replays, and 1 meets every criterion. See build verification for the measured scope, common checks and remaining limits.
- Run it, every tool and every output column:
docs/USAGE.md - What's extractable, and whether it is typed:
docs/DATA.md - Build it, test it, open a PR:
CONTRIBUTING.md(agents:CLAUDE.md)
Most replay parsers export only the fields whose type they know. vrfkit's
design premise is the opposite: export every value the replay carries. This
is possible because Unreal's property stream is self-describing -- each field
carries a handle and a bit-length before its value, so field boundaries are
walkable without knowing the type, and the handle-to-name map ships inside the
replay itself (NetFieldExportGroup). Where that map is available, names can
be resolved independently of types. Unknown properties retain raw payloads;
typed values are an additive overlay. This is a preservation strategy, not a
claim that every stream is currently understood: framing failures and missing
schema attribution are measured separately, and successful movement decodes
can be represented by their rows instead of a duplicate raw RPC.
| 🎮 Build | 🌿 Branch | ✅ Clean/checked | 🔎 Verified by |
|---|---|---|---|
| 13.08 | release-13.08 |
1/2 | Validation + checkpoints + typed/raw |
| 13.06 | release-13.06 |
38/38 | Validation + checkpoints + typed/raw |
| 13.05 | release-13.05 |
401/401 | Validation + checkpoints + typed/raw |
| 13.04 | release-13.04 |
108/108 | Validation + checkpoints + typed/raw |
| 13.02 | release-13.02 |
205/205 | Validation + checkpoints + typed/raw |
| 13.01 | release-13.01 |
215/215 | Validation + checkpoints + typed/raw |
| 13.00 | release-13.00 |
1/1 | Validation + checkpoints + typed/raw |
| 12.11 | release-12.11 |
1/1 | Validation + checkpoints + typed/raw |
| 12.10 | release-12.10 |
1/1 | Validation + checkpoints + typed/raw |
| 12.09 | release-12.09 |
3/3 | Validation + checkpoints + typed/raw |
| 12.08 | release-12.08 |
3/3 | Validation + checkpoints + typed/raw |
| 12.07 | release-12.07 |
3/3 | Validation + checkpoints + typed/raw |
| 12.06 | release-12.06 |
3/3 | Validation + checkpoints + typed/raw |
| 12.05 | release-12.05 |
3/3 | Validation + checkpoints + typed/raw |
| 12.04 | release-12.04 |
3/3 | Validation + checkpoints + typed/raw |
| 12.03 | release-12.03 |
3/3 | Validation + checkpoints + typed/raw |
| 12.02 | release-12.02 |
3/3 | Validation + checkpoints + typed/raw |
| 12.01 | release-12.01 |
3/3 | Validation + checkpoints + typed/raw |
| 12.00 | release-12.00 |
3/3 | Validation + checkpoints + typed/raw |
| 11.11 | release-11.11 |
3/3 | Validation + checkpoints + typed/raw |
| 11.10 | release-11.10 |
3/3 | Validation + checkpoints + typed/raw |
| 11.09 | release-11.09 |
3/3 | Validation + checkpoints + typed/raw |
| 11.08 | release-11.08 |
3/3 | Validation + checkpoints + typed/raw |
| 11.07 | release-11.07 |
3/3 | Validation + checkpoints + typed/raw |
| 11.06 | release-11.06 |
3/3 | Validation + checkpoints + typed/raw |
Measured on all 1,018 unique available replays across the 24 branches through 13.06, plus 2 13.08 replays in a separate run, every row by the same acceptance rule: ReplayData validation, checkpoint-enabled export, the independent comparisons on observed evidence fields, and the strict array and array-leaf error counters. ✅ 1,018/1,018 through 13.06 are clean -- not a claim that every field is understood. The 13.08 replay that is not clean fails the checkpoint GUID cross-check on a name that differs only in case, and the audit stops there for it (details). Structured-array child rows are admitted per build and per route: all measured routes on 13.01--13.06, a measured subset on 11.06--13.00, none yet on 13.08 (legacy route table).
All branches are ++Ares-Core+release-<build>. Adding a build is one
transforms! line; see Adding a new build.
- Preservation with explicit accounting — unknown property payloads and
unresolved whole RPCs remain raw; stream failures remain visible. Typed
values are an additive overlay, and the nullable
compatible_checksumcolumn helps distinguish untyped fields from fields lacking a descriptor. - Self-describing stream — field names come from the replay itself
(
NetFieldExportGroup); no hardcoded agent or map names in the parser. - Main and checkpoint Parquet output — six main tables are always written;
--checkpointsadds seven checkpoint tables. All are ready for polars, pandas, or DuckDB. - Spike state — plant site A/B (
PlantedAtSite+ position), defuser (CurrentDefuser), timer, and the canonical detonation signal. - Combat & abilities — per-player economy, magazine and reserve ammo, equipped weapon over time, cooldowns, and absolute health, armour and overheal from the damage log — the value after each change, not a running subtraction.
- Ability observations — cast time/location and replicated ability statistics can be joined to actors and rounds. Repeated snapshots require deduplication; unresolved ownership and incomplete effect pairs remain gaps.
- Numeric FastArray observations — the standalone GAS extractor retains replication keys, deleted/changed item IDs and raw property boundaries; every window it reads in the 1,018-export audit closed exactly (investigation). Property names and gameplay meanings remain unverified, and this output is separate from Parquet typed-value coverage.
- Status-effect observations — nearsight, slow, detain and suppress can arrive on affected actors. Matched start/stop records support intervals; unmatched records must not be assigned an invented duration.
- Persistent effects — smoke / wall / molly / slow / trap position and
lifetime from actor lifecycles; one command via
tools/extract_active_effects.py. - Spike custody — who carried the spike and when, resolved to the account
UUID, including Gekko's Wingman as a proxy carrier and the planter at each
spikePlanted; one command viatools/extract_spike_carrier.py. - Account identity & typed events —
manifest.players(account UUID → actor → character), eventword0/word1(killer/killed NetGUID, round index), ping/latency. - Cross-validated -- the server-written Event chunk independently confirms the kill log: 132/132 in order on one 13.01 replay, 9,677/9,677 over 71 replays on 13.02.
- Reproducible — Parquet output is byte-for-byte identical run to run.
- No
unsafe—#![forbid(unsafe_code)]in every crate; the only FFI is Oodle, isolated in an external crate. - 738 Rust tests (Windows) / 737 Rust tests (non-Windows) plus a layered validation suite (framing / bytes / decode errors / semantics).
- Supported VALORANT builds
- Highlights
- Extractable data — the full inventory of what each table carries
- Quick start
- Output
- Status
- The Event chunk -- the server's own timeline
- Whole-corpus robustness
- Type overlay
- Supported builds and the cost of a new build
- Design
- Generated files
- License
cargo +1.86.0 build --release -p vrfkit --features export --locked
vrfkit inspect <file.vrf> # header / branch / chunk summary
vrfkit validate <file.vrf> [--diagnostics] # grammar oracle, writes nothing
vrfkit export <file.vrf> --out <dir> [--checkpoints]inspect prints replay info, header, branch, and a chunk summary; it does no
parsing and returns immediately. validate walks every content block through
the RepLayout grammar and reports a pass rate; it writes no files. export
writes the Parquet tables and manifest described under Output into
--out, which must be new, empty or a previous export: anything else in it
is refused, never deleted.
Column schemas, the
tools/scripts, the full validation suite, and per-crate usage live indocs/USAGE.md. This document is about why it is built this way.
The main export writes six Parquet tables plus manifest.json; --checkpoints
adds seven checkpoint tables. The tables, their columns, the join rules and
the traps (a per-round tick, open / close / dormant, an FDateTime
timestamp) are in docs/USAGE.md section 3.
Work in progress. Currently verified: cargo +1.86.0 test --workspace --locked
737 passing (non-Windows); the Windows suite contains 738 tests (Windows).
The full Python suite has 1074 passing tests. The documentation guard
measures the executing platform's suites. The historical common build audit
records replay validation, checkpoint export and independent value checks for
each supported build.
Tagged Windows releases provide a ZIP and SHA-256 checksum. The release workflow runs the complete CI on the tagged commit, checks the packaged binary against a public replay, and publishes the verified ZIP without rebuilding it. The same packaging checks run on PRs. See release maintenance for tag naming and validation before publishing.
What CI runs, three pinned public replays included, is in CONTRIBUTING.md; the private-corpus checks stay local.
The crates, their layers and feature flags are in
docs/USAGE.md section 4, and the
layered validation suite and what each layer misses in
section 6. Timing
is tools/bench_export.py; the optimizations kept and rejected, with their
measurements, are in docs/PERFORMANCE_NOTES.md.
The .vrf Event chunk is the event list the server labeled and wrote itself,
stored elsewhere in the file under a different encoding. vrfkit reads it.
characterDeath 132 | characterUltimateUsed 34 | roundStarted 18
spikePlanted 9 | spikeDefused 1 | switchTeams 1 (02d4d478, 195 events)
The 132 characterDeath events exactly match the 132 MulticastNotifyKilledEnemy
events we extracted from RPCs. The two payload words are the killer/killed
NetGUIDs, and 132/132 match in order (0/132 matched reversed).
Scope, to be precise: the killer/killed pair diff is for one replay. A separate Event-only sweep covered 527 files and 109,126 chunks across releases 13.01, 13.02 and 13.04. Every outer chunk and known inner payload was consumed exactly, with zero unknown groups and zero residual bytes.
The payload layout is [u32 group tag][N x u32 words][FString "EReplayEventGroup::<Name>"][f32 seconds], and N differs per group. The N
for all seven groups is derived from the corpus as the residual-zero count
(CharacterDeath = 2, CharacterUltimateUsed / RoundStart / SwitchTeams = 1,
SpikePlanted / Defused / Exploded = 0). The same sweep established one stable
tag per group (respectively 8, 11, 2, 3, 4, 5 and 6), public enum-name strings
of at most 40 bytes, and a maximum payload_seconds * 1000 - time1 absolute
difference of 0.999878 ms. vrfkit requires all four checks within a 1.001 ms
time tolerance before exporting word0/word1, payload_tag, payload_name
and payload_seconds; on any mismatch those nullable columns stay empty. The
original is always left intact in raw_payload.
The common audit checks 1,018 unique replays across 24 builds: every ReplayData validation and checkpoint export succeeds, independent typed/raw comparisons match 13,387,751 observed values, and all 1,018 pass the strict quality gate.
A 100% block pass rate means every measured block reached an ordinary
field/RPC row or an explicit preservation row; partial reassembly rejections
are outside that score, and malformed framing 0 does not prove that earlier
transport stages kept every payload. It is not a typing claim either: a
block that cannot be assigned a _ClassNetCache group has inner handles
nothing can name, so it becomes one reserved row (handle = u32::MAX, the
complete decoded payload in raw_bits) counted under RPC unresolved/raw --
uninterpreted, not lost.
For any field whose inner stream can be walked, the raw bits are always
exported; when the type is known, the value_* columns are filled as an
overlay. If the type is unknown, or decoding fails, the row's raw_bits
remains. The only exception is a ClassNetCache block whose group cannot be
identified: it cannot be expanded into fields, so it emits one preservation
row (handle = u32::MAX, full payload in raw_bits) and an explicit
unresolved/raw diagnostic rather than pretending the properties were decoded.
The overlay table (crates/vrf-decode/src/table.rs) -- 224 groups, 1,123
entries, 96 handles -- is maintained in the repository, and
tools/apply_type_corrections.py --check keeps every measured type in it.
Four names resolve without a table entry: Owner, Instigator,
AttachParent and Controller are object references the engine replicates
on every actor, always as a NetGUID, so they resolve by name after the table
and the scoped types miss -- a claim about Unreal, not about one Blueprint.
02d4d478 (02d4d478-1dfb-4412-9a77-29ca29105a9d.vrf), as recorded by the
committed export baseline tools/baselines/export_02d4d478.json:
Decoded OK: 822,251 Decode errors: 0
Raw/Skip: 24,747 Not in table: 140,748
No field name: 1,249 Typed: 83.1%
Effect blobs: 61,617
The four buckets partition Rows offered exactly (822,251 + 24,747 + 140,748 +
1,249 = 988,995), and Typed is Decoded OK / Rows offered. check_docs.py
compares these counters and USAGE's Parquet row/byte table with that baseline.
Effect decoding is additive and does not move these buckets. The overlay
buckets are settled before the effect pass, so rows that gained a value from an
effect are still counted under Not in table; merging them into Decoded OK
would double-count and move the baseline for unrelated reasons. Effect blobs
is reported separately -- without it, 61,617 rows gain a value yet the summary
prints identically.
Physical value coverage -- fields.parquet rows with a non-null value_* --
is a different population from the overlay's input rows: the reference
baseline has 939,474 typed rows out of 1,296,660 (72.45%), measured from its
columns, and it is not a fraction of all game information understood.
Typed, the overlay counter, the compatible_checksum buckets that separate
"nobody described this" from "we missed this", and
tools/summarize_value_coverage.py for your own exports are in
docs/USAGE.md. Decode errors are checked
separately by tools/check_decode_errors_corpus.py, because vrfkit validate
prints no overlay counters.
The payload transform changes per game build, but far more is constant
across supported releases 11.06 through 13.08: the PRNG and its multipliers, the seed-mix
skeleton, the 64 -> 32 -> 8 -> tail staging, the tail-XOR handling, and even
the S-box table itself. Each build supplies its own word64 / word32 /
byte operation sequence (order, rotations, complements, and whether the
S-box stage is used) plus these constants:
| seed addend | offset | sign | S-box | |
|---|---|---|---|---|
| release-11.06 | 0x3325e3bd |
0x3d |
+ | used |
| release-11.07 | 0x17b077d3 |
0x2d |
- | used |
| release-11.08 | 0xacf2cdff |
0x01 |
- | unused |
| release-11.09 | 0x12cf14e5 |
0x1b |
- | used |
| release-11.10 | 0x34e9d3ec |
0x14 |
- | used |
| release-11.11 | 0xc4445c41 |
0x3f |
- | used |
| release-12.00 | 0x70876679 |
0x07 |
- | unused |
| release-12.01 | 0x13fdd831 |
0x31 |
+ | used |
| release-12.02 | 0x9830d09d |
0x1d |
+ | used |
| release-12.03 | 0x33d59dff |
0x01 |
- | used |
| release-12.04 | 0xa5684b42 |
0x3e |
- | used |
| release-12.05 | 0xc21d548c |
0x0c |
+ | used |
| release-12.06 | 0x8d686ca6 |
0x26 |
+ | unused |
| release-12.07 | 0x2d21d7c3 |
0x3d |
- | used |
| release-12.08 | 0xce2e33e5 |
0x1b |
- | used |
| release-12.09 | 0x7ff2feec |
0x14 |
- | unused |
| release-12.10 | 0x12fd0ee5 |
0x1b |
- | unused |
| release-12.11 | 0x409d36a3 |
0x23 |
+ | unused |
| release-13.00 | 0x2949b6ef |
0x11 |
- | used |
| release-13.01 | 0xe62fcd5c |
0x24 |
- | unused |
| release-13.02 | 0x9e81a37c |
0x04 |
- | used |
| release-13.04 | 0x076dc658 |
0x28 |
- | unused |
| release-13.05 | 0x48c26613 |
0x13 |
+ | unused |
| release-13.06 | 0xe974593c |
0x3c |
+ | used |
| release-13.08 | 0x3c17a9fa |
0x06 |
- | used |
In all twenty-five supported builds the tail-XOR byte equals the low byte of the seed
addend. It is a derived value, not an independent constant: SeededTransform
in crates/vrf-transform/src/lib.rs defaults TAIL_XOR to SEED_ADDEND as u8, and a build that broke the pattern would fail its 1- and 7-bit vectors,
which vectors_cover_the_staging_boundaries in
crates/vrf-transform/tests/golden.rs requires for every registered build.
So adding a build is one transforms! line in crates/vrf-transform/src/lib.rs:
its branch, SEED_ADDEND, signed INIT_A_OFFSET and the step list of its
word64 / word32 / byte stages; everything else is shared.
Every build in the table above is confirmed on live replays, not only by transform vectors. A machine-local corpus can rotate; the reproducible oracle is 88 mechanically extracted golden vectors (11 staging boundaries per build, eight builds) plus 1,343 native-machine-code vectors: 1,264 for the sixteen recovered 11.06--12.09 builds, with a full 48-sample main/checkpoint validation (build support validation report), and 79 for 13.08. The common audit checks 38 13.06 replays.
The 768-byte S-box is shared across builds, which makes it usable as a signature for locating the transform function in a binary.
The content-block header and declared bit-length are plaintext, and only the
payload transform is a pure function of (bits, seed). Decode is not: it reads
the GUID cache and channel state earlier blocks mutate, and a stale group sets
the wrong handle width, so a replay decodes in order and parallelism is per
replay (why).
Columnar storage collapses the repeated path and name strings via dictionary
encoding, zstd compresses it well, and it reads directly in pyarrow /
polars / pandas / duckdb. NDJSON is reader-bound: on a 1.8-million-row
movement stream, JSON parsing was measured at 84% of processing time.
Three files are generated and must never be edited by hand; their targets,
generators and regeneration steps are in
CONTRIBUTING.md. The
S-box and golden vectors are extracted tables whose integrity
extracted_tables_are_intact (crates/vrf-transform/tests/golden.rs) checks.
The overlay table (crates/vrf-decode/src/table.rs) and
tools/equippable_table.py are maintained in the repository, not generated.
Apache License 2.0; see LICENSE. Releases up to and including
v0.2.0 were published under the MIT License. Third-party notices are in
NOTICE.md.
This is an independent, community-developed tool. It is not affiliated with, endorsed by, sponsored by, or approved by Riot Games. VALORANT, Riot Games, and all related trademarks are the property of Riot Games, Inc.