This guide is a work in progress!

Not all information is complete, and there are likely to be errors.

Prelude

This book aims to describe the process of how to implement CS2 Demo parsing and streaming.

As of writing (Apr 2024), there is very little information on how read and process this format. We intend to detail it here, collecting information from a number of implementations and our own research.

Referencing this guide will help us double/triple check our implementation, and we hope that developers can implement and understand this format/protocol and not go through the pain that we did.

Resources

To make understanding easier, I would recommend a few resources:

Contributing

This book is not perfect. If you spot a mistake, wish to update or refactor wording your more than welcome too. We use MDBook, and it's all written in Markdown, so it's easy to edit. Open an Issue or a Pull-Request on GitHub and We'll give it a look!

Questions & Feedback

If you have any questions or feedback, please feel free to reach out to me on discord (samhdev)

Alternatively, an unrelated project has a Discord Server that sometimes helps with demo parsing. They're a friendly bunch of people who are interested in the topic.

Authors

Glossary

Structural Terms

TermDescription
Magic HeaderA header determining the file type
FrameA record within a demo
Frame HeaderA description of a demo frame
PacketA recorded game packet within a demo
TickAn measurement of time, 64 per second

Entity Terms

TermDescription
ClassA type of entity ( e.g. Player)
EntityAn instance of a class within the server
Send-TablesA version agnostic description of entity structure
Symbol IndexA string within the send-tables
SerializerA layout description of a class or set of fields
FieldA field within a serializer, describing its type and how to decode it
Field DecoderA decoder for a given field
PropertyA instance of a field within a entity, perhaps containing nested properties
Field PathA path to a specific field within a tree of serializers, used to set or retrieve a value
Entity UpdateA server packet to describe creation, deletion and updating entity properties

Counter-strike Demos

Demos are .dem files containing recorded gameplay from either a server or client perspective. They are an outer wrapper over the game's ProtoBuf protocol, containing the data needed to accurately parse entity and event data contained within.

Demos consist of a Magic Header and a series of frames. We will discuss this below.

Magic File Header

Every demo file begins with a magic header. This header enables us to confirm if a .dem file is indeed a demo and to distinguish between Source-1 and Source-2 demos.

For Source-2, the following ascii bytes should be present.

b'PBDEMS2\0`

This header is seven characters long but eight bytes in length (including the null terminator). We think it means "ProtoBuf Demo Source2"

If you want to determine if a Demo is Source-1, you can check the following 8 bytes: b'HL2DEMO\0'

Unknown Header Bytes

After reading the magic header, there are eight bytes that have an unknown purpose.

We believe it might contain some verification info, maybe the length of data, but we were unable to find any correlation.

Make sure you skip these!

Frames

After the file header, demos consist of multiple frames. They contain both game-packets, parsing information and other demo data.

Frames consist of two parts, a header and a payload. Frame payloads can (but not always) be compressed.

Frame Header

A frame header has three fields, all three of which are in the Protobuf Varint format.

FieldDescription
CommandThe type of frame payload
TickThe tick at which this frame occurs
LengthThe length of the payload (in bytes)

Aside: Protobuf VarInts

Variable-width integers, or varints, are at the core of the wire format. They allow encoding unsigned 64-bit integers using anywhere between one and ten bytes, with small values using fewer bytes. Each byte in the varint has a continuation bit that indicates if the byte that follows it is part of the varint. This is the most significant bit (MSB) of the byte (sometimes also called the sign bit). The lower 7 bits are a payload; the resulting integer is built by appending together the 7-bit payloads of its constituent bytes.

Source

Implementation

Frame Commands

A Frame commands describe the content of a frame payload.

They are specified in the demo.EDemoCommands enum, which can be found here.

Here are some of the notable ones:

CommandValueDescription
DEM_Stop0End of Demo Frames
DEM_FileHeader1A header, describe the server name, map, build version, etc..
DEM_FileInfo2A "footer", often at the end of the demo containing playback information
DEM_SyncTick3?
DEM_SendTables4Serializers for Entity Data (We'll discuss this later)
DEM_ClassInfo5Entity Class names and IDs mapping to serializers (We'll discuss this later)
DEM_StringTables 6String tables (We'll discuss this later)
DEM_Packet7Game Packet
DEM_SignonPacket 8Game Packet, but for the server->client init
DEM_ConsoleCmd9A Server-side console command
DEM_UserCmd12A Client-side user-action, include sub-tick button and movement data
DEM_FullPacket13Game Packet, but it has string-tables

Defined in the protobuf, There are two variants that are not valid commands.

CommandValueDescription
DEM_Max18the max (N < MAX) command value
DEM_IsCompressed64a flag denoting if the frame is compressed

These represent two things:

  1. The range of commands is 0..18 exclusive.
  2. The sixth bit (log2(64) = 6), is an additive flag representing if the frame is compressed.

Note, the current values mean that the command field will only ever be 1 byte in length. Optimise at peril of volvo changing it

Once you know if a frame is compressed, you can negate (set to low) the DEM_IsCompressed bit, and parse the command correctly.

// e.g. A Compressed DEM_SignonPacket
72 & (!64) = 8        

Frame Ticks

The tick field specifies the tick that a given frame happens. Ticks should always be positive integers, and frames should be in a linear format, e.g. never go back in time.

There is one exception to this, during the sign-on phase, frames with the tick 4294967295 (2^32–1) denote a tick before time began.

Frame Compression

Frames may be compressed. The 6th bit of the command field signifies whether a frame is compressed. When set, the frame's payload is compressed using the Snappy compression format.

Be aware that the snappy compression format and the snappy frame format are two different things; Demos use the former. Make sure your library supports it and your using the correct version.

Frame Payload

Frame payloads directly follow the frame header and consist of length bytes. These bytes might be compressed (see above) and must be decompressed if necessary.

The payload is a Protobuf message whose format is specified by the command field in the header. The following table maps commands to their corresponding Protobuf payload types:

CommandPayload MessageNotes
DEM_StopCDemoStopEmpty
DEM_FileHeaderCDemoFileHeader
DEM_FileInfoCDemoFileInfo
DEM_SyncTickCDemoSyncTickEmpty
DEM_SendTablesCDemoSendTablesBytes of another protobuf message
DEM_ClassInfoCDemoClassInfo
DEM_StringTablesCDemoStringTables
DEM_PacketCDemoPacket
DEM_SignonPacketCDemoPacket
DEM_ConsoleCmdCDemoConsoleCmd
DEM_UserCmdCDemoUserCmd
DEM_FullPacketCDemoFullPacketCDemoStringTables + CDemoPacket

Next Steps

That's it for demo frames, now we can parse them into packets.

Game Packets

Certain frames contain game packets, which are recorded server game packets. While stored as byte arrays within these frames, the data is actually encoded in a bitstream format.

Frames contain multiple packets, concatenated together. Within a packet is a single protobuf message, encoded as bytes, we'll discuss this below.

Layout

Packets have three fields:

FieldTypeDescription
packet_idu_bit_varthe packet type
packet_sizevarintthe length of data in the packet (in bytes)
packet_databytesthe actual packet data
Make sure your parsing using a [bit-stream.](../types/bit.md)

Note:

While packet_data is a set of bytes, it may not be byte-aligned, which can occur overhead.

Packet IDs

Packet Data consists of protobuf messages with each packet_id corresponding to a given type.

Packet IDs come from multiple protobuf enums. At this time we can guess the following types are packet IDs:

  • SVC_Messages
  • NET_Messages
  • ECstrike15UserMessages
  • EBaseGameEvents
  • EBaseUserMessages
  • ECsgoGameEvents

It may be the case that some of these overlap, while we cannot confirm this fact, we have had success parsing in that order.

Packet Payloads

Each packet IDs matches to its given protobuf message.

Here are some notable ones:

Packet IdPayload TypeDescription
svc_ServerInfoCSVCMsg_ServerInfoServer Info (including build version, map name, class field size)
svc_ClassInfoCSVCMsg_ClassInfoMappings of class ids to their names/serializers
svc_PacketEntitiesCSVCMsg_PacketEntitiesA list of entity updates
svc_FlattenedSerializerCSVCMsg_FlattenedSerializerSerializer specifications

We wouldn't be able to list all the messages here, I would recommend exploring the protobuf files.

Most enum variants match to a respective message, but some have mismatched names.

Bit-streams

Source-2 uses network order (little endian) bit-streams when sending data to clients, this comes with the advantage of compact wire messages, and ease of implementation.

Writers Note:

I am of-course joking about it being easy, Bit Reading was the source of many of our bugs throughout our development process, and took 4 attempts to get it right. It's hard to debug, and with the only documentation being other peoples code (specifically manta), it's a fucker to get working.

Bit-streams are used in a variety of places, including but not limited to:

  • Game Packets
  • Entity Updates
  • String Tables

This set of chapters aims to document the format of the bit-streams and its many types. However, it's no substitute to looking over some code; you can find our implementation here

Bit Reader

Most data sent over the wire, is formated in bits, but given as bytes.

Implementing the bit stream boils down to four simple statements:

  • You keep a buffer, no smaller than 32 bits
  • When filling the buffer, you write to the left
  • When filling the buffer, you fill byte by byte.
  • When consuming from the buffer, you read from the right.

Pseudo Code

def fill
    while offset < N
        buffer |= next() << offset
        offset += 8
        
def consume
    if offset < N then fill
    
    mask = (2 ^ N) - 1
    value = buffer & mask
    buffer >>= N
    offset -= N
    
    return value
    

Optimisations

Precompute, (hopefully with constant expressions and compile-time optimization) the masks for consuming.

Reading bytes can be improved by checking if the reader is currently "byte aligned".

  • This can be done by checking if your offset is zero, e.g. no stored bits. (offset == 0)
  • The benefit of which, is skipping the cost of filling then draining the buffer every time.

Base Types

The base types that can be read from the bitstream. We have provided sample pseudocode for each type, in which we assume a predefined function read(bits) that reads a number of bits from the bitstream.

Table of Contents

Basic

bit_bool

A boolean value, encoded as a single bit.

FUNCTION bit_bool:
    RETURN read(1) > 0

Integers

bit_varuint_32

A 32-bit unsigned integer, encoded as a protobuf style varint.

FUNCTION bit_varuint_32:
    value = 0
    offset = 0
    // A 32-bit varint is at most 5 bytes long. 5 * 7 = 35.
    // This while loop is a safeguard against reading too many bytes.
    WHILE offset != 35:
        byte = read(8)
        // Add the lower 7 bits of the byte to the result.
        value = value | ((byte & 0x7F) << offset)
        offset = offset + 7
        // If the most significant bit is 0, we're done.
        IF (byte & 0x80) == 0:
            BREAK
    RETURN value

bit_varint_32

A 32-bit signed integer, encoded as a protobuf style varint.

FUNCTION bit_varint_32:
    // First, decode as an unsigned integer.
    unsigned_value = bit_varuint_32(reader)
    // Then, perform ZigZag decoding.
    value = unsigned_value >> 1
    IF (unsigned_value & 1) != 0:
        value = NOT value // or -(value + 1)
    RETURN value

bit_varuint_64

A 64-bit unsigned integer, encoded as a protobuf style varint.

FUNCTION bit_varuint_64:
    value = 0
    offset = 0
    index = 0
    LOOP:
        byte = read(8)
        // If the most significant bit is 0, this is the last byte.
        IF byte < 0x80:
            // Check for overflow. A 64-bit varint is at most 10 bytes.
            IF index > 9 OR (index == 9 AND byte > 1):
                THROW VarintOverflowError
            RETURN value | (byte << offset)
        
        // Add the lower 7 bits to the result.
        value = value | ((byte & 0x7F) << offset)
        offset = offset + 7
        index = index + 1

bit_varint_64

A 64-bit signed integer, encoded as a protobuf style varint.

FUNCTION bit_varint_64:
    // First, decode as an unsigned integer.
    unsigned_value = bit_varuint_64(reader)
    // Then, perform ZigZag decoding.
    value = unsigned_value >> 1
    IF (unsigned_value & 1) != 0:
        value = NOT value // or -(value + 1)
    RETURN value

bit_uint64_le

An 64-bit unsigned integer, encoded as a fixed 64-bit value in little-endian format.

FUNCTION bit_uint64_le:
    bytes = read(64)
    RETURN u64_from_le_bytes(bytes)

bit_bitvar_uint

A 32-bit unsigned integer, encoded as follows:

  • The first 4 bits are the MSB of the value.
  • The next 2 bits determine how many more bits to read.
  • The remaining bits are the LSB of the value.
FUNCTION bit_ubitvar:
    // Read the first 6 bits.
    value = read(6)
    
    // The next 2 bits determine how many more bits to read.
    SWITCH (value & 0x30):
        // 01: read another 4 bits
        CASE 16:
            RETURN (value & 15) | (read(4) << 4)
        // 10: read another 8 bits
        CASE 32:
            RETURN (value & 15) | (read(8) << 4)
        // 11: read another 28 bits
        CASE 48:
            RETURN (value & 15) | (read(28) << 4)
        // 00: the value is just the first 6 bits
        DEFAULT:
            RETURN value

bit_fieldpath_uint

A 32-bit unsigned integer, encoded in huffman style:

  • When <1> then read 2 bits
  • When <01> then read 4 bits
  • When <001> then read 10 bits
  • When <0001> then read 17 bits
  • When <0000> then read 31 bits

This type is used for field paths.

FUNCTION bit_fieldpath_int:
    // This encoding uses a series of single-bit flags
    // to determine the number of bits to read for the value.
    IF bit_bool() == true:
        RETURN read(2)
    
    IF bit_bool() == true:
        RETURN read(4)
        
    IF bit_bool() == true:
        RETURN read(10)
        
    IF bit_bool() == true:
        RETURN read(17)
        
    RETURN read(31)

bit_component_uint

A 32-bit unsigned integer, encoded as a single bit.

FUNCTION unsigned_integer_component:
    // The value is simply a single bit.
    RETURN read(1)

Floating-Point

bit_float32

A 32-bit floating-point number, encoded as a fixed 32-bit value in little-endian format.

FUNCTION bit_float32:
    bytes = read(32)
    // Read bytes as a little-endian unsigned integer.
    le_uint = u32_from_le_bytes(bytes)
    // Reinterpret the integer bits as a float.
    RETURN f32_from_bits(le_uint)

bit_float32_coord

A 32-bit floating-point number representing a coordinate. It's encoded with flags for the presence of integer and fractional parts, allowing for variable precision.

FUNCTION bit_float32_coord:
    // Read flags to see if integer and fractional parts exist
    has_integer_part = read(1)
    has_fractional_part = read(1)

    // If neither part exists, the value is zero
    IF has_integer_part == 0 AND has_fractional_part == 0:
        RETURN 0.0

    // Read the sign of the float
    is_negative = bit_bool()

    integer_value = 0
    IF has_integer_part == 1:
        // Read the 14-bit integer part
        integer_value = read(14) + 1
    
    fractional_value = 0
    IF has_fractional_part == 1:
        // Read the 5-bit fractional part
        fractional_value = read(5)

    // Combine the parts. The fractional part is divided by 2^5 (32)
    value = integer_value + fractional_value / 32.0

    IF is_negative == TRUE:
        value = -value

    RETURN value

bit_normal

A 32-bit floating-point number representing a normal vector component, encoded in 11 bits plus a sign bit.

FUNCTION bit_float32_normal:
    is_negative = bit_bool()
    length = read(11)

    // This maps the 11-bit integer to a float range.
    value = length * (1.0 / 2048.0 - 1.0)
    
    IF is_negative:
        RETURN -value
    ELSE:
        RETURN value

bit_float32_noscale

A standard 32-bit IEEE float, read directly from the bitstream without any scaling.

FUNCTION bit_float32_noscale:
    bits = read(32)
    // Reinterpret the integer bits as a float
    RETURN f32_from_bits(bits)

bit_float32_angle

An angle in degrees, encoded in a variable number of bits. The raw integer value is normalised to the [0, 360) range.

FUNCTION bit_float32_angle(bits):
    raw_value = read(bits)
    // Normalize the raw integer to the range [0, 360)
    divisor = 2^bits
    angle = raw_value * 360.0 / divisor
    RETURN angle

bit_float32_angle_precise

A precise angle, encoded as a 20-bit bit_float32_angle, then mapped to the [-180, 180) range

FUNCTION bit_float32_angle(bits):
    RETURN bit_float32_angle(20) - 180.0

Angles

bit_qangle_precise

A 3-component angle vector, where each component is a bit_float32_angle_precise. Each component is prefixed with a boolean flag indicating if it's present.

FUNCTION bit_qangle_precise: 
    has_x = bit_bool() 
    has_y = bit_bool() 
    has_z = bit_bool()
    
    vec = [0.0, 0.0, 0.0]

    IF has_x:
        vec[0] = bit_float32_angle_precise()
    IF has_y:
        vec[1] = bit_float32_angle_precise()
    IF has_z:
        vec[2] = bit_float32_angle_precise()
    
    RETURN vec

bit_qangle_fixed

A 3-component angle vector, where each component is a bit_float32_angle of a fixed bit-size.

FUNCTION bit_qangle_fixed(bits): 
    vec = [0.0, 0.0, 0.0]
    
    vec[0] = bit_float32_angle(bits)
    vec[1] = bit_float32_angle(bits)
    vec[2] = bit_float32_angle(bits)
    
    RETURN vec

bit_qangle_coord

A 3-component angle vector, where each component is a bit_float32_coord. Each component is prefixed with a boolean flag indicating if it's present.

FUNCTION bit_qangle_coord: 
    has_x = bit_bool() 
    has_y = bit_bool() 
    has_z = bit_bool()
    
    vec = [0.0, 0.0, 0.0]

    IF has_x:
        vec[0] = bit_float32_coord()
    IF has_y:
        vec[1] = bit_float32_coord()
    IF has_z:
        vec[2] = bit_float32_coord()
    
    RETURN vec

Vectors

bit_vec3_normal

A 3D normal vector comprised of bit_normal. The X and Y components are read if present, and the Z component is calculated to ensure the vector is of unit length.

FUNCTION bit_vec3_normal(reader):
    value = [0.0, 0.0, 0.0]

    has_x = bit_bool()
    has_y = bit_bool()

    IF has_x:
        value[0] = bit_normal(reader)

    IF has_y:
        value[1] = bit_normal(reader)
    
    is_z_negative = bit_bool()
    
    // Calculate Z to make it a unit vector
    product_sum = value[0] * value[0] + value[1] * value[1]

    IF product_sum < 1.0:
        value[2] = square_root(1.0 - product_sum)
    ELSE:
        value[2] = 0.0
    
    IF is_z_negative:
        value[2] = -value[2]

    RETURN value

bit_vec_float32

FUNCTION bit_vec_float(decoder; degree):
    vec = [0.0] * degree
    FOR i in 0..degree:
        vec[i] = decoder()
    RETURN vec

Game Types

bit_simulation_time

A 32-bit float representing the simulation time of the game, decoded from a bit_varuint_32 and scaled by 1/64.

FUNCTION bit_simulation_time:
    base = bit_varuint_32()
    RETURN base / 64.0

bit_rune_time

A 32-bit float representing the time of the game, decoded as 4 bits

FUNCTION bit_rune_time:
    RETURN read(4)

Writers Note: I am unsure what 'rune' means in this context.

We derived this name from field encoder property "runetime"

bit_ammocount

An unsigned integer 32-bit representing an ammo count, decoded from a bit_varuint_32 with 1 subtracted from it.

FUNCTION bit_ammocount:
    value = bit_varuint_32()
    RETURN MAX(0, value - 1)

Strings

bit_string_n

A UTF-8 string with a fixed length in bytes.

FUNCTION bit_string_n(length):
    bytes = read_bytes(length)
    RETURN string_from_utf8(bytes)

bit_string_null_term

A UTF-8 string that is terminated by a null byte (\0).

FUNCTION bit_string_null_term:
    buffer = []
    LOOP:
        byte = read_byte()
        IF byte == 0:
            BREAK
        buffer.push(byte)
    RETURN string_from_utf8(buffer)

bit_string_terminated

A string that is terminated by a null byte (\0), with a hard-coded limit of 4096 bytes.

FUNCTION bit_string_terminated:
    buffer = ""
    FOR i from 0 to 4096:
        byte = read_byte()
        IF byte == '\0':
            BREAK
        buffer.append(character(byte))
    RETURN buffer

Quantised Floats

Quantised floats are variable precision floats, encoded as bits. They are configured by the server and may change from demo to demo.

Initialisation

Parameters

When initialising a QF, there are four parameters specified by a serializer field.

Param NameTypeDescription
bit_countintThe number of bits a value will be encoded with
flagsint(8)Flags for configuring behaviour (see below)
low_valuefloat(32)(Optional) The smallest value encodable
high_valuefloat(32)(Optional) The largest value encodable

Flags

As seen above, the flag field allows different behaviour for a QF

Flag nameBitDescription
ROUND_DOWN0Read an extra bit, to quickly specify the lowest value
ROUND_UP1Read an extra bit, to quickly specify the highest value
ENCODE_ZERO2Read an extra bit, to quickly specify zero
ENCODE_INTS3Have no idea, there's maths involved and it looks scary

Setup

Setting up a QF is expensive and overcomplicated, much like my attempts at relationships.

Before getting into the weeds, there's two basic steps to perform:

  • When low or high values are not specified, they default to 0.0 and 1.0 respectively
  • When the bit_count is zero, or greater than or equal to 32, it's treated as a no-scale type.

Next, we recompute flags, removing invalid or unnecessary behaviour. Why are these flags present if the server disables them anyway? We'll never know. Thanks, Volvo.

  • if low_value is 0, and round down is set, then we unset encoding zero.
  • if high_value is 0, and round up is set, then we also unset encoding zero.
  • if low_value is 0, and encode zero is set, then we set round down, and unset encoding zero
  • if high_value is 0, and encode zero is set, then we set round up, and unset encoding zero
  • if low_value is greater than zero then we unset encoding zero
  • if high_value is less than zero then we unset encoding zero
  • if encode ints is set, then we unset every other flag
  • if round up and round_down are both set, then return an error.

Next, we want to calculate 3 values:

  • offset, the offset
  • dec_mul,
  • high_low_mul

The first step is to calculate the number of steps, essentially the size of the float. This should be equal to 1 << bit_count.

Next, do some rounding adjustment only if we round down, or round up:

  1. calculate the range of values.
  2. divide this range by the number of steps, this is offset
  3. offset the value by offset
    • if round down then subtract offset from high
    • if round up then add offset to low

Next, if encode_ints is true:

  1. calculate the range of values.
    • if this range is less, than one then set it to 1.0
  2. calculate the log2 and round up to the highest integer
  3. shift one by the rounded log, we'll call this target range,

Writers Note

This is incomplete!

The original end of this file read:

hahahha i give up why is this so fucked.

Send Tables

"Send Tables" are Source-2's way of describing entity structure in a version ambiguous manner. The Send Tables contain a list of serializers, which in-turn contain fields.

Send tables are processed during the sign-on phase of the demo. The aim is to create a structure, mapping serializer field layout to their respective decoders, which can be used to parse updated (and initialised) fields in entity updates.

They are required before you process any entity updates.

Writers Note:

Send tables are one of the most complex parts of the demo protocol.

They are inherently complex and are one of the most challenging parts of the protocol to implement. It's straightforward to introduce a bug early and not find it until much later, as we found out multiple times.

Continue at your own peril...

Expanding Serializers & Fields

Send-tables can be found in two places, within a demo frame (CDemoSendTables) or in a packet (CSVCMsg_FlattenedSerializer). These contain the entire serializer set for the demo.

They are sent in a flattened format, which has three properties:

FieldDescription
symbolsa list of strings, referenced by index, representing names of fields, serializers, etc..
serializersa list of serializer objects (see below)
fieldsa list of serializer field objects, referenced by index (see below)

Serializers

Serializers (as the ProtoFlattenedSerializer_t type) have three properties:

FieldDescription
serializer_name_symthe symbol index of the serializer name
serializersthe version of this serializer
fieldsa list of indexes, referring to the fields in this serializer

Serializers are the basic building block of the send-tables. They can be referenced (nested) inside other serializers, and some are entity class types. (We'll discuss this later)

Note: multiple serializers with the same name can exist but will have different versions.

When getting a serializer for a class, only one (determinate) serializer will exist with the given name.

Fields

Fields are the most complex part of the send-tables.

Each field has a model and one or more decoded types, which are derived from a set of parameters.

Here are the notable parameters from the ProtoFlattenedSerializerField_t type:

FieldSymbol IndexOptionalTypeDescription
var_name_symallthe name of this field
var_type_symvaluedthe type of this field,
bit_count valuedthe bit-count for a field
low_valuevalueda specified lowest value (QF)
high_valuevalueda specified highest value (QF)
encode_flagsvaluedflags for controlling encoding/decoding behaviour (QF)
var_encoder_symvalueda specified encoder type for this field
field_serializer_name_symtablethe name of the field's nested serializer
field_serializer_versiontablethe version of the field's nested serializer
polymorphic_typespoly-tablea list of possible serializer types for a poly table

Field Layouts

Fields can be modelled in six possible ways:

NameNestedDescription
ValueA simple, single value.
FixedArrayA fixed size, array of values
VariableArrayA variable size (resizable) array of values
FixedTableA single nested table
VariableTableA variable size (resizable) list of nested tables
PolyTableA single table that can change type (polymorphic)

Parsing Field Type

The var_type_sym field contains a string that describes the type of the field. It can be used to derive the layout of the field as well as the other parameters we will use later in the send-table process. This value is a string and can be parsed using the following grammar:

TYPE      ::= BASE_TYPE GENERIC? POINTER? COUNT?
BASE_TYPE ::= /[a-zA-Z0-9]+/
GENERIC   ::= < TYPE > 
POINTER   ::= /\*/
COUNT     ::= [ /[0-9]*/ ] 

Here is an example regex you can use:

([^<\[*]+)(<\s(.*)\s>)?(\*)?(\[(.*)])?

You will use BASE_TYPE, GENERIC POINTER and, COUNT for multiple times in the send-table process. (Not just for determining the field layout)

Field Type Edge Cases

When processing the COUNT component:

  • If the COUNT == MAX_ITEM_STOCKS then the count should be 8
  • If the COUNT == MAX_ABILITY_DRAFT_ABILITIES then the count should be 48
  • If the COUNT is empty string then the count should be 1024
  • If the COUNT is a number, then it should be parsed.
  • Otherwise, the count should be ignored.

If the base-type is one of the following, it's deemed also as a pointer type:

  • CBodyComponentDCGBaseAnimating
  • CBodyComponentBaseAnimating
  • CBodyComponentBaseAnimatingOverlay
  • CBodyComponentBaseModelEntity
  • CBodyComponent
  • CBodyComponentSkeletonInstance
  • CBodyComponentPoint
  • CLightComponent
  • CRenderComponent
  • CPhysicsComponent

Mapping Field Type to Layouts

Once extracting components from the var-type, we can map the field to its model:

  • If the field has poly-types, then:
    • PolyTable
  • If the field has a serializer (field_serializer_name_sym && field_serializer_version are present) then:
    • If it's a pointer type, then:
      • FixedTable
    • Otherwise:
      • VariableTable
  • If the count is present and not 0, and the base-type is not char then:
    • FixedArray
  • If the base-type is CUtlVector or CNetworkUtlVectorBase then:
    • VariableArray
  • Otherwise
    • Value

Next Steps

Writers Note:

At this point I would recommend introducing a "constructor" type to contain all these parameters (including the field-model). This will make it easier to map field decoders and to construct the field. We called ours FieldConstructor, but it could be anything.

The next step is to build out decoders for fields.

Field Decoders

To parse entity updates, we need to map the fields of the send-table to their respective types within an update. To do this, we create a "field decoder" for each field which can be initialised from the parameters found from the send-table.

Note: Within this guide, we will refer to the 'field type' as a "decoder".

Decoder Types

First, let's define the possible types of decoders:

We've listed all the decoders we currently support, along with their wire type and the type they decode to.

Decoder NameWire TypeTypeDescription
Booleanbit_boolbool
Signedbit_varint_32 int32
Unsignedbit_varuint_32uint32
Componentbit_component_uintuint32
AmmoCountbit_ammocountuint32
Unsigned64bit_varuint_64uint64
FixedUnsigned64bit_uint64_leuint64
Stringbit_string_null_termstring
NoScalebit_float32_noscalefloat32
Coordbit_float32_coordfloat32
SimTimebit_simulation_timefloat32
RuneTimebit_rune_timefloat32
Qfloatquantised_float *float32*maps to a quantised float decoder
QAnglePrecbit_qangle_precisefloat32
QAngleFixedbit_qangle_fixedfloat32
QAngleCoordbit_qangle_coordfloat32
Vector3Normalbit_vec3_normalfloat32[3]
Vector2[F]F x2 *float32[2]*calls the float factory, creates a vector of degree 2
Vector3[F]F x3 *float32[3]*calls the float factory, creates a vector of degree 3
Vector4[F]F x4 *float32[4]*calls the float factory, creates a vector of degree 4
Vector6[F]F x6 *float32[6]*calls the float factory, creates a vector of degree 6

Matching Decoders

When matching a field to a decoder, there are three methods:

  • Factory: A decoder with logical mapping and parameter initialisation
  • Overrides: A overridden type, controlled by another parameter (such as var_name)
  • Mapping: A simple name to decoder mapping

Factories

As stated before, not all field types map to a decoder one-to-one. Factories map a type to multiple types of decoders depending on their parameters.

Factories can also be called other factories. For example, the float factory can call the Quantised Float factory, or the vector factory calls the float factory.

Quantised Float Factory

  • If bit_count != nil and bit_count < 32:
    • NoScale
  • Otherwise, create a quantised float decoder from the parameters:
    • bit_count (if not present then default to 0)
    • encode_flags (if not present then default to 0)
    • low_value
    • high_value

Float Factory

  • If encoder == "coord":
    • CoordType
  • If encoder == "simtime":
    • SimTime
  • If encoder == "runetime":
    • RuneTime
  • If bit_count == nil or bit_count < 32:
    • NoScale
  • Otherwise, call Quantised Float Factory

Unsigned64 Factory

  • If encoder == "fixed64"
    • Vector3[Normal]
  • Otherwise
    • Unsigned64

QAngle Factory

  • If encoder == "qangle_precise"
    • QAnglePrec
  • If bits != nil and bits != 0:
    • QAngleFixed
  • Otherwise
    • QAngleCoord

Vector3 Factory

  • If encoder == "normal"
    • Vector3Normal
  • Otherwise call VectorN Factory
    • vector(3)

VectorN Factory

In this documentation, we reference the vector factory as Vector[N] Factory where N dictates the degree (size) of the vector.

The vector factory calls Float Factory to create its inner type, and on the wire reads this N times to get a resulting vector.

Overrides

Some field types can be overridden, there is currently one case:

ConditionDecoder
`var_name == "m_iClip1"AmmoCount

Mapping

This table contains a mapping to a field's decoded type to its respective factory or decoder.

This table should be applied in sequential order to prevent any issues with overlap between factories and mappings.

Base TypeFactoryDecoder
float32Float
CNetworkedQuantizedFloatQuantised Float
uint64Unsigned 64
CStrongHandleUnsigned 64
VectorVector3
Vector2DVector[2]
Vector4DVector[4]
QuaternionVector[4]
CTransformVector[6]
QAngleQAngle



boolBoolean
 
int8Signed
int16Signed
int32Signed
HSequenceSigned
CEntityIndexSigned
NPC_STATESigned
AmmoIndex_tSigned
TakeDamageFlags_tSigned
StanceType_tSigned
 
RagdollBlendDirectionUnsigned
BeamType_tUnsigned
EntityDisolveType_tUnsigned
PrecipitatonType_tUnsigned
BeamClipStyle_tUnsigned
SharpSolid_tUnsigned
ShatterPanelModeUnsigned
gender_tUnsigned
item_definition_index_tUnsigned
uint8Unsigned
uint16Unsigned
uint32Unsigned
CHandleUnsigned
ColorUnsigned
CUtlStringTokenUnsigned
EHandleUnsigned
CEntityHandleUnsigned
CGameSceneNodeHandleUnsigned
CStrongHandleUnsigned
AttachmentHandle_tUnsigned
MoveCollide_tUnsigned
MoveType_tUnsigned
RenderMode_tUnsigned
RenderFx_tUnsigned
SolidType_tUnsigned
SurroundingBoundsType_tUnsigned
ModelConfigHandle_tUnsigned
WeaponState_tUnsigned
DoorState_tUnsigned
ValueRemapperInputType_tUnsigned
ValueRemapperOutputType_tUnsigned
ValueRemapperHapticsType_tUnsigned
ValueRemapperMomentumType_tUnsigned
ValueRempapperRatchetType_tUnsigned
PointWorldTextJustifyHorizontal_tUnsigned
PointWorldTextJustifyVertical_tUnsigned
PointWorldTextReorientMode_tUnsigned
PoseController_FModType_tUnsigned
itemid_tUnsigned
style_index_tUnsigned
attributeprovidertypes_tUnsigned
DamageOptions_tUnsigned
MaterialModifyMode_tUnsigned
CSWeaponModeUnsigned
ESurvivalSpawnTileStateUnsigned
SpawnStage_tUnsigned
ESurvivalGameRuleDecision_tUnsigned
RelativeDamagedDirection_tUnsigned
CSPlayerStateUnsigned
MedalRank_tUnsigned
CSPlayerBlockingUseAction_tUnsigned
MoveMountingAmount_tUnsigned
QuestProgress::ReasonUnsigned
tablet_skin_state_tUnsigned
ScreenEffectType_tUnsigned
 
charString
CUtlStringString
CUtlSymbolLargeString
 
GameTime_tNoScale
 
CBodyComponentComponent
CPhysicsComponentComponent
CLightComponentComponent
CRenderComponentComponent

Lookup Methodology

Mapping a field type to a decoder is done by a lookup method. There are two variants of this:

Lookup Field Decoder

This is the most common use of decoding a field type. It is implemented as such:

  1. Check factories
  2. Check overrides
  3. Check mapping
  4. Otherwise, Unsigned

This will be referenced as lookup_field_decoder

Lookup Field Inner Decoder

This is the less often used decoder, are used for decoding the inner child elements of an array.

Unlike Field Type, Base Type is actually the inner GENERIC type, not the outer type (because it will always be an array)

It is implemented as such:

  1. Check if we have a generic type.
    • Fatally Error if this is not the case.
  2. Check factories
  3. Check mapping.
  4. Otherwise, Unsigned

Note: we do not need to check overrides in this implementation.

This will be referenced as lookup_field_inner_decoder

Constructing Serializers

By this point you should be able to:

  • Parse the send-tables and extract the fields.
  • Parse a field's type
  • Determine the layout of a field
  • Create a field decoder for a given field type.

The next goal is to create a minimal representation of these fields so they can be referenced efficiently.

As stated before, serializers contain an array of fields, which during updates are referenced by indices. We need to map fields to their decoder or nested serializers while keeping the correct field layout. These fields need to be in the same order as they arrive in the send-tables.

We will combine the field layout and the field decoder into a single type called Field.

Representation of a Field

Each field layout can be boiled down to a few simple parameters:

Value

ParameterTypeDescription
decoderfield_decoderthe decoder for this value

FixedArray

ParameterTypeDescription
decoderfield_decoderthe decoder for the values in this array
countintthe size of this array (fixed)

VariableArray

ParameterTypeDescription
inner_decoderfield_decoderthe decoder for the values in this array

FixedTable

ParameterTypeDescription
serializer*serializerthe serializer of the nested table

VariableTable

ParameterTypeDescription
serializer*serializerthe serializer of the nested table(s)

PolyTable

ParameterTypeDescription
serializers[]*serializerthe serializers of the nested table types

Constructing Fields

Entity Layout

Entity Updates

Entity updates are sent in the CSVCMsg_PacketEntities message. This message has a few key fields:

FieldDescription
serializer_name_symthe symbol index of the serializer name
serializersthe version of this serializer
fieldsa list of indexes, referring to the fields in this serializer