This commit is contained in:
Martin Slachta
2026-07-18 14:31:15 +02:00
commit a04f0dc262
3343 changed files with 1140208 additions and 0 deletions
@@ -0,0 +1,90 @@
#pragma once
#include <cassert>
#include <cstddef>
#include <cstdint>
#include <cstring>
#include <span>
#include <vector>
namespace tw::serial {
/**
* A flat, pre-allocated, non-owning-after-construction byte buffer used as the
* backing store for BinaryWriter.
*
* Design goals:
* - Zero heap allocation after the initial reserve().
* - reset() in O(1) — just rewinds the write cursor.
* - view() gives a read-only span over written bytes, usable directly as a
* UDP payload without any copy.
* - Not thread-safe: one buffer per client, reused every frame.
*/
class BinaryBuffer {
std::vector<std::byte> m_storage;
std::size_t m_pos{ 0 };
public:
BinaryBuffer() = default;
explicit BinaryBuffer(std::size_t capacity) {
m_storage.resize(capacity);
}
// Reserve at least `capacity` bytes. May only grow, never shrinks.
void reserve(std::size_t capacity) {
if (capacity > m_storage.size()) {
m_storage.resize(capacity);
}
}
// Reset write cursor to 0. Does NOT zero memory — intentional for perf.
void reset() noexcept { m_pos = 0; }
std::size_t size() const noexcept { return m_pos; }
std::size_t capacity() const noexcept { return m_storage.size(); }
bool empty() const noexcept { return m_pos == 0; }
// Returns a view over bytes written so far. Valid until the next write or reset.
std::span<const std::byte> view() const noexcept {
return { m_storage.data(), m_pos };
}
// Returns a mutable span over bytes written so far.
std::span<std::byte> mutable_view() noexcept {
return { m_storage.data(), m_pos };
}
/**
* Append `n` raw bytes from `src`.
* Asserts in debug; in release callers must pre-reserve enough space.
*/
void append(const void* src, std::size_t n) noexcept {
assert(m_pos + n <= m_storage.size() && "BinaryBuffer overflow — call reserve() with a larger capacity");
std::memcpy(m_storage.data() + m_pos, src, n);
m_pos += n;
}
/**
* Reserve `n` bytes in-place and return a pointer to them.
* Lets callers write directly (e.g. via placement algorithms) without a
* temporary.
*/
std::byte* claim(std::size_t n) noexcept {
assert(m_pos + n <= m_storage.size() && "BinaryBuffer overflow — call reserve() with a larger capacity");
std::byte* ptr = m_storage.data() + m_pos;
m_pos += n;
return ptr;
}
/**
* Patch a previously written 4-byte field at `offset`.
* Useful for writing a length prefix before the payload is known.
*/
void patch_u32(std::size_t offset, uint32_t value) noexcept {
assert(offset + sizeof(uint32_t) <= m_pos);
std::memcpy(m_storage.data() + offset, &value, sizeof(uint32_t));
}
};
} // namespace tw::serial
@@ -0,0 +1,222 @@
#pragma once
/**
* tw::serial — generic, zero-allocation binary codec
* ====================================================
*
* Extensibility model (ADL / explicit specialisation)
* ---------------------------------------------------
* To make a type T serialisable, either:
*
* a) Specialise tw::serial::Codec<T>:
*
* template<>
* struct tw::serial::Codec<MyType> {
* static void encode(BinaryWriter& w, const MyType& v);
* static MyType decode(BinaryReader& r);
* };
*
* b) Or provide free functions in the same namespace as T:
*
* void tw_serial_encode(BinaryWriter& w, const MyType& v);
* MyType tw_serial_decode(BinaryReader& r, std::type_identity<MyType>);
*
* The BinaryWriter/BinaryReader forward-declared here are defined in
* BinaryWriter.hpp / BinaryReader.hpp. Codec.hpp itself is header-only
* and has no external dependencies beyond the C++ standard library.
*/
#include "BinaryBuffer.hpp"
#include <bit>
#include <concepts>
#include <cstdint>
#include <cstring>
#include <span>
#include <type_traits>
namespace tw::serial {
// Forward declarations
class BinaryWriter;
class BinaryReader;
// ── Primary template — intentionally incomplete so missing specialisations
// produce a clear compiler error rather than silent wrong behaviour.
template<typename T>
struct Codec;
// ── Concept: a type is Encodable if Codec<T>::encode exists.
template<typename T>
concept Encodable = requires(BinaryWriter & w, const T & v) {
Codec<T>::encode(w, v);
};
// ── Concept: a type is Decodable if Codec<T>::decode exists.
template<typename T>
concept Decodable = requires(BinaryReader & r) {
{ Codec<T>::decode(r) } -> std::same_as<T>;
};
// ──────────────────────────────────────────────────────────────────────────
// BinaryWriter
// ──────────────────────────────────────────────────────────────────────────
/**
* Thin write-cursor over a BinaryBuffer.
*
* All write operations are branch-free memcpy paths for trivially-copyable
* types. The buffer itself holds the memory; the writer is just a cursor.
*/
class BinaryWriter {
BinaryBuffer& m_buf;
public:
explicit BinaryWriter(BinaryBuffer& buf) noexcept : m_buf(buf) {}
// ── Raw bytes ────────────────────────────────────────────────────────
void write_bytes(const void* src, std::size_t n) noexcept {
m_buf.append(src, n);
}
void write_bytes(std::span<const std::byte> data) noexcept {
m_buf.append(data.data(), data.size());
}
// ── Primitive scalar ─────────────────────────────────────────────────
template<typename T>
requires std::is_trivially_copyable_v<T>
void write(const T& value) noexcept {
m_buf.append(&value, sizeof(T));
}
// ── Codec-dispatched write ────────────────────────────────────────────
template<Encodable T>
void encode(const T& value) {
Codec<T>::encode(*this, value);
}
// ── Cursor helpers ────────────────────────────────────────────────────
/** Returns the current write position (useful for length-prefix patching). */
std::size_t pos() const noexcept { return m_buf.size(); }
/** Reserve a 4-byte slot at the current position, return its offset. */
std::size_t reserve_u32() noexcept {
std::size_t offset = m_buf.size();
uint32_t placeholder = 0;
m_buf.append(&placeholder, sizeof(uint32_t));
return offset;
}
/** Patch a 4-byte slot previously reserved with reserve_u32(). */
void patch_u32(std::size_t offset, uint32_t value) noexcept {
m_buf.patch_u32(offset, value);
}
BinaryBuffer& buffer() noexcept { return m_buf; }
const BinaryBuffer& buffer() const noexcept { return m_buf; }
void reset() noexcept {
m_buf.reset();
}
};
// ──────────────────────────────────────────────────────────────────────────
// BinaryReader
// ──────────────────────────────────────────────────────────────────────────
/**
* Read-cursor over an immutable span of bytes.
*
* Designed for deserialisation on the client side; does not own memory.
* All reads advance an internal cursor. Out-of-bounds reads assert in
* debug and invoke undefined behaviour in release (callers must validate
* message length before feeding it to a BinaryReader).
*/
class BinaryReader {
const std::byte* m_ptr;
std::size_t m_remaining;
public:
explicit BinaryReader(std::span<const std::byte> data) noexcept
: m_ptr(data.data()), m_remaining(data.size()) {}
// ── Raw bytes ────────────────────────────────────────────────────────
void read_bytes(void* dst, std::size_t n) noexcept {
assert(n <= m_remaining && "BinaryReader underflow");
std::memcpy(dst, m_ptr, n);
m_ptr += n;
m_remaining -= n;
}
std::span<const std::byte> read_bytes(std::size_t n) noexcept {
assert(n <= m_remaining && "BinaryReader underflow");
auto span = std::span<const std::byte>{ m_ptr, n };
m_ptr += n;
m_remaining -= n;
return span;
}
// ── Primitive scalar ─────────────────────────────────────────────────
template<typename T>
requires std::is_trivially_copyable_v<T>
T read() noexcept {
T value;
read_bytes(&value, sizeof(T));
return value;
}
// ── Codec-dispatched read ─────────────────────────────────────────────
template<Decodable T>
T decode() {
return Codec<T>::decode(*this);
}
// ── State ─────────────────────────────────────────────────────────────
std::size_t remaining() const noexcept { return m_remaining; }
bool empty() const noexcept { return m_remaining == 0; }
};
// ──────────────────────────────────────────────────────────────────────────
// Built-in Codec specialisations for C++ primitives
// ──────────────────────────────────────────────────────────────────────────
// All fixed-width integer and float types that are trivially copyable get
// a direct memcpy codec — no varint encoding, deliberately, because we are
// optimising for throughput not wire-size (and positions are floats anyway).
#define TW_SERIAL_TRIVIAL_CODEC(T) \
template<> \
struct Codec<T> { \
static void encode(BinaryWriter& w, const T& v) noexcept { \
w.write(v); \
} \
static T decode(BinaryReader& r) noexcept { \
return r.read<T>(); \
} \
}
TW_SERIAL_TRIVIAL_CODEC(bool);
TW_SERIAL_TRIVIAL_CODEC(uint8_t);
TW_SERIAL_TRIVIAL_CODEC(uint16_t);
TW_SERIAL_TRIVIAL_CODEC(uint32_t);
TW_SERIAL_TRIVIAL_CODEC(uint64_t);
TW_SERIAL_TRIVIAL_CODEC(int8_t);
TW_SERIAL_TRIVIAL_CODEC(int16_t);
TW_SERIAL_TRIVIAL_CODEC(int32_t);
TW_SERIAL_TRIVIAL_CODEC(int64_t);
TW_SERIAL_TRIVIAL_CODEC(float);
TW_SERIAL_TRIVIAL_CODEC(double);
#undef TW_SERIAL_TRIVIAL_CODEC
} // namespace tw::serial
@@ -0,0 +1,30 @@
#pragma once
/**
* Codec specialisation for entt::entity.
*
* Wire format: uint32_t (the raw entity storage value, 4 bytes).
*
* EnTT entities are 32-bit identifiers internally (version bits + index bits).
* We transmit the raw value and let the receiver reconstruct via
* static_cast<entt::entity>(id).
*/
#include "Codec.hpp"
#include <entt/entt.hpp>
namespace tw::serial {
template<>
struct Codec<entt::entity> {
static void encode(BinaryWriter& w, const entt::entity& e) noexcept {
auto raw = static_cast<uint32_t>(e);
w.write(raw);
}
static entt::entity decode(BinaryReader& r) noexcept {
return static_cast<entt::entity>(r.read<uint32_t>());
}
};
} // namespace tw::serial
@@ -0,0 +1,77 @@
#pragma once
/**
* Codec specialisations for GLM types.
*
* Include this header in any translation unit that needs to
* encode/decode GLM vectors or matrices.
*
* Wire format (little-endian, matches the host layout on x86/ARM LE):
* vec2 — 2 × float (8 bytes)
* vec3 — 3 × float (12 bytes)
* vec4 — 4 × float (16 bytes)
* mat4 — 16 × float (64 bytes, column-major, matching GLM's default)
*/
#include "Codec.hpp"
#include <glm/vec2.hpp>
#include <glm/vec3.hpp>
#include <glm/vec4.hpp>
#include <glm/mat4x4.hpp>
namespace tw::serial {
template<>
struct Codec<glm::vec2> {
static void encode(BinaryWriter& w, const glm::vec2& v) noexcept {
w.write(v.x);
w.write(v.y);
}
static glm::vec2 decode(BinaryReader& r) noexcept {
glm::vec2 v;
v.x = r.read<float>();
v.y = r.read<float>();
return v;
}
};
template<>
struct Codec<glm::vec3> {
static void encode(BinaryWriter& w, const glm::vec3& v) noexcept {
// vec3 is 3 contiguous floats in GLM's layout
w.write_bytes(&v.x, 3 * sizeof(float));
}
static glm::vec3 decode(BinaryReader& r) noexcept {
glm::vec3 v;
r.read_bytes(&v.x, 3 * sizeof(float));
return v;
}
};
template<>
struct Codec<glm::vec4> {
static void encode(BinaryWriter& w, const glm::vec4& v) noexcept {
w.write_bytes(&v.x, 4 * sizeof(float));
}
static glm::vec4 decode(BinaryReader& r) noexcept {
glm::vec4 v;
r.read_bytes(&v.x, 4 * sizeof(float));
return v;
}
};
template<>
struct Codec<glm::mat4> {
static void encode(BinaryWriter& w, const glm::mat4& m) noexcept {
// GLM mat4 is column-major; 16 contiguous floats
w.write_bytes(&m[0][0], 16 * sizeof(float));
}
static glm::mat4 decode(BinaryReader& r) noexcept {
glm::mat4 m;
r.read_bytes(&m[0][0], 16 * sizeof(float));
return m;
}
};
} // namespace tw::serial
@@ -0,0 +1,21 @@
#pragma once
/**
* tw::serial — umbrella include
*
* Include this single header to get the full serialisation API:
* - BinaryBuffer (backing store)
* - BinaryWriter (write cursor + Codec dispatch)
* - BinaryReader (read cursor + Codec dispatch)
* - Codec<T> (extensible type trait)
* - Built-in Codec specialisations for all C++ primitive types
* - GlmCodec (vec2, vec3, vec4, mat4)
* - EnttCodec (entt::entity as uint32)
* - WorldStateWriter / WorldStateReader (game-specific high-level API)
*/
#include "BinaryBuffer.hpp"
#include "Codec.hpp"
#include "GlmCodec.hpp"
#include "EnttCodec.hpp"
#include "WorldStateWriter.hpp"
@@ -0,0 +1,261 @@
#pragma once
/**
* WorldStateWriter / WorldStateReader
* =====================================
* Project-specific serialisation for the per-frame world-state snapshot
* sent from the server to each connected client.
*
* Wire format (all values little-endian):
*
*
* Header (12 bytes)
* packet_type : uint32 (PacketType::WORLD_STATE = 3)
* frame_idx : uint32
* entity_count : uint32 (number of position records)
*
* Spawns section
* spawn_count : uint32
* spawn[i].id : uint32 × spawn_count
*
* Despawns section
* despawn_count : uint32
* despawn[i].id : uint32 × despawn_count
*
* Entity positions (hot path tightly packed)
* [ id:uint32, x:float, y:float, z:float ] × entity_count
*
*
* Total minimum size : 20 bytes (header + empty spawns + empty despawns)
* Per entity : 16 bytes
* 300 entities : 20 + 300×16 = 4820 bytes (well under MTU for segmented)
*
* Usage (server side, called once per client per frame):
*
* tw::serial::WorldStateWriter w(buffer); // buffer is a BinaryBuffer
* w.begin(frame_idx);
* w.write_spawns(interest.spawns());
* w.write_despawns(interest.despawns());
* w.begin_entities(num_entities); // writes entity_count slot
* for each entity in interest.entities():
* w.write_entity(entity_id, position);
* w.end(); // patches entity_count
* // buffer.view() is ready to send
*
* Usage (client side):
*
* tw::serial::WorldStateReader r(payload_span);
* auto header = r.read_header(); // frame_idx + counts
* for (auto id : r.read_spawns()) { ... }
* for (auto id : r.read_despawns()) { ... }
* while (r.has_entity()) {
* auto [id, pos] = r.read_entity();
* ...
* }
*/
#include "Codec.hpp"
#include "GlmCodec.hpp"
#include "EnttCodec.hpp"
#include <entt/entt.hpp>
#include <glm/vec3.hpp>
#include <cstdint>
#include <span>
#include <spdlog/spdlog.h>
namespace tw::serial {
// ── Packet type tag ───────────────────────────────────────────────────────
// Mirrors PacketType::WORLD_STATE_PACKET (value 3) in packets/Packet.hpp.
// Hardcoded here so the serialisation module does not depend on the network
// module — the numerical value must stay in sync if the enum changes.
inline constexpr uint32_t kWorldStatePacketType = 3; // WORLD_STATE_PACKET
// ──────────────────────────────────────────────────────────────────────────
// WorldStateWriter
// ──────────────────────────────────────────────────────────────────────────
class WorldStateWriter {
BinaryWriter m_w;
// Offsets for length-prefix patching
std::size_t m_entity_count_offset{ 0 };
uint32_t m_entity_count{ 0 };
public:
explicit WorldStateWriter(BinaryBuffer& buf) noexcept : m_w(buf) {}
/**
* Write the packet header. Call once per frame, before everything else.
* entity_count is patched in end().
*/
void begin(uint32_t frame_idx) noexcept {
m_entity_count = 0;
// packet_type — lets the receiver dispatch without peeking further
m_w.encode<uint32_t>(kWorldStatePacketType);
// frame_idx
m_w.encode<uint32_t>(frame_idx);
// entity_count placeholder — patched when end() is called
m_entity_count_offset = m_w.reserve_u32();
}
// ── Spawns ────────────────────────────────────────────────────────────
/**
* Write the spawn list. Pass any range of entt::entity.
*/
template<typename Range>
void write_spawns(const Range& spawns) noexcept {
auto count = static_cast<uint32_t>(std::size(spawns));
m_w.encode<uint32_t>(count);
for (const entt::entity e : spawns) {
m_w.encode<entt::entity>(e);
}
}
// ── Despawns ──────────────────────────────────────────────────────────
template<typename Range>
void write_despawns(const Range& despawns) noexcept {
auto count = static_cast<uint32_t>(std::size(despawns));
// m_w.encode<uint32_t>(count);
for (const entt::entity e : despawns) {
m_w.encode<entt::entity>(e);
}
}
// ── Entity positions (the hot path) ───────────────────────────────────
/**
* Write a single entity position record.
* id : raw uint32 of the entity handle
* pos : world-space position (x, y, z)
*
* This is the innermost loop of the replicator every byte matters.
* The compiler will inline both calls down to two contiguous memcpys.
*/
void write_entity(uint32_t id, const glm::vec3& pos) noexcept {
m_w.write(id);
// Write x, y, z as 3 contiguous floats
m_w.write_bytes(&pos.x, 3 * sizeof(float));
++m_entity_count;
}
// Convenience overload accepting an entt::entity handle directly
void write_entity(entt::entity entity, const glm::vec3& pos) noexcept {
write_entity(static_cast<uint32_t>(entity), pos);
}
/**
* Patch the entity_count field written in begin() and finalise the
* buffer. Must be called exactly once after all write_entity() calls.
*/
void end() noexcept {
m_w.patch_u32(m_entity_count_offset, m_entity_count);
}
/** Expose the underlying buffer view (e.g. to pass to send_message). */
std::span<const std::byte> view() noexcept {
return m_w.buffer().view();
}
void reset() noexcept {
m_w.reset();
m_entity_count = 0;
}
};
// ──────────────────────────────────────────────────────────────────────────
// WorldStateReader (client-side / test use)
// ──────────────────────────────────────────────────────────────────────────
struct WorldStateHeader {
uint32_t packet_type;
uint32_t frame_idx;
uint32_t entity_count;
};
struct EntityRecord {
uint32_t id;
glm::vec3 position;
};
class WorldStateReader {
BinaryReader m_r;
WorldStateHeader m_header{};
uint32_t m_spawn_count{ 0 };
uint32_t m_spawns_read{ 0 };
uint32_t m_despawn_count{ 0 };
uint32_t m_despawns_read{ 0 };
uint32_t m_entities_read{ 0 };
public:
explicit WorldStateReader(std::span<const std::byte> data) noexcept
: m_r(data) {}
/** Read the 12-byte header. Must be called first. */
WorldStateHeader read_header() noexcept {
// m_header.packet_type = m_r.decode<uint32_t>();
m_header.frame_idx = m_r.decode<uint32_t>();
m_header.entity_count = m_r.decode<uint32_t>();
// spawn count follows immediately
m_spawn_count = m_r.decode<uint32_t>();
return m_header;
}
/** Read the next spawn entity id. Returns 0 when exhausted. */
bool has_spawn() const noexcept { return m_spawns_read < m_spawn_count; }
uint32_t read_spawn() noexcept {
assert(has_spawn());
++m_spawns_read;
uint32_t id = m_r.decode<uint32_t>();
// if (!has_spawn()) {
// // transition to despawns
// m_despawn_count = m_r.decode<uint32_t>();
// m_phase = Phase::Despawns;
// }
return id;
}
/** Skip remaining spawns and enter despawn phase. */
void skip_spawns() noexcept {
while (has_spawn()) read_spawn();
}
bool has_despawn() const noexcept { return m_despawns_read < m_despawn_count; }
uint32_t read_despawn() noexcept {
assert(has_despawn());
++m_despawns_read;
uint32_t id = m_r.decode<uint32_t>();
return id;
}
void skip_despawns() noexcept {
while (has_despawn()) read_despawn();
}
bool has_entity() const noexcept {
return m_entities_read < m_header.entity_count;
}
EntityRecord read_entity() noexcept {
assert(has_entity());
EntityRecord rec;
rec.id = m_r.read<uint32_t>();
m_r.read_bytes(&rec.position.x, 3 * sizeof(float));
++m_entities_read;
return rec;
}
};
} // namespace tw::serial