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.
- Your first asset
- Assets, inputs and outputs
- Source syntax and names
- Types, expressions and variables
- Control flow
- User functions and argument evaluation
- Randomness and seeds
- Numbers and interpolation
- Drawing and colors
- Vectors and distance fields
- Noise and procedural utilities
- Palettes
- Immutable lists
- Captured samplers
- Deterministic steppers
- Imports and part composition
- Captured layers, masks and clipping
- Writing idiomatic props
- Compiler tools and diagnostics
- Grammar
- Maintaining this documentation
- 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/0Xplus hexadecimal digits. They become 32-bit patterns, so0xffffffffis 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; write0.5, not.5. Evaluation uses IEEE-754 binary32. - Colors:
#RGBand#RGBArepeat each digit;#abcis#aabbcc. Long forms are#RRGGBBand#RRGGBBAA. Missing alpha is 255. trueandfalsearei32values 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":
0foru32style switches and a transparent color for color inputs, as ininput color tint = #00000000read withif (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,maxandclampreturni32for integer-only arguments. Use an explicitfloat(...)or one f32 argument before reassigning an f32 local.- Colors cannot be compared with
==. Compare channels, as intint.a == 0. forbounds must bei32: writefor 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.namereads 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 = 0staysi32.
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.