Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
5ed80ff
Add Quantization as a protobuf plugin
mikhail-dcl Feb 25, 2026
e51e663
pulse_comms.proto
mikhail-dcl Feb 25, 2026
8754119
add server message & handshake response
lorux0 Mar 2, 2026
1e8d37a
add player state full/delta
lorux0 Mar 5, 2026
5f2a9fe
add player joined message
lorux0 Mar 5, 2026
799c64c
add player joined into server message
lorux0 Mar 5, 2026
26687ce
add player state into client message
lorux0 Mar 5, 2026
7deef0b
Merge branch 'experimental' into quantization
mikhail-dcl Mar 6, 2026
f597d53
Update protobufjs to 7.5.4 to resolve the problem with namespaces
mikhail-dcl Mar 6, 2026
c52cda6
proto/google/ is no longer created or published, so both buf (CI) and…
mikhail-dcl Mar 6, 2026
0bed313
fix: exclude proto/google from npm package to fix C# codegen namespace
mikhail-dcl Mar 6, 2026
21970a3
Adjust states encoding
mikhail-dcl Mar 6, 2026
48f751d
Add quantization example for PlayerStateDelta
mikhail-dcl Mar 6, 2026
417af32
Copy protoc-gen-bitwise to the output package
mikhail-dcl Mar 6, 2026
2d42c07
Make Quantize.cs compitable with Unity
mikhail-dcl Mar 6, 2026
0888875
Isolate PlayerState as a reusable component
mikhail-dcl Mar 8, 2026
a9c8af9
Add requried quntizationfor delta
mikhail-dcl Mar 11, 2026
51f6bdb
Add RESYNC_REQUEST
mikhail-dcl Mar 11, 2026
1526ae8
ProfileVersionAnnouncement
mikhail-dcl Mar 13, 2026
160a224
add emote messages
lorux0 Mar 20, 2026
d441f81
add duration on EmoteStart for one shot emotes
lorux0 Mar 23, 2026
ff63197
Fix MovementBlend Range
mikhail-dcl Mar 30, 2026
a7cc361
add teleport messages
lorux0 Mar 30, 2026
f377aeb
Add "jump_count"
mikhail-dcl Mar 30, 2026
72711af
Merge remote-tracking branch 'origin/quantization' into quantization
mikhail-dcl Mar 30, 2026
18c4d84
add teleport ClientMessage & ServerMessage
lorux0 Mar 30, 2026
4a10b7e
Add BaselineSeq to delta
mikhail-dcl Mar 31, 2026
2fb297b
add parcel index & player state in teleport
lorux0 Mar 31, 2026
13a1d38
add head yaw and head pitch to state flags
lorux0 Apr 1, 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
198 changes: 198 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,3 +67,201 @@ In this case, there is no problem with when each PR is merged. It's recommendabl
## Comms

TODO

---

# Bitwise Serialization Plugin (`protoc-gen-bitwise`)

A custom protoc plugin that generates C# partial classes with typed float
accessors for quantized `uint32` fields in high-frequency MMO networking
messages (position deltas, player input, etc.). It runs alongside
`--csharp_out` in the same protoc invocation; the two output files coexist
via C# `partial class`.

## How it works

Protobuf encodes `uint32` values as varints, which are already compact for
small values: a value up to 2¹⁴−1 costs 2 bytes, up to 2²¹−1 costs 3 bytes.
Rather than a separate binary packing layer, the plugin leverages this:

1. Declare quantized fields as `uint32` in the `.proto` schema and annotate
them with `[(decentraland.common.quantized)]` to specify the float range
and bit resolution.
2. `--csharp_out` generates the standard protobuf class with the raw `uint32`
property (e.g. `PositionX`).
3. `--bitwise_out` (this plugin) generates a `partial class` extension with a
cached float accessor (e.g. `PositionXQuantized`) that encodes/decodes
transparently via `Quantize.Encode` / `Quantize.Decode`.

The wire representation is a standard protobuf message — any protobuf-capable
client can read it without knowledge of the plugin.

## Prerequisites

| Requirement | Version |
|---|---|
| Python | 3.10+ |
| `protobuf` Python package | 4.x or 3.20+ |
| `protoc` | 3.19+ |

```bash
pip install protobuf
```

## Step 1 — Annotate your `.proto` file

Declare quantized fields as `uint32` and import `options.proto`:

```protobuf
syntax = "proto3";

import "decentraland/common/options.proto";

package decentraland.kernel.comms.v3;

message PositionDelta {
// Float range [-100, 100] quantized to 16 bits ≈ 0.003-unit precision.
// Stored as uint32 on the wire; protobuf encodes it as a 3-byte varint.
uint32 dx = 1 [(decentraland.common.quantized) = { min: -100.0, max: 100.0, bits: 16 }];
uint32 dy = 2 [(decentraland.common.quantized) = { min: -100.0, max: 100.0, bits: 16 }];
uint32 dz = 3 [(decentraland.common.quantized) = { min: -100.0, max: 100.0, bits: 16 }];

// Unannotated uint32: protobuf varint encodes small values compactly by default.
uint32 entity_id = 4 [(decentraland.common.bit_packed) = { bits: 20 }];
}
```

### Annotation reference

| Annotation | Target type | Parameters | Effect |
|---|---|---|---|
| `[(decentraland.common.quantized)]` | `uint32` | `min`, `max`, `bits` | Plugin emits a cached `float {Name}Quantized` accessor |
| `[(decentraland.common.bit_packed)]` | `uint32` | `bits` | Documents the value range; protobuf handles varint compaction automatically |

### Wire cost at worst-case (all bits set)

| Quantization bits | Max value | Varint bytes | Tag (field ≤ 15) | Total per field |
|---|---|---|---|---|
| 8 | 255 | 2 | 1 | 3 B |
| 12 | 4 095 | 2 | 1 | 3 B |
| 14 | 16 383 | 2 | 1 | 3 B |
| 16 | 65 535 | 3 | 1 | 4 B |
| 20 | 1 048 575 | 3 | 1 | 4 B |

Proto3 omits fields equal to their default value (0), so average cost is lower.

## Step 2 — Run protoc

```bash
protoc \
--proto_path=proto \
--proto_path=/path/to/google/protobuf/include \
--csharp_out=generated/cs \
--plugin=protoc-gen-bitwise=protoc-gen-bitwise/plugin.py \
--bitwise_out=generated/cs \
proto/decentraland/kernel/comms/v3/comms.proto
```

The plugin emits one `*.Bitwise.cs` file (PascalCase, flat in the output
directory) for each `.proto` file that contains at least one `[(quantized)]`
field.

## Step 3 — Copy the runtime

Copy `Quantize.cs` into your project:

```
Assets/
└── Scripts/
└── Networking/
└── Bitwise/
└── Quantize.cs ← protoc-gen-bitwise/runtime/cs/Quantize.cs
```

`Quantize.cs` lives in the `Decentraland.Networking.Bitwise` namespace and
provides two static methods used by the generated accessors:

```csharp
public static class Quantize
{
public static uint Encode(float value, float min, float max, int bits);
public static float Decode(uint encoded, float min, float max, int bits);
}
```

## Step 4 — Use the generated code

The plugin emits a `partial class` that adds float accessors on top of the
standard protobuf-generated `uint32` properties:

```csharp
using Decentraland.Kernel.Comms.V3;

// --- Build and send ---
var delta = new PositionDelta();
delta.DxQuantized = 3.14f; // encodes to uint32, stored in delta.Dx
delta.DyQuantized = 0f;
delta.DzQuantized = -7.5f;
delta.EntityId = 42u;

byte[] bytes = delta.ToByteArray(); // standard protobuf serialization
SendOnChannel1(bytes);

// --- Receive and read ---
var received = PositionDelta.Parser.ParseFrom(receivedBytes);
float x = received.DxQuantized; // decoded on first access, cached thereafter
float y = received.DyQuantized;
float z = received.DzQuantized;

// If raw uint32 fields are mutated directly after construction, invalidate the cache:
received.ResetDecodedCache();
```

## Generated file example

For the `PositionDelta` message above the plugin emits `PositionDelta.Bitwise.cs`:

```csharp
// <auto-generated>
// Generated by protoc-gen-bitwise. DO NOT EDIT.
// Source: decentraland/kernel/comms/v3/comms.proto
// </auto-generated>

using Decentraland.Networking.Bitwise;

namespace Decentraland.Kernel.Comms.V3
{
public partial class PositionDelta
{
private float? _dx;
public float DxQuantized
{
get => _dx ??= Quantize.Decode(Dx, -100.0f, 100.0f, 16);
set { _dx = value; Dx = Quantize.Encode(value, -100.0f, 100.0f, 16); }
}

private float? _dy;
public float DyQuantized
{
get => _dy ??= Quantize.Decode(Dy, -100.0f, 100.0f, 16);
set { _dy = value; Dy = Quantize.Encode(value, -100.0f, 100.0f, 16); }
}

private float? _dz;
public float DzQuantized
{
get => _dz ??= Quantize.Decode(Dz, -100.0f, 100.0f, 16);
set { _dz = value; Dz = Quantize.Encode(value, -100.0f, 100.0f, 16); }
}

/// <summary>Clears all cached decoded values. Call after mutating raw uint32 fields directly.</summary>
public void ResetDecodedCache()
{
_dx = null;
_dy = null;
_dz = null;
}
}

} // namespace Decentraland.Kernel.Comms.V3
```
5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,10 @@
"protobufjs": "7.2.4"
},
"files": [
"proto",
"proto/decentraland",
"out-ts",
"out-js",
"public"
"public",
"protoc-gen-bitwise"
]
}
30 changes: 30 additions & 0 deletions proto/decentraland/common/options.proto
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
syntax = "proto3";

package decentraland.common;

import "google/protobuf/descriptor.proto";

// Options for quantizing a float value stored as a uint32 field.
// The float is clamped to [min, max] and uniformly quantized to N bits;
// protobuf encodes the resulting uint32 as a varint on the wire.
// The generator emits a float {Name}Quantized accessor in the partial class.
message QuantizedFloatOptions {
float min = 1;
float max = 2;
uint32 bits = 3;
}

// Options for bit-packing an integer field into fewer than 32 bits.
message BitPackedOptions {
uint32 bits = 1;
}

extend google.protobuf.FieldOptions {
// Apply to uint32 fields to enable quantized float encoding.
// Example: uint32 dx = 1 [(decentraland.common.quantized) = { min: -100.0, max: 100.0, bits: 16 }];
QuantizedFloatOptions quantized = 50001;

// Apply to uint32 fields to pack into fewer bits.
// Example: uint32 entity_id = 4 [(decentraland.common.bit_packed) = { bits: 20 }];
BitPackedOptions bit_packed = 50002;
}
132 changes: 132 additions & 0 deletions proto/decentraland/common/quantization_example.proto
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
syntax = "proto3";

package decentraland.common;

import "decentraland/common/options.proto";

// High-frequency player state messages sent on Channel 1 (unreliable sequenced).
//
// Every message below uses the protoc-gen-bitwise annotations to minimise
// wire size. Wire costs are listed per-message so the trade-offs are clear.
//
// Annotation cheat-sheet:
// [(decentraland.common.quantized) = { min: F, max: F, bits: N }]
// → stores a float as a uint32 quantized to N bits over [min, max].
// → protobuf encodes the uint32 as a varint: values ≤ 2^14-1 cost 2 bytes,
// values ≤ 2^21-1 cost 3 bytes; the generated partial class adds a
// float {Name}Quantized accessor that encodes/decodes transparently.
// [(decentraland.common.bit_packed) = { bits: N }]
// → documents that this uint32 uses at most N bits; protobuf encodes it
// as a varint (same savings, no generated accessor needed).
// (no annotation)
// → standard protobuf encoding (bool/double/etc. at natural width).
//
// Varint byte costs at worst-case (all bits set):
// ≤ 7 bits → 1 byte (max 127)
// ≤ 14 bits → 2 bytes (max 16 383)
// ≤ 21 bits → 3 bytes (max 2 097 151)
// Proto3 omits fields equal to their default value (0), so average cost is lower.

// ---------------------------------------------------------------------------
// PositionDelta — Δ position relative to last acknowledged full snapshot.
//
// Sent every client tick (~10 Hz) on Channel 1.
//
// Field | Type | Range | Q bits | Wire worst-case
// ------------|--------|----------------|--------|-----------------------
// dx | uint32 | [-100, 100] | 16 | tag 1B + varint 3B = 4B
// dy | uint32 | [-100, 100] | 16 | tag 1B + varint 3B = 4B
// dz | uint32 | [-100, 100] | 16 | tag 1B + varint 3B = 4B
// entity_id | uint32 | [0, 1 048 575] | 20 | tag 1B + varint 3B = 4B
// sequence | uint32 | [0, 4 095] | 12 | tag 1B + varint 2B = 3B
// ---------------------------------------------------------------------------
// Worst-case: 19 B (vs. 20 B raw: 3×float + 2×uint32)
// Step: dx/dy/dz ≈ 0.003 units
// ---------------------------------------------------------------------------
message PositionDelta {
uint32 dx = 1 [(decentraland.common.quantized) = { min: -100.0, max: 100.0, bits: 16 }];
uint32 dy = 2 [(decentraland.common.quantized) = { min: -100.0, max: 100.0, bits: 16 }];
uint32 dz = 3 [(decentraland.common.quantized) = { min: -100.0, max: 100.0, bits: 16 }];
uint32 entity_id = 4 [(decentraland.common.bit_packed) = { bits: 20 }];
uint32 sequence = 5 [(decentraland.common.bit_packed) = { bits: 12 }];
}

// ---------------------------------------------------------------------------
// PlayerInput — client input snapshot for server-side reconciliation.
//
// Sent every client frame (~30 Hz) on Channel 1.
//
// Field | Type | Range | Q bits | Wire worst-case
// ------------|--------|----------------|--------|-----------------------
// move_x | uint32 | [-1, 1] | 8 | tag 1B + varint 2B = 3B
// move_z | uint32 | [-1, 1] | 8 | tag 1B + varint 2B = 3B
// yaw | uint32 | [-180, 180] | 12 | tag 1B + varint 2B = 3B
// buttons | uint32 | bitmask | 8 | tag 1B + varint 2B = 3B
// sequence | uint32 | [0, 4 095] | 12 | tag 1B + varint 2B = 3B
// ---------------------------------------------------------------------------
// Worst-case: 15 B (vs. 20 B raw: 3×float + 2×uint32)
// Step: move_x/move_z ≈ 0.008; yaw ≈ 0.088°
// ---------------------------------------------------------------------------
message PlayerInput {
// Normalised joystick axes in [-1, 1].
uint32 move_x = 1 [(decentraland.common.quantized) = { min: -1.0, max: 1.0, bits: 8 }];
uint32 move_z = 2 [(decentraland.common.quantized) = { min: -1.0, max: 1.0, bits: 8 }];

// Horizontal look direction in degrees.
uint32 yaw = 3 [(decentraland.common.quantized) = { min: -180.0, max: 180.0, bits: 12 }];

// Bitmask of active buttons (see ButtonFlags below).
uint32 buttons = 4 [(decentraland.common.bit_packed) = { bits: 8 }];

// Rolling input sequence number used by the server for reconciliation.
uint32 sequence = 5 [(decentraland.common.bit_packed) = { bits: 12 }];
}

// Bitmask values for PlayerInput.buttons.
enum ButtonFlags {
BF_NONE = 0;
BF_JUMP = 1; // bit 0
BF_SPRINT = 2; // bit 1
BF_INTERACT = 4; // bit 2
BF_EMOTE = 8; // bit 3
BF_CROUCH = 16; // bit 4
// bits 5-7 reserved
}

// ---------------------------------------------------------------------------
// AvatarStateSnapshot — full authoritative state, sent on Channel 0 (reliable)
// or on resync requests. Demonstrates wider ranges and mixed encodings.
//
// Field | Type | Range | Q bits | Wire worst-case
// -----------------|--------|------------------|--------|-----------------------
// x | uint32 | [-4096, 4096] | 16 | tag 1B + varint 3B = 4B
// y | uint32 | [-256, 256] | 14 | tag 1B + varint 2B = 3B
// z | uint32 | [-4096, 4096] | 16 | tag 1B + varint 3B = 4B
// pitch | uint32 | [-90, 90] | 10 | tag 1B + varint 2B = 3B
// yaw | uint32 | [-180, 180] | 12 | tag 1B + varint 2B = 3B
// entity_id | uint32 | [0, 1 048 575] | 20 | tag 1B + varint 3B = 4B
// animation_state | uint32 | [0, 63] | 6 | tag 1B + varint 1B = 2B
// is_grounded | bool | — | — | tag 1B + varint 1B = 2B
// timestamp | double | — | — | tag 1B + fixed64 8B = 9B
// ---------------------------------------------------------------------------
// Worst-case: 34 B (vs. 45 B raw: 5×float + 2×uint32 + bool + double)
// Step: x/z ≈ 0.125 units; y ≈ 0.031 units; pitch ≈ 0.176°; yaw ≈ 0.088°
// ---------------------------------------------------------------------------
message AvatarStateSnapshot {
// World-space position.
uint32 x = 1 [(decentraland.common.quantized) = { min: -4096.0, max: 4096.0, bits: 16 }];
uint32 y = 2 [(decentraland.common.quantized) = { min: -256.0, max: 256.0, bits: 14 }];
uint32 z = 3 [(decentraland.common.quantized) = { min: -4096.0, max: 4096.0, bits: 16 }];

// View angles.
uint32 pitch = 4 [(decentraland.common.quantized) = { min: -90.0, max: 90.0, bits: 10 }];
uint32 yaw = 5 [(decentraland.common.quantized) = { min: -180.0, max: 180.0, bits: 12 }];

// Identity and animation state.
uint32 entity_id = 6 [(decentraland.common.bit_packed) = { bits: 20 }];
uint32 animation_state = 7 [(decentraland.common.bit_packed) = { bits: 6 }];

// Un-annotated fields — encoded at their natural width.
bool is_grounded = 8;
double timestamp = 9; // server epoch milliseconds
}
Loading
Loading