The .ips language 0.7

Product 0.7.1 · source language 0.7 · C API 0.4 · C ABI 0.5

Download the handbook

The .ips language

Product: 0.7.1 · Source language: 0.7 · C API: 0.4 · C ABI: 0.5 · Status: development / release candidate · Source: 196ff75804d67e4771675ac92219b31f19b5a824

This is the complete authoring reference for the current compiler. .ips programs generate deterministic raster images and structured geometry. The compiler produces a self-contained .iprop file; hosts execute it with a seed and optional declared input uniforms.

Source language 0.7 is released with product 0.7.0. Product, source, C API and ABI versions are independent, and the binary format is unversioned: ipc --describe-language reports the compiler's contracts. C API 0.4 uses ABI 0.5. The generated book identifies its product, development/release status and source commit. Earlier documentation is available in Git history.

Read the chapters in order to learn the language, or use the function index in the generated book to look up a builtin. Each chapter links actual .ips files; their contents are included in the generated book and tested by CI.

  1. Your first asset
  2. Assets, inputs and outputs
  3. Source syntax and names
  4. Types, expressions and variables
  5. Control flow
  6. User functions and argument evaluation
  7. Randomness and seeds
  8. Numbers and interpolation
  9. Drawing and colors
  10. Vectors and distance fields
  11. Noise and procedural utilities
  12. Palettes
  13. Immutable lists
  14. Captured samplers
  15. Deterministic steppers
  16. Imports and part composition
  17. Captured layers, masks and clipping
  18. Writing idiomatic props
  19. Compiler tools and diagnostics
  20. Grammar
  21. Maintaining this documentation
  22. Migrating from 0.6 to 0.7

The format and VM specifications define binary encoding, exact numerical/raster algorithms, validation and deterministic execution. The authoring reference links those contracts where relevant. The recipe gallery contains 24 larger patterns combining the features described here.

Your first asset

Save first-asset.ips, then run the installed tools:

ipc first-asset.ips -o first-asset.iprop
ipval first-asset.iprop
ipgen first-asset.iprop --seed 7 -o first-asset.png

Expected result: pass.

asset FirstAsset {
  size 16x16
  draw {
    rect(0, 0, 16, 16, #123)
    rect(x = 4, y = 4, w = 8, h = 8, color = #9dc)
  }
}

asset names the program. size 16x16 declares its canvas. The required draw { ... } entry draws a background and a rectangle. Calls execute in written order, so later shapes paint over earlier ones. Coordinates use the asset's downward-y plane: x increases rightward, y downward.

Running the same executable with the same invocation reproduces the same pixels and structured outputs across conforming native and WASM hosts. Change a seed to vary random draws; change a declared input to select an authored variation. A program with no random operations ignores the seed.

For a repository build, configure with cmake -B build -DCMAKE_BUILD_TYPE=Release and run cmake --build build --parallel. Replace tool names with build/tools/ipc, build/tools/ipval and build/tools/ipgen (Windows adds .exe). A native SDK already contains those tools under bin/.

Assets, inputs and outputs

A source file contains imports followed by exactly one asset name { ... }. An asset requires size and one draw entry. It may declare an origin, inputs, outputs, palettes, constants and helpers. Metadata declarations are outside function bodies. size 16x16 and size 16 16 are accepted. Origin coordinates are constant numeric expressions; an omitted origin is (0, 0).

The host supplies a seed, optional target dimensions and ordinary declared input values. draw declares no source parameters or return type. There are no implicit variant/archetype controls: v and a are ordinary names and must be declared before use. Authors define any style or class selectors as input values and decide which selections their asset supports.

Expected result: pass.

asset UniformAsset { input i32 a = 0 input i32 v = 0
  size 16x16
  origin 8, 8
  input color tint = #9dc
  input f32 scale = 1.0
  input u32 dotCount = 2
  input u32 offset = 0
  output point anchor
  output rect bounds
  output circle area
  output list point dots
  draw {
    rect(0, 0, 16, 16, #123)
    rect(2 + offset, 2, 4 * scale + v, 4 + a, tint)
    anchor = {8, 8}
    bounds = {2, 2, 4 * scale + v, 4 + a}
    area = {8, 8, 3}
    for i in 0..dotCount { dots = {i, 8} }
  }
}

Asset constants

const type name = expression declares an immutable asset value with an explicit i32, f32, color, vec2, distribution, sampler<T>, stepper<T> or list<T> type. Constants are visible in draw and every helper of their defining asset. Imported parts resolve their own constants; a parent cannot access a child's constants. i32 initializers may widen to declared f32; other types must match. Integer arithmetic keeps its wrapping signed 32-bit rules.

Expected result: pass.

asset asset_constants {
  size 2x2
  output point checks
  output point normalized
  output point zero
  output point shaped
  output point beta_samples
  output point state

  const i32 Plain = 1
  const i32 exact = min(later, 16777218)
  const i32 later = 16777217
  const color iron = #929ca8
  const color shadow = color.shade(iron, -7)
  const vec2 direction = vec2.normalize({3, 4})
  const vec2 positive_zero = vec2.normalize({-0.0, -0.0})
  const distribution typical = gaussian(mean=0.5, sigma=spread)
  const f32 spread = 0.18
  const distribution tent = triangular(min=0.0, peak=0.25, max=1.0)
  const distribution skew = beta(alpha=2.0, beta=5.0)

  exact_value() -> i32 { return exact }
  heading() -> vec2 { return direction }
  material() -> color { return shadow }
  shape() -> distribution { return typical }

  draw {
    before = seed
    checks = {exact_value() == 16777217, Plain == 1 && before == seed}
    normalized = heading()
    zero = positive_zero
    pixel(0, 0, iron)
    pixel(1, 0, material())
    shaped = {0.0 to 1.0 with shape(), 0.0 to 1.0 with tent}
    beta_samples = {0 to 10 with skew, 0.0 to 1.0 with skew}
    state = {seed & 65535, seed >> 16}
  }
}

Initializers may reference other constants, including declarations later in the asset. The compiler evaluates the acyclic dependency graph once during compilation. Arithmetic, component/channel reads and deterministic builtins are allowed. ipc --describe-language marks each eligible overload with constant_evaluable; seeded noise is allowed with a constant explicit seed. Legacy palette indexing and ramp are also allowed, including the last-element fallback for an invalid palette index.

The current RNG state seed, input uniforms, part outputs, RNG draws and user helper or part calls are forbidden. This includes seeded selection with roll/choose and equal-bound random ranges. The compiler checks every declaration, including unused constants, and checks forbidden effects in both branches of &&/||. Pure evaluation still short circuits: false && (1 / 0 == 0) is a valid i32 constant, while false && index(1) is rejected.

Constant gaussian, triangular, beta, weighted and density constructors validate parameters and points during compilation. An invalid unused description is a source error; a dynamic constructor in draw retains its existing runtime trap. Copying or reading a constant distribution performs no draw. Each to/with still samples through the existing runtime kernel and stream.

Constant names cannot collide with asset declarations, imported aliases or helper parameters, or use the reserved controls/placement names. Constants cannot be assigned or used as loop counters. const is reserved; rename existing helpers, locals and inputs with that spelling. Use descriptive names.

Const samplers and steppers capture endpoints and distribution parameters without RNG. Const lists may contain descriptions and share their immutable data. Sampling palettes join this same dependency graph; reading a selected description does not draw, while .pick() cannot run in a const initializer. See sampling palettes.

Input defaults and legacy literal palette elements retain their literal-only grammar; declaring a constant does not expand those grammar rules. Scalar constants introduce no new opcode, type code, section, host enum or ABI layout. Scalar/vector values lower through scalar pools and instructions. Unused declarations add no executable instructions; used list constants share immutable collection data, including for folded scalar results.

Expected result: compile-error (cyclic const dependency).

asset cyclic_constants {
  size 1x1
  const i32 first = second + 1
  const i32 second = first + 1
  draw {}
}

Expected result: compile-error (RNG or generation effects).

asset random_constant {
  size 1x1
  const i32 disabled = false && index(1)
  draw {}
}

Expected result: compile-error (cannot read 'amount').

asset input_constant {
  size 1x1
  input i32 amount = 2
  const i32 fixed = amount
  draw {}
}

Expected result: compile-error (cannot read 'seed').

asset seed_constant {
  size 1x1
  const i32 fixed = seed
  draw {}
}

Expected result: compile-error (user helper and part calls).

asset helper_constant {
  size 1x1
  const i32 fixed = answer()
  answer() -> i32 { return 42 }
  draw {}
}

Expected result: compile-error (finite sigma > 0).

asset invalid_constant_distribution {
  size 1x1
  const distribution unused = gaussian(mean=0.5, sigma=0.0)
  draw {}
}

Expected result: compile-error (cannot assign to const).

asset immutable_constant {
  size 1x1
  const i32 fixed = 2
  draw { fixed = 3 }
}

Cycles use IPS-CONST-001, forbidden effects/dependencies use IPS-CONST-002, and failed compile-time value checks use IPS-CONST-003. Each diagnostic names the constant and locates the offending expression. Type mismatches retain IPS-TYPE-001 with expected and actual types. A rejected source returns exit code 1 and creates no compiled artifact.

Input uniforms

input type name = constant declares a host-settable, read-only uniform.

Input type Constant default Value inside draw
color #RGB, #RGBA, #RRGGBB, #RRGGBBAA color
f32 integer or floating literal, optionally negative f32
u32 nonnegative integer literal i32, retaining its 32-bit pattern
i32 integer in [-2147483648, 2147483647], optionally negative i32

input i32 range-checks the mathematical default before ordinary integer wrapping. Thus -2147483648 is valid and positive 0xffffffff is rejected. It uses the existing binary U32 uniform, preserving the signed two's-complement bits without a new binary input type. Root and imported-part defaults and part overrides have the same i32 semantics. The binary metadata cannot distinguish source i32 from u32; existing hosts pass U32 bit patterns. For example, a host U32 override of 4294967295 yields signed -1 inside draw (use that unsigned decimal spelling with ipgen --uniform).

Defaults permit a literal, parentheses and one numeric unary minus, such as input f32 amount = -(0.5). Arithmetic, names, calls and repeated negation are rejected. Floating defaults preserve negative zero (-0.0); an integer spelling used for an f32 default is converted directly to binary32, without first wrapping to an integer bit pattern. Root and part defaults use the same conversion. For a u32 bit pattern representing signed -1, use 0xffffffff; a negative u32 default suggests input i32. Boolean tokens are not integer input defaults. Uniforms are visible in draw; pass their values explicitly to helpers.

Expected result: pass.

asset SignedDefault {
  size 8x8
  input f32 amount = -1.0
  input f32 zero = -(0.0)
  output point value
  draw {
    value = {amount, zero}
    pixel(1, 1, #39f)
  }
}

Expected result: pass.

asset SignedInput {
  size 8x8
  input i32 amount = -7
  output point value
  draw {
    value = {amount, 0}
  }
}

A negative floating default is valid; a mathematically negative u32 default is invalid. Defaults outside the signed range and non-literal declaration expressions are also rejected:

Expected result: compile-error (negative default of input u32).

asset NegativeUnsigned {
  size 8x8
  input u32 offset = -1
  draw {}
}

Expected result: compile-error (must be in [-2147483648, 2147483647]).

asset SignedRange {
  size 8x8
  input i32 offset = 2147483648
  draw {}
}

Expected result: compile-error (declaration constants must be literals).

asset NonLiteralPalette {
  size 8x8
  palette values = [1 + 2]
  draw {}
}

Input names seed, at, rot, mirror_x and mirror_y are reserved. Inputs and outputs cannot share a name; declarations of the same kind must have unique names. Inputs cannot be assigned or used as loop counters.

Structured outputs

output kind name declares a scalar slot. Assignment replaces its current value; an unwritten slot remains unset. output list kind name appends each assignment in execution order. Outputs are written by draw or a helper belonging to the same asset, and returned to the host alongside the image. output mask name and output layer name instead declare internal resource exports. They accept shared immutable handles, cannot be lists, and never add host geometry slots.

Kind Assignment value
point {x, y}, a vec2
circle {x, y, radius}
rect {x, y, width, height}

Components must be numeric and widen to f32. Scalar part outputs can be read and propagated; list outputs cannot be read as expressions. Outputs are not ordinary readable local variables. See composition for placement and transformation of part-owned outputs.

The VM validates the resulting executable and bounds its resources. Generation is subject to the host's canvas limits and fuel budget; see execution limits.

Source syntax and names

Source must be valid UTF-8. Whitespace separates tokens; newlines do not end statements. // comments extend to the next newline and /* ... */ comments end at the first closing delimiter (they do not nest). Semicolons are optional separators inside function blocks. One optional semicolon may follow each import, size, origin, input, output or palette or const declaration. Forms without semicolons remain valid; newline is whitespace and never implicitly terminates a declaration or expression.

Ordinary identifiers match [A-Za-z_][A-Za-z0-9_]* and are case-sensitive. Control/declaration keywords (asset, input, output, import, from, if, else, while, for, in, break, continue, return, true, false) and const, to, with, distribution, sampler, stepper, weighted, density, layer, mask and clip are reserved. draw, at, scalar type names and geometry kind names and list are soft keywords accepted in name positions; draw still selects the entry block when used as an asset-level function name. Reserved words cannot name assets, inputs, outputs, locals, helpers, parameters, aliases or members. Newly introduced keywords have no contextual compatibility aliases. Asset names and for counter names require an ordinary identifier. Asset-level size, origin and palette introduce metadata. const type name = expression introduces an asset constant only in declaration position. The reserved word cannot be used as a name elsewhere.

Use descriptive names distinct from builtin functions and the namespaces color, vec2, dist, noise, cell, surface, mask and list.len. Builtin call resolution takes precedence over user helpers with the same name.

Literals

  • Integers: decimal digits or 0x/0X plus hexadecimal digits. They become 32-bit patterns, so 0xffffffff is the expression value -1. Use ordinary decimal without leading zeroes; literal parsing also recognizes leading-zero octal forms. Values outside the literal parser's range are rejected.
  • Floats: digits with a fractional part and/or exponent, such as 2.5, 1e3, 4.0e-2. The fractional part requires digits on both sides of the dot; write 0.5, not .5. Evaluation uses IEEE-754 binary32.
  • Colors: #RGB and #RGBA repeat each digit; #abc is #aabbcc. Long forms are #RRGGBB and #RRGGBBAA. Missing alpha is 255.
  • true and false are i32 values 1 and 0.
  • Strings are quoted import paths, with escapes \n, \t, \r, \" and \\. Strings are not runtime values.

There are no source literals for infinity or NaN; floating operations can produce them. Numeric minus is an operator. Input defaults and palette elements admit a small constant grammar: a literal, parentheses and one numeric unary minus. They do not admit arithmetic, calls or RNG. Ordinary runtime expressions retain their usual operator grammar and wrapping integer semantics.

Expected result: pass.

// Comments can contain arbitrary valid UTF-8: współrzędne.
asset SourceSyntax {
  size 8x8;
  origin 0, 0;


  input i32 offset = -(0);
  output point anchor;
  palette colors = [#abc, #1234, #abcdef, #abcdef80,];
  draw {
    /* A semicolon separates statements; a newline is whitespace. */
    x = 0x1; y = 1e0
    point = {x, y,}
    pixel(int(point.x), int(point.y), colors[0],)
    value = -3 + int(2.5)
    if (true && !false) { pixel(value + 2, 2, #fff) }
    anchor = {offset, 0}
  }
}

Trailing commas are accepted in call arguments, helper parameters, palette elements, list literals and compound literals. Syntax is ASCII: Unicode text may appear only in comments and string literals.

Types, expressions and variables

Value types and conversions

Type Meaning Can be stored, passed and returned?
i32 signed interpretation of a 32-bit integer pattern yes
f32 IEEE-754 binary32 yes
color RGBA8 channels, read as i32 through .r, .g, .b, .a yes
vec2 two f32 components yes
distribution a distribution family and its parameters yes
list<T> zero to 256 immutable values of a current element type yes
sampler<T> captured value/range for explicit picks; T is i32, f32, color or vec2 yes
stepper<T> deterministic range steps; T is i32, f32, color or vec2 yes
layer immutable captured RGBA8 surface yes; no host input, const or collection
mask immutable coverage view or A8 result yes; same surface restrictions
circle, rect three/four numeric components for outputs output writes only
void absence of a result call statements only

u32 is an input declaration type, not a local/helper type. A point is represented by vec2; there is no separate storable point type.

Numeric arithmetic mixes i32 and f32 by widening the integer to f32. Builtin/helper arguments and return values also permit this widening. Local reassignment requires an exact type match. Use int(x) or float(x) when an explicit conversion is needed; no implicit float-to-integer conversion is performed. Integer-to-float conversion can lose precision.

Variables and scope

The first assignment to a fresh name declares a local and fixes its type. distribution name = value also states the type and requires a distribution value. list<T> name = value supplies the element context, including for an empty literal. See collections. sampler<T> name = value declares a sampling description. stepper<T> name = value declares a deterministic step description. layer name { ... } captures a surface and binds the name after the block. layer copy = image explicitly types a handle copy. A layer handle whose capture did not execute is unset and traps when painted. Sampler, stepper and list locals whose initializer does not run contain unset handles; using them traps, including a sampler or stepper's empty batch pick. Locals belong to the whole function, including those first assigned inside a branch or loop; braces do not introduce shadowing scopes. Source is checked in traversal order. Initialize a local before branching if it is needed later: the language does not perform definite-assignment analysis, and untouched runtime local slots have the default value of their type.

Helpers have their own locals. Parameters are read-only. draw also has read-only inputs; helpers receive their values through parameters. seed reads the current RNG state in either kind of function and cannot be assigned. Palettes are asset-level constants visible to draw and helpers. Typed asset constants are also visible in both contexts. Their initializer type must match the declaration, allowing only the ordinary i32-to-f32 widening; later assignment or shadowing is rejected.

Expected result: pass.

asset ValueTypes {
  size 8x8
  draw {
    count = 2
    amount = float(count)
    count = int(amount + 0.5)
    selected = 0
    if (count > 1) { selected = 1 }
    p = {1, 2}
    q = -p + {4, 4}
    q = q * {1, 2} / {1, 2}
    q = 2 * q / 2
    bitMask = ((1 << count) | 1) ^ 2
    bitMask = (bitMask & 7) >> 1
    if (bitMask < (1 << 4) && selected) {
      pixel(int(q.x), int(q.y), #9dc)
    }
    notMask = ~bitMask
    remainder = count % 2
  }
}

Operators

Binary operators associate leftward. The precedence below goes from lowest to highest; parentheses explicitly select grouping.

Precedence Operators Operands
1 || i32, short-circuit
2 && i32, short-circuit
3–5 |, ^, & i32
6 ==, != numeric
7 <, <=, >, >= numeric
8 to (random range, not chainable) numbers, two colors or two vec2 values; see random ranges
9 <<, >> i32
10 +, - numeric or vector/vector
11 *, /, % numeric; % only i32; vector forms below
12 unary -, !, ~ numeric/vector minus; i32 logical/bitwise not
13 call, member access, palette index postfix expressions

Shifts bind more tightly than comparisons on either side: value < 1 << bits means value < (1 << bits), while value << bits < limit means (value << bits) < limit. Comparison chains are left-associative: a < b < c compares the normalized 0/1 result of a < b with c. Write a < b && b < c when both comparisons must hold. The grammar records these rules.

Comparisons and logical operations return normalized i32 0/1. && and || skip the right operand when its value cannot affect the result, including its RNG effects. Other binary operands evaluate left to right. Integer arithmetic wraps modulo 2³². / and % use signed integer semantics when both operands are integers; division by zero traps. Shift counts are masked to five bits, and >> is logical (zero-filling). Floating division uses IEEE-754 results, including NaN/infinity. Signed division of -2147483648 by -1 also traps for overflow; signed remainder for that pair is zero. These checks are distinct from the ordinary wrapping behavior of integer addition, subtraction and multiplication.

Vectors use {x, y}, expose .x and .y, support componentwise +, -, *, / with another vector, vector *// scalar, scalar * vector and unary minus. Scalar/vector +, -, /, vector comparisons and vector % are rejected. Components are values rather than assignable fields.

Colors expose .r, .g, .b and .a as i32 values in 0–255 on any color expression: a local, input, palette element, helper result or color builtin. Reading a channel evaluates its base once and draws no randomness itself. Channels are not assignable either, and there is no implicit conversion between a color and a number; build a new color with rgb()/rgba() and use float(c.a) / 255.0 for 0–1 arithmetic. See color channels.

Expected result: compile-error (cannot assign f32).

asset InvalidLocal {
  size 8x8
  draw { x = 1; x = 1.5 }
}

This example is intentionally rejected: a local fixed as i32 cannot be reassigned an f32. CI checks the expected diagnostic.

Control flow

if (condition) { ... }, optional else if branches and an optional else select blocks. while (condition) { ... } re-evaluates its condition before each iteration. Conditions must be i32: zero is false and any nonzero value is true. Compare a float or explicitly convert it instead of using it directly as a condition.

for i in start..end { ... } iterates over a half-open integer range. The start is evaluated, assigned to the counter, then the end is evaluated once and retained. The counter must be an i32 local; a fresh counter is declared and remains visible after the loop. An existing i32 local is reused. The loop tests i < end, runs the body and increments i. Start ≥ end produces no iterations. Assigning to the counter inside the body is allowed and changes subsequent iteration behavior.

break exits the innermost loop in the current function. continue moves to its next iteration; in a for loop it still executes the increment. Neither statement can target a caller's loop. Inputs, outputs, palettes, parameters and reserved names cannot serve as counters.

return value returns from a typed helper; return; returns from a void helper or draw. Since newlines are whitespace, use the semicolon when a bare return precedes another statement; before } a bare return is sufficient. A value-returning helper must return on every reachable path, as required by the bytecode validator.

When these statements leave a layer or clip block, exited scopes close in reverse entry order. Layers retain their partial image; target, mode and clip coverage are restored before the caller or loop resumes. Caller scopes remain active during helper returns. See surface scopes.

Expected result: pass.

asset Loops {
  size 8x8
  draw {
    rect(0, 0, 8, 8, #123)
    for i in 0..8 {
      if (i == 1) { continue }
      if (i >= 6) { break }
      pixel(i, 2, #9dc)
    }
    j = 0
    while (j < 3) {
      pixel(j, 4, #fc9)
      j = j + 1
    }
    if (j == 0) { pixel(0, 0, #f00) }
    else if (j == 3) { pixel(3, 3, #0f0) }
    else { pixel(0, 0, #00f) }
    return;
  }
}

Expected result: compile-error (condition must be an integer).

asset InvalidCondition {
  size 8x8
  draw { if (0.5) { pixel(1, 1, #fff) } }
}

The second example deliberately fails the integer condition check. A nonterminating loop instead compiles and exhausts runtime fuel:

Expected result: trap (fuel exhausted).

asset FuelTrap {
  size 1x1
  draw { while (1) { } }
}

See execution limits for host control of fuel.

User functions and argument evaluation

A helper is an asset-level name(parameter: type, ...) { ... } declaration. All parameter types are explicit and use the storable types, including scalars, lists, sampler/stepper descriptions and surface handles. A value-returning helper declares -> type; omission means a void helper. Arguments may be positional, named by the declared parameter, or mixed. Every parameter is required, parameter names are unique, and i32 may widen to f32. For example, paint(tint=#9dc, x=8) binds to paint(x: i32, tint: color) { pixel(x, 0, tint); }. Forward calls are allowed. Direct and indirect recursion are rejected.

Pass uniforms to helpers explicitly. Helpers share the executing asset's RNG stream and may draw or write its declared output slots. A helper cannot access a caller's local variables or mutate its own parameters. Asset constants are directly visible to helpers; helper parameters cannot shadow their names. Const initializers cannot call helpers. Calling a value-returning function as a statement discards its result.

Expected result: pass.

asset Helpers {
  size 8x8
  input color tint = #9dc
  output point anchor
  draw {
    p = shifted(amount = 2, p = {1, 1})
    paint(tint = tint, p = p)
    amount = twice(2)
  }
  shifted(p: vec2, amount: f32) -> vec2 { return p + {amount, amount} }
  paint(p: vec2, tint: color) {
    pixel(int(p.x), int(p.y), tint)
    anchor = p
    return;
  }
  twice(n: i32) -> f32 { return n * 2 }
}

Argument binding and evaluation

Builtin, canvas and helper calls accept positional and named arguments, including a mixture. Positional arguments fill signature slots from the start; named arguments bind the indicated slot. Names do not advance the positional counter. Duplicate, missing and unknown arguments are rejected. Part placement (at) is required and named-only. Transform and uniform overrides are optional and named-only; omitted uniforms keep their declared typed defaults. Variadic builtin names are derived from the selected arity (x0/y0 for polygon vertices and value0, ... for choices).

Ordinary builtin arguments evaluate once, in signature order, independent of written name order. rect(color = #fff, h = 2, x = 1, w = 2, y = 1) has the same argument evaluation order as rect(1, 1, 2, 2, #fff). poly evaluates coordinate pairs before its color, although its positional color argument comes first. Helpers evaluate every argument once in declaration order, including named calls: measure(y=unit(), x=unit()) evaluates x before y when declared as measure(x: f32, y: f32). Positional calls retain their left-to-right order. Part calls have their own order.

pick/choose evaluate all choices eagerly; only logical &&/|| provide short-circuit evaluation. RNG effects remain even when a selected result is discarded. roll/choose isolate their stream after argument evaluation.

The compiler export supplies each signature's evaluation_order and intrinsic effects from the same registry used for binding and lowering. An intrinsic summary excludes argument expressions: even a pure outer call may draw, advance the stream or trap while evaluating a helper argument. Helpers have a conservative callee summary because their bodies may draw, write outputs or trap; it is not a promise that every helper has those effects.

The following checks named helper order and eager seeded choices against a replayed stream. Its guards exhaust fuel only if the observed order/state differs. Helpers also demonstrate scalar replacement and list appends:

Expected result: pass.

asset CallEffects {
  size 8x8
  output point helper_order
  output point selected
  output point last
  output list point writes
  draw {
    saved = seed
    stream_push(saved)
    first = unit()
    second = unit()
    after = seed
    stream_pop()

    stream_push(saved)
    observed = pair(y = unit(), x = unit())
    // Named arguments still run in x, y declaration order.
    if (observed.x != first || observed.y != second || seed != after) {
      while (true) {}
    }
    stream_pop()
    helper_order = observed

    stream_push(saved)
    choice = choose(value1 = unit(), seed = 123, value0 = unit())
    // Both choices run eagerly; isolated selection restores this parent state.
    if (seed != after || (choice != first && choice != second)) {
      while (true) {}
    }
    stream_pop()
    selected = {choice, 0}
    record(p = {1, 1})
    record({2, 2})
    pixel(1, 1, #9dc)
  }
  pair(x: f32, y: f32) -> vec2 { return {x, y} }
  record(p: vec2) { last = p; writes = p }
}

Expected result: compile-error (has no argument 'colour').

asset InvalidArgument {
  size 8x8
  draw { pixel(x = 1, y = 1, colour = #fff) }
}

Expected result: compile-error (only visible inside 'draw').

asset InvalidUniform {
  size 8x8
  input color tint = #9dc
  draw { paint() }
  paint() { pixel(1, 1, tint) }
}

Expected result: compile-error (recursive call).

asset InvalidRecursion {
  size 8x8
  draw { recurse() }
  recurse() { recurse() }
}

These rejected examples demonstrate unknown parameter names, direct uniform access from a helper and recursion respectively.

Binding the same slot positionally and by name is also rejected. Duplicate helper parameter names are rejected even when the helper is never called:

Expected result: compile-error (duplicate 'x' argument).

asset DuplicateArgument {
  size 8x8
  draw { pixel(1, x = 2, color = #fff) }
}

Expected result: compile-error (duplicate parameter 'x').

asset DuplicateParameter {
  size 8x8
  draw {}
  unused(x: i32, x: f32) {}
}

Randomness and seeds

Random operations use the current deterministic 32-bit stream. seed reads its current state, so saving saved = seed before drawing preserves an explicit seed value. Changing the number or order of stream draws changes later results. Hash/noise operations with explicit seeds do not advance it. They may still evaluate effectful arguments first. The shared compiler export distinguishes read_current, advance_current, seeded computation, isolated streams and stream push/restore; these summaries describe the operation itself, separately from argument effects and execution-wide limits.

Expected result: pass.

asset RandomChoices {
  size 8x8
  draw {
    saved = seed
    offset = -2 to 2
    width = 1.5 to 2.5
    u = unit()
    x = int(range(0, 8))
    y = index(8)
    indexAlias = pick(8)
    c = pick(value1 = #fc9, value0 = #9dc)
    if (chance(0.75)) { pixel(x, y, c) }
    isolated = roll(hash(saved), 0, 8)
    nested = choose(saved, choose(saved ^ 1, 1, 2), 3)
    stream_push(saved ^ 2)
    pixel(int(isolated), nested, #fff)
    rect(4 + offset, 0, width, 1, #fc9)
    stream_pop()
  }
}

Random ranges with to

lower to upper draws a value from the current stream every time it is evaluated; it is not a list or a stored range. Both bounds are included:

Bounds Result Draw
i32 and i32 i32 every integer in the range equally often
any f32 bound f32 uniform between the bounds; an i32 bound widens
color and color color one position on the RGBA gradient (gradients)
vec2 and vec2 vec2 one point of the segment (segments)
offset = -5 to 5          // one of -5, -4, ..., 5
thickness = 1.6 to 2.0
mixed = 1 to 2.0          // f32

The lower bound is evaluated once, then the upper bound, then the value is drawn; RNG used inside a bound stays part of the current stream. Equal bounds return that value and still consume a draw. A lower bound greater than the upper one traps, as does a NaN or infinite f32 bound; bounds are never swapped. Integer draws discard the few raw values that would favour some results, so every integer of the range is equally likely for any width, and the full i32 range is allowed. Redraws cost 1 fuel each; the exact algorithms are in random ranges. Floating results never leave the bounds, even after rounding.

to binds more loosely than arithmetic and shifts and more tightly than comparisons, so base - 2 to base + 2 draws around base, and (0 to 100) < 10 compares a draw. Write c.r + (-5 to 5) to add a draw to an expression. Ranges cannot be chained: a to b to c is rejected; use (1 to 3) to (5 to 8) to draw the bounds themselves. The formatter writes every range in parentheses.

to stays a valid name. Where a statement may end, a following to = ..., to.draw(...) or a call of a declared helper to(...) starts the next statement, as before; inside parentheses or arguments to is always the operator. range() and index() keep their own algorithms and results, so existing generators keep their draws; switching them to to changes the stream.

Gradients and segments

Between two colors or two vec2 values, to draws one position t from 0 to 1 and moves every channel or component by it, so they never vary independently. #000000 to #ffffff gives only grays, and {0.0, 0.0} to {1.0, 1.0} only points with equal coordinates; write {0.0 to 1.0, 0.0 to 1.0} for two independent draws.

metal = #705845ff to #c8c8c8ff
position = {12.0, 48.0} to {40.0, 56.0}
distribution middle = gaussian(mean=0.5, sigma=0.15)
metal = #705845ff to #e8e8e8ff with middle      // mostly mid-gradient

With with, the distribution describes t on 0 to 1, as in 0.0 to 1.0 with d, so use a description in those units. Swapping the ends reverses the direction a skewed distribution favours. One stream step is drawn even for equal ends.

Colors mix their stored RGBA8 channels with the color.mix formula, alpha included (unlike color.mix, which keeps the first alpha); there is no HSV or linear-light conversion. Channels truncate as in color.mix: every byte from a channel's smaller end up to one below its larger end is equally likely, and the larger end itself appears only at the exact end of a distribution's range. Vectors interpolate each component in f32 and stay on the segment, even for opposite large ends; an infinite or NaN end traps.

Expected result: compile-error ('to' cannot be chained).

asset ChainedRange {
  size 8x8
  draw {
    size = 1 to 3 to 5
  }
}

This example is intentionally rejected: write (a to b) to c to draw a bound.

Expected result: trap (lower bound exceeds the upper bound).

asset ReversedRange {
  size 1x1
  input i32 smallest = 4
  draw { count = smallest to 3 }
}

This program is valid source, but its range is reversed for the default input and traps during generation.

Distributions

lower to upper with d draws from the distribution d restricted to the range: values outside [lower, upper] are never produced, and the shape inside the range is kept, so nothing piles up at the bounds the way a clamped draw would. Without with a range is uniform.

distribution softWear = gaussian(mean=0.0, sigma=1.5)
redOffset = -5 to 5 with softWear          // mostly small offsets
distribution mostlyClean = beta(alpha=2.0, beta=5.0)
rust = 0.0 to 1.0 with mostlyClean          // mostly below 0.3

A distribution is a value: store it, copy it, pass it to a helper and return it from one. distribution d = ... declares it explicitly; a plain first assignment infers the type as usual. Its parameters are evaluated once, when the constructor runs, and may come from inputs, earlier computations or draws; changing those variables later does not change the description. Constructing a distribution draws nothing; every with draw takes its own value from the current stream, so two ranges using one description are two independent draws. Distributions support no arithmetic, comparison, members or conversion, and are not inputs, outputs or palette elements.

Parameters describe the result's own units; they are not rescaled to the range. -5 to 5 with gaussian(mean=0.0, sigma=2.0) clusters around 0, while the same description with 20 to 30 samples its far tail. A range that leaves no probability under the distribution traps, as does an equal range outside its natural range. For an i32 range the continuous draw rounds to the nearest integer, halves away from zero; both bounds stay possible, with the probability of their half intervals.

Evaluation order is the lower bound, the upper bound, the distribution, then one stream step. Parameters are checked when the constructor runs: invalid values trap there, even when the description is never used, and an unassigned distribution local traps when drawn. Draws cost 1 fuel plus 1 per iteration of the pinned numerical kernels, typically 20-150; the specification gives the exact algorithms. A helper named gaussian, triangular or beta takes precedence over the constructor of the same name.

Expected result: pass.

asset Distributions { input i32 v = 0
  size 16x8
  input f32 spread = 1.5
  output list point wear
  shaped(level: f32) -> distribution {
    return gaussian(mean=level, sigma=0.5)
  }
  jitter(d: distribution) -> i32 {
    return -3 to 3 with d
  }
  draw {
    distribution softWear = gaussian(mean=0.0, sigma=spread)
    edge = triangular(min=0.0, peak=0.0, max=4.0)
    rust = beta(alpha=2.0, beta=5.0)
    blades = beta(alpha=2.0, beta=5.0, min=1.6, max=2.0)
    for i in 0..4 {
      damage = 0.0 to 4.0 with edge
      wear = {float(-5 to 5 with softWear), damage}
      pixel(i, int(7.0 * (0.0 to 1.0 with rust)), #b7410e)
    }
    width = 1.6 to 2.0 with blades
    rect(8, 0, width, 8, #c8c8c8)
    pixel(12 + jitter(shaped(float(v))), 4, #fff)
  }
}

Expected result: compile-error ('with' needs a distribution, got f32).

asset WithNumber {
  size 8x8
  draw {
    offset = -5 to 5 with 2.0
  }
}

This example is intentionally rejected: with needs a distribution.

Expected result: trap (gaussian needs a finite mean and a finite sigma > 0).

asset NegativeSigma {
  size 1x1
  input f32 spread = -1.0
  draw { d = gaussian(mean=0.0, sigma=spread) }
}

This program is valid source, but its sigma is negative for the default input and the constructor traps during generation.

Weighted point distributions

weighted { key: weight, ... } constructs a distribution from 1–256 numeric atoms. Keys keep their original i32/f32 types, increase strictly and are finite. Weights are finite and nonnegative, with positive total mass. Pairs evaluate key then weight, once in written order, and snapshot without an intrinsic draw. Use lower to upper with d, or capture it in a sampler; color/vec2 keys denote positions on 0–1. Copies, helpers and lists share the immutable snapshot.

distribution finish = weighted { 1: 50, 2: 20, 3: 20, 4: 10 }
style = 1 to 4 with finish

An i32 key remains exact above 2^24; mixed key types convert exactly to binary64 for comparisons, without first widening integer keys to f32. The weights are relative masses, so 50/20/20/10 and 75/20/5 describe proportions. Zero weights cannot be selected. The finite RNG grid does not promise exact counts for any particular set of seeds. F32 bounds give f32 results; i32 results round the selected atom to the nearest integer, halves away from zero.

Bounds and the initialized description are checked before one stream step. The draw then filters keys to the inclusive bounds, sums their weights in binary64 in source order, and selects the first cumulative mass strictly above its target. A tie belongs to the next positive interval. Missing bounded mass traps after the step, including equal bounds without a positive atom there. There are no redraws. Construction pays base instruction fuel plus one per point; sampling pays its base draw plus two per point for the two full scans. Buffers share the generation's 64 MiB data quota and are charged once.

Asset const distribution and const samplers may use pure weighted literals, including forward numeric const references. Invalid static descriptions reject before an artifact is written; invalid runtime operands trap at construction, even if unused. Only weighted followed by { starts the literal; a helper weighted(...) and locals named weighted keep their existing meaning. See the format and numerical contract.

Expected result: pass.

asset weighted_materials {
  size 32x24
  output point material
  output point layers
  output point state

  const i32 Plain = 1
  const i32 Mild = 2
  const i32 Bold = 3
  const i32 Skin = 4
  const distribution finishes = weighted { Plain: 50, Mild: 20, Bold: 20, Skin: 10 }
  const distribution rust = weighted { 1: 75, 2: 20, 3: 5 }
  const sampler<i32> finish = sampler(Plain to Skin with finishes)
  palette tones = [#8c969f, #8e9c70, #ad7750, #524640]

  draw {
    style = finish.pick()
    count = 1 to 3 with rust
    rect(2, 2, 28, 20, tones[style - 1])
    for i in 0..count { rect(4 + i * 8, 6, 4, 12, #342a22) }
    material = {style, style}
    layers = {count, count}
    state = {seed & 65535, seed >> 16}
  }
}

Expected result: compile-error (strictly increasing).

asset invalid_weighted_order {
  size 1x1
  const distribution shape = weighted { 2: 1, 1: 2 }
  draw { }
}

Expected result: compile-error (nonnegative).

asset invalid_weighted_mass {
  size 1x1
  draw { shape = weighted { 1: -1, 2: 2 } }
}

Expected result: compile-error (positive total mass).

asset invalid_weighted_mass {
  size 1x1
  const distribution shape = weighted { 1: 0 }
  draw { }
}

Expected result: trap (no probability).

asset weighted_without_bounded_mass {
  size 1x1
  output point value
  draw {
    shape = weighted { 1: 1, 2: 0 }
    value = {2 to 2 with shape, 0}
  }
}

Expected result: trap (nonnegative).

asset invalid_runtime_weight { input i32 v = 0
  size 1x1
  draw { unused = weighted { 1: v - 1, 2: 2 } }
}

Density point distributions

density { key: height, ... } constructs a distribution from 2–256 strictly increasing finite numeric keys and finite nonnegative heights. Heights describe a continuous, piecewise linear density, with positive total trapezoidal area; they are not atom weights. Density is zero outside the first/last key. Numeric pairs evaluate once in written order and snapshot their original i32/f32 types without an intrinsic draw. Consts, copies, lists, helpers and sampler captures use the same immutable point buffers as weighted descriptions.

distribution heightShape = density { 14.0: 0.0, 18.0: 1.0, 22.0: 0.6, 26.0: 0.0 }
height = 14.0 to 26.0 with heightShape

A bounded draw clips every intersected segment, interpolates its boundary heights and computes its trapezoidal area. One stream step selects a positive area interval, then a stable binary64 inverse integral gives the position. Zero-area segments are skipped. This preserves the conditional shape without redraws or a pile-up caused by clamping rejected samples to the range ends. An interval of positive length requires positive bounded area; otherwise it traps after its stream step. Equal bounds return that point after one step if it lies in the closed natural support, even at zero-height endpoints or gaps; equal bounds outside it trap. I32 draws round halves away from zero, while f32 and color/vec2 coefficients round once to binary32.

Construction pays base instruction fuel plus one per point; an ordinary draw adds two per original segment for clipping/selection, reserved before work. Equal-bound support checks use no segment scan. The shared 64 MiB quota counts each retained point buffer once. Invalid static data reject before artifacts; invalid runtime snapshots trap when constructed, even unused. density(...) helpers and locals named density keep their meaning; the literal starts only before {. Existing family and weighted kernels stay frozen. See the pinned density contract.

Expected result: pass.

asset density_columns {
  size 32x32
  output point material
  output point heights
  output point state

  const distribution finishes = weighted { 1: 50, 2: 20, 3: 20, 4: 10 }
  const distribution profile = density { 14.0: 0.0, 18.0: 1.0, 22.0: 0.6, 26.0: 0.0 }
  const sampler<f32> columnHeight = sampler(14.0 to 26.0 with profile)
  palette tones = [#8c969f, #8e9c70, #ad7750, #524640]

  pickHeights(spec: sampler<f32>) -> list<f32> { return spec.pick(3) }

  draw {
    style = 1 to 4 with finishes
    values = pickHeights(columnHeight)
    tint = tones[style - 1]
    for i in 0..3 {
      rect(2 + i * 10, 28.0 - values[i], 8, values[i], tint)
    }
    material = {style, style}
    heights = {values[0], values[2]}
    state = {seed & 65535, seed >> 16}
  }
}

Expected result: compile-error (between 2 and 256).

asset invalid_density_size {
  size 1x1
  const distribution shape = density { 0: 1 }
  draw { }
}

Expected result: compile-error (positive total area).

asset invalid_density_area {
  size 1x1
  const distribution shape = density { 0: 0, 1: 0 }
  draw { }
}

Expected result: compile-error (strictly increasing).

asset invalid_density_order {
  size 1x1
  draw { unused = density { 1: 1, 0: 1 } }
}

Expected result: compile-error (nonnegative).

asset invalid_density_height {
  size 1x1
  draw { unused = density { 0: -1, 1: 2 } }
}

Expected result: trap (no probability).

asset density_without_bounded_area {
  size 1x1
  output point value
  draw {
    shape = density { 0: 0, 1: 0, 2: 1 }
    value = {0.0 to 1.0 with shape, 0}
  }
}

Expected result: trap (outside the distribution).

asset density_outside_support {
  size 1x1
  output point value
  draw {
    shape = density { 0: 0, 1: 1 }
    value = {2.0 to 2.0 with shape, 0}
  }
}

Expected result: trap (nonnegative).

asset invalid_runtime_density { input i32 v = 0
  size 1x1
  draw { unused = density { 0: v - 1, 1: 2 } }
}

gaussian

Signature VM instruction
gaussian(mean: f32, sigma: f32) -> distribution distribution.gaussian

The normal distribution with centre mean and standard deviation sigma; sigma must be positive and both finite. Its natural range is unbounded, so any range with probability can use it.

triangular

Signature VM instruction
triangular(min: f32, peak: f32, max: f32) -> distribution distribution.triangular

A triangle from min to max whose most frequent values lie at peak. Requires finite min < max and min <= peak <= max; a peak at an end makes small (or large) values most common.

beta

Signature VM instruction
beta(alpha: f32, beta: f32) -> distribution compiler lowering
beta(alpha: f32, beta: f32, min: f32, max: f32) -> distribution distribution.beta

The beta distribution with shape parameters alpha and beta, both positive and finite, on the natural range min to max (0 to 1 when omitted; min < max). alpha < beta favours the low end, alpha > beta the high end, equal values above 1 the middle, and values below 1 both ends.

unit

Signature VM instruction
unit() -> f32 rng.unit

Consumes one stream draw and returns an f32 in [0, 1). The frozen stream advance and conversion are specified in RNG.

range

Signature VM instruction
range(a: f32, b: f32) -> f32 rng.range

Consumes one draw and computes a + (b - a) * unit() with pinned binary32 steps. Bounds are not reordered. Ordinary ordered bounds describe a uniform range; floating rounding follows the exact arithmetic contract.

index

Signature VM instruction
index(n: i32) -> i32 rng.index

Consumes one draw and selects using the unsigned interpretation of n. Zero returns zero; a negative i32 is a large unsigned bound, not an error. Use a positive bound for ordinary palette/array selection.

pick

Signature VM instruction
pick(n: i32) -> i32 rng.index
pick(value0: T, value1: T, ...) -> T compiler lowering

The variadic form requires at least 2 arguments. T is a shared storable type; mixed numeric choices widen to f32.

pick(n) is exactly index(n). With two or more arguments it eagerly evaluates choices, then consumes one selection draw. Choices must share a storable type; mixed numeric choices widen to f32. Named choice arguments are value0, value1, etc. A single non-integer choice is rejected because the single-argument overload is the index alias.

chance

Signature VM instruction
chance(p: f32) -> i32 compiler lowering

Returns i32 0/1 for unit() < p after evaluating p. It consumes a draw even when p <= 0, p >= 1, or p is NaN. NaN yields false.

roll

Signature VM instruction
roll(seed: i32, a: f32, b: f32) -> f32 compiler lowering

Draws a range using an explicit i32 seed. Arguments evaluate in signature order before seed isolation. The isolated operation consumes one draw and restores the outer stream to its state after argument evaluation.

choose

Signature VM instruction
choose(seed: i32, value0: T, ...) -> T compiler lowering

The variadic form requires at least 2 arguments. T is a shared storable type; mixed numeric choices widen to f32.

Selects from one or more eager typed choices using an explicit seed. Choice type rules match multi-choice pick. A single choice still performs an isolated draw. Nested isolated calls restore each caller's stream. Named arguments are seed, value0, value1, etc.

hash

Signature VM instruction
hash(x: i32) -> i32 hash

Returns the frozen splitmix32 hash of the argument's bit pattern as i32. It does not change the current stream. It is useful for deriving independent seeds from a saved seed and a coordinate or index.

stream_push

Signature VM instruction
stream_push(seed: i32) -> void stream.push

Saves the current stream and derives a nested stream from the supplied seed according to the VM contract. Pair it with stream_pop before returning. This is stream derivation, not the direct seed assignment used by roll.

stream_pop

Signature VM instruction
stream_pop() -> void stream.pop

Restores the most recently pushed stream. An unmatched pop traps. Nesting is bounded by the VM's stream-stack limit. Balanced calls isolate a sequence of draws while retaining the caller's stream.

This program is valid source but its unmatched pop traps during generation:

Expected result: trap (stream stack underflow).

asset UnbalancedStream {
  size 8x8
  draw { stream_pop() }
}

Numbers and interpolation

The signatures below come from the compiler. Numeric parameters typed f32 accept i32 widening. Domain errors in floating math generally produce the specified IEEE result rather than a compile error; consuming a nonfinite result in an operation with a checked domain can trap.

Expected result: pass.

asset NumberHelpers {
  size 8x8
  output point result
  draw {
    x = int(2.8)
    f = float(x)
    truncated = i32trunc(-2.8)
    rounded = round(2.5)
    root = sqrt(f)
    angle = atan2(1, 1)
    wave = sin(angle) + cos(angle)
    bounds = min(floor(abs(-f)), ceil(root))
    bounds = max(bounds, 1)
    growth = pow(bounds, 2) + exp(0)
    blend = lerp(0, growth, smooth(0.5))
    contrast = gain(quintic(0.25), 0.5)
    edge = smoothstep(0, 1, contrast)
    mapped = remap(edge, 0, 1, 1, 4)
    result = {clamp(mapped, 0, 7), blend}
    pixel(x, rounded, #9dc)
  }
}

int

Signature VM instruction
int(x: i32) -> i32 compiler lowering
int(x: f32) -> i32 f32.to.i32

An i32 is preserved exactly. An f32 truncates toward zero with saturation to the signed 32-bit bounds; NaN becomes 0. Use this conversion for integer pixel coordinates and conditions.

float

Signature VM instruction
float(x: f32) -> f32 compiler lowering
float(x: i32) -> f32 i32.to.f32

Widens i32 to binary32. An existing f32 is preserved, including negative zero. Integers beyond binary32 precision can round.

i32trunc

Signature VM instruction
i32trunc(x: f32) -> i32 f32.to.i32

The explicit floating truncation operation: same saturated/NaN conversion as the f32 overload of int.

round

Signature VM instruction
round(x: f32) -> i32 compiler lowering

Returns an i32, rounding to the nearest integer with halfway values away from zero, then applying saturated truncation. NaN becomes zero; infinities saturate. An i32 argument widens to f32 before rounding. See the rounding contract.

sqrt

Signature VM instruction
sqrt(x: f32) -> f32 f32.sqrt

Binary32 square root. Negative arguments yield NaN; negative zero is preserved according to the floating contract.

sin

Signature VM instruction
sin(x: f32) -> f32 f32.sin

Pinned sine approximation, with angle in radians. Exact range reduction, nonfinite behavior and polynomial evaluation are specified in trigonometry.

cos

Signature VM instruction
cos(x: f32) -> f32 f32.cos

Pinned cosine approximation, with angle in radians and the same normative trigonometry contract as sin.

abs

Signature VM instruction
abs(x: f32) -> f32 f32.abs

Floating magnitude. It accepts numeric widening and returns f32.

floor

Signature VM instruction
floor(x: f32) -> f32 f32.floor

Rounds a float downward and returns a float. Convert explicitly when an integer result is required.

ceil

Signature VM instruction
ceil(x: f32) -> f32 f32.ceil

Rounds a float upward and returns a float.

min

Signature VM instruction
min(x: f32, y: f32) -> f32 f32.min
min(x: i32, y: i32) -> i32 i32.min

Returns exact signed i32 when both arguments are integers. If either argument is f32, integers widen and the floating result uses pinned minNum semantics: one NaN selects the other argument, two NaNs produce canonical NaN, and negative zero precedes positive zero.

max

Signature VM instruction
max(x: f32, y: f32) -> f32 f32.max
max(x: i32, y: i32) -> i32 i32.max

Returns exact signed i32 for two integers, otherwise f32 with the corresponding maxNum semantics; positive zero succeeds negative zero.

pow

Signature VM instruction
pow(x: f32, y: f32) -> f32 f32.pow

Pinned floating exponentiation. Zero, negative bases, integer/noninteger exponents, NaN and infinity cases follow the math contract.

exp

Signature VM instruction
exp(x: f32) -> f32 f32.exp

Pinned base-e exponential. Overflow/underflow and nonfinite handling are defined by the same math contract.

atan2

Signature VM instruction
atan2(y: f32, x: f32) -> f32 f32.atan2

Returns the pinned angle in radians. The argument order is y, x; signed zero and quadrant handling follow the math specification.

smooth

Signature VM instruction
smooth(t: f32) -> f32 smooth

Clamps t to [0, 1] and applies cubic smoothstep t*t*(3 - 2*t) using the frozen operation order.

lerp

Signature VM instruction
lerp(a: f32, b: f32, t: f32) -> f32 lerp
lerp(a: color, b: color, t: f32) -> color color.mix

Interpolates numbers or colors with clamped t. Numeric inputs widen to f32. Color interpolation has the same behavior as color.mix, including preservation of the first color's alpha.

gain

Signature VM instruction
gain(t: f32, k: f32) -> f32 gain

Schlick contrast curve. t clamps to [0, 1], k to [1e-4, 1]. k = 0.5 is identity; k = 1 returns 0.5 for interior positions and retains endpoints. A NaN argument produces canonical NaN before clamping.

quintic

Signature VM instruction
quintic(t: f32) -> f32 quintic

Clamped quintic smootherstep, also used by gradient noise's fade function.

smoothstep

Signature VM instruction
smoothstep(a: f32, b: f32, t: f32) -> f32 smoothstep

Maps t between two edges through clamped smooth interpolation. Exact equal-edge and reversed-edge behavior follows the curve toolkit.

remap

Signature VM instruction
remap(t: f32, a: f32, b: f32, c: f32, d: f32) -> f32 remap

Maps a value from interval [a, b] to [c, d]; this is a range transform, not an automatic clamp. Degenerate intervals follow the frozen float rules.

clamp

Signature VM instruction
clamp(x: f32, min: f32, max: f32) -> f32 clamp
clamp(x: i32, min: i32, max: i32) -> i32 i32.clamp

Bounds a value between the two supplied limits, ordering them first, so reversed or equal bounds are valid. All three integer arguments select exact signed i32; any f32 argument selects the existing floating operations. Parameter names are x, min, max. Floating NaN and signed-zero behavior is unchanged. See 0.7 migration for assignments to existing f32 locals.

Drawing and colors

Raster calls paint the current canvas in call order. Pixel centers and edge coverage are fixed by canvas algorithms. Out-of-canvas coverage is clipped. Geometry arguments must be finite and within the operation's representable domain; invalid geometry can trap. The generated signatures distinguish integer pixels/thickness from floating geometry.

The frozen compositor interpolates RGB for translucent sources while retaining the destination alpha. An opaque source replaces all RGBA bytes. A translucent source over initially transparent storage can leave nonzero RGB with alpha zero; this is neither standard straight-alpha nor premultiplied-alpha blending. Start with an opaque background for translucent glaze examples.

The C API image view identifies this mode as IPROP_IMAGE_ALPHA_LEGACY_DESTINATION and returns the exact stored bytes. Exact blending, alpha endpoints and rounding are specified in pixel blending.

Expected result: pass.

asset CanvasCalls {
  size 16x16
  draw {
    rect(0, 0, 16, 16, #123)
    pixel(1, 1, #fff)
    line(1, 2, 14, 2, 1, #9dc)
    oval(4, 8, 2, 3, #fc9)
    poly(color = #99f, x0 = 8, y0 = 5, x1 = 14, y1 = 8, x2 = 8, y2 = 11)
    curve({1, 12}, {4, 7}, {7, 12}, 1.0, #fff)
    curve({8, 12}, {10, 7}, {12, 7}, {14, 12}, 1.0, #9dc)
    weather(seed, 1)
  }
}

pixel

Signature VM instruction
pixel(x: i32, y: i32, color: color) -> void pixel

Paints one integer pixel with a color, subject to clipping and blending.

rect

Signature VM instruction
rect(x: f32, y: f32, w: f32, h: f32, color: color) -> void rect

Paints an axis-aligned rectangle using floating x/y/width/height and the normative pixel-center fill rules.

line

Signature VM instruction
line(ax: f32, ay: f32, bx: f32, by: f32, thickness: i32, color: color) -> void line

Paints a segment using floating endpoints and an integer thickness. The segment rasterizer's endpoint and cap rules are frozen.

curve

Signature VM instruction
curve(a: vec2, b: vec2, c: vec2, thickness: f32, color: color) -> void curve
curve(a: vec2, b: vec2, c: vec2, d: vec2, thickness: f32, color: color) -> void curve.cubic

Paints a quadratic or cubic Bézier stroke depending on whether three or four control points are supplied. Thickness is a floating diameter in target pixels; strokes have round caps. Nonfinite geometry is rejected. Distance estimation and coverage follow the pinned bounded Bézier kernel.

oval

Signature VM instruction
oval(x: f32, y: f32, rx: f32, ry: f32, color: color) -> void oval

Paints an ellipse centered at x/y with radii rx/ry, using the exact raster contract. Use valid positive radii for ordinary ellipses.

poly

Signature VM instruction
poly(points: list<vec2>, color: color) -> void poly.list
poly(color: color, x0: f32, y0: f32, x1: f32, y1: f32, x2: f32, y2: f32, ...) -> void compiler lowering

The variadic form requires at least 7 arguments. Additional coordinates repeat as xN, yN pairs; positional color comes first.

Paints a filled polygon with at least three (x, y) pairs. Positional color comes first. Named coordinates are x0, y0, x1, y1, etc., with color. poly(points, color) also accepts an immutable list<vec2> of at least three vertices. Its named argument is points; points evaluate before color. Coordinates evaluate first in vertex order, then color, even for reordered named arguments. The point-in-polygon rule is specified in the canvas contract.

weather

Signature VM instruction
weather(seed: i32, amount: i32) -> void weather

Applies the pinned seeded wear operation to the current image. Amount is an integer wear parameter; the explicit seed makes it independent of stream draws. It operates on pixels already drawn, so place it after the base shapes.

Color helpers

Expected result: pass.

asset ColorHelpers {
  size 8x8
  draw {
    base = rgb(r = 32, g = 64, b = 96)
    glaze = rgba(128, 192, 160, 128)
    mixed = color.mix(base, #9dc, 0.5)
    alias = lerp(base, #9dc, 0.5)
    shaded = color.shade(mixed, 10)
    opaque = color.set.a(glaze, 255)
    multiplied = color.mul(opaque, #ccc)
    scaled = color.scale(multiplied, 0.8)
    adjusted = color.hsv(scaled, 0.25, 1.0, 1.0)
    warmer = rgba(adjusted.r + 5, adjusted.g - 3, adjusted.b, adjusted.a)
    metal = #705845ff to #c8c8c8ff
    bright = #705845ff to #e8e8e8ff with beta(alpha=5.0, beta=2.0)
    opacity = float(glaze.a) / 255.0
    rect(0, 0, 8, 8, base)
    rect(2, 2, 4, 4, glaze)
    pixel(1, 1, warmer)
    pixel(2, 1, metal)
    pixel(3, 1, bright)
    if (opacity > 0.5 && base.b == 96) {
      pixel(6, 6, #fff)
    }
  }
}

A random color between two ends is #705845ff to #c8c8c8ff; see gradients.

Color channels

c.r, c.g, c.b and c.a read the stored RGBA8 channels of any color value as i32 in 0–255; alpha is read as stored. The base is evaluated exactly once per read, so makeColor().r calls the helper once, while makeColor().r and makeColor().g are two calls. Store the color first to read the channels of one result.

Channel reads of a constant color fold at compile time; otherwise they lower to color.r–color.a instructions.

Channels are read-only. Build a modified color with rgb() or rgba(), which clamp their channels, so rgba(c.r + 5, c.g - 3, c.b, c.a) needs no extra clamp(). Convert explicitly for 0–1 arithmetic: float(c.a) / 255.0.

Expected result: compile-error (color has only .r, .g, .b and .a channels).

asset InvalidChannel {
  size 8x8
  draw {
    tint = #9c785a
    red = tint.red
  }
}

This example is intentionally rejected: a color has only the four channel names, and the diagnostic lists them.

Expected result: compile-error (cannot assign to '.r').

asset ChannelAssignment {
  size 8x8
  draw {
    tint = #9c785a
    tint.r = 100
  }
}

This example is intentionally rejected: a channel cannot be assigned.

rgb

Signature VM instruction
rgb(r: i32, g: i32, b: i32) -> color compiler lowering

Builds an opaque color from integer red/green/blue channels clamped to 0–255.

rgba

Signature VM instruction
rgba(r: i32, g: i32, b: i32, a: i32) -> color compiler lowering

Builds a color with an integer alpha channel; all four channels clamp to 0–255.

color.mix

Signature VM instruction
color.mix(a: color, b: color, t: f32) -> color color.mix

Interpolates RGB with clamped floating t, retaining the first argument's alpha. The color overload of lerp is equivalent.

color.shade

Signature VM instruction
color.shade(color: color, amount: i32) -> color color.shade

Offsets RGB brightness by the integer amount with channel clamping. Alpha is preserved. Bare shade/mix names are not builtins in the current language.

color.set.a

Signature VM instruction
color.set.a(color: color, alpha: i32) -> color color.set.a

Replaces alpha with a clamped integer channel while retaining RGB.

color.mul

Signature VM instruction
color.mul(a: color, b: color) -> color color.mul

Multiplies color channels using the pinned integer conversion. See color utilities for alpha and rounding.

color.scale

Signature VM instruction
color.scale(color: color, scale: f32) -> color color.scale

Scales RGB by a floating factor with pinned conversion/clamping; preserves alpha. Nonfinite behavior follows the color utility contract.

color.hsv

Signature VM instruction
color.hsv(color: color, hue: f32, saturation: f32, value: f32) -> color color.hsv

Adjusts hue by turns and scales saturation/value according to the frozen HSV conversion, preserving alpha. Nonfinite hue shifts trap; this is a color adjustment, not a constructor from three standalone HSV channels.

Vectors and distance fields

Vectors store binary32 components. Most geometry calculations use binary32; normalization uses scaled binary64 intermediates before rounding its result. Signed distances are negative inside closed shapes, zero on their boundary and positive outside. Segment/Bézier distances are unsigned. Distance queries do not paint pixels; combine them with fill and a color operation to author coverage. A random point of a segment is a to b between two vec2 values; see segments.

Expected result: pass.

asset GeometryHelpers {
  size 8x8
  output point result
  draw {
    p = {2, 3}
    length = vec2.len(p)
    product = vec2.dot(p, {1, 0})
    rotated = vec2.rot(p, 0.25)
    polarValue = polar(rotated)
    folded = fold(p, 4)
    circleDistance = dist.circle(p, {4, 4}, 2)
    rectDistance = dist.rect(p, {4, 4}, {2, 2})
    rounded = dist.roundrect(p, {4, 4}, {2, 2}, 0.5)
    segment = dist.line(p, {0, 0}, {7, 7})
    quadratic = dist.bezier(p, {0, 0}, {3, 7}, {7, 0})
    cubic = dist.bezier(p, {0, 0}, {2, 7}, {5, 7}, {7, 0})
    coverage = fill(circleDistance, 1)
    result = {0.0, 0.0} to {7.0, 7.0}
    pixel(2, 3, color.scale(#9dc, coverage))
  }
}

vec2.len

Signature VM instruction
vec2.len(v: vec2) -> f32 vec2.len

Returns vector length using the pinned multiply/add/square-root order.

vec2.dot

Signature VM instruction
vec2.dot(a: vec2, b: vec2) -> f32 vec2.dot

Returns the scalar dot product of two vectors.

vec2.normalize

Signature VM instruction
vec2.normalize(v: vec2) -> vec2 vec2.normalize

Returns a unit direction from a finite vector. Zero, including signed zero, returns {+0, +0}. NaN or infinity in either component traps. The scaled binary64 algorithm handles maximum and subnormal f32 components without overflowing the length; it draws no randomness. See the normalization contract.

Expected result: trap (vec2.normalize components must be finite).

asset invalidNormalize {
  size 1x1
  draw {
    vec2.normalize({1.0 / 0.0, 1})
  }
}

Expected result: pass.

asset mathBounds {
  size 8x8
  input i32 amount = 16777217
  output point exact
  output point direction
  output point zero
  output point mixed

  bounded(x: i32) -> i32 {
    return clamp(x, 16777218, 16777216)
  }

  draw {
    smallest = min(amount, 16777218)
    largest = max(amount, 16777216)
    exact = {smallest == 16777217, largest == bounded(amount)}
    direction = vec2.normalize({3, 4})
    zero = vec2.normalize({-0.0, -0.0})
    fraction = 1.0
    fraction = float(min(1, 2))
    fraction = max(1, 2.0)
    mixed = {fraction, clamp(3, 2.0, 0)}
    pixel(0, 0, #20c080)
  }
}

vec2.rot

Signature VM instruction
vec2.rot(v: vec2, a: f32) -> vec2 vec2.rot

Rotates a vector by a floating angle in radians using pinned sine/cosine. Positive angles are counterclockwise in the downward-y plane. This continuous vector rotation is distinct from the exact discrete part transforms.

dist.circle

Signature VM instruction
dist.circle(p: vec2, center: vec2, radius: f32) -> f32 dist.circle

Signed distance to a circle defined by a center and radius.

dist.rect

Signature VM instruction
dist.rect(p: vec2, center: vec2, half_size: vec2) -> f32 dist.rect

Signed distance to a centered axis-aligned rectangle. half_size contains half-width and half-height, not its full dimensions.

dist.roundrect

Signature VM instruction
dist.roundrect(p: vec2, center: vec2, half_size: vec2, radius: f32) -> f32 dist.roundrect

Signed distance to a centered rounded rectangle with half-dimensions and a corner radius. Supply dimensions/radius suitable for the shape being authored.

dist.line

Signature VM instruction
dist.line(p: vec2, a: vec2, b: vec2) -> f32 dist.line

Distance to a finite segment. Degenerate segments and nonfinite inputs follow the exact distance-field contract.

dist.bezier

Signature VM instruction
dist.bezier(p: vec2, a: vec2, b: vec2, c: vec2) -> f32 dist.bezier
dist.bezier(p: vec2, a: vec2, b: vec2, c: vec2, d: vec2) -> f32 dist.bezier.cubic

Distance estimate to a quadratic/cubic Bézier curve on [0, 1], selected by the control-point count. The kernel performs a fixed bounded search rather than an arbitrary host-library approximation. Nonfinite geometry traps.

fill

Signature VM instruction
fill(d: f32, w: f32) -> f32 fill

Converts signed distance and a transition width to floating coverage. The exact zero/nonpositive width and NaN rules are in the distance-field spec.

fold

Signature VM instruction
fold(v: vec2, n: i32) -> vec2 vec2.fold

Folds a vector into an n-sector kaleidoscope domain, returning a vec2. The sector count is an integer. Valid counts and angular folding follow fold and polar.

polar

Signature VM instruction
polar(v: vec2) -> vec2 vec2.polar

Returns {radius, angle} for a vector; angle is in radians under the same downward-y convention as vector rotation. Signed-zero behavior is pinned.

Noise and procedural utilities

These functions use explicit seeds and do not consume the current RNG stream. Keep coordinates modest unless testing the documented domains. All derived coordinates are checked too; a finite starting coordinate can leave a valid domain after octave scaling or warping.

Expected result: pass.

asset NoiseHelpers {
  size 8x8
  output point feature
  draw {
    saved = seed
    value = noise(0.5, 0.5, saved)
    triangle = fault(0.5, 0.5, saved)
    gradient = noise.grad(0.5, 0.5, saved)
    fractal = noise.fbm(0.5, 0.5, 3, saved)
    cells = noise.cell(0.5, 0.5, saved)
    ridge = noise.ridge(0.5, 0.5, 3, saved)
    warped = warp(0.5, 0.5, 0.2, saved)
    feature = cell.jitter(3, 4, saved)
    coverage = dither(0.5, 2, 3)
    pixel(2, 3, color.scale(#9dc, coverage))
  }
}

noise

Signature VM instruction
noise(x: f32, y: f32, seed: i32) -> f32 noise

Frozen value noise over the lattice, with interpolation defined by the original noise contract.

fault

Signature VM instruction
fault(x: f32, y: f32, seed: i32) -> f32 fault

Frozen triangular noise/fault primitive with explicit coordinates and seed; its exact hashing and interpolation are in the adjacent fault specification.

noise.grad

Signature VM instruction
noise.grad(x: f32, y: f32, seed: i32) -> f32 noise.grad

Gradient noise. Coordinates must be finite, with each floor in [-2147483648, 2147483646]. A failure traps with noise coordinate out of range.

noise.fbm

Signature VM instruction
noise.fbm(x: f32, y: f32, octaves: i32, seed: i32) -> f32 noise.fbm

Normalized octave sum of gradient noise. The integer octave count clamps to 1–8. The gradient coordinate domain is checked at every octave, after frequency scaling.

noise.cell

Signature VM instruction
noise.cell(x: f32, y: f32, seed: i32) -> f32 noise.cell

Worley F1 distance using the complete pinned 5×5 search window. Coordinates must be finite, with floors in [-16777214, 16777214].

noise.ridge

Signature VM instruction
noise.ridge(x: f32, y: f32, octaves: i32, seed: i32) -> f32 noise.ridge

Normalized ridged octave sum. Octaves clamp to 1–8, with the same per-octave gradient domain checks as noise.fbm.

warp

Signature VM instruction
warp(x: f32, y: f32, amount: f32, seed: i32) -> f32 warp

Domain-warped noise using the fixed inner/outer four-octave fbms. Amount must be finite; all offset and warped samples must meet the gradient domains.

cell.jitter

Signature VM instruction
cell.jitter(cx: i32, cy: i32, seed: i32) -> vec2 cell.jitter

Returns an absolute point inside [cx, cx+1) × [cy, cy+1). Integer cell origins must be exactly representable as binary32. All integers within ±16777216 qualify; larger ones must also be representable. A rejected origin traps with cell index is not representable as f32.

dither

Signature VM instruction
dither(t: f32, x: i32, y: i32) -> f32 dither

Applies the pinned ordered Bayer threshold to t at integer pixel x/y. It returns floating coverage. Matrix indexing and threshold comparisons are specified in dither.

The complete algorithms and domain checks are in noise v2. This example deliberately traps on a nonfinite derived coordinate:

Expected result: trap (noise coordinate out of range).

asset NoiseTrap {
  size 1x1
  input f32 coordinate = 1.0
  draw { value = noise.grad(coordinate / 0.0, 0, seed) }
}

Palettes

palette name = [constant, ...] declares a non-empty, immutable asset-level array. Elements permit literals, parentheses and one numeric unary minus, such as [-2, -(1), 0] or [-0.5, (-0.0), 1.0]. The array has one resolved element type: i32, f32 or color; boolean literals count as i32. Pure integer arrays remain i32. If any numeric element is f32, integer elements widen to f32, so [0, 0.5, 1] is an f32 palette. Colors cannot be mixed with numbers. Integer negation uses the ordinary wrapping 32-bit semantics and floating negation preserves negative zero. Arithmetic, calls and repeated negation are rejected in these legacy literal palettes. Palettes are available in draw and helpers, and cannot share a name with an input, output, helper or another palette.

name[index] requires an i32 index and returns the element type. A negative or out-of-range index selects the last element. The index runs once, and reading the selected element makes no RNG draw. A bare palette is a first-class list when it has at most 256 entries; lists use checked indices instead of fallback.

Expected result: pass.

asset PaletteHelpers {
  size 8x8
  palette colors = [#123, #9dc, #fc9]
  palette levels = [-(0.0), 0.5, 1]
  palette integers = [1, 2, 3]
  palette signed = [-2, -(1), 0]
  draw {
    c = colors[index(3)]
    last = integers[-1]
    continuous = ramp(colors, 0.5)
    intensity = ramp(t = 0.5, palette = levels)
    rect(0, 0, 8, 8, c)
    pixel(last + signed[1] + 1, 2, color.scale(continuous, intensity))
  }
}

The example includes parenthesized negative integer elements and a negative floating zero. levels widens its integer stops to f32; integers and signed retain i32. These use the declaration-constant resolver shared with root/part input defaults, rather than executing an expression at generation.

ramp

Signature VM instruction
ramp(palette: palette<f32>, t: f32) -> f32 compiler lowering
ramp(palette: palette<color>, t: f32) -> color compiler lowering

Interpolates a color or f32 palette with numeric t clamped to [0, 1]. It returns the palette's element type. Pure integer palettes are rejected; include a floating element when continuous numeric interpolation is wanted. A single element returns that element, but still evaluates t exactly once, including its RNG effects. Named arguments are palette and t.

Color interpolation preserves the first selected color's alpha. Exact clamping, NaN behavior and lowering are described in the ramp contract.

Expected result: compile-error (ramp requires a color or f32 palette).

asset InvalidRamp {
  size 8x8
  palette values = [1, 2, 3]
  draw { result = ramp(values, 0.5) }
}

Passing palettes as lists

A palette with at most 256 elements is also a first-class list<T> value. Pass its name to a helper or assign it to a local to share immutable data. A list parameter uses checked indexing; direct legacy palette[index] and ramp retain the last-element fallback and their original size limits. See immutable collections.

Sampling palettes

If a palette contains an outer lower to upper [with d] range or a sampler, its type is list<sampler<T>>. Ranges capture descriptions; scalar entries lift to constant descriptions. A plain palette still returns T when indexed, and .pick() on that value is a type error. T supports i32, f32, color and vec2. Mixed numeric entries widen to f32, including endpoints of i32 descriptions. Palettes of vec2 constants are also supported. No implicit draw or conversion from a description to its result is performed.

Descriptions, endpoints and distribution parameters must be compile-time values. They may reference asset const, including forward const distribution and const sampler<T> declarations; their shared dependency graph must be acyclic. RNG, inputs, seed, helper calls and .pick are forbidden in initializers. All declarations are checked, including unused descriptions. Bounds and parameters validate during compilation; positive probability mass is checked only on a pick. Sampling palettes cannot be empty and contain at most 256 descriptions. Existing constant palettes keep their legacy size limits for direct indexing and ramp.

widths[i] selects a description without drawing; widths[i].pick() and widths[i].pick(n) sample only that description. A scalar entry such as 40 returns 40 without RNG; an equal-bound range still uses the normal range draw. Save a sampled material color once to reuse its channels or shade related panels. Passing a sampling palette to a helper uses list<sampler<T>>; its parameter has checked list indices, while direct palette indexing keeps the last-element fallback. Copies share immutable data and use the caller's stream on a pick.

ramp accepts only constant color/f32 palettes. Sampling palettes and lists of samplers are rejected, without sampling interpolation stops. See capture and batch semantics.

Expected result: pass.

asset samplingPalettes { input i32 v = 0
  size 32x24
  output point checks
  output point direction
  output point samples
  output point normalized
  output point state

  const distribution typical = gaussian(mean=25.0, sigma=2.0)
  const distribution finishCurve = gaussian(mean=0.5, sigma=0.18)
  const sampler<f32> position = sampler(0.0 to 1.0 with finishCurve)
  const sampler<color> finish = sampler(#343a42 to #d8dce2 with finishCurve)
  const list<sampler<i32>> fixed = [sampler(40)]

  palette widths = [10 to 15, 20 to 30 with typical, 40]
  palette tints = [finish, #929ca8]
  palette directions = [{3, 2} to {26, 2} with beta(2, 3), {3, 4}]

  description(items: list<sampler<i32>>, i: i32) -> sampler<i32> {
    return items[i]
  }

  draw {
    spec = description(widths, v)
    values = spec.pick(3)
    material = tints[0].pick()
    direction = directions[0].pick()
    normalized = {position.pick(), 0}
    checks = {list.len(values), fixed[0].pick(1)[0]}
    samples = {values[0], values[2]}
    for i in 0..3 {
      rect(2, 2 + i * 6, values[i], 4, material)
    }
    state = {seed & 65535, seed >> 16}
  }
}

Expected result: compile-error (cannot read 'lower').

asset invalidPalette {
  size 1x1
  input i32 lower = 10
  palette widths = [lower to 15, 40]
  draw {}
}

Expected result: compile-error (cyclic const dependency).

asset cyclicDescriptions {
  size 1x1
  const sampler<i32> spec = widths[0]
  palette widths = [spec]
  draw {}
}

Expected result: compile-error (ramp does not accept sampling palettes).

asset invalidRamp {
  size 1x1
  palette tints = [#343a42 to #d8dce2, #929ca8]
  draw {
    tint = ramp(tints, 0.5)
  }
}

Expected result: compile-error (must have elements of one type).

asset incompatibleDescriptions {
  size 1x1
  palette widths = [10 to 15, #929ca8]
  draw {}
}

Expected result: compile-error (.pick has RNG or generation effects).

asset invalidConstantDraw {
  size 1x1
  const sampler<i32> width = sampler(10 to 15)
  const i32 result = width.pick()
  draw {}
}

Expected result: compile-error (random range lower bound exceeds).

asset invalidDescription {
  size 1x1
  palette widths = [15 to 10, 40]
  draw {}
}

Expected result: trap (list index out of range).

asset checkedDescriptionList {
  size 1x1
  palette widths = [10 to 15, 40]
  description(items: list<sampler<i32>>, i: i32) -> sampler<i32> {
    return items[i]
  }
  draw {
    width = description(widths, -1).pick()
  }
}

Expected result: trap (random range has no probability under the distribution).

asset missingProbability {
  size 1x1
  palette positions = [0.0 to 1.0 with triangular(10, 15, 20), 0.5]
  draw {
    description = positions[0]
    empty = description.pick(0)
    result = description.pick()
  }
}

Immutable lists

list<T> holds zero to 256 values of one element type: i32, f32, color, vec2 or distribution. These are ordinary value types for locals, helper parameters, helper returns and asset constants. Lists also accept sampler<T> descriptions and stepper<T> descriptions. Lists of lists and surface elements are unsupported. A list owns immutable data; passing, returning or assigning its handle shares that data.

Expected result: pass.

asset collections {
  size 32 24
  output point checks
  output point direction
  output point samples
  output point state

  const list<vec2> outline = [{3, 4}, {18, 2}, {25, 15}, {8, 20}]
  const list<color> steel = [#929ca8, #343a42]
  const list<distribution> shapes = [gaussian(0.5, 0.18), beta(2, 2)]
  palette legacy = [7, 11, 19]

  paintShape(points: list<vec2>, tint: color) {
    poly(points, tint)
  }

  echo(items: list<i32>) -> list<i32> {
    return items
  }

  empty() -> list<vec2> {
    return []
  }

  draw {
    paintShape(outline, steel[0])
    pixel(3, 4, steel[1])
    rolls = echo([10 to 15, 20 to 30])
    storedPalette = legacy
    checks = {list.len(outline), list.len(empty()) + storedPalette[2]}
    direction = outline[1]
    samples = {rolls[0], rolls[1]}
    shaped = 0.0 to 1.0 with shapes[0]
    state = {seed & 65535, seed >> 16}
  }
}

Literals and types

Square brackets construct a list. Elements evaluate once, from left to right, in the caller's stream. [10 to 15, 20 to 30] contains two sampled integers; reading either element again performs no draw. A distribution element is a description, so [gaussian(0.5, 0.18)] itself performs no draw.

All elements must agree in type. Mixed numeric elements widen i32 to f32. When a literal has an expected list<f32> type, its integers widen element by element. Existing list values never convert between list<i32> and list<f32> automatically. A local retains its original element type.

An empty literal needs a known type from an asset constant, helper parameter, helper return, typed local or reassignment to an existing list local:

const list<color> noColors = []
empty() -> list<vec2> { return [] }
draw {
  list<i32> items = []
  items = [1, 2]
  items = []
}

items = [] with a fresh untyped name and list.len([]) have no element context and are rejected. Element assignment, append, comprehensions and dynamic list growth are not supported. Assign a complete new list to a local.

Index and length

items[index] evaluates the list before its i32 index, each once. An index below zero or at least the length traps with IndexRange and list index out of range. An unassigned list handle traps when used; it is different from an assigned empty list. There is no implicit conversion of an f32 index.

Signature VM instruction
list.len(items: list<i32>) -> i32 list.len
list.len(items: list<f32>) -> i32 list.len
list.len(items: list<color>) -> i32 list.len
list.len(items: list<vec2>) -> i32 list.len
list.len(items: list<distribution>) -> i32 list.len
list.len(items: list<sampler<i32>>) -> i32 list.len
list.len(items: list<sampler<f32>>) -> i32 list.len
list.len(items: list<sampler<color>>) -> i32 list.len
list.len(items: list<sampler<vec2>>) -> i32 list.len
list.len(items: list<stepper<i32>>) -> i32 list.len
list.len(items: list<stepper<f32>>) -> i32 list.len
list.len(items: list<stepper<color>>) -> i32 list.len
list.len(items: list<stepper<vec2>>) -> i32 list.len

Returns the list length as i32, without iteration or RNG. Its overloads, like all builtin signatures, come from the compiler catalogue. It also accepts a first-class palette of at most 256 elements.

List transformations

items.distinct() returns the first occurrence of each value in its original order. items.sort() returns a stable ascending list of i32, f32, color or vec2: numbers use numeric order, colors use lexicographic RGBA byte order, and vectors use lexicographic x/y order. Both take no arguments, preserve the element type, evaluate the receiver once and leave the original unchanged. Use items = items.distinct() to replace a local, or chain items.distinct().sort(). Sorting retains duplicates.

For scalar floats and vector components, signed zeros compare equal, and all NaNs form one equal class after positive infinity. Stable sorting and distinct retain the original bits of selected elements. .distinct() also accepts distributions, samplers and steppers: it compares their stored descriptions, including mode/family, bounds, parameter bits and original point types/bits, without drawing or comparing handle addresses. .sort() rejects those description types. Both methods work in pure asset constants and on typed empty lists.

Receiver Method Result
list<i32> .distinct() list<i32>
list<f32> .distinct() list<f32>
list<color> .distinct() list<color>
list<vec2> .distinct() list<vec2>
list<distribution> .distinct() list<distribution>
list<sampler<i32>> .distinct() list<sampler<i32>>
list<sampler<f32>> .distinct() list<sampler<f32>>
list<sampler<color>> .distinct() list<sampler<color>>
list<sampler<vec2>> .distinct() list<sampler<vec2>>
list<stepper<i32>> .distinct() list<stepper<i32>>
list<stepper<f32>> .distinct() list<stepper<f32>>
list<stepper<color>> .distinct() list<stepper<color>>
list<stepper<vec2>> .distinct() list<stepper<vec2>>
list<i32> .sort() list<i32>
list<f32> .sort() list<f32>
list<color> .sort() list<color>
list<vec2> .sort() list<vec2>

For example, [3, 1, 3, 2].distinct() returns [3, 1, 2] and [3, 1, 3, 2].sort() returns [1, 2, 3, 3]. A statement such as items.sort() discards its return value; it still evaluates and checks the receiver. Unassigned lists or description elements trap. Method names are ordinary names, so a helper named sort or distinct is allowed.

Expected result: pass.

asset listMethods {
  size 1x1
  const list<i32> fixed = [3, 1, 3, 2].distinct().sort()
  output list point values
  output point counts

  ordered(items: list<i32>) -> list<i32> {
    return items.distinct().sort()
  }

  draw {
    original = [3, 1, 3, 2]
    unique = original.distinct() // [3, 1, 2]; original remains unchanged
    sorted = ordered(original) // [1, 2, 3]
    for i in 0..list.len(unique) {
      values = {unique[i], sorted[i]}
    }
    counts = {list.len(original), list.len(fixed)}
    colors = [#112233ff, #11223300, #112233ff].distinct().sort()
    pixel(0, 0, colors[1])
  }
}

Polygons

poly(points, tint) accepts list<vec2> and color; named arguments are points and color. Points evaluate before color, independent of written named order. A list with fewer than three vertices traps at execution. Rasterization, scaling, clipping, alpha and polygon visit fuel use exactly the existing variadic polygon kernel. Constructing a dynamic list adds its own construction fuel; a previously stored list is shared on each call.

Constants and palettes

const list<color> stone = [#343a42, #929ca8, #d8dce2] uses the same acyclic, pure compile-time resolver as scalar constants. Forward references, indexing, list.len, .distinct(), .sort() and valid distribution constructors are allowed. Invalid constant indices or constructors are source errors, including in unused declarations. RNG, generation inputs, seed, user helpers and part calls remain forbidden.

Legacy palette declarations retain literal-only elements, ramp semantics and the last-element fallback for out-of-range direct indexing. Their old size limits are unchanged. A palette used as a value or passed to a helper becomes list<T> and must contain at most 256 elements. Indexing that shared list through a local or helper parameter uses the ordinary checked index.

Resources, format and errors

List construction uses an internal builder and freezes its element buffer; it never pushes every component simultaneously onto the 512-slot stack. Builder values cannot be declared, copied or passed by source code. A trap releases unfinished builders and prevents publishing a partial result.

Each generation owns an arena. Immutable allocations are charged once even when many handles share them. The 64 MiB arena limit also includes additional part canvases. Accounting uses fixed conservative 128-byte headers and 64-byte element slots on both native and WASM, and four bytes per part pixel. Memory and fuel are checked before allocation or rasterization.

List transformations share an unchanged result buffer. A changed result charges only its 128-byte header and 64 bytes per retained element; description buffers remain shared. Fuel is reserved before comparisons or result allocation: base 1 plus 2*N + W*C, where N is the input length, C is N*(N-1)/2 for distinct or N*ceil(log2(N)) for sort (zero for empty/singleton lists), and W bounds the element comparison width. Scalar numbers use 1, colors 4, vectors 2; description widths are specified in the format contract. Repeated transformations remain subject to the generation arena and fuel caps.

A list used only to compute a scalar asset constant introduces no runtime collection. Lists do not add public C input/output enums; lists are internal values, not new host uniforms or geometric outputs.

Expected result: compile-error (empty list needs a known list<T> type).

asset list_empty {
  size 1 1
  draw { items = [] }
}

Expected result: compile-error (list element needs i32).

asset list_mixed {
  size 1 1
  draw { items = [1, #ffffff] }
}

Expected result: compile-error (list elements must be).

asset list_nested {
  size 1 1
  draw { items = [[1]] }
}

Expected result: compile-error (list elements are immutable).

asset list_assignment {
  size 1 1
  draw { items = [1] items[0] = 2 }
}

Expected result: compile-error (list index must be i32).

asset list_index_type {
  size 1 1
  draw { items = [1] value = items[0.5] }
}

Expected result: compile-error (list index out of range).

asset const_list_index {
  size 1 1
  const list<i32> items = [1]
  const i32 invalid = items[1]
  draw {}
}

Expected result: compile-error (.sort does not support list<distribution>).

asset invalidSort {
  size 1x1
  draw {
    descriptions = [gaussian(0, 1)]
    invalid = descriptions.sort()
  }
}

Expected result: compile-error (.distinct takes no arguments).

asset invalidArguments {
  size 1x1
  draw {
    invalid = [3, 1, 2].distinct(1)
  }
}

Expected result: trap (list index out of range).

asset list_index {
  size 1 1
  draw { items = [1] value = items[1] }
}

Expected result: trap (list handle is unassigned).

asset list_unassigned {
  size 1 1
  draw { if (false) { items = [1] } count = list.len(items) }
}

Expected result: trap (poly requires at least three vertices).

asset poly_list_size {
  size 1 1
  draw { poly([{0, 0}, {1, 1}], #ffffff) }
}

Captured samplers

Capture a description

sampler(value) captures one value; sampler(lower to upper with d) captures a range and optional distribution. sampler<T> supports i32, f32, color and vec2. Construction evaluates lower, upper and distribution once in that order, validates them and stores an immutable snapshot. It makes no intrinsic RNG draw; nested draws in those expressions still run. Changing the original local or input later does not change the description. Parentheses around the range are allowed. Other value expressions are evaluated eagerly before capture; capture is neither a lambda nor a general lazy expression.

Copying, assigning, passing or returning a description shares its arena data without drawing or allocating another description. A helper can accept spec: sampler<i32> and return sampler<i32>. Typed locals use the same spelling, and retain their first assigned type. There is no conversion from a sampler to its sampled value. Pass a number to a drawing helper when it should reuse a result, or a sampler when that helper should make independent draws.

Pick one or several results

spec.pick() returns T, while spec.pick(n) always returns list<T>, including when n is 0 or 1. n must be i32 in 0–256. The receiver is evaluated once before n, which is also evaluated once. After those effects, the operation checks the count, checks the handle and reserves the list before the first draw. An unset sampler traps even for n=0. Reading or indexing the resulting immutable list makes no further draws. The optional count name is n.

Receiver Method Result
sampler<i32> .pick() i32
sampler<i32> .pick(n: i32) list<i32>
sampler<f32> .pick() f32
sampler<f32> .pick(n: i32) list<f32>
sampler<color> .pick() color
sampler<color> .pick(n: i32) list<color>
sampler<vec2> .pick() vec2
sampler<vec2> .pick(n: i32) list<vec2>

Each result uses the current caller's stream and exactly the existing to/with algorithm. .pick(n) produces the same values and final RNG state as n consecutive single picks. Integer uniform draws can consume additional steps through rejection sampling. Color channels and vec2 components share one coefficient. A constant sampler(40) never draws; sampler(40 to 40) is a range and retains the normal equal-bound draw. .pick(0) never samples, including when a valid captured distribution has no mass in its range.

Store a result once when dimensions or channels should stay related. Separate pick calls remain independent, and short circuit skips calls that do not run. Helpers share the caller's stream; an imported part keeps its existing isolated stream. The global pick(value, ...) still eagerly evaluates choices and then selects one of them. .pick is resolved statically by sampler type and arity; there is no general method dispatch and no new sample or random binding.

Lists of descriptions

[sampler(10 to 15), sampler(40)] has type list<sampler<i32>>. Its elements capture in left-to-right order and indexed reads return descriptions without drawing. items[i].pick(n) accepts any sampler expression as its receiver. Lists of descriptions use the same checked indices, 256-element limit, empty literal context, immutable sharing and list.len as other lists. Lists of lists, samplers of resources and automatic sampler type widening are rejected.

Asset const sampler<T> and const list<sampler<T>> store compile-time descriptions without drawing. The same constant resolver checks endpoints, distribution parameters, forward references and cycles. Calls to .pick, RNG, inputs and helper calls cannot run during constant evaluation. Empty const lists need their declared type. Range/description palettes use these same descriptions; see sampling palettes. The sampler keyword is reserved; rename old helpers, locals, parameters and inputs with that spelling before compiling their source.

Errors and resource costs

Known invalid counts, reversed numeric bounds, nonfinite bounds and invalid pure distribution constructors produce located compile diagnostics. Dynamic values trap during capture or pick. Positive probability mass is checked only by the existing draw algorithm when a pick runs. Invalid count/handle or failed list allocation precedes result draws; effects already performed by the receiver and count are retained.

Descriptions and result lists share the generation's 64 MiB arena with other collections and additional part canvases. A description costs a fixed 256 accounted bytes; each list uses its existing 128-byte header and 64-byte slots. Copies and helper transfers charge the original allocation once. Batch pick costs its base instruction, one unit per stored element and each draw's existing kernel cost. Empty batches have no draw cost; constant descriptions have no RNG draw cost. Fuel or memory failure publishes no partially built list or successful generation result. See the format contract.

Serialized const descriptions share the same arena budget. A first read charges each retained description once; repeated reads and aliases share its handle. A const list of descriptions charges its list buffer and each distinct retained description once, even when entries repeat or a standalone const already uses the same description. Its first load also costs one fuel per entry; later cached reads cost only the instruction base. Compilation never draws a result.

Executable example

samplers.ips captures a Gaussian width, draws a batch through a helper, stores one gradient color and one segment point, and demonstrates empty and singleton results without extra RNG draws.

Expected result: pass.

asset samplerBars {
  size 32x24
  output point checks
  output point direction
  output point samples
  output point state

  identity(spec: sampler<i32>) -> sampler<i32> {
    return spec
  }

  paint(widths: list<i32>, tint: color) {
    for i in 0..list.len(widths) {
      rect(2, 2 + i * 6, widths[i], 4, tint)
    }
  }

  draw {
    width = identity(sampler(10 to 15 with gaussian(12, 2)))
    widths = width.pick(3)
    descriptions = [sampler(#343a42 to #929ca8), sampler(#ffffff)]
    tint = descriptions[0].pick()
    segment = sampler({3, 4} to {18, 2} with beta(2, 3))
    direction = segment.pick()
    empty = width.pick(0)
    constant = sampler(40).pick(1)
    checks = {list.len(widths) + list.len(empty), constant[0]}
    samples = {widths[0], widths[2]}
    paint(widths, tint)
    state = {seed & 65535, seed >> 16}
  }
}

Expected result: compile-error (sampler result count must be between 0 and 256).

asset invalidBatch {
  size 1x1
  draw { values = sampler(10 to 15).pick(257) }
}

Expected result: compile-error (sampler result count must be i32).

asset invalidCountType {
  size 1x1
  draw { values = sampler(10 to 15).pick(1.0) }
}

Expected result: compile-error (random range lower bound exceeds the upper bound).

asset invalidDescription {
  size 1x1
  draw { spec = sampler(15 to 10) }
}

Expected result: compile-error (.pick needs a sampler<T> receiver).

asset invalidReceiver {
  size 1x1
  draw { width = 10; value = width.pick() }
}

Expected result: compile-error (expected a declaration inside the asset block, got sampler).

asset captureCollision {
  size 1x1
  sampler(value: i32) -> i32 { return value }
  draw { value = sampler(10 to 15) }
}

Expected result: trap (sampler result count must be between 0 and 256).

asset dynamicInvalidCount {
  size 1x1
  input i32 count = -1
  draw { values = sampler(10 to 15).pick(count) }
}

Expected result: trap (sampler handle is unassigned).

asset unsetDescription {
  size 1x1
  draw {
    if (false) { spec = sampler(10 to 15) }
    values = spec.pick(0)
  }
}

Expected result: trap (random range has no probability under the distribution).

asset missingMass {
  size 1x1
  draw {
    spec = sampler(0.0 to 1.0 with triangular(10, 15, 20))
    empty = spec.pick(0)
    value = spec.pick()
  }
}

Deterministic steppers

stepper(value) captures a constant. stepper(lower to upper with d) captures a range and optional distribution. stepper<T> supports i32, f32, color and vec2. Like a sampler, it evaluates lower, upper and distribution once, in that order, and stores an immutable description. Construction and stepping consume no RNG; draws written inside the arguments still run normally.

numbers = stepper(0.0 to 5.0).pick(10)
integers = stepper(0 to 5).pick(10)
colors = stepper(#000000 to #ffffff).pick(10)
positions = stepper({0.0, 0.0} to {10.0, 20.0}).pick(10)
distribution shape = gaussian(mean=0.5, sigma=0.15)
shaped = stepper(0.0 to 1.0 with shape).pick(10)

Pick a deterministic list

.pick(n) returns a new immutable list<T>. Its required count is i32 in 0–256; the optional argument name is n. .pick() without a count is an error. The receiver evaluates once before the count. After both expressions run, the operation checks the count, the handle and the complete list allocation. An unassigned description traps even for zero results.

Receiver Method Result
stepper<i32> .pick(n: i32) list<i32>
stepper<f32> .pick(n: i32) list<f32>
stepper<color> .pick(n: i32) list<color>
stepper<vec2> .pick(n: i32) list<vec2>

Repeated picks return identical values, independently of the seed. There is no cursor. A constant description repeats its captured value. With no distribution and n ≥ 2, the unit positions are i / (n - 1), including both exact captured endpoints. pick(0) returns an empty list; pick(1) returns the first step. Integer interpolation uses binary64 without first converting the endpoints to f32, then rounds to the nearest integer with halves away from zero. For example, stepper(0 to 5).pick(10) returns [0, 1, 1, 2, 2, 3, 3, 4, 4, 5]. Duplicates are allowed.

f32 ranges use convex binary64 interpolation followed by binary32 rounding and clamping. Colors use the sampler's RGBA interpolation, and vec2 uses its component interpolation, with one shared binary32 coefficient. Exact unit endpoints return the original endpoint value, including its component bits.

Distribution quantiles

with d uses deterministic quantiles of the distribution truncated to the range. All five families are supported: gaussian, triangular, beta, weighted and density. Numeric distributions use range units; color and vec2 distributions describe the position on the unit segment, from 0 to 1. Integer quantiles use the same half-away rounding after evaluating the binary64 position.

The first and last steps are the truncated support endpoints, which can be inside the captured range. Weighted endpoints are the first and last atoms with positive included weight. Density endpoints are the clipped boundaries of the first and last segments with positive area; zero-area gaps are skipped. At a cumulative boundary, weighted and density quantiles select the next positive atom or segment, using the same strict cumulative test as sampling. Weights can repeat values; quantiles do not guarantee exact count proportions for a small list. pick(0) does not require probability mass in the range.

Reversed numeric ranges, nonfinite endpoints, invalid distribution parameters, equal bounds and missing probability mass follow the sampler rules. Known invalid bounds, parameters and counts produce located compile errors; dynamic violations trap. Equal numeric bounds with a distribution retain that family's existing support checks.

Descriptions as values

Descriptions can be assigned, passed to helpers, returned, stored as const stepper<T>, or collected in list<stepper<T>>. Copies share the description without allocating or generating steps. There is no implicit conversion to or from a sampler, or between stepper element types. Lists use checked indices and the same 256-element limit as other lists. .distinct() compares descriptions structurally, including endpoint and distribution bits; .sort() does not accept them. Palettes retain their existing sampler semantics and do not accept stepper entries.

.pick(n) runs during generation even for a const description. It is forbidden inside an asset constant expression. The global keyword stepper is reserved in every name position, without a compatibility alias.

Costs and failures

A description costs 256 accounted bytes. A result list costs 128 + 64*n bytes in the shared 64 MiB generation arena. A first constant read retains each description once; a const list retains its list buffer and each distinct description and point distribution once. Copies and subsequent reads share those allocations. The constant retention rules match samplers.

Picking costs the instruction base plus one fuel per stored element. Each nonconstant step costs one additional fuel, and color/vec2 interpolation costs one more. Distribution work uses the existing kernel iteration and point/segment costs. A fuel, memory or quantile error publishes no partial list or successful generation result. See the format contract.

Executable examples

Expected result: pass.

asset steppedSwatches {
  size 64x32
  output list point positions
  output point unchanged

  const distribution shape = gaussian(mean=0.5, sigma=0.15)
  const stepper<color> gradient = stepper(#20385840 to #efb85fff)
  const list<stepper<i32>> widths = [stepper(2 to 6), stepper(4)]

  echo(spec: stepper<color>) -> stepper<color> {
    return spec
  }

  draw {
    before = seed
    colors = echo(gradient).pick(n=10)
    points = stepper({2.0, 2.0} to {56.0, 20.0}).pick(10)
    shaped = stepper(0.0 to 1.0 with shape).pick(10)
    integers = stepper(0 to 5).pick(10)
    sizes = widths[0].pick(10)
    empty = gradient.pick(0)
    first = gradient.pick(1)

    for i in 0..list.len(colors) {
      point = points[i]
      rect(point.x, point.y, sizes[i], 2.0 + shaped[i] * 6.0, colors[i])
      positions = {point.x, float(integers[i])}
    }
    unchanged = {seed == before, list.len(empty) + first[0].r}
  }
}

Expected result: compile-error (stepper result count must be between 0 and 256).

asset invalidSteps {
  size 1x1
  draw { values = stepper(0 to 5).pick(257) }
}

Expected result: compile-error (requires one i32 count).

asset missingCount {
  size 1x1
  draw { values = stepper(0 to 5).pick() }
}

Expected result: compile-error (generation effects).

asset generatedConstant {
  size 1x1
  const list<i32> values = stepper(0 to 5).pick(10)
  draw {}
}

Expected result: trap (stepper handle is unassigned).

asset unsetSteps {
  size 1x1
  draw {
    if (false) { spec = stepper(0 to 5) }
    values = spec.pick(0)
  }
}

Expected result: trap (no probability).

asset absentMass {
  size 1x1
  draw {
    spec = stepper(10.0 to 20.0 with weighted {0: 1, 1: 2})
    empty = spec.pick(0)
    values = spec.pick(1)
  }
}

Imports and part composition

import alias from "path.ips" precedes the asset. The path is relative to the file that contains the import, and an imported asset may import parts of its own (nested parts). Duplicate aliases in one asset, missing files and import cycles are errors. The compiler links every part into one executable; generation does not load source files. The alias surface is reserved for the surface namespace; rename an existing import using that alias when recompiling. Parts can share immutable mask/layer snapshots through resource outputs. They follow each hierarchy level's placement and D4 once; host indices still count geometry only.

Expected result: pass.

asset Part {
  size 4x4
  origin 2, 2
  input color tint = #9dc
  output point anchor
  output list point dots
  draw {
    rect(0, 0, 4, 4, tint)
    anchor = {2, 1}
    dots = {1, 1}
  }
}

Expected result: pass.

import leaf from "part.ips";
asset Composition { size 16x16
  output point attached
  draw {
    leaf.draw(at = {8, 8}, tint = #fc9, seed = seed, rot = 90, mirror_x = 1)
    attached = leaf.outputs.anchor
    pixel(int(leaf.outputs.anchor.x), int(leaf.outputs.anchor.y), #fff)
  }
}

alias.draw(seed = seed, at = {x, y}, uniforms...) requires one integer seed and a named placement. Seed may be positional or named. Transform arguments and input overrides are named only.

Placement at, transform arguments and optional input overrides are named only. Extra positional arguments, unknown names and missing seed or placement are errors. Input names variant and archetype remain ordinary uniform names. Unspecified uniforms use the part's defaults. at positions the part's declared origin. Call order determines paint order; the parent's RNG stream is restored after the part executes to its state after the dynamic argument expressions. An override containing unit() therefore still advances the parent stream; random draws made by the child use its isolated stream. A part may paint and write its own outputs, and its conservative exported effect summary includes callee/placement checks rather than assuming the child is pure.

Optional transform arguments are compile-time integers: rot is 0, 90, 180 or 270; mirror_x/mirror_y are 0 or 1, including boolean constants. Each can occur once. Rotation is applied before the mirrors. Positive angles are counterclockwise in the downward-y plane; transforms are exact D4 pixel permutations, not filtered image rotation.

Scalar alias.outputs.name reads the last called part's output, including point .x/.y. A read before that slot has been set traps. The value is in the reading asset's coordinates: the child's transform and placement are already applied, so it can place another part or be drawn directly. Assigning it to a parent's output propagates it. List outputs are host-facing and cannot be read this way. The host receives every output in the root asset's coordinates, including points, rectangles, circles and list elements written by part helpers; each enclosing call is applied once, when the value is written. A singular output keeps its last written value: a later call that does not write it neither replaces nor moves it.

Part call order

Uniform overrides evaluate in the part's input declaration order. Then the seed and placement expressions evaluate, regardless of the order of named arguments in the source. Transform arguments are compile-time constants. Each dynamic argument evaluates once. See parts and D4 transforms for the exact geometry mapping and rectangle bounds, and part instances for the output coordinates.

The current example uses named arguments in a different written order from their evaluation order. Omitted overrides use the part's resolved declaration defaults, including signed integer and floating constants; no new runtime input type is introduced for a signed source declaration.

Expected result: compile-error (cannot read list output).

import leaf from "../part.ips"
asset InvalidListRead {
  size 8x8
  draw { p = leaf.outputs.dots }
}

Nested parts

An imported asset may import parts itself, to any depth. Each import path is resolved beside the importing file: a hilt.ips in parts/hilt/ that imports "grip.ips" loads parts/hilt/grip.ips, whichever asset imports the hilt. ipc resolves paths on the file system, so .. after a symlinked directory leaves its target; embedding hosts resolve against their own source store.

Aliases are local to their asset. An instance is named by its alias path from the root, such as hilt.grip, and each alias path is a separate instance with its own inputs, outputs and RNG stream, even when two branches import the same file. That file is still loaded and parsed once. A parent draws and reads only its direct children; to expose a grandchild's output, the child copies it to an output of its own. At most 256 instances may result from expanding the imports. A cycle, including one through an equivalent path such as "../dir/a.ips", is an error naming the whole chain.

The following imports the composition example, which itself imports a part, and draws it twice; attached is the grandchild's anchor in this asset's coordinates:

Expected result: pass.

import branch from "composition.ips"
asset NestedPart {
  size 32x16
  output point attached
  draw {
    branch.draw(7, at = {0, 0})
    attached = branch.outputs.attached
    branch.draw(9, at = {16, 0}, mirror_x = 1)
  }
}

examples/colony/ is a larger three-level example: a colony of mushrooms, each importing its own stem and cap, with a firefly drawn at every cap's reported peak.

Layers and alpha composition

Capture geometry once

layer name { statements } starts a transparent RGBA8 target with the current target's dimensions and raster scale. Primitives, weather, helpers and part calls write into that target. The block executes once in the current RNG stream; called parts keep their isolated streams and existing argument order. Capture does not automatically paint its parent.

After the block closes, name holds an immutable layer. Copying it, passing it to a typed helper or returning it shares the buffer. A later capture with the same name creates a new snapshot; earlier copies remain unchanged. layer copy = image can state the local type explicitly. Lists and samplers of layers, pixel reads, arithmetic, comparisons, host inputs and asset constants are unsupported. An initializer skipped by a branch leaves an unset handle; using it traps rather than silently producing an empty image.

The name becomes available after its block, including in the function's usual scope outside a loop or conditional. return, break and continue close every capture they exit with its current image and restore the previous target. A loop inside a layer does not close that layer when it exits. Returning a value evaluates it before scope cleanup. Outputs written in a capture publish once, even if the captured image is never painted.

layer is a reserved keyword. Rename old locals, inputs and helpers with that spelling before compiling their source. A capture result must be a writable local; parameters, inputs, constants, palettes and output declarations cannot be its destination.

Expected result: pass.

asset captured_panels {
  size 32x24
  output point panelSize
  output point state

  draw {
    layer panel(alpha=surface.legacy) {
      width = 24 to 28
      frame(width)
      panelSize = {width, 18}
    }
    rect(0, 0, 32, 24, #243242)
    surface.paint(reuse(panel))
    pixel(16, 12, #ffffff)
    surface.paint(panel, blend=surface.legacy)
    state = {seed & 65535, seed >> 16}
  }

  reuse(image: layer) -> layer { return image }

  frame(width: i32) {
    rect(2, 3, width, 18, #343a42)
    rect(4, 5, width - 4, 14, #929ca8)
    pixel(4, 5, #c09060)
    pixel(width - 1, 18, #c09060)
  }
}

Paint a snapshot

Signature VM instruction
surface.paint(image: layer) -> void surface.paint
surface.paint(image: layer, blend: i32) -> void surface.paint
surface.paint(image: mask, color: color) -> void surface.paint.mask
surface.paint(image: mask, color: color, blend: i32) -> void surface.paint.mask

Paints a captured layer into the current target using its saved mapping. The image expression evaluates once, with no intrinsic RNG, rerendering or pixel copy. The full source canvas is visited in row order, including transparent and off-target pixels; transparent source pixels are skipped by legacy composition. surface.paint adds no at, rotation or mirror arguments: placement and D4 come from the existing part mechanism, shared with captured surfaces.

Omitted alpha on a layer inherits the active target's composition mode. The root always starts in surface.legacy. Select layer name(alpha=surface.source_over) for correct straight RGBA8 alpha, or alpha=surface.legacy for the frozen compositor. Mode arguments must be literal namespace constants; a numeric variable or const alias is rejected. surface.legacy and surface.source_over have scalar constant values 0 and 1, but those integers cannot be passed as mode arguments.

Helpers share the active target and its mode. A part's temporary canvas and its blit inherit that mode; ordinary part source therefore works with source-over when called inside such a layer. An explicit layer inside the part may override it. End, return, break and continue restore both the caller's target and its mode. Weather still changes RGB on the active target and preserves alpha.

Omitted blend on surface.paint(image) uses the destination's active mode. The image's capture mode does not select its blend. An explicit blend=surface.source_over or blend=surface.legacy overrides that one paint operation, without changing the target's mode for subsequent draws.

Expected result: pass.

import panel from "parts/translucent-panel.ips"

asset SourceOverPanels {
  size 32x24
  output point panelSize
  output point state

  reuse(image: layer) -> layer { return image }

  draw {
    rect(0, 0, 32, 12, #243040)
    layer glass(alpha=surface.source_over) {
      panel.draw(seed, at={8, 12})
      panel.draw(seed, at={24, 12})
      panelSize = panel.outputs.extent
    }
    surface.paint(reuse(glass), blend=surface.source_over)
    state = {seed & 65535, seed >> 16}
  }
}

Expected result: pass.

// An ordinary part inherits its caller's active alpha mode for draws and blits.
asset TranslucentPanel {
  size 12x16
  origin 6, 8
  output point extent

  draw {
    width = 8 to 12
    rect(0, 0, width, 16, #70a0d880)
    rect(1, 1, width - 2, 2, #ffffff60)
    rect(1, 13, width - 2, 2, #20406080)
    extent = {width, 16}
  }
}

Exact RGBA8 source-over

Channels use their stored 0–255 values; composition does not convert to linear light and does not change color.mix. For source Cs, As and destination Cd, Ad, calculate with unsigned 64-bit integers:

D = As * 255 + Ad * (255 - As)
Aout = (D + 127) / 255
N = Cs * As * 255 + Cd * Ad * (255 - As)
Cout = (N + floor(D / 2)) / D

All divisions are integer divisions. Each RGB channel rounds to nearest, with exact halves rounding up. If D == 0, write (0,0,0,0) instead of performing channel division. A zero-alpha source leaves a visible destination unchanged; over a zero-alpha destination it canonicalizes hidden RGB to zero. Full-source paint visits include those transparent pixels.

Every primitive, part blit and paint stores a rounded RGBA8 result before the next operation. Layers are never flattened or fused; grouping and order can change rounding. For example, #11223380 on an empty source-over target stores (17,34,51,128); adding #ff000080 stores (176,11,17,192).

Legacy remains exact: opaque replacement, floor RGB division for partial alpha, unchanged destination alpha and hidden RGB. Legacy part blits and paint skip zero-alpha source pixels. Existing binaries retain this behavior.

The namespace leaves an ordinary paint(...) helper available. An import alias named surface conflicts with the new namespace and receives a located diagnostic; rename that alias when migrating its source.

Expected result: compile-error (expected an input name, got layer).

asset ReservedLayerName {
  size 1x1
  input u32 layer = 1
  draw {}
}

Expected result: compile-error (expected a declaration inside the asset block, got sampler).

asset ReservedSamplerName {
  size 1x1
  sampler(x: i32) -> i32 { return x }
  draw {}
}

Expected result: compile-error (conflicts with a local).

asset layer_type {
  size 1x1
  draw {
    image = 1
    layer image { pixel(0, 0, #ffffff) }
  }
}

Expected result: compile-error (cannot be const).

asset layer_const {
  size 1x1
  const layer image = 1
  draw {}
}

Expected result: compile-error (surface.paint blend must be surface.legacy or surface.source_over).

asset surface_blend {
  size 1x1
  draw {
    layer image { pixel(0, 0, #ffffff) }
    surface.paint(image, blend=1)
  }
}

Expected result: compile-error (conflicts with the surface namespace).

import surface from "../part.ips"

asset surface_alias {
  size 32x24
  draw { surface.draw(seed, at={16, 12}) }
}

Expected result: trap (layer is unassigned).

asset layer_unset {
  size 1x1
  draw {
    if (false) { layer image {} }
    surface.paint(image)
  }
}

Expected result: trap (fuel exhausted).

asset layer_fuel {
  size 2048x2048
  draw {
    layer image {}
    surface.paint(image)
  }
}

Gaussian blur

Both surface types provide the ordinary method .blur(sigma):

Receiver Method Result
layer .blur(sigma: f32) layer
mask .blur(sigma: f32) mask

Use softImage = image.blur(2.0) or softMask = region.blur(sigma = 2). The required sigma is f32, with automatic i32 widening, and must be finite and in 0–64. Known invalid values fail compilation; invalid dynamic values trap with NumericRange. Receiver and sigma evaluate once, in that order. Helpers may accept and return either result, and calls can chain. blur remains an ordinary name for locals or helpers; there is no global blur builtin.

The immutable result preserves dimensions, scale, placement and D4. Sigma is measured in the input raster's pixels, independent of the active drawing target. No paint or RNG draw happens automatically. Sigma zero validates and shares the original handle without allocating; an unassigned handle still traps.

The filter is a horizontal then vertical Gaussian with radius ceil(3*sigma), normalized deterministic weights and nearest-edge repetition. RGBA uses premultiplied alpha during both passes and restores straight RGBA8 afterward, so transparent pixels do not darken colored edges. Channels use their stored values without color-space conversion and round nearest, with ties up. An output with zero alpha has zero RGB. Mask blur returns A8 coverage identical to the alpha of a blurred layer. The active clip applies when painting the result; it does not restrict filtering.

Expected result: pass.

asset softGlow {
    size 16x16
    draw {
        layer image(alpha = surface.source_over) { rect(5, 5, 6, 6, #ff804080) }
        softImage = image.blur(1.5).blur(0)
        softMask = mask.from(image).blur(sigma = 2)
        surface.paint(softMask, #40b0ff, blend = surface.source_over)
        surface.paint(softImage, blend = surface.source_over)
    }
}

The following examples put a sharp source on the left and its blurred result on the right. Source-over preserves the color of translucent layers:

Expected result: pass.

asset blurredLayer {
    size 96x64
    input f32 sigma = 2.0

    lantern(cx: f32) -> layer {
        layer image(alpha = surface.source_over) {
            rect(cx - 9, 20, 18, 26, #ec8930)
            rect(cx - 6, 23, 12, 20, #ffe0a0)
            rect(cx - 3, 26, 6, 14, #fff6da)
            rect(cx - 11, 17, 22, 4, #8db8c2)
            rect(cx - 11, 46, 22, 4, #8db8c2)
            line(cx - 5, 18, cx - 5, 12, 2, #8db8c2)
            line(cx + 5, 18, cx + 5, 12, 2, #8db8c2)
            line(cx - 5, 12, cx + 5, 12, 2, #8db8c2)
        }
        return image
    }

    draw {
        rect(0, 0, 96, 64, #182332)
        for x in 0..12 { rect(x * 8, 0, 1, 64, #243342) }
        for y in 0..8 { rect(0, y * 8, 96, 1, #243342) }
        surface.paint(lantern(24), blend = surface.source_over)
        softImage = lantern(72).blur(sigma)
        surface.paint(softImage, blend = surface.source_over)
    }
}

Expected result: pass.

asset softMask {
    size 96x64
    input f32 sigma = 2.0

    region(cx: f32) -> mask {
        layer shape {
            oval(cx, 32, 12, 15, #ffffff)
            rect(cx - 17, 29, 34, 6, #ffffff)
        }
        return mask.from(shape)
    }

    draw {
        rect(0, 0, 96, 64, #182332)
        for x in 0..12 { rect(x * 8, 0, 1, 64, #243342) }
        for y in 0..8 { rect(0, y * 8, 96, 1, #243342) }
        surface.paint(region(24), #64d9cc, blend = surface.source_over)
        softRegion = region(72).blur(sigma = sigma)
        clip(softRegion) {
            for y in 0..64 {
                tone = #50b8c6 to #f0dc8c
                rect(48, y, 48, 1, tone)
            }
        }
    }
}

For positive sigma, filtering costs 1 + K + W*H*(1 + 2*K) fuel, where K = 2*ceil(3*sigma)+1 and W/H are the input dimensions. Weight preparation, result initialization and both passes are reserved before filtering, even for constant or transparent images. Output storage and a binary64 row cache share the 64 MiB generation arena. The cache holds at most min(H,K) rows and costs 8*W*min(H,K)*channels bytes (4 channels for layer, 1 for mask); it is released on success or error. The result costs the same as a capture or A8 mask of its size. Errors never expose a partial result. Large rasters or wide kernels can exhaust the normal fuel budget.

Expected result: compile-error (blur sigma must be finite and in 0..64).

asset badBlur {
    size 1x1
    draw {
        layer image {}
        image.blur(sigma = 65.0)
    }
}

Expected result: trap (blur sigma must be finite and in 0..64).

asset badDynamicBlur {
    size 1x1
    input f32 sigma = -1.0
    draw {
        layer image {}
        mask.from(image).blur(sigma)
    }
}

Resources and compatibility

At most 32 layer and clip scopes combined may be active across the complete helper/part call stack. Each capture reserves 128 + 4 * width * height bytes from the shared 64 MiB generation arena. Copies and mapping views charge no second buffer. Root canvas allocation retains its existing limits. Extra part canvases use the arena and release their allocation when the last handle expires. Existing part allocation, blit fuel, pixel rules, output transforms and RNG stay unchanged.

A capture instruction costs one fuel plus one per target pixel cleared; paint costs one plus one per source pixel visited. These costs are reserved before allocation or scanning. Drawing inside a layer keeps the primitive's usual fuel cost. Each surface buffer releases when its last handle is destroyed; success or a trap clears the generation's remaining handles. A failed generation never publishes a partial image. Default fuel remains 5,000,000.

Layers use the file's type descriptor table. They do not add public C input/output enums or host layouts. Host outputs remain points, rectangles and circles; resource exports use their own internal table.

Masks from captured alpha

Signature VM instruction
mask.from(image: layer) -> mask mask.from

Returns an immutable mask view of a layer's alpha, including partial coverage. It shares the RGBA8 buffer and mapping without scanning pixels or drawing again. Use opaque geometry in a layer when the mask should represent shape alone; legacy partial draws on an empty layer keep zero alpha. A mask can be copied, passed to a helper or returned. mask name = expression is a typed copy. Unassigned masks trap. Masks cannot be const, host inputs, geometry outputs or list/sampler elements, and have no arithmetic, comparisons or pixel reads. mask and clip are reserved in every name position.

Coverage algebra

Signature VM instruction
mask.union(a: mask, b: mask) -> mask mask.union

Takes the maximum of the two coverages at each pixel.

Signature VM instruction
mask.intersect(a: mask, b: mask) -> mask mask.intersect

Takes the minimum of the two coverages at each pixel.

Signature VM instruction
mask.subtract(a: mask, b: mask) -> mask mask.subtract

Uses min(a, 255 - b), including partial alpha; this is not arithmetic subtraction.

Signature VM instruction
mask.invert(source: mask) -> mask mask.invert

Uses 255 - a inside the input's rectangle and zero outside that rectangle.

Two-mask operations allocate the bounding rectangle of both input surfaces, at the active target's raster scale. Inputs keep their buffers and coverage. Masks are interpreted in the current asset's coordinates; exact D4 and existing part raster scales are supported, with no arbitrary resampling. Reads outside an input rectangle are zero. Nonfinite scales or unrepresentable bounds trap. The result stores one A8 byte per pixel, without a duplicate RGBA image.

Expected result: pass.

asset maskAlgebra {
    size 8x4
    output point stream
    output rect extent

    echo(region: mask) -> mask {
        return region
    }

    draw {
        layer left(alpha = surface.source_over) {
            rect(0, 0, 3, 4, #ffffff80)
        }
        layer right {
            rect(2, 0, 3, 4, #ffffff)
        }
        mask leftRegion = echo(mask.from(left))
        rightRegion = mask.from(right)
        combined = mask.union(leftRegion, rightRegion)
        overlap = mask.intersect(leftRegion, rightRegion)
        cut = mask.subtract(combined, overlap)
        complement = mask.invert(combined)
        surface.paint(cut, color = #5eacdf, blend = surface.source_over)
        clip(complement) {
            rect(0, 0, 8, 4, #efba68)
        }
        stream = {seed & 65535, seed >> 16}
        extent = {0, 0, 5, 4}
    }
}

Disk morphology and offsets

Signature VM instruction
mask.expand(source: mask, radius: i32) -> mask mask.expand

Takes the maximum coverage in the disk dx*dx + dy*dy <= radius*radius. The result includes a border of radius pixels outside the original rectangle, so an outline can extend beyond it.

Signature VM instruction
mask.shrink(source: mask, radius: i32) -> mask mask.shrink

Takes the minimum in the same disk, including zero outside the surface. Its rectangle remains the input rectangle. Radius is i32 in 0–64, measured in target pixels; radius zero preserves coverage. Constant bad radii reject at compilation; dynamic bad radii trap before scanning.

Signature VM instruction
mask.offset(source: mask, dx: i32, dy: i32) -> mask mask.offset

Moves the mapping by dx, dy integer target pixels without copying the buffer. Offsets may move the whole mask off-canvas. Algebra and morphology retain those outside pixels, while painting only writes within the target.

Expected result: pass.

asset maskedBadge {
    size 32x24
    output rect panel
    output point stream

    stripes(region: mask) {
        clip(region) {
            for x in 4..29 {
                if (x % 5 == 0) {
                    line(x, 2, x - 6, 21, 1, #ffffff80)
                }
            }
        }
    }

    draw {
        width = 18 to 23
        layer shape {
            poly(#ffffff, 5, 4, width + 5, 4, width + 2, 19, 8, 19)
            panel = {5, 4, width, 15}
        }
        region = mask.from(shape)
        rim = mask.subtract(mask.expand(region, 1), region)
        inset = mask.shrink(region, 1)
        shadow = mask.offset(region, 2, 2)

        surface.paint(shadow, color = #17253980, blend = surface.source_over)
        surface.paint(rim, color = #b9dded, blend = surface.source_over)
        surface.paint(region, color = #397ea9, blend = surface.source_over)
        layer finish(alpha = surface.source_over) {
            stripes(inset)
            clip(region) {
                weather(7, 12)
            }
        }
        surface.paint(finish, blend = surface.source_over)
        stream = {seed & 65535, seed >> 16}
    }
}

Clip scopes and painting masks

clip(region) { ... } evaluates its mask once and limits writes on the current target. Helpers inherit the active clips. A layer capture, like a part, starts its own canvas without the parent's clips; they apply once where surface.paint paints the layer, even when it was captured inside the same clip. The parent likewise applies its clips once at a part's blit. The child's own clips can independently limit its image. Geometric outputs remain complete even when the image is clipped.

Combine clip coverage from outer to inner, starting at 255, using (a*b + 127)/255 at each step. Scale primitive/paint source alpha by the combined coverage with the same rounding, then use the active legacy/source-over kernel. Zero coverage skips the write entirely, preserving hidden RGB in both modes. surface.paint(region, color=...) first scales color alpha by mask coverage, then applies combined clip coverage. A mask requires a color; omitted blend inherits the destination mode. Image, color and static blend are evaluated in signature order, regardless of named argument order, without implicit RNG.

Weather runs its usual algorithm; each proposed write interpolates the old and proposed RGBA channels by combined clip coverage, rounding nearest with halves up. Coverage zero preserves the old pixel, and weather still preserves alpha. Normal scope exit, return, break and continue restore target, mode and clips in reverse entry order. A trap releases generation resources and publishes no image.

An A8 result reserves 128 + width*height bytes. Its instruction costs one fuel, plus one per zeroed pixel and one per algebra output pixel, or one per logical disk sample for morphology. This counts all disk samples even when an optimized scan stops early. Radius 1 has five samples; radius 64 has 12,853. mask.from and offset cost one without scanning; paint visits each source rectangle pixel. Every in-target attempted write charges one per active clip level, including zero coverage. Fuel is reserved before allocation or scanning. Shared views do not charge the full buffer again.

Expected result: compile-error (mask radius must be in 0..64).

asset invalidRadius {
    size 1x1
    draw {
        layer image {}
        region = mask.from(image)
        wide = mask.expand(region, 65)
    }
}

Expected result: compile-error (clip requires mask, got layer).

asset invalidClip {
    size 1x1
    draw {
        layer image {}
        clip(image) {}
    }
}

Expected result: compile-error (expected an input name, got mask).

asset reservedName {
    size 1x1
    input i32 mask = 1
    draw {}
}

Expected result: compile-error (expected a declaration inside the asset block, got clip).

asset reservedName {
    size 1x1
    clip(x: i32) -> i32 { return x }
    draw {}
}

Expected result: trap (mask is unassigned).

asset unsetMask { input i32 v = 0
    size 1x1
    draw {
        if (v == 1) {
            layer image {}
            region = mask.from(image)
        }
        clip(region) {}
    }
}

Expected result: trap (mask radius must be in 0..64).

asset dynamicRadius {
    size 1x1
    input i32 radius = 65
    draw {
        layer image {}
        region = mask.from(image)
        wide = mask.expand(region, radius)
    }
}

Expected result: trap (fuel exhausted).

asset morphologyFuel {
    size 1x1
    draw {
        layer image { pixel(0, 0, #ffffff) }
        region = mask.from(image)
        wide = mask.expand(region, 64)
    }
}

Export regions from parts

Declare output mask region or output layer image in a component and assign a matching handle. Its parent reads child.outputs.region or child.outputs.image after calling that direct child. Exporting does not paint: explicitly paint a layer or use a mask for clipping. Capture an entire part with layer name { child.draw(...) }; there is no part.render.

Each write freezes a snapshot in the owner's caller coordinates, including that call's raster scale, placement and D4. Propagating through the parent's own output applies the next hierarchy level once. Local copies retain their buffer and mapping after slot replacements. A later call with no write retains the previous snapshot and its previous placement. Reading requires this frame's last-called part; an unwritten slot traps. A root resource output is also internal and is not host-readable.

Expected result: pass.

import frame from "parts/exported-frame.ips"

asset resourceExports { size 16x12
  output mask retained
  output point anchor
  output point state

  draw {
    frame.draw(seed, at={5, 4}, mirror_x=1)
    image = frame.outputs.image
    region = frame.outputs.region
    // Both placements are already saved in the shared views.
    surface.paint(image, blend=surface.source_over)
    clip(region) {
      pixel(7, 6, #ff0000)
    }
    retained = region
    anchor = frame.outputs.anchor
    state = {seed & 65535, seed >> 16}
  }
}

Expected result: pass.

import patch from "exported-patch.ips"

asset exportedFrame { size 5x4
  origin 1, 1
  output mask region
  output point anchor
  output layer image

  draw {
    patch.draw(seed, at={2, 1}, rot=90)
    region = patch.outputs.region
    image = patch.outputs.image
    anchor = patch.outputs.anchor
  }
}

Expected result: pass.

asset exportedPatch {
  size 3x2
  origin 1, 0
  input i32 writeExport = 1
  output layer image
  output point anchor
  output mask region

  draw {
    if (writeExport != 0) {
      layer captured(alpha=surface.source_over) {
        rect(0, 0, 3, 2, #11223380)
        pixel(2, 1, #556677)
      }
      image = captured
      region = mask.from(captured)
    }
    anchor = {1, 0}
  }
}

The format has a separate resource-export table. C API output_count, part output ranges and host indices count geometry only; point/rect/circle tags and ABI layouts do not change. Slots reserve 64 bytes each in the generation arena. Views share a buffer, charge it once, and do not scan pixels. A write or read costs one instruction fuel and consumes no RNG. The last handle frees its surface; success and traps clear remaining generation resources. Handles cannot escape a generation or survive in a cross-seed cache.

Expected result: compile-error (expects mask, got layer).

asset exportType {
  size 1x1
  output mask region
  draw {
    layer image {}
    region = image
  }
}

Expected result: compile-error (resource exports cannot be lists).

asset exportList {
  size 1x1
  output list layer images
  draw {}
}

Expected result: trap (part resource export not written).

import patch from "../parts/exported-patch.ips"
asset exportUnset { size 8x4
  draw {
    patch.draw(seed, at={2, 1}, writeExport=0)
    surface.paint(patch.outputs.image)
  }
}

Writing idiomatic props

A prop is judged by how different, and how good, every seed looks. The rules below are the house style of the examples and the showcase props. Follow them when you write a new asset: they keep sources short, every roll visible and every seed distinct. The complete example at the end applies all of them.

The smallest prop in this style draws its proportions and its color from one shaped distribution and reports its collider:

Expected result: pass.

asset Boulder {
  size 32x24
  origin 16,22
  output rect collider

  draw {
    distribution typical = gaussian(mean=0.5, sigma=0.18)
    w = lerp(14.0, 26.0, 0.0 to 1.0 with typical)
    h = lerp(8.0, 16.0, 0.0 to 1.0 with typical)
    rock = #5b6170 to #8d94a3 with typical
    oval(16, 22.0 - h * 0.5, w * 0.5, h * 0.5, rock)
    oval(16.0 - w * 0.15, 22.0 - h * 0.7, w * 0.25, h * 0.2, color.shade(rock, 3))
    collider = {16.0 - w * 0.5, 22.0 - h, w, h}
  }
}

Draw values with to

Every random number is a range: w = 20 to 30, tilt = -0.4 to 0.4, lean = base - 2 to base + 2. Do not build draws by hand from unit(), range() or arithmetic such as 20 + int(unit() * 11): to says the same thing in one readable step, picks i32 or f32 from the bounds and includes both ends. Keep index(n) for selecting a palette element.

Shape every proportion with with

Uniform draws make half of all seeds look extreme. Give sizes, lengths and offsets a shape: gaussian for natural variation around a typical value, beta to lean towards one end (rust that is mostly light, wear that is mostly low) and triangular for a clear most-likely value. Declare a shared description once, as in distribution typical = gaussian(mean=0.5, sigma=0.18), and reuse it for every proportion of one object.

Distribution parameters are in the units of the drawn value; they are not rescaled to the range. 14.0 to 26.0 with gaussian(mean=0.5, sigma=0.18) has no probability inside its range and traps. Either describe the value in its own units, as in lean = -2.0 to 2.0 with gaussian(mean=0.0, sigma=0.8), or draw a normalized roll and map it: height = lerp(14.0, 26.0, 0.0 to 1.0 with typical).

Draw colors as gradients

Colors are drawn the same way as numbers. Pick the two ends of the look you want and draw between them: metal = #705845 to #c8c8c8. Add with to skew the result: body = color.shade(base, -6) to base with beta(alpha=4.0, beta=2.0) mostly lands near base. One draw moves every channel together, so the result never leaves the gradient. Do not draw red, green and blue separately, and do not compute random channels by hand.

Derive the related tones of one material from its drawn color with color.shade (lighter or darker steps), color.mix and color.hsv. Adjust a channel with rgba(c.r + 5, c.g, c.b, c.a); rgb() and rgba() clamp.

Use palettes for fixed sets

A set of hand-picked colors or levels belongs in an asset-level palette: palette hues = [#6fd3ff, #c38bff, #7dffb0]. Choose an element with hues[index(3)] and blend a color or f32 palette continuously with ramp(hues, t). Palettes are visible in every helper, so a material ramp is declared once and used everywhere. Keep literal colors out of the drawing code when a palette can name them.

Choose with pick and chance

Pick one of several values with pick(2, 3, 4) or pick(#c0a060, #a0a0a8, #b87333); all choices share one type. Decide an optional feature with chance(p), as in if (chance(0.3)) { ... }. Both consume one draw.

Make every seed a different prop

With only a seed, and every input at its default, every seed must give a visibly different result. Variety must never depend on inputs:

  • An ordinary style input with default 0 rolls from the seed; values from 1 force one look. Derive the roll by hashing the seed, as in look = style; if (style == 0) { look = 1 + (hash(seed ^ 0x2f6b9a13) & 1) }, so a rolled style renders exactly like the same style chosen explicitly.
  • Input defaults mean "roll it": 0 for u32 style switches and a transparent color for color inputs, as in input color tint = #00000000 read with if (tint.a == 0) { base = hues[index(4)] }.
  • Roll every part of the look (shape, material, colors and details) from the seed; an input only overrides one of those rolls.

Keep one silhouette across styles

Styles of one seed should read as the same object. Roll the shape inside its own stream, stream_push(hash(seed ^ 0x51ed2701)) ... stream_pop(), before any style-dependent choice. Make every roll unconditionally, even when a style does not use it, so a style never shifts the draws that come after it.

Pass inputs to helpers

Helpers see palettes and seed, but not inputs. Pass what they need as typed parameters, as in paint(base: color, look: i32). A local keeps the type of its first assignment, so write x = 0.0 when the value becomes f32 later.

Paint runs, not pixels

Every executed instruction spends fuel from the asset's budget (5,000,000 by default). Filling a 64×64 area costs about 4,200 fuel as one rect, 9,600 as one rect per row, 71,000 as a pixel loop and 84,000 with a helper call per pixel. Compute each row's extent once and paint it with rect; draw outlines with line, oval and poly. Reserve per-pixel loops for small details.

Before and after

Each program below compiles and runs, but breaks one rule. The correction follows each one.

Expected result: pass.

asset Plank {
  size 32x8
  draw {
    w = 20 + int(unit() * 11)
    rect(0, 2, w, 4, #8a5a33)
  }
}

Write w = 20 to 30: one readable draw with both ends included.

Expected result: pass.

asset Pillar {
  size 16x32
  draw {
    h = 14.0 to 26.0
    rect(4, 30.0 - h, 8, h, #9aa3ad)
  }
}

A uniform height makes every second pillar look extreme. Write h = lerp(14.0, 26.0, 0.0 to 1.0 with gaussian(mean=0.5, sigma=0.18)).

Expected result: pass.

asset Gem {
  size 8x8
  draw {
    c = rgb(40 to 90, 120 to 200, 180 to 255)
    rect(1, 1, 6, 6, c)
  }
}

Three independent channel draws produce colors outside the intended family. Draw the color as one gradient: c = #2878b4 to #5ac8ff.

Expected result: pass.

asset Disc {
  size 32x32
  draw {
    r = 10.0 to 14.0
    for y in 0..32 {
      for x in 0..32 {
        dx = float(x) + 0.5 - 16.0
        dy = float(y) + 0.5 - 16.0
        if (dx * dx + dy * dy < r * r) { pixel(x, y, #c0a060) }
      }
    }
  }
}

The per-pixel loop spends about 43,000 fuel. Painting one rect per row from the row's extent, half = sqrt(r * r - dy * dy), draws the same disc for about 2,200.

Expected result: pass.

asset Banner {
  size 16x16
  input u32 pattern = 1
  draw {
    rect(0, 0, 16, 16, #203040)
    if (pattern == 1) { rect(0, 6, 16, 4, #e0c060) }
    else { rect(6, 0, 4, 16, #e0c060) }
  }
}

With only a seed this banner never changes. Make the default 0 mean "roll it": input u32 pattern = 0, then kind = pattern; if (pattern == 0) { kind = 1 + (hash(seed ^ 0x6d2b79f5) & 1) } and branch on kind.

Pitfalls

These compile errors and surprises catch most first drafts:

  • min, max and clamp return i32 for integer-only arguments. Use an explicit float(...) or one f32 argument before reassigning an f32 local.
  • Colors cannot be compared with ==. Compare channels, as in tint.a == 0.
  • for bounds must be i32: write for i in 0..int(count).
  • There are no arrays besides palettes. Use a palette for a fixed list and compute repeated positions inside a loop.
  • part.outputs.name reads the last call of that part. Read an output right after the call that produced it.
  • A translucent color drawn inside a part over its empty canvas disappears. Draw glazes and shadows that cover other parts in the asset that places them.
  • A local keeps the type of its first assignment: x = 0 stays i32.

A complete example

The crystal below applies every rule: a hashed variant roll, a transparent color input that means "roll it", a shape in its own stream shaped by one distribution, a skewed color gradient, palettes, pick, chance, a helper that receives what it needs, and rows painted with rect.

Expected result: pass.

asset Crystal { input i32 v = 0
  size 32x32
  origin 16,30
  // Variant 0 rolls 1 or 2 from the seed; 1 is plain, 2 has a glint.
  // Transparent means "roll a colour from the seed".
  input color tint = #00000000
  output rect collider
  palette stone = [#3b3f4a, #565b69, #767c8c]
  palette hues = [#6fd3ff, #c38bff, #7dffb0, #ff8fa3]
  palette glints = [#ffffff, #fff3b0, #ffd166]

  draw {
    // Hash the seed instead of drawing, so a rolled variant renders
    // exactly like the same variant chosen explicitly.
    look = v
    if (v == 0) { look = 1 + (hash(seed ^ 0x2f6b9a13) & 1) }

    // The shape draws from its own stream, so every variant keeps the
    // same silhouette and only the colours change.
    stream_push(hash(seed ^ 0x51ed2701))
    distribution typical = gaussian(mean=0.5, sigma=0.18)
    height = lerp(14.0, 26.0, 0.0 to 1.0 with typical)
    width = lerp(7.0, 12.0, 0.0 to 1.0 with typical)
    lean = -2.0 to 2.0 with gaussian(mean=0.0, sigma=0.8)
    facets = pick(2, 3, 4)
    stream_pop()

    // Colours are drawn, not computed: a gradient between two ends,
    // skewed towards the bright end.
    base = tint
    if (tint.a == 0) { base = hues[index(4)] }
    body = color.shade(base, -6) to base with beta(alpha=4.0, beta=2.0)
    glint = glints[index(3)]
    if (look == 2) { body = color.mix(body, glint, 0.35) }

    // Paint whole rows with rect(), never pixel by pixel.
    top = 30.0 - height
    rows = int(height)
    for r in 0..rows {
      t = float(r) / float(rows)
      half = width * 0.5 * smoothstep(0.0, 0.4, t)
      x = 16.0 + lean * (1.0 - t) - half
      rect(x, top + float(r), half, 1.0, color.shade(body, 2))
      rect(x + half, top + float(r), half, 1.0, color.shade(body, -3))
    }
    for f in 1..facets {
      fx = 16.0 + lean * 0.5 + (-3.0 to 3.0)
      line(fx, top + 4.0, 16.0, 29.0, 1, color.shade(body, 5))
    }
    pedestal(28.0)
    if (chance(0.6)) { pixel(int(16.0 + lean), int(top) + 2, glint) }
    collider = {16.0 - width * 0.5, top, width, height}
  }

  // Palettes are visible in helpers; inputs and v/a are not, so pass them.
  pedestal(y: f32) {
    for i in 0..3 {
      w = 18.0 - float(i) * 4.0 + (-1.0 to 1.0)
      rect(16.0 - w * 0.5, y - float(i), w, 2.0, stone[i])
    }
  }
}

Compiler tools and diagnostics

Compiler output

ipc file.ips -o file.iprop validates and compiles the entry and imports. Production output is optimized, debug-stripped and losslessly compressed when compression saves space. --debug retains source maps/symbols; --strip restores the default (the last flag wins). --no-compress writes an expanded file; --no-opt disables compiler optimization for inspection. ipval validates a binary, ipdis disassembles it and ipgen generates pixels/outputs. The .ipa assembly language is the VM-level tool format. For exact binary32 transport, assembly accepts bits:0xXXXXXXXX with eight hexadecimal digits wherever it accepts f32 constants/defaults/origins. ipdis uses this spelling for subnormals and nonfinite values, preserving NaN payloads and signs. This assembly spelling is not an .ips literal; ordinary source arithmetic and constant evaluation can produce those values.

ipc --describe-language exports the implemented source rules, restrictions, shared call signatures and independent product/language/format/C API/ABI versions. See the language-description contract for field meanings and executable acceptance cases.

The documentation build also generates LLM_REFERENCE.md, the complete language for coding agents: the idioms chapter first, then the handbook's language chapters, every builtin and checked registered examples. See reference generation for its budget, selection and validated rejection/repair pairs.

ipc entry.ips --check --diagnostics=json runs the same import, parsing, typing, planning, lowering, assembly, optimization and final-file validation pipeline entirely in memory. It writes no executable or formatted source; combining --check with --output or --format is an invocation error. --no-opt, --debug and compression options select the same artifact policy as ordinary compilation, while still writing no files. Each imported source is loaded once, and error snippets use that exact owning snapshot.

JSON diagnostics use schema_version: 1 with independently recorded product and source-language versions, status and diagnostics. Each diagnostic provides typed stable code/stage/severity, message, file, an end-exclusive range and SHA-256 identity of its exact source snapshot. Offsets are zero-based UTF-8 bytes; line/column positions are one-based Unicode codepoints. CLI/I/O locations may be null. Optional expected/actual types, suggestions, related locations and fix-its have explicit fields. See the protocol and schema. --diagnostics=text selects the readable renderer. Bounded recovery collects at most 20 diagnostics; dependent cascades are suppressed and a damaged AST never reaches generation.

ipc entry.ips --format canonical.ips also writes canonical parseable source. The output uses two-space indentation, explicit expression grouping, word keywords and normalized literals. Calls use the compiler's shared binding and evaluation plan: ordinary builtins and helpers follow parameter order, poly puts vertex coordinates before its colour, and a known part puts uniforms in declaration order before seed/at and transforms. Unknown imports retain written order until their declarations are available. Helpers keep their existing helper(parameters) -> type syntax. It must differ from the input and binary output. Comments keep their exact contents, including delimiters and internal whitespace. A call containing comments keeps its written argument order. When the output is in another directory, import paths are adjusted to refer to the same files, with aliases and comments preserved. Formatting is idempotent and preserves executable bytes after stripping source-line debug data.

ipc entry.ips --format canonical.ips --format-only runs the same full in-memory compile/validation pipeline and writes only canonical source. It creates no .iprop; --format without --format-only still compiles normally. Source output must differ from the entry, all imports and any requested binary, including filesystem aliases. --format-only requires --format and rejects --output and --check. A compile or output error preserves the previous source destination: the completed source is published by an atomic rename on its destination filesystem. Temporary files belong to an exclusive directory and are removed on failure. This is atomic replacement, without a power-loss durability guarantee. A final destination symlink is replaced by the formatted file; its earlier referent is left intact. Symlinks in the destination directory are resolved before deriving relative imports. JSON diagnostics keep the same schema and exit statuses in this mode.

Optional semicolons and redundant grouping normalize away when they carry no comments. A bare return; retains its required delimiter, and punctuation carrying comments remains to preserve attachment. Float text roundtrips to the same binary32 value, including signed zero; integer inputs preserve their signed declaration or unsigned bit-pattern spelling.

Legacy assignments remain in their original branch or loop. Formatting does not insert let, move an initializer to the function entry or rewrite helper declarations with fn. In particular, a local first assigned in a skipped branch keeps its existing typed zero at runtime; moving an RNG expression outside that branch would change the stream. Existing identifiers and helper names let, fn and vec2 keep their bindings. Migration advice belongs in a separate lint or diagnostic action rather than a formatting edit.

Comments are owning trivia on the source tokens, and AST nodes refer to half-open token ranges. A comment beginning on the same line as the previous token belongs after that token; other comments belong before the next token. Thus comments before declarations follow their declaration when its position is normalized, and comments after statements remain after the statement. Within expressions and between named arguments, comments stay on the same side of the adjacent token. Optional parentheses and separators carrying trivia stay in the output, so multiline comments keep their token owner when the result is parsed again. Comments in an empty block belong before its closing brace, and a commented empty else block stays present. Repeated scalar metadata retains each earlier declaration's source context inside the asset; the last declaration still sets the effective value. Comments after the file belong to the end token. None of this trivia affects execution or the byte/codepoint locations of syntax tokens.

The C++ compilation API retains debug information so callers can select an artifact policy. The editor requests it for inspection; binary exports use production defaults. Optimization folds eligible constants, removes unused pure computations and compacts pools/locals while preserving image/output bits, RNG order, drawing effects and runtime traps. --no-opt exposes the reference lowering; emitted instruction counts and total fuel may differ.

Diagnostics

Errors identify the file and one-based line/Unicode codepoint column; tabs count as one column. The VS Code extension maps these locations to UTF-16 positions. Close misspellings suggest canonical names, including transpositions. Parsing/type errors fail compilation without producing a valid executable. Runtime traps are distinct: the program can compile but a given invocation can exceed a resource limit or encounter invalid data.

Expected result: compile-error (cannot assign to input).

asset InvalidInputWrite {
  size 8x8
  input color tint = #9dc
  draw { tint = #fff }
}

Expected result: compile-error (cannot store rect).

asset InvalidCompound {
  size 8x8
  draw { bounds = {0, 0, 4, 4} }
}

Execution limits

The compiler currently emits a default fuel budget of 5,000,000. The host can supply its invocation budget; each executed instruction/drawing operation retains its normative cost. Optimizations may reduce total fuel without changing image/output bits. A loop need not be statically bounded, but execution traps when fuel runs out.

Canvas dimensions are limited to 16384 per side in the reference runtime. Hosts may impose smaller limits on dimensions or total pixel counts. Binary pools/indices, function nesting, stream stacks and structured outputs also have bounds; see validation and resource limits. Compressed v0.7 files bound both encoded and decoded size to 64 MiB. Host-specific limits and invalid input values can reject an invocation before draw starts.

Integer division/remainder by zero traps; float division follows IEEE-754. Noise coordinate checks, nonfinite HSV hue, invalid geometry, unset part output reads and unbalanced stream pops can also trap. Traps must be handled by the host; a partial image is not a successful generation result.

--describe-language separates each signature's intrinsic checks from its argument expressions and from effect_rules.resource_traps. Fuel exhaustion and the runtime call-depth limit can affect otherwise valid programs. Operand-stack bounds are checked by executable validation; allocation failure is a host/resource failure rather than a signature's numeric precondition.

Expected result: trap (divide by zero).

asset DivisionTrap {
  size 1x1
  input u32 divisor = 0
  draw { value = 1 / divisor }
}

Grammar

This EBNF records the current parser. Braces in the grammar mean repetition, square brackets mean optional syntax and quoted strings are literal tokens. identifier is an ordinary identifier; name additionally accepts soft keywords as described in source syntax. Semantic checks such as types, restricted declaration constants, required declarations and acyclic calls apply after parsing. Strings are only valid as import paths after typing.

program       = { import }, asset, EOF ;
import        = "import", name, "from", string, [ ";" ] ;
asset         = "asset", identifier, "{", { declaration }, "}" ;
declaration   = size | origin | input | output
              | palette | asset_constant | draw | helper ;
size          = "size", integer, (integer | xdigits), [ ";" ] ;
origin        = "origin", expression, ",", expression, [ ";" ] ;
input         = "input", input_type, name, "=", declaration_constant, [ ";" ] ;
input_type    = "color" | "f32" | "u32" | "i32" ;
output        = "output", [ "list" ], output_kind, name, [ ";" ] ; (* list: geometry only *)
output_kind   = "point" | "circle" | "rect" | "mask" | "layer" ;
palette       = "palette", name, "=", "[", [ expressions ], "]", [ ";" ] ;
asset_constant = "const", value_type, name, "=", expression, [ ";" ] ;  (* reserved "const" *)
draw          = "draw", block ;
helper        = name, "(", [ parameters ], ")", [ "->", value_type ], block ;
parameters    = parameter, { ",", parameter }, [ "," ] ;
parameter     = name, ":", value_type ;
scalar_type   = "i32" | "f32" | "color" | "vec2" | "distribution" ;
sampler_type  = "sampler", "<", ("i32" | "f32" | "color" | "vec2"), ">" ;
stepper_type  = "stepper", "<", ("i32" | "f32" | "color" | "vec2"), ">" ;
list_type     = "list", "<", (scalar_type | sampler_type | stepper_type), ">" ;
value_type    = scalar_type | list_type | sampler_type | stepper_type | "layer" | "mask" ;
block         = "{", { ";" | statement }, "}" ;
clip_scope    = "clip", "(", expression, ")", block ;
statement     = layer_capture | clip_scope | assignment | call_statement | if | while | for
              | "break" | "continue" | return ;
assignment    = [ "distribution" | "layer" | "mask" | list_type | sampler_type | stepper_type ], name, "=", expression ;
call_statement = (name | "mask" | "sampler" | "stepper"), { postfix_suffix } ;  (* must end as a call, capture, pick or list method *)
if            = "if", "(", expression, ")", block,
                [ "else", (if | block) ] ;
while         = "while", "(", expression, ")", block ;
for           = "for", identifier, "in", expression, "..", expression, block ;
return        = "return", [ expression ] ;
expression    = logical_or ;
logical_or    = logical_and, { "||", logical_and } ;
logical_and   = bit_or, { "&&", bit_or } ;
bit_or        = bit_xor, { "|", bit_xor } ;
bit_xor       = bit_and, { "^", bit_and } ;
bit_and       = equality, { "&", equality } ;
equality      = comparison, { ("==" | "!="), comparison } ;
comparison    = random_range, { ("<" | "<=" | ">" | ">="), random_range } ;
random_range  = shift, [ "to", shift, [ "with", shift ] ] ;  (* reserved "to"/"with"; no chaining *)
shift         = additive, { ("<<" | ">>"), additive } ;
additive      = multiplicative, { ("+" | "-"), multiplicative } ;
multiplicative = unary, { ("*" | "/" | "%"), unary } ;
unary         = ("-" | "!" | "~"), unary | postfix ;
postfix       = primary, { postfix_suffix } ;
postfix_suffix = ".", name | "(", [ arguments ], ")"
               | "[", expression, "]" ;
primary       = literal | name | "mask" | compound | list_literal | point_distribution
              | sampler_capture | stepper_capture | "(", expression, ")" ;
point_distribution = ("weighted" | "density"), "{", [ distribution_points ], "}" ;  (* reserved family names *)
distribution_points = expression, ":", expression, { ",", expression, ":", expression }, [ "," ] ;
sampler_capture = "sampler", "(", expression, ")" ;  (* reserved; the outer random_range is captured *)
stepper_capture = "stepper", "(", expression, ")" ;
stepper_pick  = postfix, ".pick", "(", [ "n", "=" ], expression, ")" ;
sampler_pick  = postfix, ".pick", "(", [ [ "n", "=" ], expression ], ")" ;
list_method   = postfix, (".distinct" | ".sort"), "(", ")" ;  (* ordinary method names, statically typed *)
surface_blur  = postfix, ".blur", "(", [ "sigma", "=" ], expression, ")" ; (* layer or mask receiver *)
list_literal  = "[", [ expressions ], "]" ;
compound      = "{", [ expressions ], "}" ;
expressions   = expression, { ",", expression }, [ "," ] ;
arguments     = argument, { ",", argument }, [ "," ] ;
argument      = [ name, "=" ], expression ;
literal       = integer | floating | color | string | "true" | "false" ;
declaration_constant = literal | "(", declaration_constant, ")"
                     | "-", constant_atom ;
constant_atom = literal | "(", constant_atom, ")" ;
surface_mode = "surface.legacy" | "surface.source_over" ;
layer_capture = "layer", name, [ "(", "alpha", "=", surface_mode, ")" ], block ;
identifier    = (letter | "_"), { letter | digit | "_" } ;
xdigits       = ("x" | "X"), digit, { digit } ;
integer       = digit, { digit } | "0", ("x" | "X"), hex, { hex } ;
floating      = digits, ".", digits, [ exponent ] | digits, exponent ;
exponent      = ("e" | "E"), [ "+" | "-" ], digits ;
color         = "#", (3 hex | 4 hex | 6 hex | 8 hex) ;

In return, the optional expression is omitted when the next token is ; or }; newline alone does not terminate it. Numeric token conversion has the literal restrictions described above. The parser allows empty palette/compound syntax, but semantic checks reject empty palettes and compounds whose arity is not 2, 3 or 4. Metadata keywords are recognized contextually; their names cannot introduce helpers in that position. Declaration typing rejects strings as values and permits unary minus only on numeric literals. Palette elements use the same restricted constant grammar after parsing; a general expression AST does not make it a valid palette element.

Asset const declarations use the general expression grammar with the compile-time restrictions. Their graph permits forward references and rejects cycles. const(...) still introduces a helper.

Shifts bind more tightly than comparisons on both sides: value < 1 << bits means value < (1 << bits). Comparison chains associate leftward, so a < b < c means (a < b) < c, not two comparisons joined by &&.

Declaration effects

Asset const and sampling-palette expressions must be pure compile-time values. An outer palette range captures a sampler; scalar entries then lift to constant descriptions. Runtime list ranges stay eager, and sampler picks cannot execute in declaration initializers. See palettes.

Point distribution literals accept general numeric runtime expressions. Static operands are checked during compilation; const declarations require pure operands. Each key/value pair snapshots its original numeric type in written order. See weighted descriptions and density profiles.

Point family names are reserved. A point literal remains an ordinary expression, including inside a helper call used as a loop upper bound.

layer is reserved both for capture and the value type. layer name = expression is a typed handle copy. Lists/samplers of surfaces are rejected. Omitted alpha inherits the active target; surface.paint uses that target's mode unless blend explicitly selects one operation. See alpha composition.

mask is also a reserved value type and the mask namespace. mask.from uses the existing reserved from token only in this namespace. clip introduces a statement block; both words remain unavailable as ordinary names.

Maintaining this documentation

Edit these Markdown chapters for explanations, limitations and examples. book.json fixes their reading order. The site, downloadable book, function signatures and search index are generated during the build. The overview must link every chapter in book order; CI checks the contents and fragments.

An example directive includes a real source file; keep its relative path valid from the chapter. Register each example in cases.json as a successful generation, an expected compile error or an expected runtime trap. CI requires every example to be registered and referenced by the book, and exercises every documented builtin in the successful examples.

Each public builtin has exactly one builtin directive followed by its handwritten behavior and limitations. Signatures/types/named parameter order come from ipc --describe-language, including variadic forms. Adding a builtin requires an explanation and executable example; missing/duplicate descriptions fail documentation checks.

LLM_REFERENCE.md quotes whole chapters for coding agents. Put <!-- llm: skip --> on the line directly above a paragraph that only matters to the documentation itself, implementations or other hosts, such as a note about an example the reference does not show; the paragraph stays in the book and the reference leaves it out.

The schema 4 language export also supplies source_rules.samplers.methods from the same receiver-first registry used for typing and lowering. Sampler method signatures are separate from global pick; their receiver/count order and intrinsic effects must be propagated to docs, LLM reference and consumers. source_rules.palettes records capture/lifting, pure declaration evaluation, direct fallback versus first-class checked indexing and ramp restrictions. Const sampler types and their list-length overloads use the same resolver and catalogue as scalar constants; no parallel signature table is maintained. The language export supplies evaluation_order on every overload and variadic signature. Its entries name signature slots, including compile-time palette and transform slots; legacy poly lists coordinates before color. call_rules.helper_evaluation_order is declaration. The part rule uses $uniform as a template expanded once per imported input, in declaration order. Each part parameter records required, positional, and an optional default: integer zero for transforms or input_declaration for uniforms. Uniform defaults are validated with their declarations and retain their exact stored values. These additive fields come from the same call signatures used by binding, typing, lowering and source formatting.

source_rules records actual input/value types, widening, legacy local scope, visibility and restrictions. Every signature also has intrinsic effects; effect_rules describes helper/part summaries, output writes and resource failures. Read these from the compiler export instead of maintaining a second signature/effect list. A callee or argument may have effects beyond an outer operation's intrinsic summary. The language-description contract explains the fields and links executable acceptance checks.

Keep current cases consistent with the implemented language contract. Historical snapshots remain unchanged; give new expected failures separate fixtures instead of changing a successful case into a rejection.

python3 scripts/language_docs.py check --compiler build/tools/ipc
python3 scripts/language_docs.py build --compiler build/tools/ipc
npm --prefix website run docs
npm --prefix website run test:docs

Checks validate relative links and fragments, compile and execute examples, verify expected failures, and compare generated content with the current compiler/version. HTML build checks validate rendered anchors and search entries. The native install, WASM archive and website include the generated documentation and its source/version manifest; packaging verifies their extracted copies. Full native builds generate build/language-docs/ when Python 3.10 or newer is available. Installed SDKs contain it under share/iprop/; WASM archives and the website use the same docs/, examples/ and spec/ layout. The manifest binds all handbook chapters, numerical contracts and examples to their file digests, product version and source commit. Release collection rejects missing files, changed inputs, foreign commits and documentation generated from uncommitted changes. Native install checks also compare the catalog with ipc --describe-language from the installed compiler.

Release documentation is built from the exact release tag. Development builds are labeled as development/candidate documentation and identify their source commit. Historical versions remain in their Git tags, rather than copies updated accidentally alongside the current chapters.

Parameterized list types and collection rules are emitted from the shared type descriptors. Keep schema-4 readers, editor highlighting, fixed list overloads and the legacy polygon variadic template aligned when extending collections.

Point distribution capabilities are emitted as source_rules.point_distributions, separate from global callable signatures. Update their schema, strict readers, real compiler acceptance cases, literal formatting/highlighting and executable examples together when a family is added.

source_rules.reserved_keywords comes from the lexer. source_rules.surfaces describes reserved capture, alpha modes, inheritance, integer rounding, immutable helper transfer, mode constants, cleanup, resource accounting and paint effects. Its limits and opcodes come from runtime constants and the opcode registry. The LLM reference default and maximum are fixed at 100 KiB through the 0.7 release.

source_rules.masks adds coverage views, A8 results, bounds, radius and disk rules, clip inheritance and normative logical sample costs. Keep the schema, strict reader, editor, overload argument names and real compiler probes aligned. The mask/clip opcodes and limits come from the same runtime registries.

Migrating from 0.6 to 0.7

Product 0.7.0 is released. Update source declarations and host clients, then rebuild every asset with the current compiler. The binary format is unversioned: the header bytes that held a version are reserved zeros in plain files, compressed envelopes and payloads, so the reader rejects every earlier binary. There is no automatic binary or control-input migration.

Independent versions

Contract 0.6 release 0.7 development
Product 0.6.0 0.7.0
Source language 0.6 0.7
Binary format 0.4 or 0.5, in a 0.4 compressed envelope unversioned (reserved zero header bytes)
C API 0.3 0.4
ABI / SONAME 0.4 0.5
Language-description schema 2 4

Query ipc --describe-language and the loaded C library independently. The export contracts describe schema requirements; the ABI policy describes client rebuilding.

Replace implicit selections with inputs

Remove variants and archetypes metadata declarations. Bare v and a are no longer supplied by the compiler; declare the selections your asset needs, and define any supported values or validation in its source:

asset example {
  size 1x1
  input i32 style = 0
  input i32 characterClass = 0
  draw {
    if (style == 1) { pixel(0, 0, #ffcc88) }
  }
}

Pass selections as ordinary input overrides:

ipgen example.iprop --seed 7 --uniform style=1 -o example.png

v, a, variants and archetypes are ordinary names; undeclared reads fail. The runtime adds no selection counts, default index choice or range checks. Seed selection and explicit sampler picks retain their algorithms.

Parts declare their own inputs. Replace implicit child selections with named input arguments; calls accept seed, named at, constant rot, mirror_x, mirror_y and declared child inputs. An explicitly declared input named v or a binds like any other input. See composition.

Rebuild host clients

C clients must rebuild against API 0.4 / ABI 0.5. Generation options and metadata descriptors no longer contain variant/archetype fields. There is no replacement guarantee for ABI 0.4 clients. Use ordinary root-input reflection, parameter sets and generation options from the C API guide.

Update CLI invocations, WASM/JS generation arguments, SFML/Godot input settings and editor integrations to use seed, dimensions and declared inputs. Sheets enumerate consecutive seeds with fixed input values. Remove reads of retired selection metadata from language-description consumers; schema 4 rejects it.

Rename reserved identifiers

The keywords const, to, with, distribution, sampler, stepper, weighted, density, layer, mask and clip cannot name assets, helpers, inputs, outputs, parameters, locals, loop counters, aliases or members. Rename old declarations before recompiling. surface is a builtin namespace and cannot be an import alias. blur, distinct and sort remain ordinary method names. The name rules describe the full policy.

Maintained examples use dotCount for the former input name density; update host overrides accordingly. An old helper named sampler or input named layer fails parsing with IPS-PARSE-001, exit 1 and no binary artifact. The formatter cannot rename a rejected declaration automatically.

Keep integer and floating locals consistent

min, max and clamp return exact i32 when all arguments are i32. Any f32 argument selects the floating overload, widening integers. Integer clamp accepts equal or reversed bounds. An existing local retains its first-assignment type.

In 0.6, x=1.0; x=min(1,2) returned f32. In 0.7 it fails because the result is i32. Use x=float(min(1,2)) or x=min(1,2.0), and apply the same rule to max/clamp. The formatter preserves argument types and inserts no casts.

Expected result: compile-error (local 'x' is f32, cannot assign i32).

asset migration {
  size 1x1
  draw {
    x = 1.0
    x = min(1, 2)
  }
}

Expected result: compile-error (local 'x' is f32, cannot assign i32).

asset migration {
  size 1x1
  draw {
    x = 1.0
    x = max(1, 2)
  }
}

Expected result: compile-error (local 'x' is f32, cannot assign i32).

asset migration {
  size 1x1
  draw {
    x = 1.0
    x = clamp(1, 0, 2)
  }
}

These failures use IPS-TYPE-001, with expected f32 and actual i32. Accepted integer calls may also preserve values that previously rounded through f32. See numbers and interpolation.

Make sampling explicit

New samplers capture operands once without an intrinsic draw. .pick() returns one value; .pick(n) returns a list of 0–256 results from the caller stream. Copying or indexing a description does not draw.

A sampling palette, such as palette widths=[10 to 15,40], returns a description on indexing. Use widths[i].pick() to draw, and save the result when several values must share it. Changing a value palette into a sampling palette changes where RNG is consumed. Direct palette indices retain last-element fallback; passing a palette as a list gives checked indexing and a 256-item limit.

Steppers provide deterministic .pick(n) batches without RNG or a cursor. They have no scalar .pick() and do not convert to samplers. Switching to weighted or density draws can change seed mapping; make that asset change deliberately.

Use captured surfaces deliberately

Layer capture executes geometry once and does not automatically paint its parent. Call surface.paint to reuse a snapshot. The root begins in legacy alpha mode; choose surface.source_over for straight RGBA8 alpha composition. Helpers and part canvases inherit the active mode. An explicit paint blend affects that operation only.

Masks, clipping, internal resource exports and .blur(sigma) use immutable surface handles; host output indices continue to count geometry only. Review surface rules when adopting these features, including their fuel and memory limits. Part geometry outputs already include placement in caller coordinates; adding placement again would move them twice.

Check and rebuild

ipc entry.ips --check --diagnostics=json
ipc entry.ips -o entry.iprop
ipval entry.iprop
ipgen entry.iprop --seed 7 -o entry.png

Compare regenerated images and named outputs at your asset's relevant seeds, sizes and input combinations. Update consumers of renamed inputs, and handle runtime traps as described in tooling.