CX+AI

CX+AI Builtins Reference

Every builtin, drawn from the source

AI 4 families · 83 builtins

AI bytecode & codeswap 28 builtins

clonemarker(src, dst)

copy one marker region's code into another

srcthe marker id to copy from
dstthe marker id to copy to

returns non-zero on success

cloneMarker(1, 2);
code_size()

report how many bytecode scratch slots are addressable

Native AOT has no live bytecode -- the program IS native C -- so this family works over a growable scratch table, one entry per PC slot, which the register VM path delegates to as well so both backends agree. Writes grow the table on demand (capped by `#pragma MaxCodeScratch`); READS NEVER GROW IT. Seeded lazily, so a program that only ever reads still sees a non-zero window.

returns the live capacity of the scratch table (always > 0)

n = code_size();
delmarker(marker)

drop a marker region

Marks the id free. Answers whether it actually removed anything, so a caller can tell a delete from a no-op -- it returned nothing until 2026-08-25.

markerthe marker id

returns 1 if a marker was dropped, 0 if there was nothing at that id

delMarker(1);
generatecode(task, marker)

ask the AI to write the body for a marker region

Runs the prompt through the same query path as ai_call, so the cache, provider and fallback chain all apply.

taska description of what the code should do
markerthe marker id to fill

returns non-zero on success

generateCode("clamp hp to 0..100", 1);
getcode()

read back the code most recently installed by setCode or generateCode

The exact inverse of setCode, and the reason generateCode is usable at all: it lets you LOOK at the bytecode the model wrote before replaceCode commits it to a marker. `generateCode(task, 1); println(getCode()); replaceCode(1);` is the whole review loop. It reads the pending buffer, not a marker -- the call takes no arguments, so there is no region for it to name.

returns the pending code text, or an empty string if nothing is pending

s.s = getCode();
hasmarker(marker)

test whether a marker id names a live region

markerthe marker id

returns non-zero if the marker exists

if (hasMarker(1)) { ... }
markercount()

count the live marker regions

Walks the marker table and answers how many ids currently hold installed code. Real on BOTH backends since 2026-08-25: it used to bind only on the register VM, to a stub that answered 0 however many markers were installed, while the native side refused it outright.

returns the number of markers with code installed; 0 if none

n = markerCount();
peek_code(addr)

read the opcode field of a bytecode scratch slot

Native AOT has no live bytecode -- the program IS native C -- so this family works over a growable scratch table, one entry per PC slot, which the register VM path delegates to as well so both backends agree. Writes grow the table on demand (capped by `#pragma MaxCodeScratch`); READS NEVER GROW IT.

addrthe PC slot to read

returns the slot's opcode field, or the out-of-range sentinel -1 beyond the allocated table

peek_code(0);
peek_flags(addr)

read the flags field of a bytecode scratch slot

Native AOT has no live bytecode -- the program IS native C -- so this family works over a growable scratch table, one entry per PC slot, which the register VM path delegates to as well so both backends agree. Writes grow the table on demand (capped by `#pragma MaxCodeScratch`); READS NEVER GROW IT.

addrthe PC slot to read

returns the slot's flags field, or the out-of-range sentinel -1 beyond the allocated table

peek_flags(0);
peek_funcid(addr)

read the function-id field of a bytecode scratch slot

Native AOT has no live bytecode -- the program IS native C -- so this family works over a growable scratch table, one entry per PC slot, which the register VM path delegates to as well so both backends agree. Writes grow the table on demand (capped by `#pragma MaxCodeScratch`); READS NEVER GROW IT.

addrthe PC slot to read

returns the slot's function-id field, or the out-of-range sentinel -1 beyond the allocated table

peek_funcid(0);
peek_i(addr)

read the 64-bit `i` operand of a bytecode scratch slot

Native AOT has no live bytecode -- the program IS native C -- so this family works over a growable scratch table, one entry per PC slot, which the register VM path delegates to as well so both backends agree. Writes grow the table on demand (capped by `#pragma MaxCodeScratch`); READS NEVER GROW IT.

addrthe PC slot to read

returns the slot's 64-bit `i` operand, or the out-of-range sentinel 0 beyond the allocated table

peek_i(0);
peek_j(addr)

read the `j` operand of a bytecode scratch slot

Native AOT has no live bytecode -- the program IS native C -- so this family works over a growable scratch table, one entry per PC slot, which the register VM path delegates to as well so both backends agree. Writes grow the table on demand (capped by `#pragma MaxCodeScratch`); READS NEVER GROW IT.

addrthe PC slot to read

returns the slot's `j` operand, or the out-of-range sentinel 0 beyond the allocated table

peek_j(0);
peek_n(addr)

read the `n` operand of a bytecode scratch slot

Native AOT has no live bytecode -- the program IS native C -- so this family works over a growable scratch table, one entry per PC slot, which the register VM path delegates to as well so both backends agree. Writes grow the table on demand (capped by `#pragma MaxCodeScratch`); READS NEVER GROW IT.

addrthe PC slot to read

returns the slot's `n` operand, or the out-of-range sentinel 0 beyond the allocated table

peek_n(0);
poke_code(addr, value)

write the opcode field of a bytecode scratch slot

Native AOT has no live bytecode -- the program IS native C -- so this family works over a growable scratch table, one entry per PC slot, which the register VM path delegates to as well so both backends agree. Writes grow the table on demand (capped by `#pragma MaxCodeScratch`); READS NEVER GROW IT. A negative address, or one past the configured cap, is not written.

addrthe PC slot to write
valuethe value to store

returns void

poke_code(0, 7);
poke_flags(addr, value)

write the flags field of a bytecode scratch slot

Native AOT has no live bytecode -- the program IS native C -- so this family works over a growable scratch table, one entry per PC slot, which the register VM path delegates to as well so both backends agree. Writes grow the table on demand (capped by `#pragma MaxCodeScratch`); READS NEVER GROW IT. A negative address, or one past the configured cap, is not written.

addrthe PC slot to write
valuethe value to store

returns void

poke_flags(0, 7);
poke_funcid(addr, value)

write the function-id field of a bytecode scratch slot

Native AOT has no live bytecode -- the program IS native C -- so this family works over a growable scratch table, one entry per PC slot, which the register VM path delegates to as well so both backends agree. Writes grow the table on demand (capped by `#pragma MaxCodeScratch`); READS NEVER GROW IT. A negative address, or one past the configured cap, is not written.

addrthe PC slot to write
valuethe value to store

returns void

poke_funcid(0, 7);
poke_i(addr, value)

write the 64-bit `i` operand of a bytecode scratch slot

Native AOT has no live bytecode -- the program IS native C -- so this family works over a growable scratch table, one entry per PC slot, which the register VM path delegates to as well so both backends agree. Writes grow the table on demand (capped by `#pragma MaxCodeScratch`); READS NEVER GROW IT. A negative address, or one past the configured cap, is not written.

addrthe PC slot to write
valuethe value to store

returns void

poke_i(0, 7);
poke_j(addr, value)

write the `j` operand of a bytecode scratch slot

Native AOT has no live bytecode -- the program IS native C -- so this family works over a growable scratch table, one entry per PC slot, which the register VM path delegates to as well so both backends agree. Writes grow the table on demand (capped by `#pragma MaxCodeScratch`); READS NEVER GROW IT. A negative address, or one past the configured cap, is not written.

addrthe PC slot to write
valuethe value to store

returns void

poke_j(0, 7);
poke_n(addr, value)

write the `n` operand of a bytecode scratch slot

Native AOT has no live bytecode -- the program IS native C -- so this family works over a growable scratch table, one entry per PC slot, which the register VM path delegates to as well so both backends agree. Writes grow the table on demand (capped by `#pragma MaxCodeScratch`); READS NEVER GROW IT. A negative address, or one past the configured cap, is not written.

addrthe PC slot to write
valuethe value to store

returns void

poke_n(0, 7);
replacecode(marker)

swap a marker region's body for the code most recently installed

markerthe marker id

returns non-zero on success

replaceCode(1);
rt_aifunc_count()

count the registered run-time AI functions

The first half of the enumeration pair: it bounds the index that rt_aifunc_name(i) takes, so valid positions are 0 to rt_aifunc_count() - 1. The verbs themselves take a NAME, not a position -- read the name with rt_aifunc_name and act on that.

returns how many run-time AI functions are registered

for (i = 0; i < rt_aifunc_count(); i = i + 1) { println(rt_aifunc_name(i)); }
rt_aifunc_name(index)

the name of the i-th registered run-time AI function

The second half of the enumeration pair, and the ONLY place an index still means anything in this family: it converts a position into the identity rt_runAiFunc and rt_destroyAiFunc take. Out of range answers the empty string rather than failing, because the loop that reaches it has already checked its own bound -- and "" is not a legal function name, so it stays distinguishable from every real answer.

indexa position in 0 to rt_aifunc_count() - 1

returns the function's name, or an empty string if the index is out of range

s = rt_aifunc_name(0);
rt_callaifunc()

run the FIRST registered run-time AI function

Takes no arguments -- that is fixed at its binding -- so "the first registered" is the only thing it can mean. Prefer rt_runAiFunc(name), which says which function it means; this exists because the name is published.

returns the first function's result, or -1 if the registry is empty

v = rt_callAiFunc();
rt_createaifunc(name, prompt)

register an AI-authored function under a name at run time

namethe function name
promptthe task description the AI is asked to implement

returns 0 on error, 1 if the function was newly registered, 2 if it replaced one of the same name -- NOT a handle (cxtest 152 asserts rc == 1)

rt_createAiFunc("clamp", "clamp to 0..100");
rt_destroyaifunc(name)

drop a registered run-time AI function by name

The registry is addressed by NAME, like the rest of the AI family (ai_call_func, ai_dump_asm) -- his ruling of 2026-08-24. rt_createAiFunc still returns 0/1/2 for error, new registration, or replaced, and is still not a handle. To ENUMERATE, use the pair rt_aifunc_count() and rt_aifunc_name(i): ask how many, ask what the i-th is called, then act on that name. An index is a position you read a name FROM, never a target you act ON -- which is also why it is safe that destroying compacts the table. It ANSWERS whether anything was dropped, and still records the detail in ai_last_error: 1/0 says WHETHER, the error text says WHICH name and how many were registered. Neither replaces the other -- a caller should not have to interrogate a separate channel to learn that its call did nothing.

namethe function's name; nothing is dropped if no function has it

returns 1 if a function of that name was dropped, 0 if none was registered

if (rt_destroyAiFunc("triple") == 0) { println("nothing to drop"); }
rt_disasm()

print the AI subsystem's current disassembly to stdout

The shared implementation the register VM's own rt_disasm uses, so both backends print the same thing.

returns void

rt_disasm();
rt_runaifunc(name)

run a registered run-time AI function by name

The registry is addressed by NAME, like the rest of the AI family (ai_call_func, ai_dump_asm) -- his ruling of 2026-08-24. rt_createAiFunc still returns 0/1/2 for error, new registration, or replaced, and is still not a handle. To ENUMERATE, use the pair rt_aifunc_count() and rt_aifunc_name(i): ask how many, ask what the i-th is called, then act on that name. An index is a position you read a name FROM, never a target you act ON -- which is also why it is safe that destroying compacts the table. The function runs with argument 0. To pass an argument, use ai_call_func(name, arg) instead.

namethe function's name, as given to rt_createAiFunc or ai_parse_bytecode

returns the function's result, or -1 if no function of that name is registered (-1 rather than 0, because 0 is a legitimate result)

v = rt_runAiFunc("triple");
setcode(src)

install CX source text as the body of a codeswap marker region

srcthe replacement source text

returns void

setCode(src);

rules & events 28 builtins

raise(type, source)

fire an event of the given type from CX code

Calls cx_event_raise: uses `source` as the event context/payload passed to OnEvent handlers (falls back to the shared event ctx when source is 0), then runs every handler registered for that type. It also sets a legacy ctx["source"] field, which is deprecated and warns once.

typeevent type id to raise
sourcebound-object / json entity handle passed to handlers as the payload (0 = use the shared event context)

returns void

raise(50, e);
airule(entity, schedule, trigger, prompt)

ask the AI to author a rule and register it on an entity, in one call

The automation engine, and the reason CX exists: an entity is a json document, a rule is a string, and behaviour is DATA you can load, edit, save and let an AI write. Registered behaviour runs on a SCHEDULE and behind a TRIGGER, so nothing polls. THE ONE-LINER THE PRODUCT IS ABOUT: an LLM query and a rule registration fused, so a sentence becomes autonomously running behaviour with no recompile. What the AI returns is a rule STRING -- data you can read, edit and save, not opaque code. ANSWERS AT THE CALL SITE -- 1 registered, 0 refused (his ruling 2026-08-25: a door handed rule TEXT can be handed something malformed at RUNTIME, so its caller must be able to learn nothing was registered; a door handed a compiled FUNCTION cannot, which is why funcAdd stays silent). Every refusal has already printed its reason, so the number is an answer and not a diagnosis. aiRule PROPAGATES that answer rather than reporting success for producing text: the authoring loop may yield no valid rule, and the rule it does yield may still fail to compile.

entitythe entity's json handle
schedulehow often it is due
triggera condition rule
promptwhat the behaviour should do, in plain language

returns void

aiRule(e, 60, "1", "restock when stock falls below the reorder point");
arm(entity, preload)

bake an entity's persist lanes by running a preload rule once

The preload half of the rules ABI: it sizes the entity's typed lanes to the compiler's persist counts, runs `preload` ONCE so its writes land in those lanes, and stamps the source document's revision. Every later ruleExec on that entity then READS the baked lanes instead of recomputing. A fire whose source document has changed re-bakes at the fire boundary. Needs `#pragma rules c`.

entitythe entity's json handle
preloadthe preload rule source

returns 1 when armed; 0 on failure -- and the entity stays UN-armed, so a later persist read faults loudly rather than reading a half-baked table

arm(e, "persist float th; th = 0.8;");
automationrun(entity, quitkey)

tick flat out until a named key on an entity goes non-zero

The automation engine, and the reason CX exists: an entity is a json document, a rule is a string, and behaviour is DATA you can load, edit, save and let an AI write. Registered behaviour runs on a SCHEDULE and behind a TRIGGER, so nothing polls. The console equivalent of riding a graphics frame loop: hand over control in one line instead of writing the while. A rule flips the key when the work is done.

entitythe entity's json handle
quitkeythe entity key a rule sets to stop the loop

returns void

automationRun(e, "done");
automationtick()

advance the clock and fire every rule that is due and triggered

The automation engine, and the reason CX exists: an entity is a json document, a rule is a string, and behaviour is DATA you can load, edit, save and let an AI write. Registered behaviour runs on a SCHEDULE and behind a TRIGGER, so nothing polls. The per-frame call. A graphics program gets this driven for it once the first rule is added.

returns void

automationTick();
eventctx()

the json object the dispatcher fills with the current event

The event registry IS json, which is what makes it two-way: load a binding set from a file, edit a binding while the program runs, save it back, or let an AI author it. A gate rule is evaluated against THIS -- `event["key"]`, `event["mx"]` and so on.

returns a json handle to the current event context

e = eventCtx();
eventfire(type)

dispatch an event through the registry

The event registry IS json, which is what makes it two-way: load a binding set from a file, edit a binding while the program runs, save it back, or let an AI author it. Every entry registered for the type is considered: its gate rule is evaluated against the event context, and the handler runs only if the gate passes.

typethe event type code

returns the result of the dispatch

eventFire(1);
eventsettable(tbl)

replace the whole event table with a caller-parsed json array

The two-way, AI-authorable load: a program (or an AI) builds the handler table as json and installs it in one call, instead of accumulating it through OnEvent. HANDLER NAMES ARE CASE-INSENSITIVE, like every CX identifier: an entry's `"fn"` refers to a handler registered from a CX function name, and the scanner folds that name, so the table's string is folded to match. `"OnHit"`, `"onhit"` and `"ONHIT"` all name the same handler -- two handlers differing only by case cannot exist, so nothing legitimate is lost. EVERY `"fn"` IS RESOLVED HERE, AT LOAD. A name that matches no registered handler is a loud CX-E5033 and STOPS the run. It is checked at load rather than at first fire because an unresolvable handler used to fail by simply never running -- the quietest failure mode there is, and one that let a run start with a dead handler and look healthy (FP4). Entries with only a `"do"` rule carry no name and are not checked. The registry keeps `tbl` for the process lifetime and increfs it, so a table built inside a function survives that function's scope exit.

tbljson array indexed by event type; each element an array of entries ({"gate": rule, "fn": handler-name} or {"gate": rule, "do": rule}).
eventSetTable(jsonLoad("events.json"));
eventtable()

the json array holding every event binding

The event registry IS json, which is what makes it two-way: load a binding set from a file, edit a binding while the program runs, save it back, or let an AI author it. Indexed by event type (1 keyboard, 2 mouse, 3 timer, 4 window; 4..255 are free for your own). Created on first use.

returns a json handle to the event table

t = eventTable();
funcadd(entity, schedule, trigger, fn)

register a compiled CX function on an entity, on the same schedule and trigger

The automation engine, and the reason CX exists: an entity is a json document, a rule is a string, and behaviour is DATA you can load, edit, save and let an AI write. Registered behaviour runs on a SCHEDULE and behind a TRIGGER, so nothing polls. The fast, heavy path beside the data path -- the function is `function f.v(json e)` and receives the entity. **NATIVE ONLY**: native carries a function value as a C address, the register VM as a bytecode index, so on the VM this hard-errors rather than calling the wrong thing. Windowed games run native.

entitythe entity's json handle
schedulehow often it is due
triggera condition rule
fna `function f.v(json e)`

returns void

funcAdd(e, 1, "1", &onTick);
oneventrule(type, gate, rule)

bind a data-only rule handler to an event type

Callable, but its argument list is not derivable from its binding: it is bound only by the register VM's name table, where the C signature is the generic (vm, N) wrapper form. Named here so an absence is never mistaken for non-existence.

The event registry IS json, which is what makes it two-way: load a binding set from a file, edit a binding while the program runs, save it back, or let an AI author it. Both the gate and the handler are RULES -- pure data, so the binding is json-native, loadable and AI-authorable.

typethe event type code
gatea condition rule; "1" or empty means always
rulethe handler rule to run when the gate passes

returns void

onEventRule(1, "event[\"key\"] == 27", "quit = 1");
ruleadd(entity, schedule, trigger, rule)

register a rule on an entity, to run on a schedule behind a trigger

The automation engine, and the reason CX exists: an entity is a json document, a rule is a string, and behaviour is DATA you can load, edit, save and let an AI write. Registered behaviour runs on a SCHEDULE and behind a TRIGGER, so nothing polls. ANSWERS AT THE CALL SITE -- 1 registered, 0 refused (his ruling 2026-08-25: a door handed rule TEXT can be handed something malformed at RUNTIME, so its caller must be able to learn nothing was registered; a door handed a compiled FUNCTION cannot, which is why funcAdd stays silent). Every refusal has already printed its reason, so the number is an answer and not a diagnosis.

entitythe entity's json handle
schedulehow often it is due
triggera condition rule; the rule runs only when this passes
rulethe rule source text to run

returns void

ruleAdd(e, 30, "hp < 20", "hp = hp + 5");
ruleauthmock(text)

queue a canned authoring response instead of calling the AI

The AI rule-authoring loop. While the queue is non-empty, ruleAuthor consumes it INSTEAD of contacting a provider -- one push per attempt, FIFO. What makes an authoring test deterministic, offline and free, and equally the seam for feeding rules from a source that is not an LLM.

textthe response to hand back for the next attempt

returns void

ruleAuthMock("{ speed = speed / 2; }");
ruleauthor(task)

ask the AI for one rule, validate that it compiles, and retry on a decline

The AI rule-authoring loop. The returned text is a `{ ... }` body over `json e`. A reply that does not compile in the rules subset is DECLINED and retried with the compile error appended, up to a bounded number of extra attempts. Every attempt and decline is recorded for ruleAuthStats.

taskthe plain-language request

returns the validated rule text; the EMPTY string on final failure -- loud, never a silent no-rule

body.s = ruleAuthor("halve speed below 20 hp");
ruleauthreset()

clear the authoring feed, drain the mock queue and forget the fire warnings

The AI rule-authoring loop. Test isolation, or the start of a fresh authoring session.

returns void

ruleAuthReset();
ruleauthstats()

read the authoring loop's evidence feed as a json document

The AI rule-authoring loop. Returns attempts, declines, whether the final attempt succeeded, the last decline's class and reason, and a record per attempt. The class is a stable string -- `pointer`, `call-denied`, `arity`, `struct`, `nonscalar`, `foreach-colon`, `syntax`, `empty`, `other` -- so a test can assert WHY the model was refused, not merely that it was.

returns a NEW json handle you own

s = ruleAuthStats();
rulecachecount()

how many distinct rule strings are currently compiled and cached

The cache-effectiveness instrument: compiling N distinct rules M times should cache N. Use it to prove a hot loop is not recompiling.

returns the number of cached rule programs

n = ruleCacheCount();
ruleentity(name, obj)

LINK A NAME TO AN OBJECT, so a rule reaches the world without

being handed it (RULEX-325 Part E; his catch: "link an entity with a variable/global"). The entity NAME is the join key the rules already had -- `#pragma rules entity ship` names it, a rule body spells its parameter with it, and a stored key carries it. This says which OBJECT that name currently is. TWO SPELLINGS, ONE MECHANISM (his ruling 2026-09-06). `ruleEntity(obj)` binds the name this program DECLARED; `ruleEntity("name", obj)` binds any name. The one-arg form is a COMPILE-TIME REWRITE -- the compiler supplies the declared name as a literal, exactly as rulec_fixup_raise already turns a rule's `raise(type)` into `raise(type, <entity>)` -- so there is one C function here and no runtime question about which name was meant. **A one-arg call in a program that declared no entity is a NAMED REFUSAL at compile time, never a silent bind to the default `e`** (his ruling, FP4). BINDING AN ALREADY-BOUND NAME REPLACES IT. It does not stack, there is no scope and there is no unbind -- a name is whatever it was last bound to. Said here because it is the only place a reader will look, and because the opposite (a stack) is what a reader coming from a scoping language will assume.

namethe entity name. Case-insensitive, matching the rest of the rule system; empty is refused loudly rather than bound.
objthe object -- a json handle, the shape a rule body reads.

returns 1 when bound, 0 when refused (empty name).

ruleEntity(ship);              // the declared name
ruleEntity("world2", other);   // any name
ruleentityof(name)

the object currently linked to a name, or -1

-1 RATHER THAN 0, and the difference is load-bearing: 0 is a legal json handle, so a caller testing `if (ruleEntityOf(n))` on a 0-handle would read a live entity as a missing link. The vtable checks `< 0`.

namethe entity name (case-insensitive).

returns the linked handle, or -1 when nothing is bound to that name.

e.i = ruleEntityOf("ship"); if (e < 0) { ...no link... }
ruleexec(entity, rule)

run a rule against an entity and return its value

The explicit one-shot path, beside the registered one. **COMPILE-CACHED**: the same rule string compiles ONCE, content-keyed for the life of the process, and runs cached after that -- so calling it in a loop does not recompile. Member writes to `e` PERSIST on the bound document.

entitythe entity's json handle
rulethe rule source text

returns the rule's `return` value as a float; a rule returning a string reads as 0

v = ruleExec(e, "return hp * 2;");
rulefire(entity, entry)

run one rule entry, using its compiled form if it has been promoted

The dispatcher that honours promotion. Given a json rule entry `{ name, body, promoted }`: if it is marked promoted AND its name is registered, the COMPILED function runs with no recompile of the text; otherwise the body runs through the cached text path. A promoted mark with no registered function -- promote ran, the rebuild did not -- falls back to text AND warns LOUDLY once per rule name: graceful for the player, visible for the developer.

entitythe entity's json handle
entrya json rule entry { name, body, promoted }

returns the rule's value as a float, on both paths

v = ruleFire(e, entry);
rulepromote(rule | rules, path)

promote a rule to a more durable form -- the store, or generated source

TWO FORMS UNDER ONE NAME, picked by how many arguments you give it, because both do the same thing at different distances: they graduate a rule out of the moment it was written in. **`rulePromote(rule)`** appends the rule to the program's rule store AND makes it live in the same call -- the store is durable, so a rule learned in one run is there for the next. It needs `#pragma rules persistence yes`, and the compiler refuses the call without it rather than let a promote go nowhere. A rule that does NOT compile is written to the store anyway, marked `failed`, with the frontend's own sentence beside it: the store records what was TRIED. The return value answers *did this rule work*, never *did anything happen*. **`rulePromote(rules, path)`** emits a compilable `.cxi` from a json array of authored rules -- one `function <name>.f(json e) <body>` per entry plus a registration block. **HOST ONLY, never rule-callable**: a rule that could promote itself would be capability escalation, so this name must never appear on a rule whitelist. ALL-OR-NOTHING -- every body is compile-checked first, a file carrying no generated marker is never overwritten, and if any rule fails nothing is written.

rule | rulesthe rule TEXT to store, or the json array of entries to emit
path(two-argument form only) the .cxi to write

returns 1 on success, 0 on failure -- and for the one-argument form a failed rule is still recorded

rulePromote("{ e[\"hull\"] = 100; }");
rulestatus()

what the last ruleExec() actually did, which its return value

cannot tell you. ruleExec returns the RULE'S OWN value (`{ return hp > 0; }` is how a gate is written), so a rule that applies, a rule that changes nothing, a rule that is refused and a rule that does not compile all answer 0 -- the refusals print, loudly, but a program cannot branch on a printout. This answers the other question, and it is written once per ruleExec. 1 APPLIED the rule ran and WROTE the json it was given. Exactly that: the document's mutation counter moved. Writing the same value again still counts as a write, and a rule that changes something other than `e` -- a persist lane, a global -- does not. 2 NOCHANGE the rule ran and wrote nothing to that document. 3 REFUSED CX declined the rule's FORM or the capability: a blank rule, the retired non-brace DSL (CX-E5027), or a binary built with no `#pragma rules c`. 4 DIDNOTPARSE the C frontend refused the BODY. A DENIED CALL (`system()` in a rule) is here too, with syntax errors: separating them is what ruleAuthor's own stats already do, and a second classifier would be a second answer to one question. 0 NOTRUN nothing has run yet. THE AMBIENT READER IS DELIBERATE, and it is the door the filed `onerror()` arc consumes: a handler reached from somewhere else in the program needs the fact without having been at the call site. Nothing wires it there yet -- that design is still open -- but when it lands, its input is already standing.

returns one of the five values above.

ruleExec(ship, rule); if (ruleStatus() == 4) { println("bad rule"); }
rulestore()

the program's rule store, as data you can walk

A json ARRAY of entries in the shape ruleFire already reads -- `{ name, body, promoted }` -- plus `phrase`, `status` ("ok" or "failed") and, on a failed one, the `error` the frontend gave. The store is TEXT ON DISK and is meant to be read, edited and curated by hand; this is the same array the running program holds, so an entry promoted a moment ago is already in it. FAILED ENTRIES ARE IN HERE TOO, deliberately: the store records what was TRIED, not only what worked. They are reported once at load and are NOT applied, so walking this array is how a program shows its author what its AI reached for and what the sandbox refused.

returns the entry array's json handle, or 0 if the program declared no store (`#pragma rules persistence yes`).

json s = ruleStore(); foreach s { ... }
rulestorecount()

how many entries the rule store holds

Loaded entries plus anything promoted so far this run, failed entries included. 0 when no store was declared, and 0 on a first run that has learned nothing yet -- those are the same number and deliberately so: neither is an error, and a program that wants to tell them apart asks ruleStore() for 0.

returns the entry count.

printf("%d rules known\n", ruleStoreCount());
rulestoredoc()

the whole store as one document

A store is `{ "options": { ... }, "rules": [ ... ] }`, and the vtable takes that DOCUMENT -- it reads both halves from it. `ruleStore()` answers only the rules array and `ruleStoreOptions()` only the options, so without this a program that LOADS a store file could not drive the vtable at all.

returns the document handle, or 0 when no store is open.

ran.i = __vtRun(wish, ruleStoreDoc(), "ship");
rulestoreoptions()

the store's `options` object

A store is `{ "options": { ... }, "rules": [ ... ] }` (his ruling 2026-09-06). The options hold the matcher's settings -- `match`, `trigram_min` and the length rule -- so they travel with the rules they govern rather than with whichever program loads them, and so the BAKE can read them at build time.

returns the options object handle, or 0 when no store is open.

json o = ruleStoreOptions();
rulestorereload()

read the store file again, now

The store is read ONCE, in main's prologue. A rules file edited by anything else while the program runs -- a person, another process, a deploy -- is invisible until this is called. **Explicit, never watched** (his ruling 2026-09-06: a file watch is a later item): the program says when to look. ⚠ A FAILED RELOAD KEEPS THE OLD TABLE RUNNING and names the reason. This is the whole difference between it and opening twice: OPEN binds an empty store when the file is broken, which is right at startup and catastrophic mid-run, because a typo would silently disarm every rule a running program has. The worst a bad file can cost you here is the edit.

returns 1 when the store was replaced, 0 when it was not (and the old one is still live).

if (!ruleStoreReload()) { ...the previous rules are still running... }

AI core 23 builtins

ai_set_options(cfg)

configure AI from ONE json document, per model

Replaces a scatter of individual setters with a document describing every model the program may reach. `defaults` holds what every section inherits; `models` is an ARRAY of sections, and **its order is the fallback chain** -- an array rather than an object because sequence is load-bearing and json promises no member order. A section carries its own `host` and `port`, so **an endpoint belongs to the model that owns it**. That is the point of the shape rather than a convenience: the older `ai_set_url` applied to every provider the chain walked, and per-section options make that unexpressible. Keys are checked when the document is read, not when a request is sent. A key misspelled inside a section is REFUSED and named; a key that is documented but not yet built is refused as "not yet wired", which is a different sentence on purpose -- the reader of the first is hunting their own typo, the reader of the second is waiting for a feature. Keys in `defaults` are PERMISSIVE: they apply where they mean something and are skipped where they do not, because a mixed local-and-hosted document would otherwise refuse itself.

cfga json document (an object with optional `defaults` and `models`)

returns 1 when the document was accepted and is now in force; 0 when it was refused, with ai_get_error() naming the key and the reason. Nothing is applied on a refusal -- the previous configuration stands, so a bad document cannot leave a program half-configured.

if (ai_set_options(cfg) == 0) { println(ai_get_error()); }
ai_async_ready(id)

ask whether a background job has finished

WINDOWS ONLY. On Linux and macOS the async family is not implemented: start and wait answer -1, pending answers 0, and result answers the unknown-job marker. Use the synchronous query builtins there.

idthe job id from ai_call_async

returns 0 while pending, 1 when done, -1 for an unknown id

if (ai_async_ready(id) == 1) { ... }
ai_async_result(id)

collect a background job's reply, waiting if necessary

BLOCKS until the job finishes if it has not already, then RETIRES the job -- the id is spent and its slot is reusable, so a second call with the same id gets the unknown-job marker. An unknown id answers the string `ERROR: unknown job id N` rather than an empty one, so a caller can test for `ERROR` without having to distinguish failure from a genuinely empty reply. WINDOWS ONLY. On Linux and macOS the async family is not implemented: start and wait answer -1, pending answers 0, and result answers the unknown-job marker. Use the synchronous query builtins there.

idthe job id from ai_call_async

returns the model's reply, or an `ERROR: unknown job id N` marker

r.s = ai_async_result(id);
ai_cache_clear()

empty the response cache and reset its counters

Releases every cached value's reference; the hit and miss counters go back to zero.

returns void

ai_cache_clear();
ai_cache_get(key)

read a cached reply by key

Counts a hit or a miss either way, so the counters reflect real lookups.

keythe cache key to look up

returns the cached reply, or an EMPTY STRING on a miss -- a stored empty string is therefore indistinguishable from absence

v.s = ai_cache_get("q");
ai_cache_hits()

count cache lookups that found an entry

Reset by ai_cache_clear. Note that ai_cache_get performs a lookup, so it moves this counter.

returns the hit count since start or since the last clear

h = ai_cache_hits();
ai_cache_miss()

count cache lookups that found nothing

Reset by ai_cache_clear. Note that ai_cache_get performs a lookup, so it moves this counter.

returns the miss count since start or since the last clear

m = ai_cache_miss();
ai_cache_put(key, value, ttl)

store a reply in the response cache under a key

The cache is a PROCESS-LIFETIME owner and takes its own reference on the value, so the entry stays valid after the caller's string goes out of scope. Re-putting an existing key replaces the value. THE TTL ARGUMENT IS ACCEPTED AND IGNORED -- the cache has no clock and entries never expire.

keycache key (typically the prompt)
valuethe reply text to store
ttlaccepted for API compatibility and ignored -- there is no expiry

returns void

ai_cache_put("q", "a", 60);
ai_cache_size()

count the entries currently in the response cache

returns the number of cached entries

n = ai_cache_size();
ai_cache_stats()

read the cache counters as one formatted line

returns a string of the form `entries=N hits=N misses=N`

print(ai_cache_stats());
ai_call(prompt)

send a prompt to the configured LLM and return its reply

Cache lookup (when enabled), provider auto-pick if none was set, the request, then the fallback chain if it failed. A successful reply is cached under the prompt text. On any failure the answer is an EMPTY STRING and the reason is readable from ai_get_error -- so test the result, do not assume it.

promptthe prompt text

returns the model's reply, or an empty string on failure

r.s = ai_call("...");
ai_call_async(prompt)

start a background LLM request and return a job id

WINDOWS ONLY. On Linux and macOS the async family is not implemented: start and wait answer -1, pending answers 0, and result answers the unknown-job marker. Use the synchronous query builtins there. Fails FAST with -1 when no API key is available, rather than starting a worker that cannot succeed. At most 32 jobs may be live at once.

promptthe prompt text

returns a positive job id; -1 with no key, no free slot, or on a non-Windows build

id = ai_call_async("Summarise this.");
ai_call_func(name, arg)

run an AI-authored function on the native AI mini-VM

The function must have been created by ai_parse_bytecode (or the codeswap path) under this name.

namethe AI function's name
argone integer argument

returns the function's integer result; 0 if the name cannot be read

r = ai_call_func("clamp", 130);
ai_dump_asm(name)

disassemble a named AI function back to readable text

The counterpart to ai_parse_bytecode: what the AI wrote, as the runtime holds it.

namethe AI function's name

returns the disassembly text; an empty string if the name cannot be read

print(ai_dump_asm("clamp"));
ai_get_error()

read the reason the last AI operation failed

Set by ai_init and by the query family; an empty string means the last operation reported no error.

returns the error text, or an empty string

print(ai_get_error());
ai_has_key()

report whether a usable API key is available for the current provider

True if the model's section in the ai_set_options document carries a "key", or the provider's environment variable is set and non-empty. A provider whose table row carries no environment key -- ollama, kwaai and custom -- always answers 1, because none is needed.

returns 1 if a key is available (or none is needed); 0 otherwise

if (ai_has_key()) { ... }
ai_init()

check that a provider and a usable key are configured

Auto-picks a provider from the environment if none was set (anthropic, openai, google, xai, deepseek, groq, mistral, cohere, ollama -- first key found wins). Sets the error text on failure. ollama, kwaai and custom carry no environment key in the provider table and so need none.

returns 1 when a provider and key are available; 0 otherwise, with ai_get_error explaining which is missing

if (ai_init() == 0) { print(ai_get_error()); }
ai_parse_bytecode(name, src)

assemble AI-authored bytecode text into a named callable function

namethe name to register the function under
srcthe bytecode source text

returns the result of the assembler -- non-zero identifies the parsed function

ai_parse_bytecode("clamp", src);
ai_pending()

count background jobs that have not finished

WINDOWS ONLY. On Linux and macOS the async family is not implemented: start and wait answer -1, pending answers 0, and result answers the unknown-job marker. Use the synchronous query builtins there.

returns the number of live, unfinished jobs

n = ai_pending();
ai_set_provider(provider)

choose the LLM provider for subsequent calls

Accepts anthropic, openai, google, mistral, cohere, xai, deepseek, groq, ollama, kwaai or custom, plus the aliases claude -> anthropic, grok -> xai and gpt -> openai. A key belongs to the model that uses it -- give it as "key" in that model's section of the ai_set_options document, where it cannot travel to a provider you did not name. Stored in a fixed runtime buffer; a value too long to fit is refused LOUDLY with CX-E5025 naming the limit rather than silently truncated.

providerprovider name or alias; an empty string clears both provider and key

returns void

ai_set_provider("anthropic");
ai_usage()

what the AI has cost so far, as a json document

Totals across every provider (`in`, `out`, `calls`, `uncounted`), the same four plus `cap` per provider under `providers`, and the call that just happened under `last` (`provider`, `in`, `out`, `counted`). The numbers are the PROVIDER'S OWN accounting, read out of its reply, so they are what you will be billed for rather than an estimate. A reply that carried no usage block adds nothing to the totals and increments `uncounted` instead -- zero is a legal token count, so "the provider did not say" is never reported as "the call was free". Counts are per PROCESS; ai_usage_reset() clears them. Only providers that have been called or capped get a row.

returns a json document handle -- read it with jsonMember/jsonAsInt, or print it with exportDoc

u = ai_usage(); printf("%d tokens in
", jsonAsInt(jsonMember(u, "in")));
ai_usage_reset()

clear the AI usage counters, keeping the caps

Zeroes every provider's token totals, call count and uncounted count, and forgets the last call. THE CAPS SURVIVE: a cap is a policy your program declared and a counter is a measurement, so a program rolling a daily budget at midnight resets what it counted without disarming what it decided.

returns nothing

ai_usage_reset();
ai_wait(id)

block until a background job finishes

WINDOWS ONLY. On Linux and macOS the async family is not implemented: start and wait answer -1, pending answers 0, and result answers the unknown-job marker. Use the synchronous query builtins there. Waits with no timeout.

idthe job id from ai_call_async

returns 1 once the job is done; -1 for an unknown id

ai_wait(id);

eval 4 builtins

eval(expr)

evaluate an arithmetic expression held in a string, at runtime

Returns a FLOAT. Names in the expression come from the eval store (evalSet / evalGet / evalClear), which is separate from the program's own variables. An expression it cannot parse is LOUD -- CX-E5029 -- not a silent 0.

exprthe expression text to evaluate

returns the value as a float; a parse failure raises CX-E5029

evalSet("x", 3.0); v.f = eval("x * 2 + 1");
evalset(name, value)

store a named float variable for the expression evaluator

Inserts or updates the value in a small case-insensitive table (max 64 vars, names truncated to 31 chars); these variables are what the eval() expression parser reads. A full table drops the insert silently.

namevariable name (case-insensitive, up to 31 chars)
valuefloat value to store

returns void

evalSet("a", 100);
evalget(name)

read a named float variable from the expression evaluator

Case-insensitive lookup in the eval variable table; yields 0.0 when the name is undefined.

namevariable name to look up (case-insensitive)

returns the variable's stored float value; 0.0 if not defined

assertFloatEqual(evalGet("a"), 10.0);
evalclear()

clear all expression-evaluator variables

Resets the eval variable table to empty (count back to 0).

returns void

evalClear();

Views 2 families · 52 builtins

cxGrid 45 builtins

gpu_grid_new(x, y, w, h)

create a native data-grid widget at x,y,w,h and return its handle

Reuses a free registry slot or grows the grid registry; the grid is a pure data structure until drawn.

xleft position in pixels
ytop position in pixels
wwidth in pixels
hheight in pixels

returns int 1-based grid handle, 0 = failure

g.i = gpu_grid_new(60, 96, 840, 600);
gpu_grid_free(g)

unconditionally destroy a grid handle now, freeing all its storage

Drops the ARC bucket directly then reclaims cells, columns, sections and text handles.

ggrid handle

returns void

gpu_grid_free(g);
gpu_grid_decref(g)

ARC-decrement a grid handle, reclaiming it when its refcount reaches 0

Codegen-emitted at scope exit for an owned `datagrid` local; the shared tracer calls the internal reclaim at refcount 0.

ggrid handle

returns void

gpu_grid_decref(g);
gpu_grid_livecount()

return the number of currently-open grid handles

Leak/regression-test signal (mirrors cxmf_live_count); a leaked grid also shows in the generic checks leak walk.

returns int count of live grid handles

int before = gpu_grid_livecount();
gpu_grid_bounds(g, x, y, w, h)

reposition and resize an existing grid to x,y,w,h

ggrid handle
xnew left position in pixels
ynew top position in pixels
wnew width in pixels
hnew height in pixels

returns void

gpu_grid_bounds(insp, 10, topY, w - 20, iH);
gpu_grid_cols(g, n)

set the grid's column count, reallocating cell storage

n must be > 0; also (re)allocates the initial row buffer.

ggrid handle
nnumber of columns

returns void

gpu_grid_cols(g, 6);
gpu_grid_rows(g, n)

set the grid's row count, growing storage and resorting

Grows cell storage as needed, re-applies the active sort, and tails the view if follow mode is on.

ggrid handle
nnumber of rows

returns void

gpu_grid_rows(g, NROWS);
gpu_grid_col(c, label, width, type, align)

define column c with a label, width, type and alignment

type: 0 TEXT, 1 NUMBER, 2 CHECK, 3 BAR, 4 COLOR, 5 BADGE; align: 0 left, 1 centre, 2 right; width is clamped to a minimum of 24px.

ccolumn index (0-based)
labelcolumn header text (string)
widthcolumn width in pixels (min 24)
typecolumn type 0-5 (TEXT/NUM/CHECK/BAR/COLOR/BADGE)
aligntext alignment 0 left, 1 centre, 2 right

returns void

gpu_grid_col(g, 0, "Module",  220, GT_TEXT,  0);
gpu_grid_clear(g)

empty all cells and section headers, resetting the grid to zero rows

Clears cell text/numbers/colours, drops section headers, and resets selection and scroll.

ggrid handle

returns void

gpu_grid_clear(insp);
gpu_grid_set(r, c, text)

set the text of cell (r,c)

rrow index (0-based)
ccolumn index (0-based)
textcell text (string)

returns void

gpu_grid_set(g, i, 0, dName[i]);
gpu_grid_num(r, c, v)

set the numeric value of cell (r,c)

Value drives NUMBER display, CHECK on/off, and BAR percentage.

rrow index (0-based)
ccolumn index (0-based)
vnumeric value (float)

returns void

gpu_grid_num(g, i, 2, dPower[i]);
gpu_grid_getnum(r, c)

read back the (possibly edited) numeric value of cell (r,c)

rrow index (0-based)
ccolumn index (0-based)

returns float cell value, 0 if the handle or indices are out of bounds

pw.i = gpu_grid_getnum(g, sel, 2);
gpu_grid_gettext(r, c)

read back the (possibly edited) text of cell (r,c)

rrow index (0-based)
ccolumn index (0-based)

returns string cell text, empty string if the handle or indices are out of bounds

selName = gpu_grid_gettext(g, sel, 0) + "   (" + gpu_grid_gettext(g, sel, 1) + ")";
gpu_grid_json(g, doc, col, row)

fill a grid from a json ARRAY OF OBJECTS, one object per row

Keys map to columns BY POSITION, in the first element's own key order -- which is the DOCUMENT's order, so the column order is visible in the source rather than decided elsewhere. If the grid has no columns yet they are CREATED from those keys (label = the key, type inferred from the first value); if it already has columns they are respected and never relabelled, because this call fills a sheet, it does not restyle one. Rows GROW to fit and are never shrunk, so a summary row added afterwards survives a refill. A VALUE IS MAPPED BY THE COLUMN'S DECLARED TYPE, NEVER BY THE JSON'S, and that is forced rather than chosen: cx_grid's numeric formatter prints a whole number without decimals, so an invoice whose money columns were GT_NUM showed "720" beside "658.80". Into a TEXT column a number is therefore rendered at cx_print_decimals -- the program's own `#pragma decimals`, not a new knob -- and into a GT_NUM column it is stored as a number. THE WHOLE DOCUMENT IS CHECKED BEFORE ONE CELL IS WRITTEN. A refusal leaves the grid exactly as it was, because a half-filled sheet looks filled.

gthe grid handle
doca json handle -- a document or a node; an ARRAY of OBJECTS
colthe first grid column to write (0 = the leftmost)
rowthe first grid row to write (0 = the top)

returns the number of rows written; 0 for an empty array or a dead handle

n.i = gridJson(g, doc, 0, 0);
gpu_grid_merge(r, c, cspan, rspan)

merge cell (r,c) across cspan columns and rspan rows

Spans are clamped to at least 1 and to the grid bounds; covered cells are flagged and not drawn.

rorigin row index
corigin column index
cspannumber of columns to span (>=1)
rspannumber of rows to span (>=1)

returns void

gpu_grid_merge(g, 11, 0, 6, 1);
gpu_grid_title(g, title)

set the grid's title-bar text and enable the title bar

ggrid handle
titletitle text (string)

returns void

gpu_grid_title(g, "MODULE DATABASE   (cx_grid demo)");
gpu_grid_font(g, font_id)

set the grid's default font

0 = raylib default pixel font; otherwise a loadfont() handle for crisp TTF text.

ggrid handle
font_idfont handle (0 = default)

returns void

gpu_grid_font(g, gFont);
gpu_grid_colfont(c, font_id)

set the font used by every cell in column c

0 = inherit the grid font; overridden per-cell by gpu_grid_cellfont.

ccolumn index (0-based)
font_idfont handle (0 = inherit grid)

returns void

gpu_grid_colfont(g, 0, gFontBold);
gpu_grid_cellfont(r, c, font_id)

set the font of one cell (r,c)

Cell font wins over the column font, which wins over the grid font; 0 = inherit.

rrow index (0-based)
ccolumn index (0-based)
font_idfont handle (0 = inherit column/grid)

returns void

gpu_grid_cellfont(g, 11, 0, gFontBold);
gpu_grid_color(r, c, color)

set the colour of cell (r,c)

Used by BAR tint, COLOR swatch and BADGE pill cells.

rrow index (0-based)
ccolumn index (0-based)
colorpacked 0xRRGGBB colour

returns void

gpu_grid_color(g, i, 3, dTint[i]);
gpu_grid_textcolor(r, c, color)

set the per-cell TEXT colour of cell (r,c)

-1 reverts the cell to the grid's default text colour.

rrow index (0-based)
ccolumn index (0-based)
colorpacked 0xRRGGBB colour, -1 = grid default

returns void

gpu_grid_textcolor(g, i, 2, parsehex("ff6b6b"));
gpu_grid_rowstyle(r, color)

set a per-row background colour override for row r

-1 = none, reverting the row to zebra/normal shading.

rrow index (0-based)
colorpacked 0xRRGGBB background colour, -1 = none

returns void

gpu_grid_rowstyle(g, 7,  parsehex("3a2a14"));
gpu_grid_style(key, value)

set one named style property of the grid to an int value

Keys: headerbg, rowbg, rowalt, sel, hover, grid, text, accent, titlebg (colours) and rowh, headerh, titleh, footerh, zebra (metrics/flags).

keystyle key name (string)
valuecolour or metric value (int)

returns void

gpu_grid_style(g, "rowh", 30);
gpu_grid_theme(g, name)

set the whole look at once, from a preset name or a json

document. A THEME IS A DOCUMENT (his ruling, 2026-08-25: "a json theme for the grid is a good idea too like a style sheet"). The shipped presets -- "light", "dark", "sheet" -- are not cases in a switch; each IS a json document that ships with the runtime, and a name is shorthand for one of them. Pass anything beginning with '{' and it is read as your own theme instead, so a program (or a rule, or a visitor) can restyle a grid by editing data. Keys are exactly gpu_grid_style's keys, and a value may be a json NUMBER or a hex STRING ("#e3ece5" or "e3ece5") -- a human writing a stylesheet writes the hex, a program generating one writes the number, and refusing either would make the door useless to one of them. REFUSES LOUDLY. Until 2026-08-25 any name that was not "light" fell through to dark, so a typo -- or `gridTheme(g, "sheet")` before this existed -- quietly handed back the dark theme and said nothing.

gthe grid handle
namea preset name, or a json object of style keys
gridTheme(g, "sheet");
gpu_grid_sheet(g, doc)

style the WHOLE sheet from ONE json document, in layers,

down to a single named cell. Sections, applied in this order so that a later layer wins: "theme" a preset name, exactly gpu_grid_theme's ("light" / "dark") "sheet" an object of gpu_grid_style keys -- the sheet-wide defaults "cols" an ARRAY, one entry per column, in column order "rows" an object keyed by the 1-based row number as text "cells" an object keyed by A1 address ("d9"), the single-cell layer Every section is optional; an empty object in "cols" leaves that column alone. Column keys: "label" "width" "type" "align" "decimals" "font" "edit". Row keys: "bg" "level". Cell keys: "bg" "text" "decimals" "font" "edit". A ROW IS ADDRESSABLE FROM THE END: "-1" is the last row, "d-1" its column D cell. A sheet whose length comes from its data has no fixed row 9, and a document that names one goes silently wrong the day a line item is added. A COLOUR is either "#rrggbb" / "rrggbb" or a plain number; "type" and "align" take either their word ("number", "right") or their number. WHY A DOCUMENT AND NOT MORE CALLS. The look of a sheet is data about the sheet, so it is written as data -- read from a file, sent by a server or chosen by a person, with nothing rebuilt. This composes the single-value doors above rather than reimplementing them (one truth, rule 33). FP4: a section, key, address or value the loader cannot honour raises CX-E5052 naming the offending row and STOPS -- deliberately unlike the 3-arg cx_grid_style it composes, which drops an unknown key in silence. A whole document is exactly where one mistyped row would otherwise vanish.

gthe grid handle
doca json handle -- a document or any object node
gpu_grid_sheet(g, look);
gpu_grid_types(g, doc)

declare how every cell is TYPED from ONE json document:

the sheet's default, a column's, a row's, and a single named cell's. The SIBLING of gpu_grid_sheet and it reads the same way: the same four sections, the same column array, the same row keys, the same A1 cell addresses including a row counted from the end. The style document says how the sheet LOOKS; this one says what each cell IS. { "sheet": "text", "cols": [ "text", "number", "bar" ], "rows": { "3": "badge", "-1": "number" }, "cells": { "c2": "check", "d-1": "number" } } The value at every layer is a bare type NAME, not an object -- there is only one thing to say about a cell here, and a document that says it in one word is the shorter program (FP5). The six types: "text" "number" "check" "bar" "color" "badge" -- the same six a column has always had, now reachable at every layer. "" means INHERIT the layer above, so a document can un-type a cell as well as type one; at the "sheet" layer, which has no layer above it, "" means plain text. PRECEDENCE, and it is resolved at the READ, not at the load: a CELL's type beats its ROW's, which beats its COLUMN's, which beats the SHEET's default. A grid nobody has typed answers "text" everywhere, exactly as it always did. WHAT A TYPE GOVERNS. How the cell is drawn (a number, a check box, a progress bar, a colour swatch, a badge); what clicking it does; what an edit commits into; and how the cell BINDS into a formula's `e` -- a "number" cell binds as a number, every other type binds as its text. A TYPE DOES NOT CONVERT DATA. A cell holds a number and a string in separate stores and the type chooses which one is read, so typing a text-filled column "number" shows the numbers nobody set (0), not the text parsed. This is the long-standing behaviour of a column's type, reachable per cell; set the store you mean with gpu_grid_num / gpu_grid_set. AGAINST A FORMULA CELL: the formula owns the VALUE, the type owns how that value is presented and bound, and a REFUSED formula outranks both -- a cell showing #ERR or #CIRC keeps its marker whatever it is typed, because a marker is not a value and a clean 0 in a number cell would look computed. FP4: a section, address, or type name the loader cannot honour raises CX-E5054 naming the offending row and STOPS. An unknown type name is refused WITH THE LEGAL SET SPELLED OUT, because the alternative is sending a person who typed "colour" to a manual they are not holding.

gthe grid handle
doca json handle -- a document or any object node
gpu_grid_types(g, kinds);
gpu_grid_formula(g, r, c, text)

give a cell a FORMULA: text that is compiled in-process

and re-run whenever the sheet changes, so a total follows its inputs. THE FORMULA IS A RULE (DOCS/design/C_RULES.md) -- a `{ ... }` C body over `json e` whose `return` is the cell's value. It is not compiled with the program: it is text the program can read, show and replace while it runs, which is what lets the person in front of the sheet change the arithmetic. Needs `#pragma rules c`, the same gate every other rule needs. `e` binds the three things a spreadsheet formula asks for: e["this"]["b"] this row's cell in column B e["above"]["d"] column D's cells ABOVE this row, as an array -- the range a subtotal sums: `sum(e["above"]["d"])` e["d7"] any cell by its A1 address (1-based row) e["d-1"] the same, counted from the END -- so a totals formula is the same text whatever number of line items the data has A cell in a NUMBER column binds as a number; any other column binds as its text, which the rule engine reads leniently ("3.50" is 3.5). The result is written to the cell as BOTH a number (gpu_grid_getnum) and the text the sheet paints (gpu_grid_gettext), formatted to that cell's decimals. Passing an EMPTY text removes the formula and leaves the cell a plain one. The sheet is re-evaluated to a fixed point, so a formula may name a cell below it. FP4: a formula that does not compile, or a sheet that does not settle because a value depends on itself, marks the CELL (`#ERR` / `#CIRC`) and reports CX-E5053 -- it never leaves the previous number sitting there looking computed, and it never spins.

gthe grid handle
r0-based row
c0-based column
textthe rule body, or "" to remove the formula
gpu_grid_formula(g, 8, 3, "{ return sum(e[\"above\"][\"d\"]); }");
gpu_grid_getformula(g, r, c)

read a cell's formula text back

The companion to gpu_grid_formula, and the reason it exists: a formula the program cannot show is one the person at the sheet cannot find, let alone edit. Answers "" for a cell that has no formula, so a caller can tell the two apart.

gthe grid handle
r0-based row
c0-based column

returns the formula's text, or "" when the cell has none

gpu_draw_text(24, 496, gpu_grid_getformula(g, row, 3), ink);
gpu_grid_rownum(g, on)

toggle the automatic row-number gutter on the left

Numbers are 1-based by original row index.

ggrid handle
on1 = show gutter, 0 = hide

returns void

gpu_grid_rownum(top, 1);
gpu_grid_section(r, label)

mark row r as a collapsible section header with a label

Rows after it (until the next header) belong to it and hide when it folds; clicking the header toggles it.

rrow index to turn into a section header
labelsection header label (string)

returns void

gpu_grid_section(g, 0, "WEAPONS");
gpu_grid_fold(r, folded)

programmatically fold or unfold the section header at row r

rsection header row index
folded1 = folded (collapsed), 0 = expanded

returns void

gpu_grid_fold(insp, r, 1);
gpu_grid_rowlevel(r, level)

set the nesting depth of the section header at row r

0 = top-level, 1 = sub-section, and so on; folding a parent also hides nested sub-sections. Nesting past depth 15 raises CX-E5025.

rsection header row index
levelnesting depth (0 = top; negatives clamp to 0)

returns void

gpu_grid_rowlevel(insp, r, 1);
gpu_grid_coledit(c, on)

mark a TEXT/NUM column c as click-to-edit

CHECK/BAR/COLOR cells are always interactive regardless of this flag.

ccolumn index (0-based)
on1 = editable, 0 = read-only

returns void

gpu_grid_coledit(g, 0, 1);
gpu_grid_celledit(r, c, on)

make one cell (r,c) typeable even if its column is not editable

Used for an editable cell sitting in an otherwise read-only grid.

rrow index (0-based)
ccolumn index (0-based)
on1 = typeable, 0 = not

returns void

gpu_grid_celledit(insp, r, 2, 1);
gpu_grid_sort(col, dir)

programmatically set the sort column and direction

dir < 0 sorts descending, otherwise ascending; col < 0 clears the sort. Group-aware when the grid has sections (sections move as whole blocks).

colcolumn to sort by (<0 = unsorted)
dirdirection: <0 descending, otherwise ascending

returns void

gpu_grid_sort(g, 2, 1);
gpu_grid_scrollx(px)

set the horizontal scroll offset in pixels

Clamped to 0..(content width - viewport width).

pxhorizontal scroll offset in pixels

returns void

gpu_grid_scrollx(g, 0);
gpu_grid_follow(g, on)

toggle follow mode so the view auto-tails to the bottom as rows append

Re-enabled automatically when the user scrolls back to the bottom.

ggrid handle
on1 = follow (auto-tail), 0 = off

returns void

gpu_grid_follow(dlog, 1);
gpu_grid_update(mx, my, wheel, down, pressed)

drive one frame of grid input from the mouse state

Handles wheel/scrollbar scrolling, header sort clicks, column resize, section fold, cell selection and text/number editing.

mxmouse x in pixels
mymouse y in pixels
wheelmouse wheel delta
downmouse button held (1/0)
pressedmouse button pressed this frame (1/0)

returns void

gpu_grid_update(g, mx, my, wheel, down, pressed);
gpu_grid_draw(g)

render the grid this frame using raylib primitives

Draws the title bar, header, visible rows (cells/sections), vertical and horizontal scrollbars and the footer.

ggrid handle

returns void

gpu_grid_draw(g);
gpu_grid_tail(g)

snap the view to the last rows (log tail)

ggrid handle

returns void

gpu_grid_tail(dlog);
gpu_grid_sel(g)

return the selected original row index

ggrid handle

returns int selected original row index, -1 = none

sel.i = gpu_grid_sel(g);
gpu_grid_selcol(g)

the COLUMN the selecting click landed on, the twin of

gpu_grid_sel's row. Answers the column index the last selecting press ACTED on, which inside a horizontal merge is the span's origin rather than the column the pointer was over. -1 means no column, and it is a real state rather than a failure: a press in the row-number gutter or to the right of the last column selects a ROW and no column, and a grid nobody has clicked has neither. It reads what the press already decided and does not hit-test again, so asking costs a field read. Pair it with gpu_grid_sel to tell one gesture from another -- typing into a value column, asking about a formula column -- without re-deriving the geometry, which a caller cannot do anyway: the column boundaries move by the row-number gutter, whose width is not reachable from CX and is not meant to be.

gthe grid handle

returns the column index (0 = leftmost), or -1 for no column

if (gpu_grid_selcol(g) >= 2) { pick(gpu_grid_sel(g)); }
gpu_grid_sortcol(g)

return the current sort column index

ggrid handle

returns int current sort column, -1 = unsorted

gpu_grid_sortcol(g);
gpu_grid_hover(g)

return the hovered original row index

ggrid handle

returns int hovered original row index, -1 = none

gpu_grid_hover(g);
gpu_grid_count(g)

return the grid's row count

ggrid handle

returns int number of rows

gpu_draw_text(60, 744, "rows: " + str(gpu_grid_count(g)), colDim, 16);

GUI 7 builtins

gpu_gui_button(x, y, w, h, text)

draws an immediate-mode button in a borderless panel and reports whether it was clicked this frame

Meant to be polled every frame inside the draw loop; wraps nuklear's nk_button_label in a mini-window keyed by its coordinates.

xleft edge of the widget in pixels
ytop edge of the widget in pixels
wwidth in pixels
hheight in pixels
textbutton label text

returns int -- 1 if the button was clicked this frame, else 0 (also 0 when there is no window/GL context)

if (gpu_gui_button(20, 60, 120, 32, "Click me")) {
gpu_gui_label(x, y, w, h, text)

draws a static left-aligned text label in a borderless panel

xleft edge of the widget in pixels
ytop edge of the widget in pixels
wwidth in pixels
hheight in pixels
textlabel text to display

returns void

gpu_gui_label(160, 60, 200, 32, "clicks: " + str(clicks));
gpu_gui_checkbox(x, y, w, h, text, state)

draws a labelled checkbox and returns its new on/off state

The passed state seeds the widget each frame; the return value reflects any toggle the user made this frame.

xleft edge of the widget in pixels
ytop edge of the widget in pixels
wwidth in pixels
hheight in pixels
textcheckbox label text
statecurrent state; nonzero = checked

returns int -- the checkbox's new state (1 checked, 0 unchecked; echoes the input state when there is no window)

on = gpu_gui_checkbox(20, 60, 140, 32, "enabled", on);
gpu_gui_console(x, y, w, h, text)

draws a bordered multi-line text panel, splitting the text on newlines into rows

A minimal read-only log/console view; each newline-separated line becomes its own row and a trailing carriage return is stripped.

xleft edge of the panel in pixels
ytop edge of the panel in pixels
wwidth in pixels
hheight in pixels
textmulti-line body text to display

returns void

gpu_gui_console(640, 152, 340, 460, log_text);
gpu_gui_input(x, y, w, h, key, initial)

draws a single-line text input field whose buffer persists across frames, keyed by name

On the first call for a given key the field is seeded with initial; later calls return whatever the user has typed. State lives in a 32-slot table with a 255-char buffer.

xleft edge of the field in pixels
ytop edge of the field in pixels
wwidth in pixels
hheight in pixels
keyimmediate-mode identity/slot key that names the persistent buffer
initialtext to seed the buffer on first use of this key

returns string -- the field's current text (empty string when there is no window or the 32-slot table is full)

string name = gpu_gui_input(20, 60, 200, 32, "name", "");
gpu_gui_editor(x, y, w, h, key, initial)

draws a multi-line scrollable text editor box whose buffer persists across frames, keyed by name

Like gpu_gui_input but backed by nuklear's NK_EDIT_BOX with an 8 KB buffer and its own scrollbar, intended as a code/log scratch pad. State lives in an 8-slot table.

xleft edge of the editor in pixels
ytop edge of the editor in pixels
wwidth in pixels
hheight in pixels
keyimmediate-mode identity/slot key that names the persistent buffer
initialtext to seed the buffer on first use of this key

returns string -- the editor's current text (empty string when there is no window or the 8-slot table is full)

string code = gpu_gui_editor(20, 152, 600, 460, "src", "// type CX code here\nint x = 7;\nprintln(x * 6);\n");
gpu_gui_fontsize(px)

sets the GUI widget font size in pixels, before the font atlas is baked

Only takes effect if called before the first widget renders (nuklear bakes its atlas on first use); the value is clamped to 8..64 and defaults to 18.

pxdesired font size in pixels

returns void

gpu_gui_fontsize(20);

Game 2 families · 50 builtins

game helpers 46 builtins

vec3addx(x1, y1, z1, x2, y2, z2)

return the X component of adding two 3D vectors (x1+x2)

CX has no vec3 value type, so vector ops are split into per-component scalar builtins; call vec3addx/y/z to build the sum vector. Only x1 and x2 are used.

x1X of the first vector
y1Y of the first vector (ignored)
z1Z of the first vector (ignored)
x2X of the second vector
y2Y of the second vector (ignored)
z2Z of the second vector (ignored)

returns float x1 + x2

vec3addx(1.0,2.0,3.0, 4.0,5.0,6.0)
vec3addy(x1, y1, z1, x2, y2, z2)

return the Y component of adding two 3D vectors (y1+y2)

Component-wise scalar helper; only y1 and y2 are used.

x1X of the first vector (ignored)
y1Y of the first vector
z1Z of the first vector (ignored)
x2X of the second vector (ignored)
y2Y of the second vector
z2Z of the second vector (ignored)

returns float y1 + y2

vec3addy(1.0,2.0,3.0, 4.0,5.0,6.0)
vec3addz(x1, y1, z1, x2, y2, z2)

return the Z component of adding two 3D vectors (z1+z2)

Component-wise scalar helper; only z1 and z2 are used.

x1X of the first vector (ignored)
y1Y of the first vector (ignored)
z1Z of the first vector
x2X of the second vector (ignored)
y2Y of the second vector (ignored)
z2Z of the second vector

returns float z1 + z2

vec3addz(1.0,2.0,3.0, 4.0,5.0,6.0)
vec3subx(x1, y1, z1, x2, y2, z2)

return the X component of subtracting two 3D vectors (x1-x2)

Component-wise scalar helper; only x1 and x2 are used.

x1X of the first vector
y1Y of the first vector (ignored)
z1Z of the first vector (ignored)
x2X of the second vector
y2Y of the second vector (ignored)
z2Z of the second vector (ignored)

returns float x1 - x2

vec3subx(5.0,5.0,5.0, 1.0,2.0,3.0)
vec3suby(x1, y1, z1, x2, y2, z2)

return the Y component of subtracting two 3D vectors (y1-y2)

Component-wise scalar helper; only y1 and y2 are used.

x1X of the first vector (ignored)
y1Y of the first vector
z1Z of the first vector (ignored)
x2X of the second vector (ignored)
y2Y of the second vector
z2Z of the second vector (ignored)

returns float y1 - y2

vec3suby(5.0,5.0,5.0, 1.0,2.0,3.0)
vec3subz(x1, y1, z1, x2, y2, z2)

return the Z component of subtracting two 3D vectors (z1-z2)

Component-wise scalar helper; only z1 and z2 are used.

x1X of the first vector (ignored)
y1Y of the first vector (ignored)
z1Z of the first vector
x2X of the second vector (ignored)
y2Y of the second vector (ignored)
z2Z of the second vector

returns float z1 - z2

vec3subz(5.0,5.0,5.0, 1.0,2.0,3.0)
vec3scalex(x, y, z, s)

return the X component of a 3D vector scaled by a scalar (x*s)

Component-wise scalar helper; only x and s are used.

xX of the vector
yY of the vector (ignored)
zZ of the vector (ignored)
sscalar multiplier

returns float x * s

vec3scalex(2.0,3.0,4.0, 10.0)
vec3scaley(x, y, z, s)

return the Y component of a 3D vector scaled by a scalar (y*s)

Component-wise scalar helper; only y and s are used.

xX of the vector (ignored)
yY of the vector
zZ of the vector (ignored)
sscalar multiplier

returns float y * s

vec3scaley(2.0,3.0,4.0, 10.0)
vec3scalez(x, y, z, s)

return the Z component of a 3D vector scaled by a scalar (z*s)

Component-wise scalar helper; only z and s are used.

xX of the vector (ignored)
yY of the vector (ignored)
zZ of the vector
sscalar multiplier

returns float z * s

vec3scalez(2.0,3.0,4.0, 10.0)
vec3length(x, y, z)

return the length (magnitude) of a 3D vector

Computes sqrt(x*x + y*y + z*z).

xX of the vector
yY of the vector
zZ of the vector

returns float sqrt(x^2 + y^2 + z^2)

vec3length(3.0, 4.0, 0.0)
vec3distance(x1, y1, z1, x2, y2, z2)

return the Euclidean distance between two 3D points

Computes sqrt of the summed squared component differences.

x1X of the first point
y1Y of the first point
z1Z of the first point
x2X of the second point
y2Y of the second point
z2Z of the second point

returns float distance between the two points

vec3distance(0.0,0.0,0.0, 3.0,4.0,0.0)
vec3dot(x1, y1, z1, x2, y2, z2)

return the dot product of two 3D vectors

Computes x1*x2 + y1*y2 + z1*z2.

x1X of the first vector
y1Y of the first vector
z1Z of the first vector
x2X of the second vector
y2Y of the second vector
z2Z of the second vector

returns float x1*x2 + y1*y2 + z1*z2

vec3dot(1.0,2.0,3.0, 4.0,5.0,6.0)
vec3crossx(x1, y1, z1, x2, y2, z2)

return the X component of the cross product of two 3D vectors

Computes y1*z2 - z1*y2.

x1X of the first vector (ignored)
y1Y of the first vector
z1Z of the first vector
x2X of the second vector (ignored)
y2Y of the second vector
z2Z of the second vector

returns float y1*z2 - z1*y2

vec3crossx(1.0,0.0,0.0, 0.0,1.0,0.0)
vec3crossy(x1, y1, z1, x2, y2, z2)

return the Y component of the cross product of two 3D vectors

Computes z1*x2 - x1*z2.

x1X of the first vector
y1Y of the first vector (ignored)
z1Z of the first vector
x2X of the second vector
y2Y of the second vector (ignored)
z2Z of the second vector

returns float z1*x2 - x1*z2

vec3crossy(1.0,0.0,0.0, 0.0,1.0,0.0)
vec3crossz(x1, y1, z1, x2, y2, z2)

return the Z component of the cross product of two 3D vectors

Computes x1*y2 - y1*x2.

x1X of the first vector
y1Y of the first vector
z1Z of the first vector (ignored)
x2X of the second vector
y2Y of the second vector
z2Z of the second vector (ignored)

returns float x1*y2 - y1*x2

vec3crossz(1.0,0.0,0.0, 0.0,1.0,0.0)
vec3normx(x, y, z)

return the X component of the normalized (unit-length) vector

Computes x/|v|; returns 0.0 when the vector length is zero.

xX of the vector
yY of the vector
zZ of the vector

returns float x divided by the vector length, or 0.0 if length is 0

vec3normx(3.0,4.0,0.0)
vec3normy(x, y, z)

return the Y component of the normalized (unit-length) vector

Computes y/|v|; returns 0.0 when the vector length is zero.

xX of the vector
yY of the vector
zZ of the vector

returns float y divided by the vector length, or 0.0 if length is 0

vec3normy(3.0,4.0,0.0)
vec3normz(x, y, z)

return the Z component of the normalized (unit-length) vector

Computes z/|v|; returns 0.0 when the vector length is zero.

xX of the vector
yY of the vector
zZ of the vector

returns float z divided by the vector length, or 0.0 if length is 0

vec3normz(0.0,0.0,5.0)
vec3lerpx(x1, y1, z1, x2, y2, z2, t)

return the X component of the linear interpolation between two 3D points

Computes x1 + (x2 - x1) * t; t is NOT clamped.

x1X of the start point
y1Y of the start point (ignored)
z1Z of the start point (ignored)
x2X of the end point
y2Y of the end point (ignored)
z2Z of the end point (ignored)
tinterpolation factor (0 = start, 1 = end; unclamped)

returns float x1 + (x2 - x1) * t

vec3lerpx(0.0,0.0,0.0, 10.0,0.0,0.0, 0.5)
vec3lerpy(x1, y1, z1, x2, y2, z2, t)

return the Y component of the linear interpolation between two 3D points

Computes y1 + (y2 - y1) * t; t is NOT clamped.

x1X of the start point (ignored)
y1Y of the start point
z1Z of the start point (ignored)
x2X of the end point (ignored)
y2Y of the end point
z2Z of the end point (ignored)
tinterpolation factor (0 = start, 1 = end; unclamped)

returns float y1 + (y2 - y1) * t

vec3lerpy(0.0,0.0,0.0, 0.0,10.0,0.0, 0.5)
vec3lerpz(x1, y1, z1, x2, y2, z2, t)

return the Z component of the linear interpolation between two 3D points

Computes z1 + (z2 - z1) * t; t is NOT clamped.

x1X of the start point (ignored)
y1Y of the start point (ignored)
z1Z of the start point
x2X of the end point (ignored)
y2Y of the end point (ignored)
z2Z of the end point
tinterpolation factor (0 = start, 1 = end; unclamped)

returns float z1 + (z2 - z1) * t

vec3lerpz(0.0,0.0,0.0, 0.0,0.0,10.0, 0.5)
vec3angle(x1, y1, z1, x2, y2, z2)

return the angle in degrees between two 3D vectors

Computes acos(dot/(|a||b|)) converted to degrees; returns 0.0 if either vector has zero length, and the cosine is clamped to [-1,1].

x1X of the first vector
y1Y of the first vector
z1Z of the first vector
x2X of the second vector
y2Y of the second vector
z2Z of the second vector

returns float angle between the vectors in degrees, or 0.0 if either is zero-length

vec3angle(1.0,0.0,0.0, 0.0,1.0,0.0)
camfollow(cam, tx, ty, tz, speed)

smoothly lerp a camera's target toward a world point

Moves the cx_gfx Camera3D target from its current position toward (tx,ty,tz) by fraction speed each call (speed clamped 0..1); call per-frame for smooth following.

camcx_gfx camera handle
txtarget X to follow
tytarget Y to follow
tztarget Z to follow
speedlerp fraction per call, clamped to 0..1

returns void

camfollow(cam, tx, ty, tz, 0.1)
camzoomto(cam, dist, speed)

smoothly move a camera toward a target orbit distance

Slides the camera position along its current view direction so the distance to the target approaches dist by fraction speed (clamped 0..1); honors mindist/maxdist clamps set by camorbitsettings and is a no-op when position equals target.

camcx_gfx camera handle
distdesired distance from the camera target
speedlerp fraction per call, clamped to 0..1

returns void

camzoomto(cam, 20.0, 0.1)
camshake(cam, intensity, duration)

apply a decaying random positional jitter to a camera

Call per-frame while shaking: undoes the previous frame's offset (so it composes with camfollow/zoom), then adds xorshift-random jitter scaled by intensity and a linear time-decay envelope over duration; intensity or duration <= 0 stops the shake. Only camera handles 1..7 hold shake state.

camcx_gfx camera handle (valid 1..7 for shake state)
intensitymaximum jitter magnitude
durationshake duration in seconds

returns void

camshake(cam, 0.5, 1.0)
camorbitsettings(cam, minspeed, maxspeed, mindist, maxdist)

store min/max speed and distance clamps for a camera

Records orbital tuning on the camera's shake-state slot; the min/max distance is later honored by camzoomto. Only camera handles 1..7 hold this state.

camcx_gfx camera handle (valid 1..7)
minspeedminimum orbit speed (stored)
maxspeedmaximum orbit speed (stored)
mindistminimum orbit distance (clamps camzoomto)
maxdistmaximum orbit distance (clamps camzoomto; 0 = unset)

returns void

camorbitsettings(cam, 0.1, 2.0, 5.0, 50.0)
goload(name)

load a game object from a JSON definition file and return its handle

Resolves name in order (raw path, data/ships/<name>.json, data/<name>, <name>.json), parses the JSON into a reusable object-pool slot, and reads optional scale plus model/shader assets (assets only load when a window is open). Grows the pool up to #pragma MaxGameObjects; on empty name or no loadable/parseable file it prints an error and exits the process (never returns a sentinel).

nameobject name or path used to locate the JSON definition

returns int object handle (>= 1); on failure it prints an error and exits, so it never returns an invalid handle

int h = goload(tmp);
gofree(obj)

free a game object and release its slot for reuse

Releases the object's JSON doc, any loaded model/shader, and its override array, then clears the slot so a later goload can reclaim it. No-op on an invalid handle.

objgame object handle from goload

returns void

gofree(h);
gogetf(obj, key)

read a float property of a game object

Returns the runtime override for key if one was set via gosetf/goseti, otherwise reads the key from the object's JSON. Returns 0.0 on an invalid handle.

objgame object handle from goload
keyproperty name to read

returns float property value (override first, else JSON member); 0.0 if the handle is invalid

speed = gogetf(obj, "speed")
gogeti(obj, key)

read an integer property of a game object

Returns the runtime override for key if one was set, otherwise reads the key from the object's JSON. Returns 0 on an invalid handle.

objgame object handle from goload
keyproperty name to read

returns int property value (override first, else JSON member); 0 if the handle is invalid

hp = gogeti(obj, "health")
gogets(obj, key)

read a string property of a game object

Reads the key from the object's JSON (string overrides are not supported — only numeric overrides exist). Returns an empty string on an invalid handle.

objgame object handle from goload
keyproperty name to read

returns string property value from the JSON member; empty string if the handle is invalid

name = gogets(obj, "name")
gosetf(obj, key, v)

set a runtime float override on a game object property

Stores an in-memory override for key (read back by gogetf/gogeti) without modifying the underlying JSON; grows the per-object override array up to #pragma MaxGameOverrides. No-op on an invalid handle.

objgame object handle from goload
keyproperty name to override
vfloat value to store

returns void

gosetf(obj, "speed", 12.5)
goseti(obj, key, v)

set a runtime integer override on a game object property

Stores an in-memory override for key (kept as a double, read back by gogeti/gogetf) without touching the JSON. No-op on an invalid handle.

objgame object handle from goload
keyproperty name to override
vinteger value to store

returns void

goseti(obj, "health", 100)
timercreate(duration)

create a countdown timer and return its handle

Allocates a timer counting down from duration seconds (negative clamped to 0); reuses freed slots and grows the pool up to #pragma MaxGameTimers.

durationcountdown length in seconds (negative treated as 0)

returns int timer handle (>= 1)

lastTmr = timercreate(1.0);
timerupdate(tmr, delta)

advance a timer by subtracting a delta from its remaining time

Decrements the timer's remaining time by delta, floored at 0. No-op on an invalid handle.

tmrtimer handle from timercreate
deltatime to subtract (e.g. per-frame delta seconds)

returns void

timerupdate(tmr, 0.016)
timerexpired(tmr)

test whether a timer has counted down to zero

Returns 1 once the remaining time reaches 0, else 0; 0 for an invalid handle.

tmrtimer handle from timercreate

returns int 1 if remaining time <= 0, else 0 (0 for an invalid handle)

timerexpired(timercreate(1000.0))
timerreset(tmr)

reset a timer's remaining time back to its full duration

No-op on an invalid handle.

tmrtimer handle from timercreate

returns void

timerreset(tmr)
timerprogress(tmr)

return a timer's elapsed fraction from 0.0 to 1.0

Computes 1 - remaining/duration, clamped to 0..1; a zero-length timer reports 1.0 (instantly done) and an invalid handle returns 0.0.

tmrtimer handle from timercreate

returns float progress in 0..1 (1.0 for a zero-length timer; 0.0 if the handle is invalid)

p = timerprogress(tmr)
cooldownready(tmr)

poll a timer as a cooldown and auto-restart it when ready

Returns 1 the moment the timer reaches 0 and simultaneously reloads it to full duration for the next interval (the weapon/AI cooldown pattern); otherwise 0, and 0 for an invalid handle.

tmrtimer handle from timercreate

returns int 1 if the cooldown is ready (and it auto-restarts), else 0 (0 for an invalid handle)

if (cooldownready(tmr)) { fire(); }
dist3d(x1, y1, z1, x2, y2, z2)

return the Euclidean distance between two 3D points

Computes sqrt of the summed squared component differences (same as vec3distance).

x1X of the first point
y1Y of the first point
z1Z of the first point
x2X of the second point
y2Y of the second point
z2Z of the second point

returns float distance between the two points

dist3d(0.0,0.0,0.0, 3.0,4.0,0.0)
inrange3d(x1, y1, z1, x2, y2, z2, range)

test whether two 3D points are within a given range

Compares squared distance against range squared (no sqrt), returning 1 if within range inclusive.

x1X of the first point
y1Y of the first point
z1Z of the first point
x2X of the second point
y2Y of the second point
z2Z of the second point
rangemaximum distance (inclusive)

returns int 1 if the distance is <= range, else 0

inrange3d(0.0,0.0,0.0, 1.0,0.0,0.0, 2.0)
worldtoscreenx(cam, wx, wy, wz)

project a 3D world point to its screen X coordinate

Uses raylib GetWorldToScreen for the given camera; returns 0.0 when no window is open or the camera handle is invalid (headless-safe).

camcx_gfx camera handle
wxworld X
wyworld Y
wzworld Z

returns float screen-space X pixel of the projected point; 0.0 if no window/invalid camera

sx = worldtoscreenx(cam, wx, wy, wz)
worldtoscreeny(cam, wx, wy, wz)

project a 3D world point to its screen Y coordinate

Uses raylib GetWorldToScreen for the given camera; returns 0.0 when no window is open or the camera handle is invalid (headless-safe).

camcx_gfx camera handle
wxworld X
wyworld Y
wzworld Z

returns float screen-space Y pixel of the projected point; 0.0 if no window/invalid camera

sy = worldtoscreeny(cam, wx, wy, wz)
headingto(x1, z1, x2, z2)

return the yaw heading in degrees from one XZ point to another

Computes atan2(x2-x1, z2-z1) in degrees on the XZ plane, where 0 degrees is +Z increasing toward +X.

x1X of the source point
z1Z of the source point
x2X of the target point
z2Z of the target point

returns float heading in degrees (0 = +Z, increasing toward +X)

h = headingto(x1, z1, x2, z2)
colorlerp(c1, c2, t)

linearly interpolate between two packed colors

Blends two 0xAARRGGBB colors channel-wise (alpha, red, green, blue) with t clamped to 0..1, returning the packed result.

c1start color (packed 0xAARRGGBB int)
c2end color (packed 0xAARRGGBB int)
tblend factor, clamped to 0..1 (0 = c1, 1 = c2)

returns int the interpolated packed 0xAARRGGBB color

colorlerp(0, 16777215, 0.5)
colorflash(col, flashcol, t)

blend a base color toward a flash color by an amount

Alias of colorlerp: t=0 returns col unchanged, t=1 returns full flashcol, with t clamped to 0..1.

colbase color (packed 0xAARRGGBB int)
flashcolflash color to blend toward (packed 0xAARRGGBB int)
tflash amount, clamped to 0..1 (0 = base, 1 = full flash)

returns int the blended packed 0xAARRGGBB color

c = colorflash(col, 0xFFFFFFFF, 0.5)

assets 4 builtins

assetclose()

close the open asset bundle

returns void

assetClose();
assetload(name)

load one asset by name from the open bundle

namethe asset's name within the bundle

returns a handle to the loaded asset; 0 on failure

h = assetLoad("hero.png");
assetopen(path)

open an asset bundle for subsequent loads

The resolver works in two modes -- loose files on disk, or a packed datafile -- and this chooses the source. assetLoad then reads through whichever is open.

paththe bundle or directory to open

returns non-zero on success

assetOpen("game.dat");
blobload(path)

load a file from disk as a blob

Callable, but its argument list is not derivable from its binding: it is bound only by the register VM's name table, where the C signature is the generic (vm, N) wrapper form. Named here so an absence is never mistaken for non-existence.

paththe file to load

returns a blob handle; 0 on failure

h = blobLoad("data.bin");

Graphics 7 families · 134 builtins

3D graphics 38 builtins

gpu_camera_new(px, py, pz, tx, ty, tz, ux, uy, uz, fov)

create a perspective Camera3D and return its handle

Fills a camera slot with position/target/up/fov and CAMERA_PERSPECTIVE projection. Returns 0 if no window is open; hard errors (CX-E5025) if the 3-slot camera table is full.

pxcamera (eye) position x
pycamera position y
pzcamera position z
txlook-at target x
tylook-at target y
tzlook-at target z
uxup vector x
uyup vector y
uzup vector z
fovvertical field of view in degrees

returns 1-based camera handle; 0 if the window is not open

int cam = gpu_camera_new(0.0,10.0,10.0, 0.0,0.0,0.0, 0.0,1.0,0.0, 45.0);
gpu_camera_begin(cam)

begin 3D rendering through a camera (raylib BeginMode3D)

Enters 3D mode for this camera; also feeds the camera position into every loaded shader's viewPos uniform for correct lighting. All 3D draws must sit between this and gpu_camera_end.

camcamera handle from gpu_camera_new

returns void

gpu_camera_begin(cam);
gpu_camera_setpos(cam, x, y, z)

set a camera's position vector

camcamera handle
xnew position x
ynew position y
znew position z

returns void

gpu_camera_setpos(cam, 1.0,2.0,3.0);
gpu_camera_settarget(cam, x, y, z)

set a camera's look-at target vector

camcamera handle
xtarget x
ytarget y
ztarget z

returns void

gpu_camera_settarget(cam, 0.0,0.0,0.0);
gpu_draw_sphere(x, y, z, radius, col)

draw a filled 3D sphere at (x,y,z) (raylib DrawSphere)

xcenter x
ycenter y
zcenter z
radiussphere radius
colcolor int

returns void

gpu_draw_sphere(3.0,0.0,0.0, 1.0, 255);
gpu_draw_grid(slices, spacing)

draw a reference grid on the XZ plane centered at the origin (raylib DrawGrid)

slicesnumber of grid squares in each direction
spacingdistance between grid lines

returns void

gpu_draw_grid(10, 1.0);
gpu_draw_line3d(x1, y1, z1, x2, y2, z2, col)

draw a 3D line from (x1,y1,z1) to (x2,y2,z2) (raylib DrawLine3D)

x1start x
y1start y
z1start z
x2end x
y2end y
z2end z
colline color int

returns void

gpu_draw_line3d(0.0,0.0,0.0, 1.0,1.0,1.0, 255);
gpu_draw_point3d(x, y, z, col)

draw a 3D point at (x,y,z) (raylib DrawPoint3D)

xpoint x
ypoint y
zpoint z
colcolor int

returns void

gpu_draw_point3d(1.0,1.0,1.0, 255);
gpu_draw_billboard(cam, tex, x, y, z, size, col)

draw a 2D image as a camera-facing billboard at a 3D position (raylib DrawBillboard)

Reuses an existing 2D image/texture handle from the image registry; always faces the given camera.

camcamera handle the billboard faces
teximage/texture handle (from loadimage)
xworld position x
yworld position y
zworld position z
sizebillboard size in world units
coltint color int

returns void

gpu_draw_billboard(cam, tex, 0.0,2.0,0.0, 1.0, 255);
gpu_draw_billboard_pro(cam, tex, sx,sy,sw,sh, x,y,z, ux,uy,uz, w,h, rot, col, alpha)

draw an ORIENTED textured billboard (raylib DrawBillboardPro): a quad

of one texture at a world position, its height axis aligned to `up`, sized (w,h) in world units, spun `rot` degrees about the view axis, tinted col(0xRRGGBB) at `alpha`. Origin is centred on the position. This is the general oriented-quad primitive (strands, tracers, beams, sprites-along-a- path); unlike gpu_draw_billboard it is NOT locked to a camera-facing square. Draws into the CURRENT blend/depth state -- bracket a glow pass with gpu_blend_add/gpu_blend_normal and gpu_depth_write for clean additive sums.

camcamera handle (gpu_camera_new)
teximage/texture handle (gpu_image_load)
sx,sy,sw,shsource rect in TEXTURE PIXELS (atlas cell). sw<=0 or sh<=0 selects the whole texture; a non-zero rect flips between frames on one sheet.
x,y,zworld position the quad is centred on
ux,uy,uzthe quad's height (up) axis; normalized here (zero -> world up)
w,hquad size in world units (w across, h along `up`)
rotrotation in degrees about the view axis
coltint packed 0xRRGGBB
alphatint alpha 0..255
gpu_draw_billboard_pro(cam, tex, 0,0,0,0, x,y,z, dx,dy,dz, 0.4, len, 0, rgb(120,180,255), 200)
gpu_depth_write(on)

toggle depth-buffer WRITES (rlgl global state). on=0 stops transparent

billboards/particles from writing depth -- so overlapping additive sprites sum cleanly instead of a near quad punching a rectangular depth hole that rejects the sprites behind it (the transparent-quad self-occlusion artifact). on=1 restores normal depth writes. Pair off/on around a glow pass, and re-enable AFTER the blend batch is flushed (i.e. after gpu_blend_normal).

on1 = normal depth writes, 0 = depth writes disabled
gpu_blend_add(); gpu_depth_write(0); ...draw strands...; gpu_blend_normal(); gpu_depth_write(1);
gpu_model_load(path)

load a 3D model from a file and return its handle (raylib LoadModel)

Allocates a model slot with a leak-tracking ARC bucket. A failed load (meshCount==0) prints a CX error and exits (FP4), never a silent invisible model.

pathmodel file path (.obj, .gltf, etc.)

returns 1-based model handle; 0 if the window is not open; hard-exits on load failure

model m = gpu_model_load("ship.obj");
gpu_mesh_sphere(radius, rings, slices)

generate a sphere mesh and return it as a model handle (raylib GenMeshSphere)

radiussphere radius
ringsnumber of horizontal rings
slicesnumber of vertical slices

returns 1-based model handle; 0 if the window is not open

int sph = gpu_mesh_sphere(1.0, 16, 16);
gpu_model_draw_ex(mdl, x, y, z, ax, ay, az, angle, sx, sy, sz, col)

draw a model with rotation axis/angle and per-axis scale (raylib DrawModelEx)

mdlmodel handle
xposition x
yposition y
zposition z
axrotation axis x
ayrotation axis y
azrotation axis z
anglerotation angle in degrees
sxscale x
syscale y
szscale z
coltint color int

returns void

gpu_model_draw_ex(pln, 0.0,0.0,0.0, 0.0,1.0,0.0, 45.0, 1.0,1.0,1.0, 255);
gpu_model_texture(mdl, tex)

apply a 2D image as a model's first-material diffuse map (raylib SetMaterialTexture)

Uses an existing image/texture handle from the 2D image registry as MATERIAL_MAP_DIFFUSE on material 0.

mdlmodel handle
teximage/texture handle (from loadimage)

returns void

gpu_model_texture(cube, tex);
gpu_model_livecount()

count currently-live model slots

Leak-check signal for the `model` resource type: number of in-use slots in the model table.

returns number of live model slots (int)

int n = gpu_model_livecount();
gpu_msaa(level)

request multi-sample anti-aliasing at the given level

Runs on both engines (since GAP, 3.5.006.0 -- until then the register VM refused it with CX-E1013). The level is STORED and applied when the window OPENS -- calling it afterwards has no effect on the live window. Any level above 0 turns on raylib's 4x MSAA hint; a negative level clamps to 0 (off).

level0 disables; any positive value requests MSAA. Negatives clamp to 0

returns void

gpu_msaa(4);   // before the window opens
gpu_texture_filter(mode)

set the default sampling filter for model textures

Runs on both engines (since GAP, 3.5.006.0 -- until then the register VM refused it with CX-E1013). Applies to textures loaded or applied AFTER this call; never calling it leaves every texture exactly as its loader made it. Modes: 0 point (crisp pixel art), 1 bilinear, 2 trilinear, 3 anisotropic.

mode0 point, 1 bilinear, 2 trilinear, 3 anisotropic

returns void

gpu_texture_filter(1);
gpu_model_smooth(model, mode)

set the sampling filter for ONE model's textures, now

Runs on both engines (since GAP, 3.5.006.0 -- until then the register VM refused it with CX-E1013). Applies a filter mode to that model's diffuse textures immediately, overriding the global default gpu_texture_filter set. It is TEXTURE FILTERING, not shading -- it does not change how normals are interpolated.

modelthe model
mode0 point, 1 bilinear, 2 trilinear, 3 anisotropic

returns void

gpu_model_smooth(m, 1);
gpu_shader_load(vspath, fspath)

load a shader from vertex + fragment shader files (raylib LoadShader)

An empty-string path becomes NULL so raylib uses its built-in default for that stage. Also caches the viewPos uniform location so gpu_camera_begin can feed camera position automatically.

vspathvertex shader file path ("" = default vertex shader)
fspathfragment shader file path ("" = default fragment shader)

returns 1-based shader handle; 0 if the window is not open

shader s = gpu_shader_load("", "");
gpu_shader_livecount()

count currently-live shader slots

Leak-check signal for the `shader` resource type: number of in-use shader slots.

returns number of live shader slots (int)

int n = gpu_shader_livecount();
gpu_shader_setf(sh, name, fvalue, uniformtype)

set a float uniform value on a shader (raylib SetShaderValue)

Looks up the uniform by name; missing uniform is a silent no-op.

shshader handle
nameuniform name
fvaluefloat value to set
uniformtypetype code: 0=float 1=vec2 2=vec3 3=vec4 4=int 5=sampler2d

returns void

gpu_shader_setf(sh, "strength", 0.5, 0);
gpu_shader_setvec3(sh, name, x, y, z, uniformtype)

set a vec3 uniform value on a shader (raylib SetShaderValue)

Packs (x,y,z) into a float[3] and uploads to the named uniform; missing uniform is a silent no-op.

shshader handle
nameuniform name
xvec3 component x
yvec3 component y
zvec3 component z
uniformtypetype code: 0=float 1=vec2 2=vec3 3=vec4 4=int 5=sampler2d

returns void

gpu_shader_setvec3(sh, "tint", 1.0,0.5,0.2, 2);
gpu_light_ambient(sh, r, g, b, a)

set a shader's "ambient" vec4 light uniform from an RGBA color

Normalizes r,g,b,a (0..255) to 0..1 and uploads to the shader's "ambient" uniform; no-op if that uniform is absent.

shshader handle
rambient red 0..255
gambient green 0..255
bambient blue 0..255
aambient alpha 0..255

returns void

gpu_light_ambient(sh, 10,10,25, 255);
gpu_light_directional(sh, dx, dy, dz, r, g, b, a)

add a directional light to a shader and return its index (rlights CreateLight)

Creates a LIGHT_DIRECTIONAL travelling along (dx,dy,dz): the light sits at the origin with its target along the direction vector.

shshader handle
dxlight direction x
dylight direction y
dzlight direction z
rcolor red 0..255
gcolor green 0..255
bcolor blue 0..255
acolor alpha 0..255

returns 1-based light index; 0 if the shader handle is invalid; hard errors if the 16-light table is full

int sun = gpu_light_directional(sh, -1.0,-0.5,0.0, 255,240,200,255);
gpu_model_shader(mdl, sh)

assign a shader to a model's first material

Sets material[0].shader on the model so it renders with the given shader. No-op if either handle is invalid or the model has no materials.

mdlmodel handle
shshader handle

returns void

gpu_model_shader(cube, sh);
gpu_model_minx(mdl)

read a model's bounding-box minimum x (raylib GetModelBoundingBox)

mdlmodel handle

returns bounding box min.x as float; 0.0 if the handle is invalid

float bminx = gpu_model_minx(cube);
gpu_model_miny(mdl)

read a model's bounding-box minimum y (raylib GetModelBoundingBox)

mdlmodel handle

returns bounding box min.y as float; 0.0 if the handle is invalid

float bminy = gpu_model_miny(cube);
gpu_model_minz(mdl)

read a model's bounding-box minimum z (raylib GetModelBoundingBox)

mdlmodel handle

returns bounding box min.z as float; 0.0 if the handle is invalid

float bminz = gpu_model_minz(cube);
gpu_model_maxx(mdl)

read a model's bounding-box maximum x (raylib GetModelBoundingBox)

mdlmodel handle

returns bounding box max.x as float; 0.0 if the handle is invalid

float bmaxx = gpu_model_maxx(cube);
gpu_model_maxy(mdl)

read a model's bounding-box maximum y (raylib GetModelBoundingBox)

mdlmodel handle

returns bounding box max.y as float; 0.0 if the handle is invalid

float bmaxy = gpu_model_maxy(cube);
gpu_model_maxz(mdl)

read a model's bounding-box maximum z (raylib GetModelBoundingBox)

mdlmodel handle

returns bounding box max.z as float; 0.0 if the handle is invalid

float bmaxz = gpu_model_maxz(cube);
gpu_ray_mouse(cam)

build a pick ray from the mouse position through a camera (raylib GetScreenToWorldRay)

Stores the ray in a round-robin ring of transient slots so per-frame calls never exhaust the table. Returns a 1-based ray handle for use with gpu_ray_x/y/z and the ray-collision tests.

camcamera handle

returns 1-based ray handle; 0 if the window is closed or the camera is invalid

int ray = gpu_ray_mouse(cam);
gpu_collide_ray_sphere(ray, x, y, z, radius)

test whether a ray hits a sphere (raylib GetRayCollisionSphere)

rayray handle (from gpu_ray_mouse)
xsphere center x
ysphere center y
zsphere center z
radiussphere radius

returns 1 if the ray hits the sphere, else 0 (also 0 if the ray handle is invalid)

int rhitS = gpu_collide_ray_sphere(ray, 0.0,0.0,0.0, 1.0);
gpu_collide_ray_box(ray, minx, miny, minz, maxx, maxy, maxz)

test whether a ray hits an axis-aligned box (raylib GetRayCollisionBox)

Box given by its min and max corners.

rayray handle (from gpu_ray_mouse)
minxbox min x
minybox min y
minzbox min z
maxxbox max x
maxybox max y
maxzbox max z

returns 1 if the ray hits the box, else 0 (also 0 if the ray handle is invalid)

int rhitB = gpu_collide_ray_box(ray, -1.0,-1.0,-1.0, 1.0,1.0,1.0);
gpu_camera_end()

end 3D camera mode and go back to screen-space drawing

Runs on both engines (since GAP, 3.5.006.0 -- until then the register VM refused it with CX-E1013). Pairs with the camera-begin call; drawing between them is in world space.

returns void

gpu_camera_end();
gpu_shader_end()

stop drawing through the active shader

Runs on both engines (since GAP, 3.5.006.0 -- until then the register VM refused it with CX-E1013).

returns void

gpu_shader_end();
gpu_frame_time()

how long the last frame took, in seconds

Runs on both engines (since GAP, 3.5.006.0 -- until then the register VM refused it with CX-E1013). The delta to scale movement by so speed is frame-rate independent.

returns the frame time in seconds

x = x + speed * gpu_frame_time();

images & textures 37 builtins

gpu_font_load(path, size)

load a .ttf/.otf font at a base pixel size, returning a font handle

raylib LoadFontEx into a 1-based slot table (32 slots). size<=0 defaults to 20. A full table is a hard CX-E5025 error.

pathfont file path
sizebase glyph size in pixels

returns 1-based font handle, or 0 if no window is open

fnt.i = gpu_font_load("data/fonts/Nunito/static/Nunito-SemiBold.ttf", 34);
gpu_draw_text_font(x, y, font_id, text, color, size)

draw text at (x,y) using a loaded font handle at a pixel size

raylib DrawTextEx with the font from gpu_font_load; falls back to the built-in default font (DrawText) if font_id is invalid. size<=0 uses the font's loaded base size.

xtext top-left x
ytext top-left y
font_idfont handle from gpu_font_load (0/invalid -> default font)
textstring to draw
colortext color 0xRRGGBB
sizepixel size (<=0 -> font base size)

returns void

gpu_draw_text_font(px, py, fntB, "MOUNT EDITOR", colHi, sc(30));
gpu_font_loadmem(blob, ext, size)

load a font from an in-memory embed() blob, returning a font handle

raylib LoadFontFromMemory; `ext` is the format hint (".ttf"/".otf"). Same slot handle as gpu_font_load.

blobembed() blob handle holding the font bytes
extformat hint, e.g. ".ttf"
sizebase glyph size in pixels

returns 1-based font handle, or 0 on failure/no window

int fnt = gpu_font_loadmem(ttf, ".ttf", 24);
gpu_font_livecount()

count currently-loaded font slots

Live-slot introspection for leak/churn tests over the `font` resource type.

returns number of live font slots

int before = gpu_font_livecount() + gpu_model_livecount() + gpu_shader_livecount();
gpu_image_load(path)

load an image file into a GPU texture, returning a texture handle

raylib LoadImage + LoadTextureFromImage into a 1-based slot. Requires an open window. Returns 0 on failure (no window, missing/undecodable file, or table full).

pathimage file path

returns 1-based texture handle, or 0 on failure

img.i = gpu_image_load("data/ships/" + shipFile[0] + ".png");
gpu_image_load_as(path, class_name)

load an image and apply an image-treatment CLASS at load time

The class (see cx_imgclass.h) is a recipe of operations run automatically: pixel-stage fixes (synthesize alpha from brightness, black transparent edges, force opaque, premultiply) then the GL filter (smooth/sharp/mipmaps). Built-in classes: particle, smoke, sprite, texture, background; a data/imageclasses.json section (gpu_image_classes) can add or redefine them.

pathimage file to load (PNG/JPG/etc.)
class_nametreatment class; unknown name -> safe default (smooth)

returns texture handle usable like gpu_image_load; 0 on failure

tex = gpu_image_load_as("fx/puff.png", "particle")
gpu_image_classes(path)

load an image-class override file, redefining/adding treatment recipes

The file is a JSON object of the shape { "imageClasses": { "<name>": ["op", "op", ...], ... } } where each op is one of: smooth, sharp, mipmaps, alphaFromLuma, edgeBlack, opaque, premultiply. Each listed class replaces (or adds) a recipe; classes not mentioned keep their built-in defaults. Unknown op names are reported loudly and skipped (FP4). An absent file or missing "imageClasses" section is a silent no-op, so the built-in defaults simply stand.

pathpath to the JSON override file (e.g. "data/imageclasses.json")

returns nothing

gpu_image_classes("data/imageclasses.json")
gpu_image_loadmem(blob, ext)

load a texture from an in-memory embed() blob, returning a texture handle

raylib LoadImageFromMemory then GPU upload; `ext` is the format hint (".png"/".jpg"). Same handle space as gpu_image_load.

blobembed() blob handle holding the image bytes
extformat hint, e.g. ".png"

returns 1-based texture handle, or 0 on failure

int img = gpu_image_loadmem(png, ".png");
gpu_image_free(handle)

destroy a texture handle now, unloading its GPU texture

Unconditional free (UnloadTexture, or UnloadRenderTexture for render targets); drops the resource bucket then reclaims the slot.

handletexture handle to free

returns void

if (img > 0) { gpu_image_free(img); }
gpu_image_width(handle)

get a texture's width in pixels

handletexture handle

returns texture width, or 0 for an invalid handle

iw.i = gpu_image_width(img);
gpu_image_height(handle)

get a texture's height in pixels

handletexture handle

returns texture height, or 0 for an invalid handle

ih.i = gpu_image_height(img);
gpu_image_cap(n)

set the GPU texture-table capacity before the window opens

Raises the maximum texture slots (default 64), clamped to 16..8192. Must be called before the first image load; a no-op once the table is allocated. The underlying C function returns nothing (the I_I sig is only for binding generation).

ndesired slot capacity (clamped 16-8192)

returns void

gpu_image_cap(384);
gpu_image_new(width, height)

create a blank transparent render-target texture of width x height

raylib LoadRenderTexture, cleared to transparent. Draw into it between gpu_image_begin/gpu_image_end, then use the handle like any texture. Returns 0 if no window or non-positive size.

widthtarget width in pixels
heighttarget height in pixels

returns 1-based texture handle, or 0 on failure

gPlayerComposite = gpu_image_new(COMPW, COMPH);
gpu_image_begin(handle)

redirect subsequent draw calls into a render-target texture

raylib BeginTextureMode on the target handle; must be paired with gpu_image_end. No-op for a non-target handle.

handlerender-target texture handle from gpu_image_new

returns void

gpu_image_begin(comp);
gpu_image_end()

stop drawing into the current render-target texture

raylib EndTextureMode.

returns void

gpu_image_end();
gpu_image_clear(color, alpha)

clear the active render target to a color at an alpha

raylib ClearBackground; call between gpu_image_begin/gpu_image_end (e.g. alpha=0 for a transparent canvas before compositing).

colorclear color 0xRRGGBB
alphaclear alpha 0-255 (0 = transparent)

returns void

gpu_image_clear(rgb(0, 0, 0), 0);
gpu_image_draw(handle, x, y, width, height)

draw a texture at (x,y) scaled to width x height

raylib DrawTexturePro; render-target textures are y-flipped automatically so they present right-side-up.

handletexture handle
xdestination top-left x
ydestination top-left y
widthdestination width
heightdestination height

returns void

gpu_image_draw(img0, sx0 - dw/2, sy0 - dh/2, dw, dh);
gpu_image_draw_region(handle, dx, dy, dw, dh, sx, sy, sw, sh)

draw a sub-rectangle (atlas cell) of a texture, scaled to a destination rect

raylib DrawTexturePro: source cell (sx,sy,sw,sh) mapped onto destination (dx,dy,dw,dh). Render targets are y-flipped automatically.

handletexture handle
dxdestination x
dydestination y
dwdestination width
dhdestination height
sxsource cell x
sysource cell y
swsource cell width
shsource cell height

returns void

gpu_image_draw_region(img, 400, 100, 200, 240, 0, 0, gpu_image_width(img) / 2, gpu_image_height(img));
gpu_image_draw_pro(handle, sx,sy,sw,sh, dx,dy,dw,dh, ox,oy, rotation_deg, col, alpha)

the general 2D image blit (raylib DrawTexturePro verbatim):

SOURCE sub-rect + DEST rect + pivot + clockwise rotation + TINT. The one draw that supersedes region/rotated/region_rot when colour is needed (tinted strands/bursts, a lightning texture drawn at any angle/hue from ONE asset). Native-only (raylib window); float coords match gpu_draw_billboard_pro.

handleimage handle from gpu_image_load
sx,sy,sw,shSOURCE sub-rectangle in texture pixels (atlas cell / frame); sw<=0 or sh<=0 => the whole texture
dx,dy,dw,dhDEST rectangle in screen pixels (position + scale)
ox,oyrotation pivot, in DEST pixels from the dest top-left (rotate-about-centre: dw/2, dh/2)
rotation_degrotation, clockwise degrees, about (ox,oy)
coltint colour, packed 0xRRGGBB (multiplies the texture)
alphaopacity 0..255

returns void

gpu_image_draw_pro(bolt, 0,0,0,0, x,y, 8,len, 4,0, angle, 0xff3030, 200)
imageopen(path)

loads an image file (PNG/JPG/...) from disk into a CPU image in RAM

Calls raylib LoadImage and normalises the pixels to R8G8B8A8; needs no open window. Returns 0 if the file is missing or fails to decode.

pathfilesystem path to the image file to load

returns cx_int image handle (1-based), or 0 on failure

int sheet = imageOpen("art/ModuleFrames.png");
imagenew(w, h, color)

creates a blank CPU image of the given size filled with a colour

Colour is packed 0xAARRGGBB (alpha 0 means opaque, as elsewhere). Returns 0 if w or h is <= 0 or the slot allocation fails.

wimage width in pixels (must be > 0)
himage height in pixels (must be > 0)
colorfill colour, packed 0xAARRGGBB

returns cx_int image handle (1-based), or 0 on invalid size/failure

image h = imageNew(4, 4, 0);
imagecopy(h)

duplicates a CPU image into a new independent image

Wraps raylib ImageCopy. Returns 0 if the source handle is invalid.

hhandle of the source image to duplicate

returns cx_int new image handle (1-based), or 0 on failure

int dup = imageCopy(h);
imagecrop(h, x, y, w, ht)

crops a CPU image in place to a rectangular region

Wraps raylib ImageCrop; mutates the existing image. No-op if the handle is invalid.

hhandle of the image to crop in place
xleft edge of the crop rectangle
ytop edge of the crop rectangle
wcrop rectangle width
htcrop rectangle height

returns void

imageCrop(h, 0, 0, 16, 16);
imagecropcopy(src, x, y, w, ht)

crops a rectangular region of a CPU image into a new image

Wraps raylib ImageFromImage; leaves the source untouched. Returns 0 if the source handle is invalid.

srchandle of the source image
xleft edge of the region to copy
ytop edge of the region to copy
wregion width
htregion height

returns cx_int new image handle (1-based), or 0 on failure

int part = imageCropCopy(h, 0, 0, 16, 16);
imageresize(h, w, ht)

resizes a CPU image in place, resampling its pixels to the new size

Wraps raylib ImageResize (bicubic), so this SCALES the picture rather than cropping or padding it -- the whole image still fills the new rectangle and the aspect ratio is the caller's to preserve. The alpha channel survives. It is the last step of a derivation and mutates the handle you already own; there is deliberately no `imageResizeCopy`, because `imageCopy` composes to give that and a second name for one operation is a forwarder (rule 33).

hhandle of the image to resize in place
wnew width in pixels; must be > 0 or the call is refused
htnew height in pixels; must be > 0 or the call is refused

returns nothing. A bad handle is a no-op, as everywhere in this family. A non-positive dimension is REFUSED rather than obeyed: a zero-width image is a live handle whose every later read is meaningless, which is the silent-wrong this project does not ship (FP4).

int a = imageOpen("plaque.png");
imageResize(a, 424, 128);
imageSave(a, "plaque_web.png");
imageblit(dst, src, sx, sy, sw, sh, dx, dy)

copies a rectangular region from one CPU image onto another

Wraps raylib ImageDraw; the source rect (sx,sy,sw,sh) is drawn to the destination at (dx,dy) at the same size. No-op if either handle is invalid.

dsthandle of the destination image (drawn into)
srchandle of the source image
sxsource region left edge
sysource region top edge
swsource region width
shsource region height
dxdestination x (top-left of the pasted region)
dydestination y (top-left of the pasted region)

returns void

imageBlit(dst, src, 0, 0, 16, 16, 0, 0);
imagecolorkey(h, color)

makes every pixel of a given colour fully transparent

The white-box alpha bake: wraps raylib ImageColorReplace, swapping the keyed colour for transparent (BLANK). No-op if the handle is invalid.

hhandle of the image to key
colorcolour to make transparent, packed 0xAARRGGBB

returns void

imageColorKey(sheet, parseHex("ffffff"));
imagealpha(h, a)

scales the whole image's alpha channel by a/255

Multiplies every pixel's alpha by a/255 in place; a is clamped to 0..255. No-op if the handle is invalid.

hhandle of the image to fade
aalpha scale factor, 0..255 (255 = unchanged)

returns void

imageAlpha(h, 128);
imagerectalpha(h, x, y, w, ht, a)

sets the alpha of a rectangular region to a fixed value

Writes alpha = a to every pixel in the region, which is clipped to the image bounds; a is clamped to 0..255. No-op if the handle is invalid.

hhandle of the image to modify
xregion left edge
yregion top edge
wregion width
htregion height
aalpha value to set, 0..255

returns void

imageRectAlpha(h, 0, 0, 16, 16, 128);
imagesave(h, path)

exports a CPU image to a file, format chosen by the extension

Wraps raylib ExportImage. No-op if the handle is invalid.

hhandle of the image to export
pathoutput file path; extension selects the format (e.g. .png)

returns void

imageSave(sheet, "art/_alpha/ModuleFrames.png");
imagetotexture(h)

uploads a CPU image to the GPU texture registry for drawing

The one bridge from the design-time CPU world to the run-time GPU draw registry (raylib LoadTextureFromImage). Needs an open window/GL context; returns 0 when headless or if the handle is invalid.

hhandle of the CPU image to upload

returns cx_int GPU texture handle, or 0 if headless/invalid

int tex = imageToTexture(h);
imagefree(h)

releases a CPU image and frees its slot immediately

Unconditional destroy that drops the resource bucket (bypassing ARC) then unloads the image; a bare imageFree(h) call still works standalone. No-op if the handle is invalid.

hhandle of the image to release

returns void

imageFree(sheet);
imagedecref(h)

decrements an owned image's ARC refcount, reclaiming the slot at zero

Compiler-emitted at scope exit for an owned `image` local (CXB_RESOURCE bucket ARC); reclaims the slot when the refcount reaches 0. Not normally written by hand.

hhandle of the owned image local being released

returns void

imageDecref(h);
imagew(h)

returns a CPU image's width in pixels

hhandle of the image to measure

returns cx_int width in pixels, or 0 if the handle is invalid

return imagew(h);
imageh(h)

returns a CPU image's height in pixels

hhandle of the image to measure

returns cx_int height in pixels, or 0 if the handle is invalid

int hgt = imageH(h);
imagegetpixel(h, x, y)

reads the packed 0xAARRGGBB colour of a pixel at (x, y)

Reads the R8G8B8A8 pixel and packs it as (a<<24)|(r<<16)|(g<<8)|b. Returns 0 if the handle is invalid or (x,y) is out of range.

hhandle of the image to sample
xpixel x coordinate (0-based)
ypixel y coordinate (0-based)

returns cx_int packed 0xAARRGGBB colour, or 0 if out of range/invalid

int c = imageGetPixel(h, 0, 0);
imagelivecount()

returns the number of currently-open CPU image slots

Always-available leak introspection (not gated by `checks on`); the CPU image pool grows on leak, so a rising count signals leaked handles.

returns cx_int count of live CPU image slots

int before = imagelivecount();

window & input 25 builtins

gpu_screen_close()

close the graphics window

Runs on both engines (since GAP, 3.5.006.0 -- until then the register VM refused it with CX-E1013).

returns void

gpu_screen_close();
gpu_frame_quit()

request that the frame loop stop

ACCEPTED AND DOES NOTHING on both backends. The loop guard is gpu_frame_poll, which already answers 0 once the window is closed -- so there is nothing for this to set. Listed so you know it is a no-op rather than assuming it ends your loop.

returns void

while (gpu_frame_poll()) { ... }
gpu_target_fps(fps)

set how the frame loop is PACED

Replaces the hardcoded 60 fps that every CX program shipped with; the default is still 60, so a program that never calls this paces exactly as before. Callable BEFORE or AFTER the screen is open. Before: the choice is stored and applied at open (vsync must be a config flag before InitWindow, so this is the only way to get vsync from the first frame). After: applied immediately, so a settings screen can change pacing live without reopening the window.

fps> 0 cap the frame rate at this many frames per second. == 0 pace to the DISPLAY instead (vsync, FLAG_VSYNC_HINT). This is the tear-free mode: the refresh rate, whatever it is, sets the rhythm, and the cap is lifted. < 0 a loud CX-E5035 and the run STOPS. There is deliberately NO uncapped mode: the main loop's `deltaMs` floors at 1 ms, so sub-millisecond frames would silently run the simulation up to ~40% fast -- an uncapped mode must arrive WITH a microsecond delta accumulator, not before it.

returns nothing.

gpu_target_fps(144);   // cap at 144
gpu_target_fps(0);     // follow the display
gpu_frame_poll()

test whether the graphics window is still open

Runs on both engines (since GAP, 3.5.006.0 -- until then the register VM refused it with CX-E1013). The canonical main-loop guard -- `while (gpu_frame_poll()) { ... }` -- so the loop exits cleanly on the close button or Alt+F4. It is the INVERSE of raylib's should-close, not an event pump; the frame begin/end pair does the pumping. Variadic and argument-tolerant for beta-era 1-argument calls.

returns non-zero while the window is open and no close has been requested; 0 once it has

while (gpu_frame_poll()) { gpu_frame_begin(); ... gpu_frame_end(); }
gpu_mouse_x()

the mouse pointer's x position in the window

Runs on both engines (since GAP, 3.5.006.0 -- until then the register VM refused it with CX-E1013).

returns x in pixels

mx = gpu_mouse_x();
gpu_mouse_y()

the mouse pointer's y position in the window

Runs on both engines (since GAP, 3.5.006.0 -- until then the register VM refused it with CX-E1013).

returns y in pixels

my = gpu_mouse_y();
gpu_mouse_wheel()

how far the wheel moved since the last frame

Runs on both engines (since GAP, 3.5.006.0 -- until then the register VM refused it with CX-E1013).

returns the wheel delta; 0 if it did not move

dz = gpu_mouse_wheel();
gpu_mouse_button(btn)

return 1 while a mouse button is held down

raylib IsMouseButtonDown. Button codes: 0=left, 1=right, 2=middle; other values return 0.

btnbutton index 0=left 1=right 2=middle

returns 1 while held, else 0

down.i = gpu_mouse_button(0);
gpu_capture()

take the window's current contents as an image

Returns an ordinary image handle, so every `image*` verb works on it: save it with imageSave, measure it with imageW/imageH, trim it with imageCrop, release it with imageFree. It captures the window THIS program owns -- not another program's window and not the desktop, neither of which has a portable answer. Call it inside the frame, between gpu_frame_begin and gpu_frame_end, so it reads what you have just drawn rather than whatever survived the buffer swap. THE IMAGE IS IN REAL PIXELS, WHICH MAY NOT BE YOUR WINDOW'S NUMBERS. On a high-DPI display the framebuffer is larger than the size you asked for -- a 320x240 window captures 640x480 on a Retina Mac, and everything you drew is at the same scale inside it. Ask the image (imageW/imageH), not the window (gpu_screen_width), when the numbers have to match the picture. Unlike its neighbours here, this REFUSES (CX-E5048) when no window is open instead of answering 0. Zero is an honest width for an unopened screen; it is not an honest picture, and a caller's next move is to save the handle and believe it.

returns an image handle, ready for any image verb

while (gpu_frame_begin(rgb(20,20,30))) { drawWorld(); shot = gpu_capture(); imageSave(shot, "frame.png"); imageFree(shot); gpu_frame_end(); }
gpu_key_down(key)

return 1 while a key (raylib keycode) is held down

raylib IsKeyDown.

keyraylib keycode (e.g. 65 = A, 32 = space)

returns 1 while held, else 0

if (gpu_key_down(65) == 1) { marc[sel] = marc[sel] - 2; }
gpu_blend_add()

switch to additive blending for subsequent draws

Runs on both engines (since GAP, 3.5.006.0 -- until then the register VM refused it with CX-E1013). What makes glows and particle flares brighten what is under them.

returns void

gpu_blend_add();
gpu_blend_normal()

switch back to normal alpha blending

Runs on both engines (since GAP, 3.5.006.0 -- until then the register VM refused it with CX-E1013).

returns void

gpu_blend_normal();
drawimageregionrot(handle, cx, cy, w, h, sx, sy, sw, sh, deg)

draw one atlas cell centred, scaled and rotated

Runs on both engines (since GAP, 3.5.006.0 -- until then the register VM refused it with CX-E1013). The source rectangle picks the cell out of a sprite atlas; (cx, cy) is the drawn CENTRE.

handlethe atlas image
cxcentre x
cycentre y
wdrawn width
hdrawn height
sxsource x in the atlas
sysource y in the atlas
swsource width
shsource height
degclockwise rotation in degrees

returns void

drawImageRegionRot(atlas, 400, 300, 64, 64, 0, 0, 32, 32, 90);
drawimagerot(handle, cx, cy, w, h, deg)

draw an image centred, scaled and rotated

Runs on both engines (since GAP, 3.5.006.0 -- until then the register VM refused it with CX-E1013). (cx, cy) is the CENTRE, and the rotation pivots about that centre. Degrees, clockwise.

handlethe image
cxcentre x
cycentre y
wdrawn width
hdrawn height
degclockwise rotation in degrees

returns void

drawImageRot(spr, 400, 300, 64, 64, 45);
eventquit(…)

signal that the event loop should stop

Callable, but its argument list is not derivable from its binding: it is bound only by the register VM's name table, where the C signature is the generic (vm, N) wrapper form. Named here so an absence is never mistaken for non-existence.

returns void

eventQuit();
fontload(name, size)

load a font for later use

ACCEPTED AND DOES NOTHING: there is no font registry yet -- raylib's built-in font is what draws. Kept so beta-era source compiles, and listed so you know it is a no-op rather than assuming a font was loaded. Pass 0 as the font id wherever one is wanted.

namefont file name
sizepoint size

returns 0, always -- the default font id

f = fontLoad("arial.ttf", 20);
fontset(handle)

select a loaded font for subsequent text

ACCEPTED AND DOES NOTHING: there is no font registry yet -- raylib's built-in font is what draws. Kept so beta-era source compiles, and listed so you know it is a no-op rather than assuming a font was loaded. Pass 0 as the font id wherever one is wanted.

handleignored

returns void

fontSet(f);
gpu_draw_text(x, y, text, colour, size)

draw a string at a screen position

Runs on both engines (since GAP, 3.5.006.0 -- until then the register VM refused it with CX-E1013). Two forms: four arguments use the default 20-pixel size, five pick the size.

xleft edge in pixels
ytop edge in pixels
textthe string to draw
colourpacked 0xRRGGBB colour
sizepixel height (optional; default 20)

returns void

gpu_draw_text(10, 10, "score", 0xFFFFFF, 32);
gpu_image_draw_tint(handle, cx, cy, w, h, colour, alpha)

draw an image centred, scaled, and multiplied by a colour

Runs on both engines (since GAP, 3.5.006.0 -- until then the register VM refused it with CX-E1013). The position is the image's CENTRE, not its top-left. Reuses the particle tint blit, so one cached white sprite can be drawn in any colour with true alpha.

handlethe image
cxcentre x
cycentre y
wdrawn width
hdrawn height
colourpacked 0xRRGGBB multiplier
alpha0..255 opacity

returns void

gpu_image_draw_tint(spr, 400, 300, 64, 64, 0xFF8080, 200);
gpu_key_pressed()

the key pressed this frame

Runs on both engines (since GAP, 3.5.006.0 -- until then the register VM refused it with CX-E1013). Reports one key per frame; 0 when nothing was pressed.

returns the key code, or 0

k = gpu_key_pressed();
gpu_screen_flip()

swap the front and back buffers

ACCEPTED AND DOES NOTHING: raylib swaps buffers itself when the frame ends, so there is nothing left for this to do. Kept because beta-era source calls it, and listed here so you know it is a no-op rather than assuming it is what makes your drawing appear -- that is gpu_frame_end.

returns void

gpu_frame_end();
gpu_screen_resize(w, h)

resize the graphics window

Reopens the screen at the new size with an empty title. Read the live dimensions back with the screen-width and screen-height builtins rather than assuming the request took effect.

wnew width in pixels
hnew height in pixels

returns void

gpu_screen_resize(1920, 1080);
gpu_text_width(font, text, size)

measure how wide a string will be drawn

Runs on both engines (since GAP, 3.5.006.0 -- until then the register VM refused it with CX-E1013). Two forms: one argument measures with the default font at size 20; three take a registered font id, the text and an explicit size.

fontregistered font id (3-argument form)
textthe string to measure
sizepixel height (3-argument form)

returns the width in pixels

w = gpu_text_width("score");
screenflip(…)

swap the front and back buffers

Callable, but its argument list is not derivable from its binding: it is bound only by the register VM's name table, where the C signature is the generic (vm, N) wrapper form. Named here so an absence is never mistaken for non-existence.

ACCEPTED AND DOES NOTHING, for the same reason as gpu_screen_flip: the frame end swaps buffers.

returns void

gpu_frame_end();
textwidth(text)

measure how wide a string will be drawn

The same measurement as gpu_text_width on native. ON THE REGISTER VM there is no window to measure against, so it falls back to a flat EIGHT PIXELS PER CHARACTER -- useful for laying out headless, but not a real measurement.

textthe string to measure

returns the width in pixels

w = textWidth("score");

2D drawing & effects 18 builtins

gpu_screen_clear(color)

clear the whole screen to a color

Wraps raylib ClearBackground; tolerates being called outside a BeginDrawing/EndDrawing frame by opening a transient one.

colorfill color as 0xRRGGBB

returns void

gpu_screen_clear(0);
gpu_draw_rect_alpha(x, y, width, height, color, alpha)

draw a filled rectangle with an explicit alpha

Like gpu_draw_rect but overrides the color's alpha with `alpha` (0-255, clamped).

xtop-left x
ytop-left y
widthrectangle width
heightrectangle height
colorfill color 0xRRGGBB
alphaopacity 0-255

returns void

gpu_draw_rect_alpha(10, 60, 200, 15, rgb(100, 80, 60), 128);
gpu_draw_line(x1, y1, x2, y2, color)

draw a line from (x1,y1) to (x2,y2)

raylib DrawLine.

x1start x
y1start y
x2end x
y2end y
colorline color 0xRRGGBB

returns void

gpu_draw_line(10, 110, 200, 110, rgb(100, 80, 60));
gpu_draw_circle(x, y, radius, color)

draw a filled circle centred at (x,y) with a radius

raylib DrawCircle.

xcentre x
ycentre y
radiuscircle radius
colorfill color 0xRRGGBB

returns void

gpu_draw_circle(msx, msy, r2, colMnt);
gpu_draw_circle_alpha(x, y, radius, color, alpha)

draw a filled circle with an explicit alpha

Like gpu_draw_circle but overrides alpha (0-255); used for soft translucent particles.

xcentre x
ycentre y
radiuscircle radius
colorfill color 0xRRGGBB
alphaopacity 0-255

returns void

gpu_draw_circle_alpha(485, 140, 45, rgb(255, 130, 80), 150);
gpu_draw_pixel(x, y, color)

draw a single pixel at (x,y)

raylib DrawPixel.

xpixel x
ypixel y
colorpixel color 0xRRGGBB

returns void

gpu_draw_pixel(560, 60, rgb(255, 255, 255));
gpu_draw_triangle(x1, y1, x2, y2, x3, y3, color)

draw a filled triangle through three points

raylib DrawTriangle, drawn in both windings so it is order-independent (no back-face culling gap).

x1first vertex x
y1first vertex y
x2second vertex x
y2second vertex y
x3third vertex x
y3third vertex y
colorfill color 0xRRGGBB

returns void

gpu_draw_triangle(340, 330, 430, 330, 385, 255, rgb(210, 210, 70));
gpu_draw_roundrect(x, y, w, h, roundness, col)

filled rounded rectangle (raylib DrawRectangleRounded)

Corner smoothness is a fixed internal default of 8 segments/corner (not a parameter -- no consumer needs it; a one-line signature extension if one ever does). Both engines (GAP, 3.5.006.0).

xleft edge, pixels
ytop edge, pixels
wwidth, pixels
hheight, pixels
roundnesscorner radius as raylib's 0..1 fraction of the SHORT side, inherited VERBATIM (0 = square corners, 1 = fully rounded / capsule). raylib clamps out-of-range values to [0,1] itself.
col0xRRGGBB colour, drawn opaque.

returns void

gpu_draw_roundrect(x, y, 200, 80, 0.3, 0x2244aa)
gpu_draw_roundrect_alpha(x,y,w,h, roundness, col, alpha)

filled rounded rectangle with an explicit alpha

Twin of gpu_draw_roundrect (family idiom, cf. gpu_draw_rect / gpu_draw_rect_alpha). 8 segments/corner. Both engines.

x,y,w,hrectangle, pixels
roundness0..1 fraction of the short side (raylib semantics, verbatim)
col0xRRGGBB colour
alpha0..255 opacity (0 = transparent, 255 = opaque)

returns void

gpu_draw_roundrect_alpha(x, y, 200, 80, 0.3, 0x2244aa, 128)
gpu_draw_roundrect_lines(x,y,w,h, roundness, thick, col)

rounded-rectangle OUTLINE (raylib DrawRectangleRoundedLinesEx)

8 segments/corner. Both engines.

x,y,w,hrectangle, pixels
roundness0..1 fraction of the short side (raylib semantics, verbatim)
thickoutline thickness, pixels
col0xRRGGBB colour, drawn opaque

returns void

gpu_draw_roundrect_lines(x, y, 200, 80, 0.3, 2.0, 0x8fb2ff)
gpu_draw_roundrect_lines_alpha(x,y,w,h, roundness, thick, col, alpha)

rounded-rectangle outline with an explicit alpha

Twin of gpu_draw_roundrect_lines. The Gigantica shield HUD box (rounded outline + intensity) is the named consumer. 8 segments/corner. Both engines.

x,y,w,hrectangle, pixels
roundness0..1 fraction of the short side (raylib semantics, verbatim)
thickoutline thickness, pixels
col0xRRGGBB colour
alpha0..255 opacity

returns void

gpu_draw_roundrect_lines_alpha(x, y, 200, 80, 0.3, 2.0, 0x8fb2ff, 180)
gpu_screen_open(w, h, title)

open the graphics window at w x h and start its world

Idempotent: a second call while a window is open does nothing. w or h <= 0 falls back to 800 x 600. The window is RESIZABLE on the desktop, so the size you ask for is where it STARTS -- read gpu_screen_width()/gpu_screen_height() if your layout must follow it, and gpu_window_resized() to know when it moved. ON THE WEB (a wasm build in a browser tab) whether this size is honoured is decided by your own program text, not by a flag: a program that references gpu_screen_width() or gpu_screen_height() ANYWHERE has said it can lay out against a size it did not pick, so its canvas fills the browser window and this w x h is only where it starts; a program that never asks has hardcoded its world, so its canvas is exactly w x h and the rest of the page is left alone. Desktop behaviour is identical either way.

wwindow width in pixels (<= 0 -> 800)
hwindow height in pixels (<= 0 -> 600)
titlewindow title text
gpu_screen_open(1280, 720, "My Game")
gpu_draw_rect(x, y, width, height, color)

draw a filled rectangle at (x,y) sized width x height

raylib DrawRectangle.

xtop-left x
ytop-left y
widthrectangle width
heightrectangle height
colorfill color 0xRRGGBB

returns void

gpu_draw_rect(10, 10, 100, 100, rgb(50,50,50));
gpu_frame_begin(clear_color)

open a frame, clear it to color, and say whether to keep going

Wraps raylib BeginDrawing + ClearBackground and returns 0 when the user has asked to close, so the idiom is `while (gpu_frame_begin(color)) { ... }`. Also fires the per-frame automation + GUI hooks. WHAT COUNTS AS "asked to close": on the desktop, the window's close button or the ESC key (raylib's default exit key). IN A BROWSER TAB (a wasm build) there is no close button, and raylib's web platform never reports one -- so ESC is the only exit, and CX checks it there itself so that the same `while` loop ends on the same key it ends on natively. A program whose only exit is the close button therefore never ends on the web; give it a frame cap or an ESC.

clear_colorbackground color to clear this frame to (rgb()/0xAARRGGBB)

returns 1 to draw this frame, 0 once the window should close

while (gpu_frame_begin(rgb(20,20,30))) { ...draw...; gpu_frame_end(); }
gpu_frame_end()

finish the frame and present it

Runs on both engines (since GAP, 3.5.006.0 -- until then the register VM refused it with CX-E1013). This is what makes drawing appear; it also swaps the buffers, which is why the flip calls are no-ops.

returns void

gpu_frame_end();
mediadraw(scene)

draw a scene document, once

Walks the scene's elements in document order and draws each one the renderer knows, reading its place, its size and its look from the element's own fields. The program never names a drawing verb: it writes fields on the document, and this walks them. THE SEVENTEEN TYPES IT DRAWS. Flat: `rect` (plain, rounded or outlined), `circle`, `text`, `line`, `triangle`, `pixel`, `image`. In the world, inside the camera: `cube`, `sphere`, `cylinder`, `plane`, `line3d`, `point3d`, `grid`, `model`, `billboard`, `emitter3d`. And `emitter`, which is flat. `window` and `camera` are ACTED ON rather than drawn. Ask the running renderer with `mediaReport()["types"]` rather than keeping a copy of this list. WHERE AN ELEMENT IS. `->x` `->y` `->w` `->h` are percent of the window, PER AXIS -- x and w of its width, y and h of its height -- unless `->units` is `"absolute"`. `->pos` `->dim` `->target` `->axis` are world coordinates and are NEVER percent: a world has no edges to be a percentage of. `->points` holds the corners of a shape whose place is not one x,y (a line reads four numbers, a triangle six). `->z` is what covers what -- an ordering, not a distance -- and does not yet order the draw; that is the renderer's remaining debt. HOW IT LOOKS. `->color`, `->alfa1` (opacity 0-100), `->tint`, `->round` (corner roundness 0-1), `->outline` (thickness in pixels; absent or 0 means FILLED, and it is what makes every `*_wires` and `*_lines` variant one shape), `->size` (a text's height in pixels), `->hidden`. WHAT IT SHOWS. `->source` is a filename, the bytes, or PARSEDOC's own image document; `->frame` is the part of the source drawn, in the SOURCE's own pixels; `->pivot` is what a rotation turns about, in percent of the element's own box; `->rot` is that rotation; `->scale` sizes a model or a billboard. `->orgw` and `->orgh` are written by the renderer when the source loads. BEHAVIOUR AND EVENTS. `->rules` is CX source as TEXT, compiled at run time, with the element bound as `e`; `->tick` asks for it every frame rather than only when the element changed. `->hover` and `->clicked` are written by the renderer -- `->clicked` is the frame a press BEGAN inside the element, and it is cleared once the rules have seen it. THE WINDOW ELEMENT carries `->fullscreen`, `->maximized`, `->resized`, `->load` (`"lazy"` or `"full"`, the scene's default), and the input the renderer sampled: `->mouse` (percent), `->buttons`, `->wheel`, `->key`, `->char`. THE CAMERA carries `->pos`, `->target` and `->fov`. AN EMITTER carries `->source` (the effects file), `->effect`, `->on`, `->burst`, `->dir` and `->vel`. THE DRAW IS GATED ON `->changed`, through a cached render target priced before it was relied on: at one changed frame in a hundred it costs 12 ms where an immediate loop costs 507, and at every-frame change it is still 13% cheaper. The renderer CLEARS the flag as it acts, because "only the changed items are acted on" is only true if acting is what ends the change. A change walks `->parent` and `->next` in both directions, and the flag is its own visited mark, so a cycle stops on its second arrival. A LIVE EMITTER MAKES EVERY FRAME STALE, and that is correctness rather than caution: its particles move inside the particle system, so nothing in the document changes while the picture does.

scenea json document whose members are elements (parseDoc reads it, and a document declared `media` is the same document)

returns how many elements it drew; -1 when the program never declared a media anywhere, so no renderer was linked

media scene = parseDoc(text); mediaDraw(scene);
n.i = mediaDraw(scene);
mediarun(scene, maxFrames)

hand the window to the renderer and get it back when it closes

THE FRAME IS THE RENDERER'S, which is what "the renderer is invisible" means in practice: a program opens a window, hands over its scene, and writes no drawing verb at all -- not even a thin one. gpu_screen_open(640, 480, "title"); mediaRun(scene, 0); gpu_screen_close(); The frame clears to black and a scene that wants a background says so with an element, not with an argument -- one fewer parameter, and the background stays IN the document with every other visual fact. `mediaDraw` is the STEP this is the LOOP over, and they are two operations rather than two names for one: a check cannot open a window, so the step is what a check drives. IT OWNS THE WHOLE FRAME, which is more than the drawing. Each pass samples the pointer and the keyboard onto the scene, runs the rules, walks the change, updates any particle system the scene is running, draws the world inside the camera and the flat elements on top of it, and spends the flags it acted on. A program that hands over its scene writes none of that.

scenethe scene document
maxFramesstop after this many frames; 0 or less means "until the window closes", which is the ordinary case

returns how many frames it ran -- 0 with no window, which is the honest answer rather than a hang

mediaRun(scene, 0);
n.i = mediaRun(scene, 120);
mediareport()

what the last walk saw, as a document

One handle rather than a builtin per number: `drawn` how many were drawn, `hidden` how many carried `->hidden`, `changed` how many carried `->changed` once the links had been walked, `unknown` how many drew nothing -- a `->type` the renderer does not know, or one it knows whose source would not load -- `walked` how many elements the propagation reached, `ruled` how many rules ran, `acted` how many elements were acted on rather than drawn (the window, the camera, an emitter), `stale` whether anything changed this frame, `redrew` whether the frame rebuilt the cached target or blitted the one it already had, and `types` the list of `->type` names this renderer draws. `types` IS THE FACE SAYING WHAT IT DRAWS, and it exists so a reader -- a check, a second provider, a person -- can ASK rather than keep a copy. A fixture that names every type the renderer claims cannot fall behind it; one written by hand falls behind the day a family lands. `walked`, `stale` AND `redrew` ARE WHAT MAKE THE RENDERER CHECKABLE. A propagation that terminates and a draw that was correctly skipped both look precisely like nothing happening, and "nothing happening" is not a measurement. `stale` AND `redrew` ARE TWO FACTS, not one spelled twice. `stale` is the question the gate asks -- did anything change? -- and has the same answer with a window and without one. `redrew` is what that answer CAUSED, and is only meaningful where a cached target exists, which means a window. A check that cannot open one reads `stale`. It exists so a scene can be checked WITHOUT a window and without reading pixels: the renderer says what it did instead of leaving a caller to infer it (FP4).

returns a json document; an invalid one when no renderer was linked

json r = mediaReport(); println(str(r["drawn"]));
json t = mediaReport()["types"];   // what this renderer draws
if (r["unknown"]) { println("the scene names a type the renderer does not know"); }
json r = mediaReport(); if (r["redrew"]) { println("the target was rebuilt"); }

particles 11 builtins

gpu_pfx_load(filename)

loads particle-effect templates from a JSON file and resets the particle pool

Parses each effect under the JSON "effects" object into a named template; the "maxParticles" field sets the pool's hard cap. Reloading discards existing templates and live particles.

filenamepath to the effects JSON file

returns void

gpu_pfx_load("Orfeus/data/effects.json");
gpu_pfx_spawn(name, x, y, angle_deg, parent_vx, parent_vy)

spawns a burst of particles from a named template at a position

Emits the template's count particles, each with randomized angle, velocity and lifetime; the parent velocity is added scaled by the template's velocityScale. No-op if the name is unknown.

nameeffect/template name to spawn
xspawn x position in pixels
yspawn y position in pixels
angle_degemission direction in degrees
parent_vxemitter x-velocity, added to each particle (scaled by velocityScale)
parent_vyemitter y-velocity, added to each particle (scaled by velocityScale)

returns void

gpu_pfx_spawn("sparks", 400.0, 300.0, 0.0, 0.0, 0.0);
gpu_pfx_spawn3d(name, x, y, z, dx, dy, dz, pvx, pvy, pvz)

emit a WORLD-space (3D) particle burst from a template

Spawns the template's `count` particles at world (x,y,z) with velocity biased along the direction (dx,dy,dz); the template's `angleSpread` is the emission cone half-angle. A near-zero direction bursts omnidirectionally (explosion). Each particle also inherits the emitter's velocity (pvx,pvy,pvz) scaled by the template's `velocityScale` -- so a continuously-emitted plume STREAMS with the moving ship (attached) instead of being left behind as a world-static jet. Pass 0,0,0 for a stationary burst (impacts / explosions). Requires the template's `space` to be `"world"` and a `texture`. Render with gpu_pfx_render3d.

nameeffect template name (from the loaded effects.json)
xworld X of the emitter
yworld Y of the emitter
zworld Z of the emitter
dxemission direction X (0,0,0 = omnidirectional)
dyemission direction Y
dzemission direction Z
pvxemitter velocity X to inherit (same units as particle motion; 0 = none)
pvyemitter velocity Y to inherit
pvzemitter velocity Z to inherit
gpu_pfx_spawn3d("engine", ex,ey,ez, -fx,-fy,-fz, svx,svy,svz);
gpu_pfx_update(delta_ms)

advances every live particle by a time delta, ageing and moving them

Applies gravity, integrates position by velocity, and kills particles whose age reaches their lifetime.

delta_msmilliseconds elapsed since the last update

returns void

gpu_pfx_update(16);
gpu_pfx_render()

draws all live particles via the gfx layer, interpolating scale, colour and alpha by age

Renders each particle as a circle, rect, pixel or tinted image per its template, blending colour along the template's ramp and fading alpha over life.

returns void

gpu_pfx_render();
gpu_pfx_render3d(cam, ox, oy, oz, scale)

draw all WORLD-space particles as camera-facing billboards

Call INSIDE the active 3D camera pass (between gpu_camera_begin/…_end). Each world template is drawn in ONE internal batch. Screen-space templates are untouched -- render those with gpu_pfx_render (the 2D overlay) as before. Particles live in an application-defined ABSOLUTE space; each is mapped to render space as (pos - origin)*scale (the floating-origin transform). With no floating origin, pass origin (0,0,0) and scale 1. `size` stays in render units.

camthe 3D camera handle (from gpu_camera_new)
oxcurrent render-origin X in the particles' space (0 if none)
oycurrent render-origin Y
ozcurrent render-origin Z
scaleparticle-space -> render-unit scale (1 if none)
gpu_pfx_render3d(cam, gOriginX, gOriginY, gOriginZ, WORLD_SCALE);
gpu_pfx_set(name, key, val)

live-tunes a named field of a particle template

Sets one of a fixed set of tunable fields (count, size, speed, randomVelocity, lifetime, randomLifetime, emitW, emitH, angleSpread, scaleStart, scaleEnd); int fields take the value cast to int. No-op if the template or field is unknown.

nametemplate name to modify
keyfield name to set
valnew value (cast to int for integer fields)

returns void

gpu_pfx_set(efx[curE], pField[idx], av);
gpu_pfx_get(name, key)

reads a named field of a particle template

Returns one of the same tunable fields gpu_pfx_set accepts, as a float.

nametemplate name to query
keyfield name to read

returns float -- the field's current value (0.0 if the template or field name is unknown)

av = gpu_pfx_get(efx[curE], pField[i]);
gpu_pfx_count()

returns the number of loaded particle-effect templates

returns int -- count of loaded templates

nfx.i = gpu_pfx_count();
gpu_pfx_name(i)

returns the name of the i-th loaded particle-effect template

Lets a tuner enumerate effects straight from the loaded JSON by index.

izero-based template index

returns string -- the template's name (empty string if i is out of range)

efx[fi] = gpu_pfx_name(fi);
gpu_pfx_clear()

kills all live particles, keeping the loaded templates

Zeroes the particle pool and resets the recycle cursor; templates remain loaded.

returns void

gpu_pfx_clear();

3D 3 builtins

gpu_collide_spheres(x1, y1, z1, r1, x2, y2, z2, r2)

test whether two spheres intersect (raylib CheckCollisionSpheres)

x1sphere 1 center x
y1sphere 1 center y
z1sphere 1 center z
r1sphere 1 radius
x2sphere 2 center x
y2sphere 2 center y
z2sphere 2 center z
r2sphere 2 radius

returns 1 if the spheres intersect, else 0

gpu_collide_spheres(0.0,0.0,0.0, 2.0, 1.0,0.0,0.0, 2.0)
gpu_collide_boxes(min1x, min1y, min1z, max1x, max1y, max1z, min2x, min2y, min2z, max2x, max2y, max2z)

test whether two axis-aligned bounding boxes intersect (raylib CheckCollisionBoxes)

Each box is given by its min and max corner.

min1xbox 1 min x
min1ybox 1 min y
min1zbox 1 min z
max1xbox 1 max x
max1ybox 1 max y
max1zbox 1 max z
min2xbox 2 min x
min2ybox 2 min y
min2zbox 2 min z
max2xbox 2 max x
max2ybox 2 max y
max2zbox 2 max z

returns 1 if the boxes intersect, else 0

gpu_collide_boxes(0.0,0.0,0.0, 2.0,2.0,2.0, 1.0,1.0,1.0, 3.0,3.0,3.0)
gpu_collide_box_sphere(minx, miny, minz, maxx, maxy, maxz, cx_, cy_, cz_, radius)

test whether a box and a sphere intersect (raylib CheckCollisionBoxSphere)

Box given by min/max corners; sphere by center and radius.

minxbox min x
minybox min y
minzbox min z
maxxbox max x
maxybox max y
maxzbox max z
cx_sphere center x
cy_sphere center y
cz_sphere center z
radiussphere radius

returns 1 if the box and sphere intersect, else 0

gpu_collide_box_sphere(0.0,0.0,0.0, 2.0,2.0,2.0, 1.0,1.0,1.0, 0.5)

color 2 builtins

rgb(r, g, b)

pack red, green, blue bytes into a 24-bit color integer

Computes ((r&0xFF)<<16)|((g&0xFF)<<8)|(b&0xFF); each channel is masked to its low 8 bits.

rred channel (0-255; masked to 8 bits)
ggreen channel (0-255; masked to 8 bits)
bblue channel (0-255; masked to 8 bits)

returns packed 24-bit color integer in 0x00RRGGBB layout

colBg.i = rgb(20, 20, 40);
rgba(r, g, b, a)

pack red, green, blue, alpha bytes into a 32-bit color integer

Computes ((a&0xFF)<<24)|((r&0xFF)<<16)|((g&0xFF)<<8)|(b&0xFF); each channel is masked to its low 8 bits.

rred channel (0-255; masked to 8 bits)
ggreen channel (0-255; masked to 8 bits)
bblue channel (0-255; masked to 8 bits)
aalpha channel (0-255; masked to 8 bits)

returns packed 32-bit color integer in 0xAARRGGBB layout

c4.i = rgba(255, 0, 0, 128);

Text processing 2 families · 49 builtins

strings 45 builtins

strtoi(s)

parse the leading base-10 integer out of a string

Forwards to bi_vali, which is strtoll(s, NULL, 10); takes only the leading numeric prefix and yields 0 when there are no leading digits.

sstring to parse

returns the parsed 64-bit integer (base 10); 0 if the string has no leading numeric prefix

assertEqual(42, strtoi("42"));
strint(s)

parse the leading base-10 integer out of a string

Exact alias of strtoi (same cx_stub_strtoi/bi_vali/strtoll base-10 parse); the FP1 unification resolved a prior backend divergence where risc treated strint as int->string.

sstring to parse

returns the parsed 64-bit integer (base 10); 0 if the string has no leading numeric prefix

assertEqual(12345, strint("12345"));
strtof(s)

parse the leading floating-point number out of a string

Forwards to cx_stub_strtof, which is strtod(s, NULL); returns 0.0 for an empty string.

sstring to parse

returns the parsed float (via strtod); 0.0 if the string is empty

f = strtof("3.14");
strfloat(s)

parse the leading floating-point number out of a string

Exact alias of strtof (same cx_stub_strtof/strtod parse).

sstring to parse

returns the parsed float (via strtod); 0.0 if the string is empty

assertFloatEqual(3.14, strfloat("3.14"), 0.01);
trim(s)

strip whitespace from both ends

Whitespace is C's isspace(): space, tab, newline, carriage return, vertical tab, form feed. Interior whitespace is untouched -- `trim(" a b ")` is "a b", not "ab".

sstring to trim

returns a new string with leading and trailing whitespace removed

name.s = trim(fread("name.txt"));
ltrim(s)

strip whitespace from the LEFT end only

sstring to trim

returns a new string with leading whitespace removed; trailing whitespace stays

body.s = ltrim(line);
rtrim(s)

strip whitespace from the RIGHT end only

The one to reach for when stripping a line ending: it removes "\r\n" as whitespace, so a Windows-terminated line and a Unix one come out the same.

sstring to trim

returns a new string with trailing whitespace removed; leading whitespace stays

line.s = rtrim(raw);
reversestring(s)

reverse a string's BYTES

Bytes, not characters: reversing UTF-8 text that has any multi-byte character in it produces invalid UTF-8. Safe for ASCII, wrong for anything else.

sstring to reverse

returns a new string with the bytes in the opposite order

r.s = reversestring(word);
hex(n)

an integer as UPPERCASE hexadecimal digits

No "0x" prefix and no padding, so 255 gives "FF". A negative number is rendered as its 64-bit two's-complement pattern -- `hex(-1)` is the 16 characters "FFFFFFFFFFFFFFFF", not "-1". Verified on both backends.

ninteger to render

returns the hex digits; "0" for zero

println("mask=" + hex(flags));
bin(n)

an integer as binary digits

Leading zeros are trimmed, so 5 gives "101" and 0 gives "0". A negative number is rendered as its 64-bit two's-complement pattern -- `bin(-1)` is 64 '1' characters, not "-1". Verified on both backends.

ninteger to render

returns the binary digits; "0" for zero

println(bin(mask));
space(n)

a string of n spaces

nhow many spaces

returns the string, or "" if n <= 0

println(space(indent) + label);
left(s, n)

the first n bytes of a string

Clamps rather than failing: asking for more than there is gives the whole string back, and a zero or negative count gives "". The original is never modified -- every builtin in this family returns a NEW string.

ssource string
nhow many bytes to take from the start

returns the leading n bytes, the whole string if n exceeds its length, or "" if n <= 0

code.s = left(line, 3);
right(s, n)

the last n bytes of a string

The mirror of left(), with the same clamping: over-long n gives the whole string, n <= 0 gives "".

ssource string
nhow many bytes to take from the end

returns the trailing n bytes, the whole string if n exceeds its length, or "" if n <= 0

ext.s = right(path, 4);
lset(s, width)

left-justify a string in a fixed-width field

Pads on the RIGHT with spaces to reach `width`. A string already at or over the width is TRUNCATED to it, keeping the left -- so the result is always exactly `width` bytes, which is what makes it usable for column output.

sstring to place
widthfield width in bytes

returns a string of exactly `width` bytes, or "" if width <= 0

println(lset(name, 20) + str(score));
rset(s, width)

right-justify a string in a fixed-width field

Pads on the LEFT with spaces to reach `width`. Over-long input is truncated to the width keeping the LEFT-hand bytes -- the same truncation as lset(), which is worth knowing because right-justified numbers lose their least significant digits, not their most.

sstring to place
widthfield width in bytes

returns a string of exactly `width` bytes, or "" if width <= 0

println(rset(str(n), 8));
countstring(hay, needle)

how many times a substring occurs

Matches are NON-OVERLAPPING: counting "aa" in "aaaa" gives 2, not 3, because the scan resumes after each hit. Same rule replacestring() uses, so the two always agree on how many replacements will happen.

haystring to search
needlesubstring to count

returns the number of non-overlapping occurrences; 0 if the needle is empty or longer than the haystack

n.i = countstring(csv, ",");
startswith(hay, prefix)

does a string begin with this prefix?

Case-SENSITIVE, byte-exact. An empty prefix answers 1.

haystring to test
prefixprefix to look for

returns 1 if `hay` begins with `prefix` (or the prefix is empty), else 0

if (startswith(path, "http://")) { ... }
endswith(hay, suffix)

does a string end with this suffix?

Case-SENSITIVE, byte-exact. An empty suffix answers 1. The usual way to test a file extension -- lowercase the name first if the check should be case-blind.

haystring to test
suffixsuffix to look for

returns 1 if `hay` ends with `suffix` (or the suffix is empty), else 0

if (endswith(tolower(name), ".json")) { ... }
strcmpi(a, b)

compare two strings, ignoring ASCII case

Returns a SIGN, not a difference: exactly -1, 0 or +1, so the value is stable across backends and safe to compare against a literal. Ordering is by byte after lowercasing, and a string that is a prefix of the other sorts first. Equality is `== 0`, which is the usual C trap -- a bare `if (strcmpi(a,b))` tests for DIFFERENCE.

afirst string
bsecond string

returns -1 if a sorts before b, 0 if they match case-insensitively, +1 if after

if (strcmpi(cmd, "QUIT") == 0) { ... }
removestring(s, sub)

delete every occurrence of a substring

Exactly replacestring(s, sub, "") -- same non-overlapping, all-occurrences rule.

ssource string
subsubstring to delete; empty means "change nothing"

returns a new string with every occurrence removed

clean.s = removestring(raw, "\r");
replacestring(s, old, nw)

replace every occurrence of a substring

Replaces ALL non-overlapping matches in one pass, left to right, and never re-scans what it wrote -- so replacing "a" with "aa" terminates rather than running away. An empty `old` is a no-op returning a copy, not an insertion at every position.

ssource string
oldsubstring to find; empty means "change nothing"
nwtext to put in its place; empty deletes (that is removestring)

returns a new string with every match replaced

out.s = replacestring(path, "\\", "/");
insertstring(s, ins, where)

splice text into a string at a 0-based position

`where` counts from 0 like every other position in this family: 0 prepends, s->len appends, and a NEGATIVE `where` counts from the end exactly as substr(s, -3) does -- -1 inserts before the last character. OUT OF RANGE CLAMPS, it does not fail: past the end appends, and more tail than the string has prepends. That is substr's answer, taken deliberately rather than re-decided, because two out-of-range rules inside one string family is the seam counting from 0 exists to close.

ssource string
instext to insert
where0-based position the inserted text will start at; negative counts from the end; out of range clamps

returns a new string with `ins` spliced in

out.s = insertstring(num, ",", 3);
length(s)

how many BYTES a string holds

Bytes, not characters: the whole string layer is UTF-8-byte-level, so `length("hello")` with an accented e is 6, not 5. Every other index in this family -- substr, strstr, left -- counts the same bytes, so they agree with each other; none of them is codepoint-aware. `length` is also the polymorphic spelling both backends accept on a container -- but the container form is the RETIRED one (CX-E1048 since v3.186.0). Read a list's or map's size with `<handle>->count` instead. This entry documents the string form, which is not retired.

sstring to measure

returns its byte length; 0 for an empty string

n.i = length(name);
chr(n)

a one-byte string from a byte value

The inverse of asc(). Deliberately refuses to build a string containing an embedded NUL, and takes a BYTE rather than a codepoint, so there is no way to spell a multi-byte UTF-8 character with a single call.

nbyte value, 1..255

returns a 1-byte string, or "" if n is 0, negative, or above 255

tab.s = chr(9);
asc(s)

the numeric value of a string's FIRST byte

Reads one byte, not one character, so the first byte of a multi-byte UTF-8 character gives that byte's value (a lead byte, >= 0xC0), not the codepoint.

sstring to read

returns the first byte as 0..255, or 0 for an empty string. An empty string and a string starting with a NUL are indistinguishable here.

code.i = asc(letter);
contains(hay, needle)

does a string hold this substring?

Case-SENSITIVE, byte-exact. An empty needle answers 1 -- everything contains nothing -- which now agrees with `strstr(s, "") >= 0`. "VERIFIED ON BOTH BACKENDS" IS WHAT THIS BLOCK USED TO SAY, AND IT WAS NOT TRUE: measured 2026-09-06, `contains(s, "")` answered 1 native and 0 on the register VM. The cause is W1's whole subject -- vb_contains did not call THIS function, it hand-rolled the test over the search core and read its "absent" as "no". Fixed by calling bi_contains, which is what a wrapper is for. `contains` is also the spelling for value-membership in a list or array, and both backends dispatch on the first argument's type. This entry documents the string form.

haystring to search
needlesubstring to look for

returns 1 if present (or the needle is empty), else 0

if (contains(line, "ERROR")) { ... }
split(s, sep, out)

break a string on a separator into a string list

CLEARS `out` FIRST, then fills it, so a second split into the same list holds the second split and nothing else. His ruling 2026-09-17: "yes reset". IT APPENDED UNTIL THEN, and that was documented rather than accidental -- which is why this is a BEHAVIOUR CHANGE and is named as one. The accumulate idiom the old default licensed was used NOWHERE: measured over the whole corpus, NINE sites wrote `listClear(xs)` immediately before a split to defend against it (tests/anvil.cx x6, tests/forge.cx x2, tests/srcdb.cx x1 -- three of the tree's own primary tools) and ZERO relied on it. A default every caller defends against is the wrong default, and a caller that FORGOT to defend got a list holding two splits' worth of fields with no diagnostic (TOOLS-397 F2). A `listClear` before a split is now redundant, never wrong. EMPTY FIELDS ARE KEPT: "a,b,,c" yields four entries, the third being "" -- this is a faithful split, not a tokenizer, so consecutive separators do not collapse. There is always a trailing field, so a string ending in the separator contributes a final "". An empty separator yields the whole string as a single field and returns 1. `stringsplit` is NOT this builtin -- it reads ONE field and returns text. This is the whole-string form and it is the only name for it.

sstring to split
sepseparator to split on
outa string list, CLEARED and then filled with the fields

returns the number of fields -- always at least 1

list cols.s
n.i = split(row, ",", cols);
toupper(s)

uppercase a whole string

Despite the C name it takes and returns a STRING, not a character code, and it converts the entire string -- there is no per-character form. ASCII only, per byte, via C's toupper(): bytes outside a-z pass through unchanged, so accented and non-Latin UTF-8 text is left exactly as it was rather than mangled. Codepoint-aware folding is a separate operation and is not this one.

sstring to convert

returns a new uppercased string (ASCII letters only; other bytes unchanged)

shout.s = toupper(word);
tolower(s)

lowercase a whole string

Takes and returns a STRING, not a character code, and converts the entire string -- there is no per-character form. ASCII only, per byte, via C's tolower(); bytes outside A-Z pass through unchanged, so non-Latin text survives intact.

sstring to convert

returns a new lowercased string (ASCII letters only; other bytes unchanged)

key.s = tolower(header);
capitalize(s, mode)

re-case a string, title-case by default

Called with one argument it TITLE-CASES: each word's first letter uppercase, the rest lowercase. A "word" boundary is any run of non-letters, which means digits and apostrophes start a new word too -- "o'brien x2y" becomes "O'Brien X2Y". Verified, on both backends; if that is not what you want, use toupper/tolower on the pieces yourself. The optional mode selects the whole-string forms instead: 0 = uppercase, 1 = lowercase, 2 = title-case. An out-of-range mode falls back to 0 (uppercase), it is not an error.

sstring to re-case
mode0 upper, 1 lower, 2 title; omit for title-case

returns a new re-cased string

title.s = capitalize(raw);
fprintf(path, fmt)

format text and APPEND it to a file

Appends; it never truncates, so repeated calls build a file up (this is the log-writing shape). Formatting honours `#pragma decimals` for floats, and the formatted result spills to the heap when it outgrows the stack buffer, so output length is not capped.

pathfile to append to; an empty path returns 0
fmtformat string, printf-style

returns the number of bytes written -- a genuine count, unlike fwrite. 0 on any failure (bad format, unopenable path).

n.i = fprintf("log.txt", "run %d took %f s\n", id, secs);
ftoa(v)

render a float as a string (C's name for str on a float)

The same renderer str reaches for a float argument, so the decimal count follows `#pragma decimals`. strf(v, d) is the form that takes the decimals per call.

vthe float to render

returns the number as text

s.s = ftoa(ratio);
getc(s, idx)

the byte at a 0-indexed position in a string

ZERO-indexed, like every index in the language. This is also what `s[i]` lowers to on both backends, so the subscript form and the call form are the same operation. Reads one BYTE, not one character. Out of range returns 0 by default. Under `#pragma checks on` it is instead a loud bounds error naming the index and the length -- the lenient 0 is the legacy behaviour, not the safe one.

sstring to read
idx0-indexed byte position

returns the byte as 0..255, or 0 if idx is out of range

c.i = getc(line, 0);
len(v)

how many BYTES a string holds -- or, on a json value, its length by kind

Callable, but its argument list is not derivable from its binding: it is bound only by the register VM's name table, where the C signature is the generic (vm, N) wrapper form. Named here so an absence is never mistaken for non-existence.

DISPATCHED BY TYPE on both backends: a string answers its byte count, and a JSON value answers its text length or its child count depending on the node's kind. **It is RETIRED for list / map / array**: `len(xs)` on a container is refused at compile time with CX-E1048 naming `<handle>->count`, which is the one spelling for a container's length. strlen is the string-only name for the same byte count.

vthe string, list, map, array or JSON value to measure

returns the string's byte count (0 for ""), or the json value's length by kind; CX-E1048 on a list, map or array

n.i = len(line);          // a container asks a different way: xs->count
ord(s)

the numeric value of a string's FIRST byte (the other name for asc)

BYTES, NOT CODEPOINTS: a multi-byte UTF-8 character gives that character's lead byte (>= 0xC0), not its Unicode value. chr is the inverse.

sstring to read the first byte of

returns the byte as 0..255; 0 for an empty string

code.i = ord(key);
parsehex(s)

read a hexadecimal string as an integer

A plain base-16 parse: no "0x" prefix is required (one is accepted), case does not matter, and parsing stops at the first non-hex character with no error channel -- unparseable text gives 0, the same silence the base-10 read behind `(int)<s>` has. For a "rrggbb" colour the result is the NATURAL 0xRRGGBB packing, so `parsehex("ff0000")` is 16711680 (red), matching `rgb()` and the draw builtins. This header claimed the opposite until v3.209.20 -- it described the PureBasic era's byte-swapped 0xBBGGRR and said so as the contract, while the implementation had carried a comment stating plainly that the C runtime does NOT swap and that `parsehex("ff0000") must be red, not blue`. Two homes, two truths; the header was the wrong one, and it is the versioned surface a reader sees. Measured on both backends before correcting.

shex digits, with or without a leading "0x"

returns the value, or 0 if the text does not start with a hex digit

col.i = parsehex("ff8800");
prts(s)

write a STRING to stdout with no newline

The raw piece-by-piece output form: no newline, no formatting, no conversion. prti, prtf and prtc are the int, float and character siblings.

sthe string to write

returns void

sprintf(fmt)

format text. ONE name, TWO shapes, and ARGUMENT 1 chooses

`sprintf(fmt, args...)` returns the formatted STRING. This is CX's form and the one nearly every program wants: nothing to size, nothing to overflow. `fmt` may be a literal or a `string` variable; conversions are libc's (the compiler rewrites `%d` to `%lld` upstream) and honour `#pragma decimals`. `sprintf(buf, fmt, args...)` where `buf` is a `char` buffer is C's form, verbatim: it writes into the buffer, NUL-terminates, and returns the BYTE COUNT. It exists so that C pasted into CX means what it meant in C -- the `Examples/C/` programs are Rosetta-Code source, unedited. Both backends answer identically; the register VM clamps the write to the declared size rather than running off the array, since C's own answer there is undefined. Argument 1's DECLARED TYPE picks the shape, at compile time. Anything that is neither a `char` buffer nor a string -- an int, a float, a container -- is CX-E1065 at the .cx line, because there is no third reading to guess at.

fmtthe format string (CX form), OR the destination buffer (C form)

returns the formatted string (CX form), or the byte count written (C form)

label.s = sprintf("%s: %d%%", name, pct);
char buf[64]; int n = sprintf(buf, "%d/%d", num, den);
str(n)

render a number as a string

The compiler picks the int or the float renderer from the argument's type, so str(3) gives "3" and str(3.0) gives "3.000" -- three decimals, because the float form honours `#pragma decimals`, whose default is 3. Use strf when you want to fix the decimal count at the call site instead.

nthe int or float to render

returns the number as text

line.s = "score: " + str(points);
strchr(s, ch)

0-indexed position of a character code in a string, C-style

Takes the character as a NUMERIC CODE, as C does, not as a one-character string. Returns -1 when the character is absent, so the test is `>= 0`; strstr is the same search with a string needle.

sthe string to search
chthe character code to look for

returns the 0-indexed position, or -1 if the character does not occur

at.i = strchr(line, 61);
strcmp(a, b)

compare two strings in byte order, C-style

Returns a SIGN, not a boolean: negative if `a` sorts before `b`, 0 if they are equal, positive if after. Comparison is by raw byte value, so it is case-sensitive and not locale-aware. CX's `==` on strings is the readable way to test equality; strcmp is for ordering.

athe first string
bthe second string

returns negative, 0, or positive as `a` sorts before, equal to, or after `b`

if (strcmp(name, pivot) < 0) { ... }
strf(v, d)

a float as text, with the number of decimals you choose

`strf(v)` renders at the file's `#pragma decimals` setting (default 3), which is what `str()` and `ftoa()` do. `strf(v, d)` overrides it FOR THAT CALL -- the pragma is file-global and last-wins, so this is the only way to write a price at 2 decimals and an angle at 6 in the same program. `d` is C's `%.*f` precision and behaves as C does, including the corner: a NEGATIVE `d` means "precision omitted", which in C is six decimals. There is no upper limit -- ask for 40 and you get 40, correctly, however long the result runs.

vthe float to render
ddecimals to show; omit to use `#pragma decimals`

returns the rendered string

label.s = strf(price, 2) + " at " + strf(angle, 6) + " rad";
strlen(s)

how many BYTES a string holds (C's name for length, string-only)

Counts bytes, not characters, so a multi-byte UTF-8 string measures longer than it looks. Unlike len, this name is string-only: it does not answer for a list, map or array.

sthe string to measure

returns the byte count; 0 for an empty string

n.i = strlen(line);
strstr(h, n, start)

where a substring starts, counting from 0

THE C answer and now the only one: a ZERO-indexed position, and -1 for "not found". So the test is `>= 0`, NOT `> 0` -- a `> 0` test silently rejects a match at the very start of the string, which is the whole reason the 1-based spelling is leaving. An optional third argument resumes the search from a 0-based position, which is how a program walks every occurrence without rebuilding the haystack. AN EMPTY NEEDLE MATCHES AT `start` -- `strstr(s, "")` is 0 and `strstr(s, "", 3)` is 3. That is C's answer (its strstr hands back a pointer to the haystack) and Python's. It CHANGED in this arc: the 1-based core it used to adapt answered "absent" for an empty needle so a loop could not spin, and at 0 that reasoning inverts -- 0 is a real position, and -1 would make the empty needle the one case where a substring the string demonstrably contains reports absent. A start past the end is -1, empty needle or not.

hstring to search
nsubstring to look for
start0-indexed position to resume from; omit for 0

returns the 0-indexed byte position of the first match at or after `start`, or -1 if there is none

if (strstr(line, "=") >= 0) { ... }
substr(s, start, n)

a substring, counting from 0

THE substring operation -- there is no longer a second, 1-indexed one. Two forms: `substr(s, start)` runs to the end, `substr(s, start, n)` takes at most n bytes, and both clamp rather than fail. A NEGATIVE START COUNTS FROM THE END, which is what makes `right(s, n)` a call to this rather than a function of its own: `substr(s, -5)` is the last five bytes, and asking for more tail than there is gives the whole string. FIXED in v3.209.22 -- and the fix was NOT the one name this note predicted. Until then the compiler did not know substr returned a string, so `"x=" + substr(s, 0)` failed the BUILD on both backends with a type error out of the generated C. The cause was not a missing entry: the classifier consulted a GENERATED table that only ever read `CX_BUILTIN` spec lines, and this family has none, so ~66 hand-bound builtins were invisible to it and a hand list was expected to know them all. The generator now derives the answer from these C prototypes instead (tests/strret_derive.awk), which found four more builtins with the same defect. See tests/bugs/B_strret_two_sources.

ssource string
start0-indexed byte position to start at; negative counts back from the end, and below -length clamps to the start
nhow many bytes to take, clamped to what remains; omit for "to the end"

returns the substring, or "" if start is at or past the end

part.s = substr(line, 4, 3);

regular expressions 4 builtins

regexmatch(s, pattern)

does `pattern` match anywhere in `s`?

sthe subject text.
patternthe regular expression.

returns 1 on a match, 0 on none. A pattern that cannot be COMPILED does not answer 0 -- it refuses (CX-E5067), because 0 is a legitimate answer and cannot also mean "your pattern is broken".

if (regexMatch(line, "^[A-Z][a-z]+$")) { ... }
regexcount(s, pattern)

how many non-overlapping matches of `pattern` are in `s`?

sthe subject text.
patternthe regular expression.

returns the count, 0 for none. Agrees with regexExtract's `->count` by construction: both walk the subject with the same one-byte advance past an empty match.

n = regexCount(text, "\\bthe\\b");
regexextract(s, pattern)

every match of `pattern` in `s`, with its capture groups, as a json document

The document is an ARRAY of matches. A match with no NAMED groups is an array of groups -- `m[0]` the whole match, `m[1]` the first group. A match from a pattern that names any group is an OBJECT carrying both halves: "0", "1", ... as string keys AND each name as a member, because a json node is an array or an object and cannot be both, and dropping the positions to gain the names would silently break `m[1]` for a pattern that happens to name one group. Which form is in play is decided once from the PATTERN, never per match.

sthe subject text.
patternthe regular expression.

returns a json document handle. No match is an EMPTY ARRAY, not null, so `for (i = 0; i < d->count; i++)` needs no special case first. An unset group is an empty string rather than a missing element, so group N is always at index N.

json d = regexExtract(s, "(\\d+)-(\\d+)");
json m = d[0]; print m[1];   // the first group of the first match
regexreplace(s, pattern, repl)

`s` with every non-overlapping match of `pattern` replaced by `repl`

sthe subject text.
patternthe regular expression.
replthe replacement, inserted LITERALLY -- `$1` and `\1` are those two characters, not a group reference. Building a replacement out of groups is regexExtract's job.

returns the rewritten text; `s` unchanged when nothing matched.

clean = regexReplace(s, "[^0-9]", "");

Numerics 3 families · 46 builtins

math 39 builtins

sin(x)

sine of an angle in radians

xangle in radians

returns the sine, in -1.0 .. 1.0

y.f = amplitude * sin(t);
cos(x)

cosine of an angle in radians

xangle in radians

returns the cosine, in -1.0 .. 1.0

x.f = radius * cos(angle);
tan(x)

tangent of an angle in radians

xangle in radians

returns the tangent; unbounded, and huge near odd multiples of pi/2

asin(x)

arc sine: the angle whose sine is `x`

xa value in -1.0 .. 1.0; outside that range the result is NaN

returns the angle in radians, in -pi/2 .. pi/2

acos(x)

arc cosine: the angle whose cosine is `x`

xa value in -1.0 .. 1.0; outside that range the result is NaN

returns the angle in radians, in 0 .. pi

atan(x)

arc tangent: the angle whose tangent is `x`

Takes one argument, so it cannot tell quadrant II from IV -- use `atan2` when you have both components of a direction.

xany value

returns the angle in radians, in -pi/2 .. pi/2

atan2(y, x)

the angle of the vector (x, y), using both signs to pick a quadrant

NOTE THE ARGUMENT ORDER: `y` FIRST, as in C. It is the usual way to turn a delta into a heading, and the reason it beats `atan(y/x)` is that it is defined when `x` is 0 and it knows which half of the circle you are in.

ythe vertical component
xthe horizontal component

returns the angle in radians, in -pi .. pi

heading.f = atan2(ty - py, tx - px);
sinh(x)

hyperbolic sine

xany value

returns the hyperbolic sine; overflows to infinity for large `x`

cosh(x)

hyperbolic cosine

xany value

returns the hyperbolic cosine, always >= 1.0

tanh(x)

hyperbolic tangent

xany value

returns the hyperbolic tangent, in -1.0 .. 1.0

exp(x)

e raised to the power `x`, the inverse of `log`

xthe exponent

returns e**x; overflows to infinity for large `x`

log(x)

natural logarithm (base e)

This is C's `log`, NOT base 10 -- `log10` is the base-10 one.

xa positive value; 0 gives -infinity and a negative gives NaN

returns the natural log

log10(x)

base-10 logarithm

xa positive value; 0 gives -infinity and a negative gives NaN

returns the base-10 log

digits.i = 1 + floor(log10(n));
log2(x)

base-2 logarithm

A direct bridge to the C library function of the same name; the answer is C's answer, to the bit.

xthe value

returns log base 2 of x

b = log2(1024.0);   // 10.0
sqrt(x)

square root

xa non-negative value; a negative gives NaN

returns the square root

cbrt(x)

cube root

A direct bridge to the C library function of the same name; the answer is C's answer, to the bit. Unlike `pow(x, 1.0/3.0)` this is exact for negative inputs.

xthe value

returns the real cube root of x

r = cbrt(-27.0);   // -3.0
pow(b, e)

raise `b` to the power `e`

bthe base
ethe exponent; need not be a whole number

returns b**e

area.f = pow(side, 2.0);
hypot(x, y)

the length of the hypotenuse, without overflow

A direct bridge to the C library function of the same name; the answer is C's answer, to the bit. Computes sqrt(x*x + y*y) in a way that does not overflow when x or y is large.

xone leg
ythe other leg

returns sqrt(x*x + y*y)

d = hypot(dx, dy);
round(x)

round to the nearest whole number and return it as an INT

Ties go to the EVEN neighbour (2.5 gives 2, 3.5 gives 4, -2.5 gives -2), which is a decision, not C's: C's `round` is half-away-from-zero and would answer 3 and -3. The int return is what lets `a[round(x)]` compile.

xthe value to round

returns the nearest whole number as an int; a value beyond int64's range cannot be represented exactly

idx.i = round(t * (count - 1));
sign(x)

which side of zero a value is on, as an INT

xany value

returns -1 if x is negative, +1 if positive, 0 if zero (including -0.0)

step.i = sign(target - current);
floor(x)

round DOWN to a whole number, towards negative infinity

Returns a FLOAT, unlike `round`, which returns an int -- so `floor(-2.5)` is -3.0, not -2.0.

xthe value to round down

returns the largest whole number <= x, as a float

ceil(x)

round UP to a whole number, towards positive infinity

Returns a FLOAT; `ceil(-2.5)` is -2.0.

xthe value to round up

returns the smallest whole number >= x, as a float

trunc(x)

discard the fractional part, toward zero

A direct bridge to the C library function of the same name; the answer is C's answer, to the bit. Differs from `floor` on negatives: trunc(-2.7) is -2.0, floor(-2.7) is -3.0.

xthe value

returns x with its fraction removed, rounded toward zero

n = trunc(-2.7);   // -2.0
fabs(x)

absolute value of a FLOAT

`abs` is the int form; this one keeps the fraction.

xany value

returns x without its sign

fmin(a, b)

the smaller of two FLOATS

afirst value
bsecond value

returns whichever is smaller

fmax(a, b)

the larger of two FLOATS

afirst value
bsecond value

returns whichever is larger

fmod(x, y)

floating-point remainder of x/y

A direct bridge to the C library function of the same name; the answer is C's answer, to the bit. The result takes the SIGN OF X, which is what distinguishes it from a mathematical modulo.

xthe dividend
ythe divisor

returns the remainder of x/y with x's sign

r = fmod(-7.5, 2.0);   // -1.5
pi()

the constant pi

The value of pi as a double.

returns 3.14159265358979323846

c = 2.0 * pi() * r;
degrees(r)

convert radians to degrees

ran angle in radians

returns the same angle in degrees

radians(d)

convert degrees to radians

Every trig builtin here takes radians, so this is the usual bridge from a human-authored angle.

dan angle in degrees

returns the same angle in radians

y.f = cy + radius * sin(radians(deg));
lerp(a, b, t)

linear interpolation between `a` and `b`

Computed as a + (b - a) * t, so it is NOT clamped: t below 0 or above 1 extrapolates past the endpoints, which is often what you want.

athe value at t = 0
bthe value at t = 1
tthe fraction of the way from a to b

returns the interpolated value

x.f = lerp(startX, endX, elapsed / duration);
between(x, lo, hi)

is `x` inside `lo`..`hi`, endpoints INCLUDED

A macro, so it works for ints and floats alike. It evaluates `x` TWICE -- do not pass a call with side effects.

xthe value to test
lolower bound, counted as inside
hiupper bound, counted as inside

returns 1 if lo <= x <= hi, else 0

if (between(mx, panelX, panelX + panelW)) { ... }
distance(x1, y1, x2, y2)

straight-line distance between two 2-D points

x1first point's x
y1first point's y
x2second point's x
y2second point's y

returns the Euclidean distance, as a float

if (distance(px, py, ex, ey) < range) { attack(); }
remap(x, aLo, aHi, bLo, bHi)

rescale `x` from one range onto another, linearly

Not clamped: an `x` outside the input range lands outside the output range in proportion. A ZERO-WIDTH input range answers `bLo` rather than dividing by zero.

xthe value to rescale
aLoinput range low
aHiinput range high
bLooutput range low, and the answer when aLo equals aHi
bHioutput range high

returns x expressed in the output range

px.f = remap(value, 0.0, 100.0, left, right);
mod(a, b)

floating-point remainder of `a / b`, keeping `a`'s sign

Computed as a - b * trunc(a/b), which is C's `fmod`. A ZERO DIVISOR ANSWERS 0.0 rather than trapping or returning NaN -- the same convention CX applies to integer `a / 0`, extended to floats on purpose.

athe dividend
bthe divisor; 0.0 gives 0.0

returns the remainder, with the sign of `a`

wrapped.f = mod(angle, 360.0);
clamp(x, lo, hi)

pull a value inside `lo`..`hi`, returning a FLOAT

Float is the default because that is what the register VM answers; an inverted range (lo > hi) is not checked and yields `hi`.

xthe value to constrain
lolower bound, returned when x is below it
hiupper bound, returned when x is above it

returns x, lo or hi, as a float

volume.f = clamp(volume + step, 0.0, 1.0);
abs(x)

absolute value of an INT

The float form is `fabs`; which one a call reaches is decided from the argument's type at compile time.

xany integer

returns x without its sign

max(a, b)

the larger of two INTS

The float form is `fmax`.

afirst value
bsecond value

returns whichever is larger

hp.i = max(0, hp - damage);
min(a, b)

the smaller of two INTS

The float form is `fmin`.

afirst value
bsecond value

returns whichever is smaller

hashing 5 builtins

md5(input)

MD5 digest of a string, as 32 lowercase hex characters

A CHECKSUM, NOT A SECURITY PRIMITIVE: MD5 is broken for anything that has to resist a deliberate collision. Use it to notice accidental change (a cache key, a file-identity check), and reach for `sha256` otherwise.

inputthe bytes to hash

returns the digest as 32 lowercase hex characters

key.s = md5(url);
sha1(input)

SHA-1 digest of a string, as 40 lowercase hex characters

Also collision-broken; keep it for compatibility with something that already speaks SHA-1, not for new security work.

inputthe bytes to hash

returns the digest as 40 lowercase hex characters

sha256(input)

SHA-256 digest of a string, as 64 lowercase hex characters

The one to reach for by default.

inputthe bytes to hash

returns the digest as 64 lowercase hex characters

if (sha256(readFile(f)) != expected) { print "tampered"; }
sha512(input)

SHA-512 digest of a string, as 128 lowercase hex characters

inputthe bytes to hash

returns the digest as 128 lowercase hex characters

crc32(input)

CRC-32 checksum of a string, as an INTEGER

The standard (zip/gzip) polynomial. Unlike the four digests above this returns a number, not hex -- it is a 32-bit error-detecting checksum for spotting corruption, and it is trivial to forge.

inputthe bytes to check

returns the checksum as an int

if (crc32(block) != stored) { print "block corrupt"; }

random 2 builtins

randomf()

a pseudo-random float

The same xorshift64* stream as random(), delivered as a float. Not cryptographic.

returns a pseudo-random float

f = randomf();
random(max)

a pseudo-random integer

Drawn from the runtime's xorshift64* generator -- fast, and NOT cryptographic. The stream is seeded from real entropy by default; `#pragma randomseed N` or randomSeed(n) makes a run reproducible.

maxexclusive upper bound (optional)

returns a pseudo-random integer

n = random(6) + 1;

Containers & algorithms 4 families · 66 builtins

containers 49 builtins

arraverage(a)

the mean of an array (the long name for arrAvg)

Callable, but its argument list is not derivable from its binding: it is bound only by the register VM's name table, where the C signature is the generic (vm, N) wrapper form. Named here so an absence is never mistaken for non-existence.

Identical to arrAvg and to avg over an array; always float.

athe array to reduce

returns the mean as a float; 0 when empty

arravg(a)

the mean of an array (the array-specific name for avg)

Callable, but its argument list is not derivable from its binding: it is bound only by the register VM's name table, where the C signature is the generic (vm, N) wrapper form. Named here so an absence is never mistaken for non-existence.

The same core as avg, and always float.

athe array to reduce

returns the mean as a float; 0 when empty

arrfirst(a)

move an array cursor to the first element

Callable, but its argument list is not derivable from its binding: it is bound only by the register VM's name table, where the C signature is the generic (vm, N) wrapper form. Named here so an absence is never mistaken for non-existence.

The array twin of listFirst -- the same one-cursor walk, over a dynamic array.

athe array to position

returns 1 if the array has an element to stand on, 0 if it is empty

arrget(a)

read the array element the cursor is standing on

Callable, but its argument list is not derivable from its binding: it is bound only by the register VM's name table, where the C signature is the generic (vm, N) wrapper form. Named here so an absence is never mistaken for non-existence.

The array twin of the cursor form of listGet. Pair it with arrFirst and arrNext.

athe array to read

returns the value of the element

arrmax(a)

the largest element of an array (the array-specific name for maxof)

Callable, but its argument list is not derivable from its binding: it is bound only by the register VM's name table, where the C signature is the generic (vm, N) wrapper form. Named here so an absence is never mistaken for non-existence.

The same core as maxof. INT-PRESERVING: an all-int sequence answers an INT; the first float element promotes the whole reduction to float. An empty container answers 0.

athe array to reduce

returns the largest element; 0 when empty

arrmin(a)

the smallest element of an array (the array-specific name for minof)

Callable, but its argument list is not derivable from its binding: it is bound only by the register VM's name table, where the C signature is the generic (vm, N) wrapper form. Named here so an absence is never mistaken for non-existence.

The same core as minof. INT-PRESERVING: an all-int sequence answers an INT; the first float element promotes the whole reduction to float. An empty container answers 0.

athe array to reduce

returns the smallest element; 0 when empty

arrnext(a)

advance an array cursor to the next element

Callable, but its argument list is not derivable from its binding: it is bound only by the register VM's name table, where the C signature is the generic (vm, N) wrapper form. Named here so an absence is never mistaken for non-existence.

The array twin of listNext, and the loop condition in the same way.

athe array to advance

returns 1 if the cursor landed on an element, 0 once past the end

arrsum(a)

add up every element of an array (the array-specific name for sum)

Callable, but its argument list is not derivable from its binding: it is bound only by the register VM's name table, where the C signature is the generic (vm, N) wrapper form. Named here so an absence is never mistaken for non-existence.

The same core as sum, reached through the array-only spelling. INT-PRESERVING: an all-int sequence answers an INT; the first float element promotes the whole reduction to float. An empty container answers 0.

athe array to reduce

returns the total -- int if every element was an int, otherwise float; 0 when empty

average(c)

the mean of a container (the other name for avg)

Callable, but its argument list is not derivable from its binding: it is bound only by the register VM's name table, where the C signature is the generic (vm, N) wrapper form. Named here so an absence is never mistaken for non-existence.

Identical to avg, and always float.

cthe array, list, map or JSON array to reduce

returns the mean as a float; 0 when empty

avg(c)

the mean of a container

Callable, but its argument list is not derivable from its binding: it is bound only by the register VM's name table, where the C signature is the generic (vm, N) wrapper form. Named here so an absence is never mistaken for non-existence.

UNIVERSAL: one name over arrays, lists, maps and JSON arrays, because all of them reduce through the same core on both backends. Over a MAP it reduces the VALUES. Elements that are not numbers are coerced leniently (a numeric string parses, a bool counts 0 or 1), the same rule the rule engine uses. ALWAYS FLOAT, unlike sum/minof/maxof, because a mean rarely is one. An empty container answers 0.

cthe array, list, map or JSON array to reduce

returns the mean as a float; 0 when empty

fill(c, v)

set every element of a container to one value

Callable, but its argument list is not derivable from its binding: it is bound only by the register VM's name table, where the C signature is the generic (vm, N) wrapper form. Named here so an absence is never mistaken for non-existence.

Writes in place, through the same element-ownership path a normal assignment uses, so replacing strings does not leak.

cthe container to overwrite
vthe value to write into every slot

returns void

fill(grid, 0);
find(c, key)

the index of the first element equal to a value

Callable, but its argument list is not derivable from its binding: it is bound only by the register VM's name table, where the C signature is the generic (vm, N) wrapper form. Named here so an absence is never mistaken for non-existence.

LINEAR and first-match, so it works on unsorted data -- arraysearch is the binary search that needs the container sorted first. Ints compare exactly, floats within the default tolerance, strings by content.

cthe container to search
keythe value to look for

returns the 0-indexed position of the first match, or -1 if there is none

at.i = find(names, "ada");
listadd(list, value)

append a value to the end of a list

Callable, but its argument list is not derivable from its binding: it is bound only by the register VM's name table, where the C signature is the generic (vm, N) wrapper form. Named here so an absence is never mistaken for non-existence.

Operates on the shared list runtime both backends use, so behaviour is identical native and VM. A list parameter is byref by default, so a call inside a function mutates the caller's list.

listthe list
valuethe value to append

returns void

listAdd(xs, 42);
listclear(list)

remove every element, leaving an empty list

Callable, but its argument list is not derivable from its binding: it is bound only by the register VM's name table, where the C signature is the generic (vm, N) wrapper form. Named here so an absence is never mistaken for non-existence.

Operates on the shared list runtime both backends use, so behaviour is identical native and VM. A list parameter is byref by default, so a call inside a function mutates the caller's list.

listthe list

returns void

listClear(xs);
listdelete(list, index)

remove the element at a position, closing the gap

Callable, but its argument list is not derivable from its binding: it is bound only by the register VM's name table, where the C signature is the generic (vm, N) wrapper form. Named here so an absence is never mistaken for non-existence.

Operates on the shared list runtime both backends use, so behaviour is identical native and VM. A list parameter is byref by default, so a call inside a function mutates the caller's list.

listthe list
index0-based position to remove

returns void

listDelete(xs, 0);
listfirst(lst)

move a list cursor to the first element

Callable, but its argument list is not derivable from its binding: it is bound only by the register VM's name table, where the C signature is the generic (vm, N) wrapper form. Named here so an absence is never mistaken for non-existence.

CX containers carry ONE internal cursor, so these walk a list without an index variable and without a second handle -- and because there is only one cursor per container, two interleaved walks of the same list tread on each other. Returns 0 for an empty list, which is what ends the loop before it starts.

lstthe list to position

returns 1 if the list has an element to stand on, 0 if it is empty

if (listFirst(items)) { repeat { use(listGet(items)); } while (listNext(items)); }
listget(lst, i)

read a list element -- at the cursor, or at an index

Callable, but its argument list is not derivable from its binding: it is bound only by the register VM's name table, where the C signature is the generic (vm, N) wrapper form. Named here so an absence is never mistaken for non-existence.

TWO FORMS, told apart by the argument count: listGet(lst) reads where the cursor stands, listGet(lst, i) reads element `i`. listGetAt is the always-indexed spelling for when you want no ambiguity.

lstthe list to read
i0-indexed element; omit to read at the cursor

returns the value of the element; an index outside 0..count-1 answers the element type's zero (0, 0.0 or "") and leaves the list untouched -- a read never grows it

line.s = rtrim(listGet(rows, i));   // a call may be another call's argument
listgetat(lst, i)

read a list element by index, always (never the cursor)

Callable, but its argument list is not derivable from its binding: it is bound only by the register VM's name table, where the C signature is the generic (vm, N) wrapper form. Named here so an absence is never mistaken for non-existence.

The unambiguous half of listGet: always the indexed form, so a one-argument mistake cannot become a silent cursor read.

lstthe list to read
i0-indexed element to read

returns the value of the element; an index outside 0..count-1 answers the element type's zero (0, 0.0 or "") and leaves the list untouched

name.s = listGetAt(names, 0);
listindex(lst)

where a list cursor is standing, 0-indexed

Callable, but its argument list is not derivable from its binding: it is bound only by the register VM's name table, where the C signature is the generic (vm, N) wrapper form. Named here so an absence is never mistaken for non-existence.

Answers -1 when the cursor is not on an element -- after listReset, or once a walk has run off the end.

lstthe list to ask

returns the 0-indexed cursor position, or -1 if the cursor is not on an element

listinsert(list, index, value)

insert a value at a position, shifting the rest along

Callable, but its argument list is not derivable from its binding: it is bound only by the register VM's name table, where the C signature is the generic (vm, N) wrapper form. Named here so an absence is never mistaken for non-existence.

Operates on the shared list runtime both backends use, so behaviour is identical native and VM. A list parameter is byref by default, so a call inside a function mutates the caller's list.

listthe list
index0-based position to insert at
valuethe value to insert

returns void

listInsert(xs, 0, 42);
listlast(lst)

move a list cursor to the LAST element

The starting point for a backwards walk with listPrev, as listFirst is for listNext.

lstthe list to position

returns 1 if the list has an element to stand on, 0 if it is empty

listnext(lst)

advance a list cursor to the next element

Callable, but its argument list is not derivable from its binding: it is bound only by the register VM's name table, where the C signature is the generic (vm, N) wrapper form. Named here so an absence is never mistaken for non-existence.

CX containers carry ONE internal cursor, so these walk a list without an index variable and without a second handle -- and because there is only one cursor per container, two interleaved walks of the same list tread on each other. It is the loop condition: it returns 0 once the cursor has run past the end.

lstthe list to advance

returns 1 if the cursor landed on an element, 0 once past the end

listprev(lst)

step a list cursor back one element

Callable, but its argument list is not derivable from its binding: it is bound only by the register VM's name table, where the C signature is the generic (vm, N) wrapper form. Named here so an absence is never mistaken for non-existence.

The mirror of listNext, for walking backwards from listLast.

lstthe list to step back

returns 1 if the cursor landed on an element, 0 once before the start

listreset(lst)

put a list cursor back before the first element

Callable, but its argument list is not derivable from its binding: it is bound only by the register VM's name table, where the C signature is the generic (vm, N) wrapper form. Named here so an absence is never mistaken for non-existence.

Leaves the CONTENTS alone -- it rewinds the walk, it does not clear anything. listClear is the one that empties.

lstthe list to rewind

returns void

listselect(lst, i)

put a list cursor on element `i`, 0-indexed

Callable, but its argument list is not derivable from its binding: it is bound only by the register VM's name table, where the C signature is the generic (vm, N) wrapper form. Named here so an absence is never mistaken for non-existence.

The random-access way to position the cursor, where listFirst/listNext walk. Returns nothing, so check with listIndex if the index might be out of range: an out-of-range select leaves the cursor off any element, and the listGet that follows answers the element type's zero rather than failing.

lstthe list to position
i0-indexed element to stand on

returns void

listSelect(rows, 2); v.s = listGet(rows);
listset(lst, i, v)

write a list element -- at the cursor, or at an index

Callable, but its argument list is not derivable from its binding: it is bound only by the register VM's name table, where the C signature is the generic (vm, N) wrapper form. Named here so an absence is never mistaken for non-existence.

TWO FORMS, told apart by the argument count: listSet(lst, v) writes where the cursor stands, listSet(lst, i, v) writes element `i`. listSetAt is the always-indexed spelling. THE INDEXED FORM GROWS THE LIST when it has to: writing element 5 of a two-element list makes it six long and pads slots 2..4 with the element type's zero, the same as the subscript store `lst[5] = v`. That is the opposite of a READ, which never grows anything.

lstthe list to write into
i0-indexed element; omit to write at the cursor
vthe value to store

returns void

listSet(rows, 5, "f");   // a 2-element list becomes 6 long, slots 2..4 zero-filled
listsetat(lst, i, v)

write a list element by index, always (never the cursor)

Callable, but its argument list is not derivable from its binding: it is bound only by the register VM's name table, where the C signature is the generic (vm, N) wrapper form. Named here so an absence is never mistaken for non-existence.

The unambiguous half of listSet, matching listGetAt.

lstthe list to write into
i0-indexed element to write
vthe value to store

returns void

listsort(list)

sort a list in place

Callable, but its argument list is not derivable from its binding: it is bound only by the register VM's name table, where the C signature is the generic (vm, N) wrapper form. Named here so an absence is never mistaken for non-existence.

Operates on the shared list runtime both backends use, so behaviour is identical native and VM. A list parameter is byref by default, so a call inside a function mutates the caller's list. The same implementation as `sort(xs)`; prefer that spelling.

listthe list

returns void

sort(xs);
mapclear(m)

remove every entry, leaving an empty map

Callable, but its argument list is not derivable from its binding: it is bound only by the register VM's name table, where the C signature is the generic (vm, N) wrapper form. Named here so an absence is never mistaken for non-existence.

Operates on the shared map runtime both backends use, so behaviour is identical native and VM. The map itself survives and is immediately reusable -- the bucket array is kept, only the entries go. String and struct values are released as they are dropped. A map parameter is byref by default, so a call inside a function empties the caller's map. Note this is NOT mapReset, which only rewinds the iteration cursor and removes nothing.

mthe map to empty

returns void

mapClear(m);
mapcontains(m, k)

does a map hold this key? (the other name for mapHasKey)

Tests for the KEY, not the value. mapHas and mapHasKey are the same function. This is the only way to tell a MISSING key from a key holding the value type's zero -- a plain read answers 0 / 0.0 / "" for both.

mthe map to test
kthe key to look for

returns 1 if the key is present, 0 if not

mapcreate()

create a map and return it as a plain int handle

Part of the EXPLICIT-HANDLE map pattern: `m = mapCreate()` keeps a map in a plain int variable, and these adapt that int back to a real map. For a declared `map m.i;` the compiler dispatches straight to the typed runtime and these are never involved.

returns an int handle to a new integer-valued map

m = mapCreate();
mapdelete(m, k)

delete one entry from a map, by key

A key that is not present is not an error -- the map is simply unchanged. This is the ONE name for the operation: `mapRemove` was retired at v3.237.0 (one verb per op).

mthe map to remove from
kthe key to remove

returns void

mapget(handle, key)

read a value by key from a handle-held map

Part of the EXPLICIT-HANDLE map pattern: `m = mapCreate()` keeps a map in a plain int variable, and these adapt that int back to a real map. For a declared `map m.i;` the compiler dispatches straight to the typed runtime and these are never involved -- but the ANSWER is the same one: a missing key reads as the value type's zero, `0` / `0.0` / `""`, on both backends, and a read never inserts, so probing a map cannot grow it. Because a stored zero and a missing key read alike, use mapHas when that difference matters.

handlethe int handle from mapCreate
keythe string key

returns the value, or 0 if the key is absent or the handle is zero -- so a stored 0 and a missing key read alike

v = mapGet(m, "hp");
maphas(m, k)

does a map hold this key? (the other name for mapHasKey)

Tests for the KEY, not the value. This is the only way to tell a MISSING key from a key holding the value type's zero -- a plain read answers 0 / 0.0 / "" for both.

mthe map to test
kthe key to look for

returns 1 if the key is present, 0 if not

if (mapHas(totals, cat)) { mapPut(totals, cat, mapGet(totals, cat) + n); } else { mapPut(totals, cat, n); }
maphaskey(handle, key)

test whether a handle-held map contains a key

Part of the EXPLICIT-HANDLE map pattern: `m = mapCreate()` keeps a map in a plain int variable, and these adapt that int back to a real map. For a declared `map m.i;` the compiler dispatches straight to the typed runtime and these are never involved. This is the way to tell a stored 0 from an absent key.

handlethe int handle from mapCreate
keythe string key

returns non-zero if the key is present; 0 otherwise (including a zero handle)

if (mapHasKey(m, "hp")) { ... }
mapkey(m)

the KEY of the map entry the cursor is standing on

Callable, but its argument list is not derivable from its binding: it is bound only by the register VM's name table, where the C signature is the generic (vm, N) wrapper form. Named here so an absence is never mistaken for non-existence.

Only meaningful between a mapNext that returned 1 and the one that returns 0. With the cursor off an entry -- before the first mapNext, after the last, or on an empty map -- it answers "".

mthe map to read

returns the key of the current entry; "" when the cursor is not standing on one

mapReset(m); while (mapNext(m)) { printf("%-12s %d\n", mapKey(m), mapValue(m)); }
mapnext(m)

advance a map cursor to the next entry

Callable, but its argument list is not derivable from its binding: it is bound only by the register VM's name table, where the C signature is the generic (vm, N) wrapper form. Named here so an absence is never mistaken for non-existence.

Maps carry the same single cursor as lists, over their entries in HASH order -- which is UNSPECIFIED by contract and is NOT insertion order. Do not write code that depends on it; put a sortndx over the map when you need a defined order. Read the entry with mapKey and mapValue once this returns 1.

mthe map to advance

returns 1 if the cursor landed on an entry, 0 once past the end

mapReset(m); while (mapNext(m)) { print mapKey(m) + "=" + mapValue(m); }
mapput(handle, key, value)

store a value under a key in a handle-held map

Part of the EXPLICIT-HANDLE map pattern: `m = mapCreate()` keeps a map in a plain int variable, and these adapt that int back to a real map. For a declared `map m.i;` the compiler dispatches straight to the typed runtime and these are never involved.

handlethe int handle from mapCreate
keythe string key
valuethe integer value

returns void; a zero handle is ignored

mapPut(m, "hp", 10);
mapreset(m)

put a map cursor back before the first entry

Callable, but its argument list is not derivable from its binding: it is bound only by the register VM's name table, where the C signature is the generic (vm, N) wrapper form. Named here so an absence is never mistaken for non-existence.

Maps carry the same single cursor as lists, over their entries in HASH order -- which is UNSPECIFIED by contract and is NOT insertion order. Do not write code that depends on it; put a sortndx over the map when you need a defined order. This rewinds the walk; it does not remove anything (use mapClear to empty the map).

mthe map to rewind

returns void

mapvalue(m)

the VALUE of the map entry the cursor is standing on

Callable, but its argument list is not derivable from its binding: it is bound only by the register VM's name table, where the C signature is the generic (vm, N) wrapper form. Named here so an absence is never mistaken for non-existence.

The companion to mapKey, over the same cursor position. With the cursor off an entry it answers the value type's zero -- and because a stored zero reads the same way, let mapNext's own 1/0 end the walk rather than the value.

mthe map to read

returns the value of the current entry; the value type's zero when the cursor is not standing on one

maxof(c)

the largest element of a container

Callable, but its argument list is not derivable from its binding: it is bound only by the register VM's name table, where the C signature is the generic (vm, N) wrapper form. Named here so an absence is never mistaken for non-existence.

UNIVERSAL: one name over arrays, lists, maps and JSON arrays, because all of them reduce through the same core on both backends. Over a MAP it reduces the VALUES. Elements that are not numbers are coerced leniently (a numeric string parses, a bool counts 0 or 1), the same rule the rule engine uses. INT-PRESERVING: an all-int sequence answers an INT; the first float element promotes the whole reduction to float. An empty container answers 0.

cthe array, list, map or JSON array to reduce

returns the largest element; 0 when empty

top.i = maxof(scores);
minof(c)

the smallest element of a container

Callable, but its argument list is not derivable from its binding: it is bound only by the register VM's name table, where the C signature is the generic (vm, N) wrapper form. Named here so an absence is never mistaken for non-existence.

UNIVERSAL: one name over arrays, lists, maps and JSON arrays, because all of them reduce through the same core on both backends. Over a MAP it reduces the VALUES. Elements that are not numbers are coerced leniently (a numeric string parses, a bool counts 0 or 1), the same rule the rule engine uses. INT-PRESERVING: an all-int sequence answers an INT; the first float element promotes the whole reduction to float. An empty container answers 0.

cthe array, list, map or JSON array to reduce

returns the smallest element; 0 when empty

reverse(c)

reverse the order of a container, in place

Callable, but its argument list is not derivable from its binding: it is bound only by the register VM's name table, where the C signature is the generic (vm, N) wrapper form. Named here so an absence is never mistaken for non-existence.

IN PLACE, like sort: the container itself is rearranged. Pairing it with sort is the other way to get a descending order. An empty or single-element container is left as it is rather than refused.

cthe list or array to reverse

returns void

listSort(rows); reverse(rows);   // descending
sort(c, desc)

sort a list or array in place, ascending by default

Callable, but its argument list is not derivable from its binding: it is bound only by the register VM's name table, where the C signature is the generic (vm, N) wrapper form. Named here so an absence is never mistaken for non-existence.

IN PLACE: it rearranges the container rather than returning a sorted copy, so the original order is gone. An optional second argument of 1 sorts descending. Elements compare by their natural order -- numeric for numbers, byte order for strings. sortCmp is the form that takes your own comparator.

cthe list or array to sort
desc1 to sort descending; omit or 0 for ascending

returns void

sort(scores, 1);
sortarray(array)

sort an array in place (compiler-emitted)

Callable, but its argument list is not derivable from its binding: it is bound only by the register VM's name table, where the C signature is the generic (vm, N) wrapper form. Named here so an absence is never mistaken for non-existence.

EMITTED BY THE COMPILER, not written by hand: this is the lowering CX generates for an array sort. Prefer the CX spelling `sort(a)`.

arraythe array to sort

returns void

sort(a);
sortcmp(c, cmp)

sort a list or array in place using a CX function as the comparator

Callable, but its argument list is not derivable from its binding: it is bound only by the register VM's name table, where the C signature is the generic (vm, N) wrapper form. Named here so an absence is never mistaken for non-existence.

The comparator is one of your own functions, and it follows C ordering: return negative if the first argument sorts first, 0 if they tie, positive if the second sorts first. In place, like sort.

cthe list or array to sort
cmpthe comparator function to order by

returns void

sum(c)

add up every element of a container

Callable, but its argument list is not derivable from its binding: it is bound only by the register VM's name table, where the C signature is the generic (vm, N) wrapper form. Named here so an absence is never mistaken for non-existence.

UNIVERSAL: one name over arrays, lists, maps and JSON arrays, because all of them reduce through the same core on both backends. Over a MAP it reduces the VALUES. Elements that are not numbers are coerced leniently (a numeric string parses, a bool counts 0 or 1), the same rule the rule engine uses. INT-PRESERVING: an all-int sequence answers an INT; the first float element promotes the whole reduction to float. An empty container answers 0.

cthe array, list, map or JSON array to reduce

returns the total -- int if every element was an int, otherwise float; 0 when empty

total.i = sum(scores);
varslot(v)

the address of a fixed-size local, as an integer slot value

An ESCAPE HATCH, not everyday CX. Its live consumer is the native-only three-argument form of jsonLoadArray, which needs an array's address to fill it in place. Prefer passing the array directly -- an array handed to a function is byref already (rule 31).

vthe local whose address is wanted

returns the variable's address as an integer

jsonLoadArray(data, path, varslot(arr));

sql 6 builtins

sqltable(name, doc)

offer a CX container to SQL under a table name

REGISTRATION IS THE BOUNDARY. A query -- including one written by a rule or by a model -- can reach only what the program deliberately offered here, so the set of registered names is the whole of what SQL can see. Registering costs nothing until SQL actually runs: the engine is opened lazily. The registered name IS the SQL table name, so it reads back in the query text. Columns are derived from the data -- the union of the keys of the leading rows, because ragged rows are normal here -- and a key missing from a row reads as SQL NULL. A query that must tell "absent" from "null" asks for the hidden `<column>_set` companion, per query. A duplicate name is REFUSED, never a silent rebind.

namethe SQL table name
doca json array of objects

returns 1 on success, 0 on refusal (the reason is in sqlError())

sqlTable("orders", orders);
sqlindex(table, column)

build a sortndx index over one column of a registered table

The index is a sortndx of { key, row } pairs: the column's value, and the json node handle of the row it came from. The planner then answers an equality on that column by binary search instead of walking every row. WHETHER IT WAS USED IS READ OFF THE PLAN, never asserted -- `EXPLAIN QUERY PLAN select ...` prints "VIRTUAL TABLE INDEX 0:" for a scan and "INDEX 1:" for an index hit. Building it is a DELIBERATE ACT, not something the engine does behind the program's back: an index costs memory and time nobody asked for, and the pattern this layer formalises is load the data, build the index you need, then refine locally. If the document is MUTATED afterwards the index is not silently trusted -- the planner sees the revision has moved and falls back to a scan, so the answer stays correct and only the speed is lost. Rebuild to get the speed back. Cut 1 indexes NUMERIC columns. A text column is refused by name and still queries perfectly well; it simply scans.

tablea name previously given to sqlTable
columnthe column to index

returns 1 when built, 0 on refusal (the reason is in sqlError())

sqlIndex("orders", "total");
sqlclose(alias)

detach a database file opened by sqlOpen

`DETACH`, and the engine's own refusal when the alias names nothing. Closing is rarely necessary -- the connection is torn down with the program -- but ten is the engine's limit on attached files, so a program that walks many databases must let go of each one.

aliasthe alias given to sqlOpen (or "disk" for its default)

returns 1 when detached, 0 on refusal (the reason is in sqlError())

sqlClose("disk");
sqlerror()

what the last SQL call objected to, or "" when it was happy

The one place a failure is explained, whether it came from the engine, from the table module, or from registration. Handing this text back to a model is what turns a refused statement into a corrected one.

returns the message, or an empty string

if (rows == 0) println(sqlError());
sqlquery(sql, params)

run one SQL statement over the registered containers

Returns a json ARRAY OF OBJECTS keyed by the statement's own column names -- self-describing, printable, and the shape a demo can put on screen. An empty result is an empty array, not an error. `?` parameters take their values from `params`, a json array, in order. A string binds as TEXT through the engine's own parameter machinery and is never concatenated into the statement, so a hostile value stays data and cannot become syntax. A statement that will not prepare is a loud error naming what the engine objected to (FP4) and the call answers 0 -- which is also the text an AI authoring loop re-asks with, exactly as a rule that will not compile is. EXPLAIN needs no second builtin: `EXPLAIN QUERY PLAN select ...` is SQL, so it comes back through here as an ordinary rowset. Stage A is READ ONLY. An insert/update/delete against a registered container is refused by name rather than half-supported.

sqlthe statement
paramsa json array of `?` values (omit when there are none)

returns a json array handle, or 0 on error (see sqlError())

json rows = sqlQuery("select * from orders where total > ?", p);
sqlopen(path, alias)

attach a database FILE beside the in-memory engine

THE ENGINE COULD ALWAYS DO THIS; WHAT WAS MISSING WAS THE DOOR. The amalgamation is compiled with no SQLITE_OMIT_ATTACH, so `ATTACH DATABASE ? AS disk` has worked from a CX program since stage A shipped -- measured on both backends before this builtin was written. Stage A's "never a file" is a true statement about this file's own code and was never one about the engine it opens. The path is BOUND, never spliced: a program names its database with a value it computed, which is what "never hardcode paths" requires of the language that has to obey it too. The alias is not bindable -- SQL has no parameter in that position -- so it is validated as a plain identifier instead, because it becomes part of the query text the program then writes ("select * from disk.t"). A quoted alias would be SAFE and unusable; both are refused. After it succeeds, EVERYTHING ELSE IS ALREADY SQL. A registered container and a disk table join in one statement; `INSERT INTO disk.t SELECT ... FROM <container>` saves the container; `SELECT` reloads it into a json array that sqlTable can offer straight back. No save verb and no load verb are added, because both already exist and are spelled in SQL. A `.db` is NOT a document format: parseDoc takes TEXT and a database is a binary meant not to fit in memory (his ruling, packet D2). Those spellings refuse by name and point here. IN WASM THERE IS NO ENGINE AT ALL -- measured: the module carries zero `sqlite` and zero `cxtable` symbols -- and a browser file would live in MEMFS and die with the tab, so this refuses there by name rather than pretending.

paththe database file; it is created if it does not exist
aliasthe schema name to reach it by (default "disk")

returns 1 when attached, 0 on refusal (the reason is in sqlError())

if (!sqlOpen(dbPath)) { println(sqlError()); }

queues 3 builtins

queueinit(q, capacity, policy)

give a declared queue its capacity and overflow policy

The ring is allocated ONCE here and never grows -- that is the point over a list for a per-frame fact queue. Re-initialising with the SAME capacity+policy clears and reuses the queue (so a per-bout setup call is harmless). Re-initialising with a DIFFERENT capacity or policy is a LOUD ERROR, not a silent resize -- use queueClear to empty a queue.

qthe queue (declared `queue q.i;`)
capacityring size. ANY RUNTIME EXPRESSION -- this is the whole point over an array dimension, which must be a literal or #define: the bound can come from json. Must be > 0 (loud otherwise).
policy0 = REJECT (push on full is a loud error -- a dropped item is a lost fact), 1 = ROLL (push on full overwrites the OLDEST, giving a "last N" sliding window; count pins at capacity).

returns void

queueInit(impacts, lookup(cfg, "maxImpact"), 0)
queueclear(q)

drop every pending element, keeping capacity and policy

This is the DATA op; queueInit is the SHAPE op. One name each. THE per-bout op -- it is what replaces the game's `impN = 0`.

qthe queue

returns void

queueClear(impacts)     // per bout: forget last bout's facts
queuevalid(h)typed handles use ->

Write h->valid — on a declared container the call form is refused with CX-E1110, and on a handle held in a plain int it is refused too, as CX-E1028 (his ruling of 2026-09-04 ended the int-held call form; a container is declared as what it is). This name survives on the user surface for the one question the arrow cannot ask — whether a RAW HANDLE NUMBER that no variable names is still live — and it is also the runtime target that ->valid lowers to. This holds in #pragma rules c rule text too since v3.187.0 — for one release rule text was the single place the call form stayed legal, because -> could not be written there at all.

is this handle a live queue?

The lowering target of `q->valid`. Unlike every other reader here it does NOT terminate on a bad handle: the others are entitled to treat one as a caller bug, whereas here the bad handle IS the answer being asked for.

hqueue handle (any integer -- an out-of-range or freed one is fine)

returns 1 when `h` names a live queue, 0 otherwise

if (q->valid) { print(q->count); }

Data & serialization 2 families · 75 builtins

json 60 builtins

jsonfree(doc)

destroy a JSON document and reclaim its node-pool subtree

The unconditional (non-ARC) free; `doc` must be a root/DOC handle. Idempotent on 0 / -1.

doca doc/root handle from parseDoc or a json declaration

returns void

jsonfree(doc);
jsondecref(handle)

decrement a JSON handle's owning-doc refcount (ARC)

At refcount 0 the whole doc subtree (and its strings) is reclaimed. Codegen-emitted at scope-exit/overwrite; also user-callable. Safe no-op on a freed/rootless handle.

handleany json handle (a doc or a doc["k"] view)

returns void

jsondecref(doc);
jsonlivecount()

undocumented

jsonincref(handle)

increment a JSON handle's owning-doc refcount (ARC)

Codegen-emitted on a json-handle copy/alias so a shared doc isn't freed early; also user-callable.

handleany json handle (a doc or a doc["k"] view)

returns void

jsonincref(doc);
jsonvalue(doc)

unwrap a parsed document to its root value node

DOC-only: always returns the doc wrapper's first child. Use it before the raw jsonMember/jsonElement/jsonSize accessors (which do not deref a doc).

doca doc handle from parseDoc

returns the root value node handle; 0 if the doc handle is invalid

root = jsonvalue(doc);
jsonfirst(node)

start a walk over a json container's members

Seeds the container's cursor at its first member and returns that member's handle, or 0 if there are none. Takes the CONTAINER on every call, never the element it hands back. A parsed document is deref'd first, so this walks the document's members rather than the wrapper.

nodejson array/object (or a document handle wrapping one)

returns the first member's handle, 0 when empty or not a container

h = jsonFirst(items); while (h != 0) { print(jsonAsStr(jsonGet(items))); h = jsonNext(items); }
jsonnext(node)

advance a walk started by jsonFirst

Takes the SAME container handle jsonFirst was given -- not the handle it returned. Returns the next member's handle, or 0 at the end.

nodethe container being walked (a document handle is deref'd)

returns the next member's handle, 0 when the walk is finished

h = jsonNext(items);
jsonget(node)

read the member the walk is currently on

Takes the container, and returns the member handle the cursor sits on -- the accessor a `foreach` body uses to reach the current element. 0 before the walk starts or after it ends.

nodethe container being walked (a document handle is deref'd)

returns the current member's handle, 0 when the cursor is not on one

foreach items { total = total + jsonAsInt(jsonGet(items)); }
jsonkey(node)

the KEY of the member this container's cursor is on

jsonGet's twin: same argument (the CONTAINER being walked, not the member), same both-backend binding, so one `foreach` body can read a name and a value without changing what it passes. foreach doc { println(jsonKey(doc) + " = " + str(jsonGet(doc))); } WITHOUT IT A JSON OBJECT COULD NOT BE ENUMERATED IN CX AT ALL (D27, filed 2026-08-19 by the page-renderer arc, fixed v3.307.0). The walk could reach every value and never learn a single name; `mapKey(doc)` -- which the foreach refusal CX-E0044 pointed at -- refused natively and answered a silent 0 on the register VM, so the language's own advice named the one thing that does not work.

nodethe container being walked (a document handle is deref'd)

returns the member's key, or "" for an ARRAY element (which has no name) and for a cursor that is not on anything

k = jsonKey(doc);
jsonmember(obj, key)

look up a key on a JSON object

Linear scan of the object's members by key. Raw form -- does NOT deref a DOC wrapper (use jsonValue first or the doc-aware jsonMemberV / j["key"]).

objthe object value node to search
keythe member key string to find

returns the member value node handle; 0 if the key is absent

nameV = jsonmember(root, "name");
jsonelement(arr, idx)

fetch an array element by index

Walks the child list to the given 0-based index. Raw form -- does NOT deref a DOC wrapper.

arrthe array value node
idxthe 0-based element index

returns the element node handle; 0 if the index is out of range

assertFloatEqual(10.0, jsonnumber(jsonelement(arrRoot, 0)));
jsonstring(node)

read the text of a JSON string node

Raw accessor: returns the node's stored string handle with no coercion or DOC-deref (a number/bool/container reads empty -- use jsonAsStr to coerce).

nodea string value node

returns the string handle; the empty string for a non-string or invalid node

jName.s = jsonstring(jsonmember(jh, "name"));
jsonnumber(node)

read the numeric value of a JSON number node as a float

Raw accessor returning the node's numv (the double mirror, kept in sync with the int64 lane); no coercion or DOC-deref (a bool/string reads 0.0 -- use jsonAsFloat to coerce).

nodea number value node

returns the numeric value as a float; 0.0 for a non-number or invalid node

jAge.f = jsonnumber(jsonmember(jh, "age"));
jsonbool(node)

read the boolean value of a JSON bool node

Raw accessor returning the node's stored 0/1; no coercion or DOC-deref.

nodea bool value node

returns 1 for true, 0 for false; 0 for a non-bool or invalid node

jActive.i = jsonbool(jsonmember(jh, "active"));
jsonisnull(node)

test whether a JSON node is null or absent

True for both an explicit JSON null and a missing/invalid handle (both report type 0).

nodeany json value node

returns 1 if the node's type is null (incl. missing key/invalid handle); 0 otherwise

assertEqual(1, jsonIsNull(j["nada"]));
jsonasstr(node)

coerce a JSON value to a string

Derefs a DOC. string -> its text; number -> exact int64 digits or %g; bool -> "true"/"false"; object/array/null -> empty. Read-coercion behind `string x = json[...]`; also user-callable.

nodeany json value node

returns the coerced string handle; empty for an object/array/null/invalid node

printf("%s ", jsonAsStr(jsonGet(tags)));
jsonasint(node)

coerce a JSON value to an int

Derefs a DOC. int-born number -> exact int64; bool -> 0/1; numeric string -> strtoll; float number -> truncated. Read-coercion behind `int x = json[...]`; also user-callable.

nodeany json value node

returns the coerced int value; 0 for a null/container/invalid node

int ri = e["hull"];
jsonasfloat(node)

coerce a JSON value to a float

Derefs a DOC. bool -> 0.0/1.0; numeric string -> strtod; number -> its value. Read-coercion behind `float x = json[...]`; also user-callable.

nodeany json value node

returns the coerced float value; 0.0 for a null/container/invalid node

float lat = e["lat"];
jsonlen(node)

length of a JSON value dispatched by node kind

The data makes the choice: string -> text length, array/object -> child count, number/bool -> its text-form length, null/missing -> 0. `len()`/`length()`/`strlen()` on a json route here; also user-callable.

nodeany json value node

returns the kind-appropriate length; 0 for null/missing/invalid

int n = len(doc);
jsonundecided()

create a new document whose ROOT KIND is not yet decided (a DOC wrapping a json null)

What a bare `json d;` declaration mints on both backends, and the constructor form of that declaration -- and, since 3.3.003.0 folded xml onto json, the declaration an XML document is built from as well (`d->doc->format = CX_XML`). It commits to nothing: the FIRST subscript decides, a string key making the root an object and an int index making it an array, exactly as a json null nested one level deep has been promoted since v3.264. A root that has already been decided -- by a parse, or by an earlier subscript -- refuses the other kind loudly instead, because a write creates and never converts.

returns an ARC-owned DOC handle (freed once at scope exit) whose value node is a json null, so an untouched document exports as `null`.

json d;  d["servers"][0]["host"] = "alpha";   \ the declaration is the constructor
jsonobjroot()

create a document whose root is an empty OBJECT -- the object-literal root

Emitted by both backends as the root of a `json j = {k: v}` object literal, where the literal itself states the root's kind so there is nothing left for a subscript to decide. NOT a general constructor: a bare `json d;` mints an UNDECIDED root instead, and the CX spelling that used to reach this C function is retired to CX-E1048 because committing the root's kind at construction is what made an int-indexed store into it vanish.

returns a new doc handle whose root value is an empty object; 0 on node-pool exhaustion

json ship = {name: "Falcon", hull: 100};
jsonarrroot()

create a document whose root is an empty ARRAY -- the array-literal root

The array twin of jsonArrRoot's sibling, emitted as the root of a `_json h { [ ... ] }` literal. Same reasoning: the literal states the kind. The retired CX spelling committed an array root, which silently filed a KEYED store at an index and threw the key away.

returns a new doc handle whose root value is an empty array; 0 on node-pool exhaustion

_json ids { [1, 2, 3] }
jsonarr()

create a new empty JSON array node

A bare array node (not a doc wrapper) for the nested-build API; append with jsonArr* / jsonArrAdd, then nest via jsonAddNode/jsonArrAdd.

returns a fresh empty array node handle; 0 on pool exhaustion

json arr = jsonArr();
jsonobj()

create a fresh empty json OBJECT node (unparented) for nesting

The object twin of jsonArr() -- the build-API asymmetry-closer. Unlike a json ROOT it is NOT a DOC wrapper and NOT an ARC creator, so it never double-frees when moved into another container's tree.

returns an object-node handle whose parent is 0, ready to fill with jsonAdd-family calls and then MOVE into a parent via jsonAddNode/jsonArrAdd (freed exactly once with its owning root).

o = jsonObj();
jsonarrnum(arr, v)

append a float number element to a JSON array

arrthe array node to append to
vthe float value to append

returns void

foreach scores { jsonArrNum(arr, listGet(scores)); }
jsonarrint(arr, v)

append an int64-exact number element to a JSON array

Stores the value exactly (int-born node), avoiding the double rounding that corrupts integers past 2^53.

arrthe array node to append to
vthe int value to append

returns void

jsonArrInt(arr, 9007199254740993);
jsonarrstr(arr, v)

append a string element to a JSON array

arrthe array node to append to
vthe string value to append

returns void

jsonArrStr(slots, mods[slotMod[si]].id);
jsonarrbool(arr, v)

append a boolean element to a JSON array

arrthe array node to append to
vthe value; stored as 1 if nonzero else 0

returns void

jsonArrBool(arr, 1);
jsonarrnull(arr)

append a null element to a JSON array

arrthe array node to append to

returns void

jsonArrNull(arr);
jsonarradd(arr, node)

append a node (object/array) as the next array element

Appends the node. A node from ANOTHER document -- or a whole document -- arrives as a deep copy and the source keeps its own; an unowned build part (a jsonObj()/jsonArr() not yet placed) moves in as it is; a node already in this array's document is refused (CX-E1140), because a node lives in exactly one container. To move one within a document, remove it and then add it.

arrthe array node to append to
nodethe object/array node to append (must be freshly built, unparented)

returns void

jsonArrAdd(els, e);
jsonarrnode(arr, src)

append a json VALUE to an array, copying by the source node's TYPE

The append twin of jsonSetElemNode, and the reason both exist: a json subscript read yields a node HANDLE, so appending it with jsonArrNum stores that handle as a number. Distinct from jsonArrAdd, which MOVES a node and refuses one already owned by this document.

arra json ARRAY node (or a document whose root is one)
srcthe value node to copy -- string, number, bool, null, blob, or a whole object/array, which is deep-copied
jsonArrNode(out, row["name"]);
jsonarrdel(arr, idx)

remove element `idx` from a json ARRAY and free its subtree

THE REMOVE SIDE OF A BUILD API THAT ONLY HAD AN ADD SIDE. Eight builtins append an element and none removed one, so an indexed json set could not delete a key -- the one thing an AVL tree does that the indexed answer to the same problem could not. Index is ZERO-BASED, which is now the whole language rather than a convention with an exception in it; the elements after `idx` shift down by one, exactly as an array delete reads. IT BUMPS THE DOCUMENT REVISION, and that is not bookkeeping. An sqlIndex baked over this array re-bakes because the counter moved, so a deleted row stops answering an indexed equality query. A delete that reported success and left the counter still would be worse than no delete at all -- the program would believe it had removed something.

arrjson array (a document handle is deref'd); an object or a scalar is refused loudly, because "delete element 0 of this number" has no honest answer
idxzero-based; negative or past the last element is refused loudly and nothing changes

returns 1 when an element was removed, 0 on any refusal (never fatal, FP4)

if (jsonArrDel(rows, 3)) { printf("%d left\n", rows->count); }
jsonaddnode(obj, key, node)

nest a node (object/array) under an object key

The object counterpart of jsonArrAdd, and the same rule under a key: a deep copy when the node comes from another document, the node itself when it is an unowned build part, and CX-E1140 when it is already in this object's document.

objthe object node to add to
keythe member key for the nested node
nodethe object/array node to nest (must be unparented)

returns void

jsonAddNode(doc, "slots", slots);
jsonaddnum(obj, key, v)

append a float member to a JSON object

Pure append (part of the build API) -- allows ordered duplicates while constructing; use jsonSet* for update-or-append semantics.

objthe object node to add to
keythe member key
vthe float value

returns void

jsonaddnum(built, "count", 42.0);
jsonaddint(obj, key, v)

append an int64-exact member to a JSON object

Int-born node stored exactly (no double rounding past 2^53). Emitters route here when the RHS is statically int.

objthe object node to add to
keythe member key
vthe int value

returns void

jsonAddInt(obj, "id", 9007199254740993);
jsonaddstr(obj, key, v)

append a string member to a JSON object

Pure append (build API); use jsonSetStr for update-or-append.

objthe object node to add to
keythe member key
vthe string value

returns void

jsonAddStr(mE, "axis", "ENERGY");
jsonaddbool(obj, key, v)

append a boolean member to a JSON object

objthe object node to add to
keythe member key
vthe value; stored as 1 if nonzero else 0

returns void

jsonaddbool(built, "active", 1);
jsonaddnull(obj, key)

append a null member to a JSON object

objthe object node to add to
keythe member key

returns void

jsonAddNull(obj, "note");
jsonrootname(node)

the element name an xml export uses for the document root

Defaults to "root"; a document parsed FROM xml remembers the name it had, so xml -> json -> xml round-trips it instead of renaming the user's root.

nodeany json node handle

returns the root element name

s = jsonRootName(doc);
jsonsetrootname(node, name)

name the element an xml export uses for the document root

nodeany json node handle; the owning document is what changes
namethe element name
d->doc->format = CX_XML; jsonSetRootName(d, "catalog");
jsonsetnum(obj, key, v)

set an object member to a float, updating in place or appending

mapPut / `obj["k"]=v` semantics: updates the existing key or appends if absent -- never a shadowing duplicate. Codegen target of a float subscript write; also user-callable.

objthe object node
keythe member key
vthe float value to store

returns void

jsonSetNum(obj, "speed", 3.5);
jsonsetint(obj, key, v)

set an object member to an int64-exact number, update-or-append

Int64-exact lane of jsonSetNum (mapPut semantics).

objthe object node
keythe member key
vthe int value to store

returns void

jsonsetint(gCgiFonts, sFont, nH);
jsonsetstr(obj, key, v)

set an object member to a string, update-or-append

String lane of the mapPut/subscript-write setters.

objthe object node
keythe member key
vthe string value to store

returns void

jsonsetstr(gCgiVars, sVar, sVal);
jsonsetnode(obj, key, src)

copy a JSON value node into obj[key] by the source's type

For `dst["k"] = src[...]`: a subscript read yields a node handle, so this stores by the source's TYPE, not the handle. A single value copies by value; an object or array from ANOTHER document arrives as a whole deep copy and the source keeps its node; one already in the destination's document is refused (CX-E1140) -- a same-document move is remove, then add.

objthe destination object node
keythe destination member key
srcthe source json value node to copy from

returns void

jsonSetNode(dst, "hp", src);
jsonrmwnum(obj, key, op, rhs)

fused read-modify-write of an object member in one key walk (float lane)

Computes obj[key] = obj[key] op rhs with a single key lookup. Missing key reads 0.0 and creates. Deliberate div/mod unification: '/' is C float (x/0=inf), '%' is fmod with mod-0=0. An unknown op reports and writes nothing (FP4). User-callable and the auto-fusion target for `e[k] = e[k] op v`.

objthe object node
keythe member key
opa one-character op string: "+" "-" "*" "/" "%"
rhsthe float right-hand operand

returns the new float value (assignment reads as its RHS); on a bad op returns the unchanged member value with no write

float v = jsonRmwNum(e, "hp", "+", 5.0);
jsonrmwint(obj, key, op, rhs)

fused read-modify-write of an object member in one key walk (int64-exact lane)

Int-exact when the member is int-born (or missing) and op is + - * % ; '/' always falls to the float lane (div/mod unification). Missing key reads 0 and creates.

objthe object node
keythe member key
opa one-character op string: "+" "-" "*" "/" "%"
rhsthe int right-hand operand

returns the new int value; on a bad op returns the unchanged value with no write

int v = jsonRmwInt(e, "hp", "-", 3);
jsonrmwstr(obj, key, op, rhs)

fused read-modify-write string concat of an object member in one key walk

Computes obj[key] = obj[key] + rhs (the read coerces to text). '+' is the only string RMW op; any other reports and writes nothing. Missing key concats from "" and creates.

objthe object node
keythe member key
opa one-character op string -- only "+" (concatenate)
rhsthe string to concatenate

returns the new concatenated string; on a bad op returns the unchanged member text with no write

string s = jsonRmwStr(e, "log", "+", "!");
jsonload(path)

read a file and parse it into a JSON document

Batteries-included sugar: fread + parseDoc in one call. A PATH door rather than a format one, so it reads whatever the file holds -- json, xml, csv or plain text -- and the document remembers which in its ->doc->format.

paththe file path to read

returns the parsed doc handle; -1 on a parse error (an empty/missing file parses as failure)

json doc = jsonLoad("data/eventsrules.json");
jsonsave(path, j)

serialize a JSON value (compact) and write it to a file

exportDoc + fwrite in one call.

paththe destination file path
jthe json value/doc to serialize

returns the fwrite status int (nonzero/bytes on success, 0 on write failure)

jsonSave("data/lastloadout.json", doc);
jsonsavepretty(path, j)

serialize a JSON value (pretty) and write it to a file

exportDoc(d, CX_JSON, 1) + fwrite in one call.

paththe destination file path
jthe json value/doc to serialize

returns the fwrite status int (nonzero/bytes on success, 0 on write failure)

jsonSavePretty("out.json", doc);
jsonflush(view)

write a file-bound JSON view's document to disk now if it is dirty

Manual flush point for a jsonBind LAZY-WRITE view (the exit hook is the backstop). No-op on an unbound handle or a clean doc.

viewa file-bound json view handle from jsonBind

returns void

jsonFlush(gBindings);
jsondirty(view)

test whether a file-bound JSON view has unsaved changes

viewa file-bound json view handle from jsonBind

returns 1 if the bound doc has pending changes; 0 if clean or the handle is unbound/invalid

if (jsonDirty(view)) { jsonFlush(view); }
jsonbound(view)

test whether a JSON handle is a file-bound view

viewany json handle

returns 1 if this json is a jsonBind view; 0 otherwise (unbound/invalid)

if (jsonBound(j)) { jsonFlush(j); }
jsonunbind(view)

release a file-bound JSON view

Decrements the shared doc's refcount; on the last view it flushes any pending changes then frees the whole doc. A still-shared doc just detaches this view's subtree.

viewa file-bound json view handle from jsonBind

returns void

jsonUnbind(view);
jsonbind(path, policy, subpath)

bind a JSON file to a policy-tagged view (shared doc, disjoint views)

One shared refcounted document per path with disjoint policy-tagged sub-views. policy 0=READONLY (a write is an FP4 error), 1=LAZY-WRITE (writes mark dirty, flushed at checkpoint/exit). The optional subpath (default "" = whole file) selects an existing top-level section. Overlapping views are rejected.

paththe JSON file path to bind
policy0 = READONLY, 1 = LAZY-WRITE
subpathoptional top-level member to view; omitted/empty = the whole file

returns a json view node handle; 0 on error (missing RO file, overlapping/duplicate view, subpath not found, parse failure)

gRules = jsonBind("data/eventsrules.json", JSON_READONLY, "rules");
exportdoc(node, format, options)

write a document out as text: its own format, or another lens

The document's own `->doc->format` decides WHICH text: json by default, and xml, csv or the bytes back unchanged when `parseDoc` read one of those or the program set `d->doc->format`. There is no second export verb for ANY of them -- the conversion is explicit because you asked for text, not because you remembered a different function name. WRITING THROUGH A LENS NAMES IT, exactly as reading through one does: `exportDoc(d, CX_CSV)` links the csv lens. Two dependencies are closed for you rather than left as a rule to remember -- an image is written by the BINARY writer and a hex dump is the same document as text, so naming either brings `CX_BINARY` with it. See `parseDoc` for the rule and the measured cost of each lens.

nodethe document or node to write out.
formatoptional; the LENS to write through. Omitted (CX_AUTO) means the document's own `->doc->format`, which is what the old name always did. Naming one converts: a json array out as CX_CSV, a tree out as CX_XML, any document out as CX_JSON.
optionsoptional; that lens's own options, the same json document `parseDoc` takes -- EXCEPT for CX_JSON, where it is `pretty`: 0 (the default) is compact machine output on one line, 1 is 2-space indented with a trailing newline. The format argument decides which of the two it is, so there is no ambiguity; the cost is that CX_JSON has no room for a second option. IGNORED for an XML document, and deliberately: the xml exporter's output is a fixed point (export, re-read, export is byte-identical), and indentation would put whitespace inside element text, where it is CONTENT.

returns the serialised text; the empty string for a dead handle (a READ never refuses -- see B_xml_write_through_dead_handle).

s.s = exportDoc(cfg);                // {"a":1} -- its own format
s.s = exportDoc(cfg, CX_JSON, 1);    // indented, for a person to read
s.s = exportDoc(img);                // an image back, byte-identical
s.s = exportDoc(msg, CX_EMAIL);      // RFC-822 again, attachments re-encoded
s.s = exportDoc(form, CX_FORM);      // a=1&b=x+y, a repeated key per element
s.s = exportDoc(rows, CX_CSV);       // a json array out as csv
parsedoc(src, format, options)

read a document of ANY format into the one json class

THE ONE DOOR. Written `parseDoc(src)`, a program never says what it was handed: a leading `{` or `[` is json, `<` is xml, and ANYTHING ELSE IS TEXT -- a document whose root is one string, which exports back as the bytes that came in. AUTO never refuses, so there is no outcome where a caller is given nothing and no reason. A DOCUMENT THAT OPENS WITH `<` IS ASKED TWO MORE QUESTIONS, because the file has already answered them and nobody names a format for a file they were handed. A root element of `svg` is read in ORDERED mode, where children keep document order instead of folding by name -- svg is painter's order, so the folded reading draws a different picture. A `<!DOCTYPE`, or a root element of `html`, is read TOLERANTLY: unclosed `p`/`li`/`td` closed by the rule the browsers share, void elements closed, `script` and `style` bodies kept verbatim, names folded, entities decoded. Both are the XML LENS IN ANOTHER MODE rather than parsers of their own, so the mapping never differs. CSV, BINARY AND HEX ARE NEVER SNIFFED, on purpose and for one reason: each of them is a claim about text that is ALREADY TRUE of text that means something else. A csv IS text that happens to contain commas, so a prose paragraph with one comma would become a one-column table. EVERY document is bytes, so sniffing binary would make nothing else reachable. And a bare hex string is also an ordinary word -- `deadbeef` is text, and answering with four bytes would be a guess. A wrong document is worse than no document, because a program acts on it. Ask for them: `parseDoc(s, CX_CSV)`, `parseDoc(s, CX_BINARY)`, `parseDoc(s, CX_HEX)`. CX SOURCE READS AS ROWS, AND THEY ARE THE RECORD'S OWN ROWS. `CX_CX` answers `{"lang", "count", "tokens": [[kind, text, subtype], ...]}` -- the same triple the compiler's journal stores, from the SAME mapping (compiler/srctok.c), so the lens and the record cannot drift. Measured across every CX file in the tree: 857 files, 708,797 rows, no disagreement. The reader is the compiler's own scanner and weighs about 811 KB, so a program ASKS for it with `#pragma lens cx` and one that does not is refused by name rather than handed an empty document. There is no WRITER: the standard form spells a name by its kind, which needs the compiler's classification of every name rather than a token stream, so `exportDoc(d, CX_CX)` says so instead of guessing. A FORM BODY IS A DOCUMENT TOO -- `a=1&b=x+y` is `{"a": "1", "b": "x y"}`, a repeated key is an array, and a space is `+` because this is FORM encoding and not URI encoding. AN IMAGE IS THE SAME DOCUMENT WITH ITS HEADER READ. `CX_IMAGE` recognises png, jpeg, bmp and gif, adds `width`, `height`, `channels` and `format`, and DECODES NOTHING -- `#bytes` is the whole file, which is what makes writing it back byte-identical a property of the design rather than of a re-encoder. `channels` is the header's SAMPLES PER PIXEL: a palette png stores one and means three, and knowing which would require reading the palette. Pixels stay a blob, which is his ruling -- a filename or a blob, as SQL does. The graphics family's `gpu_` names are untouched and remain the way to DRAW one. BINARY AND HEX ARE ONE LENS WEARING TWO COATS. Both answer the same document -- `{"#bytes": <blob>, "size": <n>, "mime": <string>}` -- so `bytes -> hex -> bytes` is the identity by construction. In memory a blob is NOT encoded; base64 appears only when a document carrying one is written as JSON TEXT, because json has no bytes type. Written through `CX_BINARY` the bytes go out as themselves, and through `CX_HEX` as the canonical `hexdump -C` form. An EXPLICIT format that will not parse is the refusal this parser already had: an invalid handle, `->valid` 0, the reason on stderr. The document remembers what it was read as in `->doc->format`, and `exportDoc` writes that format back out. `src` may also be a memfile or an embed() blob -- the emitter picks the row from the argument's type, so all three sources are this one spelling. WHICH LENSES YOUR PROGRAM ACTUALLY CARRIES, because it is not all of them and a caller has to know the rule. THE COMPILER LINKS THE LENSES YOUR PROGRAM NAMES: write `CX_IMAGE` anywhere in the source and the image lens is in your binary; never mention it and it is not. Naming the constant IS the asking -- there is no second thing to say. json, xml and text are always linked, which is why those three are also the only ones a sniff can reach. Measured, one small program per lens: binary +512 bytes, image +2,560, hex +3,072, form +3,584, email +6,144, csv +15,360 -- and cx +833,536, because the CX lens IS the compiler's own reader. That last number is the whole reason this is not simply "link everything". IF THE FORMAT IS DECIDED AT RUN TIME -- read from a file, a header, argv -- then nothing in the source names it, so say so once with `#pragma lens image` (or csv, email, binary, hex, form, cx). A lens that was not linked REFUSES and names that line; it never hands back an empty document.

srcthe document's text
formatCX_JSON | CX_XML | CX_CSV | CX_TEXT | CX_EMAIL | CX_SVG | CX_HTML | CX_BINARY | CX_HEX | CX_IMAGE | CX_FORM | CX_CX; omit it and let the document say what it is -- but the last SIX never answer a sniff, so they are only ever reached by being named
optionsa json document of per-lens options, or 0. csv takes `delim`, `header`, `widths` and `names`; xml takes `ordered`; html takes `tolerant`, which is TRUE by default -- pass `{"tolerant": false}` to turn the recovery rules off so a malformed page is refused rather than repaired. A lens that does not read options REFUSES them rather than ignoring them, so an option can never look as though it took effect

returns a json document handle; an invalid one when an EXPLICIT format will not parse

json cfg   = parseDoc(fread("config.json"));
json order = parseDoc(x, CX_XML);        // order["@id"], order["item"][0]
json page  = parseDoc(html, CX_HTML);    // unclosed tags closed for you
json logo  = parseDoc(fread("logo.svg"));  // ordered, by its root element
json sheet = parseDoc(fread("quarter.csv"), CX_CSV);
json note  = parseDoc(s, CX_TEXT);       // one string, whatever it holds
json msg   = parseDoc(fread("m.eml"), CX_EMAIL);  // msg["headers"]["subject"]
json blob  = parseDoc(fread("f.bin"), CX_BINARY); // blob["size"], blob["mime"]
json dump  = parseDoc("48 65 6c 6c 6f", CX_HEX);  // the same document as text
json img   = parseDoc(fread("logo.png"), CX_IMAGE); // img["width"]
json form  = parseDoc("a=1&b=x+y", CX_FORM);   // a space is `+`, not %20
json rows  = parseDoc(fread("main.cx"), CX_CX);  // the token rows
jsonadd(node, key, value)

add a value to a JSON node, picking the type for you

The arity-3 door onto the jsonAdd* family: it dispatches on the VALUE, so a string goes to jsonAddStr, a whole number to jsonAddInt and a fractional one to jsonAddNum. Before 3.1.051.0 it was wired to jsonAddStr alone, so passing a number produced a C compiler diagnostic quoting a runtime header rather than a CX error -- the call now simply works. Reach for the specific name when you want the type pinned.

nodethe array or object node
keythe key, or an empty string for an array append
valuethe string value

returns the result of the underlying add

jsonAdd(arr, "", "sword");
jsonloadarray(map, prefix, out)

fill a container from a flattened JSON map's indexed keys

Reads `<prefix>.0`, `<prefix>.1`, ... out of a map produced by parseFullJson. THE LIST FORM WORKS ON BOTH BACKENDS. There is also a native-only form taking `varslot(arr)` to fill a fixed array in place; the register VM REFUSES that form by name rather than silently filling nothing.

mapthe flat map from parseFullJson
prefixthe dotted key prefix
outa list (both backends), or varslot(array) on native only

returns the number of elements loaded; 0 on failure

jsonLoadArray(m, "items", xs);
parsefulljson(path, map)

read a JSON file and flatten it into a string map with dotted-path keys

The whole document becomes one flat map: `player.name` = "Alice", `items.0` = "sword", and a `items._count` entry giving each array's length. Useful when you want lookups by path rather than a walked tree.

paththe JSON file to read
mapthe string map to fill

returns non-zero on success; 0 on failure

parseFullJson("save.json", m);

memory files 15 builtins

mfnew()

create an empty memory file

The memory filesystem: a growable byte buffer with a cursor, addressed by an integer handle (0 means none). The same core both backends use, and the seam json and xml latch onto when they parse FROM memory rather than from a file.

returns a handle; 0 if allocation failed

h = mfNew();
mffree(handle)

release a memory file immediately

The memory filesystem: a growable byte buffer with a cursor, addressed by an integer handle (0 means none). The same core both backends use, and the seam json and xml latch onto when they parse FROM memory rather than from a file. Idempotent: a 0 or already-freed handle is ignored.

handlethe memory file

returns void

mfFree(h);
mfdecref(handle)

drop one owning reference to a memory file

EMITTED BY THE COMPILER at scope exit for an owned `memfile` local (the resource ARC model). You do not normally write this -- mfFree is the explicit release.

handlethe memory file

returns void

// emitted at scope exit
mflivecount()

count the memory files currently live

The memory filesystem: a growable byte buffer with a cursor, addressed by an integer handle (0 means none). The same core both backends use, and the seam json and xml latch onto when they parse FROM memory rather than from a file. The regression-test signal for a leaked handle, and visible in the `#pragma checks on` leak walk.

returns the number of live handles

n = mfLiveCount();
mfsize(handle)

the number of bytes a memory file holds

The memory filesystem: a growable byte buffer with a cursor, addressed by an integer handle (0 means none). The same core both backends use, and the seam json and xml latch onto when they parse FROM memory rather than from a file.

handlethe memory file

returns the used byte count

n = mfSize(h);
mfpos(handle)

the current cursor position

The memory filesystem: a growable byte buffer with a cursor, addressed by an integer handle (0 means none). The same core both backends use, and the seam json and xml latch onto when they parse FROM memory rather than from a file.

handlethe memory file

returns the cursor offset in bytes

n = mfPos(h);
mfseek(handle, offset)

move the cursor to a byte offset

The memory filesystem: a growable byte buffer with a cursor, addressed by an integer handle (0 means none). The same core both backends use, and the seam json and xml latch onto when they parse FROM memory rather than from a file. A NEGATIVE offset is refused rather than clamped.

handlethe memory file
offsetbyte offset from the start

returns 1 on success; 0 for a bad handle or a negative offset

mfSeek(h, 0);
mfputs(handle, s)

write bytes at the cursor, overwriting or extending

The memory filesystem: a growable byte buffer with a cursor, addressed by an integer handle (0 means none). The same core both backends use, and the seam json and xml latch onto when they parse FROM memory rather than from a file. The string's bytes are COPIED immediately, so the source may go out of scope straight after.

handlethe memory file
sthe bytes to write

returns the file's new size in bytes; 0 for a bad handle

mfPuts(h, "hello");
mfinsert(handle, s)

insert bytes at the cursor, shifting the rest along

The memory filesystem: a growable byte buffer with a cursor, addressed by an integer handle (0 means none). The same core both backends use, and the seam json and xml latch onto when they parse FROM memory rather than from a file. The in-place byte shift -- unlike mfPuts, nothing is overwritten.

handlethe memory file
sthe bytes to insert

returns 1 on success; 0 otherwise

mfInsert(h, "prefix ");
mftostr(handle)

read the whole buffer back as a string

The memory filesystem: a growable byte buffer with a cursor, addressed by an integer handle (0 means none). The same core both backends use, and the seam json and xml latch onto when they parse FROM memory rather than from a file.

handlethe memory file

returns the buffer's contents

s.s = mfToStr(h);
mfload(path)

load a disk file into a NEW memory file

The memory filesystem: a growable byte buffer with a cursor, addressed by an integer handle (0 means none). The same core both backends use, and the seam json and xml latch onto when they parse FROM memory rather than from a file.

paththe file to read

returns a handle to the new memory file; 0 on failure

h = mfLoad("in.txt");
mfsave(handle, path)

write a memory file's whole buffer to disk

The memory filesystem: a growable byte buffer with a cursor, addressed by an integer handle (0 means none). The same core both backends use, and the seam json and xml latch onto when they parse FROM memory rather than from a file.

handlethe memory file
paththe file to write

returns 1 on success; 0 on failure

mfSave(h, "out.txt");
mfinsertfile(handle, path)

splice a disk file into a memory file at the cursor

The memory filesystem: a growable byte buffer with a cursor, addressed by an integer handle (0 means none). The same core both backends use, and the seam json and xml latch onto when they parse FROM memory rather than from a file. The `#include` primitive of the beta era, kept because it is the natural way to assemble a document from parts.

handlethe memory file
paththe file to splice in

returns the number of bytes inserted, or -1 on failure

mfInsertFile(h, "part.txt");
embedsize(handle)

the byte length of an embedded blob

`embed` itself is compile-time codegen -- the bytes are baked into the binary -- so only the size and the reference are run-time operations.

handlethe embedded blob's handle

returns the blob's length in bytes

n = embedSize(logo);
embedref(index)

resolve an embedded blob's handle

Turns the compiler's blob index into a run-time handle, cached after the first call.

indexthe blob index the compiler assigned

returns the blob handle

h = embedRef(0);

Input/output & filesystem 2 families · 30 builtins

files & filesystem 19 builtins

fappend(path, content)

add text to the END of a file, creating it if it is not there

The append form of fwrite, and exactly `fwrite(path, content, 1)`: the same staged write, the same read-only refusal, the same answer. A named function rather than a macro over the three-argument call so ONE spec row binds it on both backends (GAP, 3.5.006.0); the fixed append flag is the whole of what it adds.

pathfile to append to; an empty path is a no-op returning 0
contentbytes to add at the end

returns 1 on success, 0 on failure. NOT a byte count.

fappend("log.txt", str(datetime()) + "  started\n");
fdelete(path)

delete a file. CX's portable delete

This is the one to reach for instead of shelling out: `system("del x")` is cmd.exe-only and `system("rm x")` is POSIX-only, so either one silently does nothing on the other platforms. Wraps C's remove(), so on POSIX it will also unlink an empty directory; removedir() is the explicit spelling for that.

pathfile to delete; an empty path returns 0

returns 1 if the file was deleted, 0 if it was not there or could not be removed

fdelete("scratch.tmp");
fileexists(path)

does anything exist at this path?

stat-based, so a DIRECTORY counts as existing. Use direxists() to tell the two apart, and note that fread() returning "" does not mean the file is missing. LINKS ARE FOLLOWED, on every OS. This answers about the file the path NAMES, not about a symbolic link or Windows junction pointing at it -- matching POSIX stat(), which is what CX has always done on macOS and Linux. Windows answered about the LINK until v3.204.1, because MSVCRT stat() reports the reparse point; see cx_path_stat in cx_builtins_string.c. A link whose target is missing or unreadable therefore reports ABSENT, not "a zero-byte thing that exists".

pathpath to test; an empty path returns 0

returns 1 if any filesystem object exists at path, else 0

if (fileexists("save.json")) { s.s = fread("save.json"); }
direxists(path)

is this path a directory?

LINKS ARE FOLLOWED, on every OS. This answers about the file the path NAMES, not about a symbolic link or Windows junction pointing at it -- matching POSIX stat(), which is what CX has always done on macOS and Linux. Windows answered about the LINK until v3.204.1, because MSVCRT stat() reports the reparse point; see cx_path_stat in cx_builtins_string.c. A link whose target is missing or unreadable therefore reports ABSENT, not "a zero-byte thing that exists".

pathpath to test; empty returns 0

returns 1 if path exists AND is a directory, else 0 (a plain file gives 0)

if (!direxists("out")) { makedir("out"); }
filesize(path)

a file's size in bytes

LINKS ARE FOLLOWED, on every OS. This answers about the file the path NAMES, not about a symbolic link or Windows junction pointing at it -- matching POSIX stat(), which is what CX has always done on macOS and Linux. Windows answered about the LINK until v3.204.1, because MSVCRT stat() reports the reparse point; see cx_path_stat in cx_builtins_string.c. A link whose target is missing or unreadable therefore reports ABSENT, not "a zero-byte thing that exists".

pathfile to measure

returns the byte size, or -1 if the path is empty, does not exist, or is a directory. -1 rather than 0, so an empty file (0) stays distinguishable from a missing one.

n.i = filesize("data.bin");
makedir(path)

create one directory

Creates a SINGLE level: it is mkdir, not `mkdir -p`, so creating "a/b/c" needs a call per level. Created 0755 on POSIX.

pathdirectory to create; empty returns 0

returns 1 on success, 0 on failure -- INCLUDING when the directory already exists. Guard with direxists() rather than treating 0 as fatal.

if (!direxists("out")) { makedir("out"); }
removedir(path)

remove an EMPTY directory

Refuses a non-empty directory; there is no recursive delete builtin, so clearing a tree means dirlist() + fdelete() per entry, then this.

pathdirectory to remove; empty returns 0

returns 1 on success, 0 on failure (missing, not a directory, or not empty)

removedir("scratch");
renamefile(oldp, newp)

rename a file, or move it within one filesystem

A thin rename(): it does NOT cross devices, and on Windows it fails when the destination already exists. movefile() is the one that handles both.

oldpexisting path; empty returns 0
newpnew path; empty returns 0

returns 1 on success, 0 on failure

renamefile("draft.txt", "final.txt");
copyfile(srcp, dstp)

copy a file's bytes to a new path

Binary-safe, streamed in 16KB blocks, so file size is not bounded by memory. The destination is TRUNCATED if it exists. Copies content only -- permission bits and timestamps are not carried over.

srcpfile to read; empty returns 0
dstpfile to write; empty returns 0

returns 1 on success, 0 on failure (unreadable source, unwritable destination, or a short write partway through -- in which case the destination is left partially written, not removed)

copyfile("save.json", "save.bak");
movefile(srcp, dstp)

move a file, across devices if need be

Tries rename() first, which is atomic and instant when source and destination share a filesystem. If that fails it falls back to copyfile() + delete the source, which is how a move onto another volume succeeds. The fallback is not atomic: an interrupted cross-device move can leave both copies.

srcpfile to move; empty returns 0
dstpdestination path; empty returns 0

returns 1 on success, 0 if both the rename and the copy failed

movefile("out.log", "archive/out.log");
tempdir()

the system temporary directory, ready to concatenate

ALWAYS ends with a path separator, so `tempdir() + "scratch.tmp"` is a valid path with no separator handling at the call site. The separator matches the directory's own style: backslash if the environment's path contains one, else forward slash. Resolved from TMP, then TEMP, then TMPDIR, falling back to "/tmp" -- which covers Windows and POSIX without a platform branch.

returns the temp directory path, separator-terminated

f.s = tempdir() + "cx_scratch.txt";
dirlist(path, pattern, names)

list a directory's entries into a string list

Appends to `names` (it does not clear it first), so listing two directories into one list accumulates. "." and ".." are always skipped. The pattern is a tiny CASE-INSENSITIVE glob: `*` matches any run including empty, `?` matches exactly one character; there are no character classes. An empty pattern means `*`. Entry NAMES are returned, not paths -- join them to `path` yourself. Files and subdirectories are both listed, with no marker distinguishing them; call direxists() on the joined path to tell.

pathdirectory to scan; an empty path means the current directory
patternglob to match entry names against; empty means everything
namesstring list the matches are APPENDED to

returns the number of entries appended; 0 if the directory cannot be opened

list files.s
n.i = dirlist("data", "*.json", files);
fread(path)

read an entire file into a string

Reads in one shot, sized by a 64-bit tell, so files over 2GB are not truncated. Binary-safe: the result is a counted string, so embedded NULs survive.

pathfile to read

returns the file's contents; an EMPTY STRING if the path is empty, the file cannot be opened, or the file is zero bytes. A missing file and an empty file are indistinguishable in the return value -- use fileexists() when the difference matters.

s.s = fread("config.json");
fwrite(path, content, append)

write a whole file, replacing whatever was there

The write is STAGED: content goes to "<path>.tmp" and is renamed over the target only once it is fully written and closed, so a disk-full or short write leaves the original file intact rather than truncated. A read-only target is refused (probed with fopen "r+b", which neither truncates nor creates, so the answer honours ACLs and read-only mounts). On POSIX the original file's permission bits are re-applied after the rename, because the staged file would otherwise arrive with umask's bits -- a 0600 file came back 0644 before that.

pathfile to write; an empty path is a no-op returning 0
contentbytes to write; may be empty, which truncates the file to zero
append0 replaces the file, 1 appends to it (the CX `fappend` spelling)

returns 1 on success, 0 on failure. NOT a byte count.

ok.i = fwrite("out.txt", "hello\n");
print(v)

write a value to stdout followed by a NEWLINE

Callable, but its argument list is not derivable from its binding: it is bound only by the register VM's name table, where the C signature is the generic (vm, N) wrapper form. Named here so an absence is never mistaken for non-existence.

Takes any printable value and renders it the way CX renders it, so it needs no format string. printf is the format-string form and prt* the newline-free one.

vthe value to write

returns void

print "loaded " + str(n) + " rows";
printf(fmt)

write formatted text to stdout, C-style

Callable, but its argument list is not derivable from its binding: it is bound only by the register VM's name table, where the C signature is the generic (vm, N) wrapper form. Named here so an absence is never mistaken for non-existence.

The conversions are libc's -- and so is everything between the `%` and the conversion letter. FLAGS, WIDTH and PRECISION all work: `%-20s` left-justifies in a 20-column field, `%8s` right-justifies in 8, `%5d` pads a number to 5 columns, `%05d` pads it with zeros, `%+d` forces the sign, `%11.2f` gives 11 columns with 2 decimals, `%.2s` truncates a string to 2 bytes. That is what makes columnar output possible without a padding helper. TWO CX ADJUSTMENTS: `%d` carries a 64-bit integer (the compiler rewrites it upstream), and float output honours `#pragma decimals` -- so a BARE `%f` prints 3 decimals, not C's 6. Write `%.6f` when you want C's. sprintf is the same formatting returned as a string instead of printed.

fmtthe format string

returns void

printf("%-20s %5d %11.2f\n", item, qty, price);   // name, then two numeric columns
println(v)

write a value to stdout followed by a newline (the other name for print)

Identical to print; both append the newline.

vthe value to write

returns void

println(toupper(name));   // any expression, including another call
putc(ch)

write one character to stdout, by numeric code

Takes the character as a CODE, not a one-character string, and adds no newline. It is the raw single-byte counterpart to print.

chthe character code to write

returns the code written, as C's putchar does

putc(65);
stringsplit(s, sep, n)

pull one delimited field out of a string, counting from 0

ZERO-indexed, like every other index in the language: field 0 is the text before the first separator. Nothing is allocated for the fields you did not ask for, so this is the cheap way to read one column; use split() when you want them all. THE FIELD NUMBER IS THE LAST ARGUMENT, not the middle one: the separator sits beside the string it separates, the way `split(s, sep, out)` already reads. Written the other way round it is a TYPE error rather than a wrong answer -- the separator and the number land in each other's slots and the compiler refuses the call on both backends. A field number past the end returns "" -- indistinguishable from a genuinely empty field, because an empty field is a real value ("a,,c" has one at 1) and no return could mean "absent" and nothing else. Count with countstring() first when that matters.

sstring to split
sepseparator; an EMPTY separator makes the whole string field 0
n0-indexed field number

returns the field's text, or "" if n is past the last field or n < 0

city.s = stringsplit(row, ",", 2);

compression & archives 11 builtins

targz(out, src)

create a gzip-compressed tar (.tar.gz) archive from a file or directory

Format-specific alias over bi_archivecreate/cx_archive_create; recursively walks the source with libarchive and stores paths relative to the source root.

outdestination archive path to write
srcsource file or directory to pack (directories are walked recursively)

returns 0 on success; -1 on error (also -1 when the runtime was built without libarchive)

w1 = tarGz("R:/Temp/arctest/w.tgz", "R:/Temp/arctest/src");
tarbz2(out, src)

create a bzip2-compressed tar (.tar.bz2) archive from a file or directory

Alias over bi_archivecreate with format "tar.bz2"; same recursive libarchive packing as targz.

outdestination archive path to write
srcsource file or directory to pack (directories are walked recursively)

returns 0 on success; -1 on error (also -1 when built without libarchive)

w2 = tarBz2("R:/Temp/arctest/w.tbz", "R:/Temp/arctest/src");
tarxz(out, src)

create an xz-compressed tar (.tar.xz) archive from a file or directory

Alias over bi_archivecreate with format "tar.xz"; same recursive libarchive packing as targz.

outdestination archive path to write
srcsource file or directory to pack (directories are walked recursively)

returns 0 on success; -1 on error (also -1 when built without libarchive)

w3 = tarXz("R:/Temp/arctest/w.txz", "R:/Temp/arctest/src");
zipcreate(out, src)

create a ZIP archive from a file or directory

Alias over bi_archivecreate with format "zip"; uses libarchive's zip writer.

outdestination archive path to write
srcsource file or directory to pack (directories are walked recursively)

returns 0 on success; -1 on error (also -1 when built without libarchive)

w4 = zipCreate("R:/Temp/arctest/w.zip", "R:/Temp/arctest/src");
sevenzip(out, src)

create a 7z archive from a file or directory

Alias over bi_archivecreate with format "7z"; same recursive libarchive packing as the other archive builtins.

outdestination archive path to write
srcsource file or directory to pack (directories are walked recursively)

returns 0 on success; -1 on error (also -1 when built without libarchive)

w5 = sevenZip("R:/Temp/arctest/w.7z", "R:/Temp/arctest/src");
compress(s)

zstd-compress a string and return the compressed BYTES

The result is binary, not text: keep it in a string, measure it with `length`, and do not print it. Level 19 (strong) is fixed. A build without zstd returns an empty string rather than failing to link, so an empty result means either an error or an unsupported build.

sthe bytes to compress

returns the compressed payload, or an empty string on error

packed.s = compress(readFile(path));
decompress(s)

the inverse of `compress`

Needs no size hint: the zstd frame carries the original length. Input that is not a zstd frame gives an empty string rather than garbage.

sa payload produced by `compress`

returns the original bytes, or an empty string on error or non-zstd input

archivecreate(out, format, src)

pack a file or directory into an archive

A directory source is walked recursively and stored with paths relative to it. The named wrappers (`zip`, `targz`, ...) are this function with the format fixed, so use one of those unless the format is a variable.

outthe archive path to write
formatone of "7z", "zip", "tar", "tar.gz", "tar.bz2", "tar.zst", "tar.xz"
srcthe file or directory to pack

returns 0 on success; -1 on error. A runtime built WITHOUT libarchive does not return at all: it refuses with CX-E5047, naming the package and the rebuild (the -1-for-no-libarchive contract was retired 2026-08-25 -- a silent -1 is the FP4 class its siblings already refuse)

archiveCreate("out/release.zip", "zip", "build/dist");
archiveextract(in, destdir)

unpack an archive into a directory

The format is detected from the archive itself, not from the filename.

inthe archive to read
destdirthe directory to extract into

returns 0 on success; -1 on error. A runtime built WITHOUT libarchive does not return at all: it refuses with CX-E5047, naming the package and the rebuild (contract retired 2026-08-25).

base64enc(src)

encode bytes as base64 text (RFC 4648, with "=" padding)

The standard alphabet, NOT the URL-safe one: the output can contain "+" and "/" and needs escaping before it goes in a URL.

srcthe bytes to encode

returns the base64 text

auth.s = "Basic " + base64Enc(user + ":" + pass);
base64dec(src)

decode base64 text back to bytes

srcbase64 text, with or without padding

returns the decoded bytes; an empty input gives an empty string

Networking 2 families · 33 builtins

network 22 builtins

netlisten(port)

listen for TCP connections on a port, LOOPBACK only

Loopback is the default because a listening socket is an attack surface: this server is reachable from programs on this machine and from nothing else, which is what a demo, a test and a local tool all actually want. Reaching beyond the machine is a deliberate, differently-spelled act (see netListenAny), so exposure is never something a program does by accident. The result is a SERVER handle: pass it to netPoll to ask whether a connection is waiting and to netAccept to take one. It is not itself readable or writable.

portTCP port to bind (1-65535; below 1024 needs privilege)

returns a server handle > 0, or < 0 on failure -- netError() then has a NAMED reason ("the port is already in use"), never a bare errno

srv.i = netListen(7000);
netlistenany(port)

listen for TCP connections on a port, on EVERY interface

The explicit counterpart to netListen: this one is reachable from other machines. A distinct name rather than a flag argument is the point -- exposing a port beyond this machine should be visible at a glance and greppable across a codebase. On Windows the first such bind in a program's life may raise a one-time firewall prompt; that is machine configuration, not a program error.

portTCP port to bind (1-65535; below 1024 needs privilege)

returns a server handle > 0, or < 0 on failure (netError() has the detail)

srv.i = netListenAny(7000);
netaccept(server)

take the next incoming connection on a server handle

BLOCKS until a client arrives. To wait with a deadline -- or not to wait at all -- ask netPoll first: a zero timeout never blocks, and netPoll(srv, -1) blocks exactly as this does. The returned connection is independent of the server: closing it leaves the server listening, and closing the server does not close connections already accepted from it.

servera handle from netListen or netListenAny

returns a connection handle > 0, or < 0 on failure (netError() has the detail)

c.i = netAccept(srv);
netconnect(host, port)

open a TCP connection to a host and port

The host may be a name ("localhost", "build01.local") or a literal address ("127.0.0.1"); resolution covers IPv4 and IPv6. The attempt is BOUNDED by netTimeout(ms) -- the same knob the HTTP client uses -- so a peer that is switched off costs the timeout rather than the operating system's own retry policy, which can run to tens of seconds. Set netTimeout(1000) before sweeping a peer list.

hosthostname or literal IP address
portTCP port to connect to

returns a connection handle > 0, or < 0 on failure -- netError() names the condition ("connection refused", "host not found", "timed out")

c.i = netConnect("127.0.0.1", 7000);
netpoll(handle, timeout_ms)

ask whether a handle is ready, waiting at most timeout_ms

The tier's one waiting primitive, deliberately the C mechanism named plainly (select on one handle). "Ready" means the next operation will not block: on a SERVER handle a connection is waiting, so netAccept returns at once; on a CONNECTION handle bytes are waiting (or the peer has closed), so netRead returns at once. timeout_ms = 0 polls and returns immediately -- the form that drops into a frame loop. timeout_ms < 0 waits forever, which makes netPoll(srv, -1) followed by netAccept exactly a blocking-accept server. A program holding several connections polls each with 0 and sleeps once, rather than paying the timeout per handle.

handlea server or connection handle
timeout_ms0 = poll, < 0 = wait forever, > 0 = wait up to this long

returns 1 ready, 0 timed out, < 0 on failure

ready.i = netPoll(srv, 100);
netread(handle)

read the bytes waiting on a connection

BLOCKS until at least one byte arrives or the peer closes (netPoll first to avoid that). Returns up to one buffer's worth: TCP is a byte STREAM, not a message queue, so one netWrite by the peer may arrive as two netReads and two may arrive as one -- a protocol that needs message boundaries must put them in the bytes (a length prefix, or a newline). BINARY-SAFE: CX strings carry their length, so the result may contain embedded NUL bytes and survives netWrite -> netRead unchanged. Printing such a value stops at the first NUL -- that is print's contract, not a truncation of the data.

handlea connection handle

returns the bytes read; "" if the peer closed cleanly OR on error -- netError() distinguishes them (empty after a clean close)

msg.s = netRead(c);
netwrite(handle, data)

write bytes to a connection

Writes ALL of the data or fails: partial writes are retried internally, so this never reports success having sent half a message. Binary-safe -- the length comes from the CX string, so embedded NUL bytes are sent like any other byte.

handlea connection handle
datathe bytes to send

returns the number of bytes written (always the full length on success), or < 0 on failure -- a peer that vanished mid-write is reported as "connection reset", never as a short write

n.i = netWrite(c, "hello");
netclose(handle)

close a connection, or stop a server listening

Closing a connection sends the peer an orderly end-of-stream, which its next netRead sees as "". Closing a server handle stops new connections arriving and leaves already-accepted connections untouched. The handle is invalid afterwards: using it again is a program defect, not a network condition, and is refused loudly (CX-E5038) rather than read as an empty message.

handlea server or connection handle

returns 0 on success, < 0 on failure (netError() has the detail)

netClose(c);
mailfetch(url, user, pass, max)

read a mailbox over IMAP(S) and answer it as json

THE VERB AND THE LENS ARE ONE DOOR: every element of the array that comes back is already `parseDoc(message, CX_EMAIL)`, so a caller never holds a message as bytes and never asks a second question to turn it into a record. A message that will not parse is SKIPPED rather than fatal -- refusing one must not lose the other nine hundred.

urlthe mailbox, e.g. "imaps://mail.example.com/INBOX"
userthe account name
passthe password or app token. CREDENTIALS ARE ARGUMENTS AND NEVER URL PARTS: a URL reaches logs, error text and neterror(), and a password that rides in one leaks without anybody deciding to.
maxtake the NEWEST `max` messages; 0 is every one. A cap takes the TAIL, because a mailbox is read to see what ARRIVED.

returns a json ARRAY of message documents. An unreachable mailbox still answers an array -- empty and unreachable are different facts, and the second is read off `neterror()`, as with every verb here.

json box = mailFetch(url, user, pass, 20);
foreach (json m in box) { println(m["headers"]["subject"]); }
httpget(url)

GET a URL and return the response body

An EMPTY string means the request failed -- but an empty body is also a legitimate 204, so check `httpStatus()` rather than the string when the difference matters.

urlthe URL to fetch

returns the response body, or an empty string on failure

body.s = httpGet("https://example.com/api/items");
httppost(url, body)

POST a body to a URL and return the response body

Sends `body` verbatim, with no Content-Type set and no form encoding: build the payload (JSON, form text) yourself.

urlthe URL to post to
bodythe request body, sent as-is

returns the response body, or an empty string on failure

reply.s = httpPost(url, exportDoc(doc));
httpstatus()

the HTTP status code of the last httpGet or httpPost

Per-thread. 0 means no request has run on this thread, or the transport failed before a status came back (a DNS or connect error) -- in which case `netError()` has the reason.

returns the status code, or 0

if (httpStatus() != 200) { print netError(); }
neterror()

a readable description of the last network failure

Per-thread, and EMPTY after a call that succeeded, so it can be tested as well as printed.

returns the message, or an empty string if the last call was clean

nettimeout(ms)

set the default TRANSFER timeout for the network builtins

Without it a stalled server hangs the program with no recourse, so the default is 60000 ms. It returns the PREVIOUS value, which is what makes save-and-restore around one risky call a one-liner. Passing 0 means "no transfer timeout" -- a separate 15-second CONNECT timeout still applies and is not adjustable.

msthe new transfer timeout in milliseconds; 0 disables it

returns the timeout that was in force before this call

prev.i = netTimeout(5000); body.s = httpGet(u); netTimeout(prev);
ftpget(url)

download a file over ftp or ftps and return its contents

The same transport as `httpGet`, chosen by the URL scheme, so the timeout and error reporting are identical.

urlan ftp:// or ftps:// URL naming a file

returns the file's contents, or an empty string on failure

data.s = ftpGet("ftp://host/pub/readme.txt");
sftpget(url)

download a file over SSH (sftp) and return its contents

urlan sftp:// URL naming a file

returns the file's contents, or an empty string on failure

ftplist(url)

list a remote directory

The URL must name a DIRECTORY and end with a slash; without the trailing slash the server is being asked for a file instead.

urlan ftp:// URL ending in "/"

returns the listing as text, or an empty string on failure

entries.s = ftpList("ftp://host/pub/");
ftpput(url, localpath)

upload a local file over ftp or ftps

urlthe destination ftp:// or ftps:// URL, including the remote filename
localpaththe local file to read and send

returns 0 on success, a negative number on failure

if (ftpPut("ftp://host/in/out.csv", "R:/tmp/out.csv") < 0) { print netError(); }
sftpput(url, localpath)

upload a local file over SSH (sftp)

urlthe destination sftp:// URL, including the remote filename
localpaththe local file to read and send

returns 0 on success, a negative number on failure

emailsend(smtpurl, from, to, subject, body)

send one email through an SMTP server

`body` is the message text only; no headers are synthesised beyond the addresses and subject, and there are no attachments.

smtpurlthe server URL, e.g. "smtp://mail.example.com:587"
fromthe sender address
tothe recipient address
subjectthe subject line
bodythe message text

returns 0 on success, a negative number on failure

emailSend("smtp://mail:25", "cx@host", "me@host", "build", log);
sshexec(host, user, pass, cmd)

run a command on a remote host over SSH and return its stdout

PORT 22 IS FIXED and so is the 15-second timeout; there is no port argument. Only stdout comes back -- stderr and the command's exit status do not, so a command that fails looks like one that printed nothing. `sshExecKey` is the key-based form and is the better habit.

hostthe host name or address
userthe remote user name
passthat user's password
cmdthe command line to run remotely

returns the command's stdout, or an empty string on failure

out.s = sshExec("build01", "ci", pw, "uname -a");
sshexeckey(host, user, keyfile, cmd)

the same remote exec, authenticated with a PRIVATE KEY FILE

Same fixed port 22, same 15-second timeout, same stdout-only result.

hostthe host name or address
userthe remote user name
keyfilepath to the OpenSSH or PEM private key
cmdthe command line to run remotely

returns the command's stdout, or an empty string on failure

out.s = sshExecKey("build01", "ci", "/home/ci/.ssh/id_ed25519", "uname -a");

http 11 builtins

httpread(conn)

read ONE HTTP request from a connection and parse it

Reads until the request is complete -- the blank line that ends the headers, plus however many body bytes Content-Length declared -- so a request split across several TCP segments arrives whole. Each read is bounded by netTimeout(ms), the same knob netConnect and httpGet use, so a client that connects and says nothing costs that timeout rather than the program. The parsed request belongs to THIS connection: httpMethod, httpPath, httpHeader and httpBody all take the same handle back, and asking them about a connection whose request has not been read is refused loudly (CX-E5039) rather than answered with the previous one.

conna connection handle from netAccept (or netConnect)

returns 1 a request was read and parsed; 0 the peer closed without sending one (an ordinary outcome -- browsers open speculative connections and drop them); < 0 malformed or timed out, and netError() names which

if (httpRead(c) > 0) { println(httpPath(c)); }
httpmethod(conn)

the method of the request httpRead parsed on this connection

Returned exactly as the client sent it, which for every browser and every HTTP client in practice means upper case ("GET", "POST", "OPTIONS"). It is NOT upper-cased here: HTTP methods are case-sensitive by specification, so folding one would invent a request the client did not make.

connthe connection httpRead was called on

returns the method, e.g. "GET"

if (httpMethod(c) == "POST") { body.s = httpBody(c); }
httppath(conn)

the request target of the request httpRead parsed

The second field of the request line, verbatim: "/send", "/poll?since=4", "/". Verbatim matters -- the query string is still attached (splitting it is N5) and the path is NOT decoded, so a program that routes on it compares the same bytes the client sent. Routing is a plain CX `if` on this value; there is no route table builtin, because a language with string comparison does not need one.

connthe connection httpRead was called on

returns the request target, e.g. "/send"

if (httpPath(c) == "/hello") { httpRespond(c, 200, "text/plain", "hi"); }
httpheader(conn, name)

one header of the request httpRead parsed, by name

The name is matched case-INSENSITIVELY, because HTTP header names are case-insensitive and a program that had to guess whether this client wrote "Content-Type" or "content-type" would be wrong half the time. The value has its surrounding whitespace trimmed. A header the client did not send returns "" -- which is also what a header sent EMPTY returns; the two are worth distinguishing only in tests, and the raw request line is not kept for that.

connthe connection httpRead was called on
namethe header name, in any case ("Content-Type", "origin")

returns the header value, or "" if the request did not carry it

ctype.s = httpHeader(c, "content-type");
httpbody(conn)

the body of the request httpRead parsed

Exactly Content-Length bytes, BINARY-SAFE: CX strings carry their length, so a POSTed body with embedded NUL bytes arrives whole. A request with no body (every GET, in practice) returns "".

connthe connection httpRead was called on

returns the request body

msg.s = httpBody(c);
httprespond(conn, status, ctype, body)

answer a request with a status and a body, and finish

Writes a complete response -- status line, Content-Type, Content-Length, Connection: close, the CORS header if httpAllowOrigin set one, and the body -- in ONE netWrite, so a browser never sees a half-formed reply. The status number is accompanied by its standard reason phrase for the codes a small server actually answers with; an unrecognised code is sent as-is with a generic phrase rather than being rejected, because HTTP allows it and this tier does not exist to police the caller's status codes. Binary-safe: Content-Length comes from the CX string, so a body with embedded NUL bytes is sent whole.

connthe connection to answer on
statusthe HTTP status code, e.g. 200, 404, 500
ctypethe Content-Type, e.g. "text/plain", "application/json"
bodythe response body (may be "")

returns the number of bytes written, or < 0 on failure (netError() has the detail)

httpRespond(c, 200, "application/json", "{\"ok\":true}");
httpredirect(conn, status, url)

answer a request by sending the client somewhere else

The one answer httpRespond cannot give: a redirect is a status AND a `Location:` header, and httpRespond writes no header the caller chooses. A 302 sent through it arrives with no destination, so the browser stays where it is -- measured on the wire, which is why this exists (v3.302.0). Writes the whole response in ONE netWrite: the status line with its reason phrase, `Location:`, the CORS header if httpAllowOrigin set one, and a short text body naming the destination. The body is what a client that does not follow redirects sees, and a bare redirect with an empty body tells such a reader nothing. THREE REFUSALS, each by name (FP4): - a status outside 301/302/303/307/308 -- a `Location:` on a 200 is ignored by every client, so accepting one would send a response that looks like a redirect and is not; - an empty url -- `Location:` with no value is a malformed header, and the client's behaviour on it is not defined; - a url containing CR or LF -- that is HEADER INJECTION, and it is the one failure here with a security consequence: a newline in a header value lets whoever supplied the url append headers, or a whole second response, to something a program believed it controlled. Refused unconditionally, never sanitised, because silently rewriting a caller's url is the FP4 shape this language forbids.

connthe connection to answer on
status301, 302, 303, 307 or 308
urlthe destination, absolute or site-relative

returns the number of bytes written, or < 0 on failure (netError() has the detail)

httpRedirect(c, 302, "https://example.com/cx.zip");
httpstream(conn, ctype)

answer with headers only and hold the connection open

The other half of the tier's receive story: instead of one body and a close, the response has NO Content-Length and stays open, so the program can push data as it appears. Pass "text/event-stream" and the connection is a Server-Sent Events stream a browser reads with `new EventSource(url)` -- which is why httpEvent exists beside this. Any other content type gives a plain open-ended response. The program owns the stream from here: write to it with httpEvent (framed) or netWrite (raw), and end it with netClose. Nothing times it out.

connthe connection to answer on
ctypethe Content-Type, e.g. "text/event-stream"

returns the number of header bytes written, or < 0 on failure

httpStream(c, "text/event-stream");
httpevent(conn, data)

send one Server-Sent Event on a streaming connection

Frames the data the way SSE requires -- `data: ` before each line, a blank line after the last -- which is the whole of what this does that netWrite does not. A multi-line message is emitted as several `data:` lines, one per line, so the browser reassembles it with the newlines intact; sending it as a single line with embedded newlines would end the event early and deliver the remainder as a second one. Call httpStream(conn, "text/event-stream") first. An SSE frame is text by specification: a NUL byte in the data would truncate the event at the browser, so it is refused by name rather than sent (base64 or JSON-escape binary payloads).

conna connection httpStream opened
datathe event data

returns the number of bytes written, or < 0 on failure

httpEvent(c, "hello");
httpservedir(conn, mount, dir)

answer this request from a directory of files, safely

Requests whose target begins with `mount` are answered from the file of the same name under `dir`; everything else is left alone for the program to route, which is what makes this compose with a plain CX `if` ladder rather than replacing it. `mount` of "/" serves the whole directory at the root. A request for a DIRECTORY is answered with `index.html` inside it -- so "/" is the site's index, the web's convention. A directory with no index.html is 404: there is no directory listing, deliberately, because a listing hands a stranger the names of every file a program never meant to advertise. CONTAINMENT IS THE POINT. The target is decoded ONCE, backslashes are read as separators (Windows would), `.` and `..` are resolved, and the result is checked against the RESOLVED root -- so `..`, `%2e%2e`, `..\`, a doubled `....//`, an absolute path and a symlink out of the tree all end at the same 404 as a file that simply is not there. A blocked path and a missing one are answered identically ON PURPOSE: a distinct error would tell a stranger which of their guesses about your filesystem was right. LINKS ARE FOLLOWED AND THEN JUDGED, not refused: a symbolic link or Windows junction that lands INSIDE the served directory is an ordinary file and is served; one that lands outside it is that same 404. That is one guarantee on every OS -- the same request gets the same answer -- rather than "the attack happens to be blocked here". (v3.203.0 resolved links on POSIX only, so a junction inside the root served a file outside it on Windows; v3.204.0 closed that. Recorded because the contract is what a reader trusts.) Only GET is served; another method under `mount` gets 405, and a file larger than this tier reads in one response gets 413 -- both named, never a silent 404. Content-Type comes from the file extension (html, css, js, mjs, wasm, json, png, jpg, gif, svg, ico, txt, and application/octet-stream for anything else). `.wasm` as `application/wasm` is why the table exists at all: a browser refuses to stream-compile a WebAssembly module served as anything else.

connthe connection httpRead was called on
mountthe URL prefix to serve, e.g. "/" or "/static"
dirthe directory to serve it from, e.g. "www"

returns > 0 a response was sent (the byte count); 0 the request is not under `mount` and NOTHING was written, so route it yourself; < 0 the response could not be written, and netError() says why

if (httpServeDir(c, "/", "www") == 0) { httpRespond(c, 404, "text/plain", "no"); }
httpalloworigin(origin)

allow a browser page from another origin to read replies

OFF BY DEFAULT, and that default is the point (user's ruling, 2026-07-29). A browser refuses to let a page READ a response from a different origin unless the server says it may, so a CX server that never calls this cannot be read by any foreign page -- by construction, not by configuration. Calling it is the explicit, greppable, one-line act that opens that door, the same shape as netListenAny being a different name rather than a flag. Name a specific origin ("http://localhost:8000") in preference to "*": with "*", ANY website open in the user's browser can call this server and read what comes back, for as long as the program runs. Takes effect on every httpRespond and httpStream after it; "" turns it off again.

originthe origin to allow ("http://localhost:8000"), "*" for any, "" for none

returns the origin that was in force before this call, so it can be restored

httpAllowOrigin("http://localhost:8000");

Date & time 1 family · 21 builtins

date & time 21 builtins

monthname(m)

the three-letter English name of a month

ONE-based: 1 is "Jan", 12 is "Dec". Always English and always three letters; there is no locale or long-form option.

mmonth number, 1..12

returns "Jan".."Dec", or the string "?" when m is out of range. NOT an empty string -- a "?" in the output is visible, which is the point (FP4).

println(monthname(month()) + " " + str(year()));
dayname(d)

the three-letter English name of a weekday

ZERO-based, and starting on Sunday: 0 is "Sun", 6 is "Sat". Note the index base differs from monthname()'s -- both match what the date builtins hand you.

dweekday number, 0..6 with 0 = Sunday

returns "Sun".."Sat", or the string "?" when d is out of range

println(dayname(0));
datetime()

the current date and time packed as YYYYMMDDHHMMSS

Local time, one int carrying both halves, so it sorts chronologically and survives being written to a file as a plain number.

returns now as YYYYMMDDHHMMSS

logline.s = str(datetime()) + " " + msg;
sleep(s)

block this program for `s` SECONDS

NOTE THE UNITS: seconds, not milliseconds -- `sleep(100)` waits a minute and forty seconds. `sleep_ms` and `delay` are the millisecond forms.

sseconds to wait

returns void

sleep(1);
sleep_ms()

sleep_ms -- block this program for `ms` MILLISECONDS; the same wait as delay

now()

the current time as SECONDS SINCE THE UNIX EPOCH

THE one epoch clock: `timestamp`, `seconds` and `ticks` were three more spellings of this same value and are gone (BUGS-138, rule 33). Not the one to measure a duration with: `elapsed`, `timer` and `elapsedus` are monotonic, this one has a one-second grain and can jump when the system clock is corrected.

returns seconds since 1970-01-01 UTC

stamp.i = now();
date()

today's date packed as the integer YYYYMMDD

Local time. 6 August 2026 is 20260806, so the value sorts chronologically and `year(date())` / `month(date())` / `day(date())` take it apart.

returns today as YYYYMMDD

printf("%d\n", date());
wallclock()

elapsed WALL-CLOCK seconds since the program started, as a

float with sub-millisecond precision. The name is the truth and the old contract was not: it claimed CPU time ("a program that spends a minute waiting reports almost nothing"), and it measured 303.000 ms across delay(300). It was C's `clock()`, which is CPU time on POSIX and WALL time on Windows -- one builtin meaning two different things depending on the host, which is the case CX has to decide (BUGS-138 rider 119). CX decides WALL, on every platform, because that is what its name says and what all four callers in the tree use it for: they are deadlines on a child process, and a CPU-time deadline never fires. The same monotonic clock as `timer` and `elapsedus`, in float seconds.

returns seconds since the program started

fStart.f = wallclock(); ... if (wallclock() - fStart > 2.5) ...
timer()

a MONOTONIC millisecond counter, relative to program start

The same counter `milliseconds` returns and the one `elapsed(t0)` subtracts from, so `elapsed(timer())` is about 0. Monotonic means it never goes backwards when the system clock is adjusted -- which is why durations are measured with this and not with `now`.

returns milliseconds since the program started

t0.i = timer(); work(); printf("%d ms\n", elapsed(t0));
milliseconds()

the same monotonic millisecond counter as timer

One counter under two names: `timer` and `milliseconds` both read cx_timer_ms, the clock `elapsed(t0)` subtracts from.

returns milliseconds since the program started

t0.i = milliseconds();
elapsedus()

MICROseconds since the program started, monotonic

The high-resolution twin of `elapsed`, for measurements too short to show up in milliseconds. Takes no baseline argument -- subtract two readings.

returns microseconds since program start

a.i = elapsedus(); f(); printf("%d us\n", elapsedus() - a);
dayofweek(packed)

which day of the week a date falls on, 0..6

ZERO IS SUNDAY, so Monday is 1 and Saturday is 6. No argument asks about today; a `date()`-style YYYYMMDD asks about that date.

packeda YYYYMMDD value; omit for today

returns 0 for Sunday through 6 for Saturday

if (dayofweek() == 0) { print "weekend"; }
dayofyear(packed)

which day of the year a date falls on, 1..366

ONE-BASED: 1 January is 1, not 0. No argument asks about today.

packeda YYYYMMDD value; omit for today

returns the day of the year, 1..366

year(packed)

the four-digit year

Called with NO argument it reads today's; called with a `date()`-style YYYYMMDD value it decodes that one. A value that is not a plausible packed date (below 10000101) is treated as "no argument" and answers about today.

packeda YYYYMMDD value to decode; omit for the current year

returns the year, e.g. 2026

printf("%d\n", year(20260806));
month(packed)

the month number, 1..12

No argument reads today's; a `date()`-style YYYYMMDD decodes that one. ONE-BASED, so January is 1, not 0.

packeda YYYYMMDD value to decode; omit for the current month

returns the month, 1..12

day(packed)

the day of the month, 1..31

No argument reads today's; a `date()`-style YYYYMMDD decodes that one.

packeda YYYYMMDD value to decode; omit for today

returns the day of the month, 1..31

elapsed(t0)

milliseconds of real time, monotonic

With NO argument it answers the milliseconds since the program started; with a baseline it answers the milliseconds since that baseline, which is the form to time a section with. The baseline comes from `timer()` or from a bare `elapsed()` -- they read the same counter. Monotonic, so a system clock adjustment cannot make a duration come out negative.

t0a baseline from `timer()`/`elapsed()`; omit for since-start

returns milliseconds

t0.i = elapsed(); load(); printf("%d ms\n", elapsed(t0));
hour(seconds_of_day)

the hour of the day, 0..23

Called with NO argument it reads the clock; called with a seconds-since- midnight value (what `time()` returns) it decodes that instead. Local time.

seconds_of_daya `time()`-style value to decode; omit for now

returns the hour, 0..23

if (hour() >= 18) { greet.s = "good evening"; }
minute(seconds_of_day)

the minute within the hour, 0..59

No argument reads the clock; a seconds-since-midnight value decodes that.

seconds_of_daya `time()`-style value to decode; omit for now

returns the minute, 0..59

second(seconds_of_day)

the second within the minute, 0..59

No argument reads the clock; a seconds-since-midnight value decodes that.

seconds_of_daya `time()`-style value to decode; omit for now

returns the second, 0..59

time()

the time of day as SECONDS SINCE MIDNIGHT

Local time, 0 to 86399. This is a time OF DAY, not a timestamp -- `now` is the epoch one -- and it is what `hour`, `minute` and `second` decode.

returns seconds since local midnight

secs.i = time();

Diagnostics & system 4 families · 48 builtins

debugging & assertions 20 builtins

closedebug()

stop capturing and send output back to stdout

The debug console CAPTURES print and println into a buffer instead of, or as well as, stdout. Two name families reach the same runtime -- `openDebug`/`console` and the Orfeus-era `debugOpen`/`debugPrint` -- so a program can mix them freely.

returns void

closeDebug();
debug_render(x, y, w, h)

draw the captured debug buffer as an in-game overlay panel

Runs on both engines (since GAP, 3.5.006.0 -- it was native-only and the register VM refused it with CX-E1013). The live alternative to the pop-up-on-exit window: pair it with a key toggle to show and hide the log during play, inside a frame loop that has a window open. Does nothing in a headless build (`-t` or `#pragma console off`), on either engine.

xpanel left
ypanel top
wpanel width
hpanel height

returns void

debug_render(10, 10, 400, 200);
consoleattach(title)

open a real terminal window and stream output to it LIVE

Unlike the captured buffer, this shows output AS IT HAPPENS, which is what you want for a demo where AI reasoning should scroll past while the game runs. Per platform: a console on Windows; on Linux a log plus `tail -f` in your terminal; on macOS the same through Terminal.app. NOT behind the headless guard -- it pulls in no graphics dependency, so even a headless build can use it.

titlethe terminal window's title

returns void

consoleAttach("cx trace");
consoledetach()

close the attached terminal and stop streaming

returns void

consoleDetach();
console(text)

write text straight into the captured buffer

The debug console CAPTURES print and println into a buffer instead of, or as well as, stdout. Two name families reach the same runtime -- `openDebug`/`console` and the Orfeus-era `debugOpen`/`debugPrint` -- so a program can mix them freely. BYPASSES the sink check, so it works whether or not openDebug was called -- which is what makes it usable as a plain trace call. No trailing newline.

textthe text to append

returns void

console("tick");
consoleln(text)

write a line into the captured buffer

The debug console CAPTURES print and println into a buffer instead of, or as well as, stdout. Two name families reach the same runtime -- `openDebug`/`console` and the Orfeus-era `debugOpen`/`debugPrint` -- so a program can mix them freely. Appends a trailing newline.

textthe line to append

returns void

consoleLn("tick");
console_clear()

empty the captured buffer

The debug console CAPTURES print and println into a buffer instead of, or as well as, stdout. Two name families reach the same runtime -- `openDebug`/`console` and the Orfeus-era `debugOpen`/`debugPrint` -- so a program can mix them freely.

returns void

console_clear();
splitparameters(tokens, divisor, valuetakers)

turn the command line into PARAMETERS, in one call

Reads `args` and answers a json ARRAY with one entry per parameter, each carrying his three results: `flag` (true/false), `name`, and `option` (optional). Nothing is segregated -- a positional argument is an entry with `flag: false`, so the result is the whole command line in order. THERE IS NO SEPARATOR ARGUMENT: the OS already split the line before the program started, so this never parses a quote and quoting stays the OS's business (his ruling -- "if we agree os already splits it its an uneeded option"). THREE STATES, KEPT DISTINCT on his explicit ruling ("yes keep them distinct"): a flag ABSENT is simply not in the array; a flag with NO option (`-v`) has no `option` key at all; a flag with an EMPTY option (`--out=`) has the key, holding "". Collapsing any two would make one readout mean two things. THE LONGEST TOKEN WINS, so with `"-,--"` the parameter `--out` is the flag `out` and not the flag `-out`. The divisor splits every parameter, not only flags, so `make CC=gcc` reads as name `CC` option `gcc`. FP4: a `valuetakers` flag sitting LAST, with nothing after it to consume, is reported by name on stderr and left with no option -- never given an empty one, which would be indistinguishable from the legitimate `--out=`.

tokenscomma-separated flag prefixes, e.g. "-,--"
divisorsplits name from option inside one parameter, e.g. "="
valuetakersoptional: comma-separated flags, WITH their token ("--out,--in"), whose value is the NEXT parameter

returns a json array of {flag, name, option?} entries, one per parameter

json p = SplitParameters("-,--", "=", "--out,--in");
opendebug(title, w, h)

start capturing output into a debug console window

The debug console CAPTURES print and println into a buffer instead of, or as well as, stdout. Two name families reach the same runtime -- `openDebug`/`console` and the Orfeus-era `debugOpen`/`debugPrint` -- so a program can mix them freely. Three forms: no arguments opens with the default title at 800x600, one sets the title, three set title, width and height. The window pops up when the program exits. In a HEADLESS build (`cx -t`) this is a no-op and output keeps flowing to stdout, so no gui link is dragged in.

titlewindow title (optional)
wwindow width (optional)
hwindow height (optional)

returns void

openDebug("trace", 900, 600);
assert(cond, msg)

fail the run if a condition is not true

The foundation of the CX test suites: a false condition prints a failure line and counts against the run. assertEqual is the form that reports both the expected and the actual value, which is almost always the more useful failure message.

condthe condition that must hold
msgtext identifying the assertion in the output

returns void

assert(count > 0, "parser produced tokens");
assertequal(expected, actual, msg)

fail the run unless two values are equal, reporting both

Callable, but its argument list is not derivable from its binding: it is bound only by the register VM's name table, where the C signature is the generic (vm, N) wrapper form. Named here so an absence is never mistaken for non-existence.

TYPE-DISPATCHED, and on BOTH backends the same way: a string pair compares by content, and a pair where EITHER side is a float compares as floats. That second rule is the one that matters -- comparing an int literal against a float that happens to be 100.5 must not truncate and pass.

expectedthe value the code should produce
actualthe value it did produce
msgtext identifying the assertion in the output

returns void

assertEqual(3, len(parts), "three fields");
assertequalstr(expected, actual, msg)

fail the run unless two strings have the same contents (the other name for assertStringEqual)

Identical to assertStringEqual; both reach the same string comparison.

expectedthe text the code should produce
actualthe text it did produce
msgtext identifying the assertion in the output

returns void

assertfloatequal(expected, actual, msg)

fail the run unless two floats are equal within tolerance

Compares with a tolerance rather than exactly, which is what makes it usable on computed floats where the last bits will not match.

expectedthe value the code should produce
actualthe value it did produce
msgtext identifying the assertion in the output

returns void

assertnotequal(a, b, msg)

fail the run unless two INTEGERS differ

INT ONLY, deliberately: there are no float or string siblings, because a native-only float form would be a divergence between the backends rather than a feature.

athe first value
bthe second value
msgtext identifying the assertion in the output

returns void

assertstringequal(expected, actual, msg)

fail the run unless two strings have the same contents

Compares by content, not by handle. assertEqualStr is the same function under another name.

expectedthe text the code should produce
actualthe text it did produce
msgtext identifying the assertion in the output

returns void

gcstats()

report the collector's live-object and allocation counters

The instrument behind the leak checks: what the allocator currently holds, by bucket.

returns the statistics as reported by the collector

print(gcStats());
prtc(ch)

write one CHARACTER to stdout with no newline, by code

The character sibling of prts, taking a numeric code as putc does.

chthe character code to write

returns void

prtf(v)

write a FLOAT to stdout with no newline

The float sibling of prts; decimals follow `#pragma decimals`.

vthe float to write

returns void

prti(n)

write an INTEGER to stdout with no newline

The int sibling of prts.

nthe integer to write

returns void

prtn(…)

write a newline to stdout

Callable, but its argument list is not derivable from its binding: it is bound only by the register VM's name table, where the C signature is the generic (vm, N) wrapper form. Named here so an absence is never mistaken for non-existence.

The line-ending half of the prt* family, for when a line has been built with prts/prti/prtf.

returns void

system info 16 builtins

cpucount()

how many logical processors the MACHINE has

Every one, whether or not this process may run on them; `cpucount_process` is the count you can actually use. Falls back to 1 where the platform cannot be asked, so it never answers 0.

returns the logical processor count, at least 1

workers.i = cpucount();
cpucount_process()

how many logical processors THIS PROCESS may run on

Reads the affinity mask, so a pinned or containerised process gets the smaller, truthful number. Falls back to `cpucount` if the mask cannot be read, and so is also never 0. This is the one to size a thread pool with.

returns the usable processor count, at least 1

memtotal()

total physical RAM, in BYTES

returns the byte count, or 0 if the platform cannot be asked

memfree()

physical RAM currently available, in BYTES

A snapshot, and it moves; treat it as a reading, not a reservation.

returns the byte count, or 0 if the platform cannot be asked

printf("%d MB free\n", memfree() / 1048576);
osname()

which operating system this build is running on

ONE OF A FIXED, LOWERCASE SET: "windows", "macos", "linux", "bsd", "web" (a wasm build -- browser or node), or "unix" for anything else. Decided at COMPILE time from the platform macros, so it names the build's target, and it is safe to compare with `==`. ALWAYS EQUAL TO THE COMPILE-TIME `CX_OS_NAME`, on every target. That was a promise this line already made and could not keep across a cross-compile until v3.271.0, when the preprocessor's constants started describing the TARGET rather than the compiling host (SWEEP_2026-08.md row 245).

returns the platform name

if (osname() == "windows") { sep.s = "\\"; }
hostname()

this machine's network name

returns the host name, or an empty string if the system will not say

username()

the name of the user this process is running as

Read from the environment (USERNAME on Windows, USER then LOGNAME elsewhere), so it reflects the environment rather than the OS's account database -- an empty environment gives an empty string, not an error.

returns the user name, or an empty string

argc()

how many command-line arguments the program has, C-identical

The program path counts, exactly as it does in C, so a bare invocation answers 1 and `argc() == args->count + 1`. It READS the `args` document rather than the process vector, which is what makes it move when a program patches its own command line -- and what let every existing call site keep working unchanged when the document arrived. THE ONE EXCEPTION IS STATED RATHER THAN SMOOTHED OVER: a runtime started with no vector at all -- a `--shared` library, or a `_CM{}` program that has not called cx_rt_init -- answers 0, because claiming 1 would claim a program path that does not exist.

returns 1 + the number of user arguments; 0 when there is no command line

if (argc() < 2) { println("usage: report <file>"); return 1; }
argv(n)

the n-th command-line argument as a string, C-identical

`argv(0)` is the PROGRAM PATH and `argv(1)` is the first user argument, so `argv(n)` is `args[n-1]`. That one-apart relationship is the whole C convention and it is why no `progpath()` builtin was added: argv(0) already is that, and a second name for one datum is what rule 33 refuses. Out of range answers "" -- and because it reads the `args` document, an element a program has written is what comes back, whatever type it was written as.

n0 for the program path, 1..argc()-1 for the arguments

returns the argument as a string; "" when n is out of range

path.s = argv(1);
delay(ms)

sleep for the given number of milliseconds

Windows uses Sleep(), POSIX uses nanosleep(); a value of 0 or less is a no-op.

msmilliseconds to sleep (<= 0 does nothing)

returns void

delay(16);
diskfree(path)

free space, in BYTES, on the filesystem that holds `path`

The disk-side twin of memfree(), and deliberately in the same units: bytes, not megabytes, so the two compose without a scale factor in between. Reports the space available to the CALLING user -- on POSIX that is f_bavail rather than f_bfree, the difference being the root-reserved margin, and a program asking "can I write this file" wants the smaller, honest number. `path` need not be a directory: any existing path on the target volume works, and "." is the ordinary spelling for "wherever I am". FP4, and the one place this does NOT copy memfree: that builtin answers a silent 0 when the OS call fails, which is indistinguishable from a genuinely full disk. -1 is a value no real filesystem can report, so a failure to MEASURE and a measurement OF zero stay separable by the caller.

patha path on the filesystem to measure; "" is rejected (-1)

returns free bytes >= 0, or -1 if the path cannot be interrogated (missing, unreadable, or not a mounted filesystem)

bytes.i = diskfree(".");
randomseed(seed)

pin the random stream, so the run replays

Random is RANDOM BY DEFAULT since v3.215.0: an unseeded program seeds from real entropy at startup and prints different numbers every run. This builtin is how you get the other behaviour deliberately -- a golden test, a bug report, a level you can regenerate. Called anywhere, it wins from that point on, over both the entropy default and `#pragma randomseed N`; the pragma is the same thing placed before the program's first statement. EVERY DISTINCT SEED IS A DISTINCT STREAM. It was not always: the state used to be `seed | 1`, which forced it odd and so gave `randomseed(42)` and `randomseed(43)` the identical stream, as it did 0 and 1 -- half of every seed a caller could type was unreachable, silently. Seeds are now taken as written, with ONE documented exception: xorshift64* has no zero state (0 maps to 0 forever), so seed 0 is remapped to a fixed nonzero constant. A caller who passes that constant's bit pattern as a negative integer gets seed 0's stream -- the whole of the collision, and stated rather than hidden. Seeds the int forms and randomf() alike: one stream, so a seeded program is reproducible whichever spelling it draws from.

seedany integer; the same seed always replays the same stream

returns void

randomseed(2026);   // this run is now repeatable
getenv(name)

read an environment variable

NO ERROR CHANNEL: an unset variable and one set to "" both give an empty string, so a program that must tell them apart has to arrange its own sentinel. The name is passed to the C library verbatim, so case sensitivity is the platform's -- Windows ignores it, POSIX does not.

namethe variable to read

returns the value, or an empty string if it is unset

home.s = getenv("HOME");
cxtune()

read or set a runtime tunable by name

The run-time face of the `#pragma` capacity settings (buffer seeds and caps). Prefer the pragma where the value is known at compile time -- it costs nothing at run time.

returns the tunable's value

cxTune();
exec(cmd)

run a shell command and return its exit code

Passes the string to the C library's system(). In a browser tab there is no shell, so the call is REFUSED with CX-E5037 naming the capability rather than returning a fabricated status -- the same answer on both backends.

cmdthe command line

returns the command's exit code

rc = exec("git status");
system(cmd)

run a shell command and return its exit code

The same operation as exec, under its C name. In a browser tab it is refused with CX-E5037 rather than pretending to succeed.

cmdthe command line

returns the command's exit code

rc = system("git status");

process control 10 builtins

procspawn(cmd, cwd, env)

start a program as a CHILD of this one and keep the parentage

NO SHELL IS INVOLVED, and that is a contract clause rather than an omission. A shell in between turns "there is no such program" into "a shell started fine and exited 1", which would make NEVER-STARTED -- one of the five states this family exists to serve -- all but unreachable. Without one, the OS itself refuses and the reason is real. The command line is split into argv on rules both platforms honour: spaces separate, double quotes group. Someone who genuinely wants a shell asks for one by name (`cmd /c ...`, `/bin/sh -c ...`), which is then an honest child like any other. stdout, stderr and stdin are each a private pipe, and the two output streams stay SEPARATE -- a screen that merges them can never tell a program's answer from its complaint. THE ENVIRONMENT IS THE CALLER'S TO SET, not this family's to guess. Until v3.308.0 the third argument was a per-session TEMP DIRECTORY and the runtime decided, on the caller's behalf, that it meant TMP, TEMP and TMPDIR. That was one caller's policy living inside the mechanism -- so the mechanism could serve exactly one policy, and a second need (routing a child's side channel into its session) had nowhere to go. The general form costs nothing, subsumes the special case exactly, and moves the decision to the only place that can make it (FP7: the same power, one fewer moving part).

cmdprogram and arguments; spaces separate, double quotes group
cwdworking directory for the child; "" keeps the parent's
envenvironment overrides for the child, "KEY=VALUE;KEY=VALUE". Applied ON TOP of the parent's environment: a key named here replaces any inherited one (never sits behind it), and a key not named is inherited unchanged. "" inherits everything. The parent process's own environment is never modified, so two spawns cannot race over it. A fragment with no '=' is skipped rather than guessed at.

returns a handle > 0, or 0 if the child NEVER STARTED -- in which case procError() says why, in the OS's own words

h = procSpawn("cx prog.cx --run", ".", "TMP=" + d + ";TEMP=" + d);
procerror()

why the last procSpawn failed, in the operating system's words

Latched at the failure and not cleared by anything else, so a caller can ask after the fact. "" when no spawn has failed. This is the `errno/reason` the lifecycle contract's NEVER-STARTED state is required to carry: a state with no reason is a shrug with a timestamp.

returns the failure text, or "" if the last spawn succeeded

if (h == 0) { println("never started: " + procError()); }
procpid(h)

the child's operating-system process id

For the RUNNING state's payload and for a human who wants to look with their own tools. It is NOT how liveness is decided -- a pid is a number that outlives its process and gets reused, which is what makes a `ps | grep` a guess.

ha handle from procSpawn

returns the OS process id

println("running, pid " + str(procPid(h)));
procalive(h)

ask the OS, through our own parent handle, whether it still runs

THE ONE QUESTION THIS FAMILY EXISTS TO ANSWER. Windows: WaitForSingleObject on the process handle we hold. POSIX: waitpid(WNOHANG) on our own child. Neither can be fooled by a quiet log or a busy process table, and both are answering about THE process we started rather than about a pid that looks like it. It also REAPS: the moment the child is gone its exit code is latched, so procExit() has a real answer afterwards and no zombie is left behind.

ha handle from procSpawn

returns 1 while the child is running, 0 once it is not

if (procAlive(h) == 0) { rc = procExit(h); }
procexit(h)

the child's exit code, once it has one

ONLY MEANINGFUL AFTER procAlive() HAS ANSWERED 0, and the reason is worth the sentence: before then this returns -1, which is also a legitimate exit code on Windows. That ambiguity is deliberate and undisguised. A caller that reads an exit code without first asking whether the child has finished is inferring, and inference is the failure this whole family is a refusal of.

ha handle from procSpawn

returns the exit code, or -1 if the child has not been seen to exit

if (procAlive(h) == 0) { println("rc=" + str(procExit(h))); }
procout(h)

take whatever the child has written to STDOUT and not yet been read

Never blocks: "" means "nothing waiting right now", never "the program is finished" and never "the program is stuck". Bytes still in the pipe after the child exits are still delivered, so a caller drains once more after seeing procAlive() == 0 rather than losing the last thing the program said.

ha handle from procSpawn

returns the bytes available now, or "" if none are

chunk = procOut(h); if (len(chunk) > 0) { record(chunk); }
procerr(h)

the same, for STDERR, which is a SEPARATE stream and stays separate

Kept apart from stdout all the way through: a merged capture cannot tell a program's output from its diagnostics, and every instrument downstream then inherits that confusion.

ha handle from procSpawn

returns the bytes available now, or "" if none are

e = procErr(h); if (len(e) > 0) { record_stderr(e); }
procin(h, s)

write to the child's STDIN

The control half of the contract: a program waiting on input can be answered rather than killed. No newline is added -- what you pass is what it reads.

ha handle from procSpawn
sthe bytes to write

returns bytes written, or -1 if the pipe is gone (the child closed it or ended)

procIn(h, "yes" + chr(10));
prockill(h)

end the child AND EVERYTHING IT STARTED

Tree-kill, the Invoke-Cx discipline, because a shell that spawned a compiler that spawned a linker leaves two survivors when only the shell is killed -- and survivors are how a "stopped" run keeps holding a lock. Windows: the child is created inside a Job Object and the JOB is terminated. POSIX: the child gets its own session with setsid(), so one killpg reaches the whole group. KILLED is a state the CALLER records: this function does not label anything, it only ends it. Who killed it and when is knowledge the killer has.

ha handle from procSpawn

returns 1 if the kill was issued, 0 if the child had already finished

if (procKill(h) == 1) { note("killed by the operator"); }
procclose(h)

release the pipes and handles this process holds for a child

REFUSES WHILE THE CHILD IS ALIVE, and answers 0 to say so. Closing a live child's handles would throw away the parentage that is the only honest source of its state -- the caller would be left with a pid and a guess, which is exactly the position this family removes. Kill it or wait for it, then close. The handle is not recycled afterwards: a later call on a closed handle stays LOUD rather than silently addressing a stranger's process (cx_queue.c keeps its slots for the same reason, and records the same cost).

ha handle from procSpawn

returns 1 if released, 0 if refused because the child is still running

if (procAlive(h) == 0) { procClose(h); }

error handling 2 builtins

onerroraddrule(sGate, sRule)

add a RULE to the universal error door

THE ARM EVERY SIBLING DOOR ALREADY HAD. `onevent` carries cx_event_add_rule beside cx_event_add_func and automation carries cx_auto_add_rule beside cx_auto_add_func; this door had only the function arm, which is the asymmetry the on* dogma audit found by SHAPE (DOCS/design/SCREEN_SERVER.md, finding A1) with no knowledge that the arm had ever been intended. His ruling, 2026-08-25: "onerror gains the rule arm; resumption stays refused; it was supposed to but got lost in the chat." Write `onerror("code >= 1000", MYRULE);` in CX -- the TWO-argument form is the rule arm. `onerror(&h)` is still the function arm, `onerror()` still uninstalls, and both are unchanged. RULES ACCUMULATE; A HANDLER REPLACES. A second onerror(&h) replaces the first by design (above); registered rules are a LIST, like events and automation, because rules are data you collect. The two arms of one door therefore behave differently, deliberately. GATE AND RULE ARE BOTH COMPILED AT REGISTRATION, and that is FORCED by the return value rather than chosen: a door that stored the strings and compiled them at fire time could not answer "registered" honestly. It is automation's shape already (cx_auto_add_rule compiles once), not a new one. A gate of "" or "1" is stored as no gate at all -- always fires, nothing compiled. RESUMPTION IS STILL REFUSED -- his ruling, and the no-resumption position above is unchanged and not weakened by this arm. A rule runs, it returns, the program still stops with a non-zero exit. A rule MAY mutate the doc and the annotation is visible to the function handler that runs after it; it cannot change whether execution continues. This is said here as well as in the design because a rule engine that can write to `e` LOOKS like it could change the outcome. ORDER AT A RAISE: the stderr report -> the default side-channel record -> every registered rule, in registration order, behind its gate -> the installed function handler. Rules and the handler COMPOSE; neither suppresses the other. FP2: a program that registers no rule pays one count test on a path that was already about to print and exit. Nothing on any hot path learns this exists.

sGatea rule evaluated against the error doc; "" or "1" always fires.
sRulethe rule to run when the gate fires.

returns 1 registered, 0 refused -- and every refusal has ALREADY printed its own reason (cx_rule_compile for a malformed gate or rule, cx_grow_capped for the registry cap), so the number never has to carry the diagnosis. His standing dogma: a verb reporting through an error channel answers at its call site too.

onerror("code >= 1000", LOGIT);
errorraise(nCode, sNote)

raise a USER error checkpoint

Runs the installed handler with (nCode, sNote), then stops the program with a non-zero exit. This is what `#error-check <n> <note>` lowers to, and the directive is the spelling to write -- it is checked at compile time and it prefixes the note with the source location for free. Call this form directly when the message has to be BUILT at run time; that is the whole difference between them.

nCodethe checkpoint number, CX_USER_CODE_MIN..CX_USER_CODE_MAX (1..999). CX's own codes are 1000 and up, so a handler can always tell the two apart; see the band note in cx_limits.h.
sNotethe message, handed to the handler unchanged.
errorRaise(7, "config file had no [server] section");

Runtime & VM 2 families · 13 builtins

dynamic libraries 7 builtins

hostcall(pin, arg)

call the host function wired behind one of this chip's pins

Written BY A CHIP, not by a host: it is the reverse crossing. The name is the LINKS MEMBER's, never the host function's, so a chip is written against the pins its datasheet declares and the host may wire any function of the right shape behind them -- the chip stays generic and the injection specialises it. One json in, one json out (his rule). Nothing is checked here: the socket already refused any target whose signature was not `j:j`, so by the time a call is possible there is nothing left but the name.

pinthe links member's name, as the host wired it
argone json document handle

returns the json handle the host function answered, or 0 after a refusal

r = hostCall("onscore", doc);
chipfn(handle, ordinal)

one export's address, by the ordinal its datasheet gave it

Emitted by `H->name(args)`, which lowers to libCall/libCallf over this. The symbols were resolved ONCE at socket time into a table on the instance, so this is an array index and a call costs no name lookup -- the bargain part 0 of CHIPS.md measured and set. IT ANSWERS AN ADDRESS RATHER THAN CALLING, so the marshalling stays in the ONE core libCall already uses for every argument family and both return families. A chip call and a library call are then the same call (rule 33).

handlean instance handle.
ordinalthe export's index in the datasheet the host compiled against.

returns the function's address, or 0 -- having NAMED the chip and the ordinal on stderr, because one step later there is only an address.

float a = Math1->area(2.5);
libopen(path)

open a native shared library and answer a handle to it

paththe library to load -- a full path, or a bare name the platform loader searches for ("msvcrt.dll", "libSystem.dylib").

returns the library handle, or 0 when it could not be loaded.

int h = libOpen("C:/tools/mathchip.dll");
libsym(handle, name)

resolve one symbol in an open library to its address

handlea handle from libOpen.
namethe exported symbol's name, spelled exactly as the library exports it (a CX function exports its own lowercased name).

returns the address as an integer, or 0 when the library exports no such name -- which is also what a closed or never-opened handle answers.

int fn = libSym(h, "cx_probe_pure");
libclose(handle)

close a library opened by libOpen

Every address it handed out becomes stale the moment it closes.

handlea handle from libOpen; 0 is a no-op, as C's free(NULL) is.
libClose(h);
libcall(fn)

call a function in an open library and read an integer back

Takes the address plus however many arguments the callee takes, and picks the platform call shape from what it is given: all-float goes to the float registers, a mix of integer and float through the ABI thunk, everything else through the integer registers. A string is passed as its bytes, an array as its buffer, a list or a map is refused (neither has a C representation).

fnan address from libSym.

returns whatever the callee returned, as an integer; 0 for a null address.

int n = libCall(fn_strlen, "hello");
libcallf(fn)

call a function in an open library and read a float back

libcall's twin: same arguments, same call shapes, and the return read as a double rather than as an integer.

fnan address from libSym.

returns whatever the callee returned, as a float; 0.0 for a null address.

float r = libCallf(fn_pow, 2.0, 10.0);

vm 6 builtins

vmload(path)

compile and load a whole CX file onto an embedded VM, returning a module handle

Reads the file, compiles it via the embedded frontend (requires `#pragma rules c`), admission-gated on the Layer-2 VM-clean verdict (rejects any native-only construct), and prepares a dedicated persistent VM. Errors are loud (empty path, open/size failure, OOM, compile/admission failure, module-table full, or a build without CX_RULES_C).

pathpath to the .cx module file to load

returns module handle (>= 0) on success; -1 on any error

h = vmLoad("plugin.cx");
vmloadstr(src)

compile and load a CX module from an in-memory source string

Same admission gate, capabilities, and handle registry as vmLoad, but the source is a CX string (AI-authored, embed-bundled, or network-fetched) with no temp-file round-trip; both share the vmload_core implementation.

srcCX module source text to compile

returns module handle (>= 0) on success; -1 on any error

h = vmLoadStr(modsrc);
vmhas(handle, name)

test whether a loaded module defines a named function

Linear-scans the module's captured function names; returns 0 for an invalid or already-unloaded handle.

handlemodule handle returned by vmLoad/vmLoadStr
namefunction name to look for

returns 1 if the module defines that function, else 0

vmHas(h, "bump")
vmcall(handle, name, jsonarg)

call a named function in a loaded module and return its value as a float

Passes the json entity `jsonarg` as arg 0 (the rule ABI, so results flow back through the entity) and runs under the module's loop budget with the module VM published as the rule VM. A string return, an invalid handle, a missing function, or budget exhaustion all yield 0.

handlemodule handle returned by vmLoad/vmLoadStr
namename of the function to invoke
jsonargjson entity handle passed as arg 0 and used as the result channel

returns the function's return value coerced to float; 0 on a string return or on any error

vmCall(h, "ki", e);
vmbudget(handle, n)

set a loaded module's per-call loop-iteration budget (runaway cage)

Host-only tuning (not in the rule capability whitelist, so a module cannot raise its own cage): n>0 sets a finite cap, n==0 uncaps, n<0 is a loud error leaving the budget unchanged. Takes effect on the next vmCall.

handlemodule handle returned by vmLoad/vmLoadStr
nloop-iteration budget; 0 = uncapped, negative = rejected

returns void

vmBudget(h, 2000000000);
vmunload(handle)

free a loaded module and release its handle slot

Frees the module's VM, parsed program, and library-callback array; the slot becomes reusable. No-op on an invalid or already-unloaded handle.

handlemodule handle returned by vmLoad/vmLoadStr

returns void

vmUnload(h);