5Stack is a platform for organizing and managing competitive CS2 matches and tournaments.
The demo parser is a small HTTP + CLI service that wraps markus-wa/demoinfocs-golang to extract playback metadata, events, and player stats from a CS2 .dem file.
Please visit 5Stack for more documentation.
| Endpoint | Body | Answers |
|---|---|---|
POST /parse |
{"demo_url": "...", "playback": {...}} |
parsed Result JSON, with the playback blob when asked |
POST /smoke-voxels |
{"log","samples","detonation","tick_rate"} |
a smoke rebuilt from its live voxel log |
POST /smoke-volume |
{"map","x","y","z"} |
the bloom at a point |
POST /sightlines |
map + smoke(s) + eye-position pairs | what the smoke blocks |
POST /oneway |
map + smoke(s) + player-position pairs | asymmetric visibility |
POST /drift |
map + two mesh revisions + lineups | which lineups a map update moved |
playback ({"match_map_id": "...", "schema_version": 11}) asks /parse to
build the demo's gzipped playback blob too, the document the 2D/3D replay
reads. It comes back base64 as playback.blob with its counts, and the result
leaves out what only the blob needs (positions, grenade_trajectories,
smoke_volumes, smoke_voxels, infernos, kit_drops) — ~95% of a parse.
A schema_version this parser does not build is ignored and the full result
returned, so the api can build the blob itself.
The last four answer questions about a map, not a demo, so they work for a
lineup nobody has ever thrown. They need the map's collision mesh and return
404 when none is published for it. Coordinates are raw CS2 source units.
Map meshes come from https://demo-dl.5stack.gg/maps/: latest.json names the
newest build's manifest (cached 10 minutes, refreshed in the background, and
possibly a revision such as <build>/manifest.r2.json), and the manifest names
each map's tri (collision hull) and grenadeclip files. A map the manifest
does not list uses build 24957633's flat <map>.tri.gz with no grenade clips,
and if latest.json cannot be read — or is not version 1 — everything falls
back to that build. A file the manifest lists but the CDN refuses is an error
and is retried, never remembered as "no mesh". Setting MAP_MESH_CDN to a base
URL skips all of that and reads <base>/<map>.tri.gz directly; setting it empty
disables geometry.
Line of sight, smoke flood fill, /sightlines and /oneway use the hull only.
Grenade clips block nothing but grenades, so only the /drift flight
simulation collides with them, and a build that published none is flown
against the hull alone.
/smoke-volume returns the same voxel grid the playback blob carries
(ox/oy/oz, vs, dx/dy/dz, den), so one decoder serves both, plus cells
and radius. A point that resolves inside geometry returns 422.
/sightlines takes at (a detonation point), smoke, or smokes — a cloud is
either {"at": {...}} or {"volume": {...}}, where the volume is one
/smoke-volume handed straight back to skip the flood. Each pair returns
blocked, blocked_by (world / smoke), depth (optical depth in cell
widths of full density), transmittance (e^-depth) and world_blocked.
threshold defaults to 3.0 — about 5% of the target's contrast surviving —
and is a request field because it is a judgement, not a measurement.
/oneway takes player positions (feet by default, "positions": "eyes"
otherwise) and tests both standing and crouched eye heights in both directions,
treating each player as a body rather than a point. It reports one_way,
favors, cause, confidence, contested, the best stance pairing and all
four pairings, along with caveats describing what the model does not know.
CS2 networks each smoke's simulation on the projectile as an append-only log
(m_VoxelFrameData, m_nVoxelFrameDataSize bytes in use): the voxels the
smoke comes out of, and the world geometry around them, a frame for every step
the server's simulation takes. The cloud itself is never sent; server and
client each run the same simulation. This runs it too — the game's own, step
for step (internal/parser/smoke_sim.go) — so the cloud is the one the game
had. A server plugin reads the log every tick and sends it here, and it is
replayed exactly as a demo's smoke_voxels row is:
Each frame is stamped with the first sample that completed it, so the samples
are what times the cloud's growth; the log alone gives only its shape. A voxel
is in the cloud once it holds 5% of a full one, the game's own line for a point
being in smoke, and only the steps the log holds are replayed (ten seconds at
most), so a log cut short gives the cloud as it stood then. The answer is one
smoke_voxels row (ox/oy/oz, vs, dx/dy/dz, arr) with
gid, round and start_tick 0 and end_tick the last sample's tick. A log
with no emitters, or a zero detonation, is 422; a body that does not
decode, or samples that run past the log or back in time, is 400. Errors are
{"error": "..."}.
When Valve ships a map update, every stored lineup is re-flown against the old collision mesh and the new one, and the two endpoints are compared.
The output is a differential and nothing else. The grenade simulator behind
it is deterministic and self-consistent but not fitted to CS2: an absolute
landing point out of it is wrong by an unknown amount. It is sound here only
because the same model error appears on both sides and cancels. The position
field is called comparison_point for that reason, and no coordinate from
this endpoint may ever be shown to a player as where their nade lands.
POST /drift
{
"map": "de_mirage",
"from": "24957633", // CS2 build before the patch
"to": "25537370", // after it; "" means the build latest.json names
"lineups": [
{
"id": "1f4c…",
"nade_type": "Smoke", // Smoke | HE | Flash | Molotov | Decoy
"initial_position": {"x": -2300, "y": 0, "z": -64}, // both optional; a lineup
"initial_velocity": {"x": 500, "y": -80, "z": 200} // missing either is unsimulatable
}
],
"stream": false, // true → NDJSON, required above 2000 lineups
"unchanged_radius": 8, // optional threshold overrides
"major_radius": 64,
"constants": {"gravity": 320} // optional physics overrides, applied to BOTH sides
}A mesh revision is a CS2 build id (25537370, read through that build's
first manifest <build>/manifest.json, or <build>/<map>.tri.gz for builds
published before manifests) or an http(s) base holding <map>.tri.gz directly. from and to
come back resolved, so a blank one is legible in the report later. When only
one side published grenade clips, caveats says so: a lineup that touches one
reads as moved even where the map did not change.
Each lineup gets one of four verdicts:
| verdict | meaning |
|---|---|
unchanged |
both meshes end the flight in the same place, within unchanged_radius |
moved |
both resolve, and the endpoint shifted — severity is minor or major |
broken |
it resolved on the old mesh and does not on the new one: inside_geometry, start_sealed, out_of_world, or never comes to rest |
unsimulatable |
no recorded seed, an unknown grenade, or a flight that fails on both meshes — which says nothing about the map |
{
"map": "de_mirage",
"from": "…", "to": "…",
"constants": { … }, // echoed, so a report can be reproduced
"thresholds": {"unchanged": 8, "major": 64},
"summary": {"lineups": 900, "unchanged": 848, "moved": 9, "broken": 2, "unsimulatable": 41, "max_distance": 213.4},
"results": [
{
"index": 0, // request order, for matching when ids repeat
"id": "1f4c…",
"verdict": "moved",
"severity": "minor",
"reason": "landing moved 21.7 units",
"from": {"comparison_point": {"x":…,"y":…,"z":…}, "resolved": true, "stop": "rest",
"bounces": 3, "flight_seconds": 3.61, "steps": 462},
"to": { … },
"distance": 21.7, "distance_xy": 21.6, "distance_z": 1.9 // absent unless BOTH sides resolved
}
],
"caveats": ["…"] // ships with the payload; surface it wherever the numbers are shown
}"stream": true returns application/x-ndjson: one {"type":"header"} line,
one {"type":"result"} line per lineup in request order, then
{"type":"summary"}. The status code is sent before the first result, so a run
that fails part way through ends with a {"type":"error"} line — a consumer
that does not check for one will read a truncated run as a clean one.
Thresholds are judgements, not measurements. unchanged is 8 units: two
re-exports of an unchanged map still round vertices differently, and a bounce
grazing that seam lands a few units off. major is 64 units — a smoke's radius
is 144 and a player is 32 wide, so past 64 a cloud no longer covers the same
gap. Both are request fields.
Batch sizing. A lineup costs two flights: ~2 ms on one core, ~0.4 ms across
eight (measured on de_mirage, 138k triangles). 500-1000 per request is the
comfortable size; 2000 is the cap for a single JSON body and 10000 the hard cap
with stream. Drift is the only endpoint that holds two meshes at once —
21 MiB for the de_mirage pair, 136 MiB for de_anubis — so it is serialized to
one request at a time. Raise DRIFT_CONCURRENCY only on a pod dedicated to it,
and DRIFT_WORKERS (default min(NumCPU, 8)) to change per-request
parallelism; neither changes the answer.
Mesh loading is process-wide, concurrency-safe and LRU-bounded; raise
MAP_MESH_CACHE (default 2) on a deployment serving lineups across many maps.
Drift needs at least 2 or it refetches a mesh per request, and 4 is a better
number on a pod that also serves the other endpoints.
Licensed under the GNU AGPL v3, with the additional terms in NOTICE: keep the "Powered by 5Stack" credit, and do not present a modified version as the official 5Stack. For other licensing, contact @lukepolo. By contributing you agree to the terms in CONTRIBUTING.md.
POST /smoke-voxels { "log": "<base64 of the log's bytes>", // at most 3072 bytes "samples": [[662, 0], [688, 3], [1253, 61]], // [length, ticks after detonation] each tick it grew "detonation": {"x": 1.0, "y": 2.0, "z": 3.0}, "tick_rate": 64 // optional, defaults to 64 }