Pure-Python, from-scratch declarative binary protocol (de)serializer — node-protodef style, for Minecraft, ClassiCube and any other binary protocol.
Project description
protolib (Python)
A from-scratch, pure-Python implementation of a
node-protodef-style system:
you describe the shape of a binary protocol's packets in a declarative
file (.yml or .json), and the library takes care of parsing raw
bytes into a Python dict and serializing a dict back into bytes —
without you having to hand-write a parser for every single packet.
Already tested against two real protocols, each with its own .yml
(hand-written/maintained) and its equivalent .json (the native
["type", opts] form, generated by the loader itself — so you can see
exactly what the engine understands internally, and it's useful for
consuming the protocol from something that doesn't want to parse YAML):
- Minecraft Java 1.8.9 (
examples/example_protocol.yml/.json) — advanced patterns: parametrizable switch, relativecompareTo, bitfield. - Minecraft Classic / ClassiCube 0x07 + CPE
(
examples/classicube_protocol.yml/.json) — a complete fixed-size protocol, no varints, with CPE extensions.
1. The idea in 30 seconds
from protolib.core import Protocol
proto = Protocol("my_protocol.yml")
# bytes -> dict
parsed = proto.parse_packet("play", "toClient", raw_data)
print(parsed.name, parsed.params, parsed.bytes_read)
# dict -> bytes
data = proto.serialize_packet("play", "toClient", "spawn_player", {
"playerId": 5, "x": 320, "y": 2080, "z": 320,
})
Everything else in this document is about how to declare
my_protocol.yml so that parse_packet/serialize_packet know which
fields to expect.
2. Install / use
No dependencies beyond pyyaml (for the .yml format). Used as a
local package, no need to install it:
import sys
sys.path.insert(0, "path/to/the/folder/containing/protolib")
from protolib.core import Protocol
Protocol(...) accepts:
- a path to a
.json,.yml, or.yamlfile - an in-memory string with JSON or YAML content
- an already-parsed
dict(the "native" node-protodef format)
Heads up about relative paths: a path like
"my_protocol.yml"is resolved relative to your process's current working directory (cwd), not relative to the script file that callsProtocol(...). If you run your script from a different folder than the one containing the.yml, the file won't be found there.If the file genuinely can't be found,
Protocol(...)now raises a clearLoaderErrortelling you the path it looked for and the cwd it used — it does not silently try to parse the filename string as protocol content (that used to happen and produced a confusing crash deep insidecore.pyinstead of a real "file not found" error).To make a script work no matter where it's run from, build the path relative to the script itself instead of relying on cwd:
import os here = os.path.dirname(os.path.abspath(__file__)) proto = Protocol(os.path.join(here, "my_protocol.yml"))
3. Structure of a protocol file
types:
# types valid across the ENTIRE protocol, regardless of state/direction
varint: native
myCustomType:
container:
- name: x
type: varint
<state_name>: # e.g.: "play", "login", "handshaking"...
toClient:
types:
# types that only exist for this direction/state, take
# PRIORITY over the ones above if there's a name collision
packet: ...
toServer:
types:
packet: ...
- global
types:— shared types (primitives, common structs). <state>.toClient.types/<state>.toServer.types— the real entry point to parse a packet is always a type namedpacketinside one of these blocks.parse_packet(state, direction, data)looks for thatpacket, reads it, and returns{name, params}based on how you built it (see the "packet container with mapper+switch" pattern in section 7).
Important:
Protocol.parse_packet/serialize_packetexpect thepackettype to ALWAYS have exactly two fields namednameandparams— that's what builds theParsedPacket(name=..., params=...)you get back. The standard pattern (used in both example protocols) is:packet: container: - name: name type: mapper: # wire integer -> packet name type: u8 # (or varint, depending on your protocol) mappings: '0x00': identification - name: params type: switch: # packet name -> its payload type compareTo: name fields: identification: packet_identificationIf your
packetdoesn't follow this exact shape,parse_packetwill fail withKeyError: 'name'— it's not a weird limitation of your protocol, it's literally how this library knows what to return.
state and direction don't have to be called that — they're simply
the two keys you'll later use to call
parse_packet/serialize_packet. A protocol without real "states"
(like ClassiCube) still declares a single play: just to have
somewhere to hang toClient/toServer.
4. The native .json format (without the YAML shorthand)
Everything in section 3 can be written directly in JSON, without going
through YAML — it's the format already used by minecraft-data and
the original node-protodef, and this library understands it with no
translation needed. The difference with the .yml shorthand is one of
shape, not semantics: each composite type (container, switch,
array, bitfield, etc.) is written as a 2-element list instead of a
single-key mapping:
["<base_type_name>", <options>]
The same switch from section 6, in YAML shorthand:
type:
switch:
compareTo: name
fields:
identification: packet_identification
...is this in native JSON:
{
"type": [
"switch",
{
"compareTo": "name",
"fields": { "identification": "packet_identification" }
}
]
}
And a container (which carries a list of fields instead of an
options-dict) follows the same ["container", [...]] pattern:
"packet_identification": [
"container",
[
{ "name": "protocolVersion", "type": "u8" },
{ "name": "name", "type": "string64" }
]
]
Why would you still want to write .yml? Because by hand, several
levels of nested 2-element lists are error-prone and hard to read (does
that ] close the container or the outer switch?). The loader
(protolib/loader.py) automatically translates the .yml shorthand
into this native form before Protocol uses it — that's why
example_protocol.json and example_protocol.yml (or
classicube_protocol.json/.yml) are exactly equivalent: they
serialize and parse byte-for-byte the same, one is just more
convenient to write/maintain by hand than the other.
So when should you use the raw .json directly?
- You already have a protocol from
minecraft-dataor anothernode-protodefproject in JSON and want to reuse it as-is, without rewriting it. - You want to generate the protocol programmatically from another language or script that has no business knowing the shorthand syntax.
- You want to see "what the engine actually understands internally"
with no ambiguity — useful for debugging a
.ymlthat isn't loading the way you expected: generate its.json(see below) and check if the translation came out as you thought.
Generating .json from an existing .yml, using the loader
itself (this guarantees it's faithful to what Protocol will actually
read, not a separately hand-made translation):
import json
from protolib.loader import load_protocol_dict
d = load_protocol_dict("my_protocol.yml")
with open("my_protocol.json", "w", encoding="utf-8") as f:
json.dump(d, f, indent=2, ensure_ascii=False)
This is how examples/classicube_protocol.json was generated, and
examples/example_protocol.json already existed in this project.
5. native: how primitive types get registered
native in the .yml means "don't define this here, look it up by
name in protolib/primitives.py". Already registered:
| Name | Size | Notes |
|---|---|---|
u8/i8 |
1 byte | unsigned/signed |
u16/i16 |
2 bytes BE | |
u32/i32 |
4 bytes BE | |
u64/i64 |
8 bytes BE | |
f32/f64 |
4/8 bytes BE | float/double |
lu16…lf64 |
same, little-endian | l prefix |
varint/varlong |
variable | signed zigzag LEB128 — no, see zigzag32/zigzag64 for those |
bool |
1 byte | |
void |
0 bytes | useful as an empty case in a switch |
cstring |
variable | \0-terminated |
UUID |
16 bytes | |
nbt/optionalNbt |
variable | Minecraft's NBT format |
string64 |
64 fixed bytes | CP437, space-padded (ClassiCube) |
buffer1024 |
1024 fixed bytes | raw, 0x00-padded (ClassiCube) |
fixedCoord/fixedCoordDelta |
2/1 bytes | fixed-point *32 (ClassiCube) |
If your protocol needs a new type that isn't a composition of the
ones above (say, a fixed-length string with a different encoding, or
a buffer of another fixed size), add it in Python following the
pattern of make_fixed_cp437_string/make_fixed_buffer in
primitives.py, and register it in the PRIMITIVES dict at the end
of the file. After that it's available as native in any .yml, for
you and for anyone else using this library.
Non-native types (pstring, container, switch, array,
bitfield, bitflags, buffer, mapper, option, count) are
resolved by the Python engine (core.py) according to their own logic
— explained below with examples.
6. Composite types, one by one
container — groups named fields, in order
packet_login:
container:
- name: protocolVersion
type: varint
- name: username
type: string
Reads as {"protocolVersion": ..., "username": ...}.
anon: true field: instead of being stored under its own name, its
sub-fields get merged directly into the parent container's dict.
Useful for "breaking up" a bitfield or a switch into several loose
fields at the same level (see bitfield below).
condition field: the field is only read/written if the condition
(an expression over already-read fields) is true. Syntax: same path
language as compareTo (section 7).
array — list of N elements of the same type
type:
array:
countType: varint # reads a varint first, that's the length
# count: otherField # alternative: uses the VALUE of another already-read field
type: myItemType
countType prepends an integer that's read/written automatically.
count, on the other hand, references a sibling field already present
(typically built with the count type, see section 9) — it doesn't
write anything new, it just reads that existing value as the array's
length.
switch — picks the field's type based on another field's value
type:
switch:
compareTo: name # path to an already-read field (see section 7)
fields:
valueA: typeForA
valueB: typeForB
default: void # optional, if nothing matches
This is the central piece for "the params field depends on what the
name field says" — the pattern of every network packet with an
id/name.
mapper — integer ⟷ symbolic name
type:
mapper:
type: varint
mappings:
'0x00': handshake
'0x01': status_request
When reading, a 0x00 in the stream becomes the string "handshake"
in the resulting dict (and vice versa when writing). This way the
switch above can compare against readable names (compareTo: name)
instead of the raw packet id integer.
bitfield — several fields packed into less than 1 byte each
type:
bitfield:
- name: type
size: 3
signed: false
- name: index
size: 5
signed: false
Declares sub-fields that each take up size bits, MSB first, within
the minimum necessary bytes (if they don't fill an exact byte, padding
is added). Almost always combined with anon: true on the container
that holds it, so that type/index end up as loose fields instead of
nested under an extra name.
bitflags — an integer as a named set of booleans
type:
bitflags:
type: u8
flags: [air, water, lava, null, fire] # position = bit number
# or, as a dict: {air: 0x01, water: 0x02, ...}
Returns {"air": true, "water": false, ..., "_value": <raw integer>}.
buffer — raw bytes (uninterpreted)
type:
buffer:
count: 1024 # fixed size, or a reference to a field
# countType: varint # alternative: prefixed length
# rest: true # alternative: "whatever's left in the frame"
option — present only if a preceding boolean flag says so
type:
option: myType
Reads a bool first; if true, reads myType right after; if
false, the value is None and nothing else is read.
pstring — string with a prefixed length
type:
pstring:
countType: varint # how many length-bytes come before it
# encoding: utf-8 # default
The string: shortcut already ships built with this
(countType: varint, used in Minecraft Java). For ClassiCube,
string64 is used instead (ALWAYS fixed size, no prefix — see
section 5), since that protocol doesn't prefix a length.
7. compareTo and field paths (../, /)
compareTo (in a switch) and condition (on a container field)
use the same tiny path language to refer to "a field that's already
been read":
| Syntax | Means |
|---|---|
fieldName |
sibling field, same container |
../fieldName |
goes up one level: the container that holds this one |
../../fieldName |
goes up two levels |
/fieldName |
absolute, from the root of the whole packet |
Important gotcha, already documented with a full example in
examples/README.md: ../ only goes up one real level when the
container is written inline (literally inside array.type or
another container). If instead you reference a type by name
(type: myNamedType), that named type always adds its own nesting
level — so a ../field inside it reaches, at most, the named
container itself, not the real parent further up. If your switch needs
to see a field higher up in the hierarchy, declare the container
inline at the point of use instead of moving it into a separately
named type.
8. Parametrizable types ($arg)
A named type can declare a $-prefixed placeholder instead of a fixed
value, and whoever uses it decides the real value at that point:
types:
itemByType:
switch:
compareTo: $compareTo # placeholder -- the name after
fields: # the $ is arbitrary, you choose it
'0': i8
'1': varint
myContainer:
container:
- name: type
type: u8
- name: value
type:
- itemByType # referenced as [name, options]
- compareTo: type # here the real $compareTo gets resolved
This defines a "generic" type once and reuses it with a different
comparison field depending on context — it's exactly how
minecraft-data defines entityMetadataItem.
9. count — a length-prefix declared apart from the array/buffer
For the (rare, but real in some protocols) case where an array's length prefix isn't attached to that array but is instead its own field elsewhere in the container:
container:
- name: itemCount
type:
count:
type: varint
countFor: items # when WRITING, ignores the given value and
- name: items # writes len(items) automatically
type:
array:
count: itemCount # when READING, references that already-read field
type: myItem
10. The 4 advanced Minecraft Java patterns
Covered with working, commented examples in
examples/README.md +
examples/example_protocol.yml
(its .json equivalent, already translated to the native ["type", opts] form, lives in
examples/example_protocol.json):
- Parametrizable named type with
$arg(section 8 of this doc). compareTo: ../fieldand the inline-vs-named container gotcha (section 7).- Array of containers with a
switchinside that looks at a field of the array's parent container. bitfieldpacked into 1 byte +switchthat uses that field to pick the type of another sibling field (real pattern fromentity_metadatain Minecraft 1.8.9-1.16).
Run python examples/demo.py to see them in action.
11. Full reference protocol: ClassiCube
examples/classicube_protocol.yml
(equivalent in
examples/classicube_protocol.json,
byte-for-byte identical when serializing/parsing — generated by the
loader itself, not hand-written)
has the complete 0x07 protocol (Identification, Level Init/Data/
Finalize, Set Block, Spawn/Position/Despawn Player, Message,
Disconnect, Update User Type) plus several CPE extensions (ExtInfo/
ExtEntry, ClickDistance, CustomBlockSupportLevel, HeldBlock,
ExtPlayerList) — a good example of a protocol with no varints, all
fixed size, useful if your use case doesn't look like Minecraft Java's.
examples/servidor.py is a real, minimal server (login + generates a
world and sends it) built entirely on top of Protocol.parse_packet /
serialize_packet — it's an example of how to integrate this library
with real sockets (a recv() loop accumulating bytes until
parse_packet stops throwing BufferUnderrun).
cd examples
python servidor.py # caves, port 25565
python servidor.py --modo llano # flat world
12. Errors and what they mean
All of them live in protolib/errors.py:
UnknownTypeError— a type name isn't innative(primitives.py) nor defined intypes:(global or scoped). Usually a typo, or you forgot to declare it.SwitchCaseNotFound— aswitchhas nofieldsentry for the valuecompareToproduced, and there's nodefaulteither. Check that the comparison value resolves to what you expect (does themapperbuilding that field convert it to a string? doescompareTopoint to the right field using the syntax from section 7?).InvalidTypeDefinition— a composite type is missing a required option (arraywithoutcount/countType,bufferwithoutcount/countType/rest, etc.).BufferUnderrun(inprotolib/io.py, noterrors.py) — an attempt was made to read more bytes than are available. In a real server this is NOT necessarily a protocol error: it means "the full packet hasn't arrived on the socket yet," and the correct pattern is to catch it and wait for more bytes (see the loop inservidor.py).
13. Full API (quick reference)
Everything from protolib import ... exposes (protolib/__init__.py),
with its real signature. What's already covered with examples above is
referenced, not repeated.
protolib.core — the engine
Protocol(protocol_source: dict | str, *, fmt: str | None = None)
See sections 1–2. fmt forces "json"/"yaml" when protocol_source
is in-memory content with no recognizable extension (passed straight
through to load_protocol_dict).
Public methods of Protocol:
| Method | Returns | Use |
|---|---|---|
parse_packet(state, direction, data: bytes) |
ParsedPacket |
the usual one (section 1). |
serialize_packet(state, direction, name: str, params: dict) |
bytes |
the usual one (section 1). |
read_named(state, direction, type_name: str, data: bytes) |
Any |
reads a type by name that isn't necessarily the full packet — useful for testing/debugging a standalone type (e.g. an intermediate container) without going through the name/params wrapper. |
write_named(state, direction, type_name: str, value) |
bytes |
the inverse of read_named. |
get_scope(state, direction) |
Scope |
the internal object holding the resolved types for that state/direction, in case you need to inspect what got registered there. |
ParsedPacket (dataclass, what parse_packet returns):
ParsedPacket(name: str, params: dict, bytes_read: int)
bytes_read tells you how many bytes of the original buffer the
packet consumed — useful if you're parsing several packets stuck
together in the same buffer without going through PacketFramer.
Scope (dataclass): Scope(types: dict[str, TypeDef]) — types local
to a state.direction, taking priority over the global ones.
protolib.io — raw byte layer
Reader(buffer: bytes | bytearray | memoryview, offset: int = 0)
Reader.remaining -> int # property
Reader.ensure(n: int) -> None # raises BufferUnderrun if not enough bytes
Reader.read_bytes(n: int) -> bytes
Reader.peek_byte() -> int # does not advance the offset
Writer()
Writer.write_bytes(data: bytes) -> None
Writer.result() -> bytes # concatenates all chunks
len(writer) # total bytes accumulated
BufferUnderrun(offset, needed, available) — see section 12.
protolib.framer — splitting a socket stream into packets
Not mentioned in the sections above; it's the missing piece between
"bytes are arriving from recv()" and "I have a complete frame to
hand to parse_packet". Assumes the Minecraft format
[varint length][payload].
from protolib import PacketFramer
framer = PacketFramer()
# in your recv() loop:
frames = framer.feed(chunk) # list[bytes], can come back empty, or
for frame in frames: # with several frames if multiple packets
pkt = proto.parse_packet("play", "toServer", frame) # arrived stuck together
# when sending (after serialize_packet, which does NOT add the length-prefix):
raw = proto.serialize_packet("play", "toClient", "keep_alive", {...})
sock.send(PacketFramer.wrap(raw))
PacketFramer.wrap(frame: bytes) -> bytes is a @staticmethod, no
instance needed. It doesn't compress or encrypt — that goes in your
own intermediate layer if your protocol needs it (see the module's
docstring).
protolib.nbt — Named Binary Tag
Not mentioned above except as a row in the primitives table
(nbt/optionalNbt). If you need to read/write standalone NBT,
outside of a protocol declared in .yml:
from protolib import read_nbt, write_nbt
from protolib.io import Reader, Writer
tag = read_nbt(Reader(raw_bytes))
# -> {"name": str, "type": "compound"|"int"|"list"|..., "value": ...} | None
# (None if the first byte is TAG_End)
w = Writer()
write_nbt(tag, w)
raw_bytes_back = w.result()
read_optional_nbt/write_optional_nbt are identical aliases,
exposed only so the name matches optionalNbt from
node-minecraft-protocol when generating/reading someone else's
protocol.json. NBTError is the module's own exception (unknown tag,
etc).
protolib.conditions — the condition field on a container
Section 3 mentions condition but says "same path language as
compareTo" — it's actually a separate expression grammar, closer to
boolean JS, without eval()/exec():
eval_condition(expr: str, fields: dict, root: dict | None = None,
parent: dict | None = None) -> bool
Grammar supported in condition::
fields.someField === 1
fields.type !== 0 && fields.flag == true
$root.version >= 47
$parent.hasData
(fields.a > 0) || (fields.b < 10)
Operators: === !== == != >= <= > < && ||. Paths: fields.x,
$root.x, $parent.x, with [N] index support. If the expression is
a single operand with no comparison, its truthiness gets evaluated
(same as JS/Python). Raises ConditionError if the expression isn't
valid.
protolib.loader — loading and converting formats
load_protocol_dict(source: str | dict, *, fmt: str | None = None,
extra_composite_names: frozenset[str] | None = None) -> dict
See section 4. extra_composite_names is for when you extend
core.py with your own composite types (beyond container, switch,
array, etc.) and want the YAML shorthand to also recognize them as a
single-key mapping instead of a [name, opts] list.
protocol_dict_to_yaml(protocol_dict: dict) -> str
The inverse operation from section 4: takes a dict already in
native ["type", opts] format (e.g. a minecraft-data protocol.json
as-is, untouched) and returns the equivalent YAML shorthand, readable
by hand. It's "best effort" — meant to kick off a JSON→YAML migration,
not to guarantee a byte-for-byte identical result in the generated
YAML (the YAML→JSON encoding from section 4 is exact, the inverse
can't always be 1:1).
LoaderError — see section 12 (path not found, invalid JSON/YAML,
PyYAML not installed).
protolib.primitives — everything native available
The table in section 5 doesn't list every combination. The full set of
fixed-width integers is {u,i} × {8,16,24,32,40,48,56,64} plus their
little-endian variants (l + the same), 32 names total
(u8, i8, u16, i16, ..., u64, i64, lu8, li8, ..., lu64, li64). Besides
that:
| Name | What it is |
|---|---|
varint/varlong |
signed LEB128 (no zigzag applied — see zigzag32/64 below if you need that) |
uvarint/uvarlong |
unsigned LEB128 |
zigzag32/zigzag64 |
true zigzag LEB128 (Protocol Buffers style) |
rest_buffer |
all remaining bytes in the buffer, no length prefix |
buffer64 |
1024→64 fixed bytes, \x00-padded (added in 0.2.1, see Changelog) |
from protolib import PRIMITIVES, Primitive, make_fixed_utf16be_string
PRIMITIVES["varint"].read(reader) # -> int
PRIMITIVES["varint"].write(123, writer) # -> None, writes to writer
PRIMITIVES["varint"].size_of(123) # -> int, bytes it'll take up
fixed_name = make_fixed_utf16be_string(16) # new Primitive, not registered
Primitive is a @dataclass(frozen=True) with 4 fields: name,
read(reader) -> Any, write(value, writer) -> None, and an optional
size_of(value) -> int | None. make_fixed_cp437_string and
make_fixed_buffer (mentioned in section 5) follow the same pattern
if you need to build your own fixed primitive.
protolib.errors — every exception
ProtolibError is the base for all of them. See section 12 for
UnknownTypeError, InvalidTypeDefinition, SwitchCaseNotFound.
ConditionError is explained above, under conditions.
Changelog
0.2.1
- New primitive
buffer64: fixed 64-byte raw bytes with automatic\x00padding (same semantics asbuffer1024, built with the samemake_fixed_buffermold). Added while migratingPocketNetto a singleprotocol.yml— thedatafield ofPluginMessagePacket(0x35) needed exactly this behavior, and the genericbufferwith a fixedcountis strict (fails if the value isn't exactly N bytes long instead of truncating/padding). - New example:
examples/pocketnet_protocol.yml, the complete Minecraft Classic 0.30 + CPE protocol (55 packets) migrated from the "one class per packet" style into a single declarative file. Tested with byte-exact round-trips against the originalencode()/decode()on the most complex cases (packed bitfields, nested arrays of containers, padded buffers).
0.2.0
- Base version: primitives, composites (
container,array,switch,mapper,option,bitfield,bitflags,buffer,pstring,count,entityMetadataLoop,topBitSetTerminatedArray), JSON and YAML support, native NBT and UUID.
Project details
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file protolib-0.3.1.tar.gz.
File metadata
- Download URL: protolib-0.3.1.tar.gz
- Upload date:
- Size: 50.0 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/7.0.0 CPython/3.13.13
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
e27a7821d7110ebdca7f1d74f23c1d42306c5119b3865646e2b12d0715c20fe3
|
|
| MD5 |
86b243472a610053f600a5afd2f2dd0c
|
|
| BLAKE2b-256 |
19ac03df1939d5e1ac961810f4715904efbdf2daad3d0fafe0e41977e11ae7bc
|
File details
Details for the file protolib-0.3.1-py3-none-any.whl.
File metadata
- Download URL: protolib-0.3.1-py3-none-any.whl
- Upload date:
- Size: 42.2 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/7.0.0 CPython/3.13.13
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
be26a334d0a8d56e71643cacb6a3be8cb580afb63c34be3363004e546a34613f
|
|
| MD5 |
b6dbfa3a95a6abe96c82384693ea6882
|
|
| BLAKE2b-256 |
8e7d0293a0f90f9db140e367a54ca24deb26dcd735e8748d303f4283de4d0574
|