#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: * * template<> * struct tw::serial::Codec { * 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); * * 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 #include #include #include #include #include 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 struct Codec; // ── Concept: a type is Encodable if Codec::encode exists. template concept Encodable = requires(BinaryWriter & w, const T & v) { Codec::encode(w, v); }; // ── Concept: a type is Decodable if Codec::decode exists. template concept Decodable = requires(BinaryReader & r) { { Codec::decode(r) } -> std::same_as; }; // ────────────────────────────────────────────────────────────────────────── // 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 data) noexcept { m_buf.append(data.data(), data.size()); } // ── Primitive scalar ───────────────────────────────────────────────── template requires std::is_trivially_copyable_v void write(const T& value) noexcept { m_buf.append(&value, sizeof(T)); } // ── Codec-dispatched write ──────────────────────────────────────────── template void encode(const T& value) { Codec::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 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 read_bytes(std::size_t n) noexcept { assert(n <= m_remaining && "BinaryReader underflow"); auto span = std::span{ m_ptr, n }; m_ptr += n; m_remaining -= n; return span; } // ── Primitive scalar ───────────────────────────────────────────────── template requires std::is_trivially_copyable_v T read() noexcept { T value; read_bytes(&value, sizeof(T)); return value; } // ── Codec-dispatched read ───────────────────────────────────────────── template T decode() { return Codec::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 { \ static void encode(BinaryWriter& w, const T& v) noexcept { \ w.write(v); \ } \ static T decode(BinaryReader& r) noexcept { \ return r.read(); \ } \ } 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