Ludus Core Containers — Static and Dynamic Array Design¶
For the broader family, lookup contracts and staged implementation, see Container systems. That design supersedes the historical future taxonomy in §26; §34 remains authoritative for the arrays.
Status: Implemented. This document began as the design/architecture deliverable; the containers were implemented, tested, benchmarked, and the first-party codebase migrated at that point. The 2026-10-06 source review found reintroduced
std::vectoruses in newer modules and tests. Milestone M1 of the container roadmap migrated them on 2026-10-15.tools/check_container_policy.pynow rejects new uses. See §34.3/§34.4 for the authoritative policy and permitted exceptions. Earlier sections that described a sequenced migration with deferred/"stay STL" container uses (§3, §4, §23, §24, §32.6) are historical and are superseded by §34.3/§34.4 where they conflict.§34 is the current authoritative naming and API. An architectural correction renamed the types to the Ludus-native taxonomy (
StaticArray<T, N>fixed-size,Array<T>dynamic — freeingVectorfor mathematics) and moved the member API off STL spellings onto the engine's verb-oriented convention (GetSize/IsEmpty/GetData/EnsureCapacity/Add/RemoveLast/…). Where §§1–33 use the old names (Array<T,N>/Vector<T>,Size()/PushBack()/Reserve()), read them through §34's mapping. §32 (measured results) and §33 (audit corrections) remain valid for behaviour and the growth-factor decision (2×).Audience: Ludus engine contributors and reviewers. Scope: two owning contiguous containers — a fixed-size static array and a dynamically sized array — plus the supporting traits, the future container taxonomy they must not corner, and a concrete migration plan.
This document deliberately separates four kinds of claim, and labels them inline where it matters:
- [Standard] — a requirement or guarantee of the C++23 language/library.
- [Library] — observed behaviour of an existing implementation (libstdc++, libc++, LLVM, Folly, EASTL, Unreal). Not normative; evidence of what is possible and what trade-offs others chose.
- [Measured] — something we will only assert once benchmarked on the pinned toolchain. In this design phase these are stated as hypotheses to be confirmed by the §22 benchmark suite.
- [Judgment] — an architectural decision made for Ludus with reasons, not derivable from the above alone.
1. Executive summary¶
Ludus should own two foundational contiguous containers:
ludus::foundation::Array<ElementType, Count>— a fixed-size, owning, stack/inline aggregate. Broad analogue ofstd::array.ludus::foundation::Vector<ElementType>— a dynamically sized, owning, heap-backed contiguous array. Broad analogue ofstd::vector.
Both are thin, unsurprising, cache-friendly types whose optimized-build codegen
should be indistinguishable from (or better than) the equivalent standard
container for the operations Ludus actually uses. They are not more clever
than std::vector; they are more aligned with Ludus: exception-free by
construction, explicit about allocation failure, integrated with the Ludus
assertion/build-flavor system for zero-cost release bounds behaviour, spelled in
Ludus fixed-width types and naming conventions, and positioned as the single
seam through which a future Ludus allocator is introduced.
The single most important reason to own them is not raw performance — a
tuned std::vector is already excellent. It is control of the allocation and
error-handling seam under a hard -fno-exceptions policy, combined with a
coherent, stable, engine-native container vocabulary that a custom allocator
(ADR 0003 makes it inevitable), a fixed-capacity vector, and a small/inline
vector can all attach to later without churning call sites again.
The current inventory is small (12 non-test std::vector uses, 4 std::array
uses, 7 std::span uses), and several of those uses are legitimately entangled
with facilities that are themselves slated for replacement (std::string,
std::filesystem, std::unique_ptr). This means the migration is cheap to
sequence and easy to keep green, and that some uses should stay STL-based
until their neighbours (String, VFS) are addressed. The design is therefore
paired with an explicit, subsystem-ordered migration plan and a short list of
justified STL-boundary exceptions.
std::span is kept. It is the non-owning view/interop vocabulary the owning
containers convert into; replacing it is out of scope and would weaken
ecosystem interoperability.
2. Current Ludus constraints¶
These are discovered facts about the repository as it exists today. They are the hard boundary conditions on the design.
| # | Constraint | Source | Design consequence |
|---|---|---|---|
| C1 | No C++ exceptions. Engine builds with -fno-exceptions (/EHs-c-), enforced by the compiler; bugprone-std-exception-baseclass is a secondary guard. |
AGENTS.md, cmake/EngineOptions.cmake, ADR 0003 |
Containers must be exception-free. No throw. Allocation failure and precondition violations cannot be reported by throwing. Failure must be in the signature or handled fatally. Mark hot/infra methods noexcept. |
| C2 | Fixed-width Ludus type spellings. uint8..uint64, int8..int64, usize (= std::size_t), isize (= std::ptrdiff_t), float32/float64 from ludus/foundation/base/types.h. std::size_t/std::uint32_t spellings are banned in engine code. |
AGENTS.md, types.h, ADR 0003 |
Public API uses usize for sizes/indices, isize for differences. Internal size/capacity representation is a decision (§10–§11), but the spelling is Ludus aliases. |
| C3 | Naming and style. PascalCase types and methods; m-prefixed members; template parameters spelled out (ElementType); PascalCase concepts; [[nodiscard]], [[no_unique_address]]; #pragma once. |
pointer.hpp (UniquePtr), window.h, .clang-tidy |
Vector::PushBack, Vector::Data, Vector::Size, mData/mSize/mCapacity. Not push_back. STL-shaped lowercase aliases are provided only where needed for range-for/ranges interop (§20). |
| C4 | Namespace + re-export idiom. Foundation primitives live in ludus::foundation::core and are re-exported into ludus::foundation. |
pointer.hpp, types.h |
core::Array/core::Vector with using ludus::foundation::Array = core::Array<...> aliases. |
| C5 | Module layout & dependency direction. modules/<layer>/<module>/, public headers under include/ludus/<layer>/<module>/, private under src/internal/. FoundationBase depends on nothing higher. Static libs by default. |
AGENTS.md, ADR 0002 |
The containers belong in a foundation module at or below the level of everything that will use them. See §9 for placement. |
| C6 | Build flavors drive assertion policy, not NDEBUG. LUDUS_ENABLE_ASSERTS is 1 for Debug/Development, 0 for Profile/Release. LUDUS_ASSERT compiles out entirely when disabled; LUDUS_REQUIRE/LUDUS_FATAL are always live; LUDUS_CHECK returns a bool. Assertions use an independent emergency path, never Logging. |
assert.hpp, EngineBuildFlavor.cmake, assert_config.hpp.in, ADR 0003/0006 |
Bounds/invariant checks use LUDUS_ASSERT → zero release cost, rich debug/dev diagnostics for free. Unconditionally-fatal conditions (e.g. OOM in the infallible API) use LUDUS_FATAL/LUDUS_REQUIRE. |
| C7 | Warnings-as-errors. -Wall -Wextra -Wpedantic -Wshadow -Wformat=2 -Wundef -Wnull-dereference -Wdouble-promotion -Wimplicit-fallthrough -Woverloaded-virtual -Wnon-virtual-dtor, CI -Werror. |
EngineCompilerWarnings.cmake |
Templates must be warning-clean across all instantiations, including empty types and over-aligned types. |
| C8 | Build-time budgets; no heavy STL in public headers. Default 2000 ms average per-include parse budget; heavy templates type-erased behind .cpp (ADR 0004/0005). |
build_budget.json, ADR 0004/0005 |
Container headers include only light headers (<type_traits>, <utility>, <new>, and Ludus base headers). Cold/error paths (growth math, OOM handling) are candidates for out-of-line non-template helpers. <memory>, <vector>, <algorithm>, <initializer_list>-heavy pulls avoided. |
| C9 | No engine allocator exists yet. ADR 0003 states std::vector→"Ludus Array/Span with allocator support" once a custom allocator exists. UniquePtr already uses global new/delete. |
ADR 0003, pointer.hpp |
v1 must not invent an allocator framework, but must be designed so the allocator seam can be added without an API break (§14). |
| C10 | Cross-platform intent. Milestone 0 is Linux/Clang-only (FoundationBase CMakeLists hard-errors otherwise), but the engine targets Windows, Linux, macOS/Apple Silicon, Vulkan, Metal. |
README.md, prompt |
No Linux/Clang-only constructs in the container. Alignment, memcpy/memmove, operator new behaviour must be portable. Compiler intrinsics only behind the existing compiler.hpp/defines.h macros. |
| C11 | std::span is allowed and used as the contiguous view. |
ADR 0003, logging | Keep std::span. Owning containers convert to it; do not build a competing owning-view type. |
3. Current container inventory¶
Counts are over modules/ and apps/ (engine code) plus tests, gathered by
grep on the current tree. "Engine" excludes */tests/.
std::vector: 19 occurrences total — 12 engine (non-test), 7 test.std::array: 4 occurrences — all engine.cpp, all fixed-size stack buffers.std::span: 7 occurrences — all in Logging, all non-owning views.std::initializer_list: 0.- Raw built-in arrays used as containers: only fixed-capacity buffers (see
TraceChunk), not growable containers. - APIs accepting/returning STL containers across a module boundary: the Logging
backend (
std::vector<std::unique_ptr<ILogSink>>&&) and thestd::spanformat APIs.
3.1 std::vector — engine (non-test)¶
| Site | Declaration(s) | Hotness | Entanglement |
|---|---|---|---|
foundation/profiling/src/export_perfetto.cpp |
vector<FlatEvent>, vector<TraceChunk*>, vector<uint64>, vector<std::string> |
Cold (offline trace export) | std::string, std::stable_sort; file explicitly documents deliberate STL use here |
foundation/logging/src/sinks/file_sink.cpp |
vector<std::filesystem::path>, vector<Group>, vector<Group*> |
Cold (log retention/pruning) | std::filesystem (slated for a VFS layer) |
foundation/logging/src/internal/backend.hpp + backend.cpp |
vector<std::unique_ptr<ILogSink>> mSinks; Start(vector<std::unique_ptr<ILogSink>>&&) |
Warm-ish (set up once; drained by worker) | std::unique_ptr (Ludus UniquePtr exists); header already carries a build-budget override partly due to this member |
foundation/logging/src/logger.cpp |
vector<std::unique_ptr<internal::ILogSink>> Sinks |
Warm-ish (setup) | std::unique_ptr |
3.2 std::array — engine¶
| Site | Declaration | Notes |
|---|---|---|
foundation/base/src/diagnostic.cpp |
array<char,512> buffer, array<char,16> lineDigits |
Emergency assertion path. Must remain dependency-free and allocation-free; extreme care. |
foundation/logging/src/logger.cpp |
array<char, kMaxMessageBytes> buffer |
Producer-owned stack format buffer. |
3.3 std::span — engine (all Logging, all views — keep)¶
log_format.hpp SubmitFormat(..., std::span<const FormatArg>);
format_engine.{hpp,cpp} FormatInto(std::span<char>, ..., std::span<const FormatArg>)
and internal callers.
3.4 Tests (low priority)¶
std::vector<std::thread> thread pools in logging/profiling regression and
scope/clock tests; std::vector<TraceEvent>/<TraceChunk*> drain buffers in
profiling tests. These may keep STL indefinitely; tests already re-enable
exceptions and are not bound by the engine STL policy.
3.5 Notable non-container evidence¶
foundation/profiling/src/internal/trace_chunk.hpp uses a fixed-capacity
built-in array TraceEvent Events[kEventsPerChunk] on a alignas(64) struct,
with a drop-on-full, never-grow, never-allocate policy. This is not a
std::vector/std::array use, but it is direct evidence that Ludus already
wants a fixed-capacity vector idiom (bounded count with a live size, no
growth). It is a signal for the future taxonomy (§26), not an argument for
adding small-buffer optimization to the fundamental Vector.
4. STL usage classification (A–E)¶
Using the prompt's taxonomy. Column "v1 need" states whether the initial container design must support this to migrate the site.
| Site | Class | Rationale | v1 need |
|---|---|---|---|
backend/logger vector<unique_ptr<ILogSink>> |
B (dynamic replace) + E (element should be UniquePtr) |
Owning growable list of sinks; small N; set up once. Pairs naturally with Ludus UniquePtr. Migrating may reduce backend.hpp include weight (removes a std::vector<unique_ptr> instantiation that pulls <format> transitively on libstdc++). |
Vector<T> with move-only element support; PushBack/EmplaceBack, move-assign, range-for. |
export_perfetto vector<FlatEvent>, vector<TraceChunk*>, vector<uint64> |
B | Trivially-copyable / pointer elements; cold path; classic dynamic array. | Vector<T>; PushBack, Reserve, iteration, std::stable_sort over begin/end (needs contiguous iterators). |
export_perfetto vector<std::string> |
D (external/entangled) | Element type is std::string, itself slated for a Ludus String. Migrating the outer container without the inner type is low value and risks churn. |
Defer until String exists; then Vector<String>. |
file_sink vector<std::filesystem::path> / vector<Group> / vector<Group*> |
D + E | Entangled with std::filesystem (slated for a VFS). Cold retention logic. |
Defer to the VFS milestone; Group*/Group parts could migrate earlier but low value while path remains. |
diagnostic.cpp array<char,512>, array<char,16> |
A, but sensitive | Emergency assertion path; must not gain any dependency, allocation, or header weight. | A FixedArray/Array<char,N> would fit only if it is provably as light and dependency-free as the raw buffer. Otherwise keep as-is. Not a v1 migration target. |
logger.cpp array<char, kMaxMessageBytes> |
A | Stack format buffer. | Array<char, N> migrates cleanly if header-light. Low urgency. |
Logging std::span<...> APIs |
not applicable — keep | Non-owning views; the interop vocabulary. | Vector/Array must convert to std::span. |
Test std::vector<std::thread> etc. |
tests | Not engine code; not bound by STL policy. | Optional, opportunistic. |
Reading of the inventory. Only two clusters are clean, high-value Vector
migrations: the Logging sink lists (Category B, pairs with UniquePtr) and
the profiling exporter's POD/pointer vectors (Category B). Everything else is
either entangled with a not-yet-replaced facility (String, filesystem) or is a
fixed stack buffer better served by Array or left alone. This is a small,
sequenced migration, not a sweeping one.
5. Research / reference review¶
Principles extracted (not code copied). Sources in §30. Claims labelled by kind.
5.1 Standard library baseline¶
- [Standard]
std::array<T,N>is an aggregate with no indirection;N==0is allowed anddata()may return an unspecified non-dereferenceable pointer. It isconstexpr-friendly and trivially copyable iffTis. - [Standard]
std::vector<T>guarantees contiguous storage, amortized O(1)push_back, and precise iterator/reference invalidation rules (any reallocation invalidates all;push_backinvalidates all iff it reallocates;eraseinvalidates from the erase point onward).data()/size()expose the pointer+count needed by C and GPU APIs. - [Standard] Object lifetime in raw storage begins with placement
new/std::construct_atand ends with an explicit destructor call /std::destroy_at. For trivially copyable types, copying the object representation withmemcpy/memmoveis well-defined today. For general types, moving bytes is UB unless the type is (a language notion not yet available in Clang 18) trivially relocatable. - [Standard]
std::span<T>(C++20) is the non-owning contiguous view; pointers arecontiguous_iteratorand modelstd::ranges::contiguous_range, so exposingdata()+size()and pointer iterators yields full ranges / algorithm interoperability at zero abstraction cost. - [Standard] Under
-fno-exceptions, the default throwingoperator newstill exists; on allocation failure it will attempt to throw, which with exceptions disabled terminates the process. Thestd::nothrowoverload returnsnullptrinstead. (See §5.4 / §14.)
5.2 Production implementations¶
- LLVM
SmallVector— [Library] stores 32-bit size and capacity ("32 bit size would limit the vector to ~4GB") to shrink the object, and splits storage so the inline-capacity detail is type-erased out of the size-agnostic base. Growth doubles. Lesson: the width of size/capacity is a deliberate footprint/perf trade, and the "impl base + typed derived" split is how they get a stable non-templated core. Exception-unsafe by design. - Folly
fbvector— [Library] uses an opt-inIsRelocatable<T>trait tomemcpyon reallocation, and (Folly PR #2216) upgrades tostd::is_trivially_relocatablewhen the P1144 feature-test macro is present. Documents the growth-factor trade: any factor > 1 is amortized O(1), too small (≈1.1) reallocates too often, too large (3–4) wastes memory. Lesson: relocation is opt-in and trait-driven today; the growth factor is a real tunable, not folklore. - EASTL — [Library] engine-oriented
vector: no exceptions, explicit allocators as a template policy,memcpyfast paths for POD/relocatable types, andpush_back/resizevariants that avoid value-initialization. Lesson: an engine container is allowed to make different default choices from the STL (allocator visible, exceptions absent) but should stay STL-shaped enough to interoperate. - Unreal
TArray— [Library] 32-bit (int32) size/capacity, allocator policy,memcpy-relocatable element assumption for POD, no exceptions. Lesson: an entire AAA engine runs on a 32-bit-indexed array; but its API diverges from STL and interop suffers. Ludus should not copy the divergent API surface. - Boost.Container / libstdc++ / libc++ — [Library] allocator-aware
vectors; libstdc++ and libc++ grow 2×, MSVC/Dinkumware 1.5×. libc++
vector<bool>and small-string-optimization show the cost of cleverness in debuggability. Lesson: default STL vectors are already very good; a custom type earns its keep through policy control, not micro-optimization. - LLVM is actively [Library] rewriting vector operations "in terms of relocation" for ~50% speedups on reallocation-heavy paths — evidence the relocation fast path is worth designing for, even if v1 only enables it for trivially-copyable types.
5.3 Growth-factor evidence¶
[Library/Judgment] The classic argument: with factor 2, the sum of all previously freed blocks is always strictly less than the next request, so a general allocator can never coalesce freed blocks to satisfy the next growth; with a factor below the golden ratio φ≈1.618, earlier freed blocks can be reused. This favours ≈1.5 for memory locality and allocator reuse; 2× favours fewer reallocations. Both are amortized O(1). This is a [Measured] decision for Ludus (§15): the design proposes 1.5× as the default hypothesis and gates the final value on the §22 benchmark.
5.4 Exception-free allocation¶
[Standard/Judgment] Because the throwing operator new terminates under
-fno-exceptions (§5.1), Ludus containers must allocate through a nothrow
path and check for nullptr, then either (a) report failure in the signature
(the fallible API) or (b) treat OOM as unrecoverable via LUDUS_FATAL (the
infallible default API). This is the crux of the exception-free design (§14,
§18).
6. Standard-library baseline (what we are measured against)¶
The bar is not "beat std::vector." The bar is: for the operations Ludus uses,
generate equivalent optimized code, allocate no more often, use no more
memory, and be at least as debuggable — while adding the Ludus policy
integration the STL cannot provide (exception-free failure, assertion-integrated
bounds, allocator seam, Ludus spellings). A design that is materially worse
than std::vector on any representative workload (§22) is rejected.
std::array is a near-zero-cost aggregate; Array<T,N> must match it exactly in
codegen and size, differing only in name, method casing, bounds-check policy,
and std::span conversion ergonomics.
7. Why custom containers (challenging the premise — Phase 4)¶
Direct answers to the nine questions. Honesty over agreement.
-
Why custom instead of
std::array/std::vectordirectly? The dominant reason is policy control at one seam, not speed. Ludus has a hard-fno-exceptionsrule and will get a custom allocator (ADR 0003 says so explicitly).std::vector's allocation, growth, and failure behaviour are controlled by the standard library and its allocator model — an allocator model Ludus does not want to adopt wholesale (it is verbose, and-fno-exceptionsmakesstd::vector'slength_error/bad_allocpaths terminate rather than report). Owning the container lets Ludus make allocation failure explicit, make bounds checks ride the existing build-flavor assertion system at zero release cost, and introduce the engine allocator later behind a stable API. Secondary reasons: a coherent Ludus vocabulary (Array/Vector/futureFixedVector/SmallVector/String) and consistent spelling/naming. -
Concrete benefits. (a) Exception-free, signature-level allocation-failure handling. (b) Zero-cost release bounds/invariant checking via
LUDUS_ASSERT, rich in Debug/Development. (c) A single insertion point for the future engine allocator without touching call sites. (d)usize/PascalCase consistency. (e) Opt-inmemcpyrelocation fast path controlled by Ludus, upgradable tostd::is_trivially_relocatablelater. (f) Reduced public-header include weight vsstd::vector<unique_ptr<...>>on libstdc++ (relevant to the existingbackend.hppbudget override). -
Which benefits are measurable? (b) release codegen size and bounds-check cost, (e) reallocation throughput for relocatable types, and (f) header parse time are all directly [Measured] via §22 and the existing build-budget tooling. (a) and (c) are correctness/architecture properties, verified by tests and by the absence of terminate-on-OOM, not by a benchmark.
-
Which advantages are folklore / unlikely to matter? "Custom containers are faster than
std::vector" is largely folklore for optimized builds — a goodstd::vectoron trivially-copyable types is already near-optimal, and we should not promise to beat it. "Always use 2× (or always 1.5×) growth" is folklore without measurement. "SBO everywhere is a win" is folklore — SBO changes move semantics, pointer stability, andsizeof, and is a different type's job. "32-bit indices are always faster" is folklore; the win is footprint, and it costs a 4G-element ceiling andusizedivergence. -
Which engine requirements justify ownership? The hard justifications:
-fno-exceptions(STL vector's error paths terminate), and the committed future allocator (ADR 0003). The soft justifications (naming, taxonomy, header weight) would not alone justify the maintenance burden, but they make ownership clearly net-positive given the hard ones. -
Could thin wrappers do the same with less maintenance? A wrapper around
std::vectorcould fix naming and bounds policy, but it cannot fix the two hard problems: it still terminates on OOM (the underlying allocation still goes throughstd::allocator/throwingnew), and it still forces thestd::allocatormodel when the engine allocator arrives — the wrapper would have to be rewritten into a real container at that point anyway. A wrapper also inheritsstd::vector's full API and ABI, defeating the "coherent small API" goal. Judgment: a wrapper defers, but does not avoid, the real work, and adds a throwaway layer. Owning a small real container is the lower lifetime cost. -
Long-term maintenance/correctness burden. Owning
Vectormeans owning exception-free reallocation, lifetime correctness for non-trivial types, overflow-safe growth, alignment for over-aligned types, and sanitizer/debug ergonomics — permanently. This is real and non-trivial. It is mitigated by: keeping the API small, leaning onstd::construct_at/std::destroy_atfor lifetime, restrictingmemcpyto types where it is provably legal, and a heavy test/benchmark suite (§21–§22).Arrayis nearly free to maintain. -
Is replacing existing STL usage worth the migration cost? Partly. The whole-repo replacement is not urgent — the inventory is tiny and several uses are entangled. The right posture is: build the containers now (because they unblock the allocator and set the vocabulary), migrate the two clean clusters (sink lists, exporter POD vectors) as proof and dogfooding, and defer entangled uses until their neighbours (String, VFS) are ready.
-
Uses that should intentionally stay STL-based? Yes: the emergency assertion buffers (§4, unless a
FixedArrayis provably as light), the filesystem-entangledfile_sinkvectors (until VFS), thestd::string-typed exporter vector (untilString), thestd::spanview APIs (kept by design), and test-onlystd::vector<std::thread>. See §24.
Conclusion of Phase 4: custom containers are justified for Ludus — primarily by the exception policy and the committed allocator, secondarily by vocabulary and header hygiene — provided the design stays small, standards-clean, and interoperable, and the migration is sequenced rather than sweeping.
8. Alternatives considered¶
Decision: Own two purpose-built containers (
Array<T,N>,Vector<T>) in a foundation module, standards-clean, exception-free, allocator-seam-ready.Alternatives considered:
- A0 — Keep using
std::array/std::vector. Rejected. Under-fno-exceptions,std::vector's failure paths terminate rather than report, and the standard allocator model does not match the coming engine allocator (ADR 0003). Zero migration cost now, but leaves the two hard problems unsolved and forces a bigger change later.- A1 — Thin
std::vectorwrapper (rename + bounds policy only). Rejected as the destination; useful only as a stopgap. Cannot change allocation-failure behaviour or the allocator model without becoming a real container (see §7.6). Inherits full STL API/ABI, contradicting the "small coherent API" goal.- A2 — Adopt EASTL wholesale. Rejected. Large dependency, its own build and ABI surface, divergent API, and it still needs adaptation to the Ludus assertion/type/allocator systems. Extract its principles instead.
- A3 — One über-vector with a growth policy + inline-capacity + allocator all as template parameters (EASTL/Boost style). Rejected for v1. Contaminates the common case with policy machinery, bloats the type, hurts debuggability, and corners the taxonomy. The future family (§26) achieves the same coverage with separate, honest types.
- A4 — Add small-buffer optimization to
Vector(Folly/SmallVectorstyle). Rejected for the fundamental dynamic array (explicit prompt constraint, and sound): SBO changes pointer stability, move cost,sizeof, and moved-from semantics, and makes the type harder to reason about. It is a different type (SmallVector, §26).- A5 — 32-bit size/capacity like
SmallVector/TArray. Rejected as the default for the generalVector; it diverges from theusizepolicy (C2) and fromstd::vector/std::spaninterop, for a footprint win that matters most in the small/fixed types. Revisited as an option forFixedVector/SmallVectorwhere element counts are bounded (§26).Trade-offs: Owning two small types costs permanent maintenance (§7.7) and a test/benchmark burden (§21–§22), in exchange for solving the two hard problems at one seam and establishing a coherent, allocator-ready vocabulary.
Evidence:
-fno-exceptions+ throwingnewterminates [Standard]; ADR 0003 commits to the allocator and toArray/Span[repo]; SBO and 32-bit costs are [Library] (Folly, LLVM, Unreal); "std::vector is already good" is [Library/Judgment].
9. Proposed architecture¶
9.1 Placement and module¶
Create a new foundation module FoundationContainers (directory
modules/foundation/containers/), sibling to base, logging, profiling,
exporting Ludus::FoundationContainers.
Decision: New module, header-only for the templates, depending only on
Ludus::FoundationBase.Rationale: The containers depend on
FoundationBasefor types (types.h), assertions (assert.hpp), and compiler macros (compiler.hpp/defines.h). They must sit above Base (they use its assertion API) and below Logging/Profiling/Platform (which will consume them). A dedicated module keeps the dependency direction (C5) clean and lets the future allocator either live here or in a siblingFoundationMemorymodule that Containers depends on.Alternatives considered: (a) Put containers in
FoundationBase. Rejected: Base must depend on nothing higher and hosts the sensitive emergency assertion path; keeping the container templates out of Base protects Base's build weight and dependency purity. (b) A single mixed "core" module. Rejected: the repo's convention is one module per concern.Trade-offs: One more module to wire; but it isolates build cost and makes the eventual allocator dependency explicit.
Evidence: module conventions and dependency rule are [repo] (C5,
AGENTS.md, ADR 0002).
Header layout (public):
modules/foundation/containers/
include/ludus/foundation/containers/
array.hpp # Array<T,N>
vector.hpp # Vector<T>
relocation.hpp # IsTriviallyRelocatable trait + LUDUS_TRIVIALLY_RELOCATABLE
containers.hpp # umbrella convenience include (optional)
src/
vector_support.cpp # out-of-line cold helpers (growth math, OOM handler)
internal/
contiguous_storage.hpp # shared raw-storage/lifetime helpers (private)
tests/
array_tests.cpp
vector_tests.cpp
relocation_tests.cpp
lifetime_type.hpp # instrumented element type (see §21)
benchmarks/
vector_bench.cpp # (see §22)
FoundationBase stays Linux/Clang-gated for now (C10); the container module
must compile with the same portable subset so it is ready when other backends
are enabled. No new module should replicate Base's platform hard-error unless it
has a platform-specific reason (it does not).
9.2 Shared internals¶
A private contiguous_storage.hpp provides the lifetime/relocation primitives
used by Vector (and reusable by future FixedVector/SmallVector):
ConstructAt(ptr, args...)→std::construct_at[Standard].DestroyRange(first, last)→std::destroy_atper element; a no-op (compiled away) whenstd::is_trivially_destructible_v<T>.UninitializedRelocate(dst, src, count)— the central relocation primitive:- if
IsTriviallyRelocatable<T>(which includes trivially copyable): a singlememcpyofcount * sizeof(T)bytes, then no destructor calls (relocation consumes the source); legal today for trivially-copyable, and for opt-in relocatable types by Ludus contract (§16). - else: per-element move-construct into
dstthendestroythe source (strong-enough behaviour under-fno-exceptions, where element moves are requirednoexcept— see §16/§18). UninitializedCopy,UninitializedMove,UninitializedValueConstruct,UninitializedDefaultConstruct— the standard building blocks, each with a trivial fast path.
These helpers are the only place memcpy/memmove and lifetime calls live, so
the correctness argument is centralized and testable.
10. Static-array design — Array<ElementType, Count>¶
10.1 Purpose¶
A fixed-size, owning, contiguous aggregate whose element count is a compile-time
constant. It represents: a value-semantic bundle of exactly Count
elements with no indirection and no allocation. It deliberately does not
represent: anything growable, anything with runtime size, or a non-owning view
(that is std::span).
10.2 Ownership¶
Owns its Count elements inline, by value. Copy/move/destroy are elementwise and
= default (aggregate-like), so Array<T,N> is trivially copyable / trivially
destructible iff T is — matching std::array [Standard].
10.3 Storage model¶
ElementType mData[Count]; // for Count > 0
For Count == 0, no storage is materialized and Data() returns a valid but
non-dereferenceable pointer (see §10.5). The type is an aggregate: no
user-declared constructors, enabling aggregate init Array<int,3>{1,2,3} and
constexpr use.
Decision: Keep
Arrayan aggregate wrapping a C array; do not add a size field (it is compile-timeCount).Rationale: Zero overhead,
constexpr, structured-binding friendly, exactstd::arrayparity.sizeof(Array<T,N>) == sizeof(T[N])and alignment followsT.Alternatives considered: A non-aggregate class with explicit constructors — rejected: loses aggregate init and adds nothing.
Trade-offs: Aggregate-ness means we cannot run a custom bounds-checked constructor, but element access checks live in
operator[]where they belong.Evidence:
std::arraysemantics [Standard].
10.4 Size / index type¶
Public size/index type is usize (C2). Size()/Count are constexpr
returning usize.
10.5 Public API (smallest coherent, high-value)¶
constexpr/noexcept throughout where possible.
Data()/Data() const→ElementType*/const ElementType*. ForCount==0, returns a non-null but non-dereferenceable pointer (parity withstd::array). [Standard-aligned]Size()/constexpr usize Size()(alwaysCount);Empty()(Count==0);static constexpr usize Capacity()==Count.operator[](usize)/ const —LUDUS_ASSERT(index < Count)in Debug/Development, zero cost in Profile/Release (C6).Front(),Back()— asserted non-empty in checked builds.begin()/end()/cbegin()/cend()— pointer iterators (lowercase for range-for / ranges, see §20).Fill(const ElementType&),Swap(Array&).- Conversion to
std::span<ElementType>/std::span<const ElementType>(implicit for const view, see §20). - Comparison:
operator==/operator<=>defaulted (soArrayis a usable value/key type) — [Standard] defaulted comparisons.
Not included: at() (its whole purpose is throwing; replaced by the asserted
operator[] plus an optional TryGet returning a pointer/std::optional if a
call site needs graceful bounds handling — added only if the inventory demands
it; it does not today).
10.6 Semantics¶
- Complexity: all O(1) except
Fill(O(N)) and comparisons (O(N)). - Invalidation: references/iterators are stable for the lifetime of the
Arrayobject (no reallocation exists). Moving anArraymoves elements in place; addresses of a moved-into object differ from the source. - No allocation, ever.
noexceptthroughout.
11. Dynamic-array design — Vector<ElementType>¶
11.1 Purpose¶
A dynamically sized, owning, heap-backed contiguous array with amortized O(1)
append. It represents: an owning, growable, contiguous sequence — the
default choice when the element count is not known at compile time. It
deliberately does not represent: a fixed-capacity buffer (future
FixedVector), a small-buffer-optimized vector (future SmallVector), a
non-owning view (std::span), or a node-based/stable-address container.
11.2 Ownership¶
Owns a single heap allocation and the live elements within it. Destructor
destroys [0, mSize) and frees the block. Move transfers the block and leaves
the source empty and valid (§11.7). Copy performs a fresh allocation +
elementwise copy.
11.3 Storage model¶
Decision: Three-pointer-equivalent representation:
ElementType* mData; usize mSize; usize mCapacity;Rationale: This is the clearest, most debuggable representation (a debugger shows data/size/capacity directly), matches
std::vector's mental model, gives O(1)Size/Capacity/Data, and keeps the empty state trivially representable ({nullptr, 0, 0}).usizematches the Ludus type policy (C2) andstd::vector/std::spaninterop, and removes any 4G-element ceiling.Alternatives considered: - Pointer + two
uint32(LLVMSmallVector/UETArraystyle) [Library]. Smaller object (16 bytes vs 24 on 64-bit) and cheaper size math, but caps at 4G elements, diverges fromusize/std::span, and complicates conversion/interop. Rejected for the generalVector; reconsider forFixedVector/SmallVectorwhere counts are bounded (§26). - Three pointers (begin/end/capacity_end, libc++/libstdc++ internal style). Equivalent; makesend()a load rather than an add. Neutral; the size/capacity form is more debugger-legible and matches how the rest of Ludus reasons about sizes. Judgment: prefer explicitmSize/mCapacity.Trade-offs: 24-byte object on 64-bit (vs a 16-byte 32-bit design). Accepted: footprint of the container object is rarely the bottleneck for a general dynamic array, and the debuggability/interop/consistency wins dominate.
Evidence:
usizepolicy [repo] (C2); 32-bit width is a footprint trade [Library] (SmallVector RFC,TArray).
11.4 Size / capacity / index types¶
usize throughout the public API and the representation. Internal growth math
uses usize with explicit overflow checks (§15). isize for iterator
differences.
11.5 Public API (smallest coherent, high-value — not the full std::vector)¶
Grouped; [[nodiscard]] on observers; noexcept where the operation cannot
fail. Two failure modes are offered where allocation is involved: an
infallible default (OOM → LUDUS_FATAL, matching how the rest of the engine
treats unrecoverable infrastructure failure) and a fallible Try* variant
returning bool for the rare caller that must degrade gracefully. (§18 justifies
this split.)
Construction / lifetime
- Vector() noexcept — empty, no allocation.
- explicit Vector(usize count) — count value-initialized elements.
- Vector(usize count, const ElementType&) — count copies.
- Vector(const Vector&) / Vector& operator=(const Vector&) — deep copy.
- Vector(Vector&&) noexcept / Vector& operator=(Vector&&) noexcept — steal.
- ~Vector() — destroy live elements, free.
- Array(std::initializer_list<ElementType>) — copy elements into owned storage;
requires a copy-constructible element type (see §12).
- Range/span construction: static Array FromRange(std::span<const ElementType>).
Capacity
- Size(), Capacity(), Empty(), Data()/const.
- Reserve(usize) / bool TryReserve(usize) — ensure capacity ≥ n; reallocates
at most once; no-op if already sufficient.
- Resize(usize) / Resize(usize, const ElementType&) and TryResize — grow
(value- or copy-init new elements) or shrink (destroy tail).
- ShrinkToFit() — reallocate down to mSize (may be a no-op; never grows).
- Clear() noexcept — destroy elements, keep capacity, mSize = 0.
Element access
- operator[](usize) / const — LUDUS_ASSERT(index < mSize) (C6).
- Front(), Back() — asserted non-empty.
Modifiers
- PushBack(const ElementType&) / PushBack(ElementType&&) and
bool TryPushBack(...).
- template<class... Args> ElementType& EmplaceBack(Args&&...) and
TryEmplaceBack.
- PopBack() noexcept — asserted non-empty; destroy last.
- Append(std::span<const ElementType>) / range append, and TryAppend — bulk
insert at end (single capacity check + relocation).
- Insert(usize index, ...) / Erase(usize index) / EraseRange(first,last) —
order-preserving; plus an explicit EraseUnordered(index) (swap-with-last
+ pop) as the engine-preferred O(1) removal when order is irrelevant (this is a
frequent game-engine idiom; naming it prevents the accidental O(N) erase).
- Swap(Vector&) noexcept.
Uninitialized / advanced (opt-in, clearly named)
- AppendUninitialized(usize count) → returns a std::span<ElementType> over
the newly reserved-but-value-initialized region for trivially-constructible
types only (constrained by a concept), to serve GPU staging / bulk-fill
workloads that fill immediately. Named to make the "you must fill this" contract
explicit; guarded so it cannot be used to create objects whose lifetime is not
begun. (§16 explains why this is safe only for the constrained set.)
Interop
- Conversion to std::span<ElementType> / std::span<const ElementType>;
begin()/end() pointer iterators (§20).
Deliberately excluded from v1: allocator-parameterized constructors (added
with the allocator, §14), assign, insert overload zoo, emplace at arbitrary
position beyond Insert, resize-with-default-init subtleties beyond the two
forms above, reverse iterators (add if a call site needs them), data()/at()
STL spellings beyond the interop aliases. The rule (§12): add an API member
only when a real Ludus call site needs it or it is broadly, obviously useful —
not to mechanically mirror std::vector.
11.6 Semantics — complexity¶
PushBack/EmplaceBack: amortized O(1), worst-case O(n) on reallocation.PopBack,Back,operator[],Data,Size,Capacity,EraseUnordered: O(1).Insert/Erase/EraseRange(ordered): O(n) shift.Reserve/Resize/ShrinkToFit/copy: O(n).Append(span): O(k) for k appended, amortized.
11.7 Semantics — moved-from and empty-state guarantees¶
- A moved-from
Vectoris left empty and valid:{nullptr, 0, 0}. It may be reused (assigned to, pushed into) or destroyed. This is stronger than the standard's "valid but unspecified" and is a [Judgment] we adopt because it is cheap, easy to reason about, and easy to debug. - A default-constructed and a cleared
Vectornever own an allocation (Capacity()may be > 0 afterClear, 0 after default construction / move).
12. API specification — migration-compatibility mapping¶
The API in §10–§11 is justified against the actual inventory (§3–§4). For each STL dependency the inventory relies on, we state the decision per the prompt's four options: (1) support an equivalent because broadly useful; (2) rewrite the call site into a better Ludus-native form; (3) use an adapter/span; (4) keep the boundary STL-based.
| STL feature relied on | Where | Decision | Ludus form |
|---|---|---|---|
push_back(unique_ptr) / move-only element |
logging sink lists | (1) support | PushBack(UniquePtr&&) / EmplaceBack(...); Vector supports move-only elements |
vector(&&) move, operator=(&&) |
backend.Start(vector&&) |
(1) support | move ctor/assign; Start(Vector<UniquePtr<ILogSink>>&&) |
| range-for over sinks | backend worker | (1) support | pointer iterators + range-for |
push_back, reserve over POD/pointer |
exporter | (1) support | PushBack, Reserve |
begin()/end() for std::stable_sort |
exporter sort | (3) adapter via std | pointer iterators are contiguous_iterator; std::stable_sort(v.begin(), v.end(), cmp) works unchanged (§20) |
.empty(), .front(), .size(), .push_back/.pop_back on the open-scope stacks |
exporter | (1) support / (2) rewrite | Empty(), Back(), PopBack(); the parallel openTicks/openNames stacks are a candidate to rewrite into a single Vector<OpenScope> (better Ludus-native form) |
vector<std::string> |
exporter | (4) keep until String |
keep STL element until Ludus String exists |
vector<std::filesystem::path> etc. |
file_sink | (4) keep until VFS | keep STL until VFS milestone |
std::array<char,N> stack buffer |
logger, diagnostic | (1)/(4) | Array<char,N> where header-light; keep in the emergency assertion path unless proven equally light |
std::span<...> params |
logging format APIs | keep | not a container; Vector/Array convert to std::span |
initializer_list: supported by Array for explicitly requested brace
initialization, including lists of designated aggregate elements. The constructor
copies the list's const elements into one exact-size allocation; an empty list
does not allocate. It uses the existing fatal-on-OOM/overflow constructor policy.
The array owns its copies and does not retain the list's backing storage.
Use Array<int32>{3} for one element with value 3, Array<int32>(3) for three
value-initialized elements, and Array<int32>(3, 7) for three copies of 7. List
construction requires copyable elements; use Add/AddInPlace for move-only
elements. FromRange(std::span<const T>) remains available for contiguous views.
This mapping is the guard against API bloat: every member in §11.5 traces to a real need or a broadly-useful core operation, and the entangled/STL-shaped features are explicitly deferred or kept rather than mechanically reproduced.
13. Memory / lifetime model¶
[Standard]-grounded rules the implementation must obey:
- Storage for
Vectoris raw, untyped memory obtained from the allocation seam (§14). Object lifetime begins withstd::construct_at(placementnew) and ends withstd::destroy_at(explicit destructor call).Vectornever default-constructsTinto the whole capacity; only[0, mSize)are live objects. - Element access (
operator[], iterators,Data) returns pointers/references to live objects only within[0, mSize). Accessing[mSize, mCapacity)is a precondition violation, caught byLUDUS_ASSERTin checked builds. - Reallocation moves live objects from the old block to the new block via
UninitializedRelocate(§9.2), then frees the old block. After a successful reallocation, all pointers, references, and iterators into the vector are invalidated (§19). - For non-trivially-destructible
T, shrinking (Resizedown,PopBack,Clear,Erase) callsstd::destroy_aton the removed elements before the size is reduced. std::launderis applied where the standard requires it (reusing storage that previously held a different-typed object); centralized incontiguous_storage.hppso the reasoning is in one place.- Over-aligned
T: the allocation seam must honouralignof(T). §14 requires the seam to accept alignment; v1 uses the alignedoperator new(::operator new(bytes, std::align_val_t{alignof(T)}, std::nothrow)) [Standard] so over-aligned element types (e.g. SIMD-aligned math structs, GPU-upload structs) are correct on all targets. - Empty base / empty element types:
Array<Empty, N>andVector<Empty>must compile and behave;[[no_unique_address]]is used for any stateless policy members (e.g. a future allocator) so empty policies cost zero bytes (C3, mirrorsUniquePtr's[[no_unique_address]] mDeleter).
Constexpr: Array is fully constexpr. Vector is designed so a
constexpr implementation is possible later (allocation via
std::allocator-style constexpr path is intricate under the no-exceptions/custom
seam), but v1 does not promise constexpr Vector; it is an explicit
non-goal (§27) to avoid contorting the runtime design for compile-time use that
no call site needs yet.
14. Allocation strategy¶
Decision: v1 allocates through a single internal seam — a tiny pair of functions
AllocateBytes(usize bytes, usize alignment) noexcept/FreeBytes(void*, usize bytes, usize alignment) noexcept— implemented today in terms of the C++ nothrow, alignment-awareoperator new/operator delete. There is no allocator template parameter, no allocator object, and no allocator framework in v1.Rationale: - The nothrow path lets the container check for
nullptrand handle OOM explicitly, instead of the default throwingnewthat terminates under-fno-exceptions[Standard] (§5.4). This is the core exception-free requirement (C1). - A single seam is exactly the insertion point ADR 0003 anticipates: when the engine allocator lands,AllocateBytes/FreeBytesare re-pointed at it (or an allocator handle is threaded through), without changing any container call site. - No allocator template parameter keepssizeof(Vector)minimal, keeps the type simple and debuggable, avoids allocator-propagation complexity, and avoids template bloat (C8) — none of which any current call site needs.Alternatives considered: - STL allocator model (
Allocatortemplate param +allocator_traits). Rejected for v1: verbose, propagation rules are a well-known footgun, and it presumes a model the engine allocator may not match. Can be layered later if ever needed, but the seam is enough. -std::pmr-style polymorphic allocator handle stored in the vector. Deferred: attractive when the engine allocator exists (per-vector arena/tag), but premature now; adds a pointer to every vector for zero present benefit. The seam is forward-compatible with adding an optional memory-resource handle later. - Rawmalloc/free. Rejected: does not honour over-alignment portably and bypasses any futurenew-based interposer/tooling (the repo already uses--wrap=malloc/newinterposition in assertion allocation tests; going throughoperator newkeeps those hooks meaningful).Trade-offs: No per-vector allocator now means workloads that will eventually want arena/tag allocation must wait for the allocator milestone; but the seam guarantees they will not need a call-site rewrite. OOM policy (fatal vs fallible) is handled at the API layer (§18), not the seam.
Evidence: throwing-
newtermination under-fno-exceptions[Standard]; ADR 0003 commitment to a Ludus allocator [repo]; alignment-awareoperator new[Standard]; existing allocation interposition tests [repo].
Allocation-failure behaviour (v1):
- Infallible API (PushBack, Reserve, Resize, ...): on nullptr from the
seam, call LUDUS_FATAL("Ludus::Vector allocation failed"). Rationale: for
the overwhelming majority of engine code, heap exhaustion is unrecoverable and
should fail fast with a diagnostic, consistent with how the engine treats
infrastructure failure.
- Fallible API (TryPushBack, TryReserve, TryResize, TryAppend): return
false and leave the container unchanged (strong guarantee, §18) for callers
that must degrade (e.g. streaming/asset paths that can drop or retry).
15. Growth strategy¶
Decision (proposed, benchmark-gated — REVISED to 2× after measurement; see §32.2): geometric growth, overflow-checked, never shrinking on growth, with a small minimum first capacity. The design proposed 1.5× as a hypothesis; the §22/§32.4 measurement selected 2× (≈40% fewer reallocations, ≈30% fewer bytes copied, negligible peak-memory difference). The paragraphs below are the original 1.5× reasoning, preserved; §32.2 records why 2× won.
Rationale: - Any factor > 1 gives amortized O(1) append [Standard/Library]. - A factor below the golden ratio (φ≈1.618) lets a general allocator reuse previously freed blocks, improving locality and reducing fragmentation [Library] (Folly/Dinkumware reasoning). 1.5× is the widely used memory-friendly point (MSVC/Dinkumware). - Integer-only
oldCap + oldCap/2avoids floating point and is cheap.Alternatives considered: - 2× [Library] (libstdc++/libc++). Fewer reallocations (fewer relocations of non-trivial elements), but higher peak memory and no freed-block reuse. Better when relocation is expensive (non-relocatable element types) or append counts are huge; worse for memory. Kept as the leading alternative; the benchmark (§22, "repeated growth", "push N POD/non-trivial") decides. - Golden-ratio-ish (≈1.6) — marginal; not worth the odd constant vs a clean 1.5×. - Adaptive/size-class-aware growth — deferred to the allocator milestone, where the allocator's size classes are known; premature now.
Growth mechanics (all builds): - First growth from capacity 0 jumps to a floor (proposed:
max(1, ceil(kMinBytes / sizeof(T)))with a smallkMinBytes, so tiny elements do not reallocate on the first few pushes and large elements start at 1). Exact floor is [Measured]. -Reserve(n)/Append/Resizethat request more than the geometric next step allocate exactly the requested amount (no over-allocation beyond the request), matchingstd::vectorintuition and avoiding surprise memory use. - Growth math checksusizeoverflow: a requested capacity that overflowsusizeor exceeds aMaxSize()(=PTRDIFF_MAX / sizeof(T)analogue) is a fatal error in the infallible API /falsein the fallible API — never a silent wrap. This is the exception-free replacement forstd::length_error.Trade-offs: 1.5× reallocates slightly more often than 2× (more relocations) in exchange for lower peak memory and allocator reuse. For trivially-relocatable elements (the common engine case) relocation is a cheap
memcpy, so the extra reallocations are inexpensive — which is precisely why 1.5× is the proposed default. For expensive-to-move element types, 2× might win; the benchmark settles it, and the factor is a single named constant so revising it is trivial.Evidence: amortization [Standard]; factor trade-offs and the φ argument [Library] (Folly, StackOverflow analyses); relocation-cost dependence [Measured] (to be produced by §22).
16. Type-specific optimizations — classified¶
The prompt's required classification. Never require UB for performance.
16.1 Portable and standards-compliant now (ship in v1)¶
- Trivially destructible
T: skip destructor loops entirely (if constexpr (std::is_trivially_destructible_v<T>)→ the loop compiles away). [Standard] - Trivially copyable
T: relocation and range copy viamemcpy/memmove. Copying the object representation of a trivially copyable type is well-defined [Standard];memmovefor overlapping orderedInsert/Eraseshifts is the correct primitive. libstdc++/libc++/MSVC all do this [Library]. - Ludus opt-in relocation trait
IsTriviallyRelocatable<T>: defaultstd::is_trivially_copyable_v<T>; a type author can opt in for a move-safe, no-internal-pointer type viaLUDUS_TRIVIALLY_RELOCATABLE(Type)(a specialization macro), exactly like Folly'sIsRelocatable[Library]. When true, reallocation usesmemcpyand omits source destructors. This is the central engine win (POD-heavy and move-cheap types). Correctness rests on the contract the author asserts (the type is bitwise-relocatable); it is not inferred unsafely. noexcept-move requirement: for the non-relocatable reallocation path,VectorrequiresT's move constructor to benoexcept(or falls back to copy). Under-fno-exceptionselement moves cannot throw anyway, but requiringnoexcept(via astatic_assert/concept) documents intent and matches the strong-guarantee reasoning (§18). [Standard/Judgment]
16.2 Potentially useful later (design leaves room; do not ship in v1)¶
std::is_trivially_relocatable(P1144/P2786) fast path: when a supported toolchain defines the feature-test macro, makeIsTriviallyRelocatable<T>default to it, widening thememcpyset automatically [Standard-future] (Folly PR #2216 pattern [Library]). Gated behind the macro so Clang 18 behaviour is unchanged.realloc-based growth for trivially-relocatable types once the engine allocator exposes aReallocatethat can grow in place — avoids copying when the block can extend. Deferred to the allocator milestone.constexpr Vector— see §13; possible, not promised.
16.3 Premature (explicitly not now)¶
- Small-buffer optimization (belongs to
SmallVector, §26 — prompt constraint). - 32-bit size/capacity for the general
Vector(§11.3). - Allocator template parameter / propagation (§14).
- SIMD-accelerated bulk copy beyond what
memcpyalready yields.
16.4 Unsafe / UB (never)¶
memcpy-relocating a non-trivially-copyable type that has not opted intoIsTriviallyRelocatable(e.g. a type with self-referential pointers, or whose move has side effects). UB; forbidden. The trait default is the conservativeis_trivially_copyable_v, so the unsafe path is impossible to reach by accident — it requires an explicit, auditable opt-in.reinterpret_cast-based type punning of storage withoutstd::launderwhere the standard requires it.- Treating
AppendUninitializedstorage as live objects for non-trivial types (hence its concept constraint to trivially-constructible types, §11.5).
17. Debugging strategy¶
Intentional per-flavor semantics, riding the existing build-flavor system (C6) so Release/Profile pay nothing and Debug/Development are richly checked.
| Facility | Debug (ID1) | Development (ID2) | Profile/Release (ID3/4) |
|---|---|---|---|
Bounds checks (operator[], Front/Back, PopBack non-empty, Insert/Erase index) |
LUDUS_ASSERT (live, resumable, rich site) |
LUDUS_ASSERT (live) |
compiled out (zero cost) |
Invariants (mSize ≤ mCapacity, mData != nullptr iff mCapacity > 0) checked on mutation |
LUDUS_ASSERT |
LUDUS_ASSERT |
out |
| OOM / overflow (infallible API) | LUDUS_FATAL (always) |
LUDUS_FATAL |
LUDUS_FATAL (always live) |
| Memory poisoning of freed/relinquished storage | fill freed block + moved-from elements with a poison pattern before free | optional | none |
| Debug iterator checking (detect use-after-invalidation) | optional checked-iterator wrapper (see below) | off | off (raw pointer iterators) |
| Sanitizer cooperation | see §21 | — | — |
- Bounds/invariant checks use
LUDUS_ASSERT(notLUDUS_CHECK/REQUIRE): precondition violations are programmer errors; they should be loud in dev/debug and free in shipping, which is exactlyLUDUS_ASSERT's contract (C6). OOM and capacity-overflow in the infallible API are unrecoverable and useLUDUS_FATAL. - Poisoning: in Debug, overwrite freed element storage (and moved-from source
bytes) with a recognizable pattern (e.g.
0xDD) so use-after-free reads are obvious in a debugger; this is in addition to ASan, which already catches the access. Zero cost outside Debug. - Debug iterators: raw pointer iterators are the default (best codegen,
ranges-compatible). An optional checked iterator (generation counter on the
vector; iterator stores container + generation, asserts on deref/compare after
invalidation) can be enabled in Debug only. Proposed as a follow-up, not v1
core, to keep the first implementation small — but the storage model reserves
the design room (a generation counter can be added behind
LUDUS_ENABLE_ASSERTSwithout ABI concern since the engine is static-linked, ADR 0002). Listed as an open question (§28). - ASan is first-class: because storage is a single
operator newblock and freed storage is released promptly, ASan detects OOB and UAF on the container buffer directly. The design avoids "cache the whole capacity as live objects" tricks that would blind ASan. - Debugger legibility: the
{mData, mSize, mCapacity}layout (§11.3) is trivially inspectable; a natvis / pretty-printer is a cheap follow-up (§28).
18. Error / exception semantics¶
No exceptions anywhere (C1). Failure taxonomy:
- Precondition violations (index out of range,
Back()/PopBack()on empty, badInsertposition): programmer errors →LUDUS_ASSERT(checked builds), UB-if-disabled by contract in shipping (documented; same contract asstd::vector::operator[]). We do not pay for these in Release. - Allocation failure / capacity overflow:
- Infallible API →
LUDUS_FATAL(fail fast, always live). This is the exception-free analogue ofstd::bad_alloc/std::length_errorthatstd::vectorwould throw (and which would terminate under-fno-exceptionsanyway) — but with a Ludus diagnostic and a defined, auditable site. - Fallible
Try*API → returnsfalse, container unchanged. - Exception guarantees (adapted to a no-throw world):
Try*operations provide the strong guarantee: on failure the container is exactly as before (achieved by allocating the new block and fully populating it before releasing the old one; the old state is only replaced on success).- Infallible operations either succeed or terminate; there is no partial-failure observable state.
PopBack,Clear,Size,Data,Swap, move operations arenoexceptand cannot fail.- Element operations: if a
Tcopy/move/constructor could fail, it must do so without exceptions (engine types cannot throw).Vectorrequiresnoexceptmove for the fast reallocation path (§16.1). If an element copy (in the copy constructor /Resize-with-value) has side effects that can leaveTin a bad state, that isT's concern;Vectorguarantees it will not leak or double-destroy: it tracks exactly how many elements were constructed and destroys precisely those on teardown.
This makes failure part of the signature (Try* returns bool) or fatal and
diagnosable (LUDUS_FATAL), exactly as AGENTS.md mandates.
19. Iterator / reference invalidation rules¶
Documented as a hard contract (matching std::vector intuition so migration is
safe, with Ludus-specific notes):
Array<T,N>: iterators/references never invalidate for the lifetime of the object. Moving theArrayobject does not preserve addresses across the move (elements live inline).Vector<T>:- Any operation that reallocates (
Reserve/Resize/ShrinkToFit/growth inPushBack/EmplaceBack/Append, copy-assign) invalidates all iterators, pointers, and references. PushBack/EmplaceBack/Appendinvalidate all iff they reallocate (i.e.Size()would exceedCapacity()); otherwise onlyend()moves.PopBackinvalidates only the removed element andend().- Ordered
Insert/Erase/EraseRangeinvalidate iterators/references at and after the modification point (and all of them ifInsertreallocates). EraseUnorderedinvalidates the erased slot and the last element (which moves into the slot) andend(). This differs fromstd::vector::eraseand is documented prominently because it is an intentional Ludus idiom.Swapinvalidates nothing (pointers follow the storage to the other object; end iterators are conceptually swapped).Clearinvalidates all references (elements destroyed) but not the capacity.- Debug detection: the optional checked-iterator (§17) turns
use-after-invalidation from UB into a
LUDUS_ASSERTfailure in Debug.
20. Standard interoperability¶
Owning Ludus containers must not isolate Ludus from the ecosystem (explicit goal). Concretely:
std::spanconversion. Both containers convert tostd::span<T>andstd::span<const T>.const-view conversion is implicit (safe, cheap); mutable-view conversion is via an explicitAsSpan()/ implicit as decided in review (implicit is ergonomic and matches howstd::vectorinteroperates viastd::span's range constructor). This is the primary bridge to the existing loggingstd::spanAPIs and to GPU/C APIs (span.data(),span.size()).- Contiguous iterators / ranges.
begin()/end()return raw pointers, which arestd::contiguous_iteratorand make the container astd::ranges::contiguous_range[Standard]. Thereforestd::sort,std::stable_sort(used by the exporter),std::find, ranges views, and range-for all work with no adapter. Lowercasebegin/end(anddata/sizefree-function support via ADL / the members) are provided specifically for this interop, alongside the PascalCaseData/Sizeused by Ludus code. This is the one sanctioned lowercase-alias concession (C3). - C / GPU APIs (pointer + count).
Data()+Size()is exactly the Vulkan/Metal upload idiom (pCode/codeSize, buffercontents+length). No copy or adapter needed.AppendUninitialized(§11.5) targets the staging-buffer fill pattern. - Structured bindings work for
Array(aggregate) viastd::tuple_size/std::tuple_element/getspecializations if we choose to provide them (optional, low priority;std::arraysupports it — add only if a call site wants it). - What we do not do: we do not implement full
std::vector-compatible allocator traits,std::erase/std::erase_iffree-function overloads (provide LudusErase/EraseUnorderedmembers instead), or reverse iterators in v1 (add on demand). We do not chaseconstexprcontainer interop (§13).
21. Testing strategy¶
Tests use Catch2 v3 (test exceptions re-enabled per ludus_enable_test_exceptions),
run under the Debug, Development, and ASan/UBSan presets, plus a
zero-allocation check reusing the existing allocation-interposer pattern
(--wrap=malloc/calloc/realloc, and the LD_PRELOAD interposer) already present
for assertions.
21.1 Instrumented element types (test scaffolding)¶
A tests/lifetime_type.hpp providing element types that make lifetime bugs
observable:
LifetimeTracked— a global/thread-local counter set tracking construct/copy/move/destroy counts and live-instance count; every test asserts the ledger balances (constructions == destructions, no leaks, no double-destroy). Each instance carries a canary/id to detect use-after-move/use-after-free.MoveOnly— deleted copy; verifies move-only element support (the sink-list case:UniquePtris move-only).NonTrivial— user dtor + heap member; forces the non-relocatable path and ASan/leak detection.ThrowingLikeError— since we cannot throw, a type whose copy can report failure by leaving a flag; used to test the strong-guaranteeTry*paths and precise destroy-count on partial fills. (No real exceptions; validates the no-throw error model.)Relocatable— a type that opts intoLUDUS_TRIVIALLY_RELOCATABLE; verifies thememcpyfast path is taken (via a static-assert probe / counting hook) and correctness after relocation.OverAligned—alignas(64)(cache line) andalignas(128); verifies the aligned allocation seam; ASan/UBSan alignment checks.Empty— stateless type; verifies empty-type handling and (with a policy)[[no_unique_address]].
21.2 Coverage matrix (minimum)¶
Per container, across trivial scalar / POD struct / non-trivial / move-only / over-aligned / empty element types:
- Sizes: zero-size, one element, many elements, exactly-at-capacity, one-past-capacity (forces growth), large N.
- Growth: repeated
PushBacktriggering multiple reallocations; verify capacity sequence follows the chosen factor; verify element values and order preserved; verify no leaks across every reallocation. - Capacity ops:
Reserve(grow, no-op, shrink-request ignored),Resizeup/down,ShrinkToFit,Clear(capacity retained, elements destroyed). - Modifiers:
PushBack/EmplaceBack(lvalue/rvalue),PopBack,Insert/Erase/EraseRangeat begin/mid/end boundaries,EraseUnordered,Append(span)empty/one/many, self-append aliasing guard. - Copy/move: copy ctor/assign (deep, independent), move ctor/assign
(moved-from is empty+valid), self-copy-assign and self-move-assign safety,
Swap. - Access:
operator[],Front/Back,Data, iteration (range-for, explicit iterators), const-correctness. - Lifetime: ledger balances in every test; destruction of a non-empty vector; destruction after moves; exact destroy count after partial-fill failure.
- Aliasing:
Insert/Appendwhere source overlaps the vector. - Boundaries: capacity/overflow — a
Reserve/Resizethat exceedsMaxSizetriggersLUDUS_FATAL(death test) /Try*returnsfalse(unchanged). - Interop:
std::spanround-trip;std::stable_sort/std::findoverbegin/end;std::rangesview; pointer+count handed to a fake "GPU upload" sink. - Failure model:
Try*OOM simulation (interposer forcesnullptr) →false+ strong guarantee; infallible OOM → death test hittingLUDUS_FATAL.
21.3 Sanitizers, leaks, and allocation¶
- All tests pass under ASan + UBSan (existing preset) and, where relevant,
TSan is out of scope (containers are not thread-safe by contract — a
documented non-goal, §27; concurrent use is the caller's responsibility, as
with
std::vector). - Leak checks: the
LifetimeTrackedledger + ASan leak detector. - Zero-allocation assertions: using the interposer, assert that
Arraynever allocates, that a default-constructed /Reserve(0)Vectornever allocates, thatCleardoes not free/allocate, and thatReserve(n)then NPushBacks performs exactly one allocation. - Relocation-path assertion: a test that a
memcpy(not per-element move) is used for a relocatable/trivially-copyable type — verified via a move-counting element that must observe zero moves across a reallocation.
21.4 Array specifics¶
Aggregate init, constexpr use (static_assert on a constexpr Array),
structured/std::span conversion, Fill, comparison operators, Count==0 edge.
22. Benchmark strategy¶
Primary comparison: Ludus::Vector vs std::vector and Ludus::Array vs
std::array, on the linux-clang-release and linux-clang-development
presets with the pinned Clang 18. Goal is not to beat std::vector; it is to
prove we are not materially worse and to settle the §15 growth factor with data.
22.1 Workloads (representative)¶
- Construction (empty;
Reserve(N);Vector(N)). - Push N
uint32(trivial scalar). - Push N POD structs (e.g. a 32-byte transform-like struct — trivially copyable).
- Push N non-trivial objects (heap-owning element — non-relocatable path).
- Push N move-only (
UniquePtr-like) — the sink-list workload. Reserve+ append vs unreserved append (measures reallocation cost).- Repeated growth from empty (measures growth-factor behaviour: allocation count, peak capacity, bytes).
- Iteration (sum/transform — should be identical codegen to
std::vector). - Random access.
- Bulk
Append(span)of N elements. Insert/Eraseat front/mid;EraseUnordered.- Copy whole; move whole; destruction of large vector.
22.2 Metrics¶
- Wall-clock runtime (Catch2/nanobench-style or a small harness).
- Allocation count and total bytes (via the interposer) — the honest measure of growth policy.
- Peak capacity reached for a given push sequence (growth factor validation).
sizeof(Vector<T>),sizeof(Array<T,N>)(footprint parity/deltas).- Generated code for tiny common ops — inspect assembly for
operator[],PushBackfast path (no reallocation),Size,Data, iteration loop; compare tostd::vector. Expectation [Measured]: identical or fewer instructions in Release;operator[]bounds check fully elided in Release. - Debug vs Release delta (confirm checks vanish in Release/Profile).
- Header parse time via the existing
./scripts/profile-build+ build-budget tooling — confirmvector.hpp/array.hppstay within the default 2000 ms budget and that migratingbackend.hppoffstd::vector<unique_ptr>does not regress (ideally improves) its parse time.
22.3 Acceptance gates¶
- No representative workload is materially slower than
std::vectorin Release. - Trivially-copyable reallocation performs a single
memcpy(zero element moves). - Allocation count for
Reserve(N)+N pushes is exactly 1. Arraycodegen andsizeofmatchstd::array.- Container headers within build budget.
- The growth-factor decision (§15) is recorded with the measured allocation count / peak-bytes / runtime table (1.5× vs 2× on the POD and non-trivial push workloads).
23. Codebase-wide migration strategy¶
Principles: migrate by subsystem, keep every stage green (builds
warning-clean, unit tests pass, ASan/UBSan clean, format/tidy pass — the
AGENTS.md "ready for review" bar), prove with the clean clusters first, and
defer entangled uses. No giant blind text replacement. Each stage is a
separate PR.
23.1 Stages (ordered)¶
Stage 0 — Build the containers (no migration).
Implement Array, Vector, relocation.hpp, tests (§21), benchmarks (§22) in
FoundationContainers. Land behind its own module; nothing else depends on it
yet. Green by construction. Risk: none to existing code.
Stage 1 — Dogfood: logging sink lists (Category B, clean).
Migrate logger.cpp Sinks and backend.{hpp,cpp} mSinks /
Start(...) from std::vector<std::unique_ptr<ILogSink>> to
Vector<UniquePtr<ILogSink>> (pairs with the existing Ludus UniquePtr).
- Requires: Vector move-only element support, PushBack/EmplaceBack, move
ctor/assign, range-for, Size/Empty.
- Mechanical? Mostly, but touches a public-ish internal header
(backend.hpp) and its build-budget override — must re-measure header parse
time (§22.2) and tighten/remove the override if it improves.
- Manual refactor value: none beyond the type swap.
- Risk: low-medium (worker ownership semantics; covered by existing logging
regression tests + ASan).
Stage 2 — Profiling exporter POD/pointer vectors (Category B, clean).
Migrate export_perfetto.cpp vector<FlatEvent>, vector<TraceChunk*>,
vector<uint64> to Vector<...>. Consider rewriting the parallel
openTicks/openNames stacks into one Vector<OpenScope> (option (2),
Ludus-native improvement) — but openNames holds std::string, so this
rewrite is only clean once String exists; until then, keep those two as STL and
migrate only the POD/pointer vectors.
- Requires: PushBack, Reserve, Empty, Back, PopBack, begin/end for
std::stable_sort, std::span conversion.
- Mechanical? Yes for the POD/pointer vectors (sort works unchanged via
contiguous iterators).
- Risk: low (cold path; export tests cover it).
Stage 3 — Array for stack format buffers (Category A, non-sensitive).
Migrate logger.cpp std::array<char, kMaxMessageBytes> to Array<char, N>
iff §22 confirms array.hpp is header-light and codegen-identical.
- Risk: low. Skip if it adds any header weight to the logging hot path.
Stage 4 (deferred, gated on other milestones):
- export_perfetto.cpp vector<std::string> → Vector<String> — after
String exists.
- file_sink.cpp vector<std::filesystem::path> / Group / Group* — after
the VFS layer replaces std::filesystem.
- Emergency assertion buffers in diagnostic.cpp — only if a FixedArray
is proven equally light and dependency-free; otherwise keep STL permanently
(this path must not gain dependencies).
- Test std::vector<std::thread> — optional/opportunistic; not required.
23.2 Per-usage migration table¶
| # | Site | Current | Proposed | Required API | Mechanical? | Manual preferred? | Boundary exception? | Risk | Stage |
|---|---|---|---|---|---|---|---|---|---|
| 1 | logger.cpp Sinks |
vector<unique_ptr> |
Vector<UniquePtr> |
move-only, PushBack/EmplaceBack, range-for | yes | no | no | Low-Med | 1 |
| 2 | backend.{hpp,cpp} mSinks, Start(&&) |
vector<unique_ptr> |
Vector<UniquePtr> |
move, move-only, iterate | mostly | re-measure header budget | no | Med | 1 |
| 3 | export_perfetto vector<FlatEvent> |
vector<POD> |
Vector<FlatEvent> |
PushBack, Reserve, iter | yes | no | no | Low | 2 |
| 4 | export_perfetto vector<TraceChunk*> |
vector<ptr> |
Vector<TraceChunk*> |
PushBack, range-for | yes | no | no | Low | 2 |
| 5 | export_perfetto vector<uint64> openTicks |
vector<uint64> |
Vector<uint64> (or fold into Vector<OpenScope>) |
PushBack/PopBack/Back/Empty | yes | maybe rewrite w/ #6 | no | Low | 2 |
| 6 | export_perfetto vector<std::string> openNames |
vector<string> |
Vector<String> |
— | no | wait for String |
yes (until String) | Low | 4 |
| 7 | logger.cpp array<char,N> |
std::array |
Array<char,N> |
Data/Size/span | yes | only if header-light | maybe | Low | 3 |
| 8 | diagnostic.cpp array<char,512/16> |
std::array |
keep / maybe FixedArray |
— | no | keep | yes (emergency path) | — | 4/never |
| 9 | file_sink.cpp vector<path>, Group, Group* |
vector |
Vector after VFS |
— | no | wait for VFS | yes (until VFS) | Low | 4 |
| 10 | logging/profiling tests vector<thread> |
vector |
optional | — | n/a | optional | tests exempt | — | opportunistic |
| 11 | logging format APIs std::span<...> |
span |
keep | — | n/a | n/a | kept by design | — | never |
23.3 Where the API must NOT imitate STL merely to ease conversion¶
erase: provideErase(ordered, O(n)) and the explicitly-namedEraseUnordered(O(1)); do not silently makeEraseswap-and-pop, and do not addstd::erase_iffree functions just because they exist. The exporter and future engine code should reach forEraseUnorderedwhen order is irrelevant — naming it prevents accidental O(n).at(): not provided (its reason for existing is to throw). Bounds safety is the assertedoperator[]; graceful bounds handling, if ever needed, is an explicitTryGet.initializer_listctor: not added preemptively (§12).- Allocator constructors /
get_allocator: not added until the allocator exists (§14). - The parallel-stack idiom in the exporter (
openTicks+openNames) is an invitation to a Ludus-native rewrite (oneVectorof a small struct), not a reason to mirror twostd::vectors verbatim.
24. Explicit STL-boundary exceptions¶
Superseded by §34.3/§34.4. This section listed container-level exceptions (the
file_sinkfilesystem vectors and the teststd::vector<std::thread>pools) that have since been migrated toLudus::Array.std::vectoris now prohibited in all first-party code, engine and tests alike; the only permittedstd::vector/std::arrayuses are the benchmark comparison baseline and third-party/vendor code (§34.3). The rows below are corrected to the current state.
Cases that correctly remain STL-based, each with a concrete reason. These are not container migrations that were skipped; they are either non-container facilities or hard architectural boundaries.
| Exception | Reason | Revisit when |
|---|---|---|
std::span<...> in the logging format APIs and as the container view/interop type |
Non-owning view; the sanctioned ecosystem vocabulary; replacing it weakens ranges/GPU/C interop and is out of scope | never (kept by design) |
std::string element in export_perfetto/file_sink (the container is Ludus::Array) |
The element type is std::string, slated for a Ludus String; the owning container has already migrated |
element migrates when String lands |
std::filesystem::path element in file_sink (the container is Ludus::Array) |
The element type is entangled with std::filesystem, slated for the VFS layer; the owning container has already migrated |
element migrates when the VFS layer lands |
std::array<char,N> in the emergency assertion path (diagnostic.cpp) |
Must stay allocation-free, dependency-free, and header-light; FoundationBase cannot depend on FoundationContainers (circular), and the path must not run LUDUS_ASSERT. It is std::array, not std::vector |
only if a dependency-free StaticArray variant is proven equally light; otherwise never |
std::vector/std::array in array_bench.cpp |
The comparison baseline; benchmarking against the STL is the entire purpose. Not in the engine build graph | never (kept by design) |
std::stable_sort/std::find/ranges over Ludus containers |
Algorithms are explicitly out of scope; Ludus containers interoperate with them via contiguous iterators | not a replacement target |
The default is absolute: all first-party code (engine and tests) uses Ludus
containers, never std::vector. The only std::vector permitted anywhere in
the repository is the benchmark baseline and third-party/vendor code.
25. (folded into §12/§20 — API + interop specification)¶
See §11.5 (API), §12 (migration-compatibility mapping), §20 (standard interop).
26. Future container taxonomy¶
Proposed so current naming does not corner the architecture. Not designed here; sketched to reserve names and semantics.
| Type (proposed name) | Category | One-line purpose | Distinct from Vector how |
|---|---|---|---|
Array<T, N> |
static/fixed array | compile-time-sized inline owning array | no allocation, size is a type parameter |
Vector<T> |
dynamic array | growable heap-backed owning array | this document |
FixedVector<T, N> (a.k.a. static/inline-capacity) |
fixed-capacity vector | runtime size ≤ compile-time capacity N, no heap |
never allocates; PushBack past N is a fatal/Try failure; directly serves the TraceChunk/bounded-buffer idiom (§3.5) |
SmallVector<T, N> |
small/inline vector | inline storage for ≤ N, spills to heap beyond | SBO changes pointer stability & sizeof; a separate type, never a mode of Vector (prompt) |
Span<T> → use std::span |
view | non-owning contiguous view | not owning; kept as std::span (do not build a competitor) |
String / StringView |
string | owning UTF-8 string / view | ADR 0003; backed by the allocator; StringView may alias std::string_view |
| flat/open-addressed hash map, flat set | hash / flat | cache-friendly associative | ADR 0003 ("flat/open-addressed hash map"); separate design |
Naming coherence: static things are Array/FixedVector, the growable default is
Vector, inline-optimized is SmallVector, views are std::span. FixedVector
and SmallVector are the natural homes for the 32-bit size/capacity and
inline-storage ideas rejected for the general Vector (§11.3, §16.3), and
they can share contiguous_storage.hpp (§9.2). This taxonomy is explicitly not
committed by v1 beyond reserving the names and confirming Vector must not absorb
their roles.
27. Non-goals¶
- Not a replacement for
std::span, algorithms, ranges, iterators, smart pointers (UniquePtralready exists), strings, or hash containers. Those are separate decisions (prompt). - Not thread-safe. Concurrent mutation is the caller's responsibility, as
with
std::vector. (The lock-free structures in profiling/logging are purpose-built and stay as they are.) - No small-buffer optimization in
Vector(that isSmallVector). - No allocator framework / allocator template parameter in v1 (single seam only, §14).
- No
constexpr Vectorguarantee in v1 (§13). - No 32-bit size/capacity for the general
Vector(§11.3). - No view replacement:
std::spanstays by design (§24). Owning containers, however, have been fully migrated — no first-partystd::vectorremains outside the benchmark baseline (§34.3). Element types that are still STL facilities (std::string,std::filesystem::path,std::thread) migrate with their own subsystems, not by keeping the container asstd::vector. - No exceptions, RTTI dependence, or heavy-header inclusion in public container headers.
- Not a
std::vectorAPI clone — only the small, high-value surface in §11.5.
28. Risks¶
| Risk | Severity | Mitigation |
|---|---|---|
Relocation fast path applied to a type that is not actually bitwise-relocatable (author mis-opts-in LUDUS_TRIVIALLY_RELOCATABLE) |
High (UB) | Default trait is conservative (is_trivially_copyable); opt-in is explicit and auditable; relocation tests with LifetimeTracked/canaries; ASan; document the contract loudly |
| Lifetime bugs (double-destroy / leak / use-after-free) in the non-trivial path | High | std::construct_at/std::destroy_at only; centralized contiguous_storage.hpp; LifetimeTracked ledger in every test; ASan/UBSan; poisoning in Debug |
| Integer overflow in growth math → tiny allocation + OOB writes | High | Overflow-checked growth; MaxSize cap; fatal/Try-false on overflow; boundary tests |
| OOM behaviour surprises callers (fatal vs fallible) | Medium | Two clearly-named API families; documented; death tests for the fatal path, interposer tests for the fallible path |
| Header/build-time regression from a template in a public header | Medium | Minimal includes; out-of-line cold helpers; measured against build budget (§22.2); the backend.hpp migration is a chance to improve it |
| Growth factor chosen wrong for real workloads | Medium | Decision is benchmark-gated (§15/§22); factor is one named constant, trivially revised |
| Maintenance burden of owning a container forever | Medium | Small API; heavy tests; principles-not-clone approach; Array is nearly free |
Cross-platform correctness (alignment, operator new semantics) before non-Linux backends are enabled |
Medium | Portable subset only (C10); aligned nothrow operator new; no compiler-specific tricks outside compiler.hpp; revisit when Windows/macOS backends land |
| Debug-iterator absence lets use-after-invalidation stay UB in Debug | Low-Medium | ASan catches most; optional checked-iterator reserved as a follow-up (§17/§28 open question) |
Divergent EraseUnordered semantics surprise a reader expecting std::vector::erase |
Low | Distinct name; prominent invalidation docs (§19) |
29. Open questions (need decisions / your approval)¶
- Growth factor: 1.5× (proposed) vs 2×. Final value gated on §22 benchmark. Do you want 1.5× as the default hypothesis, or start from 2× (fewer reallocations, matches libstdc++/libc++)?
- Size/capacity width:
usize(proposed) vs 32-bit forVector. Proposedusizefor policy consistency and interop; a 32-bit variant is reserved forFixedVector/SmallVector. Confirmusizefor the generalVector. - OOM policy default: infallible-fatal (
LUDUS_FATAL) + fallibleTry*(proposed). Confirm this dual model, or prefer fallible-only / fatal-only. - Module placement: new
FoundationContainersmodule (proposed) vs folding intoFoundationBase. Confirm a new module. - Checked (debug) iterators in v1 or as a follow-up? Proposed follow-up (raw-pointer iterators in v1). Confirm.
AppendUninitializedin v1? Useful for GPU staging but adds a sharp-edged API; proposed to include it constrained to trivially-constructible types. Include now or defer until a real GPU-upload call site exists?- Should this design also produce an ADR (0007) recording the "own the containers / single allocation seam" decision, alongside this architecture doc? (The repo pairs big decisions with ADRs.)
- Naming:
Vector/Array(proposed, matches ADR 0003 wording and STL mental model) — confirm vs alternatives (DynArray,List,TArray-style). ADR 0003 already says "LudusArray/Span", which argues forArray/Vector.
30. Implementation plan¶
Sequenced, each step green and reviewable (matches §23 stages):
- ADR + review. (If §29.7 approved) write ADR 0007 recording the decision; get this design reviewed; resolve §29.
- Module scaffold.
modules/foundation/containers/with CMake (Ludus::FoundationContainers, depends onLudus::FoundationBase), install rules, test target (Catch2 +ludus_enable_test_exceptions), ASan/UBSan wiring, build-budget entry. relocation.hpp.IsTriviallyRelocatabletrait +LUDUS_TRIVIALLY_RELOCATABLEmacro + tests (including the future feature-test upgrade path behind a macro).Array<T,N>. Implement + full test/bench; confirmstd::arrayparity (codegen,sizeof, constexpr). Lowest risk, validates conventions.contiguous_storage.hpp(private) — lifetime/relocation primitives + focused tests withLifetimeTracked/OverAligned/Relocatable.Vector<T>. Implement the §11.5 API on top of the seam and storage helpers; out-of-line cold helpers invector_support.cpp; full §21 tests; §22 benchmarks; record the growth-factor decision with data.- Verify gates. Warning-clean, unit tests, ASan/UBSan, format/tidy,
build-budget — the
AGENTS.mdbar — on the pinned toolchain. - Stage 1 migration (logging sink lists) + re-measure
backend.hppbudget. - Stage 2 migration (profiling exporter POD/pointer vectors).
- Stage 3 (
Arrayfor the logging format buffer, if header-light). - Document deferred stages (String/VFS-gated) as tracked follow-ups; do not execute until their milestones.
No step lands unless the whole engine still builds warning-clean and all tests (incl. ASan/UBSan) pass.
31. References¶
Grouped by claim type. Attribution provided; content paraphrased/summarized for licensing compliance (no source quoted beyond brief identifiers). Content was rephrased for compliance with licensing restrictions.
Repository (authoritative for Ludus constraints):
- AGENTS.md, .kiro/steering/coding-standards.md — coding standards, no
exceptions, types, naming.
- docs/decisions/0002-static-library-default.md,
0003-standard-library-usage-policy.md (commits to Ludus Array/Span +
allocator), 0004-logging-format-type-erasure.md,
0005-build-time-budgets.md, 0006-resumable-development-assertions.md.
- cmake/EngineOptions.cmake, EngineBuildFlavor.cmake,
EngineCompilerWarnings.cmake, EngineSanitizers.cmake, assert_config.hpp.in,
config/build_budget.json.
- modules/foundation/base/include/ludus/foundation/base/:
types.h, assert.hpp, pointer.hpp (UniquePtr), compiler.hpp,
defines.h.
- modules/foundation/profiling/src/internal/trace_chunk.hpp (fixed-capacity
idiom evidence).
Standard C++ [Standard]:
- cppreference: std::array, std::vector, std::span, std::construct_at,
std::destroy_at, std::launder, contiguous_iterator,
std::ranges::contiguous_range, operator new (std::nothrow,
std::align_val_t), defaulted comparisons.
- WG21 P1144 (is_trivially_relocatable, O'Dwyer):
https://open-std.org/jtc1/sc22/wg21/docs/papers/2024/p1144r12.html
- WG21 P2786 (Trivial Relocatability for C++26, Bloomberg):
https://open-std.org/jtc1/sc22/wg21/docs/papers/2025/p2786r13.html
- Arthur O'Dwyer, "STL algorithms for trivial relocation" (relocation ≈ memmove
for trivially-relocatable):
https://quuxplusone.github.io/blog/2023/03/03/relocate-algorithm-design/
Production implementations [Library]:
- LLVM SmallVector (32-bit size/capacity rationale; split storage):
https://www.llvm.org/docs/doxygen/SmallVector_8h_source.html ; RFC "Should
SmallVectors be smaller?" (footprint trade); LLVM issue #129328 (vector ops in
terms of relocation, ~50% speedups):
https://github.com/llvm/llvm-project/issues/129328
- Folly IsRelocatable/fbvector (opt-in memcpy relocation; growth factor
discussion): https://github.com/facebook/folly/blob/main/folly/docs/Traits.md ,
https://github.com/facebook/folly/blob/main/folly/docs/FBVector.md ; Folly PR
#2216 (use std::is_trivially_relocatable when P1144 macro present):
https://github.com/facebook/folly/pull/2216
- EASTL vector (engine container principles: no-exceptions, allocator policy,
POD memcpy fast paths) — EA Standard Template Library documentation.
- Unreal Engine TArray (32-bit int32 size, allocator policy, POD relocation).
- Growth-factor survey (libstdc++/libc++ 2×, MSVC/Dinkumware 1.5×; golden-ratio
reuse argument): StackOverflow answers 20481237 and 27672798.
- -fno-exceptions + operator new termination vs nothrow returns null:
StackOverflow 6049563 / 21605092.
All external sources were used for principles and trade-offs only; no implementation code was copied.
32. Implementation & migration results (post-implementation addendum)¶
Status update: the containers designed above were implemented, tested, benchmarked, and the Ludus-owned codebase was migrated onto them. This section records what was actually built, the one design decision that changed under measurement, and the migration outcome. It supersedes the "not yet implemented" banner at the top for everything it covers.
32.1 What was built¶
Module modules/foundation/containers/ (Ludus::FoundationContainers, depends
only on Ludus::FoundationBase):
include/ludus/foundation/containers/relocation.hpp—IsTriviallyRelocatabletrait (default =std::is_trivially_copyable_v),TriviallyRelocatableconcept,LUDUS_TRIVIALLY_RELOCATABLE(Type)opt-in macro.include/ludus/foundation/containers/array.hpp—Array<T, N>(+Array<T,0>specialization).include/ludus/foundation/containers/vector.hpp—Vector<T>.include/ludus/foundation/containers/detail/contiguous_storage.hpp— lifetime/relocation primitives (installed detail header; the header template needs it at instantiation). Placed underdetail/rather thaninternal/because the SDK-install check forbids installing headers whose path containsinternal.include/ludus/foundation/containers/containers.hpp— umbrella.src/vector_support.cpp— out-of-line allocation seam, growth math, OOM/overflow fatal sinks.tests/—lifetime_type.hpp(instrumented element types),array_tests.cpp,vector_tests.cpp,relocation_tests.cpp,allocation_tests.cpp.benchmarks/vector_bench.cpp— comparison harness vsstd::vector/std::array.
The API matches §11.5 / §10.5 as designed. All lifetime work goes through
std::construct_at/std::destroy_at; the memcpy relocation fast path is
taken only for IsTriviallyRelocatable types and guarded against
constant-evaluation.
32.2 Design decision that changed under measurement — growth factor¶
Decision (revised): 2× geometric growth, not the 1.5× hypothesis proposed in §15.
Rationale: §15 explicitly made the factor benchmark-gated. A reallocation/ byte-count simulation (100k pushes, element sizes 4 B and 32 B) and the runtime benchmark (§32.4) showed 2× performs ~40% fewer reallocations and ~30% fewer total bytes copied than 1.5×, with negligible peak-memory difference, and matches libstdc++/libc++. The 1.5×-for-allocator-block-reuse argument depends on an allocator Ludus does not yet have; the extra reallocation cost of 1.5× is real today. The factor is a single named constant (
kGrowthNum/kGrowthDeninvector_support.cpp) and is trivial to revisit when the engine allocator lands.Evidence: measured, recorded in §32.4. This resolves design open question #1.
All other design decisions were implemented as specified. Open questions #2
(usize size type), #3 (dual fatal + Try* OOM), #4 (new module), #8
(Vector/Array names) were implemented as proposed. #5 (checked debug
iterators) and #6 (AppendUninitialized) were deliberately not implemented
(kept as future work, §27 non-goals honored); #7 (ADR 0007) is left to the
reviewer.
32.3 Migration results¶
Toolchain: pinned Clang 18.1.8, libstdc++, LLD, CMake/Ninja/Conan, Catch2 3.
std::vector call sites migrated (engine): 6 owning uses across 3 files →
Ludus::Vector:
- logging/src/logger.cpp — sink list (Vector<UniquePtr<ILogSink>>).
- logging/src/internal/backend.{hpp,cpp} — worker-owned sink list + Start()
parameter.
- profiling/src/export_perfetto.cpp — Vector<FlatEvent>, Vector<TraceChunk*>
(plus a semantic cleanup: two parallel dead-value stacks collapsed to one
usize openDepth counter, which removed a std::vector<std::string> entirely).
std::array call sites migrated (engine): 1 → Ludus::Array:
- logging/src/logger.cpp — Array<char, kMaxMessageBytes> producer format
buffer.
Test dogfood: profiling/tests/scope_tests.cpp drain buffers
(Vector<TraceEvent>, Vector<TraceChunk*>).
Modules affected: FoundationLogging, FoundationProfiling (+ new FoundationContainers). FoundationBase unchanged (correctly — see exceptions).
Notable API cleanups during migration:
- Introduced a MakeSink<T>() helper wrapping new-ed sinks in UniquePtr
(the sink list is now Vector<UniquePtr<ILogSink>>, no std::unique_ptr).
- Collapsed the exporter's two parallel push/pop stacks (whose stored values
were never read) into a single depth counter — clearer and dependency-lighter.
32.4 Benchmark results (Clang 18, -O2, ns/element, best of 7)¶
| Workload | Ludus | std | ratio |
|---|---|---|---|
| push N uint32 (unreserved) | 2.25 | 1.96 | 1.15 |
| reserve + push N uint32 | 0.63 | 0.54 | 1.16 |
| push N POD32 (unreserved) | 18.21 | 17.77 | 1.03 |
| push N non-trivial (std::string) | 10.31 | 9.82 | 1.05 |
| iterate + sum | 0.10 | 0.13 | 0.75 |
| random access | 0.76 | 0.76 | 1.00 |
| bulk append (span) | 0.08 | 0.08 | 1.01 |
| copy N POD32 | 1.97 | 1.95 | 1.01 |
sizeof(Vector<int>) == sizeof(std::vector<int>) == 24;
sizeof(Array<int,16>) == sizeof(std::array<int,16>) == 64.
Reading: at or near parity on every workload except small-scalar push_back,
which is ~15% slower. Root cause (confirmed by inspecting -O2 assembly): the
debuggable {data,size,capacity} representation writes mSize back to memory
each push and reloads it for the capacity compare, because the out-of-line
GrowForOne call forces mSize to be memory-current; libstdc++ keeps its
end-pointer in a register. This is an inherent, ~0.09 ns/element cost of the
representation chosen in §11.3 for debuggability; it is reported, not hidden, and
was judged not worth trading debuggability for. Single-call PushBack codegen is
otherwise identical to std::vector (load size, compare capacity, store,
increment).
32.5 Verification performed¶
- Container unit tests (Debug): 42 cases / 3178 assertions pass.
- Allocation tests (Debug, counting
operator new/delete): 6 cases pass — defaultVectorandArraynever allocate;Reserve(n)+n pushes = exactly 1 allocation;Clearfrees/allocates nothing; growth is O(log n) reallocations; alloc/free symmetric. - Relocation tests: trivially-relocatable/POD elements reallocate via
memcpywith zero element move-constructions; non-relocatable elements use move+destroy. - ASan + UBSan (
detect_leaks=1,halt_on_error=1): container tests + full migrated logging + full migrated profiling suites pass with zero UB/leak/ lifetime findings. - Full engine build matrix: Debug (17/17 ctest), Release (whole engine +
smoke app compile/link clean, asserts compiled out), ASan+UBSan (14/14 ctest;
the alloc test and sanitizer-gated death tests are excluded by design), and a
-Werror(CI-equivalent) build of the container + migrated targets — all clean.
32.6 Remaining intentional STL exceptions (post-migration)¶
Superseded by §34.3/§34.4. This table reflected an earlier, partial migration that still deferred the
file_sinkretention containers and the test thread pools. Those have since been migrated toLudus::Array; the element types (std::filesystem::path,std::string,std::thread) stay STL on their own replacement tracks while the containers are Ludus-owned. The current, authoritative exception list is §34.4.
Only two categories now remain (see §34.4 for the full statement):
| Location | Kept STL | Reason |
|---|---|---|
foundation/base/src/diagnostic.cpp |
std::array<char,512> ×2, std::array<char,16>, <array> |
Emergency assertion path in FoundationBase. Dependency direction forbids Base depending on FoundationContainers, and the emergency path must be dependency-free and must not run LUDUS_ASSERT (which Array::operator[] uses). Genuine hard boundary — and it is std::array, not std::vector. |
containers/benchmarks/array_bench.cpp |
std::vector/std::array, <vector>/<array> |
The comparison baseline; using the STL types is the point. Not in the engine build graph. |
std::string in export_perfetto.cpp/file_sink.cpp is an element/facility
(slated for a Ludus String, ADR 0003), not a container, and is out of scope
for the container-migration policy.
No first-party std::vector remains outside the benchmark baseline. No
undocumented owning-STL-container usage remains.
33. Independent audit corrections (post-implementation)¶
An independent adversarial audit of the implementation and migration was performed. Summary of what it found and what changed; §33 is authoritative where it overlaps earlier sections.
33.1 P0 fixed — self-referential insertion was undefined behaviour¶
The original EmplaceBack/PushBack/EmplaceAt/Insert reallocated (or
shifted) before materializing the new element from its arguments. When an
argument aliased an existing element — e.g. v.PushBack(v.Back()),
v.EmplaceAt(i, v[j]), v.Insert(i, v[k]) — the referenced element was freed
(reallocation) or moved (shift) before it was read. Consequences, both confirmed
under ASan/UBSan:
- Heap-use-after-free when the operation reallocated.
- Silent wrong value when a shift moved the referenced element first.
std::vector guarantees these self-referential forms are well-defined, so this
was also a latent migration hazard (any future v.PushBack(v.Back()) — a very
common idiom — would have been UB). No existing migrated call site triggered
it (all push fresh/temporary values), so it was latent, not an active crash.
Fix. The new element is now materialized from its arguments before any
reallocation or shift:
- EmplaceBack/TryEmplaceBack: on the (cold) grow branch, construct a local
from the arguments, then grow, then move the local into place. The hot
(no-grow) path is unchanged and still constructs in place. The local lives only
in the cold branch, so the common append path pays nothing (verified by
codegen and benchmark — see §33.3).
- EmplaceAt/Insert: construct a local from the arguments before shifting or
growing, then move it into the opened gap.
The earlier code comment on EmplaceAt claiming self-references were "supported"
was false and has been corrected.
33.2 Cost of the fix — bounded extra moves on the grow path¶
For a non-trivially-copyable but opt-in-relocatable element pushed by value,
the grow path now performs one extra move-construction of the pushed element
per reallocation (the local → slot move). This is O(log n) total moves across a
full fill, not per element, and does not affect the existing elements (which
are still relocated by memcpy). For trivially-copyable elements the "move" is a
trivial copy and is elided/negligible. The relocation regression test was
tightened accordingly: it asserts existing-element relocation performs no
per-element moves (bulk moves bounded by the reallocation count), and a second
test confirms the reserved (no-grow) path performs zero moves.
33.3 Performance re-verified after the fix¶
The append hot path is unchanged; benchmark (Clang 18, −O2, Release assert config) after the fix matches the pre-fix numbers: reserved push ≈1.13×, unreserved push ≈1.18×, POD/non-trivial/iterate/random-access/bulk-append/copy at or near parity — i.e. the §32.4 profile holds. (An interim version of the fix that forwarded arguments to an out-of-line grow helper regressed reserved push to ~3.4× by forcing a per-iteration argument spill in the hot loop; that approach was rejected in favour of the cold-branch local above.)
33.4 Doc drift fixed¶
The vector.hpp header comment still described "1.5x" growth; corrected to 2×
(the shipped value; §15/§32.2).
33.5 Audit items confirmed clean (no change needed)¶
Copy/move assignment (incl. self-assignment and capacity reuse), moved-from
state, EraseRange/Erase/EraseUnordered lifetime, Append from another
vector across reallocation, over-aligned allocation, empty/zero-size handling,
EraseRange(0,0) on an empty (null-data) vector (no null-pointer arithmetic),
overflow/MaxSize defenses, std::span conversions, and the exception-free
allocation-failure model were all exercised adversarially under ASan/UBSan and
found correct. The two remaining engine STL-container usages (diagnostic.cpp
emergency path — blocked by the FoundationBase→FoundationContainers
dependency direction; file_sink.cpp — entangled with std::filesystem, pending
the VFS layer) were re-verified as genuine, not convenience.
34. Ludus-native taxonomy and API (authoritative naming)¶
An architectural correction moved the containers off STL-mimicking names onto Ludus's own conventions. This section is authoritative for naming; earlier sections describe the same designs under their original working names.
34.1 Type taxonomy¶
Decision: two owning contiguous types, named so the storage discipline is obvious at a glance, with
Vectordeliberately reserved for mathematics.
Concept Name Was fixed-size (compile-time count) contiguous owning storage ludus::foundation::StaticArray<T, N>Array<T, N>dynamically-sized contiguous owning storage ludus::foundation::Array<T>Vector<T>mathematical vector (reserved — future Vec2/Vec3/Vec4)— Rationale.
Vectoris the single most overloaded word in a rendering/ physics engine (position, direction, velocity,Vec3…); using it for a growable byte container guarantees a lifetime of "which vector?" ambiguity. ReservingVectorfor math and naming the growable sequenceArrayremoves that collision.Array<T>reads naturally as "an array of T" and is the common case, so it earns the short name;StaticArray<T, N>makes the fixed, compile-time size explicit at every use. This is a coherent long-term taxonomy (the futureFixedVector/SmallVectorin §26 becomeFixedArray/SmallArray, keeping "Array" as the sequence family and "Vector" as math).Trade-offs.
Array<T>(dynamic) reads slightly less "obviously heap" thanVector<T>would to an STL native; mitigated by the taxonomy being consistent and documented, and byStaticArraymaking the fixed sibling unmistakable.
34.2 Member API — verb-oriented, not STL spellings¶
Ludus functions begin with a verb/verb-phrase (GetX, IsX, EnsureX,
AddX, RemoveX), a convention already established across the engine
(GetCurrentThreadId, IsInitialized, HasPending, SetGlobalLevel). The
container API now follows it. STL spellings are used only where they are
independently the clearest Ludus choice and are needed for ecosystem interop
(operator[], begin/end, data/size, std::span conversion, operator==).
| Old (STL-ish) | Ludus | Notes |
|---|---|---|
Size() |
GetSize() |
|
Capacity() |
GetCapacity() |
|
Empty() |
IsEmpty() |
predicate → Is |
Data() |
GetData() |
|
MaxSize() |
GetMaxSize() |
|
Front() |
GetFirst() |
|
Back() |
GetLast() |
|
Reserve(n) |
EnsureCapacity(n) / TryEnsureCapacity(n) |
name states the postcondition: GetCapacity() >= n; never shrinks; no-op if already satisfied |
ShrinkToFit() |
TrimCapacity() |
releases excess; best-effort |
PushBack(v) |
Add(v) / TryAdd(v) |
|
EmplaceBack(args…) |
AddInPlace(args…) / TryAddInPlace(args…) |
|
PopBack() |
RemoveLast() |
|
Insert(i,v) |
InsertAt(i,v) |
|
EmplaceAt(i,args…) |
InsertAtInPlace(i,args…) |
|
Erase(i) |
RemoveAt(i) |
order-preserving, O(n) |
EraseRange(f,l) |
RemoveRange(f,l) |
order-preserving, O(n) |
EraseUnordered(i) |
RemoveAtSwap(i) |
O(1) swap-with-last; name makes the reorder explicit |
Append(span) |
AddRange(span) / TryAddRange(span) |
|
From(span) |
FromRange(span) |
static factory |
Resize, Clear, Swap, Fill |
unchanged | already verbs |
operator[], begin/end/cbegin/cend, data/size, AsSpan, operator==/<=> |
unchanged | interop surface (range-for, ranges, std::sort, GPU pointer+count) |
EnsureCapacity(n) is deliberately not Reserve(n) or SetCapacity(n): the
name states its contract (ensure at least n; do nothing if already met; never
shrink). An exact-capacity SetCapacity was not introduced — Ludus has no
call site that needs an exact resulting capacity, and exact-shrink semantics
would be a sharper, less obviously-correct tool; releasing excess is the explicit
TrimCapacity().
Files were renamed to match: static_array.hpp (StaticArray), array.hpp
(Array, formerly vector.hpp), src/array_support.cpp. The detail header
detail/contiguous_storage.hpp and the relocation trait are unchanged in
behaviour.
34.3 Migration completeness (requirement: actually migrate)¶
Policy (authoritative, current). std::vector is prohibited in all
first-party Ludus code — engine source and tests alike. The only permitted
occurrences are:
- Benchmark comparison baselines — the container benchmark
(
containers/benchmarks/array_bench.cpp) deliberately declaresstd::vectorandstd::arrayto measureLudus::Array/StaticArrayagainst the STL. The STL types are the point of that translation unit, which is not part of the engine build graph. - Third-party / vendor / generated code — code Ludus does not own.
- Private Crashpad ABI adapter —
apps/editor/src/crash_capture_macos.mmconstructs exactly thestd::vectorargument/result types required by Crashpad's external-handler/database API, as scoped in ADR 0003. The adapter does not expose these containers or use them as Ludus storage. This macOS editor boundary never enters the engine, SDK or browser.
There is no "convenience", "cold path", "filesystem-related", "test-only",
"temporary", or "will be rewritten later" exception. The element type being a
not-yet-Ludus facility (std::filesystem::path, std::string, std::thread)
does not justify keeping the container as std::vector: the owning
container migrates to Ludus::Array regardless, and only the element type stays
STL until its own replacement lands.
The original migration replaced every first-party owning std::vector then
present, including the log-retention path and the test thread pools that prior
revisions of this document had deferred:
logging/logger.cpp: sink list →Array<UniquePtr<ILogSink>>; producer format buffer →StaticArray<char, N>.logging/internal/backend.{hpp,cpp}: worker sink list →Array<UniquePtr<…>>.logging/sinks/file_sink.cpp: the retention grouping (std::vector<Group>,std::vector<Group*>, andGroup::Segmentsstd::vector<path>) → LudusArray. The element typesstd::filesystem::pathandstd::stringstay STL — they are<filesystem>/String facilities on their own replacement track, not container replacements. Pointer stability is preserved (theGroup*snapshot is built only aftergroupsstops growing), andstd::sortstill operates over the array's contiguous iterators.profiling/export_perfetto.cppandprofiling/tests/scope_tests.cpp: event/chunk buffers →Array<…>.graphics/rhi/src/rhi.cpp: Vulkan extension enumeration/enable buffers (std::vector<VkExtensionProperties>,std::vector<std::string>,std::vector<const char*>) →Array<…>. Owning storage is genuinely required (Vulkan writes counts + out-params viaGetData()/GetSize()), so these are owningArray, notstd::spanviews.profiling/tests/{scope_tests,clock_tests}.cppandlogging/tests/regression_tests.cpp:std::vector<std::thread>thread pools →Array<std::thread>.std::threadis move-only;Arrayhandles it correctly (verified — see §34.5).docs/architecture/logging-review-evidence/probe.cpp: the review evidence probe'sstd::vector<std::thread>→Array<std::thread>.
The 2026-10-06 source review found later first-party std::vector uses in Text,
GameHost protocol/session storage, Audio Gym and tests. They are migration debt
under this policy. Milestone M1 tracks
consumer-by-consumer repair and an automated regression gate.
34.4 Remaining STL exceptions (verified genuine)¶
The original audit retained two categories of exceptions:
foundation/base/src/diagnostic.cpp—std::array<char, N>(×3) in the emergency diagnostic/assertion path. This is a hard boundary, not convenience:FoundationBasemust not depend onFoundationContainers(the containers module depends on Base; usingStaticArraythere would be a circular module dependency), and the emergency path must stay dependency-free and must not invokeLUDUS_ASSERT(whichStaticArray::operator[]uses). It isstd::array(fixed-size, no allocation), notstd::vector, and it is correct that it stays.containers/benchmarks/array_bench.cpp—std::vector/std::arraycomparison baselines. Their whole purpose is to benchmark against the STL, and the translation unit is not in the engine build graph.- The macOS Editor's private Crashpad ABI adapter, limited to upstream argument/result types under ADR 0003 (§34.3).
The later first-party std::vector uses found by the 2026-10-06 review were
migrated by M1. The tree again
contains only these exceptions, and tools/check_container_policy.py enforces
the std::vector exception list. It runs from ./scripts/check --format and
in CI. The checker reports any listed exception that no longer needs the type.
34.5 Array with move-only / non-trivial element types (verified)¶
The thread-pool migration exercised Array<std::thread>, a move-only,
non-trivially-destructible element type. Array handled it correctly with no
container change required: EnsureCapacity + AddInPlace(lambda) construct
threads in place, growth relocates via moves (never copies), and destruction
runs each element's destructor. This was confirmed both by the migrated
multithreaded tests passing under ASan/UBSan and by a standalone probe that
spawned and joined 28 threads through Array<std::thread>, alongside
Array<std::string> and Array<const char*>, with no leaks or UB. Because no
correctness deficiency was exposed, the container behaviour was not
redesigned (per the task's contract: change the container only if migration
reveals a real bug).