DocsEngine design

Mojo 1.2.0 notes for Cinder

Source of truth: https://mojolang.org/docs (manual and std reference for 1.2.0) and the release notes for 1.0.0, 1.1.0 and 1.2.0. Everything below was read from those pages on 2026-11-08 and confirmed by compiling spikes/hello/ under the pinned toolchain. When the compiler disagrees with this file, the compiler and the current docs win; update this file.

Forms we use #

Functions and conventions #

  • def is the only function keyword. fn was removed in 1.1.0.
  • Functions are non-raising by default. Add raises after the parameter list; typed errors are written def f() raises MyError -> Int:.
  • Argument conventions: default (immutable borrow, spelled imm when explicit), mut, var (take ownership, caller passes x^), ref (mutability follows the referenced value), out (uninitialised, must be initialised; used for self in __init__ and for named results), deinit (consumed; used for self in __deinit__).
  • read as a convention is a hard error since 1.1.0; use imm.
  • C ABI: def f(a: Int32) abi("C") -> Int32:. Function pointer type: def(Int32) thin abi("C") -> Int32. thin means a function pointer, not a closure. Never combine abi("C") with raises.
  • Closures with capture lists replace @parameter closures. @parameter is deprecated; @__parameter exists only for legacy nested closures.

Compile time #

  • comptime declares compile-time values: comptime pi = 3.14159. alias was removed in 1.1.0.
  • comptime if / comptime for replace @parameter if / @parameter for (removed in 1.1.0).
  • comptime match evaluates patterns at compile time (1.2.0).
  • Parameters: def repeat[count: Int](msg: String):, struct FixedBuffer[length: Int]:.
  • Constraints: trailing where <cond> else "<message>" after the signature (preferred form in 1.2.0).

Variables #

  • Every variable is declared with var. Implicit declaration is an error since 1.1.0. let does not exist.
  • := only assigns to existing variables.

Structs, traits, lifecycle #

  • struct Name(TraitA, TraitB):; conformance is always explicit.
  • @fieldwise_init generates the field-wise initialiser.
  • Structs are Movable by default and not copyable. Declare Copyable for a generated copy initialiser (__init__(out self, *, copy: Self)), ImplicitlyCopyable to allow implicit copies. Prefer x.copy().
  • Destructor: def __deinit__(deinit self):. __del__ still compiles but warns; we treat it as banned.
  • Values are destroyed ASAP after their last use.
  • C-layout structs: struct DivT(RegisterPassable): with C-compatible fields in C order; @align(n) for alignment. @register_passable as a decorator is not the current form.
  • @export marks a function for export from a built library. @inline(.always | .never | .nodebug | .automatic) is preferred over @always_inline / @no_inline.
  • Core traits used: Copyable, Movable, ImplicitlyCopyable, Deinitable, Defaultable, Writable, Stringable, Hashable, Equatable, Comparable, Boolable, Intable, Sized, Iterable, Iterator, EnumLike, Reflected, RegisterPassable, AnyType.

Strings and formatting #

  • String (mutable, UTF-8), StringSpan (immutable view; StringSlice is a deprecated alias), StringLiteral, CStringSpan (C strings; as_c_string_span()).
  • Formatting uses template strings: t"value={x}" (TString, lazy). String.format exists but we use t-strings.
  • MutStringSpan/MutStringSlice were removed in 1.2.0.
  • Iterating a String yields grapheme clusters; use .bytes() or .codepoints() explicitly.

Pointers and memory (std.memory) #

  • Pointer[T, origin] is the raw pointer. UnsafePointer is a deprecated alias (1.0.0). MutPointer, ImmPointer, OptionalPointer, OpaquePointer (= Pointer[NoneType]).
  • Every unsafe operation is prefixed: ptr[unsafe_offset=i], unsafe_load(), unsafe_store(), unsafe_offset(n), unsafe_bitcast[T](), unsafe_write(value^) / unsafe_write(copy=v), unsafe_take_pointee(), unsafe_deinit_pointee(), unsafe_free(). Unprefixed load/store/free are deprecated and banned here.
  • Pointer(to=value) borrows an existing value. Pointer[T, MutUntrackedOrigin](unsafe_from_address=addr).
  • Allocation: alloc[T]({count = n}) or alloc(Layout[T](count=n)) returns an Allocation; allocation.unsafe_ptr(), into_thin() for a field-sized ThinAllocation, dealloc(allocation^), unsafe_leak(). Layout takes alignment as a keyword-only Alignment (Alignment.of[T](), Alignment.of_bytes[n]()) since 1.2.0.
  • OwnedPointer (into_inner(), not take()), ArcPointer, MaybeUninit.
  • Raw memory helpers are the unsafe_-prefixed forms; memcpy, memset, memcmp were removed in 1.1.0.

Collections #

  • List[T] (capacity() is a method; resize(new_length=, fill=)), Array[T, length] (replaces InlineArray; .length), Span (Span(unsafe_ptr=, length=); ImmSpan, MutSpan), Dict, Set, Optional (iterable), Variant (unwrap[T]()), Tuple (Tuple[] is the empty tuple type).
  • A list literal defaults to Array, not List; annotate var l: List[Int] = [...] when a List is wanted.
  • Negative slice indices and out-of-range insert abort; always spell indices out.

SIMD and numerics #

  • SIMD[dtype, length]; Scalar[dtype]; Float32, Int32, etc. The parameter is length, not size.
  • SIMD.from_bytes()/as_bytes() take endian: Endian; compare against Endian.native().
  • std.simd holds SIMD (moved out of std.builtin in 1.1.0).

FFI (std.ffi) #

  • external_call["symbol", ReturnType](args...) for symbols linked into the binary; variadics need num_fixed_args=.
  • OwnedDLHandle(path) for runtime loading; get_function[ReturnType]("name"), check_symbol("name"). Argument types are not checked against C, which is why Cinder has layout and signature tests.
  • c_int, c_uint, c_char (target signedness since 1.2.0), c_uchar, c_short, c_long, c_long_long, c_size_t, c_ssize_t, c_float, c_double.
  • Large by-value struct returns are not directly callable; the C side takes an out-pointer. This is exactly the subject of spike 3.
  • mojo build has no -l/-L; use -Xlinker and MODULAR_MOJO_MAX_SYSTEM_LIBS, or OwnedDLHandle.

Testing (std.testing) #

  • Tests are module-level def test_*() raises: functions. Each test file ends with def main() raises: TestSuite.discover_tests[__functions_in_module()]().run() and is run with mojo run -I <src> <file>. There is no mojo test subcommand in 1.2.0.
  • Assertions: assert_true, assert_false, assert_equal, assert_not_equal, assert_almost_equal, assert_is, assert_is_not, assert_raises (context manager), assert_aborts module, testing.prop for property tests.
  • Filtering: --only, --skip, --skip-all; programmatic suite.skip[test](). Cinder never skips a test to make a run pass (section 10 of the mission).

GPU #

  • std.gpu is private; GPU compute lives in max.gpu. Cinder does not use it (ADR-0002). All GPU work is WGSL through wgpu-native.

Build #

  • mojo build --Werror -O3 -g1 -o out file.mojo; --sanitize address|thread is experimental; --emit for other outputs; -D KEY=VALUE read through std.defines.
  • .mojopkg is gone; precompiled packages are .mojoc.

Facts established by compiling under 1.2.0 (spikes/api_probe, 2026-11-08) #

  • ASAP destruction applies to OwnedDLHandle. The handle is destroyed, and dlclose runs, right after the handle's last syntactic use. A function pointer obtained from it then dangles and the next call segfaults with no diagnostic. This cost half a day. Rule: function pointers live only as fields of the struct that also owns the handle (cinder/ffi/* generates exactly that), they are never copied out into locals, and the owning struct is held by a long-lived engine object. In a main that uses a handle directly, reference it after the last call (print(Bool(lib))).
  • Untracked pointers do not keep their pointee alive. Once Pointer(to=x) is cast to MutUntrackedOrigin (which every raw C binding requires), the compiler no longer sees reads of x through it. For a trivially destructible struct (every C descriptor), ASAP destruction then lets the stack slot be reused after the last visible read, and _ = x is not a read. Symptom: C sees garbage (wgpu read backendType = 0x7F19 from a dead WGPURequestAdapterOptions; naga read a garbage string length). Rule: the safe layer owns descriptors in engine-lifetime storage, and any stack value handed to C by pointer is followed by std.benchmark.keep(value) after the last C call that can read it. Spikes 2 to 4 are written this way.
  • Pointer is non-nullable. Nullable C pointers are Optional[Pointer[T, origin]], and size_of[Optional[Pointer[...]]]() == 8, so they pass through abi("C") as plain pointers. The layout test asserts this size.
  • Optional[def(...) thin abi("C") -> R] is 16 bytes, not 8. A nullable C function pointer field is therefore bound as Optional[Pointer[NoneType, MutUntrackedOrigin]], never as an optional function type. Function-pointer parameters are bound as the non-optional thin function type.
  • A 96-byte struct (CnBig: Array[Float64, 8], Array[Int32, 4], UInt8, padding) passes by value into C correctly without RegisterPassable; returning it by value is verified in spike 3's test.
  • A Mojo def ... abi("C") function is callable from C on the calling thread and from a freshly created native thread (spike 4).
  • reflect[T].field_offset[index=i](), size_of, align_of work on these structs at compile time.
  • obj.field(args) calls a function pointer stored in a struct field correctly.
  • MutUntrackedOrigin / ImmUntrackedOrigin are prelude names, not std.memory exports.
  • An empty struct body is an error; opaque C types get one var _opaque: UInt8 field and are never instantiated.

Compiler limits met so far #

  • Large comptime Array[StaticString, N] literals exhaust the compiler (N ≈ 2,000 already fails under a 13 GB cap; N = 370 compiles but costs ~90 s per program). Reproducer and measurements: bugs/comptime_array_literal_oom/. Workaround in the generated bindings: one newline-joined StringLiteral, split at load time. Keep compile-time collections small; put bulk data in a single literal or in a file.

Legacy forms that are banned in this repository #

A CI grep (tools/lint_legacy_mojo.sh) fails the build on any of these in *.mojo files: fn (as a keyword), alias , inout, borrowed, owned , let , @parameter, __del__, InlineArray, StringSlice, UnsafePointer, MutUnsafePointer, ImmUnsafePointer, std.gpu, .load(/.store(/.free( on pointers, memcpy(, memset(, read as a convention, @parameter if, @parameter for, @register_passable, __comptime_assert, steal_data, .size on SIMD/Array.

Open questions to verify by compiling #

  • Whether abi("C") Mojo functions can be passed to C and invoked from a foreign thread (spike 4).
  • How @export interacts with mojo build --emit shared-lib (needed only if Mojo ever exports to C; the design keeps all callbacks in C, so this is not on the critical path).
  • The exact std.runtime / threading primitives available for the job system (Atomic[T], std.runtime.parallelism_level()); verified in Phase 2.

wgpu-native behaviour worth knowing (verified 2026-11-08) #

  • A validation error raised inside wgpuQueueSubmit (for example a texture-to-buffer copy whose bytesPerRow is not a multiple of 256) is fatal: wgpu-native panics and aborts the process instead of reporting through the uncaptured-error callback. Resource creation errors do go through the callback. Tests therefore keep copy strides 256-aligned and the engine's copy helpers take the stride explicitly so the caller can see it.
  • Line rasterisation follows the diamond-exit rule: a line placed exactly on a pixel boundary lands on one of the two rows depending on the backend. Pixel tests put lines through pixel centres.

More lifetime pitfalls seen in Phase 4 (verified 2026-11-08) #

  • Early returns skip the keep. A function that creates a NativeLibrary, calls lib.ptr()[]... and returns from one branch destroys lib (dlclose) right after .ptr() on that branch, so the C call jumps into unmapped memory (SIGSEGV with a ?? () frame under gdb). Structure such functions as if/else with one keep(lib) at the end, or keep on every path.
  • _uptr(list)-style helpers that return an untracked pointer end the list's lifetime at the call site. Every list whose pointer is passed to C must be fenced with keep(list) after the call (MeshData.build_lods fences optimised, current, dest and cache).
  • SIMD[DType.bool, 4](True) is rejected ("must be a scalar; use the fill keyword"); write SIMD[DType.bool, 4](fill=True), or avoid boolean vectors and reduce a float margin instead.
  • Float32.from_bits does not exist; use bitcast[DType.float32, 1](bits) from std.memory.
  • Struct fields are destroyed in declaration order. A struct holding both a NativeLibrary and objects whose destructors call into it must declare the library last (see the renderer test harness).

Pitfalls seen in Phase 6 (verified 2026-11-09) #

  • A method taking mut self cannot also take mut field_of_self (passing self.current to a mut layer: _Layer parameter is an exclusivity violation). Return the new value and assign it, or branch on which field to touch.
  • pose.blend(pose, other, t) with mut self and a: Pose aliasing the same object is rejected; write in-place variants (self = lerp(self, other, t)).
  • T: Copyable & Movable is flagged as a redundant composition: Copyable implies Movable.
  • Single-field enum-style structs (struct Kind: var value: UInt8) need @fieldwise_init for Kind(0) to resolve; a struct with an explicit __init__ must not also carry it.
  • Int(phase) truncates towards zero: counting loop wraps from an unwrapped phase in [0, inf) is Int(after) - Int(before), not a < comparison of wrapped values (0.25 -> 2.25 wraps to 0.25 and compares equal).
  • joltc: JPH_Init is idempotent; shape handles upcast to JPH_Shape* by bitcast; block-free query filters need C procs (native/src/physics_shim.c).
  • miniaudio: the sound-end callback fires on the processing step after the one that reached the end (the sound is already stopped inside it), so headless tests must read one more buffer before draining the queue.
  • A destructor that aborts on leaks hides the real failure when a test raises: count and assert in tests instead (live_objects() == 0).

Pitfalls seen in Phase 7 (verified 2026-11-09) #

  • Inside a parametric struct, the parameter must be written Self.T in field and method signatures (var _storage: Allocation[Self.T]).
  • unsafe_deinit_pointee() needs the pointee type bound to Deinitable (T: Deinitable & Movable).
  • A struct whose constructor stores pointers to its own fields breaks when the struct moves; box such subsystems (cinder/core/boxed.mojo) and keep raw pointers to the box.
  • String has inline small-string storage: a pointer from as_c_string_span() dies when the owning list reallocates. Hand C APIs List[UInt8] buffers (heap data stays put) when the string must outlive the call.
  • Passing a field of self as an immutable argument to a mut self method is an aliasing error (self._inspector(..., self.entities[i])); copy the value into a local first.
  • dcimgui float v[3] parameters are generated as Array[Float32, 3] by value and cannot be written back; use scalar widgets.
  • ImGui enum constants have their own types (ImGuiCond_); the bindings' parameters are Int32, so wrap them: Int32(ImGuiCond_FirstUseEver).
  • Every field must be initialised before any method call in __init__ (self.scene had to be set before _create_offscreen_target() logged through self).

Allocation facts measured in Phase 8 (verified 2026-11-09 with libcinder_alloccount) #

  • Mojo heap allocations go through KGEN_CompilerRT_AlignedAlloc in libKGENCompilerRTShared, served by the runtime's own TCMalloc over mmap: glibc malloc hooks never see them. Count them by interposing that entry point (LD_PRELOAD, dlsym(RTLD_NEXT)).
  • String("literal"), t-string formatting of short strings, small-String copies, Optional[Int] assignment and as_c_string_span() on short strings do not allocate; List growth does (and List[...] = [x] literals allocate every time).
  • List.clear() keeps capacity, so clear-and-refill scratch fields are allocation-free after warm-up; resize(n, fill) only allocates when growing.
  • A List moved out of a struct and back (var p = passes.pop(); ...; spares.append(p^)) does not allocate, which is how the frame graph recycles its pass records.
  • Array[T, N] locals are stack storage: use them for small fixed-size per-call data (cascade splits, frustum corners, render-pass attachments) instead of List.
  • Passing self.field mutably to a method of self is an exclusivity error; give the method the field's name and let it touch self.field directly, or keep the scratch in the caller.

Phase 9 (defect sweep) #

  • Stale compile cache. The compiler's module cache under .pixi/envs/default/share/max/cache occasionally served an earlier version of an edited module (seen after reverting and restoring a file, and once as "value has no attribute" for a method that was present in the source). tools/run_tests.sh deletes the cache before compiling; delete it by hand when a build result contradicts the source.
  • Self-aliasing with handles. self.method(self.handle_field) where method takes mut self is the same exclusivity error as with lists: copy the handle into a local first (handles are ImplicitlyCopyable).
  • String(from_utf8_lossy=...) / String(from_utf8=...) build Strings from byte spans; never rebuild text one byte at a time with chr(Int(byte)), which double-encodes multi-byte UTF-8. C strings go through cinder.core.cstring.string_from_c.
View as MarkdownSource: docs/MOJO_NOTES.md