Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
164 changes: 164 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
# CLAUDE.md — MMO Networking Stack

## Project Overview

High-performance MMO-style multiplayer networking stack. Protocol is **open** (Unity C# client + others). Infrastructure on **AWS**. Goals: high concurrency, aggressive interest management, low-latency state sync.

| Layer | Choice |
|---|---|
| Transport | ENet (UDP, channel-based) |
| Client | Unity (C#) |
| Server | Custom server |
| Schema source of truth | `.proto` files |
| Serialization | Standard protobuf wire format + custom protoc plugin (quantized float accessors) |
| Auth | Decentraland ECDSA chain validation (local, on HANDSHAKE channel 0) |

---

## Serialization: Custom Protoc Plugin

### What it does
Reads `.proto` files with custom field options and generates C# **partial classes** (`*.Bitwise.cs`) that add typed float accessors on top of quantized `uint32` fields, keeping the quantization math bit-for-bit identical across all client implementations.

The wire format is **standard protobuf** — a quantized value lives in a plain `uint32` field and travels as an ordinary varint. There is no custom bit stream; any protobuf-capable client can parse the messages without this plugin. Per annotated field the plugin emits:

- `float {Field}Quantized` — computed accessor (no backing cache): the getter decodes the stored `uint32`, the setter encodes a float back into it, via the static `Quantize` helpers
- `const float {Field}QuantizedStep` — the coarsest quantization step of the field, safe as an equality tolerance
- per message: `bool AreQuantizedFieldsInRange()` — pure-integer check that every stored code fits its declared bit width (`0 .. 2^bits-1`); reject malformed/hostile messages before storing or relaying

Only non-repeated `uint32` fields get accessors; `bit_packed` and unannotated fields pass through with no generated code.

### Custom Field Options (`options.proto`)

```protobuf
syntax = "proto3";
import "google/protobuf/descriptor.proto";

message QuantizedFloatOptions {
float min = 1;
float max = 2;
uint32 bits = 3;
}

// Signed power-law quantizer: an (bits-1)-bit magnitude (high bits) plus a sign
// (LSB), decoded as sign * max * u^pow. Exact zero; pow>1 concentrates resolution
// near zero; sign in the LSB keeps small magnitudes in one varint byte.
message QuantizedPowerFloatOptions {
float max = 1;
float pow = 2;
uint32 bits = 3;
}

message BitPackedOptions {
uint32 bits = 1;
}

extend google.protobuf.FieldOptions {
QuantizedFloatOptions quantized = 50001;
BitPackedOptions bit_packed = 50002;
QuantizedPowerFloatOptions quantized_power = 50003;
}
```

### Usage example

Quantized fields are declared **`uint32`** (not `float`) — the float type exists only in the generated accessor:

```protobuf
message PositionDelta {
uint32 dx = 1 [(quantized) = { min: -100.0, max: 100.0, bits: 16 }];
uint32 dy = 2 [(quantized) = { min: -100.0, max: 100.0, bits: 16 }];
uint32 dz = 3 [(quantized) = { min: -100.0, max: 100.0, bits: 16 }];
uint32 entity_id = 4 [(bit_packed) = { bits: 20 }];
uint32 sequence = 5 [(bit_packed) = { bits: 12 }];
}
// Varint wire cost: dx/dy/dz/entity_id ≤ 4 B each (1 B tag + ≤ 3 B varint),
// sequence ≤ 3 B — worst-case 19 B, less when proto3 omits zero-valued fields.
```

`proto/decentraland/common/quantization_example.proto` is the fully worked reference: per-field wire costs for the linear, power-law, and bit-packed annotations.

### Plugin structure

```
protoc-gen-bitwise/
├── plugin.js # stdin -> CodeGeneratorRequest, stdout -> CodeGeneratorResponse (Node)
├── generator_csharp.js # emits the *.Bitwise.cs accessor partials for Unity
├── options.js # parses the custom quantized / quantized_power / bit_packed field options
├── wire.js # self-contained protobuf wire codec (zero runtime deps)
└── runtime/cs/ # C# runtime: Quantize.cs — consumers copy it next to the generated files
```

Plugin contract: a protoc plugin that reads a serialized `CodeGeneratorRequest` from stdin and writes a serialized `CodeGeneratorResponse` to stdout. It is a plain Node script — **no `npm install` required, only `node` on PATH**. protoc invokes it through a tiny wrapper that runs `node plugin.js` (`.cmd` on Windows, a shell script elsewhere, since protoc cannot exec a `.js` directly).

```bash
protoc \
--proto_path=proto \
--bitwise_out=generated/ \
--plugin=protoc-gen-bitwise=protoc-gen-bitwise/plugin.js \
movement.proto position.proto
```

Parity is locked down by `npm run gen:test` (compares generator output against golden C# fixtures in `protoc-gen-bitwise/test/`).

---

## Quantize Runtime

Generated accessors call the static `Quantize` class (`protoc-gen-bitwise/runtime/cs/Quantize.cs`, namespace `Decentraland.Networking.Bitwise`) — the only C# runtime file; consumers copy it next to the generated `*.Bitwise.cs` partials. Quantization uses **`Round`** (not truncate) to minimize error; identical rounding on both sides makes encode -> decode a round-trip no-op.

### Core math — linear (`Quantize.Encode` / `Quantize.Decode`)

```
encoded = Round(clamp01((value - min) / (max - min)) * (2^bits - 1))
decoded = encoded / (2^bits - 1) * (max - min) + min
```

### Core math — power-law (`Quantize.EncodePower` / `Quantize.DecodePower`)

For signed fields like velocity that need an exact zero and fine resolution near zero:

```
u = clamp01(|value| / max) ^ (1 / pow)
encoded = (Round(u * (2^(bits-1) - 1)) << 1) | sign // magnitude in high bits, sign in LSB
decoded = sign * max * ((encoded >> 1) / (2^(bits-1) - 1)) ^ pow
```

- Zero encodes exactly to code `0` (a zero magnitude never sets the sign bit), so proto3 omits a stopped field entirely
- `pow > 1` concentrates resolution near zero, coarse near `±max`
- Sign in the LSB makes the varint cost track magnitude, not direction — a small `|value|` of either sign stays in one varint byte

---

## Precision Reference

| Range | Bits | Step size |
|-------------|------|---------------|
| [-100, 100] | 16 | ~0.003 units |
| [-10, 10] | 12 | ~0.005 units |
| [-100, 100] | 12 | ~0.049 units |

Sub-centimeter precision is achievable at 12-16 bits for position deltas.

Wire cost is varint-based — 1 tag byte per present field (field numbers ≤ 15) plus:

| Code bits | Worst-case varint | Worst-case field total |
|-----------|-------------------|------------------------|
| ≤ 7 | 1 B | 2 B |
| ≤ 14 | 2 B | 3 B |
| ≤ 21 | 3 B | 4 B |

Proto3 omits fields equal to 0, so typical cost is lower than worst-case.

---

## Key Design Principles

- `.proto` files are the **single source of truth** for all message schemas
- The protoc plugin generates the **C# quantized accessors** from the schema — never hand-write quantization math; standard protobuf handles the wire encoding
- Encode -> decode is a **no-op** (round-trip safe) due to consistent use of `Round`
- Validate inbound quantized messages with `AreQuantizedFieldsInRange()` before storing or relaying — the server relays raw codes verbatim
- Prefer **client-driven resync** over proactive server corrections
- Push complexity to clients where appropriate; server maintains authority
- Channel 0: reliable messages (STATE_FULL snapshots, ACKs, resync requests, HANDSHAKE)
- Channel 1: unreliable sequenced (high-frequency position deltas, client input)
217 changes: 217 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,3 +67,220 @@ 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
computed float accessor (e.g. `PositionXQuantized`) that encodes/decodes
transparently via `Quantize.Encode` / `Quantize.Decode` on every access —
the raw `uint32` property remains the single source of truth (no cache to
go stale when the raw field is mutated directly).

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

## Prerequisites

| Requirement | Version |
|---|---|
| Node.js | 16+ |
| `protoc` | 3.19+ |

The plugin is a dependency-free Node script — no `npm install` or extra
packages are required to run it.

## 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 `float {Name}Quantized` accessor and a `{Name}QuantizedStep` const |
| `[(decentraland.common.quantized_power)]` | `uint32` | `max`, `pow`, `bits` | Power-law quantizer over `[-max, max]`: `(bits-1)`-bit magnitude (high bits) + sign (LSB), decoded as `sign·max·u^pow`. Exact zero; `pow>1` gives fine resolution near zero, coarse near `±max`; sign in the LSB keeps small magnitudes one varint byte. `float {Name}Quantized` accessor (`Quantize.EncodePower`/`DecodePower`) |
| `[(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.js \
--bitwise_out=generated/cs \
proto/decentraland/kernel/comms/v3/comms.proto
```

> `plugin.js` carries a `#!/usr/bin/env node` shebang. On Windows, protoc
> cannot exec a `.js` directly, so point `--plugin` at a small `.cmd` wrapper
> that runs `node plugin.js`; on unix an equivalent shell script is used. Both
> consumer repos (Pulse, unity-explorer) generate this wrapper automatically.

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 the static encode/decode 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);
public static uint EncodePower(float value, float max, float pow, int bits);
public static float DecodePower(uint encoded, float max, float pow, 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);
if (!received.AreQuantizedFieldsInRange()) return; // reject malformed/hostile codes
float x = received.DxQuantized; // decoded from the stored uint32 on each access
float y = received.DyQuantized;
float z = received.DzQuantized;
```

## Generated file example

For the `comms.proto` file above the plugin emits `Comms.Bitwise.cs` (one file
per `.proto`, named after the proto file). Each quantized field gets a
`{Name}QuantizedStep` const and a float accessor; each message gets an
`AreQuantizedFieldsInRange()` guard for validating inbound wire codes:

```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
{
/// <summary>Coarsest quantization step of <see cref="DxQuantized"/>. Safe as an equality tolerance.</summary>
public const float DxQuantizedStep = 0.0030518044f;
/// <summary>Float accessor for <see cref="Dx"/>. Range [-100.0f, 100.0f], 16 bits, step ≈ 0.0030518.</summary>
public float DxQuantized
{
get => Quantize.Decode(Dx, -100.0f, 100.0f, 16);
set => Dx = Quantize.Encode(value, -100.0f, 100.0f, 16);
}

/// <summary>Coarsest quantization step of <see cref="DyQuantized"/>. Safe as an equality tolerance.</summary>
public const float DyQuantizedStep = 0.0030518044f;
/// <summary>Float accessor for <see cref="Dy"/>. Range [-100.0f, 100.0f], 16 bits, step ≈ 0.0030518.</summary>
public float DyQuantized
{
get => Quantize.Decode(Dy, -100.0f, 100.0f, 16);
set => Dy = Quantize.Encode(value, -100.0f, 100.0f, 16);
}

/// <summary>Coarsest quantization step of <see cref="DzQuantized"/>. Safe as an equality tolerance.</summary>
public const float DzQuantizedStep = 0.0030518044f;
/// <summary>Float accessor for <see cref="Dz"/>. Range [-100.0f, 100.0f], 16 bits, step ≈ 0.0030518.</summary>
public float DzQuantized
{
get => Quantize.Decode(Dz, -100.0f, 100.0f, 16);
set => Dz = Quantize.Encode(value, -100.0f, 100.0f, 16);
}

/// <summary>
/// True when every quantized field holds a wire code within its declared bit width
/// (<c>0 .. 2^bits-1</c>). The encoder never emits a code above this bound, so a larger
/// value is a malformed/hostile message: decoding it would land far outside the field's
/// <c>[min, max]</c> and, since the server relays raw codes verbatim, poison every observer.
/// Reject before storing or relaying. Pure integer comparison — no decode.
/// </summary>
public bool AreQuantizedFieldsInRange() =>
Dx <= 65535u
&& Dy <= 65535u
&& Dz <= 65535u;
}

} // namespace Decentraland.Kernel.Comms.V3
```
Loading
Loading