Skip to content

Reflection-Based Encoding (RBE)

A header-only C++ library for declarative binary serialization and deserialization via reflection-based annotations. Describe a binary layout once as plain C++ structs; RBE handles the rest.

The library source code is available on GitHub and is distributed under the MIT License.

Experimental

RBE is at an early, experimental stage. Until version 1.0.0 the API is unstable and breaking changes may land in any minor release (0.1 → 0.2). Patch releases (0.1.0 → 0.1.1) stay backwards compatible. Check the feature matrix for what is implemented today.

#include <rbe/annotations.hpp>
#include <rbe/srl.hpp>

struct Position { std::int32_t x, y, z; };
enum class Item : std::uint16_t { Sword = 1, Shield = 2, Potion = 3 };

// nested types inherit the annotations: Position is packed little-endian too
struct [[=rbe::pack_le]] SaveGame {
    std::uint32_t                version;
    std::uint16_t                level;
    std::uint32_t                health;
    Position                     position;
    std::array<Item, 8>          inventory;
};

std::array<std::byte, rbe::wire_size_of<SaveGame>()> file{};
auto size = rbe::serialize(file, SaveGame{
    .version   = 2,
    .level     = 3,
    .health    = 100,
    .position  = {.x = 10, .y = -20, .z = 0},
    .inventory = { Item::Sword, Item::Potion },
}); 
// then write `file` to disk
#include <rbe/annotations.hpp>
#include <rbe/dsrl.hpp>

struct Version { std::uint8_t major, minor, patch; };

// header at the start of a firmware image, as a bootloader finds it in flash
struct [[=rbe::pack, =rbe::big]] FirmwareHeader {
    std::uint32_t magic;
    Version       version;
    std::uint32_t image_size;
    std::uint32_t crc32;
};

// `flash` holds the bytes at the start of the firmware partition

// lazy: a view over the bytes, each field is decoded only when read
auto header  = rbe::deserialize<FirmwareHeader>(flash, rbe::dsrl::lazy);
auto magic   = header.field<"magic">();            // big-endian to host order, on access
auto version = header.field<"version">().value();  // a nested struct, decoded as a whole

// wider contract: std::nullopt instead of undefined behavior if the partition is too small
auto checked = rbe::try_deserialize<FirmwareHeader>(flash, rbe::dsrl::lazy);
#include <rbe/annotations.hpp>
#include <rbe/frames.hpp>

struct [[=rbe::pack_be]] Header {
    [[=rbe::frame_length]] std::uint16_t length;
    [[=rbe::id]]           std::uint8_t  type;
};

struct [[=rbe::pack_be, =rbe::id(std::uint8_t{1})]] Order  { std::uint32_t order_ref; std::uint64_t price; };
struct [[=rbe::pack_be, =rbe::id(std::uint8_t{2})]] Cancel { std::uint32_t order_ref; };

using message = rbe::frames::frame<Header, rbe::frames::any<Order, Cancel>>;

auto msg = rbe::deserialize<message>(buffer);

msg.payload().match(
    [](Order const&) { /* ... */ },              // decoded eagerly
    [](rbe::dsrl::proxy<Cancel>) { /* ... */ },  // or read lazily
    [](rbe::unmatched auto) { /* unknown type */ }
);

What is [[=rbe::pack_le]]?

It is a C++26 annotation, not an attribute. Attributes like [[nodiscard]] are hints the compiler acts on; an annotation, spelled [[=value]], attaches a constant value to a declaration that code can read back through C++26 static reflection. RBE reads it to decide how the type travels on the wire: here, packed and little-endian.

The annotation does not change the struct itself. SaveGame keeps its usual C++ layout, padding included, so sizeof(SaveGame) is still 40; only the bytes RBE writes are packed, and rbe::wire_size_of<SaveGame>() is 38.

Next steps