cs2df 3.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- cs2df/__init__.py +11 -0
- cs2df/cli.py +374 -0
- cs2df/enums.py +269 -0
- cs2df/events.py +881 -0
- cs2df/package.py +203 -0
- cs2df/parse.py +532 -0
- cs2df/rounds.py +328 -0
- cs2df/streams.py +472 -0
- cs2df/validate.py +551 -0
- cs2df-3.0.0.dist-info/METADATA +100 -0
- cs2df-3.0.0.dist-info/RECORD +13 -0
- cs2df-3.0.0.dist-info/WHEEL +4 -0
- cs2df-3.0.0.dist-info/entry_points.txt +2 -0
cs2df/package.py
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
"""Assemble a cs2-demo-format v3 ZIP package from a parsed demo."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import io
|
|
7
|
+
import math
|
|
8
|
+
import time
|
|
9
|
+
import zipfile
|
|
10
|
+
from datetime import datetime, timezone
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any, Callable
|
|
13
|
+
|
|
14
|
+
from . import EXPORTER_NAME, SCHEMA_VERSION, __version__
|
|
15
|
+
from .events import (
|
|
16
|
+
PlayerDirectory, build_blinds, build_bombs, build_clutches, build_damages,
|
|
17
|
+
build_economies, build_grenades, build_kills, build_match,
|
|
18
|
+
build_player_stats, build_players,
|
|
19
|
+
)
|
|
20
|
+
from .rounds import build_rounds
|
|
21
|
+
from .streams import build_duels, build_replay, build_shots
|
|
22
|
+
|
|
23
|
+
ProgressFn = Callable[[str, float], None]
|
|
24
|
+
|
|
25
|
+
_FILENAMES = {
|
|
26
|
+
"match": "match.json",
|
|
27
|
+
"players": "players.json",
|
|
28
|
+
"rounds": "rounds.json",
|
|
29
|
+
"playerStats": "player-stats.json",
|
|
30
|
+
"playerEconomies": "player-economies.json",
|
|
31
|
+
"kills": "kills.json",
|
|
32
|
+
"damages": "damages.json",
|
|
33
|
+
"blinds": "blinds.json",
|
|
34
|
+
"bombs": "bombs.json",
|
|
35
|
+
"grenades": "grenades.json",
|
|
36
|
+
"clutches": "clutches.json",
|
|
37
|
+
"shots": "shots.json",
|
|
38
|
+
"replay": "replay.json",
|
|
39
|
+
"duels": "duels.json",
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def export_demo(dem_path: str, *, research: bool = False, sample_rate: int = 8,
|
|
44
|
+
window_before_ms: int = 2000, window_after_ms: int = 1000,
|
|
45
|
+
compress_level: int = 3,
|
|
46
|
+
progress: ProgressFn | None = None) -> tuple[bytes, dict]:
|
|
47
|
+
"""Parse `dem_path` and return (v3 ZIP bytes, match_meta dict)."""
|
|
48
|
+
from .parse import parse_demo
|
|
49
|
+
|
|
50
|
+
try:
|
|
51
|
+
demo_hash: str | None = _sha256_hex(dem_path)
|
|
52
|
+
except Exception:
|
|
53
|
+
demo_hash = None
|
|
54
|
+
|
|
55
|
+
scaled = (lambda stage, frac: progress(stage, frac * 0.9)) if progress else None
|
|
56
|
+
raw = parse_demo(dem_path, sample_rate=sample_rate, research=research,
|
|
57
|
+
window_before_ms=window_before_ms,
|
|
58
|
+
window_after_ms=window_after_ms, progress=scaled)
|
|
59
|
+
|
|
60
|
+
if progress:
|
|
61
|
+
progress("build package", 0.92)
|
|
62
|
+
return build_package(raw, dem_path, demo_hash,
|
|
63
|
+
window_before_ms=window_before_ms,
|
|
64
|
+
window_after_ms=window_after_ms,
|
|
65
|
+
compress_level=compress_level)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def build_package(raw: dict[str, Any], dem_path: str, demo_hash: str | None, *,
|
|
69
|
+
window_before_ms: int = 2000, window_after_ms: int = 1000,
|
|
70
|
+
compress_level: int = 3) -> tuple[bytes, dict]:
|
|
71
|
+
"""Pure assembly: raw parse output → (v3 ZIP bytes, match_meta)."""
|
|
72
|
+
timings = dict(raw.get("_timings") or {})
|
|
73
|
+
tickrate = int(raw.get("tickrate") or 64)
|
|
74
|
+
sample_rate = int(raw.get("sample_rate") or 8)
|
|
75
|
+
|
|
76
|
+
players = _timed(timings, "package.players", lambda: build_players(raw))
|
|
77
|
+
team_map = {p["steamId64"]: p["teamKey"] for p in players.rows}
|
|
78
|
+
rounds, round_model = _timed(timings, "package.rounds", lambda: build_rounds(raw, team_map))
|
|
79
|
+
|
|
80
|
+
def build_events():
|
|
81
|
+
kills_out = build_kills(raw, players, round_model)
|
|
82
|
+
grenades_out = build_grenades(raw, players, round_model)
|
|
83
|
+
flash_lookup = {
|
|
84
|
+
(g["roundNumber"], g["effectTick"]): g["grenadeId"]
|
|
85
|
+
for g in grenades_out if g["grenade"] == "flashbang" and g["grenadeId"]
|
|
86
|
+
}
|
|
87
|
+
blinds_out = build_blinds(raw, players, round_model, flash_lookup=flash_lookup)
|
|
88
|
+
damages_out = build_damages(raw, players, round_model)
|
|
89
|
+
clutches_out = build_clutches(kills_out, rounds, players)
|
|
90
|
+
economies_out = build_economies(raw, players, round_model, rounds) # mutates rounds economy
|
|
91
|
+
player_stats_out = build_player_stats(
|
|
92
|
+
raw, players, round_model, rounds,
|
|
93
|
+
kills_list=kills_out, blinds_list=blinds_out,
|
|
94
|
+
damages_list=damages_out, clutches_list=clutches_out,
|
|
95
|
+
)
|
|
96
|
+
bombs_out = build_bombs(raw, players, round_model)
|
|
97
|
+
match_out = build_match(raw, rounds)
|
|
98
|
+
return (kills_out, grenades_out, blinds_out, damages_out, clutches_out,
|
|
99
|
+
economies_out, player_stats_out, bombs_out, match_out)
|
|
100
|
+
|
|
101
|
+
(kills, grenades, blinds, damages, clutches,
|
|
102
|
+
economies, player_stats, bombs, match_json) = _timed(timings, "package.events", build_events)
|
|
103
|
+
|
|
104
|
+
shots = _timed(timings, "package.shots", lambda: build_shots(raw, players, round_model))
|
|
105
|
+
replay = _timed(timings, "package.replay", lambda: build_replay(raw, players, round_model,
|
|
106
|
+
tickrate, sample_rate))
|
|
107
|
+
duels = _timed(timings, "package.duels", lambda: build_duels(
|
|
108
|
+
raw, players, round_model, tickrate, kills, damages, window_before_ms, window_after_ms))
|
|
109
|
+
|
|
110
|
+
data_by_key: dict[str, Any] = {
|
|
111
|
+
"match": match_json,
|
|
112
|
+
"players": players.rows,
|
|
113
|
+
"rounds": rounds,
|
|
114
|
+
"playerStats": player_stats,
|
|
115
|
+
"playerEconomies": economies,
|
|
116
|
+
"kills": kills,
|
|
117
|
+
"damages": damages,
|
|
118
|
+
"blinds": blinds,
|
|
119
|
+
"bombs": bombs,
|
|
120
|
+
"grenades": grenades,
|
|
121
|
+
"clutches": clutches,
|
|
122
|
+
}
|
|
123
|
+
if shots:
|
|
124
|
+
data_by_key["shots"] = shots
|
|
125
|
+
if replay:
|
|
126
|
+
data_by_key["replay"] = replay
|
|
127
|
+
if duels:
|
|
128
|
+
data_by_key["duels"] = duels
|
|
129
|
+
|
|
130
|
+
manifest = {
|
|
131
|
+
"schemaVersion": SCHEMA_VERSION,
|
|
132
|
+
"exporter": {"name": EXPORTER_NAME, "version": __version__},
|
|
133
|
+
"parser": {"name": "demoparser2", "version": _demoparser2_version()},
|
|
134
|
+
"demo": {"hash": demo_hash, "sourceFileName": Path(dem_path).name},
|
|
135
|
+
"mapName": str(raw.get("header", {}).get("map_name") or "unknown"),
|
|
136
|
+
"tickrate": tickrate,
|
|
137
|
+
"exportedAt": datetime.now(timezone.utc).isoformat(),
|
|
138
|
+
"files": {key: _FILENAMES[key] for key in data_by_key},
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
match_meta = {
|
|
142
|
+
"mapName": match_json.get("mapName", "unknown"),
|
|
143
|
+
"teamA": match_json.get("teamA") or {},
|
|
144
|
+
"teamB": match_json.get("teamB") or {},
|
|
145
|
+
}
|
|
146
|
+
zip_bytes = _timed(timings, "package.writeZip",
|
|
147
|
+
lambda: _write_zip(manifest, data_by_key, compress_level=compress_level))
|
|
148
|
+
match_meta["timingsSeconds"] = timings
|
|
149
|
+
match_meta["compressLevel"] = compress_level
|
|
150
|
+
return zip_bytes, match_meta
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _write_zip(manifest: dict, data_by_key: dict[str, Any], *,
|
|
154
|
+
compress_level: int = 3) -> bytes:
|
|
155
|
+
buf = io.BytesIO()
|
|
156
|
+
with zipfile.ZipFile(buf, "w", compression=zipfile.ZIP_DEFLATED,
|
|
157
|
+
compresslevel=compress_level) as zf:
|
|
158
|
+
zf.writestr("manifest.json", _dumps(manifest))
|
|
159
|
+
for key, data in data_by_key.items():
|
|
160
|
+
zf.writestr(_FILENAMES[key], _dumps(data))
|
|
161
|
+
return buf.getvalue()
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _timed(timings: dict[str, float], name: str, fn):
|
|
165
|
+
started = time.perf_counter()
|
|
166
|
+
result = fn()
|
|
167
|
+
timings[name] = round(time.perf_counter() - started, 3)
|
|
168
|
+
return result
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _dumps(data: Any) -> bytes:
|
|
172
|
+
"""Minified JSON via orjson (fast, no NaN — raises on non-finite floats)."""
|
|
173
|
+
import orjson
|
|
174
|
+
|
|
175
|
+
return orjson.dumps(_json_safe(data))
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _json_safe(obj):
|
|
179
|
+
"""Replace float NaN/inf with None (orjson would raise otherwise)."""
|
|
180
|
+
if isinstance(obj, float):
|
|
181
|
+
return None if (math.isnan(obj) or math.isinf(obj)) else obj
|
|
182
|
+
if isinstance(obj, list):
|
|
183
|
+
return [_json_safe(v) for v in obj]
|
|
184
|
+
if isinstance(obj, dict):
|
|
185
|
+
return {k: _json_safe(v) for k, v in obj.items()}
|
|
186
|
+
return obj
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _sha256_hex(path: str) -> str:
|
|
190
|
+
h = hashlib.sha256()
|
|
191
|
+
with open(path, "rb") as f:
|
|
192
|
+
for chunk in iter(lambda: f.read(1 << 20), b""):
|
|
193
|
+
h.update(chunk)
|
|
194
|
+
return h.hexdigest()
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _demoparser2_version() -> str:
|
|
198
|
+
try:
|
|
199
|
+
from importlib.metadata import version
|
|
200
|
+
|
|
201
|
+
return version("demoparser2")
|
|
202
|
+
except Exception:
|
|
203
|
+
return "unknown"
|