MecScript Language

MecScript is a statically-typed, heap-less, C-style scripting language for 32-bit embedded systems. Source files (.mec) compile to a compact bytecode binary (.mbin) that runs on a stack-based virtual machine inside the host device.


1. Program Structure

There is no main function. Top-level statements execute in source order when the script runs.

int x = 40;
int y = 2;
System::PrintInt(x + y);   // 42

System:: is the namespace host (native) functions are called through by default. A host can put its natives in a namespace of its own instead. See Native functions.

Functions and classes may be declared at the top level. They can be referenced before their declaration appears in the file (see Forward references).


2. Comments

MecScript supports C-style single line and block comments.

// Line comment.

/* Block
   comment. */

3. Types

MecScript is a statically typed language. All data types are known at compile time, making execution faster and safer.

KeywordAliasMeaningWidth
voidno value (return type)-
booltrue / false1 byte
chars8signed 8-bit integer1 byte
byteu8unsigned 8-bit integer1 byte
shorts16signed 16-bit integer2 byte
ushortu16unsigned 16-bit integer2 byte
ints32signed 32-bit integer4 byte
uintu32unsigned 32-bit integer4 byte
floatf3232-bit IEEE-7544 byte
stringimmutable text constantref

The alias column is just another way to declare the same keyword: s8 and char compile to exactly the same type, so they mix freely and error messages always report the char/byte/… name.
Aliases are provided for convenience as they are a little more descriptive than the classical type names.

// These two data types are the same and both compile as "short"
short var1 = 0; // 16 bit signed integer
s16 var2 = 0;   // 16 bit signed integer

char and byte are numeric types, not a distinct character type. Individual character literals are not supported, so an integer must be used.

char c = 'a';  // Compile error
char c = 0x61; // OK

string is a reference to a compile-time constant. String variables and string native parameters can only ever hold a literal or another string constant. For mutable text, use a byte array (see Strings).


4. Literals

int   a = 42;        // decimal
int   b = 0xFF;      // hex
int   c = 0b1010;    // binary
int   d = 0o377;     // octal
float e = 3.14;      // float (a decimal point makes it a float)
bool  f = true;
bool  g = false;

null, NULL, and nil are all accepted and evaluate to integer 0.

String literals use double quotes. There are no escape sequences: "\n" is a backslash followed by n, not a newline.

System::PrintLine("Hello, world");

5. Variables

int count = 0;       // explicit initializer
int total;           // zero-initialized

All variables are zero-initialized if no initializer is given.

Scope

  • Variables declared at the top level are globals.
  • Variables declared inside a function or block are locals.
  • Blocks ({ ... }) introduce a new scope. A local is visible from its declaration to the end of its enclosing block.
int g = 1;           // global

void f() {
    int local = 2;   // local to f
    {
        int inner = 3;   // local to this block only
    }
    // inner is not visible here
}

const

const marks a variable read-only after its initializer runs. Writing to it later is a compile error.

const int LIMIT = 100;
LIMIT = 200;         // compile error: Cannot write to const variable after initialisation.

Naming Style

MecScript enforces no naming convention. One common pattern is UpperCamelCase for functions and global variables and lowerCamelCase for locals and parameters, which makes a name’s scope visible at a glance and matches how the native reference set is written. The compiler accepts any style, and the choice is entirely yours.


6. Operators

All basic mathematical and bitwise operations are supported.

Arithmetic

a + b       // add
a - b       // subtract
a * b       // multiply
a / b       // divide
a % b       // modulus (remainder)
Important

Integer division and modulus by zero halt the VM (vmDivisionByZero). Float division by zero also halts.

Bitwise

a & b       // bitwise AND
a | b       // bitwise OR
a ^ b       // bitwise XOR
~a          // bitwise NOT (ones complement)
a << b      // shift left
a >> b      // shift right

Comparison

a == b      // equal
a != b      // not equal
a < b       // less than
a > b       // greater than
a <= b      // less than or equal
a >= b      // greater than or equal

A comparison always produces a bool.

Logical

a && b      // logical AND
a || b      // logical OR
!a          // logical NOT

Assignment

a = b       // assign
a += b      // add and assign
a -= b      // subtract and assign
a *= b      // multiply and assign
a /= b      // divide and assign
a &= b      // bitwise AND and assign
a |= b      // bitwise OR and assign
a ^= b      // bitwise XOR and assign

There is no %=, <<=, or >>=.

Increment / Decrement

Both prefix and postfix forms work on a variable:

i++;        // postfix increment
++i;        // prefix increment
i--;        // postfix decrement
--i;        // prefix decrement

Narrow types wrap at their own width:

byte b = 255;
b++;                 // b is now 0

Ternary

Behaves like a single line if/else statement.

int max = (a > b) ? a : b;

// Is equivalent to:
int max;
if (a > b)
    max = a;
else
    max = b;

Precedence

From tightest to loosest binding:

  1. . [] () (member, index, call)
  2. ! ~ ++ -- (unary)
  3. * / %
  4. + - & | ^ << >>
  5. < > <= >=
  6. == !=
  7. &&
  8. ||
  9. ?: (ternary)
  10. = += -= *= /= (assignment)

Note that the bitwise and shift operators sit at the same level as + and -. Parenthesize when mixing them with arithmetic.


7. Type Conversions

Numeric types convert implicitly across signed, unsigned, and float categories as needed by an assignment, argument, or operator. There is no explicit cast syntax.

int   i = 10;
float f = i;         // int -> float
int   j = f / 4.0;   // float -> int on assignment

There is no integer overflow or wraparound detection. Arithmetic that exceeds a type’s range wraps silently.


8. Control Flow

if / else if / else

if (x > 0) {
    System::PrintInt(1);
} else if (x < 0) {
    System::PrintInt(-1);
} else {
    System::PrintInt(0);
}

while

A while loop will continue as long as the condition is true. There is no do/while.

int i = 0;
while (i < 5) {
    System::PrintInt(i);
    i++;
}

for

A for loop has an initializer, condition, and update expression. The loop will continue as long as the condition is true.

for (initializer, condition, update) { ... }
for (int i = 0; i < 10; i++) {
    System::PrintInt(i);
}

All three clauses are optional. Any of them may be left empty, and for (;;) is an infinite loop.

int i = 0;
for (; i < 10;) {        // no initializer, no post-expression
    System::PrintInt(i);
    i++;
}

for (;;) {               // infinite loop; exit with break
    if (done()) {
        break;
    }
}

break & continue

  • continue will skip the rest of the loop body and continue to the next loop iteration.
  • break exits the loop immediately.

break and continue work in while and for loops.

for(int i = 0; i < 10; i++) {
    if (i == 5) {
        break;
    }
    if (i % 2 == 0) {
        continue;
    }
    // do work...
}

switch

The controlling expression is an integer (float is rejected). case labels are integer literals and must be unique. default is optional. A case without a break falls through to the next case, as in C.

switch (code) {
    case 1:
        System::PrintLine("one");
        break;
    case 2:
        System::PrintLine("two");
        break;
    default:
        System::PrintLine("other");
        break;
}

A case with no body falls straight into the next one, which is how several values share a single handler:

switch (key) {
    case 1:
    case 2:
    case 3:
        System::PrintLine("low");      // runs for key 1, 2, or 3
        break;
    case 4:
        System::PrintLine("four");
        break;
}

A case that has a body but no break runs its own body and then continues into the next case:

switch (n) {
    case 1:
        System::PrintInt(1);           // no break: falls through
    case 2:
        System::PrintInt(2);
        break;
    case 3:
        System::PrintInt(3);
        break;
}
// n == 1 prints 1 then 2
// n == 2 prints 2
// n == 3 prints 3
Info

switch statements are much more efficient than long if/else chains. They use a jump table to very quickly jump to the correct case label, rather than checking every entry for a match. The tradeoff is they produce more compiled binary size for large ranges.

A switch statement has to produce a jump table entry for every value between its lowest and highest case label value. This means that for large value ranges with a low number of case labels, the compiled output will be huge compared to if/else. The number of case labels has no effect on performance. The compiler will output a warning if the number of case labels is less than half the value range.

// Very fast, small/tight binary.
// Generates ~4 jump table entries
switch(input) {
    case 1: //...
    case 2: //...
    case 3: //...
    case 4: //...
}

// Very fast, very bloated binary.
// Generates ~300 jump table entries. Compiler warning.
switch(input) {
    case 100: //...
    case 200: //...
    case 300: //...
    case 400: //...
}

// Slower (must evaluate every condition until a match is found), minimal binary size.
if (input == 100) {
    //...
} else if (input == 200) {
    //...
} else if (input == 300) {
    //...
} else if (input == 400) {
    //...
}

9. Functions

int add(int a, int b) {
    return a + b;
}

void greet() {
    System::PrintLine("hi");
}

System::PrintInt(add(3, 4));   // 7
greet();
  • Scalar parameters are passed by value.
  • A non-void function must return a value.
  • Calling with the wrong number of arguments is a compile error.
  • Recursion is supported.

Forward References

A top-level function or class may be used before it is declared in the file. This allows mutual recursion:

bool isEven(int n) {
    if (n == 0) { return true; }
    return isOdd(n - 1);
}

bool isOdd(int n) {
    if (n == 0) { return false; }
    return isEven(n - 1);
}

System::PrintInt(isEven(10));  // 1

Array Parameters

An array parameter is written T name[] or T *name (equivalent). The array size is not carried in the type itself, so the caller still passes the array by bare name plus an explicit length argument, and should not rely on count/len matching the array’s real size. A variable index used inside the function is checked at runtime against the caller’s actual array, the same as any other array (see “Bounds checking” in the Arrays section); only a native function’s own C++ implementation is unchecked, C snprintf-style.

int sum(int values[], int count) {
    int total = 0;
    for (int i = 0; i < count; i++) {
        total += values[i];
    }
    return total;
}

int data[4] = {1, 2, 3, 4};
System::PrintInt(sum(data, 4));    // 10

Passing a non-array where an array parameter is expected, or an array of the wrong element type, is a compile error. So is giving the parameter a size (int values[4]).

Inside the function the parameter is used exactly like an array: index it, or pass it on by name to another array parameter, script or native. It cannot be used as a value on its own.

void send(byte data[], int length) {
    System::SendCanMessage(0, 0x100, data, length);
}

Class parameters

See Classes.


10. Arrays

int arr[5] = {10, 20, 30, 40, 50};
int zero[8];                 // all elements 0
  • Array size is fixed at compile time.
  • If an initializer list is present, its length must match the declared size exactly. int a[3] = {1, 2}; is a compile error.
  • Without an initializer, every element is zero.

Indexing and assignment

System::PrintInt(arr[0]);      // 10
arr[2] = 99;
arr[2] += 1;         // 100

Packed storage

char/byte arrays pack 4 elements per 4-byte slot; short/ushort arrays pack 2 per slot. This is transparent to the script; index them normally.

byte buf[16];        // 4 slots
buf[0] = 1;
buf[15] = 200;

Bounds checking

A literal out-of-range index is a compile error, including a negative one:

byte b[8];
b[8] = 1;             // compile error: index 8 is out of bounds for 'b' (size 8)
b[-1] = 1;            // compile error

A variable or computed index is checked at runtime instead. An out-of-range access halts the VM with vmArrayIndexOutOfBounds rather than reading or writing whatever happens to sit next to the array:

byte b[8];
int i = 8;
b[i] = 1;             // halts with vmArrayIndexOutOfBounds

This covers array parameters too: a function indexing a T name[] parameter with a variable index is checked against the size of whatever array the caller actually passed in, even through several levels of forwarding. It also covers an array field declared inside a class, however it’s reached: directly, through a class-typed parameter, or through a composed/embedded instance. It never applies inside a native function’s own implementation, which reads and writes the buffer directly in host C++ with no VM involvement at all.

Bare array references

An array name used without an index has no value. It cannot be assigned to a scalar, returned as a scalar, or passed as a scalar argument. Pass it only to an array parameter (with a length) or index it.

int arr[3] = {10, 20, 30};
int x = arr;         // compile error

11. Strings

String literals are immutable compile-time constants. Use them directly with the print natives:

System::Print("no newline");
System::PrintLine("with newline");

For text you need to build or modify at runtime, use a byte buffer and the string natives. Every string native takes an explicit capacity, C snprintf-style. Nothing grows a buffer for you.

byte msg[32];
System::StrCopy(msg, 32, "Value: ");
byte num[16];
System::IntToStr(num, 16, 42);
// (append num's characters via StrAppend from a string constant only;
//  buffer-to-buffer append is not supported)
System::StrAppend(msg, 32, "42");
System::PrintBuffer(msg, 32);        // Value: 42
System::PrintInt(System::StrLength(msg, 32));  // 8

Notes and limits:

  • StrCopy / StrAppend take a string constant as the source, not another byte[] buffer.
  • A freshly declared buffer is zero-filled, so StrLength on an untouched buffer is 0.
  • Content that does not fit the capacity is truncated and null-terminated.

12. Classes

Classes are supported for advanced data structures.

class Point {
    int x;
    int y;

    Point(int px, int py) {
        this.x = px;
        this.y = py;
    }

    int sum() {
        return this.x + this.y;
    }

    void shift(int dx, int dy) {
        x += dx;         // 'this.' is optional inside a method
        y += dy;
    }
}

Point p(1, 2);
System::PrintInt(p.sum());         // 3
p.shift(10, 10);
System::PrintInt(p.sum());         // 23

Fields

Declared in the class body. Each instance gets its own copy.

Methods

  • Inside a method, this.field and a bare field name both refer to the current instance’s field.
  • A method can call a sibling method on the same instance with this.method().
class Calculator {
    int total;

    Calculator(int start) { this.total = start; }

    void addFive() { this.total += 5; }

    void addTen() {
        this.addFive();
        this.addFive();
    }

    int get() { return this.total; }
}

Constructors

ClassName(params) { ... }. A class has at most one constructor. It runs when an instance is declared with an argument list:

Counter c(10);       // runs Counter(int)

Fields are always zero-initialized first, before the constructor body runs. A class with no constructor is declared without parentheses:

Counter d;           // no constructor; fields are 0

Declaring an instance of a class that has a constructor without an argument list compiles, but produces a warning.

Destructors

~ClassName() { ... }. Runs automatically when the instance goes out of scope:

  • A local instance is destroyed at the end of its enclosing block.
  • Multiple instances in the same scope are destroyed in reverse declaration order (LIFO).
  • Global instances are destroyed once, at script end, in reverse declaration order.
class Noisy {
    int id;
    Noisy(int i) { this.id = i; }
    ~Noisy() { System::PrintInt(this.id); }
}

void test() {
    Noisy n(42);
    System::PrintInt(1);
}

test();              // prints 1 then 42
System::PrintInt(2);

Passing instances

Pass an instance to a function or method by reference with ClassName *param. The callee can call methods on it and read or write its fields, including compound assignment.

void bump(Counter *c) {
    c.increment();
}

Counter a(5);
bump(a);             // a.value is now 6

Passing an instance of the wrong class, or a non-instance, is a compile error. A bare instance name (no ., no method call, not passed to a class parameter) has no value and cannot be used as one.

Class-typed fields (composition)

A field may itself be a class instance. The embedded instance is laid out inline in its owner and reached with a chain of .:

class Inner {
    int value;
    void bump() { value += 100; }
}

class Outer {
    Inner inner;
    int count;

    void run() {
        this.inner.bump();      // method call on an embedded instance
        inner.bump();           // 'this.' is optional, same as any field
    }
}

Outer o;
o.inner.value = 900;           // read / write an embedded field
o.count = 4;
o.run();
System::PrintInt(o.inner.value);         // 1100
  • Nesting is unlimited: a.b.c.x resolves as long as each step names a class-typed field.
  • Compound assignment works through the chain: o.inner.value += 50;.
  • An embedded instance can be passed by reference like any other instance: take(o.inner); where take takes Inner *i.
  • When an instance is created, each embedded field is zero-initialized and its field-default initializers run, outermost first.
  • Destructors run automatically and in order: the owner’s destructor body first, then each embedded field’s destructor in reverse declaration order.

Limits:

  • No member-initializer syntax. You cannot pass constructor arguments to an embedded field. Embedding a class whose constructor takes arguments is a compile error. A class with no constructor (or a parameterless one) is fine.
  • No cycles. A class cannot contain itself, directly or indirectly (class A { A a; }, or A holding a B that holds an A). This is a compile error.

Not supported

  • Inheritance. Every class is standalone. There is no subclassing.
  • Class-typed return values. A function cannot return a class instance.

13. Namespaces

Namespaces are optional. They group top-level declarations under a name so they can be kept tidy and referred to explicitly. They have no runtime cost or effect: a namespaced global is still a plain global, and a namespaced function is still an ordinary function.

namespace Geometry {
    int gridSize = 16;

    int area(int w, int h) {
        return w * h;
    }

    class Point {
        int x;
        int y;
        int sum() { return x + y; }
    }
}

Refer to a member from outside with the :: scope operator:

Geometry::gridSize = 32;
System::PrintInt(Geometry::area(3, 4));   // 12

Geometry::Point p;
p.x = 1;
p.y = 2;
System::PrintInt(p.sum());                 // 3
  • Unqualified access inside the block. Within namespace Geometry { }, other members are visible without the prefix (area() can call gridSize and Point directly). Names that don’t resolve inside the namespace fall back to the global scope.
  • Reopening. The same namespace name may be opened more than once, and the contents are merged.
  • Forward references work across the whole file, exactly as they do at the top level.
  • No nesting. A namespace cannot be declared inside another namespace.
  • Native namespaces are reserved. A script cannot declare a namespace that natives were declared in, and System is always off limits. See Native functions.

14. Preprocessor

Runs on the token stream before parsing. Two directives are supported.

#include

#include "utils.mec"
  • Path is resolved relative to the including file first. If it is not found there, each include search directory given to the compiler (MecCompile -I <dir>, see TOOLCHAIN.md) is tried in order. Absolute paths are used as is.
  • Each file is included at most once, so diamond includes are safe.
  • A circular include is a compile error, not a hang.
  • A missing file is a compile error.
  • Errors inside an included file are reported against that file’s own line numbers.

#define

Object-like macros only.

#define WIDTH  10
#define HEIGHT 5
#define AREA   (WIDTH * HEIGHT)

System::PrintInt(AREA);        // 50
  • A name is a macro only from its #define onward.
  • A macro body may reference an earlier macro, which is re-scanned and expanded.
  • Self-referential and mutually-referential macros expand once and stop.
  • An empty replacement is allowed and vanishes at the use site.
  • Function-like macros (#define SQ(x) ((x)*(x))) are a compile error.
  • There are no conditional directives (#ifdef, #if, #endif, #undef).
  • A macro defined in an including file is visible inside included files.

15. Native functions

Native functions are implemented by the host application and called from a script. They are declared in a signature file that the build supplies to the compiler.

Calling convention

Every native must be called through the namespace it was declared in:

System::Yield(10);
System::PrintInt(count);

A bare Yield(10) is a compile error.

Declaration syntax

A declaration written outside any namespace goes into System:

[native 3] void PrintInt(int i);              // called as System::PrintInt(1)
[native 8] void StrCopy(byte dest[], int destCapacity, string src);

Each declaration needs a unique [native N] id that maps to the host’s implementation. A native declared here but not resolved by the host fails at runtime (vmNativeFunctionNotResolved), not at compile time.

Namespaces

Wrap declarations in a namespace block to group them under a name of your own:

namespace CAN {
    [native 301] void Send(uint id, byte *buffer, int length);
    [native 302] int Read(uint id, byte *buffer, int length);
}
CAN::Send(0x100, frame, 8);
  • The namespace only changes the name a script calls the native by. There is no runtime cost and no effect on the compiled binary, and the [native N] id is still the only thing the host resolves against.
  • Namespaces cannot be nested, same as in a script.
  • A namespace used by any native is reserved, so a script cannot declare one with the same name. System is reserved whether or not anything is declared in it.
  • Only the namespace is reserved, not the names inside it. With CAN::Read declared, a script is still free to declare its own Read variable or function, or a Data::Read of its own.

Callback parameters

A native can take a script function as an argument. The parameter type is func, and the script passes a bare function name, no parentheses:

namespace CAN {
    [native 303] void Subscribe(uint node, uint idMatch, uint idMask, func(uint id, byte data[], int length) handler);
}
void onFrame(uint id, byte data[], int length) {
    // data[0] .. data[length - 1] is the frame payload
}

CAN::Subscribe(0, 0x100, 0x7FF, onFrame);

The parameter list after func is the signature the script function must have. It always returns void. The compiler checks the function’s parameter count, each parameter’s type, and whether it is an array, and reports a mismatch at the call site. A bare func with no parameter list accepts any void function; a wrong parameter count is then only caught when the host calls it (vmCallArgCountError).

An array parameter in a callback is host-owned memory that lives only for the duration of the call. Index it or pass it on to another array parameter as usual, and copy anything you want to keep into a script array before returning.

Reference set

SignaturePurpose
void SetError(int code)Signal a recoverable error code to the host.
void Print(string str)Write a string, no newline.
void PrintLine(string str)Write a string and a newline.
void PrintInt(int i)Write an integer and a newline.
void PrintFloat(float f)Write a float and a newline.
void PrintFormat(string str, float f)Write a format string with one float (PrintFormat("v: %f", 3.14)).
int StrLength(byte buf[], int capacity)Length up to the null terminator or capacity.
void StrCopy(byte dest[], int destCapacity, string src)Copy a string constant into a buffer, truncating to fit.
void StrAppend(byte dest[], int destCapacity, string src)Append a string constant onto a buffer’s content.
void IntToStr(byte dest[], int destCapacity, int value)Format an integer as decimal text into a buffer.
bool StrEquals(byte a[], int capA, byte b[], int capB)Compare two buffers’ null-terminated contents.
void PrintBuffer(byte buf[], int capacity)Write a buffer’s null-terminated content.
uint NowMs()Host uptime in milliseconds. Wraps on overflow - compare with unsigned subtraction.
uint NowUs()Host uptime in microseconds. Wraps on overflow, typically much sooner than NowMs.
void Yield(uint t)Pause for t milliseconds.
uint YieldUntil(uint lastTime, uint delay)Fixed-period pause: sleeps until lastTime + delay, returns the new lastTime to pass back in next iteration.

SetError

SetError(int) records an error code without halting the VM. The host reads it back after the run (MecVm::GetScriptError()). The last call wins. The default is 0.

if (sensorReading < 0) {
    System::SetError(42);
}

16. Runtime Model and Limits

  • No heap. Globals, locals, and call frames all live in one buffer supplied by the host. The VM never allocates at runtime.
  • 64 KB bytecode ceiling. A single compiled binary, including everything pulled in by #include, cannot exceed 65,535 bytes of bytecode.
  • Fixed stack. The host sets the stack size. Deep recursion or large local arrays can exhaust it (vmStackOverflow).
  • Bounds checking. Stack overflow/underflow and out-of-range pointer dereferences are caught and halt the VM rather than corrupting memory. The host can disable this for targets that cannot afford the checks. A local, global, or array-parameter index that runs off the end of its array is also caught (vmArrayIndexOutOfBounds); see “Bounds checking” under Arrays.
  • Division by zero halts the VM (vmDivisionByZero) for integer and float operands.
  • No overflow detection. Arithmetic wraps silently.
  • No exceptions. There is no try/catch/throw. A genuine runtime error halts the VM. Use SetError for recoverable conditions.
  • No string escape sequences.

VM status

A run ends with a status code. vmEnd is normal completion. Others include vmDivisionByZero, vmStackOverflow, vmStackUnderflow, vmPointerOutOfBounds, vmArrayIndexOutOfBounds, vmNativeFunctionNotResolved, and vmUnknownInstruction.


17. Complete Example

#define TABLE_SIZE 8

int primes[TABLE_SIZE];
int primeCount = 0;

bool isPrime(int n) {
    if (n < 2) {
        return false;
    }
    for (int d = 2; d * d <= n; d++) {
        if (n % d == 0) {
            return false;
        }
    }
    return true;
}

void collectPrimes() {
    int candidate = 2;
    while (primeCount < TABLE_SIZE) {
        if (isPrime(candidate)) {
            primes[primeCount] = candidate;
            primeCount++;
        }
        candidate++;
    }
}

class Accumulator {
    int total;

    void add(int v) {
        this.total += v;
    }

    int get() {
        return this.total;
    }
}

collectPrimes();

Accumulator acc;
for (int i = 0; i < primeCount; i++) {
    System::PrintInt(primes[i]);
    acc.add(primes[i]);
}

System::PrintLine("sum:");
System::PrintInt(acc.get());

Output:

2
3
5
7
11
13
17
19
sum:
77