Working with Byte Blocks

Most binary data in an application needs a stable owner, but it does not need to be mutable everywhere it travels. ByteBlock is the general-purpose owning value for that data, while ByteBlockEditor provides a focused place to assemble or change it. This page explains how the two types work together, how copy-on-write affects their cost, and how to read, edit, retain, and compare byte data without giving up clear ownership.

One Allocation, Several Values

Copying a ByteBlock copies a small value that shares the underlying allocation. The bytes themselves are not copied. This makes blocks inexpensive to pass by value, return from functions, and keep in several objects.

A slice() is equally inexpensive. It owns a reference to the same allocation and merely selects a visible range within it. Because the slice owns that reference, its bytes remain valid even after the block from which it was created has gone out of scope. This is an important difference from a borrowed ConstByteSpan.

Sharing has two consequences worth keeping in mind. It avoids eager copying, especially when a parser retains several fields from one input block, but a tiny slice also keeps the complete original allocation alive. Likewise, the first mutation through a shared ByteBlockEditor must detach its storage so existing read-only values remain unchanged. That deferred copy is the “copy on write” in the design.

Use copy() when you need independent storage for all currently visible bytes. Use kept() when you already know the range to retain: it creates the same result as block.slice(range).copy(), but copies the selected range directly. For short-lived parsing, a slice is usually the natural choice. For a small field that will be cached long after a large packet or file is released, kept() prevents that large allocation from remaining alive.

/// Choose between shared slices and independent byte blocks.
///
/// `slice()` creates a cheap read-only view that keeps the complete shared
/// allocation alive. `copy()` and `kept()` allocate only the visible bytes, which
/// is useful when a small result must outlive a much larger source.
void slicingBlocks() {
    const auto completeTree = el::ByteBlock{
        el::Byte{1U}, el::Byte{2U}, el::Byte{3U}, el::Byte{5U}, el::Byte{8U}, el::Byte{13U}, el::Byte{21U}};
    const auto branchRange = el::ByteRange{el::ByteIndex{2U}, el::ByteLength{3U}};

    // Share the original allocation when the source and branch have similar lifetimes.
    const auto sharedBranch = completeTree.slice(branchRange);

    // Keep only the selected bytes when the branch will be stored independently.
    const auto compactBranch = completeTree.kept(branchRange);
    const auto explicitCopy = sharedBranch.copy();

    el::io::printLine("Skill tree         : Arbre du veilleur"_el);
    el::io::printLine("Complete tree      : "_el, el::ByteFormat::separated(), completeTree);
    el::io::printLine("Shared branch      : "_el, el::ByteFormat::separated(), sharedBranch);
    el::io::printLine("Compact branch     : "_el, el::ByteFormat::separated(), compactBranch);
    el::io::printLine("Explicit copy      : "_el, el::ByteFormat::separated(), explicitCopy);
}
Skill tree         : Arbre du veilleur
Complete tree      : 01 02 03 05 08 0d 15
Shared branch      : 03 05 08
Compact branch     : 03 05 08
Explicit copy      : 03 05 08

Choosing the Right Owner

A ByteBlock is the usual application-level byte value. It is immutable from the holder’s perspective, easy to share, and well suited to parameters, return values, stored records, and parsing input. A ByteBlockEditor is its mutable companion for assembling small records or making local changes. Converting an editor to a block is implicit and shares the data; a later editor mutation detaches and cannot change the block that was already handed out.

Other byte containers solve different problems. A ByteArray has a compile-time size and works well for fixed algorithm state. A ByteBuffer owns dynamic storage without copy-on-write, which is useful for a mutable working buffer whose copies should always be independent. RingBuffer and ByteRingBuffer model queued stream data with bounded capacity and consuming reads; they are not general stored values or record builders.

Creating Blocks

Both block types can start empty, contain a repeated byte, copy an initializer list, or copy a fixed ByteArray. The fromSpan() factories make ownership explicit when bytes arrive through an Erbsland or standard span. fromVector() is the matching compatibility boundary for raw uint8_t and char vectors.

These factories copy their input, so the resulting block no longer depends on the source lifetime. Passing a ByteBlockEditor where a block is expected is different: the implicit conversion shares its allocation and gives the receiver a stable read-only snapshot.

/// Create owning byte blocks from common sources.
///
/// `ByteBlock` is the read-only owning value for storing and passing binary data.
/// `ByteBlockEditor` provides the same construction choices when the bytes must
/// remain mutable, and converts implicitly to a read-only block without copying.
void creatingBlocks() {
    // Create blocks with a repeated value and with individual skill bytes.
    const auto emptySlots = el::ByteBlock{el::ByteLength{4U}, el::Byte{0U}};
    const auto learnedSkills = el::ByteBlock{el::Byte{1U}, el::Byte{3U}, el::Byte{8U}};

    // Copy fixed arrays, borrowed spans, and standard vectors into owned blocks.
    const auto fixedSkills = el::ByteArray{el::Byte{2U}, el::Byte{5U}, el::Byte{13U}};
    const auto networkBytes = std::array<std::byte, 3>{std::byte{21U}, std::byte{34U}, std::byte{55U}};
    const auto savedBytes = std::vector<uint8_t>{89U, 144U};
    const auto fromArray = el::ByteBlock{fixedSkills};
    const auto fromSpan = el::ByteBlock::fromSpan(std::span{networkBytes});
    const auto fromVector = el::ByteBlock::fromVector(savedBytes);

    // Build mutable data and hand it to an API expecting a read-only block.
    auto editor = el::ByteBlockEditor{el::Byte{1U}, el::Byte{2U}};
    editor.append(el::Byte{3U});
    const el::ByteBlock immutableSkills = editor;

    el::io::printLine("Character          : Gardienne des brumes"_el);
    el::io::printLine("Empty slots        : "_el, el::ByteFormat::separated(), emptySlots);
    el::io::printLine("Learned skills     : "_el, el::ByteFormat::separated(), learnedSkills);
    el::io::printLine("From fixed array   : "_el, el::ByteFormat::separated(), fromArray);
    el::io::printLine("From borrowed span : "_el, el::ByteFormat::separated(), fromSpan);
    el::io::printLine("From vector        : "_el, el::ByteFormat::separated(), fromVector);
    el::io::printLine("Editor as block    : "_el, el::ByteFormat::separated(), immutableSkills);
}
Character          : Gardienne des brumes
Empty slots        : 00 00 00 00
Learned skills     : 01 03 08
From fixed array   : 02 05 0d
From borrowed span : 15 22 37
From vector        : 59 90
Editor as block    : 01 02 03

Recognizing Binary Structure

Binary formats often begin with a marker, end with a trailer, or contain a delimiter. startsWith(), endsWith(), and contains() express those checks directly. find() returns the first matching byte index, optionally at or after a chosen start, while findLast() searches from the other end. A missing match is reported as ByteIndex::noIndex().

length() and isEmpty() complete the common structural checks. All search inputs may be blocks, initializer lists, or borrowed spans, so validation does not require a temporary owning object.

/// Inspect and search the contents of a byte block.
///
/// Length and membership tests make common binary validation readable, while
/// `find()` and `findLast()` return byte-aware indexes for matched sequences.
void inspectingContents() {
    const auto skillPath = el::ByteBlock{el::Byte{0x10U}, el::Byte{0x21U}, el::Byte{0x34U}, el::Byte{0x21U}};

    // Validate the expected beginning, ending, and an unlock marker in the path.
    const auto hasRoot = skillPath.startsWith({el::Byte{0x10U}, el::Byte{0x21U}});
    const auto hasMastery = skillPath.endsWith({el::Byte{0x34U}, el::Byte{0x21U}});
    const auto hasUnlock = skillPath.contains({el::Byte{0x21U}});

    // Locate the first and final occurrence of the repeated unlock marker.
    const auto firstUnlock = skillPath.find({el::Byte{0x21U}});
    const auto lastUnlock = skillPath.findLast({el::Byte{0x21U}});

    el::io::printLine("Skill path         : Voie des brumes"_el);
    el::io::printLine("Byte count         : "_el, skillPath.length().toSizeT());
    el::io::printLine("Path is empty      : "_el, el::BooleanFormat::yesNo(), skillPath.isEmpty());
    el::io::printLine("Expected root      : "_el, el::BooleanFormat::yesNo(), hasRoot);
    el::io::printLine("Mastery ending     : "_el, el::BooleanFormat::yesNo(), hasMastery);
    el::io::printLine("Contains unlock    : "_el, el::BooleanFormat::yesNo(), hasUnlock);
    el::io::printLine("First unlock index : "_el, firstUnlock.toSizeT());
    el::io::printLine("Last unlock index  : "_el, lastUnlock.toSizeT());
}
Skill path         : Voie des brumes
Byte count         : 4
Path is empty      : no
Expected root      : yes
Mastery ending     : yes
Contains unlock    : yes
First unlock index : 1
Last unlock index  : 3

Reading Individual Bytes

Choose tolerant or strict access according to the meaning of a missing byte. get() returns a chosen fallback when the index is invalid or beyond the end. That is convenient for an optional field. getOrThrow() raises OutOfRangeError when a required layout invariant is broken.

endIndex() names the position immediately after the final byte. For complete traversal, forEach() invokes a callback with each byte and, when accepted by the callback, its index. The returned LoopResult also allows callbacks designed for early exit.

/// Read individual bytes or visit a complete block.
///
/// Tolerant access returns a chosen fallback for an invalid index. Strict access
/// throws when a required byte is absent, and `forEach()` visits every visible
/// byte with an optional byte index.
void accessingBytes() {
    const auto skillCosts = el::ByteBlock{el::Byte{3U}, el::Byte{5U}, el::Byte{8U}, el::Byte{13U}};

    // Read one optional cost and one position guaranteed by the skill-tree layout.
    const auto missingCost = skillCosts.get(el::ByteIndex{20U}, el::Byte{255U});
    const auto masteryCost = skillCosts.getOrThrow(el::ByteIndex{3U});

    // Visit every cost together with its index to calculate a total.
    auto totalCost = uint32_t{};
    const auto visitResult = skillCosts.forEach(
        [&totalCost](const el::Byte cost, const el::ByteIndex) -> void { totalCost += cost.toUInt32(); });

    el::io::printLine("Skill tree         : Sentier du lynx"_el);
    el::io::printLine("End index          : "_el, skillCosts.endIndex().toSizeT());
    el::io::printLine("Missing fallback   : "_el, missingCost.toUInt32());
    el::io::printLine("Mastery cost       : "_el, masteryCost.toUInt32());
    el::io::printLine("Total cost         : "_el, totalCost);
    el::io::printLine("Visited all bytes  : "_el, el::BooleanFormat::yesNo(), visitResult == el::LoopResult::Success);
}
Skill tree         : Sentier du lynx
End index          : 4
Missing fallback   : 255
Mastery cost       : 13
Total cost         : 29
Visited all bytes  : yes

Reading Native Integers

When a binary layout stores an ordinary C++ integer at a known offset, getInteger() decodes it with a fallback for an incomplete range. getIntegerInto() instead reports success and leaves an existing destination unchanged on failure. getIntegerOrThrow() makes the complete field an invariant.

All three operations accept Endianness and default to little endian. They decode the native width of their template type. For wire formats whose signedness or encoded width differs from a native type, including compact integers, see Byte Integer Formats Explained and use a ByteReader.

/// Decode native integers from known positions in a byte block.
///
/// Integer access can return a fallback, report success while preserving an
/// existing destination on failure, or throw when the binary layout guarantees
/// that the complete integer is present.
void readingIntegers() {
    const auto record = el::ByteBlock{
        el::Byte{0x34U},
        el::Byte{0x12U},
        el::Byte{0x00U},
        el::Byte{0x00U},
        el::Byte{0x00U},
        el::Byte{0x00U},
        el::Byte{0x01U},
        el::Byte{0x2cU}};

    // Read a little-endian rank and a big-endian experience value.
    const auto rank = record.getIntegerOrThrow<uint16_t>(el::ByteIndex{0U}, el::Endianness::Little);
    const auto experience = record.getIntegerOrThrow<uint32_t>(el::ByteIndex{4U}, el::Endianness::Big);

    // Preserve an existing value when an optional field is incomplete.
    auto prestige = uint32_t{7U};
    const auto hasPrestige = record.getIntegerInto(prestige, el::ByteIndex{7U}, el::Endianness::Big);
    const auto fallback = record.getInteger<uint32_t>(el::ByteIndex{7U}, el::Endianness::Big, uint32_t{99U});

    el::io::printLine("Character          : Éclaireuse lunaire"_el);
    el::io::printLine("Rank               : "_el, rank);
    el::io::printLine("Experience         : "_el, experience);
    el::io::printLine("Prestige available : "_el, el::BooleanFormat::yesNo(), hasPrestige);
    el::io::printLine("Preserved prestige : "_el, prestige);
    el::io::printLine("Fallback prestige  : "_el, fallback);
}
Character          : Éclaireuse lunaire
Rank               : 4660
Experience         : 300
Prestige available : no
Preserved prestige : 7
Fallback prestige  : 99

Crossing Container Boundaries

Keep a block as a block while data remains inside APIs that understand Erbsland Core byte types. At a boundary that specifically requires another owner, toByteBuffer() creates an independent mutable ByteBuffer. toUInt8Vector() and toCharVector() create independent standard vectors.

These are deliberate deep-copying conversions. If the receiving operation only borrows bytes during the call, span() is the lower-cost boundary, provided the block outlives the span.

/// Convert a byte block at an interoperability boundary.
///
/// `toByteBuffer()` creates an independent mutable Erbsland Core buffer. The
/// vector conversions copy bytes into standard-library containers for APIs that
/// specifically require unsigned-byte or character storage.
void convertingBlocks() {
    const auto encodedBranch = el::ByteBlock{el::Byte{0x41U}, el::Byte{0x72U}, el::Byte{0x63U}};

    // Create independent containers suited to each receiving API.
    auto mutableBuffer = encodedBranch.toByteBuffer();
    const auto unsignedBytes = encodedBranch.toUInt8Vector();
    const auto characterBytes = encodedBranch.toCharVector();
    mutableBuffer.set(el::ByteIndex{0U}, el::Byte{0x61U});

    el::io::printLine("Skill branch       : Arc ancien"_el);
    el::io::printLine("Original block     : "_el, el::ByteFormat::separated(), encodedBranch);
    el::io::printLine(
        "Mutable buffer     : "_el, el::ByteFormat::separated(), el::ByteBlock::fromSpan(mutableBuffer.span()));
    el::io::printLine("Unsigned vector len: "_el, unsignedBytes.size());
    el::io::printLine("Character vector len: "_el, characterBytes.size());
}
Skill branch       : Arc ancien
Original block     : 41 72 63
Mutable buffer     : 61 72 63
Unsigned vector len: 3
Character vector len: 3

Editing and Assembling Small Blocks

ByteBlockEditor supports both structural changes and in-place updates. append(), insert(), and appendInteger() grow an assembled record. replace() removes a clamped range and inserts replacement bytes, so its result may have a different length.

overwrite() and fill() never resize the editor. They change only the part of the selected range that exists, which makes them suitable for fixed fields in an already allocated record. remove() deletes a range, while keep() discards everything outside it.

xorWith() returns false without changing the editor when a whole-block source has a different length. xorWithOrThrow() expresses equal length as an invariant. The ranged XOR overload remains a clamped, partial operation.

/// Assemble and edit a small binary record in place.
///
/// `ByteBlockEditor` combines growing operations such as `append()` and
/// `insert()` with non-growing `overwrite()` and `fill()`, structural changes,
/// and whole-block XOR for compact binary transformations.
void editingBlocks() {
    auto build = el::ByteBlockEditor{el::Byte{0x10U}, el::Byte{0x20U}};

    // Assemble the skill record from bytes, a block, and a fixed-width integer.
    build.append(el::Byte{0x30U})
        .insert(el::ByteIndex{1U}, el::ByteBlock{el::Byte{0x15U}})
        .append(el::ByteBlock{el::Byte{0x40U}, el::Byte{0x50U}})
        .appendInteger(uint16_t{0x1234U}, el::Endianness::Big);

    // Adjust selected fields without changing the overall size.
    build.replace(el::ByteRange{el::ByteIndex{2U}, el::ByteLength{1U}}, el::ByteBlock{el::Byte{0x22U}});
    build.overwrite(el::ByteIndex{3U}, el::ByteArray{el::Byte{0x33U}, el::Byte{0x44U}}.span());
    build.fill(el::ByteRange{el::ByteIndex{5U}, el::ByteLength{1U}}, el::Byte{0x55U});

    // Remove a retired node, keep the record payload, and apply an equal-size mask.
    build.remove(el::ByteRange{el::ByteIndex{1U}, el::ByteLength{1U}});
    build.keep(el::ByteRange{el::ByteIndex{0U}, el::ByteLength{6U}});
    build.xorWithOrThrow(el::ByteBlock{el::ByteLength{6U}, el::Byte{0x0fU}});

    el::io::printLine("Skill build        : Gardien des étoiles"_el);
    el::io::printLine("Encoded build      : "_el, el::ByteFormat::separated(), el::ByteBlock{build});
}
Skill build        : Gardien des étoiles
Encoded build      : 1f 2d 3c 4b 5a 1d

Controlling Editor Storage

Capacity management matters when an editor is built incrementally. reserve() prepares room without changing the visible length, and resize() changes that length, filling newly visible bytes with zero. capacity() reports the current allocation, while shrinkToFit() reduces it to the visible length.

Usually copy-on-write detaches automatically at the first real mutation. detach() lets you pay that cost at a deliberate point before a series of changes. clear() removes all visible bytes but retains capacity for reuse; reset() also releases the allocation.

/// Manage mutable byte-block length, capacity, and shared storage.
///
/// Reserving avoids repeated growth while assembling data. Resizing changes the
/// visible length, `detach()` eagerly establishes unique storage, and clearing or
/// resetting chooses whether allocated capacity is retained.
void managingStorage() {
    auto path = el::ByteBlockEditor{el::Byte{1U}, el::Byte{2U}, el::Byte{3U}};

    // Reserve working room and grow the visible block with zero-filled bytes.
    path.reserve(el::ByteLength{32U});
    const auto reservedCapacity = path.capacity();
    path.resize(el::ByteLength{6U});

    // Preserve a read-only snapshot and detach before a sequence of edits.
    const auto snapshot = el::ByteBlock{path};
    path.detach();
    path.fill(el::ByteRange{el::ByteIndex{3U}, el::ByteLength{3U}}, el::Byte{9U});
    path.shrinkToFit();

    el::io::printLine("Skill path         : Discipline astrale"_el);
    el::io::printLine("Reserved capacity  : "_el, reservedCapacity.toSizeT());
    el::io::printLine("Edited bytes       : "_el, el::ByteFormat::separated(), el::ByteBlock{path});
    el::io::printLine("Snapshot bytes     : "_el, el::ByteFormat::separated(), snapshot);
    el::io::printLine("Tight capacity     : "_el, path.capacity().toSizeT());

    // Clear retains the tight allocation; reset releases it.
    path.clear();
    el::io::printLine("Capacity after clear: "_el, path.capacity().toSizeT());
    path.reset();
    el::io::printLine("Capacity after reset: "_el, path.capacity().toSizeT());
}
Skill path         : Discipline astrale
Reserved capacity  : 48
Edited bytes       : 01 02 03 09 09 09
Snapshot bytes     : 01 02 03 00 00 00
Tight capacity     : 6
Capacity after clear: 6
Capacity after reset: 0

Comparing Blocks

The equality and three-way comparison operators compare visible bytes lexicographically across blocks and editors. They are the right tools for ordinary equality, sorting, and ordered containers.

For authentication tags and similar equal-length values, use isEqualConstTime(). It inspects every byte when lengths match, avoiding a content-dependent early exit. The length comparison remains observable, so callers working with secret values should use a format with a fixed, already validated length.

/// Compare byte blocks lexicographically or without content-dependent exits.
///
/// The ordinary comparison operators are appropriate for ordering and general
/// equality checks. `isEqualConstTime()` inspects every byte for equal-length
/// inputs and is the safer content comparison for authentication-related data.
void comparingBlocks() {
    const auto novice = el::ByteBlock{el::Byte{1U}, el::Byte{3U}, el::Byte{5U}};
    const auto sameNovice = el::ByteBlockEditor{el::Byte{1U}, el::Byte{3U}, el::Byte{5U}};
    const auto adept = el::ByteBlock{el::Byte{1U}, el::Byte{4U}, el::Byte{1U}};

    // Use regular comparisons for equality and skill-tree ordering.
    const auto sameBuild = novice == sameNovice;
    const auto noviceComesFirst = (novice <=> adept) == std::strong_ordering::less;

    // Use constant-time content comparison for equal-length verification tokens.
    const auto matchingToken = novice.isEqualConstTime(sameNovice.span());
    const auto differentToken = novice.isEqualConstTime(adept);

    el::io::printLine("Build              : Apprentie des runes"_el);
    el::io::printLine("Same build         : "_el, el::BooleanFormat::yesNo(), sameBuild);
    el::io::printLine("Sorts before adept : "_el, el::BooleanFormat::yesNo(), noviceComesFirst);
    el::io::printLine("Matching token     : "_el, el::BooleanFormat::yesNo(), matchingToken);
    el::io::printLine("Different token    : "_el, el::BooleanFormat::yesNo(), differentToken);
}
Build              : Apprentie des runes
Same build         : yes
Sorts before adept : yes
Matching token     : yes
Different token    : no