cs2df 3.0.2__tar.gz → 3.0.3__tar.gz

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.
@@ -4,6 +4,7 @@ dist/
4
4
  *.d.ts
5
5
  .DS_Store
6
6
  .omc/
7
+ .codegraph/
7
8
  __pycache__/
8
9
  *.py[cod]
9
10
  .pytest_cache/
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cs2df
3
- Version: 3.0.2
3
+ Version: 3.0.3
4
4
  Summary: Reference exporter & validator CLI for cs2-demo-format v3 (CS2 demo → ZIP data package)
5
5
  Project-URL: Homepage, https://github.com/Starfie1d1272/cs2-demo-format
6
6
  Project-URL: Repository, https://github.com/Starfie1d1272/cs2-demo-format
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "cs2df"
3
- version = "3.0.2"
3
+ version = "3.0.3"
4
4
  description = "Reference exporter & validator CLI for cs2-demo-format v3 (CS2 demo → ZIP data package)"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -5,7 +5,7 @@ demoparser2 / pandas stack installed. Heavy imports are deferred into the
5
5
  submodules that need them.
6
6
  """
7
7
 
8
- __version__ = "3.0.0"
8
+ __version__ = "3.0.3"
9
9
 
10
10
  SCHEMA_VERSION = "cs2-demo-format/3.0"
11
11
  EXPORTER_NAME = "cs2df"
@@ -121,7 +121,8 @@ def _active_event_round_number(round_model: _RoundModel, row: dict) -> int | Non
121
121
  return None
122
122
  tick = int(row.get("tick") or 0)
123
123
  window = round_model.window_for_round(n)
124
- if window is None or tick < window.freeze_end_tick or tick > window.end_tick:
124
+ event_end = round_model.event_end_tick(n)
125
+ if window is None or event_end is None or tick < window.freeze_end_tick or tick > event_end:
125
126
  return None
126
127
  return n
127
128
 
@@ -372,7 +373,8 @@ def build_bombs(raw: dict, players: PlayerDirectory, round_model: _RoundModel) -
372
373
  continue
373
374
  tick = int(r.get("tick") or 0)
374
375
  window = round_model.window_for_round(n)
375
- if window is None or tick < window.freeze_end_tick or tick > window.end_tick:
376
+ event_end = round_model.event_end_tick(n)
377
+ if window is None or event_end is None or tick < window.freeze_end_tick or tick > event_end:
376
378
  continue
377
379
  actor_sid = _sid(r.get("user_steamid") or r.get("steamid") or r.get("userid"))
378
380
  site = bomb_site_from_place(r.get("user_last_place_name"))
@@ -431,7 +433,8 @@ def build_grenades(raw: dict, players: PlayerDirectory, round_model: _RoundModel
431
433
  continue
432
434
  tick = int(r.get("tick") or 0)
433
435
  window = round_model.window_for_round(n)
434
- if window is None or tick < window.freeze_end_tick or tick > window.end_tick:
436
+ event_end = round_model.event_end_tick(n)
437
+ if window is None or event_end is None or tick < window.freeze_end_tick or tick > event_end:
435
438
  continue
436
439
  gtype = str(r.get("_grenade_type") or "")
437
440
  if gtype not in _GRENADE_TYPE_ENUM:
@@ -460,7 +463,7 @@ def build_grenades(raw: dict, players: PlayerDirectory, round_model: _RoundModel
460
463
  if not _is_valid_side(round_model.side_map.get((n, players.team(thrower_sid)), "unknown")):
461
464
  continue
462
465
  if destroy_tick is not None and (
463
- destroy_tick < tick or window is None or destroy_tick > window.end_tick
466
+ destroy_tick < tick or event_end is None or destroy_tick > event_end
464
467
  ):
465
468
  destroy_tick = None
466
469
 
@@ -489,13 +492,13 @@ def build_grenades(raw: dict, players: PlayerDirectory, round_model: _RoundModel
489
492
  for g in out:
490
493
  if g["grenade"] != "molotov" or g["destroyTick"] is not None:
491
494
  continue
492
- window = round_model.window_for_round(g["roundNumber"])
495
+ event_end = round_model.event_end_tick(g["roundNumber"])
493
496
  best = None
494
497
  best_d2 = None
495
498
  for e in expires:
496
499
  if e["used"] or e["rn"] != g["roundNumber"] or e["tick"] < g["effectTick"]:
497
500
  continue
498
- if window is not None and e["tick"] > window.end_tick:
501
+ if event_end is not None and e["tick"] > event_end:
499
502
  continue
500
503
  ep, gp = e["pos"], g["effectPosition"]
501
504
  d2 = (ep["x"] - gp["x"]) ** 2 + (ep["y"] - gp["y"]) ** 2
@@ -521,11 +524,11 @@ def build_grenades(raw: dict, players: PlayerDirectory, round_model: _RoundModel
521
524
  eid = g.pop("_entityId", None)
522
525
  if g["grenade"] != "smoke" or eid is None:
523
526
  continue
524
- window = round_model.window_for_round(g["roundNumber"])
527
+ event_end = round_model.event_end_tick(g["roundNumber"])
525
528
  for e in smoke_index.get((g["roundNumber"], eid), []):
526
529
  if e["tick"] < g["effectTick"]:
527
530
  continue
528
- if window is not None and e["tick"] > window.end_tick:
531
+ if event_end is not None and e["tick"] > event_end:
529
532
  continue
530
533
  g["destroyTick"] = e["tick"]
531
534
  break
@@ -351,7 +351,7 @@ def parse_demo(dem_path: str, *, sample_rate: int = 8, research: bool = False,
351
351
  stage_started = time.perf_counter()
352
352
  round_ends = g_round["round_end"]
353
353
  step = max(1, tickrate // max(1, sample_rate))
354
- replay_ticks = _build_sample_ticks(round_ends, round_freeze_ends, step)
354
+ replay_ticks = _build_sample_ticks(round_ends, round_freeze_ends, g_round["round_start"], step)
355
355
  replay_df = _safe_ticks_df(p, _REPLAY_PROPS, replay_ticks) if replay_ticks else None
356
356
  _record("parse.replayGrid", stage_started)
357
357
 
@@ -368,7 +368,8 @@ def parse_demo(dem_path: str, *, sample_rate: int = 8, research: bool = False,
368
368
  _p("duel windows (full tick)", 0.78)
369
369
  anchor_ticks = [int(r.get("tick") or 0) for r in deaths + hurts]
370
370
  duel_windows = _merge_windows(anchor_ticks, round_ends, round_freeze_ends,
371
- tickrate, window_before_ms, window_after_ms)
371
+ g_round["round_start"], tickrate,
372
+ window_before_ms, window_after_ms)
372
373
  duel_ticks: list[int] = []
373
374
  for start, end in duel_windows:
374
375
  duel_ticks.extend(range(start, end + 1))
@@ -444,34 +445,50 @@ def _freeze_by_round(round_freeze_ends: list[dict]) -> dict[int, int]:
444
445
  return out
445
446
 
446
447
 
448
+ def _round_starts_by_round(round_starts: list[dict]) -> dict[int, int]:
449
+ out: dict[int, int] = {}
450
+ for r in round_starts:
451
+ rn = int(r.get("total_rounds_played") or 0) + 1
452
+ t = int(r.get("tick") or 0)
453
+ if rn > 0 and t > 0 and rn not in out:
454
+ out[rn] = t
455
+ return out
456
+
457
+
447
458
  def _round_spans(round_ends: list[dict],
448
- round_freeze_ends: list[dict]) -> list[tuple[int, int]]:
449
- """[(freeze_end_tick, end_tick)] for rounds with a valid window."""
459
+ round_freeze_ends: list[dict],
460
+ round_starts: list[dict] | None = None) -> list[tuple[int, int]]:
461
+ """[(freeze_end_tick, event_end_tick)] for rounds with a valid window."""
450
462
  freeze = _freeze_by_round(round_freeze_ends)
463
+ starts = _round_starts_by_round(round_starts or [])
451
464
  spans: list[tuple[int, int]] = []
452
465
  for r in round_ends:
453
466
  rn = int(r.get("total_rounds_played") or 0)
454
467
  end_t = int(r.get("tick") or 0)
468
+ next_start_t = starts.get(rn + 1)
469
+ event_end_t = next_start_t - 1 if next_start_t is not None else end_t
455
470
  start_t = freeze.get(rn, 0)
456
- if start_t > 0 and end_t > start_t:
457
- spans.append((start_t, end_t))
471
+ if start_t > 0 and event_end_t > start_t:
472
+ spans.append((start_t, event_end_t))
458
473
  return spans
459
474
 
460
475
 
461
476
  def _build_sample_ticks(round_ends: list[dict], round_freeze_ends: list[dict],
477
+ round_starts: list[dict],
462
478
  step: int) -> list[int]:
463
- """Sorted unique sample ticks at interval `step` within active play."""
479
+ """Sorted unique sample ticks through the post-round tail."""
464
480
  ticks: list[int] = []
465
- for start_t, end_t in _round_spans(round_ends, round_freeze_ends):
466
- ticks.extend(range(start_t, end_t, step))
481
+ for start_t, end_t in _round_spans(round_ends, round_freeze_ends, round_starts):
482
+ ticks.extend(range(start_t, end_t + 1, step))
467
483
  return sorted(set(ticks))
468
484
 
469
485
 
470
486
  def _merge_windows(anchor_ticks: list[int], round_ends: list[dict],
471
- round_freeze_ends: list[dict], tickrate: int,
487
+ round_freeze_ends: list[dict], round_starts: list[dict],
488
+ tickrate: int,
472
489
  before_ms: int, after_ms: int) -> list[tuple[int, int]]:
473
490
  """Merged [start, end] full-tick combat windows, clamped to round spans."""
474
- spans = _round_spans(round_ends, round_freeze_ends)
491
+ spans = _round_spans(round_ends, round_freeze_ends, round_starts)
475
492
  if not spans or not anchor_ticks:
476
493
  return []
477
494
  before = (before_ms * tickrate) // 1000
@@ -57,6 +57,7 @@ class _RoundModel:
57
57
  # Indexes built in __post_init__; events are resolved per-row across every
58
58
  # builder, so these turn O(rounds) scans into O(1)/O(log rounds) lookups.
59
59
  _by_round: dict[int, _RoundWindow] = field(init=False, repr=False, default_factory=dict)
60
+ _event_end_by_round: dict[int, int] = field(init=False, repr=False, default_factory=dict)
60
61
  _sorted_starts: list[int] = field(init=False, repr=False, default_factory=list)
61
62
  _sorted_windows: list[_RoundWindow] = field(init=False, repr=False, default_factory=list)
62
63
 
@@ -65,6 +66,12 @@ class _RoundModel:
65
66
  ordered = sorted(self.windows, key=lambda w: w.start_tick)
66
67
  self._sorted_windows = ordered
67
68
  self._sorted_starts = [w.start_tick for w in ordered]
69
+ self._event_end_by_round = {}
70
+ for i, window in enumerate(ordered):
71
+ next_start = ordered[i + 1].start_tick if i + 1 < len(ordered) else None
72
+ self._event_end_by_round[window.round_number] = (
73
+ next_start - 1 if next_start is not None else window.end_tick
74
+ )
68
75
 
69
76
  def window_for_round(self, round_number: int) -> _RoundWindow | None:
70
77
  return self._by_round.get(round_number)
@@ -72,15 +79,19 @@ class _RoundModel:
72
79
  def has_round(self, round_number: int) -> bool:
73
80
  return round_number in self._by_round
74
81
 
82
+ def event_end_tick(self, round_number: int) -> int | None:
83
+ return self._event_end_by_round.get(round_number)
84
+
75
85
  def round_for_tick(self, tick: int) -> int | None:
76
86
  # Windows are sorted by start_tick and non-overlapping: the candidate is
77
- # the last window whose start_tick <= tick; confirm tick is within it
78
- # (ticks falling in inter-round gaps resolve to None, as before).
87
+ # the last window whose start_tick <= tick; confirm tick is before the
88
+ # next round start (or <= end_tick for the final round).
79
89
  i = bisect.bisect_right(self._sorted_starts, tick) - 1
80
90
  if i < 0:
81
91
  return None
82
92
  window = self._sorted_windows[i]
83
- return window.round_number if window.start_tick <= tick <= window.end_tick else None
93
+ event_end = self.event_end_tick(window.round_number)
94
+ return window.round_number if event_end is not None and window.start_tick <= tick <= event_end else None
84
95
 
85
96
  def round_for_event(self, row: dict) -> int | None:
86
97
  tick = int(row.get("tick") or 0)
@@ -178,8 +178,11 @@ def build_replay(raw: dict, players: PlayerDirectory, round_model: _RoundModel,
178
178
  })
179
179
 
180
180
  rounds_out: list[dict] = []
181
- for w in sorted(round_model.windows, key=lambda w: w.round_number):
182
- grid = np.arange(w.freeze_end_tick, w.end_tick, step, dtype="int64")
181
+ windows = sorted(round_model.windows, key=lambda w: w.round_number)
182
+ for i, w in enumerate(windows):
183
+ next_start_tick = windows[i + 1].start_tick if i + 1 < len(windows) else None
184
+ stop_tick = next_start_tick if next_start_tick is not None else w.end_tick + 1
185
+ grid = np.arange(w.freeze_end_tick, stop_tick, step, dtype="int64")
183
186
  if len(grid) == 0:
184
187
  continue
185
188
  sl = _slice_by_tick(df, tick_values, int(grid[0]), int(grid[-1]))
@@ -339,7 +339,8 @@ def _check_shots_stream(shots: dict, n_players: int, round_set: set,
339
339
  break
340
340
  ticks = decode_delta(t.get("tick", []))
341
341
  rd = rounds_by_number.get(t.get("roundNumber"))
342
- if rd and ticks and not all(rd.get("freezeEndTick", 0) <= tk <= rd.get("endTick", 0) for tk in ticks):
342
+ event_end = _round_event_end(rounds_by_number, t.get("roundNumber"))
343
+ if rd and ticks and event_end is not None and not all(rd.get("freezeEndTick", 0) <= tk <= event_end for tk in ticks):
343
344
  err(f"{label}: decoded ticks fall outside the round window")
344
345
 
345
346
 
@@ -370,7 +371,8 @@ def _check_replay_stream(replay: dict, n_players: int, round_set: set,
370
371
  step = rd_obj.get("tickStep", 1)
371
372
  if fc and rd:
372
373
  last_tick = start + (fc - 1) * step
373
- if start < rd.get("freezeEndTick", 0) or last_tick > rd.get("endTick", 0):
374
+ event_end = _round_event_end(rounds_by_number, rn)
375
+ if event_end is not None and (start < rd.get("freezeEndTick", 0) or last_tick > event_end):
374
376
  err(f"{label}: frame grid [{start}, {last_tick}] outside round window")
375
377
  for pi, track in enumerate(rd_obj.get("players", [])):
376
378
  tlabel = f"{label} players[{pi}]"
@@ -414,7 +416,8 @@ def _check_duels_stream(duels: dict, n_players: int, round_set: set,
414
416
  step = w.get("tickStep", 1)
415
417
  if fc and rd:
416
418
  last_tick = start + (fc - 1) * step
417
- if start < rd.get("freezeEndTick", 0) or last_tick > rd.get("endTick", 0):
419
+ event_end = _round_event_end(rounds_by_number, rn)
420
+ if event_end is not None and (start < rd.get("freezeEndTick", 0) or last_tick > event_end):
418
421
  err(f"{label}: frame grid [{start}, {last_tick}] outside round window")
419
422
  anchors = w.get("anchors", [])
420
423
  if not anchors:
@@ -499,7 +502,7 @@ def _check_tick_windows(name: str, rows: list, rounds_by_number: dict, err,
499
502
  if not isinstance(tick, int):
500
503
  continue
501
504
  start = round_row.get("freezeEndTick")
502
- end = round_row.get("endTick")
505
+ end = _round_event_end(rounds_by_number, row.get("roundNumber"))
503
506
  if not isinstance(start, int) or not isinstance(end, int):
504
507
  continue
505
508
  if tick < start or tick > end:
@@ -510,6 +513,17 @@ def _check_tick_windows(name: str, rows: list, rounds_by_number: dict, err,
510
513
  err(f"{name}.json: {len(bad)} row(s) have ticks outside their round window; sample: {sample}")
511
514
 
512
515
 
516
+ def _round_event_end(rounds_by_number: dict, round_number) -> int | None:
517
+ round_row = rounds_by_number.get(round_number)
518
+ if not isinstance(round_row, dict):
519
+ return None
520
+ next_round = rounds_by_number.get(round_number + 1) if isinstance(round_number, int) else None
521
+ if isinstance(next_round, dict) and isinstance(next_round.get("startTick"), int):
522
+ return next_round["startTick"] - 1
523
+ end_tick = round_row.get("endTick")
524
+ return end_tick if isinstance(end_tick, int) else None
525
+
526
+
513
527
  def _check_bomb_lifecycle(bombs: list, err):
514
528
  by_round: dict[object, list[dict]] = {}
515
529
  for b in bombs:
@@ -0,0 +1,74 @@
1
+ from __future__ import annotations
2
+
3
+ import pandas as pd
4
+
5
+ from cs2df.events import PlayerDirectory
6
+ from cs2df.parse import _build_sample_ticks
7
+ from cs2df.rounds import _RoundModel, _RoundWindow
8
+ from cs2df.streams import build_replay
9
+
10
+
11
+ def _replay_row(tick: int) -> dict:
12
+ return {
13
+ "steamid": "76561198000000001",
14
+ "tick": tick,
15
+ "active_weapon_name": "ak47",
16
+ "last_place_name": "Middle",
17
+ "inventory": [],
18
+ "X": tick,
19
+ "Y": tick + 1,
20
+ "Z": tick + 2,
21
+ "yaw": 90,
22
+ "pitch": 0,
23
+ "balance": 800,
24
+ "current_equip_value": 2700,
25
+ "health": 100,
26
+ "armor": 100,
27
+ "flash_duration": 0,
28
+ "has_defuser": False,
29
+ }
30
+
31
+
32
+ def test_replay_round_extends_to_sample_before_next_round_start():
33
+ players = PlayerDirectory([
34
+ {"steamId64": "76561198000000001", "name": "p1", "teamKey": "teamA"},
35
+ ])
36
+ round_model = _RoundModel(
37
+ windows=[
38
+ _RoundWindow(round_number=1, start_tick=50, freeze_end_tick=100, end_tick=200),
39
+ _RoundWindow(round_number=2, start_tick=300, freeze_end_tick=360, end_tick=460),
40
+ ],
41
+ side_map={},
42
+ )
43
+ raw = {
44
+ "replay_df": pd.DataFrame([_replay_row(tick) for tick in range(100, 360, 16)]),
45
+ "grenade_trajectories": [],
46
+ }
47
+
48
+ replay = build_replay(raw, players, round_model, tickrate=128, sample_rate=8)
49
+
50
+ assert replay is not None
51
+ first_round = replay["rounds"][0]
52
+ last_tick = first_round["startTick"] + (first_round["frameCount"] - 1) * first_round["tickStep"]
53
+ assert last_tick == 292
54
+ assert last_tick < 300
55
+
56
+
57
+ def test_parse_replay_ticks_include_post_round_tail_before_next_start():
58
+ round_ends = [
59
+ {"total_rounds_played": 1, "tick": 200},
60
+ {"total_rounds_played": 2, "tick": 460},
61
+ ]
62
+ round_freeze_ends = [
63
+ {"total_rounds_played": 0, "tick": 100},
64
+ {"total_rounds_played": 1, "tick": 360},
65
+ ]
66
+ round_starts = [
67
+ {"total_rounds_played": 0, "tick": 50},
68
+ {"total_rounds_played": 1, "tick": 300},
69
+ ]
70
+
71
+ ticks = _build_sample_ticks(round_ends, round_freeze_ends, round_starts, step=16)
72
+
73
+ assert 292 in ticks
74
+ assert 300 not in ticks
@@ -0,0 +1,50 @@
1
+ from __future__ import annotations
2
+
3
+ from cs2df.events import PlayerDirectory, build_kills
4
+ from cs2df.rounds import _RoundModel, _RoundWindow
5
+
6
+
7
+ def test_round_for_tick_assigns_post_round_tail_to_previous_round():
8
+ round_model = _RoundModel(
9
+ windows=[
10
+ _RoundWindow(round_number=1, start_tick=50, freeze_end_tick=100, end_tick=200),
11
+ _RoundWindow(round_number=2, start_tick=300, freeze_end_tick=360, end_tick=460),
12
+ ],
13
+ side_map={},
14
+ )
15
+
16
+ assert round_model.round_for_tick(200) == 1
17
+ assert round_model.round_for_tick(250) == 1
18
+ assert round_model.round_for_tick(299) == 1
19
+ assert round_model.round_for_tick(300) == 2
20
+
21
+
22
+ def test_kills_after_round_end_before_next_start_are_kept_in_previous_round():
23
+ players = PlayerDirectory([
24
+ {"steamId64": "76561198000000001", "name": "attacker", "teamKey": "teamA"},
25
+ {"steamId64": "76561198000000002", "name": "victim", "teamKey": "teamB"},
26
+ ])
27
+ round_model = _RoundModel(
28
+ windows=[
29
+ _RoundWindow(round_number=1, start_tick=50, freeze_end_tick=100, end_tick=200),
30
+ _RoundWindow(round_number=2, start_tick=300, freeze_end_tick=360, end_tick=460),
31
+ ],
32
+ side_map={(1, "teamA"): "t", (1, "teamB"): "ct"},
33
+ )
34
+ raw = {
35
+ "deaths": [{
36
+ "tick": 250,
37
+ "user_steamid": "76561198000000002",
38
+ "attacker_steamid": "76561198000000001",
39
+ "weapon": "ak47",
40
+ "user_X": 1,
41
+ "user_Y": 2,
42
+ "user_Z": 3,
43
+ }],
44
+ }
45
+
46
+ kills = build_kills(raw, players, round_model)
47
+
48
+ assert len(kills) == 1
49
+ assert kills[0]["roundNumber"] == 1
50
+ assert kills[0]["tick"] == 250
@@ -0,0 +1,23 @@
1
+ from __future__ import annotations
2
+
3
+ import subprocess
4
+ import sys
5
+ from pathlib import Path
6
+
7
+
8
+ ROOT = Path(__file__).resolve().parents[2]
9
+
10
+
11
+ def test_check_versions_script_accepts_current_release_versions():
12
+ script = ROOT / "tools" / "check_versions.py"
13
+
14
+ result = subprocess.run(
15
+ [sys.executable, str(script)],
16
+ cwd=ROOT,
17
+ text=True,
18
+ stdout=subprocess.PIPE,
19
+ stderr=subprocess.PIPE,
20
+ check=False,
21
+ )
22
+
23
+ assert result.returncode == 0, result.stdout + result.stderr
@@ -30,7 +30,7 @@ wheels = [
30
30
 
31
31
  [[package]]
32
32
  name = "cs2df"
33
- version = "3.0.2"
33
+ version = "3.0.3"
34
34
  source = { editable = "." }
35
35
  dependencies = [
36
36
  { name = "demoparser2" },
File without changes
File without changes
File without changes
File without changes
File without changes