> Cinder 0.1.0 documentation: Mojo 1.2.0 notes for Cinder
> Canonical page: https://cinder.ash10.dev/docs/mojo-notes/ · Source: `docs/MOJO_NOTES.md` in the engine repository (engine commit 47c9352, imported 2026-11-09)
> Index of every page for agents: https://cinder.ash10.dev/llms.txt

# 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`.
