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/parse.py
ADDED
|
@@ -0,0 +1,532 @@
|
|
|
1
|
+
"""demoparser2 extraction for the v3 exporter.
|
|
2
|
+
|
|
3
|
+
Performance notes (this module is the reference for fast producers):
|
|
4
|
+
|
|
5
|
+
- Event scans are batched: one `parse_events` call per group of events that
|
|
6
|
+
share extra props, instead of one demo scan per event.
|
|
7
|
+
- The big per-frame `parse_ticks` result is kept as a pandas DataFrame all the
|
|
8
|
+
way into the columnar stream builders. v2-era exporters converted it to
|
|
9
|
+
~200k row dicts (`to_dict(orient="records")`), which dominated Python-side
|
|
10
|
+
time; v3's columnar layout makes that conversion unnecessary.
|
|
11
|
+
- `inventory` (a per-row list of strings, by far the most expensive tick prop)
|
|
12
|
+
is NOT extracted on the per-frame grid. The bomb-carrier flag is derived
|
|
13
|
+
from bomb lifecycle events instead (see streams.build_bomb_carrier_timeline);
|
|
14
|
+
inventory is only read at the ~24 freeze ticks for economy rows.
|
|
15
|
+
- The research-profile duel windows reuse the already-parsed kill/damage ticks
|
|
16
|
+
to compute merged combat windows, then fetch them in ONE lean `parse_ticks`
|
|
17
|
+
call (6 props at full tick) instead of widening the main grid.
|
|
18
|
+
|
|
19
|
+
Provenance: event extraction layout ported from cs2-demo-analysis-kit
|
|
20
|
+
(originally DrEAmSs59/CS2-insight-agent, with the author's permission).
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import logging
|
|
26
|
+
import time
|
|
27
|
+
from typing import TYPE_CHECKING, Any, Callable
|
|
28
|
+
|
|
29
|
+
if TYPE_CHECKING:
|
|
30
|
+
import pandas as pd
|
|
31
|
+
from demoparser2 import DemoParser # type: ignore
|
|
32
|
+
|
|
33
|
+
log = logging.getLogger(__name__)
|
|
34
|
+
|
|
35
|
+
ProgressFn = Callable[[str, float], None]
|
|
36
|
+
|
|
37
|
+
# 火用 inferno_startburn(起火)作为 effect 事件;inferno_expire(熄灭)单独
|
|
38
|
+
# 解析,由 events.py 配对成 destroyTick。
|
|
39
|
+
_GRENADE_EVENTS = [
|
|
40
|
+
("smokegrenade_detonate", "smoke"),
|
|
41
|
+
("flashbang_detonate", "flashbang"),
|
|
42
|
+
("hegrenade_detonate", "hegrenade"),
|
|
43
|
+
("inferno_startburn", "molotov"),
|
|
44
|
+
("decoy_detonate", "decoy"),
|
|
45
|
+
]
|
|
46
|
+
|
|
47
|
+
# steamid/XYZ for grenade detonations — resolve thrower via player extras
|
|
48
|
+
# (raw userid is an entity slot, not a Steam64).
|
|
49
|
+
_GRENADE_PLAYER_FIELDS = ["steamid", "X", "Y", "Z"]
|
|
50
|
+
|
|
51
|
+
# Per-frame props for the replay grid. No `inventory`, no `has_c4` — see module
|
|
52
|
+
# docstring. `balance` is the cash account (replay `money` column);
|
|
53
|
+
# `current_equip_value` is the equipment value (`equipValue` column).
|
|
54
|
+
_REPLAY_PROPS = [
|
|
55
|
+
"steamid", "team_num", "X", "Y", "Z", "yaw", "pitch",
|
|
56
|
+
"health", "armor", "active_weapon_name", "flash_duration",
|
|
57
|
+
"balance", "current_equip_value", "has_defuser", "last_place_name",
|
|
58
|
+
]
|
|
59
|
+
|
|
60
|
+
# Lean per-frame props for full-tick duel windows.
|
|
61
|
+
_DUEL_PROPS = ["steamid", "X", "Y", "Z", "yaw", "pitch", "health", "flash_duration"]
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _rows(result: Any) -> list[dict]:
|
|
65
|
+
"""Convert a demoparser2 result (DataFrame or list) to a list of dicts.
|
|
66
|
+
|
|
67
|
+
Only used for small event tables; per-frame data stays as DataFrames.
|
|
68
|
+
"""
|
|
69
|
+
if result is None:
|
|
70
|
+
return []
|
|
71
|
+
if hasattr(result, "to_dict"):
|
|
72
|
+
return result.to_dict(orient="records")
|
|
73
|
+
return list(result)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _raise_if_control_flow(exc: BaseException) -> None:
|
|
77
|
+
if isinstance(exc, (KeyboardInterrupt, SystemExit)):
|
|
78
|
+
raise exc
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _safe_event(parser: DemoParser, event: str,
|
|
82
|
+
other: list[str] | None = None,
|
|
83
|
+
player: list[str] | None = None) -> list[dict]:
|
|
84
|
+
try:
|
|
85
|
+
kwargs: dict[str, list[str]] = {}
|
|
86
|
+
if other is not None:
|
|
87
|
+
kwargs["other"] = other
|
|
88
|
+
if player is not None:
|
|
89
|
+
kwargs["player"] = player
|
|
90
|
+
return _rows(parser.parse_event(event, **kwargs))
|
|
91
|
+
except Exception:
|
|
92
|
+
return []
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _safe_events(parser: DemoParser, names: list[str],
|
|
96
|
+
other: list[str] | None = None,
|
|
97
|
+
player: list[str] | None = None) -> dict[str, list[dict]]:
|
|
98
|
+
"""Batch-parse several events in ONE demo scan via parse_events."""
|
|
99
|
+
kwargs: dict[str, list[str]] = {}
|
|
100
|
+
if other is not None:
|
|
101
|
+
kwargs["other"] = other
|
|
102
|
+
if player is not None:
|
|
103
|
+
kwargs["player"] = player
|
|
104
|
+
try:
|
|
105
|
+
pairs = parser.parse_events(names, **kwargs)
|
|
106
|
+
out = {name: _rows(df) for name, df in pairs}
|
|
107
|
+
except Exception:
|
|
108
|
+
log.warning("parse_events(%s) failed; falling back to per-event parse", names, exc_info=True)
|
|
109
|
+
out = {name: _safe_event(parser, name, other=other, player=player) for name in names}
|
|
110
|
+
for name in names:
|
|
111
|
+
out.setdefault(name, [])
|
|
112
|
+
return out
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _safe_ticks_df(parser: DemoParser, props: list[str], ticks: list[int]) -> "pd.DataFrame | None":
|
|
116
|
+
"""parse_ticks kept as a DataFrame; drops unknown props one by one on failure."""
|
|
117
|
+
remaining = list(props)
|
|
118
|
+
while remaining:
|
|
119
|
+
try:
|
|
120
|
+
return parser.parse_ticks(remaining, ticks=ticks)
|
|
121
|
+
except Exception as exc:
|
|
122
|
+
msg = str(exc)
|
|
123
|
+
dropped = next((p for p in remaining if p in msg), None)
|
|
124
|
+
if dropped is None:
|
|
125
|
+
log.warning("parse_ticks failed: %s", msg)
|
|
126
|
+
return None
|
|
127
|
+
log.warning("parse_ticks: dropping unsupported prop %r", dropped)
|
|
128
|
+
remaining.remove(dropped)
|
|
129
|
+
return None
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def parse_demo(dem_path: str, *, sample_rate: int = 8, research: bool = False,
|
|
133
|
+
window_before_ms: int = 2000, window_after_ms: int = 1000,
|
|
134
|
+
progress: ProgressFn | None = None) -> dict[str, Any]:
|
|
135
|
+
"""Full extraction. Event tables are lists of dicts; per-frame data is DataFrames."""
|
|
136
|
+
from demoparser2 import DemoParser # lazy: native dep
|
|
137
|
+
|
|
138
|
+
timings: dict[str, float] = {}
|
|
139
|
+
parse_started = time.perf_counter()
|
|
140
|
+
|
|
141
|
+
def _record(stage: str, started: float) -> None:
|
|
142
|
+
timings[stage] = round(time.perf_counter() - started, 3)
|
|
143
|
+
|
|
144
|
+
def _p(stage: str, frac: float) -> None:
|
|
145
|
+
if progress is not None:
|
|
146
|
+
progress(stage, frac)
|
|
147
|
+
|
|
148
|
+
_p("open demo", 0.01)
|
|
149
|
+
stage_started = time.perf_counter()
|
|
150
|
+
p = DemoParser(dem_path)
|
|
151
|
+
_record("parse.openDemo", stage_started)
|
|
152
|
+
|
|
153
|
+
stage_started = time.perf_counter()
|
|
154
|
+
try:
|
|
155
|
+
header = dict(p.parse_header())
|
|
156
|
+
except BaseException as exc:
|
|
157
|
+
_raise_if_control_flow(exc)
|
|
158
|
+
header = {}
|
|
159
|
+
_record("parse.header", stage_started)
|
|
160
|
+
try:
|
|
161
|
+
tickrate = int(float(header.get("tick_rate") or 64))
|
|
162
|
+
except (TypeError, ValueError):
|
|
163
|
+
tickrate = 64
|
|
164
|
+
|
|
165
|
+
_p("round events", 0.05)
|
|
166
|
+
stage_started = time.perf_counter()
|
|
167
|
+
g_round = _safe_events(p,
|
|
168
|
+
["round_start", "round_freeze_end", "round_end", "player_blind",
|
|
169
|
+
"round_announce_match_start"],
|
|
170
|
+
other=["winner", "reason", "legacy", "blind_duration", "total_rounds_played"],
|
|
171
|
+
)
|
|
172
|
+
_record("parse.roundEvents", stage_started)
|
|
173
|
+
|
|
174
|
+
_p("kills", 0.12)
|
|
175
|
+
stage_started = time.perf_counter()
|
|
176
|
+
deaths = _safe_event(p, "player_death",
|
|
177
|
+
other=["headshot", "noscope", "thrusmoke", "penetrated", "penetrated_objects",
|
|
178
|
+
"assistedflash", "attackerblind", "total_rounds_played"],
|
|
179
|
+
player=["X", "Y", "Z", "active_weapon"],
|
|
180
|
+
)
|
|
181
|
+
_record("parse.kills", stage_started)
|
|
182
|
+
|
|
183
|
+
_p("damages", 0.2)
|
|
184
|
+
stage_started = time.perf_counter()
|
|
185
|
+
hurts = _safe_event(p, "player_hurt",
|
|
186
|
+
other=["weapon", "hitgroup", "dmg_health", "dmg_armor", "health", "armor",
|
|
187
|
+
"total_rounds_played"],
|
|
188
|
+
player=["X", "Y", "Z"],
|
|
189
|
+
)
|
|
190
|
+
_record("parse.damages", stage_started)
|
|
191
|
+
|
|
192
|
+
_p("shots", 0.28)
|
|
193
|
+
stage_started = time.perf_counter()
|
|
194
|
+
fires = _safe_event(p, "weapon_fire",
|
|
195
|
+
other=["weapon", "total_rounds_played"],
|
|
196
|
+
player=["X", "Y", "Z", "yaw", "pitch"],
|
|
197
|
+
)
|
|
198
|
+
_record("parse.shotsEvents", stage_started)
|
|
199
|
+
# velocity is not available via weapon_fire player extras; fetch from tick data.
|
|
200
|
+
stage_started = time.perf_counter()
|
|
201
|
+
fire_velocity_df = None
|
|
202
|
+
if fires:
|
|
203
|
+
shot_ticks = sorted({int(r["tick"]) for r in fires if int(r.get("tick") or 0) > 0})
|
|
204
|
+
if shot_ticks:
|
|
205
|
+
fire_velocity_df = _safe_ticks_df(
|
|
206
|
+
p, ["steamid", "velocity_X", "velocity_Y", "velocity_Z"], shot_ticks)
|
|
207
|
+
_record("parse.shotsVelocity", stage_started)
|
|
208
|
+
|
|
209
|
+
_p("bomb events", 0.36)
|
|
210
|
+
stage_started = time.perf_counter()
|
|
211
|
+
g_bomb = _safe_events(p,
|
|
212
|
+
["bomb_planted", "bomb_defused", "bomb_exploded",
|
|
213
|
+
"bomb_beginplant", "bomb_begindefuse", "bomb_dropped", "bomb_pickup"],
|
|
214
|
+
other=["site", "total_rounds_played"],
|
|
215
|
+
player=["steamid", "X", "Y", "Z", "last_place_name"])
|
|
216
|
+
_record("parse.bombEvents", stage_started)
|
|
217
|
+
|
|
218
|
+
_p("grenade detonations", 0.42)
|
|
219
|
+
stage_started = time.perf_counter()
|
|
220
|
+
g_nade = _safe_events(p,
|
|
221
|
+
[name for name, _ in _GRENADE_EVENTS] + ["inferno_expire", "smokegrenade_expired"],
|
|
222
|
+
other=["total_rounds_played"], player=_GRENADE_PLAYER_FIELDS)
|
|
223
|
+
grenade_detonations: list[dict] = []
|
|
224
|
+
for ev_name, gtype in _GRENADE_EVENTS:
|
|
225
|
+
grenade_detonations.extend({**r, "_grenade_type": gtype} for r in g_nade[ev_name])
|
|
226
|
+
_record("parse.grenadeEvents", stage_started)
|
|
227
|
+
|
|
228
|
+
# ── player info / team names at match start ──────────────────
|
|
229
|
+
stage_started = time.perf_counter()
|
|
230
|
+
announce_rows = g_round["round_announce_match_start"]
|
|
231
|
+
round_freeze_ends = g_round["round_freeze_end"]
|
|
232
|
+
if announce_rows:
|
|
233
|
+
match_start_tick = int(announce_rows[0]["tick"])
|
|
234
|
+
elif round_freeze_ends:
|
|
235
|
+
match_start_tick = int(round_freeze_ends[0]["tick"])
|
|
236
|
+
else:
|
|
237
|
+
match_start_tick = 1
|
|
238
|
+
|
|
239
|
+
team_a_name: str | None = None
|
|
240
|
+
team_b_name: str | None = None
|
|
241
|
+
try:
|
|
242
|
+
for row in _rows(p.parse_ticks(
|
|
243
|
+
["CCSTeam.m_szClanTeamname", "CCSTeam.m_iTeamNum"],
|
|
244
|
+
ticks=[match_start_tick])):
|
|
245
|
+
tn = row.get("CCSTeam.m_iTeamNum")
|
|
246
|
+
name = str(row.get("CCSTeam.m_szClanTeamname") or "").strip()
|
|
247
|
+
if not name or name.lower() in ("ct", "terrorist", "t", "team a", "team b"):
|
|
248
|
+
continue
|
|
249
|
+
if tn == 2:
|
|
250
|
+
team_a_name = name
|
|
251
|
+
elif tn == 3:
|
|
252
|
+
team_b_name = name
|
|
253
|
+
except BaseException as exc:
|
|
254
|
+
_raise_if_control_flow(exc)
|
|
255
|
+
pass
|
|
256
|
+
|
|
257
|
+
try:
|
|
258
|
+
player_info = _rows(p.parse_ticks(
|
|
259
|
+
["name", "steamid", "team_num", "team_name"], ticks=[match_start_tick]))
|
|
260
|
+
except BaseException as exc:
|
|
261
|
+
_raise_if_control_flow(exc)
|
|
262
|
+
player_info = []
|
|
263
|
+
|
|
264
|
+
# Side ground truth sampled shortly after each freeze ends.
|
|
265
|
+
round_side_ticks = sorted({
|
|
266
|
+
int(r["tick"]) + 16 for r in round_freeze_ends if int(r.get("tick") or 0) > 0
|
|
267
|
+
})
|
|
268
|
+
round_side_samples: list[dict] = []
|
|
269
|
+
if round_side_ticks:
|
|
270
|
+
try:
|
|
271
|
+
round_side_samples = _rows(p.parse_ticks(["steamid", "team_num"],
|
|
272
|
+
ticks=round_side_ticks))
|
|
273
|
+
except BaseException as exc:
|
|
274
|
+
_raise_if_control_flow(exc)
|
|
275
|
+
round_side_samples = []
|
|
276
|
+
_record("parse.playerInfo", stage_started)
|
|
277
|
+
|
|
278
|
+
# ── replay grid (single DataFrame, no row dicts) ─────────────
|
|
279
|
+
_p("replay grid (slowest stage)", 0.5)
|
|
280
|
+
stage_started = time.perf_counter()
|
|
281
|
+
round_ends = g_round["round_end"]
|
|
282
|
+
step = max(1, tickrate // max(1, sample_rate))
|
|
283
|
+
replay_ticks = _build_sample_ticks(round_ends, round_freeze_ends, step)
|
|
284
|
+
replay_df = _safe_ticks_df(p, _REPLAY_PROPS, replay_ticks) if replay_ticks else None
|
|
285
|
+
_record("parse.replayGrid", stage_started)
|
|
286
|
+
|
|
287
|
+
_p("grenade trajectories", 0.72)
|
|
288
|
+
stage_started = time.perf_counter()
|
|
289
|
+
grenade_throws, grenade_trajectories = _extract_grenade_paths(p, replay_ticks)
|
|
290
|
+
_record("parse.grenadeTrajectories", stage_started)
|
|
291
|
+
|
|
292
|
+
# ── duel windows (research profile): full-tick lean parse ────
|
|
293
|
+
stage_started = time.perf_counter()
|
|
294
|
+
duel_df = None
|
|
295
|
+
duel_windows: list[tuple[int, int]] = []
|
|
296
|
+
if research:
|
|
297
|
+
_p("duel windows (full tick)", 0.78)
|
|
298
|
+
anchor_ticks = [int(r.get("tick") or 0) for r in deaths + hurts]
|
|
299
|
+
duel_windows = _merge_windows(anchor_ticks, round_ends, round_freeze_ends,
|
|
300
|
+
tickrate, window_before_ms, window_after_ms)
|
|
301
|
+
duel_ticks: list[int] = []
|
|
302
|
+
for start, end in duel_windows:
|
|
303
|
+
duel_ticks.extend(range(start, end + 1))
|
|
304
|
+
if duel_ticks:
|
|
305
|
+
duel_df = _safe_ticks_df(p, _DUEL_PROPS, duel_ticks)
|
|
306
|
+
_record("parse.duelWindows", stage_started)
|
|
307
|
+
|
|
308
|
+
_p("economy", 0.92)
|
|
309
|
+
stage_started = time.perf_counter()
|
|
310
|
+
freeze_ticks = sorted({int(r["tick"]) for r in round_freeze_ends if r.get("tick")})
|
|
311
|
+
economy_raw: list[dict] = []
|
|
312
|
+
if freeze_ticks:
|
|
313
|
+
try:
|
|
314
|
+
economy_raw = _rows(p.parse_ticks(
|
|
315
|
+
["steamid", "team_num", "cash_spent_this_round", "current_equip_value",
|
|
316
|
+
"start_balance", "armor", "has_helmet", "has_defuser", "inventory"],
|
|
317
|
+
ticks=freeze_ticks))
|
|
318
|
+
except BaseException as exc:
|
|
319
|
+
_raise_if_control_flow(exc)
|
|
320
|
+
economy_raw = []
|
|
321
|
+
_record("parse.economy", stage_started)
|
|
322
|
+
timings["parse.total"] = round(time.perf_counter() - parse_started, 3)
|
|
323
|
+
|
|
324
|
+
return {
|
|
325
|
+
"_timings": timings,
|
|
326
|
+
"header": header,
|
|
327
|
+
"tickrate": tickrate,
|
|
328
|
+
"sample_rate": max(1, tickrate // step),
|
|
329
|
+
"match_start_tick": match_start_tick,
|
|
330
|
+
"team_a_name": team_a_name,
|
|
331
|
+
"team_b_name": team_b_name,
|
|
332
|
+
"player_info": player_info,
|
|
333
|
+
"round_starts": g_round["round_start"],
|
|
334
|
+
"round_freeze_ends": round_freeze_ends,
|
|
335
|
+
"round_ends": round_ends,
|
|
336
|
+
"deaths": deaths,
|
|
337
|
+
"hurts": hurts,
|
|
338
|
+
"fires": fires,
|
|
339
|
+
"fire_velocity_df": fire_velocity_df,
|
|
340
|
+
"blinds": g_round["player_blind"],
|
|
341
|
+
"bomb_planted": g_bomb["bomb_planted"],
|
|
342
|
+
"bomb_defused": g_bomb["bomb_defused"],
|
|
343
|
+
"bomb_exploded": g_bomb["bomb_exploded"],
|
|
344
|
+
"bomb_beginplant": g_bomb["bomb_beginplant"],
|
|
345
|
+
"bomb_begindefuse": g_bomb["bomb_begindefuse"],
|
|
346
|
+
"bomb_dropped": g_bomb["bomb_dropped"],
|
|
347
|
+
"bomb_pickup": g_bomb["bomb_pickup"],
|
|
348
|
+
"grenade_detonations": grenade_detonations,
|
|
349
|
+
"inferno_expires": g_nade["inferno_expire"],
|
|
350
|
+
"smoke_expires": g_nade["smokegrenade_expired"],
|
|
351
|
+
"grenade_throws": grenade_throws,
|
|
352
|
+
"grenade_trajectories": grenade_trajectories,
|
|
353
|
+
"replay_df": replay_df,
|
|
354
|
+
"replay_ticks": replay_ticks,
|
|
355
|
+
"duel_df": duel_df,
|
|
356
|
+
"duel_windows": duel_windows,
|
|
357
|
+
"round_side_samples": round_side_samples,
|
|
358
|
+
"economy_raw": economy_raw,
|
|
359
|
+
"freeze_ticks": freeze_ticks,
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
# ── sampling grids ────────────────────────────────────────────────────────────
|
|
364
|
+
|
|
365
|
+
def _freeze_by_round(round_freeze_ends: list[dict]) -> dict[int, int]:
|
|
366
|
+
"""total_rounds_played at round_freeze_end = N-1 for round N."""
|
|
367
|
+
out: dict[int, int] = {}
|
|
368
|
+
for r in round_freeze_ends:
|
|
369
|
+
rn = int(r.get("total_rounds_played") or 0) + 1
|
|
370
|
+
t = int(r.get("tick") or 0)
|
|
371
|
+
if rn > 0 and t > 0:
|
|
372
|
+
out[rn] = t
|
|
373
|
+
return out
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def _round_spans(round_ends: list[dict],
|
|
377
|
+
round_freeze_ends: list[dict]) -> list[tuple[int, int]]:
|
|
378
|
+
"""[(freeze_end_tick, end_tick)] for rounds with a valid window."""
|
|
379
|
+
freeze = _freeze_by_round(round_freeze_ends)
|
|
380
|
+
spans: list[tuple[int, int]] = []
|
|
381
|
+
for r in round_ends:
|
|
382
|
+
rn = int(r.get("total_rounds_played") or 0)
|
|
383
|
+
end_t = int(r.get("tick") or 0)
|
|
384
|
+
start_t = freeze.get(rn, 0)
|
|
385
|
+
if start_t > 0 and end_t > start_t:
|
|
386
|
+
spans.append((start_t, end_t))
|
|
387
|
+
return spans
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def _build_sample_ticks(round_ends: list[dict], round_freeze_ends: list[dict],
|
|
391
|
+
step: int) -> list[int]:
|
|
392
|
+
"""Sorted unique sample ticks at interval `step` within active play."""
|
|
393
|
+
ticks: list[int] = []
|
|
394
|
+
for start_t, end_t in _round_spans(round_ends, round_freeze_ends):
|
|
395
|
+
ticks.extend(range(start_t, end_t, step))
|
|
396
|
+
return sorted(set(ticks))
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def _merge_windows(anchor_ticks: list[int], round_ends: list[dict],
|
|
400
|
+
round_freeze_ends: list[dict], tickrate: int,
|
|
401
|
+
before_ms: int, after_ms: int) -> list[tuple[int, int]]:
|
|
402
|
+
"""Merged [start, end] full-tick combat windows, clamped to round spans."""
|
|
403
|
+
spans = _round_spans(round_ends, round_freeze_ends)
|
|
404
|
+
if not spans or not anchor_ticks:
|
|
405
|
+
return []
|
|
406
|
+
before = (before_ms * tickrate) // 1000
|
|
407
|
+
after = (after_ms * tickrate) // 1000
|
|
408
|
+
windows: list[tuple[int, int]] = []
|
|
409
|
+
spans.sort()
|
|
410
|
+
anchors = sorted(set(t for t in anchor_ticks if t > 0))
|
|
411
|
+
import bisect
|
|
412
|
+
starts = [s for s, _ in spans]
|
|
413
|
+
for t in anchors:
|
|
414
|
+
i = bisect.bisect_right(starts, t) - 1
|
|
415
|
+
if i < 0:
|
|
416
|
+
continue
|
|
417
|
+
lo, hi = spans[i]
|
|
418
|
+
if not (lo <= t <= hi):
|
|
419
|
+
continue
|
|
420
|
+
windows.append((max(lo, t - before), min(hi, t + after)))
|
|
421
|
+
if not windows:
|
|
422
|
+
return []
|
|
423
|
+
windows.sort()
|
|
424
|
+
merged = [windows[0]]
|
|
425
|
+
for s, e in windows[1:]:
|
|
426
|
+
ls, le = merged[-1]
|
|
427
|
+
if s <= le + 1:
|
|
428
|
+
merged[-1] = (ls, max(le, e))
|
|
429
|
+
else:
|
|
430
|
+
merged.append((s, e))
|
|
431
|
+
return merged
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
# ── grenade trajectories ──────────────────────────────────────────────────────
|
|
435
|
+
|
|
436
|
+
def _nearest_path(path: dict[int, tuple], t: int) -> tuple:
|
|
437
|
+
if not path:
|
|
438
|
+
return (0.0, 0.0, 0.0)
|
|
439
|
+
k = min(path.keys(), key=lambda kt: abs(kt - t))
|
|
440
|
+
return path[k]
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
def _extract_grenade_paths(parser: DemoParser,
|
|
444
|
+
sample_ticks: list[int]) -> tuple[list[dict], list[dict]]:
|
|
445
|
+
"""Throw origins + in-flight trajectories from parse_grenades().
|
|
446
|
+
|
|
447
|
+
trajectories are sampled onto the replay grid for replay rendering:
|
|
448
|
+
{grenade, steamid, start_tick, xs, ys, zs}. Flight phase only; the static
|
|
449
|
+
smoke/fire effect afterwards lives in grenades.json.
|
|
450
|
+
"""
|
|
451
|
+
from .enums import grenade_projectile_to_type
|
|
452
|
+
|
|
453
|
+
try:
|
|
454
|
+
g = parser.parse_grenades()
|
|
455
|
+
except Exception:
|
|
456
|
+
return [], []
|
|
457
|
+
if g is None or not hasattr(g, "columns") or "grenade_entity_id" not in g.columns:
|
|
458
|
+
return [], []
|
|
459
|
+
try:
|
|
460
|
+
proj = g[g["grenade_type"].astype(str).str.endswith("Projectile")]
|
|
461
|
+
proj = proj.dropna(subset=["x", "y", "z"]).sort_values(["grenade_entity_id", "tick"])
|
|
462
|
+
if proj.empty:
|
|
463
|
+
return [], []
|
|
464
|
+
# Entity ids recycle; a new throw starts when the id changes or the
|
|
465
|
+
# per-tick flight path breaks (gap > ~1s).
|
|
466
|
+
eid = proj["grenade_entity_id"]
|
|
467
|
+
tick = proj["tick"]
|
|
468
|
+
seg = ((eid != eid.shift()) | ((tick - tick.shift()) > 64)).cumsum()
|
|
469
|
+
proj = proj.assign(_seg=seg)
|
|
470
|
+
grouped = proj.groupby("_seg", sort=False)
|
|
471
|
+
first = grouped.first()
|
|
472
|
+
last_tick = grouped["tick"].last()
|
|
473
|
+
except Exception:
|
|
474
|
+
return [], []
|
|
475
|
+
|
|
476
|
+
grid = sorted({int(t) for t in (sample_ticks or [])})
|
|
477
|
+
|
|
478
|
+
throws: list[dict] = []
|
|
479
|
+
trajectories: list[dict] = []
|
|
480
|
+
for seg_id, seg_rows in grouped:
|
|
481
|
+
row = first.loc[seg_id]
|
|
482
|
+
gtype = grenade_projectile_to_type(row.get("grenade_type"))
|
|
483
|
+
if gtype is None:
|
|
484
|
+
continue
|
|
485
|
+
eid_val = row.get("grenade_entity_id")
|
|
486
|
+
throw_tick = int(row["tick"])
|
|
487
|
+
last = int(last_tick.loc[seg_id])
|
|
488
|
+
steamid = row.get("steamid")
|
|
489
|
+
throws.append({
|
|
490
|
+
"grenade_entity_id": int(eid_val) if eid_val is not None else None,
|
|
491
|
+
"grenade": gtype,
|
|
492
|
+
"tick": throw_tick,
|
|
493
|
+
"destroy_tick": last,
|
|
494
|
+
"steamid": steamid,
|
|
495
|
+
"X": float(row["x"]),
|
|
496
|
+
"Y": float(row["y"]),
|
|
497
|
+
"Z": float(row["z"]),
|
|
498
|
+
})
|
|
499
|
+
|
|
500
|
+
path = {
|
|
501
|
+
int(t): (x, y, z)
|
|
502
|
+
for t, x, y, z in zip(seg_rows["tick"], seg_rows["x"], seg_rows["y"], seg_rows["z"])
|
|
503
|
+
}
|
|
504
|
+
gticks = [t for t in grid if throw_tick <= t <= last]
|
|
505
|
+
if not gticks:
|
|
506
|
+
gticks = [throw_tick]
|
|
507
|
+
path.setdefault(throw_tick, (row["x"], row["y"], row["z"]))
|
|
508
|
+
xs: list[int] = []
|
|
509
|
+
ys: list[int] = []
|
|
510
|
+
zs: list[int] = []
|
|
511
|
+
for t in gticks:
|
|
512
|
+
pos = path.get(t) or _nearest_path(path, t)
|
|
513
|
+
xs.append(int(round(pos[0])))
|
|
514
|
+
ys.append(int(round(pos[1])))
|
|
515
|
+
zs.append(int(round(pos[2])))
|
|
516
|
+
# Trim the stationary at-rest tail (smoke/decoy entities linger at rest).
|
|
517
|
+
while len(xs) >= 2 and (
|
|
518
|
+
(xs[-1] - xs[-2]) ** 2 + (ys[-1] - ys[-2]) ** 2 + (zs[-1] - zs[-2]) ** 2
|
|
519
|
+
) <= 100:
|
|
520
|
+
xs.pop()
|
|
521
|
+
ys.pop()
|
|
522
|
+
zs.pop()
|
|
523
|
+
trajectories.append({
|
|
524
|
+
"grenade": gtype,
|
|
525
|
+
"steamid": steamid,
|
|
526
|
+
"start_tick": gticks[0],
|
|
527
|
+
"xs": xs,
|
|
528
|
+
"ys": ys,
|
|
529
|
+
"zs": zs,
|
|
530
|
+
})
|
|
531
|
+
|
|
532
|
+
return throws, trajectories
|