CS2DemoKit.Parser
0.12.0
dotnet add package CS2DemoKit.Parser --version 0.12.0
NuGet\Install-Package CS2DemoKit.Parser -Version 0.12.0
<PackageReference Include="CS2DemoKit.Parser" Version="0.12.0" />
<PackageVersion Include="CS2DemoKit.Parser" Version="0.12.0" />
<PackageReference Include="CS2DemoKit.Parser" />
paket add CS2DemoKit.Parser --version 0.12.0
#r "nuget: CS2DemoKit.Parser, 0.12.0"
#:package CS2DemoKit.Parser@0.12.0
#addin nuget:?package=CS2DemoKit.Parser&version=0.12.0
#tool nuget:?package=CS2DemoKit.Parser&version=0.12.0
CS2DemoKit.Parser
A zero-copy parser for CS2 (Counter-Strike 2) .dem files. Parses the frame stream directly off
the input buffer with no intermediate copies, decodes 272 typed game events, and includes
EntityTracker for stateful entity replay (player positions, health, weapons, …). Typed entity
wrappers (CSPlayerPawn, CSGameRules, WeaponAWP, …) come from the companion
CS2OpenDev.Sdk.Entities package and bind over this runtime through the
Entities/SdkAbstractions seam (LensBoundReader / TrackerEntityWorld). Targets net10.0.
This package has no knowledge of rules, stats, or highlights — see CS2DemoKit.Analysis for that.
Dependencies: Google.Protobuf, Snappier, and CS2OpenDev.Sdk.Entities.Abstractions (the
entity read contract the seam implements).
Quickstart
using CS2DemoKit.Parser;
using CS2DemoKit.Parser.GameEvents;
// A stable file already fully written to disk — the memory-mapped path avoids putting the whole
// file on the managed heap.
ParsedDemo demo = MemoryMappedDemoSource.ParseFile(path);
foreach (GameEvent evt in demo.AllGameEvents)
{
if (evt is PlayerDeathEvent death)
{
PlayerInfo? victim = demo.Players.GetValueOrDefault(death.VictimSlot);
PlayerInfo? killer = demo.Players.GetValueOrDefault(death.KillerSlot);
Console.WriteLine($"tick {death.GameTick}: {killer?.SteamId64} killed {victim?.SteamId64} with {death.Weapon}");
}
}
ParsedDemo.Players is keyed by player slot (int), the same key every typed game event uses
for its slot-shaped fields (VictimSlot, KillerSlot, …) — join through it to get PlayerInfo
(SteamId64, Name, Team, …).
Reading forward
DemoReader walks a demo front to back, decoding what its plan asks for and keeping nothing the
caller has let go of. It is the path for anything that never seeks: batch statistics, a service,
a scan that only wants to count message types.
using CS2DemoKit.Parser;
using CS2DemoKit.Parser.GameEvents;
using DemoReader reader = DemoReader.OpenFile(path, new ParseOptions { Plan = DecodePlan.GameEventsOnly });
foreach (DemoFrame frame in reader.ReadFrames())
{
foreach (NetMessage msg in frame.DecodedMessages)
{
if (msg is GameEventMessage evt)
{
Console.WriteLine($"{evt.DecodedEvent.GameTick} {evt.DecodedEvent.Name}");
}
}
}
Console.WriteLine($"{reader.Provenance.FramesRead} frames, {reader.Provenance.MessagesSkipped} messages never decoded");
DecodePlan is the declaration of what a parse needs. Categories picks message families
(header, schema, string tables, game events, entities, user commands, other); GameEventNames
keeps only the named events, dropped after the cheap id decode; IncludeMessageTypes and
ExcludeMessageTypes adjust by wire type id; RecordStructure fills
DemoFrame.InnerMessageHeaders with every inner message's type id and length whether or not it
was decoded, which is how StructureOnly counts a demo without materialising a payload. The
presets are Everything (the default and the options-less parse), StructureOnly,
GameEventsOnly and EntityReplay (what a curated EntityTracker needs and nothing more). The
plan applies to DemoParser.Parse as well through ParseOptions.Plan; a ParsedDemo reports
the plan it was decoded under as Plan and the counters as Provenance.
What a reader knows before its first frame: the header and server info are probed at Open, so
Enrichment.TickRate, MapName and Profile are valid immediately. ProbeGameEventNames() is a
structure-only pass over the file that reports which game events it fires, the one thing that
tells a tournament recording from a matchmaking one when the header cannot; the overload with a
stop predicate ends the pass as soon as the caller has seen enough, which for the dialect is the
first round. Configure(plan)
replaces the plan; both are allowed only before the first read, as is Materialize(), the
whole-file parse under the reader's plan: one window over every frame, every frame kept.
DemoParser.Parse is that call over a reader it opens and disposes.
Enrichment is live: Players, TickCount and Warnings reflect the frames read so far and
settle on what a whole-file parse reports once the stream ends. EndReason says how it ended
(Stop, EndOfData, Truncated, Corrupt); damage mid-stream warns and ends the stream exactly
as Parse does, and only a bad magic throws, at Open. A yielded frame holds offsets, decoded
messages and arena blocks, never a slice of the input, so it stays valid after Dispose.
ParseOptions.ReadAheadFrames decodes a window of frames in parallel ahead of the consumer, in
order, under MaxDegreeOfParallelism; one window of decoded frames is all the reader then holds
beyond what the caller keeps. It pays off when the decode is the bottleneck (an event scan runs
about 2.7x faster with a 2048-frame window) and not when the consumer is; Provenance.Mode says
Sequential or WindowedParallel. A consumer that has finished with part of a frame can drop
it: frame.Release(MessageCategories.Entities) removes the decoded entity messages and keeps the
rest, which is how the analysis engine holds several folded chunks without their payloads.
IDemoFrameSource is the contract both a reader and a retained ParsedDemo
(demo.AsFrameSource()) satisfy: TryReadNext, TryPeekNext, the enrichment view, and
SupportsRandomAccess with Frames for the retained case. Code written against it runs over
either.
Tick clocks — read this before touching ticks
| Property | Clock | Notes |
|---|---|---|
DemoFrame.ServerTick (int) |
frame clock | Despite the name, this holds the game tick in CS2 — pre-game frames carry a large negative sentinel, gameplay frames run 1, 2, 3, … There is no DemoFrame.Tick. |
DemoFrame.GameTick (int?) |
frame clock | An alias of ServerTick, set by the parser after the header decodes. |
GameEvent.GameTick (int) |
frame clock | Same clock as the two above. |
GameEvent.ServerTick (int) |
absolute engine tick | Convert to frame clock with GameEvent.ServerTick - ParsedDemo.ServerStartTick. |
Rule: never subtract ParsedDemo.ServerStartTick from a value that is already frame clock
(DemoFrame.ServerTick/GameTick, GameEvent.GameTick). Only the absolute GameEvent.ServerTick
needs that conversion.
Input: buffer vs. file path
DemoParser.Parse(ReadOnlyMemory<byte> data, DemoProfile? profileOverride = null) is the whole-file
parse; DemoReader above is the forward read over the same input. MemoryMappedDemoSource wraps a local file as a ReadOnlyMemory<byte> without
materializing it as a managed array; MemoryMappedDemoSource.ParseFile(path) is the one-line
convenience over Open + Parse.
- Uploaded / in-flight demos →
byte[].DemoParser.Parse(bytes.AsMemory())snapshots the bytes up front, so it's safe even if the source is still being written or copied concurrently. - Stable files already fully on disk →
MemoryMappedDemoSource. Cheaper (~166 MB avoided on the large-object heap for a 180 MB demo), but only for files that will not be written to while mapped: a concurrent truncation while a page is mapped raises an uncatchableAccessViolationExceptionthat kills the process — notry/catcharound the read fires. Don't map a file mid-download or mid-copy. - The memory-mapped path rejects files over
int.MaxValue(~2 GB) — a singleReadOnlyMemory<byte>can't address more. Thebyte[]path has no explicit guard, but is bounded by the sameint-typed frame offsets internally, so plan for the same ceiling either way. - A parse retains roughly 2.5× the input file's size in the returned
ParsedDemo(frames, decoded proto messages, event/player indexes). Size worker pools and per-process demo-concurrency limits against that multiplier, not the raw file size. - Per-parse control (0.8+):
DemoParser.Parse(data, new ParseOptions { ... }, profileOverride)—CancellationToken(checked at pass boundaries and per frame in the parallel pass),MaxDegreeOfParallelismfor the parallel decode pass (null/≤0 = unbounded), throttledIProgress<double>, andOnUnknownMessage, a per-parse callback that doesn't cross-talk between concurrent parses the way the staticOnUnknownMessageTypeevent does. Still gate the number of concurrent demos with your ownSemaphoreSlimsized to the ~2.5× memory multiplier. - Scoring an untrusted upload: set
ParseOptions.CountDropSites = true— silently-dropped net-messages then surface onParsedDemo.WarningsasParseWarningCodes.NetMessageDroppedentries (top offenders + remainder, each with aCount), emitted after the parse's own structural warnings so they never displace them.
Parse warnings
ParsedDemo.Warnings (IReadOnlyList<ParseWarning>) carries non-fatal, structured diagnostics —
a damaged demo still yields a usable partial parse, but the damage is no longer silent. Each
ParseWarning has a stable Code from ParseWarningCodes (StringTableCreateFailed,
StringTableUpdateFailed, StringTableTruncated, PlayerInfoUnreadable), a human Message, and
an optional Tick.
The list is capped at 256 entries per parse; once the cap is hit, a final entry with code
ParseWarningCodes.WarningsTruncated reports how many further warnings were suppressed — it is
always the last entry when present.
Pool-consumer caveat: the accumulator behind Warnings is thread-affine ([ThreadStatic]),
drained into the ParsedDemo you get back and reset on that same thread. If a parse throws
before constructing its ParsedDemo, its warnings are left on that thread; the next successful
parse on the same thread drains them along with its own, so a dead parse's warnings can end up
misattributed to whatever parse runs next on that thread. This only bites if you run parses on a
reused thread pool (e.g. Task.Run over a shared pool) and something upstream swallows the
exception from a failed parse — treat Warnings on the following result on that thread as suspect
in that case.
Entity tracking
Build a lens-bound tracker in one call: EntityTrackerFactory.CreateCurated() binds the
generated Schema Lens (omitting it does not throw — lane-routed reads silently degrade to the
fallback dict, so prefer the factory). To read through typed wrappers, register the
CS2OpenDev.Sdk.Entities factories via TrackerEntityWorld.RegisterWrapper (or bind a wrapper
per entity with new TrackerEntityWorld(tracker).CreateReader(binding, state)).
ReplayTo/ReplayToIndex replay from frame 0 on every call; for forward walks use
EntityStateLayer in CS2DemoKit.Analysis instead. Decode diagnostics go to
EntityTracker.DecodeDiagnosticSink (Action<string>, defaults to Console.WriteLine) — redirect
or silence it per tracker in batch services. PositionUtil.CellToWorld (namespace
CS2DemoKit.Parser.EntityTracking) is the oracle-pinned pawn cell→world reconstruction and
PawnLookup beside it resolves pawn↔slot. TickMapper and TickBoundaries.FrameIndices cover
demo-tick mapping and tick-boundary frame indexing.
Player trajectories
PositionSampler.Walk is those pieces assembled: it steps the tracker one frame at a time,
enumerates live pawns, resolves each to a slot, and reconstructs world position.
foreach (PositionSample s in PositionSampler.Walk(demo, frameStride: 8))
{
// s.FrameIndex, s.Tick, s.PlayerSlot, s.Position (Vector3), s.Place
}
frameStride subsamples the output only. Every frame is still decoded, because entity state is
delta-encoded and skipping a frame's deltas corrupts the frames after it, so the stride buys memory
and downstream work rather than decode time. At 64 tick a player covers roughly 4 units per frame,
so a stride of 8 draws a path to about 32 units at an eighth of the points.
Measured on a 223,628-frame match, Release, parse excluded: 1,635,249 samples in 3.6 s at stride 1,
and 204,411 in 2.3 s at stride 8. The stride cuts points 8x and wall time by a third, which is the
decode floor showing through. Walk is lazy, so a consumer that folds rather than collects pays
that time without the ~50 MB the full list retains.
maxFrames stops the walk early. There is deliberately no way to start it late: entity state is
delta-encoded, so a walk beginning anywhere but frame 0 reports positions built on deltas it never
saw. For one round's path, walk from the start and filter by PositionSample.Tick.
Do not reach for the player.pos_x/y/z rule providers to extract paths. They emit into the
digest per change, which is 26x the cells of the whole shipped provider set for one axis and 100x
for all three (docs/perf/baseline.md). Coordinates in rules are for predicates at events; this
walk is for trajectories.
Working with raw net messages
Generated Valve proto types (CDemoPacket, CSVCMsg_PacketEntities, CCSUsrMsg_*, …) live in
CS2OpenSchema.Protos, not the global namespace — add the using to pattern-match
NetMessage.Payload. They ship in the CS2OpenDev.Protos package, which comes along as a
dependency of this one:
using CS2OpenSchema.Protos;
foreach (NetMessage msg in frame.InnerMessages)
{
if (msg.Payload is CSVCMsg_PacketEntities entities)
{
// ...
}
}
Opt-in diagnostics
Two environment variables gate diagnostics that are off by default and cost nothing when unset:
CS2DEMOKIT_PROFILE=1 enables the parse-profiling accumulator, CS2DEMOKIT_TRACE_DECODE=1
enables verbose entity-decode tracing. Both are settable in code as well, via Profiling.Enabled
and Tracing.Enabled — set them before the run they govern begins.
The DEMOVIEWER_* spellings these switches shipped under are still honoured, second in
precedence, so existing scripts keep working.
License
MIT. Contains code adapted from demofile-net (MIT) —
see THIRD-PARTY-NOTICES.md in the repo for the full attribution and file list.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net10.0
- CS2OpenDev.Protos (>= 0.9.0)
- CS2OpenDev.Sdk (>= 0.9.0)
- CS2OpenDev.Sdk.Entities.Abstractions (>= 1.0.3)
- CS2OpenDev.Sdk.GameEvents (>= 0.9.0)
- Google.Protobuf (>= 3.29.5)
- Snappier (>= 1.3.1)
NuGet packages (1)
Showing the top 1 NuGet packages that depend on CS2DemoKit.Parser:
| Package | Downloads |
|---|---|
|
CS2DemoKit.Analysis
Rule-driven analysis engine for parsed Counter-Strike 2 demos: a state-graph evaluator, the shipped rulesets embedded in the assembly, per-player stats, rich highlights, and a 3D line-of-sight engine. |
GitHub repositories
This package is not used by any popular GitHub repositories.
| Version | Downloads | Last Updated |
|---|---|---|
| 0.12.0 | 83 | 9/17/2026 |
| 0.11.0 | 204 | 9/14/2026 |
| 0.10.0 | 395 | 8/24/2026 |
| 0.10.0-beta0002 | 107 | 8/24/2026 |
| 0.10.0-beta0001 | 99 | 8/24/2026 |
| 0.9.2 | 119 | 8/24/2026 |
| 0.9.2-beta0003 | 113 | 8/24/2026 |
| 0.9.2-beta0002 | 112 | 8/23/2026 |
| 0.9.2-beta0001 | 102 | 8/22/2026 |
| 0.9.1 | 123 | 8/18/2026 |