Object handles and persistent UUID architecture¶
Status: Architecture and implementation contract. Stage 1 of the
implementation sequence is implemented as
FoundationIdentity, with native and browser
entropy adapters in Platform. Stage 2 is implemented: EntityRegistry issues
world tokens and advances generations through FoundationIdentity, while audio and
RHI keep their own sources for the reasons recorded in the
implementation sequence. Stage 3 is implemented:
version-2 World records and a one-time migration manifest give World objects
committed UUIDs (UUID authoring records).
Stage 4 is in progress and lands as independently reviewable slices. Implemented
so far: WorldBindingTable (modules/gameplay/world) holds the two derived
mappings at the World boundary and resolves durable keys with TryResolve;
TryRestoreWorld (modules/content/world_restore) rebuilds a version-2 World
record into a registry and binding table in two passes
(resolution and streaming); and DuplicateEntities
(modules/content/identity_records) duplicates a selected set of record entities
as one graph transaction (authoring and prefab instances).
Editor selection and stale-operation checks are the remaining stage-4 slice.
Audience: Engine, gameplay, tools, content, and SDK contributors.
Evidence baseline: Repository inspection at 52e29ce on 2026-10-04 with
concurrent uncommitted changes. This document and its
reference review are the outputs of
this design task. No engine implementation or performance benchmark was added.
Ludus should use typed generational handles for runtime access and typed UUIDs for durable identity. Resolve durable references at explicit boundaries, then run simulation over handles or dense component storage. Keep owners visible, borrow pointers within protected phases, and retire resources after their users finish. This provides a small, inspectable core that supports streaming, multiple worlds, editor undo, saves, and reload without making every object participate in a universal manager.
These are design judgments for Ludus, informed by current primary documentation and the local chapters. There is no universally fastest handle representation. The architecture prioritizes identity safety and clear lifetime contracts, then permits measured storage optimizations without changing those contracts.
Core decisions¶
- Preserve
EntityIdasWorld / Slot / Generation:uint64 / uint32 / uint32. Use equivalent owner-scoped values for new runtime domains, with separate semantic types. Keep existing audio and RHI ABIs until separately migrated. - Never reuse a runtime owner identity within its process and never wrap a slot generation. Retire exhausted slots and report owner-identity exhaustion.
- A handle neither owns an object nor pins its memory. Pointer borrowing, lifetime leases, and identity validation are separate operations.
- UUIDs belong in durable records and resolution tables. Dense numeric access belongs in gameplay and rendering loops. A hash is only a lookup accelerator.
- Generate new authored UUIDv4 values through platform entropy. Preserve them through renames and moves; duplicate objects through a graph remapping transaction. Scope reusable source objects by their instance identity.
- Share small value types and generation rules. Keep object storage, synchronization, destruction, residency, and ownership in each subsystem.
- Validate runtime handles in release builds. Cache identities, never unprotected pointers. Gate delayed results with operation identity and content revision.
- Start with fixed capacity during active phases and owner-thread structural mutation. Add compressed handles, paging, concurrent reclamation, or distributed ID issuance only for a measured or explicit product requirement.
Current Ludus baseline¶
| Facility | Current behavior | Design treatment |
|---|---|---|
| EntityId and EntityRegistry | A nonzero process-issued world token, slot, generation, pending-destroy flag, creation sequence, and structural revision. Exhausted generations retire; initialization reserves capacity. | Preserve these guarantees and the public representation. Since stage 2 the token and generation rules come from FoundationIdentity. |
| ComponentPool | Dense values and full owner IDs, sparse slot lookup, exact owner equality, swap removal, preallocated storage, phase restrictions. | Preserve. Identity allocation does not replace component storage. |
| Audio handles and bookkeeping | Typed uint32 session/slot/generation values, process-issued sessions, generation exhaustion, subsystem-specific asynchronous ownership. |
Reuse contracts where compatible; preserve the current sentinel and concurrency rules. |
| RHI handles | Separate shader/uniform/pipeline types with private uint32 IDs and backend-owned validation. |
Review backend issuance, session rejection, and retirement before a dedicated migration. A small public ID alone does not establish its implementation policy. |
| Levels and content | Scoped authored strings and logical manifest keys; cold-path resolution; staged replacement. Content also has SHA-256 digests. | Keep version-1 formats usable. Add UUIDs through a versioned authoring workflow; keep names and digests in their existing roles. |
This proposal follows AGENTS.md, the steering rules, ADR 0003, and the foundational include boundary. It complements entity storage, memory, and gameplay reload. Proposed allocation facilities are not prerequisites for nonallocating identity values and codecs.
Identity types and their lifetimes¶
| Value | Identifies | Lifetime and use |
|---|---|---|
EntityId, TextureHandle, VoiceHandle |
One runtime incarnation in one owning registry/session | Nonowning runtime references. Reject after destruction or owner replacement. |
AssetId |
One logical authored asset | A UUID that survives moving files or changing asset contents. |
SourceObjectId |
One authored object in a level or reusable source document | A UUID retained through edits. Distinct from its display name. |
InstanceId |
One placement or saved instance scope | A UUID that distinguishes repeated instances of the same source. |
PersistentObjectKey |
One object within an instance | The pair (InstanceId, SourceObjectId), normally 32 bytes. Used in saves, undo, and durable object links. |
ContentRevision and content digest |
Which state or payload of an identity is in use | Changes when content changes; identity need not change. |
NetObjectId |
A replication binding in a network session | Protocol-owned; mapped to local runtime identity, never confused with authority or persistence. |
Debug name or NameId |
A human label or interned spelling | Search and presentation. Rename does not destroy identity. |
Give AssetId, SourceObjectId, and InstanceId separate wrapper types even
though each contains the same 16-byte UUID value. Require named conversion at
serialization boundaries. Compile-time tags prevent mixing entity, texture,
and voice domains. Runtime owner checks prevent mixing two registries of the
same domain. Neither mechanism replaces the other.
Transient particles, temporary query results, plain component values, and most projectiles need no UUID. Give an object durable identity only when something must name it after its runtime incarnation ends. Persistable runtime spawns can receive a new source-object UUID within their saved instance scope; an instance scope need not refer to a prefab asset.
Runtime handle representation¶
The default for a new externally held domain is a trivially copyable 16-byte value on the supported targets:
#pragma once
#include <ludus/foundation/base/types.h>
namespace ludus::foundation::identity
{
// Thanks to Scott Bilas, "A Generic Handle-Based Resource Manager", Game
// Programming Gems 1, section 1.6, pp. 68-79, for typed indexed handles, and
// Jason Gregory, "Object References and World Queries", Game Engine Architecture,
// third edition, section 16.5.3, pp. 1082-1085, for incarnation validation.
// Design inspiration only: owner tokens and nonwrapping generations are Ludus
// choices; no sample code is copied. Review: object-handles-uuid-reference-review.md.
template <typename Tag>
struct Handle final
{
uint64 Owner = 0;
uint32 Slot = 0;
uint32 Generation = 0;
[[nodiscard]] constexpr bool operator==(const Handle&) const noexcept = default;
};
} // namespace ludus::foundation::identity
This is an API sketch, not an instruction to replace EntityId or expose an
untyped object pointer. Each subsystem names its tag and handle type. The handle
does not contain a pointer, refcount, UUID, virtual function, or manager lookup.
Public fields aid inspection and match EntityId; forged or malformed values
still fail validation. For FFI, use explicit plain fields and adapters through
the existing versioned function-table boundary rather than exporting templates
as an ABI.
All-zero is the canonical null value. Owner zero or generation zero is invalid;
slot zero is a valid first slot. Equality compares the complete value, including
owner. Equality and null tests say nothing about liveness. There is no implicit
integer conversion, automatic dereference, or manager-searching operator->.
An owner token identifies the registry's lifetime, not its C++ address, thread, world name, or mutable object revision. Moving a registry transfers its token and storage, leaving the source inert. Clearing and rebuilding a registry invalidates all references and obtains a fresh token. It must not zero its generations and continue using the same owner token. The existing registry's no-reset policy is the initial implementation of this rule.
The process host issues nonzero monotonically increasing uint64 tokens, once
per new registry lifetime. Issue them through one host-owned source when code
modules can reload: a header-local static or a static linked independently into
each dynamic module is not process-wide issuance. A named host callback can
provide issuance across the gameplay ABI. The source's counter may use relaxed
atomic operations for unique numbers; publication of objects requires separate
synchronization. Return IdentityExhausted at the terminal value; never wrap.
Forked processes cannot exchange these runtime tokens as object references.
Optional local handles¶
A distinct internal LocalHandle<Tag> can contain uint32 Slot and uint32
Generation, occupying eight bytes. It is suitable only where the enclosing
storage proves one owner and is discarded before that owner is replaced. Two
owners can legitimately issue the same local value: passing one to the other
cannot be detected by those eight bytes.
Keep this type out of public gameplay links, asynchronous messages, editor selection, script references, and mixed-world queues. Merely taking a registry parameter does not prove the caller chose the correct registry. Promotion to a full handle requires an established owning context; attaching an arbitrary owner token does not recover lost provenance. Adopt local handles only after measuring memory or cache pressure and auditing every escape path. Do not steal a few bits for an owner or type field without a demonstrated capacity and exhaustion plan.
Registry state and validation¶
Each owner keeps slot generations, slot state, and a reusable-slot list. A domain that stores dense objects also needs slot-to-dense and dense-to-slot mappings; an ECS registry does not need a second object-pointer table. Keep metadata and payload separate so an invalid lookup does not touch a destroyed object.
stateDiagram-v2
Free --> Reserved: Preflight and reserve
Reserved --> Live: Publish complete state
Reserved --> Free: Roll back private reservation
Live --> PendingDestroy: Record accepted destruction
PendingDestroy --> Free: Reader barrier and generation advance
PendingDestroy --> Retired: Terminal generation
Reserved is private construction state. It need not be stored as an additional
flag if unpublished construction already guarantees it. A reservation that has
escaped to a client or job is an observable incarnation: cancellation must consume
its generation before reuse. Returning an asynchronous pending handle requires
that explicit protocol; it cannot use private rollback.
IsAlive validates, in order:
- The owner is nonzero and exactly matches the receiving registry.
- The generation is nonzero and the slot is within the configured range.
- The slot is published and live.
- The stored generation exactly equals the handle's generation.
IsActive additionally rejects pending destruction. These checks remain in
release builds. A component lookup also checks its sparse index, dense bounds,
and exact dense owner, as Ludus does today. Callers that need authoritative
activity check the registry or an active-world facade; pool membership alone
can remain visible during cleanup of a pending entity.
Generations start at one. Release increments the slot's generation before reuse; a slot released at its terminal usable generation becomes retired permanently for that owner. Retain a subsystem's reserved sentinel policy, including audio's, until deliberately migrated. Retired slots count against configured capacity. Changing free-list order cannot compromise correctness; a simple deterministic LIFO list is adequate initially. A FIFO list or reuse delay may spread churn, but cannot substitute for exhaustion handling.
The safety argument is small: a recycled slot has a different generation; a replaced registry has a different owner; neither counter wraps or resets while old handles may exist. Therefore an old value cannot name a newly issued incarnation within this process. This argument assumes correct publication and synchronization; it is not a concurrency or memory-safety proof for escaped pointers.
Initialization allocates and checks all required arrays and byte-size products
before installing an owner. Active-phase create, validate, and release do not
allocate or grow. Capacity, sequence, and structural-revision exhaustion have
explicit statuses. Fallible operations are [[nodiscard]] and noexcept; failed
creation leaves the output unchanged. Preflight a batch's capacity and revision
budget before publishing any of its structural changes. Avoid ordinary allocating
STL calls on a path that promises recoverable allocation failure.
Storage and pointer borrowing¶
Keep the current ECS sparse/dense design. Its owners are identities, its dense positions are locations, and its values are state. Swap removal updates the moved owner's sparse entry. It never changes that owner's generation. A removed and later re-added component can reuse an address even though the entity survives; entity liveness does not validate an old component pointer. If a component itself must have independently retained identity, give that facility an explicit membership generation in a separate design.
For non-ECS domains, choose dense storage for movable plain values and stable allocation for objects whose backend or API requires a stable address. Indirection allows either choice without changing handles. Relocating nontrivial objects requires legitimate move/construction/destruction semantics; do not byte-copy arbitrary C++ objects. Use current fallible arrays and reserve capacity before introducing slab or custom allocator machinery.
A pointer or span from Find is borrowed until the current protected phase ends,
or earlier if a documented structural operation occurs. Resolve once and reuse
within that phase. Do not retain it in a handle wrapper, suspended coroutine,
callback, editor model, or job that outlives the phase. Dense iteration should not
perform UUID lookup or repeatedly resolve the owner already supplied by its pool.
Owner-thread structural mutation is the initial synchronization policy. Jobs may
read protected storage or perform declared disjoint value writes; the owner joins
them before committing structural changes. InvalidPhase is a runtime error,
not just a debug assertion. A structural revision helps debug borrowed views but
cannot make an escaped raw pointer safe. An optional noncopyable debug view can
check the revision under the existing reader protocol.
Destruction and physical retirement¶
Accepting a destroy command marks the entity pending only after the bounded command has been recorded successfully. At the commit boundary, close new gameplay access and join current readers. Preflight the structural batch, detach durable bindings, remove components, then release the registry slot. Existing pool removal requires a live registry entity, so preserve this order inside the exclusive commit facade. No user callback can resolve partially dismantled state.
Detach any external resources into subsystem-owned retirement records before recycling their storage. Call external cleanup only after public identity access is closed; never invoke arbitrary destructors while a registry lock is held. GPU fences, audio callback acknowledgments, and job completion determine when the detached resources can actually be freed. Queue space and lease budgets are part of preflight. Invalidating identity does not prove physical users have stopped.
An asynchronous consumer stores a full handle and an operation ticket containing the owning session/request sequence and expected content revision. On completion, the owner revalidates all of them before publication. A still-live entity may have cancelled a request or started a newer one. A copied UUID is not sufficient to authorize an old completion after unload and recreation. Worker access to payload memory needs an explicit lease, snapshot, or reader phase in addition to the ticket. Atomic generation checks alone leave a validate-then-free race; add concurrent reclamation only with a concrete consumer and a separate synchronization design.
Persistent UUID representation and generation¶
Use Uuid { uint8 Bytes[16]; } in canonical byte order, with semantic wrappers
containing a UUID by value. Do not depend on packed structs, bitfield layout,
native-endian integer dumps, unaligned loads, or platform GUID memory layout.
Keep the codec, equality, and hashing interface small and allocation-free.
RFC 9562 specifies 128-bit UUIDs and network byte order. Its UUIDv4 definition uses 122 random bits after version/variant fields; section 6.9 recommends cryptographic random generation. These are format facts. Choosing v4 for Ludus authoring is this proposal's judgment: minting occurs mainly outside hot loops and needs neither clock ordering nor a central ID server.
The tools or runtime persistence owner obtains 16 bytes from an injected
platform entropy provider and sets the v4 version/variant fields. Entropy failure
returns EntropyUnavailable with no partial output; do not fall back to wall
time, pointer addresses, rand, or a gameplay PRNG. Native and browser adapters
must satisfy the same entropy contract. Batch generation can amortize calls
after measurement without maintaining a second custom cryptographic generator.
Deterministic tests inject a controlled byte source that cannot become the
production default.
Write lowercase 8-4-4-4-12 hexadecimal text, exactly 36 characters. Parse this
shape with case-insensitive hex; reject whitespace, braces, wrong separators,
wrong length, and unsupported versions/variants under the schema policy. Initial
durable schemas require v4 for nonnull identity. All-zero is an explicit optional
reference sentinel and is forbidden for actual asset/object/instance identity.
Formatting uses a caller-owned 36-byte buffer, or 37 bytes for a terminating
zero. Parsing failure leaves the output unchanged. Store exactly 16 canonical
bytes in versioned binary formats.
Treat global UUID uniqueness as probabilistic, then enforce uniqueness exactly
within every loaded project, import, save, or package index. Full-byte equality
decides identity after a hash match. For newly generated identities, check the
target index and retry at most four candidates; return IdentityConflict if
generation cannot produce a free one. For duplicates supplied by content,
reject with both source locations. Never silently regenerate one side when
existing references may depend on it. Project merge and duplication use an
explicit remapping transaction.
UUIDv7 adds timestamp ordering
(RFC 9562 section 5.7).
Adopt it only for a separately measured ordered database requirement with a
versioned schema and clock policy. It does not improve direct slot lookup. Keep
deterministic derived subresource keys explicit, such as (AssetId, SubresourceId);
do not label an arbitrary truncated hash as a standard UUID or allow changes in
names to silently change identity.
Authoring and prefab instances¶
The editable document stores an asset/document UUID, an object UUID for each durable source object, and independent human-readable names. A maintained project index maps UUID to source location, kind, and current revision. Rebuilding that derived index never mints replacement IDs. Reject duplicate identity before publishing the rebuilt index. Raw file copies that preserve IDs are either the same logical artifact or an import conflict, not an implicit duplication request.
Renaming, moving between files, reordering, or changing a recipe preserves the source object's identity when the operation means the same logical object. Explicit replacement can mint a new identity. Undo of deletion can restore the original durable identity, but recreation receives a new runtime handle. An editor selection or undo record keeps the durable key, expected document revision, and optional cached handle; it never keeps a component pointer.
Every placed prefab instance receives its own InstanceId. An internal target
reference names a SourceObjectId within that same instance, so ten copies of a
prefab resolve the target to ten different runtime objects. An external link stores
the complete PersistentObjectKey. A standalone level uses its placement/save
scope as the instance. Two placements of the same level receive different
instance IDs; reopening the same saved placement restores its saved ID.
Nested prefab scopes have their own instance IDs stored in the placement/save graph. The loader resolves a relative path of authored placement IDs to the appropriate scope, then resolves the leaf source object. Flatten these references to concrete keys at activation. Do not invent a new nested instance ID on every load, use ambiguous concatenated strings, or truncate a parent/child hash to claim collision-free identity. Prefab revision migration explicitly remaps renamed or replaced source objects; missing required targets reject the candidate.
Duplication is a graph transaction: allocate all new source or instance IDs, build the old-to-new table, clone data, rewrite references whose targets belong to the copied set, retain deliberate external references, validate, then publish. Duplicating a source document remaps its contained source objects; duplicating a placement remaps instance scopes while retaining source-template IDs. If any step fails, neither the original nor a partially rewritten clone is published.
Stage 4 implementation (graph duplication). DuplicateEntities
(modules/content/identity_records) duplicates a set of
version-2 record entities as one transaction. It mints a fresh SourceObjectId
for each selected entity with TryGenerateUuidV4 and the injected entropy source,
checking each candidate against every identity already in the record and those
minted so far and regenerating a collision at most four times before reporting
IdentityConflict. It then builds the old-to-new table, clones the selected
entities, rewrites each clone's entity-typed references whose target is in the
copied set to the target's new UUID, retains references to entities outside the
set unchanged, and re-emits the whole record. The transaction is all-or-nothing:
a malformed record, an unknown selection, an unavailable entropy source, or an
unresolved conflict leaves the output unchanged and mints nothing into it. Only
durable SourceObjectId values are produced; no runtime handle is read or
written. The Editor's group-duplicate command performs the same old-to-new
remap over its draft today; converging it onto this shared engine-side
transaction is tracked as editor follow-up.
Resolution and streaming¶
Keep two derived mappings at the owning boundary: durable key to runtime handle, and runtime slot to optional durable binding. The reverse binding is validated with generation and owner, so recycled slots cannot inherit a former object's UUID. Publish and remove both mappings together with object activation and destruction. Reserve their capacity before commit. Do not add UUID metadata to every transient entity or retain multiple mutable sources of truth.
Start with a sorted contiguous binding table for small immutable candidate
worlds. For a measured dynamic streaming workload, use a private preallocated
flat hash table with a declared load limit and exact key equality. Sorted lookup
is O(log n); hash lookup is expected O(1), not a worst-case guarantee. Keep
table iteration order out of gameplay priority and serialized output. Cache
handles after resolution; require an allocation-failure-capable container seam
before promising transactional growth.
The proposed TryResolve returns a status and a full handle, without I/O or
implicit loading. It validates both the runtime incarnation and the durable
binding on a cache hit. Unload/reload removes the old binding and creates a fresh
handle; the durable key remains available for a later explicit resolve. A cached
negative result must be checked against a binding-table revision or invalidated
on publication. Otherwise an unloaded target can remain falsely missing forever.
| Resolution state | Meaning and response |
|---|---|
Resolved |
A matching live binding exists. Check activity and required capability before use. |
NotResident |
An authoritative catalog/save index knows the target, but its instance is not loaded. The caller may explicitly request streaming. |
Missing |
No known target exists in the available authoritative scope. Report it; do not claim permanent deletion without evidence. |
TypeMismatch |
Identity exists but the required asset kind or gameplay capability does not match. |
InvalidReference or Conflict |
Malformed key, unknown scope, or duplicate identity. Reject required bindings. |
Deletion can be reported separately if a save/editor layer actually maintains tombstones. Optional absence is distinct from a broken required reference. Required local links are resolved in a second pass after all candidate entities exist, preserving forward references and the current staged-world transaction. Streaming links state whether absence is allowed; the resolver must not guess gameplay policy.
Cooked package indices and local relocations may compress UUID references into numeric dictionary indices, provided the dictionary preserves full identity and is included in the versioned artifact. These indices are artifact-local; changing the package ordering cannot retarget saved references. Rehydrate durable identities and rebuild runtime bindings on load. Serialize neither runtime owner/slot/generation nor object addresses. Restore uses two passes: create objects, then fix up links.
Stage 4 implementation (two-pass restore). TryRestoreWorld
(modules/content/world_restore) rebuilds
a version-2 World record into an EntityRegistry and a WorldBindingTable in two
passes: pass 1 creates one entity per record entity and records its
SourceObjectId -> EntityId mapping without resolving any link; pass 2 resolves
every required local link (an entity-typed property naming another entity's
SourceObjectId) now that all candidates exist, so forward references resolve.
It then publishes all durable bindings with one atomic TryActivate. The caller
supplies the activating InstanceId (from the level's placement/save) and a
registry with spare capacity. On any failure — malformed input, an unresolved
required link, a duplicate identity, or a capacity/allocation failure — the
entities created in pass 1 are released, no binding is published, and the active
world is unchanged; runtime owner/slot/generation and addresses are never read
from or written to the record. The in-tree world_restore library bridges
Ludus::ContentIdentity (record parsing) and Ludus::GameplayWorld (entity
creation and binding) so neither of those modules depends on the other.
Stage 4 implementation (binding table). WorldBindingTable
(modules/gameplay/world) implements the two
derived mappings at the World boundary: a sorted contiguous
SortedMap<PersistentObjectKey, EntityId> forward map and a slot-indexed reverse
mapping validated by owner and generation, so a recycled slot never inherits a
former object's UUID. TryActivate publishes both mappings and advances a
monotonic binding revision in one step after reserving capacity through the
SortedMap allocation-failure seam; a failed batch publishes nothing.
DetachBinding removes an entity's binding inside the registry destruction order,
before the slot is recycled. TryResolve performs no I/O, validates the stored
incarnation (owner and generation) on a hit, drops a stale binding and reports
Missing when the incarnation is gone, and returns the ResolveStatus vocabulary
above. Callers invalidate a cached negative result by comparing
GetBindingRevision(). The caller forms each PersistentObjectKey from the
activating instance scope's InstanceId and the authored SourceObjectId; a
standalone level's InstanceId comes from its placement or save scope, not minted
per activation. Two-pass restore (which rebuilds these bindings) is a later
stage-4 slice.
Logical assets and resident payloads¶
Distinguish an asset's logical record from its current resident payload. An
AssetId resolves to a typed AssetHandle for a record in the content session.
The record can survive eviction or validated hot reload while moving through
Unloaded / Loading / Ready / Failed. Handle liveness therefore does not imply
that payload data is available. An entity or GPU-object handle, by contrast,
names one runtime incarnation and does not silently rebind after destruction.
Hot reload keeps the logical asset ID, advances ContentRevision, validates a
candidate payload, and publishes at the subsystem's safe boundary. A caller that
needs bytes or a backend resource obtains a phase borrow or explicit lease of
one immutable payload version. Older leases retain that version until completion;
they cannot be redirected midway through a draw, audio callback, or job. A logical
asset handle cannot be used as a fence or physical-resource lifetime lease.
Presentation may choose a visible missing-texture material or silence through an explicit fallback policy. Keep fallback objects immutable and distinguish fallback use in inspection. Required gameplay targets, collision data, and authoritative state return errors; they do not become null objects that quietly change game rules. This adapts the weak-reference article's useful residency indirection without adopting automatic lifetime ownership or a fallback for every domain.
Persistence and networking¶
Save the instance scopes, durable keys, schema versions, and required state. Allocate fresh runtime owners and handles on restore. Asset identity is independent of its digest: an edited asset may retain its UUID while changing digest; copied bytes can belong to two distinct logical assets. Store source revisions/digests where reproducible loading requires them. A rename alias table may assist a versioned migration; it is not an alternate mutable identity system.
Networking owns a separate session-to-local binding table with generation or issuance policy that rejects delayed packets after reuse. Persistent server objects may carry durable keys; compact session IDs can carry routine packets. The authoritative peer chooses creation and binding, and validates permissions separately. Possession of a UUID or locally valid handle grants no authority. Client-predicted objects use a separate provisional ID type and explicit reconciliation; they cannot assert a permanent server binding by choosing a UUID. Detailed replication/reclamation rules belong to the future networking design.
Deterministic simulations must not mint random UUIDs as ordering decisions. Use authored identity, a recorded spawn identity, or the existing deterministic creation sequence for documented tie policies. Hash iteration, runtime owner values, pointer addresses, and worker completion order are unsuitable priorities. If replay needs new persistent IDs, record the assigned identities in the replay or use a separately specified deterministic issuance protocol.
Module and API boundaries¶
Introduce FoundationIdentity only for small handle/UUID values, UUID codecs,
generation advancement, and the host-owned lifetime-token source. It depends on
FoundationBase and does not include Platform, Logging, strings, or containers in
its value headers. Atomic counters and implementation details stay in .cpp.
The entropy adapter lives in Platform/tools and supplies bytes to a pure UUIDv4
construction function; FoundationBase never gains an upward dependency.
When implementing these facilities, place concise author acknowledgments beside the affected code, following the reference review. Credit the consulted work and explain departures; these are design inspirations, not copied implementations. UUID codecs credit K. Davis, B. Peabody, and P. Leach, Universally Unique IDentifiers (UUIDs), RFC 9562, sections 4, 5.4, and 6.9 (stable specification). Asset residency credits Noel Llopis's article 1.7 in Game Programming Gems 4, pp. 61-68; issuance/authority policy credits Yongha Kim's article 7.3 in Game Programming Gems 6, pp. 623-628; pointer-cache rules credit Brian Hawkins's article 1.5 in Game Programming Gems 3, pp. 44-48. Thank the authors near those implementation boundaries and link to the exact titles and adoption/departure details in the review.
Use the explicitly included FoundationBase checked_integer.hpp helpers for
capacity conversion and allocation-size arithmetic, and byte_order.hpp for
fixed-width integer fields in versioned identity formats. Canonical UUID byte
arrays need no host-endian reinterpretation. Neither opt-in helper belongs in
the identity value header or the foundational types.h/core.h include closure;
see primitive types and numeric boundaries.
Typed facades remain GameplayWorld, audio, content, and RHI responsibilities.
Do not export one giant Registry<T, Allocator, StoragePolicy, ThreadPolicy> or
include private headers across module boundaries. Shared non-template bookkeeping
can be extracted once two concrete consumers need it. The existing component
template continues to serve its own documented storage contract.
| Proposed operation | Contract |
|---|---|
TryParseUuid(text, output) |
Bounded validation; no allocation; unchanged output on failure. |
FormatUuid(value, buffer) |
Canonical text in caller-owned storage; explicit insufficient-space result. |
TryMakeUuidV4(randomBytes, output) |
Pure construction from an explicitly supplied 16-byte input; production generation obtains entropy first. |
TryIssueLifetimeId(output) |
Host source issues once, thread-safe; no wrap or allocation. |
Registry TryCreate, IsAlive, IsActive, TryRelease |
Preserve the current phase, capacity, full validation, and output contracts. |
Find(handle) and DiagnoseHandle(handle) |
Null-returning protected lookup; separate cold diagnostic explaining rejection. No auto-load. |
TryResolve(key, output) |
Explicit durable binding lookup; no I/O; validates cache provenance. |
TryAcquirePayload(handle, output) |
Domain-specific readiness check and bounded borrow/lease acquisition; independent of identity validity. |
Use subsystem statuses at facades rather than imposing one engine-wide enum for unrelated failures. Ordinary missing content and stale delayed work are statuses, not assertions. Broken internal free-list invariants are assertion failures through the FoundationBase emergency path. Diagnostics use existing logging outside hot validation; failure to log must not change identity behavior.
Debugging and observability¶
Display complete values such as Entity{World=42, Slot=17, Generation=9}. UUID
inspection shows its semantic type, source path, debug name, instance scope, and
revision when those records exist. Keep generation distinct from content revision.
The editor can copy canonical UUIDs and navigate to their source without requiring
simulation to store long strings on every entity.
DiagnoseHandle distinguishes null/malformed value, owner mismatch, out-of-range
slot, free or retired slot, stale generation, and pending destruction. For a
resolved asset it also shows residency and payload revision. A bounded optional
history ring records creation, invalidation reason, and issuing site/sequence;
debug names and history are side metadata. Rate-limit stale-reference reports at
the operation boundary rather than logging each IsAlive call.
Track capacity, live/pending/free/retired counts, peak generations, capacity failures, durable-binding conflicts, unresolved references, stale completions, lease counts, retired payload bytes, and completion backlog. Report enough to identify the actual owner and source; do not truncate a UUID or hash and then use that display abbreviation as an identity key. Provide debugger pretty-printers without modifying the shipping handle layout.
Validation and performance gates¶
Implementation needs tests for wrong-domain compilation, two owners issuing the same slot/generation, deletion/reuse, pending visibility, registry moves and replacement, slot retirement, terminal owner issuance, double destroy, and malformed values. Test terminal counters through a narrow test seam. Exercise create/commit allocation failure and output preservation; validate free-list uniqueness and complete sparse/dense back-mappings after randomized operations.
UUID and content tests cover known format vectors, binary/text round trips, case handling, malformed input, nil-policy enforcement, entropy failure, injected duplicate generation, hash collisions with exact equality, and duplicate import diagnostics. Test rename/move preservation, graph duplication, nested and repeated prefabs, undo recreation, cross-instance links, two-pass forward references, streaming negative-cache invalidation, save restore, and unchanged active worlds after a failed candidate. Test stale operations while the handle remains alive, as well as unload/recreation of the same durable key.
Run warning-clean builds, format/tidy, unit tests, ASan/UBSan, and SDK consumption with the pinned native tools when code is implemented. Exercise browser UUID adapters and portable serialization with the pinned web toolchain. Add TSan and explicit concurrency scenarios if a concurrent implementation is introduced. Sanitizers supplement lifetime tests; they do not prove semantic identity safety.
Measure release-build lookup, create/destroy churn, dense iteration, binding resolution, UUID batch generation, and reload/retirement. Include hot and cold working sets, live/stale/wrong-owner mixes, realistic component values, repeated slot churn, and memory footprint. Report latency distribution, allocation counts, branch/cache data where available, and total frame cost against the current Ludus baseline. A raw-pointer comparison is valid only inside the same protected lifetime.
Full handle validation and fixed-capacity free-list operations have bounded constant work; complete object construction/destruction can have additional domain costs. UUID resolution is outside the dense loop. At 1,048,576 references, 16-byte handles occupy 16 MiB and eight-byte local handles occupy 8 MiB, before enclosing-struct padding. Current entity slot metadata targets 16 bytes plus a four-byte free-list entry per capacity slot; each reserved component pool also budgets sparse indices, full dense owners, and values. Measure actual layouts and reserved capacity on each target before optimizing them.
| Alternative | Decision |
|---|---|
| UUID-to-pointer lookup for every runtime access | Keep for durable resolution; avoid its table work in dense iteration. |
| Raw pointers as long-lived object identity | Allow protected short borrows; they cannot reject slot reuse or support relocation. |
| Shared ownership for all entities | Keep explicit world ownership; use leases for actual retained resources. Copying a reference must not delay gameplay destruction. |
| A packed 32-bit handle with short generation | Preserve existing domains where independently justified; reject as the general default because capacity and reuse lifetime are unnecessarily constrained. |
| An eight-byte local slot/generation | Conditional optimization with a proved owner boundary and measurements. |
A global singleton resolver with operator-> |
Keep owners explicit and lookup visible; avoid hidden synchronization and lifetime policy. |
| UUIDv7 or server-assigned distributed IDs everywhere | Conditional facilities for ordered databases or distributed authority, not ordinary runtime identity. |
| Lock-free registry and automatic reclamation | Separate future design after a concrete concurrent consumer exists. |
Implementation sequence¶
- Add the pure identity values/codecs and lifetime source with tests and SDK
header checks. Leave existing public types and formats unchanged.
Implemented in FoundationIdentity,
including the injected entropy-source seam.
Ludus::PlatformEntropysupplies the production source (GetSystemEntropySource):getentropyon macOS/Linux and a guarded Web Cryptocrypto.getRandomValuesbridge in browsers. Emscripten's owngetentropyis not used because a missing Web Crypto object raises a JavaScript exception instead of reporting failure.
SceneCook entropy decision. SceneCook mints level, import-namespace and
object UUIDs through TryGenerateUuidV4, the only UUIDv4 construction path;
its private helper no longer calls getentropy or sets version bits itself.
The source is injected through AssetOptions::EntropyOrNull, and SceneCook
acquires no entropy of its own. A null or failing source reports
AssetErrorCode::MissingTools before any level is returned or catalog
published. The adapter is a separate Ludus::PlatformEntropy target
(ludus/platform/entropy.h, FoundationBase and FoundationIdentity only), which
Ludus::Platform re-exports. The ludus-cook CLI links that target, so a
command-line tool does not link window backends, AppKit or Wayland. The
Editor already links Platform and passes the same source, which also replaces
the cooker's unguarded getentropy call in browser builds.
Editor entropy decision. The Editor mints World entity IDs, world-demo
settings-entity IDs and new 3D-level IDs through the same TryGenerateUuidV4
path; Qt's QUuid::createUuid() is no longer a parallel generator. The World
and level workspaces hold an EntropySource, defaulting to
GetSystemEntropySource(), and pass it to WorldDocument entity creation and
duplication and to every SceneCook operation; Editor tests inject failing or
fixed sources through the same seam. Existing text forms are unchanged:
canonical lowercase UUID text for live-edit entities and level IDs, and the
world-demo sample's truncated entity- (24 UUID characters) and settings-
(22) forms, which fit its 32-byte name field. Those truncated forms are
legacy sample identifiers, not UUIDs, and remain so until the stage 3 authoring
records replace them. When entropy is unavailable, the operation fails before
the document, history or workspace changes and shows that reason in the status
line; there is no fallback.
Alternatives considered: linking SceneCook to Ludus::Platform would make a
content library depend on windowing. A tools-owned copy of the adapter would
duplicate the browser guard. A default system source inside SceneCook would
hide an OS dependency in the cooker and let tests reach real entropy by
omission. AssetOptions::UuidsOrNull is kept only so golden tests can replay
the Python reference's sequential, non-v4 identifiers verbatim. Tests that need
new identities inject controlled entropy, and no replay source is a production default.
2. Align duplicate generation/token helpers behind existing subsystem facades
where policies actually match. Validate old-world, old-audio-session, and RHI
session rejection independently. Preserve audio callback and RHI fence protocols.
Implemented. Only the world registry's policy matched, so only it moved.
World registry. EntityRegistry dropped its private gNextWorld counter
and release arithmetic. TryInitialize takes its world token from
TryIssueLifetimeId, and TryRelease advances the slot with
AdvanceGeneration. The old and shared policies are identical: tokens start at
1, the last token is UINT64_MAX - 1 (LAST_LIFETIME_ID, pinned by a
static_assert), and the next initialization reports IdentityExhausted
without wrapping. Generations start at 1, and a slot released at UINT32_MAX
(TERMINAL_GENERATION) is retired and still counts against capacity. The token
is still issued last, so a failed initialization consumes none. EntityId, the
registry headers and its statuses are unchanged; GameplayWorld links
FoundationIdentity privately. One intentional difference: world tokens now share
the process counter with other FoundationIdentity owners, so two consecutive
worlds need not receive consecutive tokens. Nothing relies on that; consumers
such as the camera's discontinuity revision need only uniqueness.
Reload limitation. The counter is process-wide only within one linked
image. Registries are created today by host-side code (the world demo model and
the script-interaction fixture), and ludus_add_game links a reloadable
gameplay module only to the header-only Ludus::GameApi. A project that also
links Ludus::GameplayWorld into its _module gets a private copy of the
counter, as it already did of the old gNextWorld. Its worlds could then reuse
a token held by a host world, and owner equality would no longer separate them.
Until a gameplay module must own a registry, keep registries in the host or
shipping executable. The supported fix is a host issuance entry in the
versioned GameApi service table that the module calls in place of its copy.
Adding that entry is an ABI change and is not part of stage 2.
Audio stays separate. Its policy does not match, and the design keeps its
ABI and sentinel until a deliberate migration. Public clip, stream, voice and
modifier handles store a uint32 session, so the uint64 lifetime source could
only feed it by truncation, which would reuse values. SessionSource also issues
0xFFFFFFFF as its last session. Within a session, SlotGeneration stores 0 for
a free slot and 0xFFFFFFFF as the stored "exhausted" state, so the last live
generation is 0xFFFFFFFE. FoundationIdentity keeps generations across the free
state and issues TERMINAL_GENERATION as a live value. The render-side control
owner compares these values in voice and modifier commands, and the stop and
terminal mailboxes use 0 to mean "no generation". Changing the encoding would
therefore change the audio callback protocol.
RHI stays separate. Device owners come from DeviceOwnerSequence, a
facade-thread counter rather than an atomic one. It issues through UINT64_MAX,
one value past LAST_LIFETIME_ID. CreateDevice checks for exhaustion before
startup and takes the token only after startup succeeds, so a failed or
unsupported startup consumes none. Legacy shader, uniform and pipeline IDs share
one uint32 process counter that stops instead of wrapping, and session release
clears every stored ID before backend teardown. Portable lifetime records use
uint64 generations in which 0 marks an exhausted slot. Moving any of these
would change public statuses or RHI-owned representation, and the device fence
and completion protocols depend on the current release order. Stage 2 reviewed
them in place and made no RHI changes.
Rejection tests. Each subsystem has a regression for a rebuilt successor
whose slots and generations coincide with the old ones, so only the owner field
separates the two. A world rebuilt in the same EntityRegistry object rejects
the old entity in its registry, its new component pool and the old world's
pool (ludus_gameplay_world_tests). A second AudioSystem rejects clip,
voice, stream and modifier handles of an ended session (ludus_audio_tests). A
second RHI device rejects every resource kind of a destroyed session: status,
update, pipeline composition, draw and destroy (ludus_rhi_lifecycle_tests).
In every case the successor's live objects in the reused slots are unaffected.
3. Introduce versioned UUID authoring records and an explicit migration manifest
mapping existing scoped strings to committed UUIDs. Generate that manifest
once, atomically, and preserve names as readable labels. Existing v1 inputs
continue to work through their current loader; do not mint unstable UUIDs on
every v1 load or silently rewrite those files.
Implemented for World documents; see
UUID authoring records.
4. Add durable binding, graph duplication, and two-pass restore in tools/content.
Integrate stable editor selection and stale operation checks through the
current staged-world and gameplay-reload boundaries. This does not by itself
add in-place world migration to the current whole-level restart behavior.
In progress. The durable binding table
(WorldBindingTable) and
two-pass restore (TryRestoreWorld) are
implemented; graph duplication and the editor selection/stale-operation checks
are the remaining slices. Restore lives in the in-tree world_restore bridge
library rather than content/identity_records, because driving entity creation
requires GameplayWorld, which the authoring-record parser must not depend on.
5. Add streaming records and immutable payload leases when a real resource needs
them. Benchmark the complete consumer before selecting a hash table, local
handle, paged sparse storage, or concurrent reclamation.
Each stage is independently reviewable. The reference review records the design before chapter reading, the adopted refinements, and historical mechanisms that would weaken Ludus's present guarantees.
Stage 3: UUID authoring records¶
Ludus::ContentIdentity (modules/content/identity_records) defines the records,
the migration manifest and their codecs. The ludus-cook identity commands are
its only production entry points. It is an in-tree tools library: it is not
installed with the SDK until an SDK consumer needs it.
Version-2 World records. Version 2 keeps the version-1 World envelope and component values, and adds durable identity. An abridged example, with most component properties omitted:
{
"version": 2,
"schema": "ludus.world-demo.v1",
"document": "0b7e8c1d-2f43-4a96-9d51-6c2e4f8a1b3d",
"entities": [
{
"uuid": "5d1f0e7a-93c2-4b8e-a4f6-1e2d3c4b5a69",
"label": "guard-a",
"name": "guard-a",
"components": {
"enemy": { "target": "8a2b4c6d-1e3f-4a5b-9c7d-2e4f6a8b0c1d", "speed": 2 }
}
}
]
}
documentis the World document'sAssetId.uuidis each entity'sSourceObjectId. It is the entity's identity, so renaminglabelornamenever changes it.labelis the version-1id, kept as a readable, unique, scoped label for inspection and diagnostics.- Each
entity-typed property stores the target'suuid. An empty string still means no target.
Authored records carry no InstanceId. A standalone level's instance scope belongs
to its placement or save, as described in
authoring and prefab instances. Stage 4 forms
PersistentObjectKey values from that scope and these SourceObjectId values.
Migration manifest. One committed JSON file maps every scoped ID of a set of
version-1 documents to its UUIDs. The format is
ludus.identity-migration version 1. Paths are relative to the content root
given to the tool. Documents are sorted by path and objects by legacy ID, so the
canonical file diffs cleanly:
{
"format": "ludus.identity-migration",
"version": 1,
"documents": [
{
"path": "levels/first-room.world.json",
"document": "0b7e8c1d-2f43-4a96-9d51-6c2e4f8a1b3d",
"objects": [
{ "legacy_id": "guard-a", "object": "5d1f0e7a-93c2-4b8e-a4f6-1e2d3c4b5a69" }
]
}
]
}
The reader rejects:
- unknown or duplicate members and an unsupported format or version;
- unsorted or repeated entries and invalid paths or legacy IDs;
- any UUID that is not a nonnil version-4 value;
- any UUID that appears twice anywhere in the manifest.
Legacy IDs follow the Editor's World ID rule (1–64 ASCII letters, digits, _,
-, . or :). The world-demo sample's IDs, including the Editor's truncated
entity- and settings- forms, are a subset.
Generate once. ludus-cook identity migrate ROOT MANIFEST WORLD... works in
this order:
- It refuses to run when the manifest already exists.
- It reads the entity IDs of the listed version-1 documents.
- It mints one document UUID and one object UUID per entity with
TryGenerateUuidV4andGetSystemEntropySource(), in path order and then legacy-ID order. - It creates the manifest atomically through
content::SaveFilewith no expected digest.
Each candidate is checked against every identity already in the manifest. A
colliding candidate is replaced at most four times before the command reports
identity-conflict. If entropy is unavailable, nothing is minted or written and
the command exits with status 3; there is no fallback generator. Publication never
replaces an existing manifest, even one that appears during the command. Run the
command once per content root and commit the result. Tests call
GenerateMigrationManifest with controlled EntropySource values.
Replay, never re-mint. ludus-cook identity apply ROOT MANIFEST SCHEMA WORLD OUTPUT
creates the version-2 record at OUTPUT from a version-1 document, the committed
manifest and the World schema. The schema identifies the entity-typed properties.
Replay is pure: the same inputs always produce identical bytes, and it mints
nothing. The command never replaces an existing file, so it cannot overwrite the
version-1 source. It refuses inputs that the manifest does not describe exactly:
stale-manifest: an entity has no committed UUID, or a manifest object has no entity.unresolved-reference: a reference names an ID that is not in the document.unsupported: the document uses the Editor'ssceneextension. Its parent links are not migrated yet.
Version-1 compatibility. Version-1 Worlds still load through their existing
loaders. Loading one never mints identities, and nothing rewrites it implicitly.
The world-demo sample commits
identity-migration.json
for its two rooms, and its levels stay version 1. ReadWorldLevel accepts both
versions:
- For version 2 it records the document, settings, actor and collision UUIDs.
- It resolves each UUID target back to its label. Runtime lookup, validation and the replay hash are therefore identical for both versions.
ludus_world_identity_tests replays the committed manifest against the committed
rooms. The Editor's World tests open the replayed first room, edit it and save it
as version 2. ludus_content_identity_tests covers:
- deterministic manifest replay and canonical output;
- create-once atomic publication;
- unavailable, partially failing and colliding entropy;
- schema rejection and stale manifests.
Not yet implemented:
- The committed sample levels are still version 1. The Editor opens and saves both versions without converting either; see the World workspace. Converting the sample levels also moves their replay test onto version-1 fixtures.
- A document gains entities after its manifest is committed: replay reports
stale-manifest. Extending a committed manifest needs an explicit operation that mints only the missing identities. - The
.lulevelscene workflow already stores UUIDs, and the cooked LUSCN format keeps its 64-bit scoped source IDs. Neither is part of this migration.
Research after the initial implementation¶
Implement and validate the initial architecture above before running the broader research and improvement pass. Retain the implementation, regression tests and representative measurements as the comparison baseline. Consumer-dependent streaming and payload leases remain conditional as described in stage 5.
The post-implementation reading queue tracks conference and journal resources, review status, improvement hypotheses and the evidence required for adoption. Required specification checks, including RFC 9562, remain part of implementing the initial design. Later trials should address an observed correctness, diagnostic, memory or performance problem and preserve this architecture's identity and lifetime contracts. Record any adopted change in this owner and its implementation, with source attribution and reproducible before/after evidence.