Core concept
DominScript is built on three pillars. Understanding them up front explains nearly every design decision in the language.
A minimal interpreter
The interpreter deliberately knows very little on its own. There is no built-in networking, file, graphics, or operating-system call. All of that arrives from plugins — platform-specific shared libraries (.so on Linux, .dll on Windows). A script has exactly the capabilities the loaded plugins provide, and no more.
Early error catching
Code passes through three phases before it runs:
- Parser — syntactic correctness.
- Binder — types, variable scope, definite assignment, and plugin ABI agreement.
- Runtime — dynamic range and type checks during execution.
This makes it impossible to, for example, read an uninitialized variable or pass a wrongly-typed argument to a plugin function — those are binder errors, raised before the program starts.
C-translatability as a design rule
DominScript is intentionally C-like. A hot script function should be mechanically and conceptually easy to move into a C plugin. The #define directive, brace blocks, typed signatures, and explicit casts all serve that goal.
This does not mean full C compatibility. DominScript deliberately sits between a flexible scripting language and C — it borrows C's shape and discipline where they pay off, but stays smaller and more script-like elsewhere. The #define directive, for instance, is not (yet) a complete macro language. Treat the C resemblance as a guiding principle, not a compatibility promise.
Your first program
plugin "../plugins/print/PrintPlugin"; void main() { i32 $Counter = 0; while ($Counter < 5) { printf("Count: %d\n", $Counter); $Counter += 1; } return; }
domin first.dom
Count: 0
Count: 1
Count: 2
Count: 3
Count: 4
File structure
DominScript source files use the .dom extension. Every statement ends with a semicolon (;).
The plugin directive
Plugin directives may appear only at the top of the file, before any other statement.
plugin "builtin:blob"; plugin "builtin:string"; plugin "builtin:struct"; plugin "builtin:os"; plugin "../plugins/print/PrintPlugin"; plugin "../plugins/file_io/FileIoPlugin"; plugin "../plugins/path/PathPlugin";
- The extension is optional — the runtime appends the platform-appropriate one (
.so/.dll) automatically. - The
builtin:xxxform loads internal plugins written in C and linked into the interpreter. - Two plugins may not export the same
namespace.functionpair — that is a binder error. - There is no in-script plugin unload; everything is released automatically when the program ends.
The include directive
#include "utils/helpers.dom" #include "../common/config.dom"
Brings another source file into the program. The # prefix is required (unlike plugin and import); a trailing semicolon is optional. Paths are resolved relative to the including file.
The semantics are module-like, not textual: every file enters the program exactly once, however many #include lines refer to it and by whatever relative path (identity is decided on the resolved, canonical path). Included files may include each other; if that forms a cycle (a → b → a) it is not an error — the second inclusion is simply skipped, as with C's #pragma once. No guard macros are needed, and one shared file can be pulled in from several places.
Two cases are preprocessor errors, before compilation starts: a chain that leads back to the root script (the root is not on the already-included list), reported with the whole chain — leaf.dom:1: Include cycle detected: main.dom -> leaf.dom -> main.dom; and an include depth above 32 (Include depth limit (32) exceeded while including 'd33.dom'.), which include-once makes reachable only with more than 33 distinct files nested in each other. A missing file is Failed to read included file 'x.dom'. All three are anchored at the #include directive (file and line of the directive), and paths are shown as resolved from the source (relative to the including file), never as canonical absolute paths.
Diagnostic shape in general: every compile-time message (Preprocess, Lexer, Parser, Binder) is one stderr line of the form <Phase> error: <file>:<line>:<column>: <message> and always carries at least file:line; program-level messages with no source position (an empty script without main) are anchored at <script>:1:. Runtime errors carry the same prefix unless the script runs under --release, where position bookkeeping is off by design.
Comments
// Single-line comment /* Multi-line comment */
The # character is not a comment prefix — it belongs to the preprocessor.
Preprocessor — #define
The #define directive performs simple single-token text substitution — to a literal value, a keyword, or a previously defined name.
#define MAX_SIZE 256 #define WAIT_FOREVER -1 #define PI_APPROX 3.14159 #define STATUS_OK 0 void main() { blob $B[MAX_SIZE]; i32 $Status = STATUS_OK; double $Pi = PI_APPROX; printf("Size: %d, Pi: %f\n", MAX_SIZE, $Pi); return; }
It is especially handy for naming switch/case labels, keeping branch logic readable.
This is plain token substitution, not a full C preprocessor. There are no function-like macros, conditional compilation, or token-pasting tricks — #define maps a name to a value, and that is intentionally all it does for now.
Types
Scalar types
| Type | Description | Size | Range |
|---|---|---|---|
| bool | boolean value | 1 byte | true / false |
| i8 | signed integer | 1 byte | −128 .. 127 |
| u8 | unsigned integer | 1 byte | 0 .. 255 |
| i16 | signed integer | 2 bytes | −32 768 .. 32 767 |
| u16 | unsigned integer | 2 bytes | 0 .. 65 535 |
| i32 | signed integer | 4 bytes | −2.15×10⁹ .. 2.15×10⁹ |
| u32 | unsigned integer | 4 bytes | 0 .. 4.29×10⁹ |
| i64 | signed integer | 8 bytes | −9.2×10¹⁸ .. 9.2×10¹⁸ |
| u64 | unsigned integer | 8 bytes | 0 .. 1.8×10¹⁹ |
| float | single-precision float | 4 bytes | ~±3.4×10³⁸, ~7 digits |
| double | double-precision float | 8 bytes | ~±1.8×10³⁰⁸, ~15 digits |
Compound types
| Type | Description |
|---|---|
| string | text with a declared capacity — string $S[N] |
| blob | raw byte array — blob $B[N] |
| void | valid only as a return type |
Type rules that matter
- A
voidvariable cannot be declared. stringandblobdo not participate in implicit numeric conversion.boolis not compatible with integer types at the plugin ABI level.- Scalar assignment does not allow implicit conversion between float and integer —
float → i32needs an explicit cast. Integer-to-integer stores are implicit and wrap like C; see narrowing.
Printing: the output family
Four calls, and the choice between them is about formatting and flushing, not about power. All four live in the print plugin.
| Call | Newline | Formats | Use it when |
|---|---|---|---|
print(text) | no | no | Text as-is, pieces joined on one line. No format characters are interpreted, so a stray % is harmless. |
print.Line(text) | yes | no | The everyday "put this line on screen" call. |
printf(fmt, …) | you write \n | yes | C-style formatting; flushes on every call, so output appears immediately. |
print.FastPrintF(fmt, …) | you write \n | yes | Byte-identical to printf but buffered — for bulk output into a pipe or file, where a flush per line means a write() syscall per line. |
print("no newline, "); print("so calls run together"); print.Line(""); // end the line print.Line("adds the newline for you"); printf("%s is %d\n", $Name, $Year); print.FastPrintF("same bytes, buffered\n");
Printing text that came from outside — a file, a request, user input? Use print.Line or an interpolated literal. A % inside that text is just a character there, while in a printf format string it would be read as a directive.
Strings: length, slicing, joining
A string variable carries its own length and can be sliced with a range, which covers most of what you would otherwise reach for a library for. The str plugin adds the rest (41 verbs: search, case, trim, pad, replace, split, conversions).
string $Name[16] = "DominScript"; $Name.Length // 11 $Name[0..4] // "Domin" — range slice $Name + " rocks" // "DominScript rocks" str.Upper($Name) // "DOMINSCRIPT" str.Sub($Name, 5, 6) // "Script" str.Replace($Name, "Script", "Lang") // "DominLang" str.PadLeft("42", 6, 48) // "000042" (48 = '0') str.Find($Name, "Script") // 5
A runnable tour of everything on this page — every output call, interpolation, slicing and the str verbs — is in Examples/output_and_strings_demo.dom.
Integer narrowing & the @ prefix
Storing a wider integer into a narrower variable follows C: at runtime the value wraps silently to the target's width. Nothing fails, nothing traps — the same arithmetic a C compiler would emit. What the language adds is a static warning set that mirrors gcc's defaults, so the wrap is never a surprise.
- Constant source — silent inside the N-bit band
[-2^(N-1) … 2^N-1], which keeps the classic sign idioms quiet:u8 $A = -1;is 255,i8 $B = 200;is -56. Outside the band you get a "changes value from X to Y" warning. - Non-constant source — a warning when the source's declared type is wider than the target. Arithmetic intermediates are typed
i64internally, so everyday code likei32 $Sum = $A + $B;stays quiet.
Two ways to say "I know": an explicit cast silences it by changing the type — and the cast is the truncation — or the @ statement prefix silences the bind-time warnings of one statement.
u8 $A = -1; // 255 — silent idiom i8 $B = 200; // -56 — silent idiom u8 $C = 300; // 44 + warning: changes value from '300' to '44' i32 $W = Widen(); u8 $D = $W; // 44 + warning: 'i32' source is wider (stored modulo 2^8) @u8 $E = $W; // 44 — '@' silences this statement u8 $F = (u8)$W; // 44 — the cast says it out loud
Function parameters and return values are not stores — that boundary stays strict. An out-of-range constant argument is a binder error, and an out-of-range value returned at runtime is a runtime error.
Variables
Variable names are prefixed with $. The compiler distinguishes a declaration (which fixes the type) from later assignments.
Declaration and initialization
// Scalar types i32 $Number = 42; double $Pi = 3.14159; bool $Done = true; u8 $Bitmask = 0xFF; // Declaration without initialization i64 $Result; // String — capacity is mandatory string $Name[32]; string $City[64] = "Budapest"; // Blob — size is mandatory blob $Data[128]; blob $Empty[0];
Scope rules
void main() { i32 $X = 10; // outer scope { i32 $Y = 20; // inner scope printf("%d\n", $X + $Y); } // $Y is no longer reachable here // FORBIDDEN: redeclaring $X in the same scope -> binder error // FORBIDDEN: shadowing an outer $X in an inner scope -> binder error return; }
Globals in two worlds
DominScript does have global variables — in two separate worlds that cannot see each other. The rule in one sentence: data is touched only on the thread that owns it; the other thread's data is reached by a call, never by a read.
global i32 $Retries = 3; // MAIN WORLD: main() + every ordinary function global string $Label[32] = "demo"; // unlocked, as cheap as a local callback global i64 $Ticks = 0; // CALLBACK WORLD: callback functions only i32 NextRetry() { $Retries--; return $Retries; } // main thread: sees $Retries callback i32 Tick(ref blob $X) { $Ticks++; return 1; } // dispatcher: sees $Ticks // FORBIDDEN (binder error, with an explanation): // a callback naming $Retries; main() or NextRetry naming $Ticks; // a callback calling a function that touches $Retries.
A global lives for the whole run and is initialized before main(). Every callback runs on one dispatcher thread, strictly one at a time, so two callbacks never race for a callback global either — there is no lock anywhere, because nothing is shared. Between the worlds there are exactly two channels: a single value goes through map/queue (the plugin locks, main pulls), and a consistent state goes through a call on the other thread — callbackworker.Call, which lends main's own blob to a callback, zero copies, request and reply in one. Full example: Examples/global_two_worlds_demo.dom. In a C port a global is a file-level static.
Definite-assignment check
The binder verifies that every variable is definitely assigned before it is read. If a value is set on only some branches, reading it afterward is a binder error.
i32 $Result; if (GetSomeValue() > 0) { $Result = 10; } else { $Result = 0; // assigned on every path } printf("%d\n", $Result); // OK
Literals
Integer literals
// Decimal i32 $A = 42; i32 $B = -17; // Hexadecimal (0x / 0X prefix) u8 $H1 = 0xFF; // 255 i32 $H2 = 0x1A2B; // 6699 // Binary (0b / 0B prefix) u8 $B1 = 0b10101010; // 170 i32 $B3 = 0b11110000; // 240 // Negative hex / binary (unary minus) i32 $Neg = -0x10; // -16
Floating-point, string, boolean
float $F = 3.14; double $D = 3.14159265358979; string $S[32] = "Hello, world!"; string $Lines[64] = "First line\nSecond line"; string $Quote[32] = "she said: \"hi\""; string $Path[16] = "C:\\user"; string $Mix[32] = 'she said: "hi" — no escapes'; bool $Yes = true; bool $No = false;
String literals can be written with "..." or '...' — the closing quote always matches the opener, so the opposite quote can appear inside without escaping. The choice never changes meaning (unlike PHP); it is purely a convenience, and it makes eval fragments pleasant: os.ValidateScript('printf("hello");'). Supported escape sequences in both forms include \n, \t, \", \', \\, and \0 (NUL terminator).
Interpolated string literals — $"..." and $'...'
i32 $Count = 3; string $Name[32] = "Anna"; print.Line($"Hi $Name, you have $Count items"); // Hi Anna, you have 3 items string $Tag[32] = $"item-$Count"; // works in assignments too
A literal written with a $ prefix substitutes $VariableName references with the variable's value. The substitution happens at compile time — $"i=$X" is exactly equivalent to ( "i=" + (string)$X ) — so the literal can stand anywhere a string expression can (declarations, assignments, return, function arguments, concatenation, comparisons); a mistyped variable name is a compile-time error, not a silent empty string; and there is no runtime name lookup.
Rules: only simple $Identifier interpolation; $$ yields a literal $; a $ before a non-identifier is literal (so $"price: $5" works); ${ is a lexer error, reserved for a future ${expression} form. For plain text the recommended output call is print.Line($"...") (see Printing) — it interprets no format characters, so a % inside a substituted value can never cause trouble; with printf, keep the interpolated part behind a %s rather than in the format-string position.
Operators and expressions
Arithmetic
i32 $A = 10; i32 $B = 3; printf("%d\n", $A + $B); // 13 printf("%d\n", $A - $B); // 7 printf("%d\n", $A * $B); // 30 printf("%d\n", $A / $B); // 3 (integer division) printf("%d\n", $A % $B); // 1 (remainder)
Bitwise (integer types only)
i32 $A = 0b00001100; // 12 i32 $B = 0b00001010; // 10 printf("%d\n", $A & $B); // 8 AND printf("%d\n", $A | $B); // 14 OR printf("%d\n", $A ^ $B); // 6 XOR printf("%d\n", ~$A); // -13 NOT (sign-extending) printf("%d\n", 1 << 4); // 16 left shift printf("%d\n", -8 >> 1); // -4 arithmetic right shift
Shift counts must fall between 0 and 63.
Comparison & logical
bool $R1 = ($A == $B); // equal bool $R2 = ($A != $B); // not equal bool $R3 = ($A <= $B); // less-or-equal bool $L1 = ($X && $Y); // logical AND bool $L2 = ($X || $Y); // logical OR bool $L3 = !$X; // logical NOT
String and blob concatenation (+)
string $First[32] = "Hello"; string $Both[64]; $Both = $First + ", world!"; // Hello, world! blob $A[3]; blob $B[3]; blob $C[0]; $C = $A + $B; // 6-byte blob: A then B
Operator precedence (high → low)
| Level | Operators |
|---|---|
| 1 (highest) | ! ~ unary - |
| 2 | * / % |
| 3 | + - |
| 4 | << >> |
| 5 | < <= > >= |
| 6 | == != |
| 7 – 9 | & then ^ then | |
| 10 | && |
| 11 | || |
| 12 (lowest) | ?: (right-associative) |
Conditional operator ?:
The C conditional, with C's rules: the condition is evaluated first, then only the selected branch runs (short-circuit). It is right-associative, so a chain reads top-to-bottom like a series of ifs — the classic clamp idiom. Both branches must agree on type: identical types, or numeric types that combine the same way a binary operator would; mixing a number with a string is a compile-time error.
string $Kind[8] = ($N % 2 == 0) ? "even" : "odd"; // right-associative chain — the classic C clamp return ($X < 0.0) ? 0.0 : ($X > 1.0) ? 1.0 : $X; // a constant condition folds to the chosen branch at compile time i32 $C = true ? 10 : 20; // 10
Assignment & compound operators
Compound operators are desugared by the parser: $X op= Y becomes $X = $X op Y.
i32 $X = 100; $X += 5; // 105 $X -= 10; // 95 $X *= 2; // 190 $X /= 4; // 47 $X %= 10; // 7 $X &= 0xFF; $X |= 0x01; $X ^= 0x0F; $X <<= 2; $X >>= 1;
A compound operator may be applied only to a simple variable. Index, range, or member targets such as $Array[0] += 5 or $S.x += 1 are binder errors.
Increment and decrement: ++ / --
Both prefix and postfix forms increment or decrement by one, and unlike the compound operators they work on any assignable target — plain variables, blob elements, struct members. They are statements, not expressions: DominScript deliberately does not have C's "value of an increment", so there is no argument about what $A[$I++] means. Using one inside an expression is a parser error that tells you what to write instead.
i32 $I = 5; $I++; // 6 ++$I; // 7 (same effect: a statement, not a value) $I--; // 6 $B[0]++; // works on blob elements and struct members too i32 $Y = $I++; // PARSER ERROR: no value in an expression — use '+= 1'
Capacity-bounded assignment: = vs :=
Strings and blobs have a second assignment operator, :=. The two differ in exactly one thing: := never grows the target's allocated capacity, whereas = grows it as needed.
= (plain) | := (capacity-bounded) | |
|---|---|---|
| Upper bound | none — capacity grows if needed | the target's declared capacity |
| Source too long | capacity grows to fit | truncated at capacity; Overflow becomes 1 |
| Shorter source | the remainder is dropped (new length = source length) | the existing tail beyond the source is kept |
| Typical use | general assignment | fixed-size fields — e.g. a string member of a packed struct |
string $S[4]; $S = "abcdefgh"; // '=' grows the capacity // $S = "abcdefgh", Length 8, Capacity 8, Overflow 0 string $T[4]; $T := "abcdefgh"; // ':=' truncates at the capacity // $T = "abcd", Length 4, Capacity 4, Overflow 1 string $A[16] = "Hello"; $A := "AB"; // shorter source: the existing tail is kept // $A = "ABllo", Length 5, Overflow 0
Blobs behave identically. Writing into a fixed-size blob with := — including at an offset, e.g. $Cut[1] := $Src[0..3] — will not grow it: it truncates at the capacity and flags Overflow, whereas = grows the blob to fit.
Range fill — writing a span in one statement
An index range on the left-hand side fills every position in that span, rather than assigning one element. It is the compact way to blank a field, pad a record, or stamp a marker across a region.
string $S[32] = "abcdef"; $S[1..3] = "X"; printf("%s\n", $S); // aXXXef $S[..2] = 42; printf("%s\n", $S); // ***Xef (42 = '*') blob $B[0]; $B = blob.FromText("abcdef"); $B[2..4] = 88; // bytes 2,3,4 become 'X' -> abXXXf
The open form [..n] starts at 0, and a reversed range is normalised — [4..2] fills positions 2..4. The span is not clipped to the current length: filling past the end extends the value ("abcdef" with [4..9] = "w" becomes 10 characters), and it grows past the declared capacity too, exactly as plain = does elsewhere.
Two things are refused outright rather than guessed: := as the range-fill operator ("Range fill assignment does not support ':='; use '='"), and a multi-character string pattern ("expects exactly one character") — because repeating a longer pattern across a span has no single obvious meaning.
Blobs — raw bytes you can index, slice, grow and trim
A blob is a flat run of bytes with a length and a capacity. It is the type that everything binary flows through: a received packet, a file’s contents, a frame of pixels, the bytes a plugin hands back. There is no hidden encoding and no object header — $B[0] is the first byte, $B.Length is how many there are, and the next section shows how a schema lays field names over the same bytes.
Declaring and reading
blob $B[n] declares a blob of n zero bytes: length n, capacity n. Indexing reads or writes one byte as an integer 0–255; .Length reads the byte count and can be assigned to shrink it (the bytes beyond stay allocated, see .Fit()).
plugin "builtin:blob"; blob $B[8]; // 8 zero bytes $B[0] = 0x48; $B[1] = 0x69; // 'H' 'i' printf("len=%d first=%d\n", $B.Length, $B[0]); // len=8 first=72 $B.Length = 4; // logical length down to 4
Growing, bounded copying, and overflow
Two assignment operators, the same split as for strings (section 8): plain = grows the target to fit the source; := is bounded by the current capacity, truncates, and records the fact in blob.Overflow($B). This is how a fixed receive buffer stays fixed while a working buffer is allowed to stretch.
blob $Src[0]; $Src = blob.FromText("abcdef"); // 6 bytes (plugins/blob) blob $Grow[2]; $Grow = $Src; // = grows: len 6 blob $Fixed[3]; $Fixed := $Src; // := bounded: len 3, blob.Overflow($Fixed) == 1
Slices and concatenation
$B[a..b] copies bytes a through b inclusive out into a new blob; + concatenates two blobs into a new one. Both produce values, so they compose with assignment and with plugin calls. (Range fill, $B[a..b] = 88, is the write-side counterpart described in section 8.)
blob $S[0]; $S = $Src[1..3]; // "bcd": 3 bytes $S = $S + $Src[0..0]; // "bcda": 4 bytes string $T[16]; $T = $S; // blob -> string: bytes become text printf("%s\n", $T); // bcda
Trimming: .Fit()
$B.Fit() gives the surplus back: it cuts the blob’s capacity down to its length. It is a method of the built-in type — no plugin needed — and the usual moment for it is after a wide declaration has settled on a short value, or after .Length was lowered. A later = grows the blob again as needed; := is bounded by the trimmed size from then on. Strings have the same method, $S.Fit().
blob $F[64]; // capacity 64 $F = blob.FromText("xy"); // length 2 $F.Fit(); // capacity 2 printf("len=%d\n", $F.Length); // len=2
Looking at bytes
The blob plugin renders a hexdump with blob.ToDump($B) (and a chosen row width with blob.ToDumpWidth) — the quickest way to see what a protocol actually put on the wire. For everything beyond raw bytes — named fields, fixed-width integers, wire layouts — keep reading: that is what the assign system in the next section is for.
A blob passed to a plugin is handed over as a pointer and a length — no copy on the way in. Plugins that produce bytes (file reads, sockets, decoders) return blobs the same way. This is the “socket → buffer → struct → decision” path the front page talks about: the buffer is a blob, the struct is a schema over it.
Structs over blobs — the assign system
A blob is a flat run of bytes. A schema gives those bytes names and types, so the same buffer you can hexdump byte by byte is also readable as $P.x and $P.y. Nothing is copied and no wrapper object appears: the schema is a view bound to the blob at runtime.
This is what makes binary work pleasant in DominScript. You keep the exact byte layout a wire protocol or file format demands, and you still write ordinary member access.
Declaring a schema
Schemas are declared at file level and registered under a global name. Member types are the fixed-width scalars: i8/u8, i16/u16, i32/u32, i64/u64, float, double.
plugin "builtin:struct"; // natural C alignment (default) struct Point { i32 x; i32 y; } // packet: no padding between members — for wire formats struct Header packet { u8 flags; i32 length; u8 checksum; }
The packet qualifier is the whole difference between a struct that matches your compiler's layout and one that matches the bytes on the wire. sizeof tells you which you got:
struct Point { i32 x; i32 y; } struct Header packet { u8 flags; i32 length; u8 checksum; } printf("%d %d\n", sizeof(Point), sizeof(Header)); // 8 6 — Point is 4+4 aligned; Header is 1+4+1 packed (not 12)
Binding a schema: the assign statement
Binding is a statement, not an expression — there is no =:
struct Point { i32 x; i32 y; } blob $P[sizeof(Point)]; $P assign Point; // bind the schema — no '=' $P.x = 100; $P.y = 200; printf("x=%d y=%d\n", $P.x, $P.y); // x=100 y=200
Writing $P = assign Point; is a parser error: "Schema binding is now a statement, not an expression." The reason is that binding does not produce a value — it attaches a view to a variable that already exists. If you meet the = form in older material, drop the =.
Each blob carries its own binding, so two blobs on the same schema are fully independent — there is no shared instance behind them.
Asking a variable about its schema
Three properties describe the binding. They work on blobs and on strings.
| Property | Meaning |
|---|---|
.HasStruct | 1 if a schema is bound, otherwise 0 |
.StructSize | size of one element in bytes (0 when unbound) |
.Count | how many elements fit — .Length / .StructSize |
struct Point { i32 x; i32 y; } blob $P[sizeof(Point)]; printf("%d %d\n", $P.HasStruct, $P.StructSize); // 0 0 $P assign Point; printf("%d %d %d\n", $P.HasStruct, $P.StructSize, $P.Count); // 1 8 1
Struct arrays — many elements in one buffer
If the buffer is longer than one element, it is an array: elements sit back to back with the schema size as stride, and $V[i].member addresses them. The index may be any runtime i32 expression.
struct Point { i32 x; i32 y; } blob $Pts[24]; // 3 x 8 bytes $Pts assign Point; printf("Count=%d Length=%d\n", $Pts.Count, $Pts.Length); // Count=3 Length=24 $Pts[0].x = 10; $Pts[1].x = 30; $Pts[2].x = 50; i32 $i = 1; printf("%d %d\n", $Pts[$i].x, $Pts.x); // 30 10 — the unindexed form is element 0
$V.member and $V[0].member are the same thing, so single-element code keeps working unchanged. Out-of-range indices and indexing an unbound variable are caught as errors, not silently read.
Schemas on strings — fixed-width text fields
A string can carry a schema too, with u8 members only. The u8 name[N] form declares an N-byte fixed-width field — exactly what fixed-column records and text-based protocols are made of.
Reading a field returns the slice as a NUL-terminated string. Writing pads the field with spaces out to its width, so the record's column layout survives every write.
plugin "builtin:struct"; struct Row packet { u8 name[8]; u8 code[4]; } string $S[32] = "alice AB12"; $S assign Row; string $N[16]; $N = $S.name; printf("<%s>\n", $N); // <alice > $S.name = "bob"; // padded to 8 bytes printf("<%s>\n", $S); // <bob AB12>
Dynamic schemas — growing the shape at runtime
Schemas are not frozen at parse time. Four helpers from builtin:struct add and remove members while the program runs; the new offsets are recomputed for you under the same alignment or packing rules. The last positional argument is an insertion index: 0 puts the member first, MemberCount appends.
| Helper | Scope |
|---|---|
AddStructMember / DelStructMember | the global schema — affects every future binding |
AddBlobMember / DelBlobMember | one instance — only the blob you pass by ref |
For the per-instance form the final argument is migrate, and it is the one flag worth reading twice:
migrate | What happens to the bytes |
|---|---|
0 | the schema changes, the bytes stay exactly where they are — you are reinterpreting the same buffer, and keeping it coherent is your job |
1 | the blob physically grows and existing members are moved to their new offsets; the new member is zeroed |
struct Point { i32 x; i32 y; } blob $A[8]; $A assign Point; $A.x = 1; $A.y = 2; // insert z in the MIDDLE (index 1) and move the data with it AddBlobMember(ref $A, "z", "i32", 1, 1); printf("x=%d z=%d y=%d len=%d\n", $A.x, $A.z, $A.y, $A.Length); // x=1 z=0 y=2 len=12 — x kept, z zeroed, y slid over, blob grew 8 -> 12 blob $C[8]; $C assign Point; printf("%d\n", $C.StructSize); // 8 — the sibling is untouched
Per-instance mutation is what lets one script speak several versions of the same format: bind the base schema, then extend the instances that carry the newer fields. Use migrate=1 when you are holding live data; migrate=0 is for the case where the bytes already have the new shape and only the description was missing.
The four helpers are documented one by one under Built-ins → builtin:struct, and the blob side of the story — hexdumps of the very buffers you are shaping here — is on the blob plugin page.
Explicit casts
Casts follow C. An integer → integer cast always succeeds and wraps silently to the target width — the cast is the spelled-out truncation, exactly as in C. Casts involving floats stay strict: the value must be whole and in range, or you get an error.
// float/double -> integer (only if the value is whole) float $F = 3.0; i32 $I = (i32)$F; // OK: 3.0 is whole i32 $J = (i32)3.7; // ERROR: 3.7 is not whole // narrowing integer cast: silent wrap, like C i8 $Ok = (i8)127; // 127 i8 $Wr = (i8)300; // 44 (300 mod 256, reinterpreted signed) i8 $Ng = (i8)200; // -56 // unsigned / signed: the classic C idiom, no error u32 $U = (u32)-1; // 4294967295 // chainable double $Val = (double)(i16)7; // scalar -> string: integers decimal, float/double %.15g string $Sn[16] = (string)-42; // "-42" string $Sf[16] = (string)3.5; // "3.5" // casts to/from string, blob, or void are binder errors
Control flow
if / else if / else
if ($Value > 100) { printf("large\n"); } else if ($Value > 0) { printf("small\n"); } else { printf("zero or negative\n"); }
The elseif and else if spellings are equivalent.
while, for, do/while, do/until
i32 $I = 0; while ($I < 10) { $I += 1; } for ($I = 0; $I < 5; $I += 1) { printf("%d\n", $I); } // runs at least once, while condition is TRUE do { $I += 1; } while ($I < 3); // runs at least once, until condition is TRUE do { $I -= 1; } until ($I <= 0);
switch / case
switch ($Err) { case ERR_NONE: printf("none\n"); break; case ERR_IO: printf("i/o\n"); break; // fallthrough: a case without break falls into the next case 6: case 7: printf("weekend\n"); break; default: printf("unknown\n"); break; }
break, continue, goto, return
for ($I = 0; $I < 10; $I += 1) { if ($I == 3) { continue; } // skip 3 if ($I == 7) { break; } // stop at 7 printf("%d\n", $I); } // prints: 0 1 2 4 5 6 start: if ($I >= 5) { goto done; } $I += 1; goto start; done: return;
Resource cleanup: the goto canon
DominScript has no defer keyword, and that is a decision, not an omission: classic C has none either, and even the C2y technical specification chose a block-scoped design rather than the function-scoped one people usually mean by "defer". For handle-heavy code — listeners, TLS sessions, files — the language recommends the C canon instead, on two rules:
- Sentinel init — every handle starts invalid (
-1), so the cleanup block can tell what actually opened. - Single exit — after the first open there is no bare
return; every error path jumps to one cleanup block, where the closes stand in reverse order.
i32 Worker() { i32 $Rc = 1; // pessimistic default i32 $H1 = -1; i32 $H2 = -1; $H1 = Open(10); if ($H1 < 0) { goto vege; } $H2 = Open(20); if ($H2 < 0) { goto vege; } $Rc = 0; vege: if ($H2 >= 0) { Close($H2); } // reverse order: if ($H1 >= 0) { Close($H1); } // last opened closes first return $Rc; }
Even when a partially built state unwinds, exactly what opened gets closed, in the right order.
Functions
Basic syntax
// return type + name + parameters i32 Sum(i32 $A, i32 $B) { return $A + $B; } void PrintNumber(i32 $N) { printf("Number: %d\n", $N); return; }
Value vs ref parameters
Value parameters receive a copy; ref parameters receive a reference, so changes are visible on the original. Type matching on ref parameters is strict — an i64 argument will not bind to a ref i32 parameter.
void Increment(i32 $X) { $X += 1; } // copy void IncrementRef(ref i32 $X) { $X += 1; } // reference void main() { i32 $A = 10; Increment($A); printf("%d\n", $A); // 10 IncrementRef($A); printf("%d\n", $A); // 11 return; }
Blob / string ref parameters & recursion
void FillBlob(ref blob $B, i32 $Value) { i32 $I; for ($I = 0; $I < $B.Length; $I += 1) { $B[$I] = $Value; } } i32 Factorial(i32 $N) { if ($N <= 1) { return 1; } return $N * Factorial($N - 1); // 5! = 120 }
Calling plugin namespaces
Plugin functions are called in namespace.Function(...) form. (printf is an exception: the print plugin exports it directly, without a namespace.)
plugin "../plugins/print/PrintPlugin"; plugin "../plugins/path/PathPlugin"; plugin "../plugins/file_io/FileIoPlugin"; void main() { string $Norm[64] = path.Normalize("a/./b/../c"); printf("%s\n", $Norm); // a/c fileio.WriteFileText("out.txt", "content"); bool $Exists = fileio.Exists("out.txt"); printf("exists: %d\n", $Exists); return; }
That covers the core language. The section below walks through how plugins and callbacks actually move data through the runtime; the per-plugin reference lives one click away.
Plugins & callbacks: how data flows
DominScript plugins fall into four shapes. Adapter plugins are thin synchronous wrappers around an existing C library — every script call is one library call (e.g. json over cJSON, sqlite over libsqlite3, crypto over OpenSSL). Bridge plugins wrap a library too, but additionally mediate callbacks: sdl, gl, ffmpeg and image expose synchronous calls and deliver events to script callbacks through the host's callback queue. Callback-driven plugins like callbacktcp or callbackworker exist primarily to deliver events — their own OS threads enqueue work onto the same central queue. (Plus a fourth, plain group: native plugins such as str, map or csv — pure C implementations, wrapping nothing.) The two interesting data paths are below.
The callback model
Every callback in DominScript flows through the same machinery, regardless of which plugin produced the event (TCP/UDP listener, HTTP listener, worker pool, timer, callback listener). One single dispatcher thread drains a central FIFO queue and runs callbacks strictly one at a time. The plugin's own thread blocks until its callback finishes — or until its timeoutMs expires, in which case it resumes without a response (this is where the listeners' Dropped counters come from). The single-dispatcher rule is a load-bearing invariant — two script-side callbacks never run simultaneously.
And the script's main thread? It is not part of this path at all. main() keeps running (or sleeping) on its own thread while callbacks run on the dedicated dispatcher — the two can genuinely overlap in time. This is safe by language design: functions share no script variables (a callback sees only its own parameters, main() only its own locals), so there is nothing for the two threads to race on. Cross-thread state lives where it is guarded: the thread-safe plugin channels (map, queue, script_state) or a blob the plugin itself hands to the callback. The serialization invariant above is about callbacks among themselves — not about the main thread.
Safe data passing between main() and callbacks
Because main() and a callback can genuinely overlap in time, the rule is a pull model: data never "arrives" in a main-side variable — the callback writes to a guarded channel, and main() reads it into its own variable at a moment of its choosing. Three channels, three rules:
| Channel | Guarded by | Use it when |
|---|---|---|
Synchronous round-tripRequestText(...) return value | the completion mutex/cond-var pair — no overlap exists at all | main() wants the answer and is willing to wait for it. The cleanest handoff. |
| map / queue | the plugin's internal mutex (pinned by test_map_thread_safe) | main() keeps working while callbacks run — true concurrency. The mutex also guarantees visibility: main reads a fresh value. |
| script_state | nothing — a raw, unguarded byte buffer | Only when the two sides provably never overlap in time: under a synchronous request, or the cube demo's layout where event callbacks run while main sits inside a gl call. When in doubt, use map. |
callback i32 Tick(ref blob $X) { map.PutInt("s", "n", map.GetInt("s", "n") + 1); // callback writes the channel return 1; } // main, running concurrently, pulls a snapshot into ITS OWN variable: i64 $Snapshot = map.GetInt("s", "n");
On the synchronous path the callback's answer is produced by reassigning the ref parameter ($Req = $Reply;). Element-level writes ($Req[0] = …) mutate the borrowed bytes in place but do not by themselves produce a response — see step ⑧ above.
- ① Event arrives. A plugin OS thread reads bytes from a socket, fires a timer, or finishes a worker job. The bytes sit in the plugin's own buffer.
- ② InvokeBorrowedCallbackMeta. The plugin calls the host's
InvokeBorrowedCallbackMetawith the pointer, size, timeout, an optionalReleasefunction pointer, and a small metadata record — the peer's host, port, and (for mTLS) the client certificate's subject. No deep copy is made. (The plainInvokeBorrowedCallbackstill exists as the metadata-free fallback.) - ③ Wraps a borrowed blob. The host builds a lightweight
DominBlobwhose.Dataaliases the plugin's bytes. The wrapper carriesREADONLY | EXTERNALat the host level — but for aref blobcallback the dispatcher hands the script a writable view aliasing the same bytes, because that is the contract ofref: writing through it mutates the plugin's buffer in place, true zero-copy. - ④ Enqueue. The host queues a
DominCallbackEventon the central FIFO callback queue; the event carries the borrowed blob and the peer metadata together. - ⏸ Plugin blocks — bounded by timeoutMs. The plugin's own thread sleeps on the event's completion condition variable; it does not run the script callback itself. With
timeoutMs = -1it waits as long as the callback takes. With a finite timeout there are two outcomes when time runs out: if the callback hadn't started yet, the host removes the event from the queue and the callback never runs; if it was already running, it runs to completion on the dispatcher, but the plugin has already resumed — the response is discarded. Either way the plugin sees a TIMEOUT status; the listeners surface this as theirDroppedcounter. - ⑤ One dispatcher. A single, long-lived dispatcher thread drains the queue. Two script callbacks never run at the same time, so shared script state seen by callbacks is not subject to a data race.
- ⑥–⑦ Script callback runs. The dispatcher invokes the registered script function with the blob aliasing the borrowed buffer — and matches the callback's declared parameter list: 1 parameter (payload only), 3 (
+ string $PeerHost, i32 $PeerPort), or 4 (+ string $PeerCertSubject), filling the extras from the event's metadata. The same forms work on TCP, HTTP and UDP. - ⑧ Return. A ref-style callback returns an
i32, and the response contract is reassigning the ref parameter ($Req = $Reply;) — the dispatcher parks the reassigned blob as the response. Element-level writes ($Req[0] = …) mutate the borrowed bytes in place but do not by themselves produce a response. A blob-returning callback returns a fresh responseblobinstead. - ⑨ Signal completion. The host stores the response on the event and broadcasts the cond var.
- ⑩–⑪ Plugin resumes. The plugin wakes, takes the
ResponseBlob(for a ref-style callback this is the modified ref blob copied out at return), sends or stores the bytes, then frees it withhost->DestroyBlob.
Three lifetimes are at play:
- The plugin's own buffer (the bytes
buf). After the script callback returns, the host frees the borrowed wrapper, which fires the plugin-suppliedReleasefunction onbuf. This is the moment the borrowed bytes are reclaimed. If the plugin passesNULLforRelease(the skeleton's path), the synchronous call simply held the bytes alive for its duration and the plugin keeps ownership afterwards. - The borrowed-blob wrapper (the small
DominBlobstruct that the host built in step ③). Freed automatically when the script callback returns; the script never sees it again. - The response blob (the modified
ref blobcopied out at return — or the fresh blob a blob-returning callback builds in step ⑦). Owned by the host; refcounted. The plugin frees it withhost->DestroyBlobwhen done — that is when the response bytes are freed.
Adapter plugins — the contrasting model
Pure adapters (json, sqlite, crypto) have no threads, no queue, no callbacks. Every script-side call is a thin layer over exactly one call into an underlying C library (cJSON, libsqlite3, OpenSSL). They are stateless from the runtime's perspective; control flow is plain, synchronous, and lives on the same thread as the caller. Blobs do cross the boundary — crypto.Sha256($Data) takes a blob in and returns a blob out — but they don't travel the queue/dispatcher path: the plugin reads the script's bytes directly while the synchronous call holds them alive.
sdl, gl, ffmpeg, image wrap an external library and deliver events via callback. sdl.SetEventCallback and gl.PumpEvents use the same RegisterCallback + InvokeBorrowedCallback path shown in the first diagram — events from the underlying library funnel through the central queue and the single dispatcher, exactly as a TCP packet does.
Adapter plugins are synchronous: the script call returns on the same thread, no event queue, no dispatcher involvement; blobs cross the boundary by direct pointer while the call holds. Callback-driven and bridge plugins are queue-merged: every event funnels through one dispatcher, and the payload crosses the boundary as a borrowed (or refcounted) DominBlob.