Counter-Strike 2 ∙ Code

Counter-Strike 2: Where are the callouts stored?

A deep dive into the Source 2 vmap format in search of callout definitions.

In CS:GO, if you wanted to extract the bounding boxes for callouts, all you had to do was parse the nav file with one of many well written libraries. The nav file contains all the walkable space information for bots to use in a level. This is where my search started.

The Old

First off, we need a map file. Finding them is easy, they’re in a similar location to where they were in Global Offensive: steamapps\common\Counter-Strike Global Offensive\game\csgo\maps.

The maps in Source 2 are packaged in ValvePak (vpk) files. All the files that make up a map (except for shared textures) are stored in these. So I opened up GCFScape and started poking around. I didn’t have to look long before I found the nav file. This is going to be easier than I thought!

I extracted it, pulled down a copy of the gonav library, loaded in my newly extracted de_vertigo.nav and was immediately greeted with an error. Shit. Time to dive into the source code.

Having a look through, and filling it with prints, I discovered that the new CS2 nav file has a major version of 35. The go parser only supports up to 15. That’s a lot of major versions. This is a bust.

Let’s just look at the file directly, then. I downloaded HxD and prepared myself to look at some raw hex data. Did I know what I was doing? No. Do I have a plan? Also no. Funnily enough, this didn’t help.

I then tried opening the files in VSCode and seeing if there were any plain text elements. I reasoned that a place name might be in raw text that I can CTRL+F for. Nope.

Okay, what if I grab a nav file from Global Offensive and try to compare the two? This is where I made some progress, but not the progress I was hoping for. In hindsight, this should have been the first thing I did.

A comparison between the two file formats

As you can see in the screenshot, the place names are in plain text in the CS:GO nav file. In CS2, they aren’t. This is a dead end.

The New

Source 2 brings new formats, and the complete restructure of map files. No longer are the callouts stored in the navmesh.

After doing a few hours of googling, I emerged on the other side with my sanity (mostly) intact armed with new information.

Place names in CS2 are defined as entities. Specifically, the env_cs_place entity. Next step? Hunting them down.

Enter Source 2 Viewer.

Source 2 Viewer Screenshot of de_vertigo

Here I can inspect the entities and get the metadata. Place names are what I’m after. I have the origin dimensions, now I just need a way of parsing the vmap file.

You can decompile the entire VPK with Source 2 Viewer, so we get the raw versions of the map files.

Parsing VMap Files

I’ve never written a parser before, you can probably tell by looking at the code.

Parsers essentially work by examining the file character by character using a set of select rules to extract a readable format. I found some great resources which helped massively during this process.

I now have a (mostly) typed representation of the VMap file. I can recurse through and fish out the env_cs_place entities. Fantastic.

function searchForEnvCsPlace(ob) {
    if (ob.children !== null) {
        ob.children.forEach((child) => {
            if (child.__type === "CMapEntity") {
                const entity = child as CMapEntity;

                if (entity.entity_properties.classname === "env_cs_place") {
                    envCsPlaces.push(entity);
                }
            } else if (child?.children) {
                searchForEnvCsPlace(child);
            }
        });
    }
}

Now all I’ve got to do is get the bounding boxes of the element and we’re all good!

Terminal Screenshot showcasing the extracted data

If you’re keen eyed, which I hadn’t been up until this point, you’ll notice that we only have one set of co-ordinates. The origin. This isn’t a bounding box.

The actual shape of the entity comes from the model. Another layer to get through.

It was at this point I shut my computer down and went to bed.

Parsing VMDL files

Taking a look at the VMDL file referenced in the env_cs_place entity, it looks like I might have found what I’m looking for.

Inside DmeVertexData, there’s a vector3_array with position data. This should be the 8 corners of the cube.

Screenshot of DmeVertexData

Now, I could use the same parser. It's using the same dmx format, I'd just need some extra types adding. But instead, I'm going to look for the "position$0" key and parse only that line.

let position: string | null = null;

for (const line of contents.split("\n")) {
		if (line.includes("position$0") && !line.includes("vertexFormat")) {
				position = line;
				break;
		}
}

Then, I can split the string and extract all the numbers inside. This is a bit of a nasty one liner, I'm not proud of it.

const positionArray = position.split("[").pop().split("]")[0].split(", ").map((x) => x.replace(/"/g, ""));

Now I just need to parse the values as numbers and add the positions to the origin from earlier. This should give us the bounding boxes for each callout.

// parse positions
const positions = positionArray.map((p) => {
		return p.trim().split(" ").map(Number);
});

let min_x = 0;
let min_y = 0;
let min_z = 0;
let max_x = 0;
let max_y = 0;
let max_z = 0;

// Get the mins and maxes
for (const [x, y, z] of positions) {
		if (x < min_x) min_x = x;
		if (y < min_y) min_y = y;
		if (z < min_z) min_z = z;
		if (x > max_x) max_x = x;
		if (y > max_y) max_y = y;
		if (z > max_z) max_z = z;
}

const [origin_x, origin_y, origin_z] = entity.origin;

const place = {
		name: entity.entity_properties.place_name,
		// Work out the bounding box
		min_x: origin_x + min_x,
		min_y: origin_y + min_y,
		min_z: origin_z + min_z,
		max_x: origin_x + max_x,
		max_y: origin_y + max_y,
		max_z: origin_z + max_z,
};

Once I'd got an array of places, I dumped it to JSON, then used json2csv to convert them to csv files.

The result? I have a Google Sheet with all of the callouts and their locations inside.

Validation

At this point I still had no idea if the co-ordinates were correct. I needed some data to validate it against.

The awpy library has a file in the repo called nav_info.csv. This contains the callouts for all CS:GO maps, extracted from nav files.

We'll use vertigo's ramp as a spot check.

My csv has 2 entries for A Ramp. Awpy's has 20. I'm going to get the minimum and maximum X, Y and Z values for the ramp callout from each file and see where they line up.

A Ramp Comparison

It's looking pretty good!

Wrapping Up

Feel free to use the data from my Google Sheet in your own projects. I've made the library open source for a reason.

github.com/hjbdev/cs2-vmap-tools

The process is a bit more convoluted than in CS:GO, but the placenames are by no means unreachable. If you make something cool with this library/data, feel free to Tweet it at me.

Let's Chat

email: harry [at] hjb [dot] dev
discord: indexgg