The language, end to end
Version 3.137.1 | July 2026 | Generation 3 (pure C)
This replaces the V1.x reference. CX's implementation moved from PureBasic (three generations: alfa → beta/v02 → retired 2026-05-30) to pure C. The language most users write hasn't changed shape as much as the machine under it has — this document covers both, and calls out the handful of places where user-visible syntax genuinely moved (struct field access, marker syntax, new return-type suffixes).
CX is C eXpanded — the C language made easier by being clever, never by losing power. It is not a replacement for C: it compiles through C, retains every escape hatch, and runs on any C compiler you choose.
The design goal is the minimum code to achieve the objective. The mechanism is to put complexity into the variable and its type suffix, so the source stays flat and readable:
_C{} blocks, FFI, pointer arithmetic, DLL calls: all of C's escape hatches are load-bearing and remain. This is C underneath.aifunc declarations) are first-class rather than a library.CX is a C-like language whose compiler and runtime are now written entirely in C (gcc/clang, C99/C11). It compiles to native binaries via a C transpiler path — there is no more "compile to portable bytecode, always run on a VM" model. A CX program is, by default, straight-line native C. Only the parts you explicitly mark as AI-mutable, or that the AI itself generates at runtime, need an embedded register VM at all — and even then, once that bytecode can be decompiled back to source (§26), it can be recompiled and run as native C too, rather than interpreted indefinitely.
Key facts, current build:
.i) and C-style keyword types (int) accepted in the same source file..i (64-bit int), .f (double), .l (64-bit long, for the same width made explicit) — plus .s (string), .v (void, return-only), .p (pointer/handle).list, map, queue (a bounded ring whose capacity is set from a runtime expression), and a first-class json type with subscript syntax._risc/_cisc/_jit-marked code, codeswap markers, and AI-generated bytecode; (3) while-running codegen (future); (4) lift-to-native (--run-lift, shipped) — decompile a program's own VM bytecode back to CX and run it natively instead, recovering most of the VM's interpretation cost for whatever the decompiler currently covers (scalar/string/simple control flow; richer constructs still fall back to mode 2). The old CISC VM and the separate AI mini-VM were both retired 2026-06-04 in favor of the one register VM described here.aifunc_ct/aifunc_rt declarations, and runtime codeswap markers.gcc (default), clang, or msvc at full feature parity; icx is feature-gated; tcc/pocc build stub backends (no networking/archive/graphics).Compiler pipeline (current):
Source (.cx) → Preprocessor → Scanner → Parser → AST
→ emit_c (native path) ─┐
→ emit_risc (VM path) ─┴→ backend cc (gcc/clang/msvc) → native .exe
There's no separate post-processor/optimizer/FixJMP/serialize stage anymore — each emitter writes its final form directly, and the "bytecode file you distribute and always VM-execute" model (.ocx as the only way to ship a program) is gone. .ocx still exists as an internal artifact for diagnostics and for the register-VM path, but a normal build produces a real executable.
cx <file.cx> [options]
| Option | Description |
|---|---|
--build | Transpile + backend-compile to an exe; don't run |
--run | Build then execute; process exit code propagates; -- args… passes argv |
-o FILE | Output path |
-t, --terminal | Headless — skip opening a debug console |
--keep-c | With --run, keep the generated .c |
-P pcode=risc | Request the register-VM path. For a program with an explicit main(), this still emits native C by default and embeds the bytecode only as inert metadata for inspection — the VM interpreter itself only actually runs for the top-level-statement (no-main) program shape. Check with grep cx_risc_run_embedded on the emitted C if you need to know which you got. |
--emit-risc | Dump the register-VM bytecode listing (function names, [_local] slots, escaped string literals, jump targets — this is today's ASM listing) |
--decompile | Reconstruct CX source from register-VM bytecode |
--run-lift | Mode 4. Decompile a program's own register-VM bytecode back to CX, recompile it through the native path, and run that in-process — recovers most of the VM's interpretation cost for whatever the decompiler can faithfully lift (§26) |
--target wasm | Build the same source as a web page instead of a native executable — emits <name>.html + <name>.js + <name>.wasm. A target is a toolchain, not a second code generator: same emit_c output, a different C compiler, and nothing in the source knows the difference. Graphics needs no extra instruction, and #pragma rules c still works, so a rule authored at run time compiles and fires in the tab. Needs the emsdk toolchain and refuses loudly without it (CX-E2005, naming the environment variables and the activation command) — it never quietly builds a native binary instead |
--shared | Build a shared library instead of an executable — .dll on Windows, .so on Linux, .dylib on macOS (also #pragma BuildShared yes). No main() is emitted: the runtime starts when the library is LOADED and shuts down when it is unloaded, so an export can be called the moment the host has the handle. Every CX function is exported, which makes the functions you write the library's API — and on Windows it is also what keeps the runtime's own symbols private, since a DLL that declares no exports of its own has all of them taken. Top-level statements still run, at load, under the platform's loader lock: that is the module body, so keep it to declarations and cheap setup and put real work in a function the host calls when it is ready. gcc and clang only, and the combinations that cannot produce a library — --run, --target, -P pcode=risc, a C compiler with no __attribute__((constructor)) — are refused by name, each with its own code (CX-E2009 a run, CX-E2010 a cross target, CX-E2011 the VM whole-program mode, CX-E2012 the toolchain) rather than half honoured. See Conclave/tests-internal/com_spike/ for a CX shared library that loads into Microsoft Outlook as a COM add-in |
--tokens, --ast | Front-end dumps |
-P key=val | Inject a pragma from the CLI (repeatable) |
--version, -V | Print the baked version |
-h, --help | Help |
--emit-cisc no longer exists — there is one VM. .ocx compile-only output and the old -C/--no-source/--no-od flags from the PureBasic era are gone; use --build -o FILE to produce a binary.
.cx — CX source (text).c — the generated, transpiled C (kept with --keep-c).exe / no extension — the native binary a build produces.risc — register-VM bytecode listing (diagnostic; --emit-risc)println("Hello, World!");
cx --run hello.cx
aifunc_ct function fib.i(n.i) {
"Return the n-th Fibonacci number recursively."
}
println(fib(10)); // 55
aifunc_ct invokes the configured LLM at compile time and bakes the resulting body into the binary; aifunc_rt defers the call to program startup. Both share the same 10-provider fallback chain (§27).
// single-line
/* multi-line
comment */
Statements end with ;. Blocks use { }.
CX source is effectively case-insensitive: the scanner lowercases identifiers before they reach code generation, so myVar, MYVAR, and MyVar name the same variable. (The compiler itself is written in case-sensitive C — this folding is a parser behavior, not a C-language one.)
There is no opt-out. #pragma VariablesToLower 0 was retired in v3.172.0 and now errors CX-E5032: the off mode also stopped folding CALL names, so jsonParse() failed as an unknown call and no real program could run under it.
The consequence worth naming is that two declarations differing only in case are ONE variable, silently — a global A and a global a share a cell, with no diagnostic. Single-letter names collide with scratch names soonest, so prefer matA / matB over A / B.
#define MAX_SIZE 100
#include "other_file.cx"
#cinclude "engine/hud.c" // splice raw C at file scope — new; see §7
#cinclude "file.c" / #cinclude <header.h> spices a verbatim C file into the generated C at file scope (before main), so it can define global C functions/types/data. Pair it with foreign declarations to call into it from CX.
A #defined name is substituted before the parser sees it, so assigning to one is assigning to a constant — #define K 5 followed by K = 7 becomes 5 = 7, and is refused with CX-E1061. (New in v3.223.0: this used to reach gcc as "lvalue required as left operand of assignment" against generated C on native, and a wrong "native-only construct" on the register VM.)
| Suffix | Type | Notes |
|---|---|---|
.i | 64-bit int | default when no suffix is given |
.f | double-precision float | |
.l | 64-bit long | for when you want the width explicit; int64_t/uint64_t/longlong all fold to this |
.s | string | immutable, UTF-8, GC-managed (§12) |
.v | void | return-type only — not a variable type |
.p | pointer | a slot handle, not a raw memory address (§10) |
C-style declarations are also accepted (int n = 0; string s = "hi"; float f = 0.0;); the two styles freely mix in one file. C-stdint aliases (int8_t..uint32_t, size_t) fold to .i; unsigned/signed is accepted with a warning (no signedness tracking).
count.i = 0;
pi.f = 3.14159;
message.s = "Hello";
x = 42; // inferred int
name = "Alice"; // inferred string
File-scope declarations are global; declarations inside a function body are local to it. Params and in-body declarations shadow a same-named global, as in C.
A name you never declare is still legal — CX declares it on first use. The rule is one line: an undeclared name binds a same-named module-global if there is one, and otherwise becomes a fresh local. That is what CX has always done, and the compiler now tells you every time it makes that call, because whether a bare i is your loop counter or the file's global i was invisible in the source and is the kind of thing that costs an afternoon.
| what you wrote | what happens | what the compiler says |
|---|---|---|
x.i = 0 — an explicit declaration | a fresh local, shadowing any global | nothing (this is the fix) |
a parameter named x | a fresh local | nothing |
bare x = 1, no module-global x | a fresh local, type inferred (int if it can't be) | nothing |
bare x = 1, module-global x exists | binds the global — the write leaves the function | CX-W1010 |
bare x read before anything writes it, declared nowhere | assumed int 0 | CX-W1012 |
bare x = 1 where x is a module-global list/map/array/json/queue | refused | CX-E1056 |
bare x = 1 where x is a function's name | refused | CX-E1057 |
x read on a line above this function's own declaration of x | refused | CX-E1058 |
x["k"] = 1, x declared nowhere | mints a json document — see §18 | nothing |
x["k"] read, x declared nowhere and never written | refused | CX-E1091 |
x where a struct x { … } also exists | works — the variable is renamed in the emitted C | nothing |
CX-E1056, CX-E1057 and CX-E1058 are errors rather than warnings because there is no useful reading of them: before they were diagnosed, the container case crashed the program and the json case looped forever, and a warning followed by a segfault would be worse than the silence it replaced. CX-E1091 is the same judgement one operator over — a subscript write to an unknown name has a document to mint, and a read has nothing to read.
struct point { x.i y.i }
struct point point; // a struct variable named after its own type
point.i = 5; // ...or a plain int of that name, at module or function scope
C keeps typedefs and variables in one namespace, so neither of these can be spelled literally in the generated C. CX renames the variable to cxv_point there and leaves you the name you wanted — you never see the renamed form unless you read the emitted C. It costs nothing at runtime and applies to every kind: scalars, arrays, lists, maps, sortndx, struct variables, module-globals and function locals alike.
Until v3.221.8 this was true of every use of such a name but only some of its declarations, so a few shapes reached the C compiler with the two spellings disagreeing and failed with messages about a cxv_ name you never wrote.
CX-E1058)function f.v() {
printf("%d\n", a); // CX-E1058: 'a' is read before its own declaration on line 3
a.i = 7;
}
This is C's rule — a name is usable from its declaration onward — and where the surface is C's, CX answers as C does. It is about position, not existence: declaring a mid-function is perfectly ordinary CX, and every read after the declaration is untouched.
It is worth knowing what this replaced, because the old behaviour was silent. Native builds declarations at the top of their block, so the early read used to answer that fresh local's 0 with no warning at all; the register VM declares in source order, so it refused with "unknown identifier" — about a name declared on the very next line. And when a module-global of the same name existed, the two backends ran the program and printed different numbers (native 0 from the hoisted local, the VM the global's value), both at exit code 0. One diagnostic at the read, on both backends, is the whole fix.
The compiler reports the first early read of each name per function — the second is the same mistake in a second place, and the line it names is the declaration you need to move.
This is a different question from the undeclared lane above: x = 1 with no type tag is not a declaration, so it stays in the ruled mint-or-bind world and never fails a build. CX-E1058 needs an actual declaration, further down.
#pragma localdeclares and _local — C discipline on demandIf you would rather an undeclared name always be local, say so:
#pragma localdeclares on // whole file; default off
_local function tick.v() { ... } // or one function at a time
function tick.v() _local { ... } // same thing, either position
_forcelocal function tick.v() { ... } // heritage spelling, identical meaning
In a strict function an undeclared name mints a local that shadows any same-named global, and the compiler reports each one with CX-W1011 naming what it shadowed. Strict mode never turns a program into a build failure — it changes which way the compiler guesses, it does not add a way to lose. It also dissolves the two refusals above by construction: a local int has no container to crash into. An explicit declaration silences either warning.
_forcelocalis a heritage alias for_local— same bit, same behaviour, either position._localis the canonical spelling; write that in new code. It used to mean something else: it forced a function to get its own call frame so locals survived recursion. That is automatic on both backends now (native locals are C automatic storage; the register VM'sCALL/RETsave and restore the callee's window), including recursion through a function pointer, so the marker had stopped doing anything at all. Rather than leave a spelling that silently did nothing, it was pointed at the nearest thing it looks like it means. Existing_forcelocalfunctions keep compiling and keep framing exactly as before; what changes is that undeclared names inside them now mint locals and reportCX-W1011.
Module-level code is untouched by the pragma: out at file scope x = 5 is the global's declaration, so there is nothing to be strict about.
A declaration inside { } belongs to that block, on both backends, exactly as in C. It shadows an enclosing declaration of the same name, and the name comes back at the closing brace:
int x; x = 7;
if (ready) {
int x; x = 9; // a DIFFERENT x, this block's own
println(str(x)); // 9
}
println(str(x)); // 7 -- the outer x was never written
Three consequences worth knowing, all of them C's:
int c; is a fresh 0 on every pass — it does not carry the last iteration's value forward. The same is true of a string (empty), a float (0.0) and of every container, which has been true since containers started building at their declaration.function f(int p) { if (c) { int p; ... } } leaves the argument the caller passed untouched outside that block.x.i = 9 is not a declaration in this sense — it is a typed assignment, and inside a block it writes the enclosing x. Write int x; when you mean a new one.
Until v3.292.0 this was true natively and false on the register VM, which allocated one slot per name per function: the inner declaration wrote the outer variable, on every type, with no diagnostic from either backend.
#define MAX_ITEMS 100
#define TAX_RATE 0.08
Standard C precedence throughout: arithmetic (+ - * / %), comparison (== != < <= > >=), bitwise (& | ^ ~ << >>), logical (&& || !), assignment (= += -= *= /= %= ++ --), ternary (?:), address-of (&).
There is no**power operator — CX is C, and C has no such operator. Usepow(base, exp). This documented one until 2026-07-23, which is worth a warning because the mistake is not caught:a = 2 ** 3;parses as2 * (*3), emits a dereference of address 3, and segfaults with no diagnostic. Logged as a defect (it should be a clear CX error, per FP4).
A few things worth knowing that weren't true of the older interpreter:
&&/|| genuinely short-circuit (C semantics, on both native and the register VM) — n != 0 && x / n never divides by zero. Result normalizes to 0/1.+ auto-converts. "n=" + 42 converts the int via str() semantics and emits a compile warning nudging you toward str(...) — the value is correct either way, it's a clarity nudge, not an error.s = s + x in a loop grows a uniquely-owned buffer in place on both backends.2e10, 1.5e-3). Hex literals are not — use decimal.Evaluation order is DEFINED: left to right, on both backends. C leaves the order of a + b's operands and of a call's arguments unspecified, which is exactly what permits an implementation to pin it — and CX pins it, so a program cannot answer differently on native and on the register VM:
int n = 0;
function bump.i() { n = n + 1; return n; }
printf("%d %d\n", bump(), bump()); // always "1 2" -- never "2 1"
int r = two(bump(), bump()); // two() receives 1 then 2
s = str(take(q)) + str(take(q)); // the queue is drained left to right
The compiler pins it only where the order is observable — two or more operands or arguments that can each change something (a call to one of your functions, or a builtin marked as mutating). Everything else costs nothing, which is why an expression with at most one such call emits exactly what it always did. (v3.245.0 operands; v3.259.0 call arguments.)
The one remaining gap:++/--inside one expression.f(++a, ++a)and++a + ++amodifyatwice with no sequence point between, which is undefined in C rather than merely unspecified — CX has not pinned it and does not diagnose it. Assign to a variable first.
if/else if/else, while, C-style for, switch/case/default, break/continue — all unchanged from a C programmer's expectations.
There is one form. The collection's own cursor is the iterator — no loop variable is introduced — so the body pulls the current element explicitly:
foreach myList { v = listGet(myList); print(v); }
foreach nums { print(arrGet(nums)); } // works on a grown array too
foreach ages { print(mapKey(ages), " = ", mapValue(ages)); }
foreach ranks { print(get(ranks)); } // sortndx, in sorted order
Iterable kinds: list, map, array (fixed, multi-dim, or grown), sortndx, json. A queue is a container but is not iterable this way — take from it (queueTake) in a while instead.
For json, foreach walks the members of whatever the handle names, and a parsed document is deref'd to its root — so foreach doc over jsonParse("[10,20,30]") runs three times, the same three ->count reports. It is the same walk whether you hold the document or a node inside it:
doc = jsonParse("{\"items\":[10,20,30]}");
foreach doc { ... } // the document's members
items = doc["items"];
foreach items { n = n + jsonAsInt(jsonGet(items)); } // 10, 20, 30
foreach doc { row = jsonGet(doc); foreach row { ... } } // nesting is fine
One limit: the cursor lives on the node, so you cannot run two iterations of the same node at once (foreach doc { foreach doc { } }). Different nodes — the nested form above — and a second pass afterwards are both fine. (Before v3.219.6, foreach over a parsed document ran exactly once, visiting its root container instead of the members.)
Anything else is refused at the .cx line, identically on both backends:
| you wrote | you get |
|---|---|
foreach v in c { } or foreach (v in c) { } | CX-E0044 — the foreign spelling; neither has ever been CX |
foreach n { } where n is an int, string, queue, … | CX-E1053 — names the type it was declared with |
foreach ghost { } | CX-E1054 — not declared anywhere in the program |
(Until v3.219.3 this section documented foreach (item in myList) as a current form. It never parsed — and the three invalid shapes above were silent no-ops on at least one backend, which is why they now have codes.)
An open design question (not yet built, don't rely on it) is a foreach (x : c) colon form — noted here only so you don't go looking for it.
Syntax changed from the PureBasic era — markers are now backslash-braced:
\{5: "description"
... default code ...
\5:}
Full detail in §26.
function add.i(a.i, b.i) { return a + b; }
function greet.s(name.s) { return "Hello, " + name; }
function log.v(msg.s) { print(msg); return; } // .v: bare return only
Return-type suffixes: .i (default), .s, .f, .v (void), .p (pointer), .l (long), .StructName (struct return — new; the function must declare it: function origin.Point() { Point z; return z; }).
A .v function makes return <expr>; a compile error — use bare return; to exit early. As before, omitting a required suffix on a string/float-returning function silently emits an int RETURN and corrupts the value — always declare it.
All of these are accepted, and can mix in one signature:
function calc(x, y.f, label.s) // untyped defaults to int
function calc(int x, float y, string label) // C-style
function f(struct Point p) // struct param — byref (see below)
function f(byref n.i) // explicit byref primitive
function f(byval n.i) // explicit byval (redundant; it's the default)
function connect(host.s, port.i = 8080) // default value, trailing params only
A call must supply between the required and the declared number of arguments — defaults make it a range, so connect("h") and connect("h", 99) are both fine and connect() is not. Outside that range the call is refused with CX-E1062, naming what you passed and what the function takes. (New in v3.223.0. Native used to hand this to gcc, which answered "too many arguments to function" at a line in generated C you cannot open; the register VM compiled the call and failed at RUN time with an arity error. CX-E5012 still covers the calls no compiler can count — through a function pointer or a fn value.)
A function that returns a function value declares .fn (or .fp), and then the value prints as <fn> wherever it lands — in the call, or in a variable bound from it:
function pick.fn() { return &add; }
print(pick()); // <fn>, both backends
function raw.i() { return &add; }
print(raw()); // a raw number — see below
(New in v3.227.0. The declaration is what CX reads: a function returning a function while declaring .i keeps the raw lane — an address natively, a function index on the register VM — because inferring the type from the body would be guessing, and two returns in one function can disagree. Before this, even the DECLARED form printed the raw lane.)
An untyped parameter is an int, and CX takes that literally on both backends. Three consequences, all decided at the CALL:
| what you pass | what happens |
|---|---|
| an int | ordinary CX. Nothing is said. |
| a container or a string | CX-E1063 — the parameter has no type, and a handle is not an int |
| a float | it runs, TRUNCATED to the int, and says so with CX-W1013 |
function total(xs) { foreach xs { } } // CX-E1063 at the call
function total(list xs.i) { foreach xs { } } // the fix — one suffix away
function passf.f(x) { return x; }
print(passf(2.5)); // CX-W1013; prints 2.000
function passf.f(x.f) { return x; }
print(passf(2.5)); // 2.500
(CX-E1063 new in v3.224.0, widened to strings in v3.228.0; CX-W1013 new in v3.228.0. Before this the register VM RAN the refused shapes — its slots are typed at runtime — while native handed gcc a cx_list_int * for a long long; and the float lane printed 2.000 natively against 2.500 on the VM, silently. The truncation is not a defect being papered over: an untyped parameter IS an int, so C truncates, and CX answers as C answers — the warning exists because the collapse is lossy, not because it is wrong. The check is per CALL, so a function nothing in the file calls is not judged.)
Compound values — struct, list, map, array — are byref by rule when passed as parameters. Primitives are byval unless you write byref. A list/map parameter is a heap handle: the callee shares the same container, and mutations propagate to the caller with no byref keyword needed.
function fill(list xs.i) { listAdd(xs, 1); } // caller's list gains the element
Array parameters historically required an honest compile error; the current compiler instead promotes a passed array to dynamic at the call site (v3.10.0+): the array becomes a cx_array grown handle, and inside the callee re-stating array a.i[n] resizes the caller's array, a->count reads the live length, and element reads/writes propagate. This is precise — only a user-function call with an array-typed param triggers it; builtins like sort/len still operate on fixed arrays in place. (Re-measured against the shipped 3.271.0 compiler on 2026-08-14, both backends identically: a callee's a[0] = 99 is visible to the caller, and a callee's array a[8] leaves the caller reading ->count 8 with the new element in place. This paragraph used to end by telling you to go and check that yourself — which is the sentence a reference writes when nobody has.)
A brace initialiser survives the promotion (v3.279.0). The declaration's values are written into the grown handle at the declaration, so passing the array changes where it lives and nothing else:
function peek.i(array a.i) { return a[4]; }
function demo.v() {
array nums.i[5] = {10, 20, 30, 40, 50};
printf("%d %d\n", nums[4], peek(nums)); // 50 50
}
Before v3.279.0 the promotion dropped that initialiser: both numbers printed 0, including the caller's own nums[4], on both backends — the same line answered 50 if you removed the call. It reached int, float and string elements, multi-dimensional arrays and file-scope arrays alike.
Reassigning one container variable to another (a = b, both containers) is dropped with a compile warning — containers share by reference, so a plain rebind would alias two owners onto one object (a double-free). Pass byref into a function instead. This covers json too (v3.279.0 — until then a json a = b; rebound silently, in both declaration forms, which is exactly the aliasing the rule exists to prevent).
Every container family may be a parameter, and the natural spelling works inside the callee (v3.280.0). A map parameter takes a subscript store, and a sortndx parameter takes its own verbs:
function tally.v(map m.i) { m["hits"] = m["hits"] + 1; }
function rank.v(sortndx s.i) { add(s, 5); add(s, 1); }
Before v3.280.0 both of these compiled natively and were refused by the register VM — m["k"] = 1 as cannot compile subscript-assign, and add(s, 5) as unknown call add, because the VM learns a parameter's family from one enumeration and those two families were missing from it. mapPut(m, ...) had always worked, which is what made the subscript form a trap rather than an unsupported feature.
fp is the canonical keyword for a function-pointer type (fn is kept as an alias):
fp callback = &add;
result = callback(3, 4);
array *ops[2];
ops[0] = &add; ops[1] = ⊂
result = ops[0](10, 5);
A function NAME on its own is not a value. f means the function; f() calls it; &f passes it. Writing the bare name where a value is wanted is refused (CX-E1106) rather than quietly compiled to the function's address as an integer, which is what it used to do — and into an .i parameter that produced a plausible number and no diagnostic anywhere. Two places keep the bare form, because in both it is a name rather than a value: a comparator argument (sort(xs, descI), sortndx ranks.i by descCmp), and a call into C — qsort(x, n, sizeof(double), cmp_dbl) is correct C, C has function pointers, and CX keeps all of C.
main()CX synthesises the program's real entry point, so a main() you write always moves aside — and what happens next depends on whether your file has anything else at the top level.
| Your file | What runs |
|---|---|
main() and nothing else at top level | your main() is the program (this is what lets verbatim C source run unchanged) |
main() plus module-level statements | the module-level statements run first, then your main() is called |
The second case warns (CX-W1018) so the arrangement is never a surprise. Before v3.311.0 it did something else entirely: your main() was renamed and never called, with no diagnostic and exit 0.
Your return value is the program's exit code, in both shapes and on both backends — the same thing it means in C. A main.v() has no value to exit with, so the program exits 0.
argc and argv are forwarded when your signature takes two parameters, in either spelling: the C declarator int main(int argc, char **argv), or CX's function main.i(argc.i, argv.i), where argv arrives as a pointer in an int slot like any other pointer CX stores. (You rarely need either: argc() and argv(i) are builtins.) Both backends run it: the register VM has the same command line the native build does, so its entry call is built with the same two arguments, and argv is the real process vector on either.
main() in the C escape takes the entry — _CM{}A main written in raw C is a declaration: you are taking the program's entry. Say so by spelling the block _CM{}, and CX steps aside — the block is spliced at file scope and no main is synthesized.
string tag;
tag = "ready"; // module-level statements still run, first
function status.s() { return tag; }
_CM {
int main(int argc, char **argv) {
cx_rt_init(argc, argv); // hand CX the command line (optional)
cx_str_handle s = status();
printf("%s
", cx_str_peek(&s, (size_t *)0));
return 0; // your return value is the exit code
}
}
Nothing is lost by taking the entry. The runtime startup and your module-level statements run before your main, from the C runtime's static-initialiser list, and the shutdown runs at exit — the same mechanism --shared uses, and for the same reason: the entry belongs to somebody else, so a prologue cannot be decorated onto it. The one thing that does not arrive by itself is the captured command line: a static initialiser has no arguments, so argc() and argv(n) read empty until you call cx_rt_init(argc, argv) yourself. Your own main parameters are the real ones either way.
A plain _C{} at top level that defines a main means exactly the same thing and CX says so once (CX-W1020) — an entry that moves without you saying so is a surprise, and _CM{} is the spelling that removes the line. Three shapes are refused rather than guessed at: _CM{} inside a function body (CX-E1107 — it is spliced where it stands, so there is no file scope to take), _CM{} with no main in it (CX-E1108), and a C entry beside a CX function main (CX-E1109 — two entries, one program). Inside a function body a _C{} main is still just a nested function nothing calls, and still says so (CX-W1019).
On the register VM the entry belongs to the host that starts the VM, so a C entry block is declined there by name — the program-scale version of the rule that already makes any _C{} function native-only.
array nums.i[10]; // all dims compile-time constant -> STATIC: raw C stack array, zero allocation
array a.i[n]; // n is a runtime variable -> GROWN: cx_array on the GC heap
A static array is a plain C-stack buffer — no allocation, raw C semantics, no bounds check unless you ask for one. A grown array lives on the GC heap, can be resized, and its element reads/writes are bounds-checked by default in the lenient sense: an out-of-range read returns 0, a write is silently dropped ("still C"). Add #pragma checks on (aliases: check, checkbounds) to turn that into a hard runtime error instead.
arr and dim are both aliases for array. A constant dimension must be a literal or a single #define — [N*2] is rejected; precompute into one define.
array IS THE WHOLE STATEMENT — it declares AND it redimensions (v3.285.0)One statement, one meaning, wherever it appears. array names an array of a size; writing it again for a name already declared in the same scope RESIZES that array. There is no second keyword for the second act:
function probe.v() {
array data.i[3];
data[0] = 1;
array data.i[6]; // the SAME array, resized
printf("%d %d
", data->count, data[0]); // 6 1 -- grown, elements preserved
}
redim was the older spelling of that second line and is retired: it is refused at compile time with CX-E1048, naming array, on both backends. It was never a statement of its own — the parser aliased it onto this same production — so nothing it did has gone away, only the second name for doing it.
What the one statement means:
->count is the LIVE length, so a re-statement that SHRINKS reports the smaller number: array d.i[6]; array d.i[3]; answers 3, at module scope as inside a function. ``cx array d.i[4]; d[0] = 1; d[3] = 7; array d.i[4] = {9}; // 9 0 0 0 -- the literal, then the type's zero ``
.type suffix keeps the element type the declaration wrote. array ws.s[2]; array ws[4]; stays a string array. ``cx array d.i[2]; d[0] = 4; if (ready) { array d.i[6]; // a NEW array, six long d[5] = 55; } printf("%d %d\n", d->count, d[0]); // 2 4 -- the outer array is untouched ``
A RE-STATEMENT AT THE SAME SCOPE still RESIZES, and the difference is the block, not the spelling: array d.i[2]; array d.i[6]; on consecutive lines is one array grown to six. (Until v3.292.0 the nested case resized too. The register VM had no block scope for any type — an inner-block int x shadowed natively and reused the slot on the VM — so shadowing arrays alone would have been a one-backend behaviour, which is never acceptable. Block scope landed for every type at once, and this clause came with it.)
array a.i[8] resizes the caller's array — that is how a resize crosses a scope boundary now that a nested block declares its own: ``cx function grow.v(array a.i) { array a.i[8]; a[7] = 99; } function probe.v() { array d.i[3]; grow(d); printf("%d %d ", d->count, d[7]); // 8 99 } ``
An array that is never re-stated, never passed to a user function and never appended to stays a raw C stack array with no allocation. That is the point of the distinction, and it is not widened by this rule.
A declarator has N dimensions; an expression supplies K indices. K == N is an element; K < N is a row — a pointer to the remaining slice, exactly as in C.
char roman[13][3] = {"M\0", "CM\0", "D\0", /* ... */};
printf("%s", roman[i]); // roman[i] is a `char *` -- a C string
printf("%d", roman[i][0]); // roman[i][0] is a character
int grid[4][8];
int *row;
row = grid[2]; // a real row pointer
printf("%d", row[5]); // == grid[2][5]
Depth here is arithmetic, not a special case: char cube[2][3][4] subscripted twice is still a char *, because it is still one index short.
Two limits worth knowing, both deliberate:
printf("%s", roman[i]) works because printf is libc and takes the pointer. assertEqual("CD", r[1]) and s + r[1] do not — those take a cx_str_handle.%s of a row whose elements are not characters is CX-E1087, at your own line, because printing a non-character pointer as text is undefined in C.CX-E1038) and the program builds native instead. The one shape it does handle is char T[D][M] = {"..", "..", ..}, which it stores one string per row.array nums.i[0]; // the [0] forces it heap-backed / grown
arrAdd(nums, 10); arrAdd(nums, 20);
print(nums[0]); // index
foreach nums { print(arrGet(nums)); } // iterate
And a list or grown array doubles as an ordered map (PHP/Lua-style): mapPut(c, key, value) appends the value into the sequence and indexes it by string key; the keyed value is still visible to foreach and to ->count.
A cell that exists but has never been assigned answers 0, 0.0 or "" by the array's declared element type, on both backends and however the cell came to exist — declared, grown by arrAdd, or invented by a re-statement:
function first.s(array a.s) { return a[0]; }
function demo.v() {
array ws.s[2];
println("[" + ws[1] + "]"); // []
println("[" + first(ws) + "]"); // []
}
It is the same sentence lists pad a sparse store with (§11) and the analogue of the real nulls a sparse JSON store pads with (§18) — one rule, stated once, true of the write path and the read path alike.
(Until v3.280.0 the register VM answered the integer 0 for the string case, because a cell's zero BITS are the int zero and nothing had said otherwise. Native answered "". The wrong value then corrupted the concatenation it landed in — the line above printed 0], having lost its own [ — which is why a split like this is silent-wrong rather than cosmetic. It was invisible until v3.279.0, because before that a passed array lost its initialiser and every element of a passed string array was empty.)
One name, works over any sequence (fixed array, grown array, list — both backends, one implementation):
| Function | Does |
|---|---|
fill(c, v) | set every element |
sum(c) / minof(c) / maxof(c) | int-preserving numeric reduce (any float element promotes the whole result) |
avg(c) / average(c) | always float |
find(c, v) | linear first-match → index or −1 |
search(c, v) | binary search, sorted arrays |
contains(c, v) | membership → 0/1 (on a string, stays the substring test) |
reverse(c) | in-place |
Also works on maps (reduces over the values; key membership is mapHas/mapContains). arrSum/arrMin/arrMax/arrAvg remain as accepted synonyms on arrays specifically.
json value (v3.220.0)The reduces and the finders work over a json array's elements or an object's member values — the same members foreach walks — and the subject can be a handle or a subscript:
json doc;
doc = jsonParse("{\"units\":[12,40,7],\"skus\":[\"widget\",\"gizmo\"]}");
print(sum(doc["units"])); // 59.000 -- float, see below
print(maxof(doc["units"])); // 40.000
print(find(doc["units"], 7)); // 2 -- an index, so int
print(contains(doc["skus"], "gizmo")); // 1
Two differences from the sequence contract, both deliberate:
sum over json ints is 6.0 where the same call over a list of ints is 6. Elements coerce leniently — a numeric string parses, true is 1, false/null are 0, and a nested array or object element counts as 0.fill reverse sort sortarray search raise CX-E1055 on both backends: json members are a linked chain of pool nodes with no slots to reorder or write, and a binary search has no random access to use. A wired verb handed a json scalar (jsonParse("42")) raises CX-E5040 at runtime — fatal rather than 0, because an empty json array legitimately answers 0.Before v3.220.0 none of these worked: natively every one was CX-E1051, and on the register VM every one answered silently — 0 for the reduces, −1 for find, 0 for contains, and a no-op for the mutators.
Struct field access is C-style ., not the old PureBasic-style \:
struct Point { x.i; y.i; }
struct Pair { a.i; b.f[3]; } // inline array field
Point p1;
p1.x = 3;
p1.y = 4;
Nested structs and arrays-of-structs work the same shape as always, with . throughout. Compounds are byref by rule as parameters (§7); a struct-typed return needs the .StructName suffix.
The declare-on-first-use suffix takes a struct type in the tag position, exactly as it takes .i or .s — and assignment between two struct variables COPIES, as it does in C (both true on both backends since v3.280.0):
struct Pt { x.i; y.i; }
function demo.v() {
struct Pt p;
p.x = 3;
q.Pt = p; // declares q as a Pt, and copies p into it
p.x = 99;
printf("%d %d\n", q.x, p.x); // 3 99 -- q is its own object
}
(Before v3.280.0 the register VM refused the q.Pt = ... form outright — CX-E1038, reading Pt as a field name — and, for the explicit struct Pt q; q = p; form, it moved the buffer pointer instead of the bytes, so the two names silently became one object. Native did neither.)
Mistakes here are refused at the .cx line, identically on both backends:
| you wrote | you get |
|---|---|
p.zz where the struct has no zz (read or write) | CX-E1020 — names the struct and the field |
p = { 1, 2, 3 } into a 2-field struct | CX-E1021 — the value count must match the field count |
p = { nosuch: 1 } | CX-E1020 — a named entry must name a real field |
struct NoSuch v; where no such struct is defined | CX-E1060 — the type, not the construct, is missing |
(Before v3.223.0 the first three were native-only — on the register VM a spare value was silently dropped and p.zz was reported as a "native-only construct", which is true of neither. CX-E1060 is new in v3.223.0: it used to be gcc's "unknown type name" against generated C on native, and the same wrong "native-only" on the VM.)
struct hero { int hp; float speed; string name; }
json doc = jsonParse(fileRead("save.json"));
hero h;
h.speed = 1.0; // a default
jsonToStruct(doc["hero"], h); // fills h.hp / h.speed / h.name from doc["hero"]
This expands at compile time into the field-by-field assignments you'd otherwise hand-write — not a reflection-based runtime call. Field mapping is by exact (lowercased) name; a missing/null json key leaves that field untouched (set a default first); extra json keys are ignored; nested struct fields recurse; array/list/map fields are a compile-time error (fill those yourself). Returns the count of fields filled, so you can check completeness — but only as a statement or assignment target, not nested inside a larger expression.
x = 42;
ptr = &x; // address-of
value = *ptr; // dereference
A .p value is a slot handle, not a raw memory address — this is a real change from the PureBasic era's peek_i/poke_i/realptr/realaccess model, which assumed literal process memory. If your program does low-level pointer arithmetic or raw memory poking, verify the current equivalents against your build; that corner of the language changed shape with the VM redesign (§ARCHITECTURE) and isn't something this rewrite can responsibly guess at in detail.
Function pointers (fp, see §7) are the well-confirmed, current pointer-adjacent feature: address-of a function, call through the pointer, arrays of function pointers, and functions-as-values passed to higher-order functions.
Writing the C declarator gives you a genuine C pointer with the pointee you wrote, and the number of stars is a number, not a list of supported cases — **, *** and deeper all ride one implementation, at every position that declares a pointer:
double v; double *p; double **pp; double ***ppp;
v = 7.0; p = &v; pp = &p; ppp = &pp;
printf("%.3f\n", (***ppp)); // 7.000
Works as a function local, a module-scope global, a struct field, a parameter and a return type; on both backends for locals and globals. Pointer arithmetic strides by element, because the pointee is real.
Two limits, and each is a loud refusal rather than a wrong number:
CX-E1080 on the register VM): a field holds eight bytes with nowhere to keep what it points at.double *v[3] — is not supported yet (CX-E1081). The star there belongs to the element, which is a different question from depth. Any depth of pointer without an array extent is fine.The pointee can be one of your own struct types, in either spelling, and every way of reaching through it works:
struct Pt { x.i; y.i; }
Pt *find(Pt *first) { return first; } // parameter and return type
int main() {
Pt v; Pt *p; struct Pt *q; // `T *` and `struct T *` are synonyms
v.x = 7; p = &v; q = find(p);
printf("%d %d %d %d\n", (*p).x, p->x, p.x, p[0].x); // 7 7 7 7 — all four
q->x = 9; // writes through to v
return 0;
}
p.x is CX's own spelling and lowers to p->x; use whichever reads better. Depth composes here too — Pt **pp behaves exactly as double **pp does.
A struct field may be a pointer — including a pointer to the struct being declared. That is what C allows and for the same reason: a pointer is one word wide whatever it points at, so the size is known while the type is still incomplete.
struct node {
val.i;
node *next; // points at the struct being declared
} // `struct node *next;` is the same declaration
node *push(node *head, int val) { // C's declarator: see the note below
node *n;
n = malloc(16);
n->val = val; n->next = head;
return n;
}
int main() {
node *p; int total;
p = 0;
p = push(p, 1); p = push(p, 2); p = push(p, 4);
total = 0;
while (p != 0) { total = total + p->val; p = p->next; } // 7
printf("%d
", total);
return 0;
}
With that one field come the linked list, the binary tree and the graph. Reaching through the field works in every spelling — n.next->val, (*n.next).val, n.next.val, n.next[0].val — and chains to any depth: a.next->next->next->val. The link is zero-initialised like any other field, so if (p->next == 0) is a real terminator test. Depth composes: node **p and node ****p as fields need no extra syntax.
Two structs may point at each other, and a field may name a struct defined later — no forward declaration is required:
struct vertex { id.i; edge *first; } // `edge` is defined below
struct edge { to.i; vertex *target; edge *next; }
Worked example: Examples/407 self-referential structs.cx (list, tree, adjacency list).
Not yet: an array of pointers as a field (T *v[N], CX-E1081).
.T* suffix spelling (v3.267.0)A pointer type can also be written in CX's suffix form, at a function's return type and at a struct field. node *next; and next.node*; are the same declaration — two doors onto one fact — so you can mix them inside one struct.
struct item {
val.i;
next.item*; // same as `item *next;`
}
function last.item*(item *h) { // same as `item *last(item *h)`
while (h->next != 0) { h = h->next; }
return h;
}
Depth is a number, not a set of cases: .item** and .item**** work for the same reason .item* does. .p remains the untyped pointer suffix (a pointer whose pointee nobody named); .T* is the typed one.
Everywhere else — a parameter, a local, a list/map/array element type — the suffix pointer is refused with CX-E1083, which says coming, not wrong. Use C's form at those positions: function get.i(node *p) has always worked.
function push.i(node *head) returning a node * is CX-E1084 at that line. An address is not a number CX converts for you, and before this the mismatch was your C compiler's to report, somewhere in generated code you never wrote. Declare the return as a pointer — .T* or node *push(...), both exact — or say you meant the address as an integer with an explicit cast.
Two things it deliberately leaves alone: an explicit cast, because an explicit conversion is obeyed exactly; and &fn, a function's address, which CX parks in an integer slot by design (fp h = &add1; is the same lowering at the assignment position).
A bare return &x; on a data object through a non-pointer return type is CX-E1084 too, since v3.267.1 — C refuses it and so does CX. It built and ran before that, so this is the one part of the floor that removes something rather than moving where it is reported. The fix is the same one C wants: a cast, or a pointer return type.
They work. A struct-pointer variable, a pointer field, a walk, a recursive tree and mutual reference all compile and run on the register VM and print what native prints — p->x and a.x are the same instruction there, because a struct pointer on that backend is the struct handle. Depth composes for free: a.next->next->next->val costs one field load per arrow.
Three things are still native-only, and each says why:
malloc / free (CX-E1013) — C's heap is native-only. Since v3.269.0 this is rarely the wall it was: new / delete (below) allocate nodes on BOTH backends, and Examples/407 moved to them.int *ip;, double *dp;) — CX-E1080. A scalar's address has a different representation from a struct handle, and the field has eight bytes and nowhere to record which it holds.node **pp;) — CX-E1080. The level below the field is a second storage decision.Worked example: Examples/408 suffix pointer types.cx runs byte-identically on both backends.
new and delete — nodes from CX's own heap (v3.269.0)struct node { val.i; node *next; }
node *push(node *head, int v) {
node *n;
n = new node; // one ZEROED node; the size comes from the shape
n->val = v;
n->next = head;
return n;
}
...
delete p; // give it back
new T allocates one zeroed instance of struct T and yields a T *. It is an expression, so it works anywhere a struct-pointer expression works — assignment, initialiser, argument, return. delete p; is a statement that releases one.
You never write a byte count. The size comes from the shape, so adding a field to node cannot leave a stale malloc(16) behind it somewhere else in the file. The node also carries its own identity: its string fields are released with it, and under #pragma checks on the shutdown leak report names the struct type that leaked rather than a byte count:
LEAK #2 type=struct size=16 rc=1 node
A node forgotten anywhere in the program comes back by name — something a raw malloc block can never do, because the libc heap keeps no books CX can read.
delete sets its operand to 0, which C does not do. That is deliberate: it makes if (p != 0) mean what it reads, turns a use-after-delete into a named null-deref instead of a silent wrong value, and keeps the two backends byte-identical on a program that touches the pointer afterwards. And because of it, delete on a pointer that is already 0 is a no-op — like C's free(NULL) — so a "delete if held" needs no guard.
One spelling caution: delete is a contextual keyword — the sortndx element verb delete(ranks, 3) and a user function named delete both still work, separated by one token of lookahead. The cost of that rule: **delete (p); — with parentheses — reads as a call to a function named delete**, not as a delete of a parenthesised operand. Write delete p;.
Both backends. Examples/407 self-referential structs.cx — a linked list, a binary search tree and a mutually-referential graph, all built with new — runs identically native and on the register VM.
Checks are off by default on both backends; #pragma checks on enables them on both; a program behaves identically native or on the register VM either way.
That single sentence is the whole rule, and it has a cost side and a safety side. By default a field access through a null struct pointer does what C does — it dereferences null, and the OS ends the program. Nothing is spent asking whether the pointer was null, on either backend, because the default is C's own bargain: speed first, and you were the one who wrote p->x.
Turn checks on and the same access is a named error at the .cx line instead:
#pragma checks on
struct node { val.i; node *next; }
node *p;
printf("%d\n", p->val); // CX-E5024: p: null pointer dereference
The register VM says the same thing in the same words — null pointer dereference — under its own code (CX-E5021 for a read, CX-E5022 for a write), because it can name the access but not the variable. Grep the phrase, not the code, and you will find it on either backend.
The pragma is decided at COMPILE time, so a program built without it carries no guard at all — the register VM's bytecode is byte-for-byte what it would have been if the feature did not exist. One deliberate exception on both backends: the guard covers a named pointer (p->x, p.x, p->arr[i]), not a chained intermediate (a->next->val where a->next is null), because a guard on the chain could only name the wrong thing in its own message.
The same pragma is what turns grown-array bounds violations into hard errors (§"Static and grown arrays") — one switch, one meaning, both backends. It is also what makes access through a deleted container a named error rather than the typed zero it answers by default (CX-E5044; §containerDelete).
Not built yet: new T[n] (an array of nodes) — filed, with delete[]'s design question attached.
CX has exactly two ways to get heap memory, and they are different heaps:
new T / delete p; | malloc() / free() | |
|---|---|---|
| Heap | CX's GC — every allocation carries a typed chunk header | raw libc — the C escape hatch, obeyed exactly as C |
| Backends | both — native and the register VM | native only (CX-E1013 on the VM) |
| Contents | zeroed | uninitialised, as C's malloc |
| Size | from the struct's shape — you never write a byte count | you write the byte count, as in C |
| Leak report | visible by type under #pragma checks on (LEAK #2 type=struct … node) | invisible — the GC keeps no books on it |
| Node churn | free-list reuse; a 400k-cycle alloc/free benchmark ran ~4.4× faster than malloc/free (measured on the Windows dev box — advisory, not a gate) | plain libc speed |
The rule is C++'s own: delete what you new, free what you malloc, and never cross. Crossing hands one heap's pointer to the other allocator — undefined behaviour, exactly as passing a new[] pointer to free is in C++. CX does not police the pairing at run time.
Guidance: CX code uses new / delete. Reach for malloc only when C interop requires it — a buffer a C library will own, realloc, or free — and then release it the C way.
(const double *)p casts to a pointer to double, and dereferencing it reads a double:
int cmp_dbl(const void *a, const void *b) {
double x = *(const double*)a - *(const double*)b;
return x < 0 ? -1 : x > 0;
}
Before v3.270.0 the base type was thrown away at the * and every non-struct pointer cast was spelled long long, so that subtraction was comparing IEEE-754 bit patterns. char *, int *, double *, void * and your own struct types all now cast to what they say. float * becomes double * and long * becomes long long * — the same deliberate collapses a declaration makes, because CX has one 8-byte float and one 8-byte integer.
#include <stdlib.h>
int cmp_dbl(const void *a, const void *b) { /* ... */ }
qsort(values, n, sizeof(double), cmp_dbl); // just works
bsearch(key, values, n, sizeof(double), cmp_dbl);
A CX function's C signature returns long long, and qsort wants a comparator returning int with const-qualified parameters. CX emits a small adapter with the callee's exact declared shape and passes that — your function is untouched and stays callable directly from CX in the same program.
This covers qsort and bsearch. It is keyed on the callee, and a function of your own with that name shadows it, so a program that defines its own bsearch is unaffected. Declaring a C function-pointer type (int (*f)(int, int);) is still CX-E0005 — use fp (§7) for CX's own function values.
list todo.s; // grows
map scores.i; // hash map, .i value type
listAdd(todo, "buy milk");
mapPut(scores, "alice", 100);
Byref by default, same rule as arrays/structs (§7): a container variable is a heap handle, so passing it to a function shares the same container, and reassigning one container variable to another is dropped with a warning rather than silently aliasing two owners.
Defining a container initialises it. A declaration is a constructor: after list xs.i; the list exists and is empty — xs->count answers 0, a verb works, a foreach runs zero times. Think of it as an array of 0 elements. The same holds for map, sortndx, array and xmldoc, at module scope and inside a function, on both backends. You never need a separate create call, and there is no window in which a declared container is a handle to nothing:
function demo.v() {
list xs.i;
map m.i;
xmldoc d;
printf("%d %d
", xs->count, m->count); // 0 0
r.i = xmlAddNode(d, "person"); // the document already exists
}
Two families qualify that, and both for reasons of their own:
queue is live too, but a queue has no CAPACITY until queueInit(q, n, policy) — deliberately, so the bound can be data read at runtime (§11). Until then every queue op answers with a written reason naming queueInit.json builds on first use rather than at the declaration, because what its declaration cannot know is the ROOT KIND: d["k"] = 1 makes an object and c[0] = 10 makes an array, and that decision belongs to the first subscript (§18). Every first use of a declared json builds — a read before any build answers quietly and creates nothing.Every container family takes a brace initialiser in the shape its own elements have. The declaration is the constructor, so the literal is part of the construction rather than a series of statements after it:
list xs.i = {1, 2, 3}; // positional elements
list names.s = {"ada", "grace"}; // .i / .f / .s
map m.i = {alpha: 1, beta: 2}; // KEYED pairs
sortndx s.i = {3, 1, 2}; // sorted AS IT BUILDS -> reads 1 2 3
array nums.i[5] = {10, 20, 30}; // padded with the element type's zero
Three things worth knowing:
sortndx initialiser sorts as it builds. The elements go in through the index's own binary insert, so {3, 1, 2} reads back 1 2 3 — and a desc or by modifier written before the initialiser is already in force.map key is written as an identifier — {alpha: 1}, the same literal shape a json declaration uses (§18). For a key that is not identifier-shaped, store it: m["two words"] = v.= <another container> all raise CX-E1092 and name what that family takes. A queue's elements arrive through queuePush, so it has no initialiser at all.A subscript store BUILDS the slot it needs (v3.279.0). xs[n] = v grows the list to n + 1 if it has to, padding anything in between with the element type's zero — 0, 0.0 or "". It is the list's version of what a sparse json store does with real nulls (§18), and it means a list can be filled by index without a priming loop:
function demo.v() {
list xs.i;
xs[0] = 11; // builds slot 0
xs[3] = 5; // grows to 4; slots 1 and 2 are 0
printf("%d %d %d\n", xs[0], xs[1], xs->count); // 11 0 4
}
Reads never grow. xs[99] on a four-element list answers the element type's zero and leaves ->count at 4 — probing a list can never mutate it. Before v3.279.0 the store was the silent one: the slot did not exist, the write vanished, and xs[0] answered 0 without a word.
| Function | Returns | Does |
|---|---|---|
l->count | int | element count (the retired listSize(l) — §11) |
listAdd(l, v) | void | append |
listGet(l) | varies | current value |
listSet(l, v) | void | set current value |
listFirst(l) / listLast(l) | int | move cursor |
listNext(l) | int | advance |
listSort(l [, desc]) | void | sort |
listDelete(l) | void | delete current |
listClear(l) | void | remove all |
mapPut/mapGet/mapDelete/mapClear/mapReset/mapNext/mapKey mirror the shape you'd expect. Some functions from the older interpreter's list API (listPrev, listReset as a distinct no-arg reset, listSelect-by-index) are flagged as not currently present in the ported builtin set — if your code depends on one of these specifically, check before you rely on it rather than assuming it survived the port.
sortndx (a separate sortable-index container) also exists for stable multi-key sorts by comparator or by struct field.
queue samples.f; // .i / .f / .s — or .StructName
queueInit(samples, cfg["window"], 1); // capacity from JSON; 1 = ROLL
queuePush(samples, 120.0);
while (samples->count > 0) { print(queueTake(samples), "\n"); }
An array's dimension must be a literal (§8), so the moment a limit belongs in a config file a fixed array stops being the right shape — and you end up hand-rolling head/tail indices around it. A queue takes its capacity at queueInit from any runtime expression, so the bound can come straight out of JSON, and the wrap arithmetic lives in the container instead of in your loop. The ring is allocated once at init and never grows: no per-push allocation, which is the point over list for a per-frame fact queue.
One type, no mode flag — the ops decide the behaviour:
| Function | Returns | Does |
|---|---|---|
queueInit(q, cap, policy) | void | set capacity + overflow policy. cap is any runtime expression |
queuePush(q, v) | void | add an element |
queueTake(q) | element | remove + return the oldest → FIFO, a work queue |
queuePop(q) | element | remove + return the newest → LIFO, a stack |
q->count | int | live element count right now (the retired queueCount(q) — §11) |
q->cap | int | capacity as set at init (the retired queueMax(q) — §11) |
queueClear(q) | void | drop the contents, keep capacity + policy |
Using both ends of one queue makes it a deque — that's allowed on purpose. You write one name for each verb; the declaration's .suffix picks the typed entry point at compile time, so the runtime never guesses the element type.
A queue has no subscript, and neither does a sortndx (v3.281.0). Position is the container's own business — a queue hands back the oldest or the newest, a sortndx keeps itself in key order — so there is no slot for you to name, and q[0] is refused at your line on both backends:
queue q.i;
queueInit(q, 4, 0);
q[0] = 1; // CX-E1090: the queue type has no subscript … use queuePush(q, v) to add,
// queueTake(q) for the oldest, q->count
The other four families — list, map, array, json — do subscript, read and write. Which is which is one column in the compiler's container-family table, so a family answers this question by existing, not by being remembered.
(v3.287.0) That same column says more than yes-or-no: it says what kind of key the subscript takes. There are exactly two kinds, because there are exactly two ways to name a slot — a position (an int) and a key (a string):
| You wrote | It takes | Because |
|---|---|---|
xs[i] on a list | an int position | a list is a sequence |
a[i] on an array | an int position | same — and the same for a passed (dynamic) array |
m["name"] on a map | a string key | a map is a lookup |
d[...] on a json | either | the first subscript decides whether the root is an object or an array (§json) |
s[i] on a string | an int position | the i-th character |
p[i] on a pointer | an int position | plain C: p[i] is *(p + i) |
q[...], s[...] on a queue / sortndx | nothing | there is no slot to name — above |
d[...] on an xmldoc, memfile, image, … | nothing | a resource handle is walked by verb |
n[...] on an int or a float | nothing | one number has no slots — below |
Offer the wrong kind and CX says so at your line, on both backends, before anything reaches the C compiler — it names the family, what that family takes, what you gave it, and where to go instead:
list xs.i;
listAdd(xs, 1);
xs["k"] = 9; // CX-E1093: list subscripts take an int position, and this one was
// given a string key … Use a map
map m.i;
m["a"] = 1;
m[0] = 9; // CX-E1093: map subscripts take a string key, and this one was
// given an int position … Use a list or an array
xmldoc d;
d["k"] = 9; // CX-E1090: the xmldoc type has no subscript … an xmldoc is a TREE,
// walked by verb — xmlroot(d), xmlchild(e, name), xmltext(e)
And a plain number is not a container either (v3.290.0). int and float hold one value, so there is no slot to name and n[0] is refused at your line on both backends — exactly like a queue, for exactly the same reason:
int n;
n = 5;
n[0] = 1; // CX-E1090: the int type has no subscript … an int is ONE number,
// so there is no slot to name — declare a `list xs.i`
// for positions or a `map m.i` for string keys
Before v3.290.0 this was the one shape that got past the refusal, and what it cost was the whole point of having one: natively it reached gcc, which answered "subscripted value is neither array nor pointer nor vector" at a line in generated C — a file you never wrote; on the register VM it reached CX-E1038, whose advice was "build native", which walked you straight into that gcc line. A pointer is the case to keep separate in your head: p[0] is valid — it means *p, plain C, and CX keeps all of C.
json has no refusal here, and that is the ruling rather than an omission. It is the one family that takes either kind, because the first subscript is what decides whether its root is an object or an array — so neither key can be wrong at compile time. Ask a json root for the other kind after it has been decided and you get the runtime refusal instead, which writes nothing:
json d = jsonParse("[1]");
d["k"] = 7; // refused at run time, nothing written — a write creates, it never converts
println(jsonExport(d)); // [1]
The key is judged when the compiler can prove its type: a literal, or a name whose declarations agree (k.s = "b"; m[k] = 1; is fine, xs[k] is not). A key hidden behind a call or an expression is left alone — CX would have to guess, and it would rather say nothing than guess wrong.
Overflow is a policy, chosen per queue, and it's data too:
0 = REJECT — a push onto a full queue is a loud error. Use it when losing an item would be a bug you must hear about.1 = ROLL — a push onto a full queue overwrites the oldest, giving a "last N" sliding window that never grows.Every failure is loud — there are no sentinel returns anywhere in this API. Use before queueInit, capacity <= 0, a push onto a full REJECT queue, and a take/pop on an empty queue all stop with an error naming the queue or the op. That's why q->count exists as the drain-loop guard: no caller ever has to probe by failing. Re-initialising with the same capacity and policy is a clear-and-reuse; re-initialising with a different one is an error (a bound that changed between calls means the program read a different config key).
Struct elements (queue window.Sample;) queue several fields as one element, so they can't drift apart the way a queue-per-field pushed in lockstep can:
queue window.Sample;
struct Sample s; struct Sample out;
queueInit(window, 3, 1);
s.ms = 120.0; s.code = 200; queuePush(window, s); // copies the record IN
queueTake(window, out); // copies the OLDEST record OUT
Note that take/pop take a second argument here. A struct is bytes, not a value a call can hand back, so it's copied into a variable you supply; writing the 1-arg form is a compile error, not a surprise at runtime. v1 restriction: a queued struct must hold plain numbers — a string field is refused at the declaration, because the element moves as raw bytes and a copied string handle would have no owner. Queue an id and keep the text in a map alongside.
Queues are byref like the other containers, and for the simplest possible reason: a queue is an opaque handle, so passing it to a function passes the queue itself.
When to reach for it: use a queue when a dropped item is a lost fact. Keep an explicit bound plus a loud counter instead when a drop is a degraded result the program is meant to survive.
Worked example: Examples/507 test queues.cx.
-> asks about the handle, [...] asks about the dataA container variable has two selves. There is the data it holds, which you reach with a subscript, and there is the handle itself — how many items, how big, whether it is still alive. For a long time both were spelled the same way, so the compiler had to guess which you meant. That guess is the shared root of several long-standing footguns: jsonvar == 0 not meaning what it looks like, an int sink silently reading an object as 0, a queue whose capacity you passed in and could never ask for again.
-> gives the second self its own spelling:
json config; queue jobs.s; list scores.i;
config->count // 3 — members / elements / children
jobs->cap // 8 — capacity (queue only)
config->valid // 1 — still live? 0 after a free
config->type // 5 — json node kind (runtime); element type elsewhere
config->id // the raw handle integer, if you need it
while (i < ports->count) { ... } // the everyday use
if (jobs->count * 2 >= jobs->cap) { ... } // capacity is visible now
config["servers"]->count // a SUBNODE answers too
config["servers"]->type // 4 if that member is an array
config["a"]["b"]["c"]->count // chains as far as you like
A subscript can be the base, not just a variable. Asking about a subnode — "how many entries are under servers?", "is this member an array or a string?" — is one of the most common metadata questions there is, so doc["key"]->count reads directly rather than forcing you to park the node in a temporary variable first. It chains, and it costs the same as the bare-variable form.
| Field | Means | Available on |
|---|---|---|
->count | elements / members held right now | json, queue, list, map, array, sortndx |
->cap | capacity chosen at creation | queue |
->type | json: node kind, at runtime. Others: element type, a compile-time constant | all |
->valid | handle liveness | all (see the note below) |
->id | the raw handle integer | all |
It costs nothing. Every one of these resolves at compile time from the variable's declared type. No struct exists at runtime, nothing carries a tag, nothing is dispatched: config->count becomes exactly the call you would have written by hand, and scores->type folds to a constant before the program runs.
Read-only. These are derived facts about a handle, not storage. jobs->count = 0 is a compile error (CX-E1046) rather than a silently dropped write — the only thing that assignment could mean is "discard the elements", and that already has its own name, queueClear. An unknown field is CX-E1045, and it names the field you probably meant.
-> is the only spelling — the call forms are retired (v3.186.0). queueCount(q), queueMax(q), jsonSize(doc), jsonType(doc), listSize(xs), mapSize(m), arrSize(a), arrCount(a), and the container-typed resolution of len/length/size/count are compile errors (CX-E1048) that name the field replacing them. Two things this removed, beyond the duplication:
arrCount used to compile natively and be refused by the VM — the same program built one way and not the other.mapSize on a 3-element list answered 8, jsonSize on a queue answered 0, queueValid on a live json handle answered 0. There is now one spelling and it resolves from the declared type, so those cannot be written at all.| was | now |
|---|---|
listSize(xs) mapSize(m) arrSize(a) arrCount(a) | xs->count |
len(xs) length(xs) size(xs) count(xs) (container arg) | xs->count |
queueCount(q) / queueMax(q) | q->count / q->cap |
jsonSize(doc) / jsonCount(doc) | doc->count (see the note below) |
jsonType(doc) / jsonValid(doc) / queueValid(q) | doc->type / doc->valid / q->valid |
Three things are deliberately NOT retired, because each answers a question -> cannot:
len(s) / length(s) on a string — canonical, untouched. The type does the classifying, so string code never had to change.len / length / jsonLen on a json node — these dispatch on the node's kind, so len(j["s"]) on a string node is its text length (5), where j["s"]->count is its child count (0). Different questions, both worth having.jh.i = jsonParse(...)). -> resolves from the declared type, so there is no arrow form for an int — retiring it there would leave nothing in its place. Declare the variable json and you get ->; keep it an int and the call form is still yours. (Worth knowing if you are tempted to "upgrade" old code by changing the declaration: on a json-typed variable doc > 0 does not compare the raw handle, so that edit changes more than the spelling.)One thing worth knowing, because it is not what you might assume:
config->count derefs a document handle, so a 3-member object answers 3. The retired jsonSize(config) did not deref and answered 1 — so if you are porting code, that one site changes its answer, and the compiler's error says so at the site rather than leaving you to find out.->valid catches use-after-free, not use-after-free-then-reuse. json and queue keep real liveness state, so a freed handle answers 0 where a plain != 0 test could not tell. But a pool slot can be recycled by a later parse, and no generation counter is kept, so a handle held across a free and a new parse may name a different live node.-> on a struct pointer is unaffected — this is a resolution step keyed on the declared type, and a struct is not a container family.
This holds inside #pragma rules c rule text too, since v3.187.0. Rule text was the one exemption for a single release — the arrow was unwritable there — and it no longer is; see §28.
The retirement above replaces a metadata read with a field. The same rule, one spelling per operation, also retires four verbs, each replaced by another verb. Same compile error, same code: CX-E1048 names the replacement.
| retired spelling | write this instead |
|---|---|
listRemove(xs, i) | listDelete(xs, i) |
mapRemove(m, k) | mapDelete(m, k) |
mapWalkNext(m) | mapNext(m) |
mapFirst(m) | mapReset(m) then mapNext(m) -- not a rename, see below |
remove(s, v) | delete(s, v) -- the sortndx element verb, not a file operation |
sortndxRemove(s, v) | sortndxDelete(s, v) |
prt(v) | prts(<string>) -- it never printed anything; see the note below |
prtl(s) | prts(<string>) -- a second name for the same expansion |
jsonStringify(j) | jsonExport(j) -- it was an alias of exactly this |
jsonStringifyPretty(j) | jsonExportPretty(j) -- likewise |
Delete is the verb for removing one element, in every container family. listRemove and mapRemove were aliases onto that same operation; mapWalkNext was an alias onto the same operation as mapNext.
The two rows that were never real: prt and prtl. Unlike every other retirement here, these do not rename a working operation onto its canonical spelling -- there was no working operation. prt expanded natively to ((void)(x)), a silent no-op: it compiled, ran, printed nothing and exited 0, while the register VM refused the name outright at every arity. prtl expanded to exactly the same code as prts (one implementation under two names) and was likewise refused by the VM. Neither appears in the v2.0.28 builtin table, neither had a Builtins Reference entry, and no program in the tracked tree called either one. The no-newline print family is, and always was, prts / prti / prtf / prtc.
remove was the sortndx element verb, never CX's file-delete. The bare name did not reach C's remove() before this retirement either — it was a compiler verb gated on the operand being a sortndx, so nothing that deleted a file has changed. Use fdelete(<path>) for a file; C's remove() is still reachable through _C{}. The compiler repeats that note at the site rather than answering a file-delete with advice about a container.
mapFirst is the one that is not a pure rename. It did two things: rewound the cursor and advanced onto the first entry. So it is replaced by the pair; mapNext alone resumes from wherever the cursor already was. The compiler carries that caveat at the site.
Two of these were only ever spellable on one backend, which is how they survived long enough to need retiring: listRemove and mapWalkNext resolved in the native emitter and nowhere else, so the register VM had always refused them.
containerDelete — releasing a container early (v3.239.0)listClear and mapClear empty a container and keep it. Until v3.239.0 there was no way to say the other thing — "I am done with this container" — at all, in any spelling. containerDelete(c) is that verb, and it is the only one:
list rows.s;
listAdd(rows, "a");
// ... use it ...
containerDelete(rows); // release now, not at the end of the block
Containers die like strings — by reference count. containerDelete is not a free(); it is an early scope exit for one reference. It releases your reference and nothing else, and the memory is reclaimed when the last holder releases it — which, for the ordinary case of a single holder, is immediately. What it buys you over just letting the block end is timing: a large container released at the point you finish with it rather than dozens of lines later.
It works on every container family — list, map, dynamic array, sortndx, queue, json — and the compiler picks the right release from the declared type, so there is one verb to remember rather than six.
After the call the name is dead. Using it again is a compile error at your own line, on both backends:
containerDelete(rows);
listAdd(rows, "b"); // CX-E1068: 'rows' was deleted on line 4
"Using it" includes SUBSCRIPTING it, on either side of the = (v3.283.0). A verb call, a ->count read and a foreach were caught from the start; a subscript was not, so containerDelete(v); v[0] = 9; compiled — and natively ran, writing through a released reference. Every subscript position now asks the same question and gets the same code, in every family, and wherever the subscript stands:
list v.i;
list w.i;
listAdd(v, 1);
listAdd(w, 5);
containerDelete(v);
v[0] = 9; // CX-E1068 -- a store
printf("%d", v[0]); // CX-E1068 -- a read
printf("%d", v[0] + 1); // -- inside an expression
printf("%d", w[v[0]]); // -- as someone else's index
v[0] += 1; // -- a compound assign
A fixed array is the one exception, and for a reason that is not an exemption: containerDelete on a fixed array is itself refused (CX-E1069 below), so the name was never validly dead and the subscript after it is fine.
This is deliberately strict, and strict in the direction that cannot bite you at runtime. A delete in one branch of an if kills the name in the other branch too, and a delete anywhere in a loop body kills it for the whole body — including lines above the delete, because the next iteration reaches them with the container already released. Some programs that would have run are refused; the alternative is a container that silently does nothing, which is worse.
Three things are refused outright rather than quietly accepted:
| you wrote | why it is refused | |
|---|---|---|
containerDelete(n) on an int, a string, or a fixed array | none of these holds a reference. A fixed array is a raw C array — it has no header and no refcount | CX-E1069 |
containerDelete(xs) where xs is a parameter | a container parameter is borrowed: the caller owns the reference. Delete it where it was declared | CX-E1070 |
| using the name after the delete | see above | CX-E1068 |
The deleted variable holds null, and access through it is DEFINED (v3.284.0). containerDelete nulls its operand, the same thing delete does to a struct pointer — so what happens if a read or a write does reach that null handle is not left to whatever the freed memory happens to say. By default a read answers the element type's zero (0, 0.00, "") and a write drops, touching nothing; under #pragma checks on the same access is CX-E5044: access through a deleted container instead, on both backends.
You mostly cannot get there, because the compile error above is what a program actually meets. The one position it cannot see is a delete and a use in different function bodies — a file-scope container deleted at file scope and read inside a function, or the reverse — and that is the position this promise is for:
list v.i;
function use.v() { printf("R=%d\n", v[0]); } // 0 by default; CX-E5044 under checks
listAdd(v, 1);
containerDelete(v);
use();
Two notes, because both are the kind of thing you would otherwise have to find out by experiment. The write drops rather than rebuilding the container: construction on demand is what a declaration promises, and a store through a handle its owner released is not a declaration. And a json handle is the one family where checked mode stays quiet here — a released json reads back as the same handle 0 a never-built one has, so refusing would refuse subscript-birth (d["k"] = 1 on a fresh json, which is ruled to work); it answers 0 on both backends, identically, as before.
There is no force-free spelling, and there will not be one. A verb that frees a shared container regardless of who else is holding it would hand every other holder a dangling pointer — and since container parameters are byref by default (§11), a container passed to a function has more than one holder by construction. Strings do not have such an escape hatch either; containers follow strings, which is the whole design.
Immutable, UTF-8, small-string-optimized up to 12 bytes inline (widened from 7 bytes), GC-managed handles beyond that. Concatenation in a loop is amortized O(1) (§5).
| Function | Does |
|---|---|
len(s) / strlen(s) | length |
left(s,n) / right(s,n) / mid(s,pos[,len]) | substrings — mid is 1-indexed |
trim/ltrim/rtrim | whitespace trim |
lcase/ucase/capitalize | case |
findstring/instr | substring search (1-based, 0 = not found) |
replacestring/removestring/countstring/reversestring/insertstring | mutation-by-copy (strings are immutable — these return a new string) |
contains(s, sub) | substring test → 0/1 (also polymorphic over containers, §8) |
startswith(s, prefix) | 0/1 |
stringfield(s, idx, sep) | delimited field, 1-based |
hex(n) / bin(n) | int → hex/binary string |
Unchanged from a C programmer's expectations: abs/fabs, min/max/fmin/fmax, sqrt, pow, mod, sign, the full trig set (sin/cos/tan/asin/acos/atan/atan2), hyperbolic (sinh/cosh/tanh), log/log10/exp, rounding (floor/ceil/round), clamp/lerp/remap, and random/randomseed.
| Function | Does |
|---|---|
str(n) / strf(n) | number → string |
vali(s) / valf(s) | string → int/float |
atoi/atof/ftoa | C-standard-library-style aliases over the same conversions |
(int)expr / (float)expr | explicit cast |
Int-to-float promotion in mixed expressions is automatic, same as always.
fread(path) / fwrite(path, content[, mode]) — unchanged.
list files.s;
n = dirlist("data", "*.cx", files); // glob match, case-insensitive, skips . and ..
listSort(files);
foreach files { f = listGet(files); print(f, " ", filesize("data/"+f), " bytes\n"); }
fileexists/direxists/filesize/makedir/removedir/renamefile/copyfile/movefile/dirlist round out a full cross-platform (dirent.h/sys/stat.h) filesystem surface that didn't exist in the PureBasic era.
A memfile is one GC-allocated, page-grown byte buffer with a read/write cursor — a small integer handle, safe on invalid handles (returns 0/empty, never crashes). It's the substrate the compiler itself uses internally for #include expansion, now exposed as a language feature:
mf = mfnew();
mfputs(mf, "Hello World");
mfseek(mf, 5);
mfinsert(mf, " there"); // "Hello there World" — shifts the tail right
print(mftostr(mf), "\n");
mfsave(mf, "out.txt");
mffree(mf);
doc = mfload("out.txt"); // read a real file into a memfile
mfseek(doc, mfsize(doc));
mfinsertfile(doc, "footer.txt"); // splice another file's bytes in — the in-memory #include
JSON and XML latch directly onto a memfile's bytes with no copy: jsonparsemf(h), xmlparsemf(h).
compress(s) -> s / decompress(s) -> s — raw zstd, binary-safe via length-prefixed strings.
archiveCreate("game.dat", "zip", "assets"); // pack a directory
archiveExtract("game.dat", "out/"); // auto-detects format
Convenience one-liners: tarGz/tarBz2/tarXz/zipCreate/sevenZip(out, src).
HTTP/FTP/SFTP/email/SSH are shipped, built on a vendored static libcurl + libssh2 + TLS bundle (MinGW/MSVC; auto-detected against system libcurl on Linux). httpGet / httpPost / httpStatus / netError / netTimeout, ftpGet / ftpPut / ftpList / sftpGet / sftpPut, emailSend, sshExec / sshExecKey.
Async variants, email attachments/MIME, and IMAP/POP3 receive are open refinements rather than gaps in the base surface.
Everything above is a client. Since v3.194.0 a CX program can also be the service. Eighteen builtins, one specification, both backends.
Sockets — netListen(port) binds loopback only; netListenAny(port) is reachable from other machines. Two names rather than one call with a flag, so exposing a port is visible at a glance. Then netAccept(srv), netConnect(host, port), netRead(conn), netWrite(conn, s), netClose(h). All return a handle or a negative error; netError() names the failure in words ("the port is already in use", not 10048).
netPoll(handle, timeout_ms) is the whole blocking model — 1 ready, 0 timed out, negative on error:
netPoll(srv, -1) waits forever, so it is blocking-accept — no second surface needed.netPoll(srv, 0) polls and returns immediately, so it drops into a render loop as one more line and a game can serve while it draws.HTTP — on an accepted connection: httpRead(conn) parses the request, then httpMethod(conn), httpPath(conn), httpHeader(conn, name), httpBody(conn) read it, and httpRespond(conn, status, contentType, body) answers. httpStream and httpEvent cover chunked and server-sent events.
httpServeDir(conn, mount, dir) answers requests under mount from dir. It returns 0 having written nothing for a request outside the mount, which is what lets it sit in an ordinary if ladder beside routes you write by hand instead of taking the server over:
int srv = netListen(7440);
while (running == 1) {
if (netPoll(srv, 200) == 1) {
int conn = netAccept(srv);
if (httpRead(conn) == 1) {
if (httpPath(conn) == "/status") {
httpRespond(conn, 200, "application/json", "{\"ok\":1}");
} else if (httpServeDir(conn, "/", "www") == 0) {
httpRespond(conn, 404, "text/plain", "no");
}
}
netClose(conn);
}
}
/ serves the directory's index.html; a directory without one is a 404, and there is never a directory listing. Containment is one decision, not a ladder of patches: decode once, resolve the path, check the resolved path against the resolved root. .., %2e%2e, ..\, ....//, %252e%252e, an absolute path, an embedded NUL and a symlink out of the tree all get the same 404 an absent file gets — identical on purpose, so a probe cannot learn which guess was right. Same behaviour on all three platforms.
Cross-origin access is off by default. httpAllowOrigin(origin) turns it on for a named origin. A server that always sends * is a liability, so CX does not send one for you — and the best answer is often not to need it: a page served from the same program shares its origin and has nothing to allow.
.wasm is served as application/wasm, which is the row that MIME table exists for — a browser will not stream-compile a module served as anything else.
See examples/network/ — 01_mesh.cx (one program, several machines), 02_chat.cx (hosts, joins, and joins from a browser tab), 03_serve.cx (serves a directory, including a page CX itself compiled to wasm).
Graphics moved from a SpiderBasic-compatible 2D API to a raylib-backed 2D/3D API, and every builtin was renamed onto one convention: gpu_<category>_<action>. This was a genuine, comprehensive rename — old scattered names (screenopen, drawrect, imgload...) all map onto the new scheme, and multiple historical duplicate names collapse onto one canonical spelling.
gpu_screen_openex(0, 0, 0, 800, 600);
fnt = gpu_font_load("Arial", 16);
while (running) {
gpu_screen_clear(rgb(0,0,0));
gpu_frame_begin(0);
gpu_draw_text(10, 10, "Game Running", rgb(255,255,255));
gpu_draw_rect(gpu_mouse_x(), gpu_mouse_y(), 10, 10, rgb(255,0,0));
gpu_frame_end();
gpu_screen_flip();
running = !gpu_key_pressed(27); // ESC
}
Categories, with a representative sample (the full old→new table has ~150 entries):
| Category | Covers |
|---|---|
gpu_screen_* | open/close/clear/flip/resize/width/height |
gpu_frame_* | begin/end drawing, event poll, frame time |
gpu_window_* | fullscreen, size/pos, resized-flag |
gpu_draw_* | 2D primitives (rect/circle/line/pixel/text) and 3D (cube/sphere/cylinder/plane/billboard/triangle3d) |
gpu_camera_* | 3D camera begin/end/new/setpos/settarget/update |
gpu_mesh_*, gpu_model_*, gpu_shader_*, gpu_light_* | full 3D asset pipeline |
gpu_collide_* | box/sphere/ray collision tests |
gpu_ray_* | mouse-ray picking |
gpu_image_*, gpu_font_* | 2D asset loading/drawing, including _loadmem variants for embedded bytes |
gpu_mouse_*, gpu_key_* | input |
gpu_grid_* | a full data-grid/spreadsheet widget (sort, cell edit, theming, scroll) |
gpu_pfx_* | particle-effect system |
gpu_gui_* | Nuklear-backed immediate-mode GUI (button/checkbox/console/editor/input/label) |
gpu_fx_* | named screen effects (glitter, plasma, thruster, wormhole) |
All gpu_* builtins are native-only — calling one under the register VM (-P pcode=risc) is a clean compile-time error, not a silent stub.
json is a first-class type with subscript syntax, on both native and the register VM.
json j = jsonParse("{\"user\":{\"name\":\"Alice\",\"age\":30},\"tags\":[\"x\",\"y\"]}");
name = j["user"]["name"]; // chained subscript, coerces on read -> "Alice"
age = j["user"]["age"]; // -> 30
ship["hull"] = 90; // creates the object on first write
ship["hull"] -= 10; // compound forms: -= += *= /= %=
cfg["screen"]["w"] = 800; // "screen" vivifies as an object
doc["servers"][0]["host"] = "a"; // "servers" vivifies as an ARRAY, [0] as an object
json c; c[0] = 10; // an int index at the root: c is an array
A write updates an existing key rather than appending a duplicate. Nested chained writes mint any missing intermediate level as they go, to any depth, including on a still-null base (json d; d["a"]["b"] = 1;).
What kind each level becomes is decided by the subscript that indexes it: a string key means that level is an object, an int index means it is an array. That one rule is read off the chain from left to right and has no depth limit, so doc["servers"][0]["host"] = "alpha" builds {"servers":[{"host":"alpha"}]} — the nesting you wrote is the nesting you get, without a save-and-reload round trip.
Vivification creates, but never converts. An existing level of the right kind is reused; a json null is promoted in place; but an int index into a level that already holds an object — or a string key into one that already holds an array, or anything written through a scalar — is refused loudly, with nothing written, because silently changing a level's kind would throw away data you already stored. A read never vivifies.
Storing past the end of an array grows it, padding the gap with real json nulls (d["a"][5] = 1 on an empty array gives five nulls and then the value). A negative index is refused.
A name whose first use is a string-keyed subscript write needs no declaration at all — inv["gold"] = 100; on a fresh identifier births a json object exactly as if json inv; had preceded it. A read and a computed key still decline and error as before, so this can't surprise you on an already-typed variable.
json d; creates a real document immediately — like every other container declaration — but with no root kind yet. The first subscript decides it, in whichever direction that subscript asks for, and this is the same rule every nested level has followed since v3.264: a string key means an object, an int index means an array.
json a; a["k"] = 1; // {"k":1} -- a string key made the root an object
json b; b[0] = 10; // [10] -- an int index made it an array
json c; println(jsonExport(c)); // null -- nothing has decided yet, and it says so
Three consequences worth knowing:
It works wherever the deciding subscript is written. Inside a callee, through a computed index, at file scope — the decision happens when the write runs, so nothing has to see it in advance:
function fill.v(json d) { d["servers"][0]["host"] = "alpha"; }
function demo.v() {
json d;
fill(d);
println(jsonExport(d)); // {"servers":[{"host":"alpha"}]}
}
A decided root stays decided. Once a subscript (or a parse) has given the root a kind, a subscript of the other kind is a loud refusal that writes nothing — creates never convert, exactly as at every nested level:
json d = jsonParse("[1]");
d["k"] = 7; // refused, loudly; the document is still [1]
jsonCreate() and jsonCreateArr() are retired (CX-E1048). They committed the root to a kind at construction, which is now the subscript's job — so jsonCreate(); d[0] = 10; used to drop the store in silence, and jsonCreateArr(); a["k"] = 10; used to file the value at an index and throw the key away. Declare instead; where a call is what you need (reassigning an existing variable to a fresh document), jsonUndecided() is the same thing the declaration mints.
A name whose first use is a subscript write still needs no declaration at all — inv["gold"] = 100; on a fresh identifier births the document exactly as if json inv; had preceded it.
ship["hull"] = ship["hull"] - dmg and its compound form ship["hull"] -= dmg compile to one key walk (a single native call does the read, the op, and the write) instead of two — measured roughly 1.7–1.9× faster on a hot loop. This kicks in automatically for a literal string key, a + - * / % op, and a pure right-hand side; anything else (computed key, a call in the expression) falls back to the two-step path, identically on both backends. jsonRmwNum/jsonRmwStr are the same fusion exposed as callable builtins for manual use.
A json number keeps an exact int64 lane when it was born integral (an int-typed write, an integral parsed token, or an int RMW), so full-range 64-bit integers round-trip through set/get/RMW/export/parse without rounding through a double past 253. A float write to the same key returns it to float.
The value's type resolves from context: a typed sink (int n = j["k"];, a printf %d/%s/%f) coerces to that type; an untyped sink (print(j["k"])) renders as text; a binary op takes its concrete sibling's type (total + j["price"] reads as float if total is float). json op json with nothing else to infer from defaults to integer and warns — use jsonAsStr/jsonAsInt/jsonAsFloat to be explicit.
json doc; // root kind decided by the first write below
els = jsonArr(); // array
json e;
jsonAddNum(e, "x", 10); jsonAddNum(e, "y", 20);
jsonArrAdd(els, e); // append object to array
jsonAddNode(doc, "els", els); // nest array under a key
jsonSave("layout.json", doc);
jsonExport(j) (compact) and jsonExportPretty(j) (2-space indent) cover serialization to a string; jsonSave/jsonSavePretty write the same two forms straight to a file. len(j) dispatches by node kind (string length / child count / text-form length).
jsonStringify and jsonStringifyPretty are retired (CX-E1048). They were pure aliases -- each bound the very same code as its Export twin -- so one operation answered to two names and no program could tell them apart. Both went together: leaving stringify alive without its pretty twin would have been a surface where one spelling works and its obvious sibling is a hard error.
_json h { ... }Instead of building a document call-by-call, or escaping every quote in a jsonParse("{\"...\"}") string, write the JSON as itself in a braced block. The compiler parses it at build time and lowers it to the builder calls above — so a malformed literal is an error at that .cx line, never an invalid handle at runtime.
string sName; int nAge; float fHp;
sName = "Ada"; nAge = 30; fHp = 99.5;
_json cfg {
{
"name": sName,
"age": nAge,
"hp": fHp,
"tags": ["a", "b", 3],
"meta": { "level": 7 }
}
}
name = cfg["name"]; // an ordinary json handle from here on
sName/nAge/fHp are unquoted, so each is a CX variable spliced in — the float enters as a number rather than round-tripping through text — and "meta" shows that objects and arrays nest to any depth. Note what the block does not carry: no // comments inside the braces. It is strict JSON in there, so a comment is a compile error at that line, exactly like a comment in a .json file.
json cfg in place and builds the document at the block's position. It is a compile-time jsonParse — the handle it yields is the same kind jsonParse returns, so jsonValue/subscripts/jsonExport all work uniformly. Top-level object and array are both legal.n + 1 is refused (precompute to a local, C89-style); keys stay literal (dynamic keys remain runtime jsonSet); strict JSON otherwise (no comments — exactly like a .json file). A spliced value's builder is chosen from its declared type (string→text, int→exact int64, float→number, json→nested node)._json is for program-shaped data — fixtures, request bodies, structural config. Game content still belongs in Orfeus/data/*.json; the ergonomic literal is not the easy path to hardcoding it._rules s { { ... } }A CX rule is a short C body over the entity json e, and it is data: a string the program can swap at runtime, from a config or from an AI, with no recompile. The price used to be writing that C inside a string literal — every quote escaped, and, worse, unchecked: a typo is not a compile error, so the rule fails to compile the first time it runs, prints a complaint, and does nothing while the program carries on as though it had fired.
_rules takes the same text in braces and runs it through the rules-C frontend at build time:
#pragma rules c
_rules COMBAT {
{
float dmg = 0;
json hits = e["hits"];
foreach hits { json it = jsonGet(hits); dmg = dmg + it; }
e["hull"] = e["hull"] - dmg;
if (e["hull"] < 30) raise(50, e);
}
}
ruleExec(ship, COMBAT); // an ordinary string argument -- nothing new here
ruleAdd/ruleExec/ruleAuthor consume it unchanged — no new builtin, no new runtime surface, and the rule stays data: assign the variable something else at runtime and the behaviour changes, exactly as before.{ is what marks the C dialect. What the block captures is the text verbatim, the same rule _C{} follows.{ is DSL rule text, which stays an ordinary string (its text has no escape problem to solve). No splices — a C body has no free grammar slot, and runtime-composed rule text (the LLM-reply shape) keeps today's string path. Builtins only — a rule calling a ruleFunc(&fn)-registered function cannot be checked before that registration has happened, so keep that one as a runtime string._rules, and nothing at all for a file that does not.XML keeps its original shape: xmlparse/xmlfree/xmlroot/xmlchild/xmlnext/xmlname/xmltext/xmlattr/xmlcreate/xmladdnode/xmlsettext/xmlsetattr/xmlexport, plus xmlparsemf to parse straight out of a memfile. Regex is new since the original reference: regexmatch/regexextract/regexreplace/regexcount.
The original SQLite surface (dbopen/dbexec/dbquery/dbnext/dbstring/dbint/dbfloat/dbclose, etc.) is listed among the builtins that did not carry over cleanly in the port — treat database access as not currently available pending a real C-port SQLite binding, rather than assuming the old API works.
Each test is a .cx file with a numeric prefix, #include "default.cxi" for assertion macros, and assert/assertEqual/assertFloatEqual/assertStringEqual calls:
#include "default.cxi"
x.i = 41;
assertEqual(x + 1, 42);
Numbering convention: 0xx basics/control flow, 1xx functions/typed decls, 2xx arrays, 3xx pointers, 4xx structs, 5xx collections, 6xx algorithms/perf, 7xx perf benchmarks (often assertion-free), 14x the AI suite. The canonical test tree currently runs to at least #720 (well past 300 numbered tests, not counting a retired/ subfolder) — a big jump from the ~120–170 of the PB era; treat any specific pass-count you see quoted elsewhere as a snapshot, since this suite grows with nearly every commit.
The sweep runner (tests/sweep.ps1 / tests/cport_cxc_sweep.ps1) checks a golden expected-output file per test on both the native and register-VM builds — matching each other isn't sufficient, since both could be wrong the same way; each must independently match the golden.
md5/sha1/sha256/sha512/crc32/base64enc/base64dec — unchanged.
date()/time()/year()/month()/day()/hour()/minute()/second()/milliseconds()/elapsed()/delay(ms)/monthname(n) — unchanged.
getenv/exit/memaudit/memused are current. The old realptr/realaccess raw-memory-address functions assumed the PureBasic VM's flat memory model; given the register VM's slot-handle representation of .p (§10), don't assume these still mean what they used to without checking.
Confirmed current:
| Pragma | Effect |
|---|---|
#pragma named "a, b, c" | Declare global names AI-generated bytecode may FETCH/STORE |
#pragma checks on (aliases check, checkbounds) | Turn on the runtime value checks — hard-error grown-array bounds violations instead of the lenient default, refuse a field access through a null struct pointer instead of dereferencing it, and refuse access through a container containerDelete released instead of answering the element type's zero (CX-E5044). Checks are off by default on both backends; this pragma turns them on for both; a program behaves identically compiled native or to the register VM either way. See §"Checked mode" and §containerDelete and §32 (every one of these refusals routes through an installed onerror handler) |
#pragma risc all | Force whole-program register-VM codegen |
#pragma ftoi truncate / #pragma ftoi round | Float→int cast mode |
#pragma temp_ring_size N | Per-function _temp ring slot count (default 8) |
#pragma gc max= realloc= min= | GC arena sizing |
#pragma randomseed N | Pin the RNG stream so the program replays identically — see below |
#pragma localdeclares on (default off) | An undeclared name inside a function becomes a local that shadows any same-named module-global, instead of binding it — C's discipline, reported with CX-W1011. _local on one function does the same thing at that grain (_forcelocal is its heritage alias). Never an error; see §4 "Global vs. Local" |
#pragma BuildLTO yes (same as --lto) | File-wide link-time optimization — a measured ~4× win on gcc for container/string-heavy code |
#pragma nowarn W1010,W1012 | Silence those specific warning codes for this program — see below |
#pragma onerrordefault off | Stop CX writing its own structured record for a failure to the side channel. Your registered onerror handler still runs — see §32 |
#pragma nowarn — silence a warning you can name (v3.262.0)#pragma nowarn W1010 // one code
#pragma nowarn W1010, W1012 // several; commas or spaces, and the lines accumulate
cx game.cx --build -P nowarn=W1010
The two spellings are two doors onto one judgement, and they union — -P nowarn=W1010 alongside #pragma nowarn W1012 suppresses both. That makes nowarn the one pragma key where the command line does not override the source: a set has no last writer, and overriding would mean that asking for one more suppression removed one.
The warnings are silenced, never uncounted. The per-site lines go; one line per requested code stays, naming the code, the number withheld, and which door asked:
cx: note: CX-W1015: CX-W1010: 566 occurrence(s) suppressed by #pragma nowarn -- remove the suppression to see them
[cx] done 0 warnings (566 suppressed) in 7.56s
So a suppressed program still proves the warning fires, and a count that moves is still information. A requested code that suppressed nothing reports 0, which is how you find a nowarn line that has outlived its reason.
Specific codes only. There is no all or * form, and E codes are refused (CX-E0045) — an error is a refusal, not an opinion. A warning that carries no code cannot be named and therefore always prints.
Recognized but not yet wired: #pragma ai_iterations/#pragma ai_momentum (AI refinement-pass tuning) are reserved for a future AI-tuning pass; #pragma cxc_fallback is the live provider-fallback knob today, not ai_fallback.
Random is random by default. A program that never seeds anything gets a fresh stream on every run, because the runtime seeds from real entropy at startup. When you need the opposite — a golden test, a bug report, a level you can regenerate — you pin it, and there are two ways to do that. The layers override each other in the order they run:
| Layer | Spelling | Scope | Use it for |
|---|---|---|---|
| 1 — default | (nothing) | whole program | games, demos, anything that should feel unpredictable |
| 2 — pragma | #pragma randomseed N | whole program, from before the first statement | golden-compared tests, replaying a run without touching the code |
| 3 — call | randomseed(n) | from that point on | pinning one section (a world seed) while the rest stays unpredictable |
#pragma randomseed N is randomseed(N) placed as the program's first statement — not a second, parallel way of seeding. Last one wins if the file carries more than one, the same as #pragma decimals. N must be a plain integer; anything else is refused with CX-E1052 rather than silently ignored, because a program that says it is pinned and is not still looks right until the day its output matters.
Two details worth knowing:
randomseed(42) and randomseed(43) gave the same numbers, as did 0 and 1 — half of every seed you could type was unreachable. Seed 0 is remapped internally (the generator has no zero state), which is a documented one-value collision, not a silent one.#pragma randomseed N and the old determinism is back, on purpose this time.Gone: #pragma optimize (the post-codegen bytecode optimizer it controlled was removed at the 2026-06-04 CISC retirement). The old stack-size pragmas (GlobalStack/FunctionStack/EvalStack/LocalStack) belonged to the retired stack-based VM — the register VM has no eval stack, so these don't carry meaning in the current model; don't rely on them.
Marker syntax now uses a leading backslash — \{N: / \N:}, not the old {N:/N:}:
\{5: "compute something"
result = x * 2 + 1;
\5:}
generatecode("compute result as x squared plus y cubed", 5); // AI writes replacement bytecode
replacecode(5); // swaps it in
setcode("..."); // hand-supply bytecode, bypassing the LLM (deterministic offline tests)
clonemarker(srcId, dstId); // copy one marker's active body to another
| Function | Reads |
|---|---|
peek_code(pc) | opcode at PC |
peek_i(pc) / peek_j(pc) / peek_n(pc) | operand fields |
peek_flags(pc) | flags field (renamed from peek_ndx — the register VM's instruction shape changed) |
peek_funcid(pc) | function ID |
poke_* | write counterparts |
code_size() | total bytecode length |
Marker and codeswap bytecode doesn't have to stay interpreted. cx --run-lift decompiles a program's own register-VM bytecode back to CX source, recompiles that through the normal native path, and runs the result in-process — recovering most of the VM's interpretation cost for whatever the decompiler can faithfully reconstruct. Coverage today is the decompiler's coverage: scalar arithmetic, strings, and simple control flow lift cleanly; structs, arrays, maps, and multi-argument user-function calls don't yet, and a program using them declines the lift with a clear error rather than running it wrong. Outside that subset, the register VM remains the real, current execution path for marker-generated and codeswapped code — this isn't a claim that interpretation is gone, only that it's no longer the only way such code can run.
ai_set_provider("anthropic"); // openai / google / mistral / cohere / xai / deepseek / groq / ollama / custom
ai_set_key("sk-..."); // optional — env var read otherwise
ai_init(); // 1 if a key resolves
Each cloud provider reads its conventional env var (ANTHROPIC_API_KEY, etc.). Ollama is local-but-HTTP.
ai_set_fallback("groq, ollama"); // one-shot rescue if the primary call errors; state restores after
hit = ai_cache_get(key);
if (hit == "") { result = ai_call(prompt); ai_cache_put(key, result, 3600); } else { result = hit; }
req = ai_call_async(prompt);
while (ai_async_ready(req) == 0) { }
result = ai_async_result(req);
aifunc_ct function add.i(a.i, b.i) { "Return a + b." }
aifunc_rt function clamp.i(x.i, lo.i, hi.i) { "Return x clamped to [lo, hi]." }
aifunc_ct resolves at compile time (compile fails if the LLM/parse fails); aifunc_rt resolves at program startup (same failure semantics, before user code runs). An aifunc with no return suffix returns a variant — the answer is runtime-shaped, and coerces at the call site (int n = ask(q); parses it, string s = ask(q); keeps the text). Pin a concrete return type to skip that.
Refinement (#pragma ai_iterations/#pragma ai_momentum) is recognized syntax but not yet wired in the current C runtime (§25) — don't depend on it doing anything yet.
Covered fully in §26 — this is the runtime-mutable half of the AI story; aifunc is the compile/startup half.
ct_createaifunc/rt_createaifunc/ai_call_func/ai_parse_bytecode/ai_dump_asm/ai_set_named/ai_get_named still function for backward compatibility; new code should prefer aifunc + markers.
All AI-generated bytecode — whether from an aifunc or a codeswap marker — now executes on the same register VM as everything else (§ARCHITECTURE), not a separate AI-only interpreter. Named variables bind to real storage in place (a VM slot, or the bound native global) rather than being copied in and out.
Deferred, not shipped: a local-model provider (CxLlama, a llama.cpp daemon) and a peer-shared library of vetted generations are both designed but not implemented — don't budget on either being present.
A newer subsystem, absent from the original reference entirely: a small embedded rule language that runs against a json entity, aimed at "the data carries its own behavior" game/simulation logic.
ruleExec(ship, "{ float d = e[\"dmg\"]; e[\"hull\"] = e[\"hull\"] - d; if (e[\"hull\"] < 30) raise(51); }");
OnEvent(51, gate, &handler);
The rule source is dialect-sniffed: a leading { is a C-rule (plain C over a json e parameter, compiled at runtime by the shared front-end to register-VM bytecode); anything else is the legacy imperative DSL (bare keys, $temps, for (key)). Both run on the same scratch VM, so behavior is identical native vs. -P pcode=risc. C-rules need #pragma rules c in a built program (the cx --run REPL-style path always has it).
A rule written as a literal is checked when you BUILD, not when it fires. OnEvent(51, "{ ... }", &handler) takes rule text in two of its three slots, and until v3.311.0 neither was looked at until the event happened — so a typo reached you the day the key was pressed, or, on a handler nobody exercised, never. Both slots now go through the same build-time check _rules blocks already used, and a malformed one is an error at the .cx line that wrote it. The two gate forms that are not rules — "1" (always) and a plain number on a timer, which is an interval — are left alone, and a gate held in a variable, or loaded with eventSetTable, is still the runtime's to judge: that registry is json and two-way on purpose, and a rule nobody has written down yet cannot be checked before it exists.
The sandbox is deliberately narrow: scalar locals, full operators, if/switch/while/for, e["key"] access, and a whitelist of math/string/json/print builtins. Pointers, _C{}, non-whitelisted calls, and container declarations are declined loudly rather than silently ignored. Loops are budget-capped (default 1,000,000 iterations/tick; #pragma rules budget N) so a runaway rule can't hang the host program — it dies with a clear error and ruleExec returns, letting the caller's tick survive.
->, same as everywhere (v3.187.0)Rule text reads handle metadata with ->, exactly like program text — int n = e["skus"]->count; inside a rule is the same surface, the same five fields (->count ->cap ->type ->valid ->id), and the same lowering. The §11 retirement therefore applies here too: the call spellings are CX-E1048 in rule text as well, and CX no longer has any place where one idea has two spellings.
Until v3.187.0 this was the single exemption, and the reason was mechanical rather than a preference: -> parses as a field read on a dereference, and the rule sandbox declines that entire node class (the same rule that rejects pointers), so the arrow could not be written in a rule at all and the call forms had to stay legal there. The sandbox now admits that one shape — a metadata field on a handle base — and nothing more.
What is still refused inside a rule, so the sandbox is no wider than before:
| in rule text | result |
|---|---|
e["skus"]->count, e->valid | the metadata surface, allowed |
e->hull | declined — a non-metadata field name is still struct field access, and never was a spelling of e["hull"] |
*p, &x, struct/pointer locals | declined, unchanged |
n->count where n is an int | an error, not a silent 0 |
jsonSize(e["skus"]) and the other retired spellings | CX-E1048, naming the field that replaces them |
This matters most for AI-authored rules: an aifunc writing a rule from documentation older than v3.187.0 will emit the retired call form, and a rule that fails to compile makes arm() return 0 — the rule silently never fires. Regenerate the prompt rather than translating it (see the AI Manual).
This is the mechanism behind "an entity's state and its behavior both live in the same JSON container" — and, notably, the same primitive an aifunc uses to write a rule at runtime means an AI can author new entity behavior as data, with no recompile. It's also the reason the older CGI2D engine is being phased out (§30).
embed(path) bakes a file's bytes into the executable at compile time (via C23 #embed on capable backends — gcc 15.2 supports it; a cc that lacks it is a hard compile error, not a silent stub):
data = embed("levels.json");
levels = jsonParseBlob(data);
atlas = gpu_image_loadmem(embed("sprites.png"), ".png");
asset(path) goes further: one call site, three deployment modes, switched by a single pragma and a recompile, no source change:
#pragma assetmode disk // dev: load from filesystem
// #pragma assetmode embed // release: bytes baked into the exe
// #pragma assetmode datafile // packaged: loaded from a game.dat archive at runtime
levels = asset("data/levels.json"); // .json -> a json doc
sprites = asset("art/sprites.png"); // image -> a texture handle
Resolution happens entirely at compile time (zero runtime branch) — asset() just lowers to the matching disk/embed/datafile builtin for that file's extension and mode.
CGI2D — a JSON-driven 2D HUD/UI engine (describe widgets in JSON, bind them to CX variables, render every frame) — still exists and still works: cgiInit/cgiLoadJSON/cgiRender/cgiUpdate/cgiLinkVar/cgiHitTest/cgiGet/cgiSet, same shape as the original reference described.
It's explicitly being replaced, not extended. The engine's JSON-binding model is exactly the boilerplate the newer rules/automation system (§28) deletes: instead of describing a widget in JSON and hand-wiring it to CX variables, a rule is the binding — an entity is data + rules + an invisible per-tick loop. The plan is a piecewise cannibalization: the pure drawing primitives (text layout, color/font lookup, hit-testing) get lifted into a small standalone draw module with no JSON dependency at render time; the ~20 widget-specific renderers get ported one at a time as draw-rules; and the JSON element-loading/data-binding layer — the part with the real complexity, and a since-patched string-ownership bug — gets dropped in favor of rule state. If you're building new UI, treat CGI2D as usable-but-legacy and look at whether a rule-driven entity fits your case first.
The original library mechanism (lib_save/lib_load/lib_call, backed by .ocx bytecode serialization) is not currently functional in the C port — the compiler doesn't load .ocx at runtime today (each register-VM-eligible function ships its bytecode as a string baked into the executable instead), so there's no consumer for a saved library file yet. This is open, designed-but-not-built work, not a small gap.
A related but different and also not started effort is exporting CX-compiled code as a consumable DLL for other languages (C#/Python/Rust via P/Invoke or an embedded-engine model) — most of the native↔VM marshaling machinery the register VM already needed turns out to cover much of what this needs too, but it hasn't been wired up as a public feature yet.
onerror and #error-checkCX has ONE error door. Install a handler with onerror, and every runtime error that would stop the program passes through it on the way out — the checked-mode guards, the per-verb refusals, and your own checkpoints, all with the same payload.
function myErrors.v(json e) {
printf("error %d in %s: %s\n", e["code"], e["function"], e["message"]);
}
onerror(&myErrors);
The handler takes one json parameter and returns nothing. What arrives is a subscriptable handle:
| Field | What |
|---|---|
e["code"] | the error number — your checkpoint (1–999) or a CX-E#### |
e["message"] | the full formatted message |
e["file"] | the source file, where the error site recorded one — native only |
e["line"] | the source line, likewise — native only |
e["function"] | the CX function the failure happened in |
That is the same payload OnEvent hands its handlers, which is the point: a field can be added later without breaking a signature you already wrote.
e["file"]ande["line"]arrive EMPTY on the register VM, and that is an accepted asymmetry rather than a bug on a list. The VM has reportedcx: error:with no position since long before the error door existed: it carries no line table at run time, so its check sites have no position to bake in. Closing the gap means giving the VM one — its own arc, with its own cost on every loaded program — and this door deliberately neither widened the gap nor paid for it.e["function"]is populated on both backends, because the function identity is already there for other reasons; it is the file and the line that are native's alone. Write handlers that read a missing position as missing, not as wrong.
The older two-parameter shape still works.function myErrors.v(n.i, msg.s)compiles and runs exactly as before and warns once (CX-W1017) that it has been superseded. It cannot carry the file, the line or the function, so every field worth having would have been another signature break — which is why the handle form exists.
A function of neither shape is refused where you install it, not where it runs:
function bad.i(n.i) { return n; }
onerror(&bad);
The argument must be &function. Anything else — a variable, a string, an expression — is CX-E1094:
x = 3;
onerror(x);
onerror() with no argument uninstalls. There is one handler at a time: a second onerror(&other) replaces the first rather than stacking.
CX writes a structured record for every failure with no registration at all — the code, the message, the source position and the function it happened in — to the same side channel onwatch reports to (§33). One json object per line:
{"kind":"error","code":5023,"message":"CX-E5023: nums: index 9 out of bounds (size 4)","file":"game.cx","line":41,"function":"takedamage"}
It writes nothing at all unless $CX_SIDE_CHANNEL names a destination, so a program's stdout and stderr are exactly what they have always been. Under the screen (forge screen) the session names one, so every program it runs reports its failures structurally without being changed.
#pragma onerrordefault off turns the record off. Registering your own handler does not: yours runs as well, and the pragma governs only the default.
#error-check <n> <text note> raises error n with that note. It is a directive, so the number and the range are checked while the program compiles.
function myErrors.v(n.i, msg.s) {
printf("caught %d\n", n);
}
onerror(&myErrors);
#error-check 7 the config file had no [server] section
The note reaches the handler prefixed with the source position, so a log line says where the checkpoint was without you writing the location twice.
Checkpoint numbers are 1 to 999. CX's own error codes are 1000 and up, which is what lets a handler select n on one argument and never confuse "my checkpoint 7" with "CX's bounds error 5023". A number outside the band is refused at compile time (CX-E5045), so a colliding checkpoint cannot ship.
When the message has to be built at run time, call the lowered form directly:
name = "server";
errorRaise(7, "the config file had no [" + name + "] section");
That is the only difference between the two: the directive takes a literal note and gets compile-time checking and a free source position; errorRaise takes any string.
| Source | Reaches the handler? | n is |
|---|---|---|
#error-check / errorRaise | yes | your number, 1–999 |
#pragma checks on guards — bounds, divide-by-zero, null pointer, resource cap, stack | yes | the CX-E#### number, e.g. 5023 |
Always-on per-verb refusals — cursor read off a walk (CX-E5041), access through a deleted container (CX-E5044) | yes | the CX-E#### number |
Compile-time diagnostics — CX-E0xxx, CX-E1xxx, CX-E2xxx | no | — |
| Refusals that deliberately do not stop the program — an XML write through a dead node handle reports itself and execution continues | no | — |
The compile-time boundary is not a gap. Those errors happen before the program runs, so there is no handler to call and never will be for that compile.
When the handler returns, the program stops with a non-zero exit — exactly as it would have without one. The handler's job is to see, log and clean up; it decides nothing about control flow, and the statement after a raise never runs.
A handler that raises while it is handling is not re-entered: CX says so and stops, rather than recursing until the stack ends.
Nothing. The door is only reached where the program was already about to print an error and exit. A program that never calls onerror compiles to byte-identical output on both backends, and nothing on any hot path knows the feature exists. The function in e["function"] costs nothing either: the compiler already knows which function it is emitting, so the name is a constant baked at the error site rather than something the running program keeps track of.
Everything on this page behaves identically compiled native or to the register VM.
onwatchA watch is a debugger you compile in. You name a variable, say when you want to hear about it, and CX reports every change that matters — with the old value, the new one, and the function the write happened in.
int hull = 100;
function takeDamage.v(int n) {
hull = hull - n;
}
onwatch(hull, onchange, "hull <= 70", &takeDamage);
takeDamage(10);
takeDamage(25);
Six arguments, and only the first is required:
| Position | What | Values |
|---|---|---|
| 1 | the variable | a name, never a string |
| 2 | the trigger | onchange (or 0), or a number of milliseconds |
| 3 | the condition | a string holding a CX expression, or 0 for "always" |
| 4 | the scope | 0 for every function, or &someFunction |
| 5 | the handler | omitted for the side channel alone, or &myHandler |
| 6 | the relay | 0 to report every trigger, or a count of triggers per report |
CX instruments the writes to that name, in that scope, and emits nothing anywhere else. A program with no onwatch in it compiles to byte-identical output — the feature leaves no trace at all.
That only works because the compiler can see which name you meant. A watch named by a string would have to be looked up at run time, which means a table check on every store in the program, watched or not. So the subject is an identifier:
int a = 0;
a = 1;
onwatch("a", onchange, 0, 0);
Watchable variables are int, float and string. A container is refused (CX-E1098): its writes do not go through a store of the name, so there is nothing at that spelling to instrument.
onchange reports every change that satisfies the condition. It is complete, and it pays at every write. With no condition on a hot variable that is exactly what you asked for: one line per change, however many that is. Give it a condition, a scope, or a millisecond trigger.
A number is milliseconds, and it rate-limits the reports:
int ticks = 0;
int i = 0;
onwatch(ticks, 500, 0, 0);
for (i = 1; i <= 100000; i = i + 1) { ticks = i; }
This is a sampler, not a tracer, and it is lossy by construction: writes inside the quiet window update the watch's memory of the value but are never reported, so a value that changes and changes back between samples is invisible.
The trigger limits reports, not instrumentation. Both triggers cost the same at the write — measured at about 1.2 ns per instrumented write, against a bare scalar store of about 1.3 ns. What a millisecond trigger buys is quiet: no line written, no handler called, no clock read except for a write that would have been reported. (An earlier build guarded each time-triggered write with "has the window elapsed?" and it cost twelve times more, because answering that means reading the clock and a clock read is ~18 ns. CX is single-threaded and has no ambient timer to sample from, so a time-based watch cannot be free per store — it can only be quiet.)
onwatch(v, onchange, 0, 0, 0, 1000) on a hot variable reports once every 1000 changes, and the report carries how many it stands for:
{"name":"ticks","site":"main","old":999,"new":1000,"seq":1,"ms":4,"count":1000,"trigger":0}
Every trigger is still counted — the relay throttles delivery, not the watch. So nothing is lost as a count, including the partial batch when the program ends: at exit CX delivers what is pending, with old set to null because there is no transition to report, only a value and the number of triggers behind it. That tail goes to the side channel only — your handler is not called, on either backend, because by then there is no running program left to call it into. (A millisecond trigger gets a tail too: its last window is delivered with the count of changes it stood for.)
What a relay does cost is the values in between: a relayed line carries the latest change plus a count, not a history of the ones it stood in for. That is the trade, and it is the whole trade.
It composes with the millisecond trigger rather than competing with it — the trigger throttles by time, the relay by volume, and a report waiting on either keeps accumulating its count instead of resetting.
Like the trigger, the relay must be written literally (CX-E1105).
The two real cost controls are still the condition and the scope.
The trigger must be written literally. The compiler picks the instrumentation from it, and it cannot pick from a value that will not exist until run time (CX-E1099).
You write it in quotes, and CX parses it at that line and compiles the result into each instrumented site. So it is an ordinary CX expression in every way that matters — it reads your variables, it costs what the expression costs, and a typo in it is a compile error on the line that wrote it (CX-E1104), never a watch that silently never fires:
int hull = 100;
function takeDamage.v(n.i) { hull = hull - n; }
onwatch(hull, onchange, "hull <= = 70", &takeDamage);
It reads the variable after the write, which is what a watch means.
Why quotes at all, when an expression needed no parsing? Because a condition is data about the program, and quoting it makes the whole of it legible as one unit — to a reader, to a tool, to a model. What quoting must never cost is the check, and here it costs nothing: the compiler does the same work it did when the condition was unquoted, at the same moment, with the same result. Writing the expression without quotes is refused (CX-E1103) rather than accepted quietly, so nothing silently changes meaning between the two spellings.
A condition is one expression, not a sequence of statements. Text that tries to be more than one is refused by the same rule.
&someFunction instruments the writes in that body and nowhere else. 0 covers every function. Naming one function is how a watch on a busy global stays cheap.
The record CX keeps is per (variable, site) — so watching one variable in two functions gives you two independent stories, each with its own memory of the last value and its own sequence numbers, rather than one interleaved one.
Never to stdout. A report on stdout would be indistinguishable from your program's own output. They go to the side channel: the file named by $CX_SIDE_CHANNEL, or a per-process file in the temp directory. One json object per line:
{"name":"hull","site":"takedamage","old":90,"new":65,"seq":1,"ms":12,"count":1,"trigger":0}
count is how many triggers this line stands for — always 1 unless a relay is set (see above).
When CX picks the file itself it says so once, on stderr — but only when stderr is a terminal. A report nobody can find would be the silent failure a watch exists to prevent; a line that varies per run (the name carries the process id) would break every captured comparison there is, which it did to this feature's own corpus fixture before the rule was added. A human gets the hint; a pipe does not, and anything reading a pipe can name the file itself.
Under the screen (forge screen), the session sets CX_SIDE_CHANNEL for the programs it runs, so a watched program's reports arrive beside the streams the screen already captured — no change to the program.
string label = "";
function onLabel.v(json e) {
println(e["name"] + " in " + e["site"] + ": " + str(e["old"]) + " -> " + str(e["new"]));
}
onwatch(label, onchange, 0, 0, &onLabel);
label = "ready";
The handler receives a subscriptable handle — e["name"], e["site"], e["old"], e["new"], e["seq"], e["ms"], e["count"], e["trigger"] — the same payload shape OnEvent hands its handlers, so fields can be added later without breaking your signature. A handler that writes the variable it watches is not re-entered.
If nothing in scope ever writes the variable, CX warns (CX-W1016) rather than compiling a watch that would sit silent forever. That silence is the exact failure a watch exists to prevent.
Everything on this page behaves identically compiled native or to the register VM, and the reports are byte-for-byte the same.
#pragma decimals 2
struct Point { x.f; y.f; }
function distance.f(a.Point, b.Point) {
dx.f = b.x - a.x;
dy.f = b.y - a.y;
return sqrt(dx*dx + dy*dy);
}
function describe.s(p.Point) {
return sprintf("(%.1f, %.1f)", p.x, p.y);
}
array path.Point[4];
path[0].x = 0.0; path[0].y = 0.0;
path[1].x = 3.0; path[1].y = 4.0;
path[2].x = 6.0; path[2].y = 1.0;
path[3].x = 10.0; path[3].y = 10.0;
total.f = 0.0;
for (i = 0; i < 3; i = i + 1) {
d = distance(path[i], path[i+1]);
printf(" %s -> %s = %.2f\n", describe(path[i]), describe(path[i+1]), d);
total += d;
}
printf("\nTotal path length: %.2f\n", total);
json doc;
jsonAddNum(doc, "distance", total);
jsonAddNum(doc, "segments", 3);
out = jsonExport(doc);
printf("JSON: %s\n", out);
jsonfree(doc);
printf("SHA-256: %s\n", sha256(out));
CX+AI Language Reference — v3.137.1 — July 2026. Compiled directly against the live DOCS/ source tree (REFERENCE.md, AI.md, UNIFIED_REGISTER_ISA.md, CLAUDE.md, GPU_NAMING.md, CGI2D_SALVAGE.md, TODO.md, BUILD.md) rather than reconstructed from summary. A handful of items (exact networking builtin names, the full current pointer/peek/poke surface, and the array-param-byref detail in §7) are flagged in place as worth a direct source check if they're load-bearing for your use — everything else here was cross-confirmed across at least two independent live documents.