gem
v0.13.0

Bits & Bytes: A Crash Course for Python Programmers

Most Python code never looks below the level of int, str, and bytes. Replay parsing does. A Dota 2 replay packs data as tightly as it can: a 2-bit command sits right next to a 17-bit number, then a 1-bit flag, with no padding between them. To read gem's low-level code (binary/reader.py, then schema/), you need a handful of ideas that Python normally hides from you.

This page teaches them from scratch, one at a time. Each section has a small snippet you can paste into a Python prompt, and a Where gem uses this note. The examples use real bytes from match 8855242704.

Each section ends with a few Try it questions. For hands-on practice, the repo also has a small exercise file: you build your own mini bit reader, one function at a time, and a grader checks it against real replay bytes and gem's own BitReader:

uv run python examples/bits_and_bytes_exercises.py

If you want to know how a replay is laid out (headers, envelopes, inner messages), read How Proto Parsing Works. This page explains the tools used to read that layout.

1. Bits, bytes, binary, and hex

A bit is a single 0 or 1. A byte is 8 bits, so it holds a number from 0 to 255. Python's bytes is a sequence of those numbers:

data = b"PBDEMS2\x00"   # the first 8 bytes of every Source 2 replay
print(list(data))       # [80, 66, 68, 69, 77, 83, 50, 0]
print(data.hex(" "))    # 50 42 44 45 4d 53 32 00

The same number can be written three ways:

NotationPython42 written that way
Decimal4242
Binary (base 2)0b101010bin(42) → '0b101010'
Hexadecimal (base 16)0x2ahex(42) → '0x2a'

Hex is popular because one hex digit is exactly 4 bits, so a byte is always two hex digits (00 to ff). Reading bits off hex takes practice but no arithmetic: 0xf0 is 1111 0000, and 0x0f is 0000 1111.

print(int("2a", 16))            # 42
print(int("101010", 2))         # 42
print(format(0xB7, "08b"))      # 10110111  (8 digits, zero-padded)

Where gem uses this: everywhere. Error messages, tests, and this documentation show bytes in hex.

Try it

  1. What is 0xB7 in binary, and which bit positions hold a 1?
  2. Why is every byte exactly two hex digits?
Answers
print(format(0xB7, "08b"))                       # 10110111
print([i for i in range(8) if 0xB7 >> i & 1])    # [0, 1, 2, 4, 5, 7]

One hex digit covers exactly 4 bits (16 values), and a byte is 8 bits, so it always takes two.

2. Bit operations: the six operators

Python has six operators that work on the individual bits of an integer:

OperatorNameWhat it doesExample
a & bAND1 where both have a 10b1100 & 0b1010 → 0b1000
a | bOR1 where either has a 10b1100 | 0b1010 → 0b1110
a ^ bXOR1 where they differ0b1100 ^ 0b1010 → 0b0110
~aNOTflips every bit~0b1100 → -13 (see section 5)
a << nleft shiftmoves bits up by n, adding zeros (× 2ⁿ)0b11 << 2 → 0b1100
a >> nright shiftmoves bits down by n, dropping the lowest (÷ 2ⁿ)0b1100 >> 2 → 0b11

Combined, they do three jobs that come up constantly:

x = 0b1011_0111          # 183; underscores are allowed in numbers

# Keep only the lowest n bits with a mask of n ones: (1 << n) - 1
print(bin(x & ((1 << 4) - 1)))    # 0b111     the low 4 bits (0111)

# Pull bits out of the middle: shift them down, then mask
print(bin((x >> 4) & 0b11))       # 0b11      bits 4 and 5

# Test, set, and clear a single flag bit
FLAG = 0x40
cmd = 0x47
print(bool(cmd & FLAG))   # True   is the flag set?
print(hex(cmd & ~FLAG))   # 0x7    clear it
print(hex(0x07 | FLAG))   # 0x47   set it

Bit positions are counted from the right, starting at 0. In 0b1011_0111, bit 0 is the rightmost 1 and bit 7 is the leftmost 1.

Where gem uses this: the last three lines are exactly how binary/stream.py reads each envelope's label. Bit 6 (0x40) of the command means "compressed", and the rest is the envelope kind: compressed = command & 0x40 and msg_type = command & ~0x40.

Try it

  1. Using only >> and &, get bits 4 to 6 of 0b1011_0111.
  2. An envelope's command is 0x44. Is it compressed, and what kind is it?
Answers
print(bin((0b1011_0111 >> 4) & 0b111))   # 0b11
print(bool(0x44 & 0x40), 0x44 & ~0x40)   # True 4

Bits 4 to 6 are 011. Command 0x44 is a compressed DEM_SendTables (kind 4).

3. Byte order: little-endian

A number bigger than 255 needs several bytes, so something has to decide which byte comes first. Replays use little-endian order: the lowest byte first.

Bytes 8 to 11 of match 8855242704 are e0 85 e7 11. Read little-endian, that is 0x11e785e0:

import struct

raw = bytes.fromhex("e0 85 e7 11")
print(int.from_bytes(raw, "little"))    # 300385760
print(struct.unpack("<I", raw)[0])      # 300385760   "<" little-endian, "I" uint32
print(int.from_bytes(raw, "big"))       # 3766871825  the same bytes read the wrong way

300,385,760 is the byte position of the match summary at the end of that 300 MB file (see Stage 1). Reading it in the wrong order gives a number that means nothing.

Where gem uses this: struct.unpack("<I", ...) in binary/reader.py loads 4 bytes at a time, and read_float decodes floats little-endian.

Try it

  1. Header bytes 12 to 15 of the same replay are 6b 85 e7 11. What number is that?
  2. Write 1,000 as 4 little-endian bytes.
Answers
print(int.from_bytes(bytes.fromhex("6b 85 e7 11"), "little"))   # 300385643
print((1000).to_bytes(4, "little").hex(" "))                     # e8 03 00 00

300,385,643 is the position of the DEM_SpawnGroups envelope, 117 bytes before the match summary.

4. Bitstreams: reading bits that don't line up with bytes

Here is the part most Python programmers have never needed. Inside Valve's packed formats, a value can start and end in the middle of a byte. A 6-bit number is followed immediately by a 17-bit number, and so on. Reading these streams is the job of BitReader.

Replays read the bits of each byte least-significant bit first (LSB-first): bit 0 of the first byte is the first bit in the stream, then bit 1, and so on, then bit 0 of the next byte. Picture each byte's bits laid out right to left, and the reader taking them from the right:

bytes:       0x0f        0xf0
bits:     0000 1111   1111 0000
          <-- read    <-- read    (right to left within each byte, first byte first)
from gem.binary.reader import BitReader

r = BitReader(bytes([0b0000_1111, 0b1111_0000]))
print(bin(r.read_bits(4)))   # 0b1111      the low 4 bits of byte 0
print(bin(r.read_bits(8)))   # 0b0         the high 4 of byte 0, then the low 4 of byte 1
print(bin(r.read_bits(4)))   # 0b1111      the high 4 bits of byte 1

The middle read takes 4 bits from each byte. Nothing lines up with a byte boundary, and that is normal in these streams.

Under the hood, BitReader keeps unread bits in an integer (its bit cache) and uses the operators from section 2:

cache = 0b1111_0000_0000_1111   # both bytes loaded, byte 0 lowest
n = 4
value = cache & ((1 << n) - 1)   # take the lowest n bits
cache >>= n                      # drop them
print(bin(value), bin(cache))    # 0b1111 0b111100000000

Where gem uses this: BitReader.read_bits in binary/reader.py is those two lines plus refilling the cache 4 bytes at a time. Entity updates, string tables, and the inner-message bundles are all read this way.

Try it

  1. BitReader(bytes([0b1010_1010])): what do three read_bits(1) calls return, and then read_bits(5)?
  2. A 2-byte buffer has had 3 bits read. How many bits are left?
Answers
r = BitReader(bytes([0b1010_1010]))
print(r.read_bits(1), r.read_bits(1), r.read_bits(1))   # 0 1 0
print(bin(r.read_bits(5)))                               # 0b10101

r = BitReader(b"\x00\x00")
r.read_bits(3)
print(r.rem_bits())   # 13

The first bit read is the rightmost bit of 1010_1010, which is 0. The last 5 bits are 10101 read from bit 3 upward, and the first of them becomes the lowest bit of the result.

5. Signed numbers, and why Python needs masks

A fixed number of bits can only hold so many values, so computers store negative numbers with a trick called two's complement: in 32 bits, the patterns from 0x80000000 upward mean negative numbers, and 0xFFFFFFFF means −1.

def to_int32(u: int) -> int:
    """Interpret the low 32 bits of u as a signed number."""
    u &= 0xFFFFFFFF
    return u - (1 << 32) if u >= 1 << 31 else u

print(to_int32(0xFFFFFFFF))   # -1
print(to_int32(0x80000000))   # -2147483648   the smallest int32
print(hex(-1 & 0xFFFFFFFF))   # 0xffffffff    and back again

In C, Go, or Java, a 32-bit integer can't hold more than 32 bits. Extra high bits simply fall off, which programmers call wrapping. Python integers never overflow: they grow as large as needed. That is convenient, but it means Python code that decodes 32-bit data has to throw away the extra bits itself with & 0xFFFFFFFF. The same unbounded integers explain why ~0b1100 printed -13 in section 2: Python flips "all" the bits of a number with infinitely many leading zeros, giving −(x + 1).

gem once missed one of these masks. A malformed 5-byte number could come out as 0x7ffffffff, a 35-bit value from a function that promised 32 bits. Valve's and Manta's readers wrap it to 0xffffffff, and now gem's does too.

Zigzag encoding. In two's complement, −1 is 0xFFFFFFFF: a big number, and therefore expensive to store with the variable-length integers in the next section. Zigzag interleaves negatives with positives, so numbers close to zero stay small whether they are positive or negative:

Signed value0−11−22−3
Zigzag-encoded012345
def zigzag_decode(n: int) -> int:
    return (n >> 1) ^ -(n & 1)

print([zigzag_decode(n) for n in range(6)])   # [0, -1, 1, -2, 2, -3]

Where gem uses this: read_varint32 and read_varint64 in binary/reader.py decode zigzag, and read_varuint32 masks to 32 bits.

Try it

  1. What is −2 as a 32-bit two's-complement pattern, in hex?
  2. Zigzag-encode −3 and 3. (Encoding is (n << 1) ^ (n >> 31) for 32-bit values.)
  3. Why does ~5 print -6 in Python?
Answers
print(hex(-2 & 0xFFFFFFFF))   # 0xfffffffe

def zigzag_encode(n):
    return (n << 1) ^ (n >> 31)

print(zigzag_encode(-3), zigzag_encode(3))   # 5 6

~x flips every bit of a number with infinitely many leading zeros, which gives −(x + 1). To flip only 8 bits, use 5 ^ 0xFF (250).

6. Variable-length integers

Most numbers in a replay are small, so spending 4 bytes on every one would waste space. Replays use two ways to make small numbers short.

Varints: 7 bits per byte

A varint stores 7 bits of the number in each byte. The top bit of each byte (0x80) means "another byte follows". The lowest 7 bits come first.

ValueBytes
707
1277f
12880 01
300ac 02

Here is a complete decoder:

def read_varint(data: bytes) -> tuple[int, int]:
    """Return (value, bytes used)."""
    value = shift = 0
    for i, b in enumerate(data):
        value |= (b & 0x7F) << shift   # add this byte's 7 bits
        shift += 7
        if not b & 0x80:               # no continuation bit: done
            return value, i + 1
    raise ValueError("ran out of bytes")

print(read_varint(bytes.fromhex("ac 02")))   # (300, 2)

Real bytes: the first envelope in match 8855242704 starts right after the 16-byte file header with 01 ff ff ff ff 0f cb 01. Those are three varints in a row, the envelope label from Stage 1:

label = bytes.fromhex("01 ff ff ff ff 0f cb 01")
command, used = read_varint(label)
tick, used2 = read_varint(label[used:])
size, _ = read_varint(label[used + used2:])
print(command, hex(tick), size)   # 1 0xffffffff 203

Command 1 is DEM_FileHeader. The tick 0xffffffff is the special "before the game" value, which gem stores as 0. And 203 bytes is the size of the file header.

Protobuf uses the same varints. The 203-byte payload starts 0a 08 50 42 44 45 4d 53 32 00 10 30. In protobuf, each field starts with a varint tag equal to field_number << 3 | wire_type:

  • 0a = field 1, wire type 2 ("length-delimited"). Its length is 08, then 8 bytes follow: PBDEMS2\0, the demo_file_stamp field.
  • 10 = field 2, wire type 0 ("varint"). The value is 30 = 48, the patch_version field.
print(0x0A >> 3, 0x0A & 0b111)   # 1 2
print(0x10 >> 3, 0x10 & 0b111)   # 2 0

That is all a protobuf message is on the wire: tags and values. See What protobuf is.

ubit_var: Valve's own variable-width integer

Inside bitstreams, Valve uses a second format that works in bits rather than bytes. BitReader.read_ubit_var() reads it. Read 6 bits first. The low 4 bits are the start of the value, and the top 2 bits say how many more bits follow:

Top 2 bitsExtra bitsValue
000low 4 bits
014low 4 bits + (4 extra bits << 4)
108low 4 bits + (8 extra bits << 4)
1128low 4 bits + (28 extra bits << 4)

Small numbers cost 6 bits, and large ones grow as needed. The first svc_PacketEntities message in match 8855242704 has this type ID:

first 6 bits:  01 0111   top "01" -> read 4 more bits; low 4 bits = 0111 = 7
next 4 bits:   0011      = 3
type_id = (3 << 4) | 7 = 55   -> svc_PacketEntities

Where gem uses this: varints are payload sizes, ticks, and many entity fields. ubit_var is inner-message type IDs, entity index gaps, and string-table sizes.

Try it

  1. Encode 1,000 as a varint by hand, then check it with read_varint.
  2. The file-header payload continues with the tag 1a. Which field number and wire type is that?
  3. For the number 5, which is smaller: a varint or a ubit_var?
Answers

1,000 is 0b111_1101000. The low 7 bits (1101000 = 0x68) come first, with the continuation bit set (0xe8), then the rest (0b111 = 0x07):

print(read_varint(bytes.fromhex("e8 07")))   # (1000, 2)
print(0x1A >> 3, 0x1A & 0b111)               # 3 2

Tag 1a is field 3, length-delimited: server_name in CDemoFileHeader. For 5, a varint takes 8 bits and a ubit_var takes 6.

7. Floats, and how to store them in fewer bits

A normal float in Python takes 64 bits. Replays use two cheaper ways to store decimal numbers.

32-bit floats. Four bytes in the standard IEEE 754 format, about 7 significant digits. Python can read them with struct, but prints them as its own 64-bit floats, which shows the rounding:

import struct

raw = struct.pack("<f", 0.1)            # store 0.1 as a 32-bit float
print(raw.hex(" "))                      # cd cc cc 3d
print(struct.unpack("<f", raw)[0])       # 0.10000000149011612

Quantized floats. When the schema knows a value's range, it can store the value as a whole number of equal steps across that range, in exactly as many bits as it needs:

value = low + (high - low) × stored_number / (2^bits − 1)

A real example from the schema of match 8855242704: m_flMana is stored in 20 bits over the range 0 to 65,536. Each step is about 1/16 of a mana point:

bits, low, high = 20, 0.0, 65536.0
step = (high - low) / ((1 << bits) - 1)
print(round(step, 6))                   # 0.0625
print(low + (high - low) * 8000 / ((1 << bits) - 1))   # ≈ 500.0

So mana costs 20 bits instead of 32. Angles work the same way: BitReader.read_angle(n) spreads n bits evenly over 0 to 360 degrees.

Where gem uses this: read_float and read_angle in binary/reader.py, and schema/field_decoder/quantized_float.py, which also handles rounding flags and special cases. That module has its own walkthrough.

Try it

  1. m_flHealthThinkRegen is stored in 18 bits over −100 to 1,000. How big is one step, and what does a stored 0 mean?
  2. What does read_angle(8) return for the stored value 64?
Answers
step = (1000.0 - -100.0) / ((1 << 18) - 1)
print(round(step, 6))                                # 0.004196
print(BitReader(bytes([64])).read_angle(8))          # 90.0

A stored 0 means −100, the bottom of the range. For angles, 64 steps of 360 / 256 degrees is a quarter turn.

8. Prefix codes: short codes for common things

Suppose you have to send a long list of instructions, and one instruction is far more common than the rest. You can give it a very short code, and give rare ones long codes. As long as no code is the start of another code (a prefix code), the reader always knows where each code ends, with no separators needed.

A toy code:

InstructionCode
A (common)0
B10
C11

0 10 11 0 can only mean A, B, C, A. Read bits until they match a code, output it, and start again. Huffman coding is a standard method for choosing such codes from how often each instruction appears.

Where gem uses this: schema/field_path/ decodes the list of fields that changed in each entity update. The instructions are 40 field-path operations. The most common, "move to the next field" (PlusOne), is the single bit 0. "Finished" (FieldPathEncodeFinish) is 10, and the rarest operations take 17 bits. That module has its own walkthrough.

Try it

  1. With the codes A = 0, B = 10, C = 11, decode 1101000.
  2. Why can't 1 and 10 both be codes?
  3. There are 40 field-path operations. How many bits would each take with a fixed-width code?
Answers
  1. 11 0 10 0 0: C, A, B, A, A.
  2. 1 is the start of 10. After reading a 1, the reader couldn't tell whether the code had ended.
  3. 6 bits (2⁶ = 64 ≥ 40). With the prefix code, the most common operation costs 1 bit.

9. Decode it yourself

Now put the pieces together on real bytes. The first DEM_Packet in match 8855242704 holds a bundle of inner messages. Each inner message starts with a ubit_var type ID, then a varint size (Stage 3). The bundle's first bytes are:

88 b7 b4 81 02

Find the first inner message's type ID and size by hand before reading the answer.

Answer

Type ID. Read 6 bits, LSB-first, so the low 6 bits of 0x88:

0x88 = 1000 1000  ->  low 6 bits: 00 1000
top 2 bits "00": no extra bits.  type_id = 1000 = 8

Type 8 is net_SpawnGroup_Load, a map-chunk loading message.

Size. The varint starts at bit 6 of the first byte, so every "byte" of it straddles two real bytes: the top 2 bits of one byte, then the low 6 bits of the next.

data = bytes.fromhex("88 b7 b4 81 02")

def byte_at_bit_6(i):
    """The 8 bits starting at bit 6 of data[i]."""
    return (data[i] >> 6) | ((data[i + 1] & 0b11_1111) << 2)

groups = [byte_at_bit_6(i) for i in range(3)]
print([hex(g) for g in groups])   # ['0xde', '0xd2', '0x6']

size = (0xDE & 0x7F) | ((0xD2 & 0x7F) << 7) | ((0x06 & 0x7F) << 14)
print(size)                        # 108894

0xde and 0xd2 have the continuation bit set and 0x06 doesn't, so the varint is three groups long: 94 + 82 × 128 + 6 × 16,384 = 108,894 bytes.

BitReader agrees:

from gem.binary.reader import BitReader

r = BitReader(bytes.fromhex("88 b7 b4 81 02"))
print(r.read_ubit_var(), r.read_varuint32(), r.position())   # 8 108894 3.6

position() says the payload starts at byte 3, bit 6. It isn't byte-aligned, which is why BitReader.read_bytes needs a path for unaligned reads.

Common pitfalls

  1. Forgetting to mask. Python integers don't wrap. When you decode a fixed-width value, & 0xFFFFFFFF (or the width you need) is on you.
  2. ~x is negative. In Python, ~x == -(x + 1). To flip bits within a fixed width, use x ^ 0xFF (8 bits) or ~x & 0xFF.
  3. Byte order. Replays are little-endian. int.from_bytes(b, "big") gives plausible-looking nonsense.
  4. Bit order. Bitstreams are read LSB-first. The first bit of 0x88 is its rightmost bit, not its leftmost.
  5. Assuming byte alignment. After any bit-width read, the next byte may start in the middle of a real byte. Use BitReader.read_bytes rather than slicing the buffer yourself.
  6. Mixing hex and decimal. 10 and 0x10 are different numbers. Hex dumps never show the 0x.

Next pages

  1. Practice: examples/bits_and_bytes_exercises.py (12 graded exercises; solutions in examples/bits_and_bytes_solutions.py).
  2. How Proto Parsing Works: how a replay is layered, using these tools.
  3. The Proto Files gem Uses: the protobuf messages inside it.
  4. src/gem/binary/reader.py: BitReader, which you can now read line by line.