String Heap

Overview

Strings cannot be stored directly in the Words buffer because they are variable-length. Instead, Fusion provides NetworkedStringHeap, a per-object heap that stores string data and exposes fixed-size handles for use in the Words buffer.

NetworkedStringHeap

Each Object (both ObjectRoot and ObjectChild) contains a lazy-allocated NetworkedStringHeap:

C++

class Object {
    // Private member, constructed with size 0 — storage is allocated on first AddString.
    NetworkedStringHeap _stringHeap{0};
public:
    NetworkedStringHeap&       GetStringHeap();
    const NetworkedStringHeap& GetStringHeap() const;
    // ...
};

The heap grows automatically when allocations cannot find a contiguous free segment. Every object owns exactly one heap — sub-objects (ObjectChild) have their own per-object heap accessed through GetStringHeap(), fully independent from their root's heap. A handle is only meaningful within the heap of the object whose Words buffer stores it.

StringHandle

A StringHandle is a fixed-size reference (2 words / 8 bytes) stored in the Words buffer:

C++

struct StringHandle {
    uint32_t Id;          // 1-based slot index in the heap
    uint32_t Generation;  // Generation counter for use-after-free detection
};
Field Description
Id 1-based index into the heap's entry table. Two sentinel values exist — see below.
Generation Incremented when a slot is reused. Stale handles (wrong generation) are rejected by ResolveString.

Two Id values are sentinels rather than slot indexes:

Sentinel Meaning
0 Invalid / null handle (StringMessage::InvalidHandle). This is what zero-initialized words decode to.
UINT32_MAX Explicit empty string (StringMessage::EmptyString). FreeString returns this on success, and it can be written directly to represent an empty value without allocating.

In the Words buffer, a StringHandle occupies exactly 2 words at the property's offset:

  • words[offset] = handle.Id
  • words[offset + 1] = handle.Generation

Lifecycle

Allocate

C++

StringHandle Object::AddString(const RealtimeCore::Common::CharType* str);

Allocates heap storage, copies the UTF-8 string data (plus a null terminator) and returns a handle. The handle's Id and Generation are then written into the Words buffer by the integration layer.

For empty strings, AddString is not needed. Write the empty-string sentinel {UINT32_MAX, 0} directly — remote clients resolve it to StringMessage::EmptyString without any heap allocation.

Resolve

C++

const RealtimeCore::Common::CharType* Object::ResolveString(
    const StringHandle& handle,
    StringMessage& outStatus
);

Looks up the string data for the given handle. Returns a pointer to the stored UTF-8 data and sets outStatus to indicate success or failure. The pointer is only valid until the next heap mutation — copy the data if you need to keep it.

Free

C++

StringHandle Object::FreeString(const StringHandle& handle);

Releases the heap storage associated with the handle. On success it returns the empty-string sentinel {UINT32_MAX, 0}, which can be written back to the Words buffer as the property's new value. Passing a handle whose Id is 0 or UINT32_MAX returns the handle unchanged; a stale or unknown handle returns the null handle {0, 0}. The slot's generation is incremented when it is reused by a later AddString, so any stale handles pointing to it fail resolution from then on.

You must call FreeString before allocating a replacement string for the same property. Failing to do so leaks heap space.

Validation

C++

bool Object::IsValidStringHandle(const StringHandle& handle);

Returns true if the handle points to a live entry, and also for the empty-string sentinel (Id == UINT32_MAX). Returns false for the null handle (Id == 0) and out-of-range ids. Note that this check ignores the generation — only ResolveString rejects stale generations.

Helpers

C++

uint32_t Object::GetStringLength(const StringHandle& handle);
void Object::LogStringData(const StringHandle& handle);

GetStringLength returns the stored size of the entry, which is the string length plus one for the null terminator (strlen + 1). It returns 0 for sentinel, stale or out-of-range handles.

StringMessage Status Codes

ResolveString reports a status code indicating the result:

C++

enum class StringMessage {
    Valid           = 0,  // String resolved successfully
    NotALiveEntry   = 1,  // Slot has been freed
    WrongGeneration = 2,  // Handle is stale (slot was reused)
    OutOfRange      = 3,  // Handle Id exceeds entry table bounds
    WrongSize       = 4,  // Internal size mismatch
    EmptyString     = 5,  // Empty-string sentinel (Id == UINT32_MAX)
    InvalidHandle   = 6,  // Null handle (Id == 0)
    EmptyHeap       = 7,  // The heap has no allocated storage
};
Status Meaning Action
Valid String resolved successfully Use the returned pointer
NotALiveEntry Slot has been freed Treat as no string available
WrongGeneration Handle is stale (slot was reused) Treat as no string available
OutOfRange Handle Id exceeds entry table bounds Treat as no string available
WrongSize Internal size mismatch Treat as no string available
EmptyString Empty-string sentinel (Id == UINT32_MAX) Treat as an empty string value
InvalidHandle Null handle (Id == 0) Treat as no string available
EmptyHeap The heap has no allocated storage Treat as no string available

Only Valid returns usable string data. EmptyString means the property intentionally holds an empty value; all other statuses should be treated as "no string available."

Heap Internals

Entry Table

The heap maintains a table of Entry structs:

C++

struct Entry {
    uint32_t Offset;       // Offset into the StringData buffer
    uint32_t Size;         // Stored size (string length + null terminator)
    uint32_t Generation;   // Current generation for this slot
    bool     Alive;        // Whether this slot is in use

    // Local state sync data.
    bool     IsDirty;      // Needs replication
    Tick     ChangedTick;  // When the entry was last modified
};

Memory Layout

Region Contents Notes
Slot 0 str_0 Live entry
Slot 1 str_1 Live entry
Slot 2 (freed) Tracked by FreeByOffset
Slot 3 str_3 Live entry
Remaining (free) Available for allocation

Free List

Freed slot ids are tracked in FreeIds (the lowest freed id is reused first). Freed heap segments are tracked in FreeByOffset (sorted by offset) and adjacent segments are coalesced to reduce fragmentation:

C++

struct FreeSeg {
    uint32_t Offset;
    uint32_t Size;
};

Auto-Resize

The heap is allocated lazily on first AddString and grows automatically when allocation cannot find a contiguous free segment. The constant HEAP_BUFFER_PADDING (256 chars) provides a growth margin. The heap never shrinks.

Replication

The heap has its own replication path separate from the Words buffer, using the same tick-ledger model.

  • Entries: AddString and FreeString mark the affected Entry as IsDirty. At send time, dirty entries get their ChangedTick stamped with the current tick and are included in the packet while ChangedTick is newer than the last acknowledged tick.
  • String data: the heap keeps its own Shadow and Ticks buffers, analogous to the Words buffer's. Each send tick the string data is compared byte-wise against Shadow; changed bytes are tick-stamped and transmitted individually (index + value) until acknowledged.

Two send flags mark heap content in a state packet:

C++

constexpr uint8_t OBJECT_SENDFLAG_STRINGHEAP_ENTRIES_CHANGE = 2;
constexpr uint8_t OBJECT_SENDFLAG_STRINGHEAP_DATA_CHANGE    = 4;

Spawn Data Exception

During object creation, strings in spawn data (the header blob) must be serialized as raw bytes rather than as StringHandle references. The heap is available immediately after object allocation, but any strings written to Words during the initial population (before SetHasValidData()) follow the normal handle flow.

The spawn-data blob (stored as Object::EngineBlob) is opaque bytes that the SDK passes through without interpreting. If your spawn data includes strings, encode them as length-prefixed UTF-8 in that blob, not as StringHandles.

Sub-Object Heaps

There is no heap sharing or delegation within an object hierarchy — each object, root or child, owns its own independent NetworkedStringHeap. AddString / FreeString / ResolveString called on an ObjectChild always operate on that child's own heap, and each heap replicates with its own object's state. A handle stored in a child's Words buffer must be resolved through that same child.

Handle Tracking for Leak Prevention

Every AddString call must eventually be paired with a FreeString call, either when the property value changes or when the object is destroyed. Leaked handles waste heap space and can eventually exhaust the heap.

A recommended pattern:

C++

// Track handles per property
struct StringProperty {
    StringHandle handle{0, 0};
    int32_t wordOffset;

    void Set(FusionCore::Object* obj, const RealtimeCore::Common::CharType* str) {
        // Free old handle (skips sentinels internally)
        handle = obj->FreeString(handle);

        // Allocate new, or keep the empty-string sentinel
        if (str && str[0] != 0) {
            handle = obj->AddString(str);
        } else {
            handle = {UINT32_MAX, 0};  // Empty string
        }

        // Write to Words
        obj->Words.Ptr[wordOffset]     = static_cast<int32_t>(handle.Id);
        obj->Words.Ptr[wordOffset + 1] = static_cast<int32_t>(handle.Generation);
    }

    void Free(FusionCore::Object* obj) {
        handle = obj->FreeString(handle);
    }
};

Usage Patterns

Authority Side (Writing)

C++

// Read the current handle from Words
StringHandle oldHandle;
oldHandle.Id         = static_cast<uint32_t>(words[offset]);
oldHandle.Generation = static_cast<uint32_t>(words[offset + 1]);

// Free the old string (sentinels pass through unchanged)
obj->FreeString(oldHandle);

// Allocate new string
StringHandle newHandle = obj->AddString(PHOTON_STR("Hello, world!"));

// Write new handle to Words
words[offset]     = static_cast<int32_t>(newHandle.Id);
words[offset + 1] = static_cast<int32_t>(newHandle.Generation);

Remote Side (Reading)

C++

// Read handle from Words
StringHandle handle;
handle.Id         = static_cast<uint32_t>(words[offset]);
handle.Generation = static_cast<uint32_t>(words[offset + 1]);

if (handle.Id == 0) {
    // Never set — no string
} else if (handle.Id == UINT32_MAX) {
    // Explicit empty string
} else {
    StringMessage status;
    const RealtimeCore::Common::CharType* str = obj->ResolveString(handle, status);
    if (status == StringMessage::Valid && str) {
        // Use str (UTF-8 encoded)
    }
}
  • {VersionPath}/manual/architecture -- Words buffer layout and type mapping
  • {VersionPath}/manual/scene-management -- Spawn data exception during object creation
Back to top