Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
1a3bf9f
Fix bytes to vec for meshes
JanEricNitschke Mar 4, 2025
adaef20
Fix ruff warning
JanEricNitschke Mar 4, 2025
34c7db4
Add option to also parse player clippings
JanEricNitschke Mar 4, 2025
42d4a2b
Merge pull request #4 from JanEricNitschke/fix-mesh-parsing
JanEricNitschke Mar 4, 2025
470efa3
Extract radar and map info for more maps
JanEricNitschke Mar 5, 2025
a6fcf65
Extract more detailed level information for maps
JanEricNitschke Mar 5, 2025
25c7187
Remove temp panorama folder again
JanEricNitschke Mar 5, 2025
07c8897
Fix extraction of spawn points relevant for competitive
JanEricNitschke Mar 5, 2025
6b1f8e5
Also parse csgo_community_addons
JanEricNitschke May 8, 2025
39873c7
Add workaround for dogtown data
JanEricNitschke May 8, 2025
cd34923
Catch parsing errors in general
JanEricNitschke May 8, 2025
78cbc12
Stream vphys output to avoid OOM errors
JanEricNitschke Jul 22, 2025
95c1be2
Only use streaming if we actually go OOM
JanEricNitschke Jul 23, 2025
73432d8
Always do a faster streaming approach
JanEricNitschke Jul 24, 2025
7bb7c32
Ensure utf8
JanEricNitschke Oct 2, 2025
a7bb09e
Handle new Version 36 nav meshes
JanEricNitschke Jan 23, 2026
90667d1
Fix vent parsing
JanEricNitschke Apr 29, 2026
ddcec5a
Add volume parsing
JanEricNitschke Apr 29, 2026
52b49cf
Update pre-commit
JanEricNitschke Apr 29, 2026
3f6c7a5
Add more logging to generate maps script
JanEricNitschke Apr 29, 2026
f1295e2
Fix plantzones
JanEricNitschke Apr 29, 2026
abb1f8c
Fix generate-maps to prefer earlier images
JanEricNitschke Apr 29, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/artifacts.yml
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ jobs:
if: steps.update_patch.outputs.wasPatchUpdated == 'true'
run: |
Get-ChildItem -Force
.\scripts\generate-maps.ps1 -inputPath "cs_go\game\csgo\pak01_dir.vpk"
.\scripts\generate-maps.ps1 -inputPath "cs_go\game\csgo\"

- name: Generate Nav
if: steps.update_patch.outputs.wasPatchUpdated == 'true'
Expand Down Expand Up @@ -146,7 +146,7 @@ jobs:
uv run ruff format .
git add awpy/data/__init__.py
git commit -m "Update data with latest patch info ($env:LATEST_PATCH_ID)"

if ($LASTEXITCODE -eq 0) {
# Push changes and set upstream if necessary
git push --set-upstream origin $targetBranch
Expand Down
8 changes: 4 additions & 4 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
exclude: docs/
repos:
- repo: 'https://github.com/pre-commit/pre-commit-hooks'
rev: v5.0.0
rev: v6.0.0
hooks:
- id: check-yaml
language: python
Expand All @@ -26,18 +26,18 @@ repos:
- id: check-builtin-literals
language: python
- repo: 'https://github.com/charliermarsh/ruff-pre-commit'
rev: v0.9.9
rev: v0.15.12
hooks:
- id: ruff
args:
- '--fix'
- '--exit-non-zero-on-fix'
- repo: https://github.com/astral-sh/uv-pre-commit
rev: 0.6.3
rev: 0.11.8
hooks:
- id: uv-lock
- repo: https://github.com/crate-ci/typos
rev: v1.30.0
rev: v1.45.2
hooks:
- id: typos
args: []
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,4 +93,4 @@ The name "Awpy" is due to [Nick Wan](https://www.twitch.tv/nickwan_datasci) -- w

Awpy was first built on the amazing work done in the [demoinfocs-golang](https://github.com/markus-wa/demoinfocs-golang) Golang library. We now rely on [demoparser2](https://github.com/LaihoE/demoparser) for parsing, which is another fantastic parsing project, built specifically for Python.

Awpy's team includes JanEric, adi and hojlund, who you can find in the Awpy Discord. Their work, among others, is crucial to Awpy's continued success! To contribute to Awpy, please visit [CONTRIBUTING](https://github.com/pnxenopoulos/awpy/blob/main/CONTRIBUTING.md).
Awpy's team includes JanEric, adi and hojlund, who you can find in the Awpy Discord. Their work, among others, is crucial to Awpy's continued success! To contribute to Awpy, please visit [CONTRIBUTING](https://github.com/pnxenopoulos/awpy/blob/main/CONTRIBUTING.md).
5 changes: 4 additions & 1 deletion awpy/__init__.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
"""Provides data parsing, analytics and visualization capabilities for CSGO data."""

from awpy.buyzone import Buyzone
from awpy.callout import Callout
from awpy.demo import Demo
from awpy.nav import Nav
from awpy.plantzone import Plantzone
from awpy.spawn import Spawns

__version__ = "2.0.0"
__all__ = ["Demo", "Nav", "Spawns"]
__all__ = ["Buyzone", "Callout", "Demo", "Nav", "Plantzone", "Spawns"]
131 changes: 131 additions & 0 deletions awpy/buyzone.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
"""Module to parse .vents files to get map callouts."""

from __future__ import annotations

import pathlib
from dataclasses import dataclass
from enum import Enum
from typing import cast

import awpy.vector
from awpy.visibility import Triangle, VphysParser
from awpy.volume import VentData, Volume, VolumeDict


class BuyzoneDict(VolumeDict):
"""Typed dictionary for Buyzone."""

associated_team: str


class AssociatedTeam(Enum):
"""Enum for Buyzone designations."""

CT = "CT"
T = "T"
UNKNOWN = "UNKNOWN"

@classmethod
def from_teamnum_integer(cls, teamnum: int) -> AssociatedTeam:
"""Create a designation from the `teamnum` integer in the entities data.

Normally, 3 should be CT and 2 should be T.

Args:
teamnum (int): Value of `teamnum` in the entities data.

Raises:
ValueError: If the teamnum is not 2 or 3.

Returns:
BuyzoneDesignation: The BuyzoneDesignation enum value.
"""
match teamnum:
case 3:
return cls.CT
case 2:
return cls.T
case _:
return cls.UNKNOWN


@dataclass
class Buyzone(Volume):
"""Buyzone."""

associated_team: AssociatedTeam

def __repr__(self) -> str:
"""String representation of the callout."""
return f"Buyzone(associated_team={self.associated_team}, origin={self.origin}, triangles={len(self.triangles)})"

def to_dict(self) -> BuyzoneDict:
"""Converts the spawns to a dictionary."""
return {
"associated_team": self.associated_team.value,
"inside_point": self.inside_point.to_dict(),
"origin": self.origin.to_dict(),
"triangles": [triangle.to_dict() for triangle in self.triangles],
}

@staticmethod
def from_dict(buyzone_dict: BuyzoneDict) -> Buyzone:
"""Convert a dictionary to a Buyzone object.

Args:
buyzone_dict (BuyzoneDict): Dictionary representation of a Buyzone.

Returns:
Bomnbsite: Buyzone object created from the dictionary.
"""
return Buyzone(
associated_team=AssociatedTeam(buyzone_dict["associated_team"]),
origin=awpy.vector.Vector3.from_dict(buyzone_dict["origin"]),
inside_point=awpy.vector.Vector3.from_dict(buyzone_dict["inside_point"]),
triangles=[Triangle.from_dict(triangle) for triangle in buyzone_dict["triangles"]],
)

@classmethod
def from_data(cls, vents_data: VentData, phys_blocks: dict[str, str]) -> list[Buyzone]:
"""Parse the content of a vents file into Spawns information.

Args:
vents_data (VentData): Data of the the .vents file.
phys_blocks (dict[str, str]): Extracted PHYS blocks from .vmdl_c files.

Returns:
Spawns: A Spawns object with the parsed data.
"""
buyzones: list[Buyzone] = []
for properties in vents_data.values():
if properties.get("classname") != "func_buyzone" or "2v2" in properties.get("targetname", ""): # pyright: ignore[reportOperatorIssue]
continue

associated_team = AssociatedTeam.from_teamnum_integer(properties["teamnum"]) # pyright: ignore[reportArgumentType]
x, y, z = properties["origin"] # pyright: ignore[reportGeneralTypeIssues]
origin = awpy.vector.Vector3(x=x, y=y, z=z) # pyright: ignore[reportArgumentType]

model_name = cast("str", properties.get("model", "")).replace("\\", "/")
model = pathlib.Path(model_name).stem

# Get the PHYS block for this callout
phys_block = phys_blocks.get(model)
if not phys_block:
continue

triangles: list[Triangle] = VphysParser(
vphys_file=None, vphys_data=phys_block, include_everything=True
).triangles

triangles = [
Triangle(p1=triangle.p1 + origin, p2=triangle.p2 + origin, p3=triangle.p3 + origin)
for triangle in triangles
]

inside_point = cls.get_inside_point(triangles)

buyzones.append(
Buyzone(associated_team=associated_team, origin=origin, inside_point=inside_point, triangles=triangles)
)

return buyzones
112 changes: 112 additions & 0 deletions awpy/callout.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
"""Module to parse .vents files to get map callouts."""

from __future__ import annotations

import pathlib
from dataclasses import dataclass
from typing import Self, cast

import awpy.vector
from awpy.visibility import Triangle, VphysParser
from awpy.volume import VentData, Volume, VolumeDict


class CalloutDict(VolumeDict):
"""Typed dictionary for callout."""

callout: str


@dataclass
class Callout(Volume):
"""Callout."""

callout: str

def __repr__(self) -> str:
"""String representation of the callout."""
return f"Callout(callout={self.callout}, origin={self.origin}, triangles={len(self.triangles)})"

def to_dict(self) -> CalloutDict:
"""Converts the spawns to a dictionary."""
return {
"callout": self.callout,
"inside_point": self.inside_point.to_dict(),
"origin": self.origin.to_dict(),
"triangles": [triangle.to_dict() for triangle in self.triangles],
}

@classmethod
def from_dict(cls, callout_dict: CalloutDict) -> Self:
"""Convert a dictionary to a Callout object.

Args:
callout_dict (CalloutDict): Dictionary representation of a Callout.

Returns:
Callout: Callout object created from the dictionary.
"""
return cls(
callout=callout_dict["callout"],
origin=awpy.vector.Vector3.from_dict(callout_dict["origin"]),
inside_point=awpy.vector.Vector3.from_dict(callout_dict["inside_point"]),
triangles=[Triangle.from_dict(triangle) for triangle in callout_dict["triangles"]],
)

@staticmethod
def callout_from_position(player_pos: awpy.vector.Vector3, places: list[Callout]) -> str | None:
"""Get the callout from a position.

Args:
player_pos (awpy.vector.Vector3): The position of the player.
places (list[Callout]): The list of callouts to check against.
"""
for place in places:
if place.collision_checker.is_visible(player_pos, place.inside_point):
return place.callout
return None

@classmethod
def from_data(cls, vents_data: VentData, phys_blocks: dict[str, str]) -> list[Callout]:
"""Parse the content of a vents file into Spawns information.

Args:
vents_data (VentData): Data of the the .vents file.
phys_blocks (dict[str, str]): Extracted PHYS blocks from .vmdl_c files.

Returns:
Spawns: A Spawns object with the parsed data.
"""
callouts: list[Callout] = []
for properties in vents_data.values():
if properties.get("classname") != "env_cs_place":
continue

callout_name: str = properties["place_name"] # pyright: ignore[reportAssignmentType]
x, y, z = properties["origin"] # pyright: ignore[reportGeneralTypeIssues]
origin = awpy.vector.Vector3(x=x, y=y, z=z) # pyright: ignore[reportArgumentType]

model_name = cast("str", properties.get("model", "")).replace("\\", "/")
model = pathlib.Path(model_name).stem

# Get the PHYS block for this callout
phys_block = phys_blocks.get(model)
if not phys_block:
continue

triangles: list[Triangle] = VphysParser(
vphys_file=None, vphys_data=phys_block, include_everything=True
).triangles

triangles = [
Triangle(p1=triangle.p1 + origin, p2=triangle.p2 + origin, p3=triangle.p3 + origin)
for triangle in triangles
]

inside_point = cls.get_inside_point(triangles)

callouts.append(
Callout(callout=callout_name, origin=origin, inside_point=inside_point, triangles=triangles)
)

return callouts
49 changes: 43 additions & 6 deletions awpy/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,9 @@
import awpy.data
import awpy.data.map_data
import awpy.data.utils
from awpy import Demo, Nav, Spawns
from awpy import Buyzone, Callout, Demo, Nav, Plantzone, Spawns
from awpy.visibility import VphysParser
from awpy.volume import extract_phys_blocks, parse_vents_file_to_dict


@click.group(name="awpy")
Expand Down Expand Up @@ -83,7 +84,40 @@ def parse_spawn(vent_file: Path, *, outpath: Path | None = None) -> None:
outpath = vent_file.with_suffix(".json")
spawns_data = Spawns.from_vents_file(vent_file)
spawns_data.to_json(path=outpath)
logger.success(f"Spawns file saved to {vent_file.with_suffix('.json')}, {spawns_data}")
logger.success(f"Spawns file saved to {outpath}, {spawns_data}")


@awpy_cli.command(
name="volumes",
help="Parse volumes such as callouts, bombsites, buyzones from a Counter-Strike 2 .vent file and physics volumes.",
hidden=True,
)
@click.option("--vent_file", type=click.Path(exists=True), required=True)
@click.option("--models_file", type=click.Path(exists=True), required=True)
@click.option("--outdir", type=click.Path(), help="Base directory to save the volumes to.")
@click.option("--outname", type=str, help="Filename to save the volumes with.")
def parse_volumes(*, vent_file: Path, models_file: Path, outdir: Path | str, outname: str) -> None:
"""Parse callouts from a Counter-Strike 2 .vent file and physics volumes."""
vent_file = Path(vent_file)
models_file = Path(models_file)
vents_data = parse_vents_file_to_dict(Path(vent_file).read_text())
phys_blocks = extract_phys_blocks(Path(models_file).read_text())

callout_dir = Path(outdir) / "callouts"
callout_dir.mkdir(parents=True, exist_ok=True)
callout_data = Callout.from_data(vents_data, phys_blocks)
Callout.multiple_to_json(callout_data, path=callout_dir / f"{outname}.json")

plantzone_dir = Path(outdir) / "plantzones"
plantzone_dir.mkdir(parents=True, exist_ok=True)
plantzone_data = Plantzone.from_data(vents_data, phys_blocks)
Plantzone.multiple_to_json(plantzone_data, path=plantzone_dir / f"{outname}.json")

buyzone_dir = Path(outdir) / "buyzones"
buyzone_dir.mkdir(parents=True, exist_ok=True)
buyzone_data = Buyzone.from_data(vents_data, phys_blocks)
Buyzone.multiple_to_json(buyzone_data, path=buyzone_dir / f"{outname}.json")
logger.success("Volume files saved.")


@awpy_cli.command(name="nav", help="Parse a Counter-Strike 2 .nav file.", hidden=True)
Expand All @@ -96,7 +130,7 @@ def parse_nav(nav_file: Path, *, outpath: Path | None = None) -> None:
if not outpath:
outpath = nav_file.with_suffix(".json")
nav_mesh.to_json(path=outpath)
logger.success(f"Nav mesh saved to {nav_file.with_suffix('.json')}, {nav_mesh}")
logger.success(f"Nav mesh saved to {outpath}, {nav_mesh}")


@awpy_cli.command(name="mapdata", help="Parse Counter-Strike 2 map images.", hidden=True)
Expand All @@ -108,16 +142,19 @@ def parse_mapdata(overview_dir: Path) -> None:
overview_dir_err_msg = f"{overview_dir} is not a directory."
raise NotADirectoryError(overview_dir_err_msg)
map_data = awpy.data.map_data.map_data_from_vdf_files(overview_dir)
awpy.data.map_data.update_map_data_file(map_data, "map-data.json")
awpy.data.map_data.update_map_data_file(map_data, Path("map-data.json"))
logger.success("Map data saved to map_data.json")


@awpy_cli.command(name="tri", help="Parse triangles (*.tri) from a .vphys file.", hidden=True)
@click.argument("vphys_file", type=click.Path(exists=True))
@click.option("--outpath", type=click.Path(), help="Path to save the parsed triangle.")
def generate_tri(vphys_file: Path, *, outpath: Path | None = None) -> None:
@click.option(
"--include_player_clippings", is_flag=True, default=False, help="Include player clippings in tri generation."
)
def generate_tri(vphys_file: Path, *, outpath: Path | None = None, include_player_clippings: bool) -> None:
"""Parse a .vphys file into a .tri file."""
vphys_file = Path(vphys_file)
vphys_parser = VphysParser(vphys_file)
vphys_parser = VphysParser(vphys_file, including_player_clippings=include_player_clippings)
vphys_parser.to_tri(path=outpath)
logger.success(f"Tri file saved to {outpath}")
Loading