Subsections of Scripting

EmC Language

EmC Icon EmC Icon

EmC is a statically-typed, heap-less, C-style scripting language for memory-constrained 32-bit embedded systems. Source files (.emc) compile to a compact bytecode binary (.emcbin) that runs on a stack-based virtual machine with no external dependencies and no runtime heap allocation.

The syntax is close to C. Most C code that avoids pointers, the heap, and the standard library will look familiar.


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 for functions provided by the host application, called native functions. A host can also group natives under namespaces of its own. 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

EmC supports C-style single line and block comments.

// Line comment.

/* Block
   comment. */

3. Types

EmC 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
uint  e = 42U;         // unsigned (a U or u suffix, any base)
uint  f = 0xFFFFFFFFu; // unsigned literals can use the full 32-bit range
float g = 3.14;        // float (a decimal point makes it a float)
bool  h = true;
bool  i = false;

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

An integer literal without a suffix is an int. Add U to make it a uint, as in C. This matters in two places: a plain literal assigned to a uint variable warns about an implicit cast, and an expression is only compiled with unsigned arithmetic and comparisons when one of its operands is unsigned, so x > 0U and x / 2U behave correctly for a uint x above 2147483647. A literal larger than 4294967295 is a compile error. An unsuffixed literal above 2147483647 keeps its bit pattern and wraps negative as an int, with a warning unless it is being assigned to a uint (so uint mask = 0xFFFFFFFF; is fine) or negated.

String literals use double quotes and must end on the line they start on. A backslash starts an escape sequence:

EscapeCharacter
\nNewline (line feed)
\rCarriage return
\tTab
\\Backslash
\"Double quote
\'Single quote

Any other escape is a compile error. That includes \0, because strings are stored null-terminated and it would cut the string short. To put a backslash in a string, write it twice: "C:\\temp". An empty string "" is allowed.

System::PrintLine("Hello, world");
System::Print("Line one\r\nLine two\r\n");
System::PrintLine("She said \"hi\"");
System::PrintLine(""); // Just the newline

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.

A const array must be initialized where it is declared, and its elements can’t be written after that. Assignment, compound assignment and ++/-- on an element are all compile errors. This works well for lookup tables:

const byte GEAR_RATIOS[4] = {35, 21, 14, 10};
byte r = GEAR_RATIOS[gear];   // reading is fine
GEAR_RATIOS[0] = 40;          // compile error: Cannot write to an element of const array 'GEAR_RATIOS'.
const byte EMPTY[4];          // compile error: a const array must be initialized

A const array can only be passed to a const array parameter (see “Array Parameters”).

Naming Style

EmC 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 functions are named. 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 with a “Division By Zero” error. 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, a class field or an array element:

i++;        // postfix increment
++i;        // prefix increment
i--;        // postfix decrement
--i;        // prefix decrement
car.speed++;
--this.count;
counts[b]++;
++buf[n];

A postfix ++ or -- has to be the whole expression, as in i++; or int old = counts[b]++;. Using it inside a larger expression, such as a + i++, is a compile error. The index is evaluated once, so counts[Next()]++; calls Next() once.

Narrow types wrap at their own width:

byte b = 255;
b++;                 // b is now 0
short s = 32767;
s++;                 // s is now -32768

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

A function call’s return value converts the same way as any other value, so float f = count(); converts an int result to float and int n = 2 * ratio(); converts a float result to int.

The compiler warns when a float is converted to an integer type, because the fractional part is lost. This covers declarations, assignments, arguments, return values and array indexes. Other conversions, such as signed to unsigned, don’t warn by default.

Mixing int and float in arithmetic gives a float, as in C. The value keeps its fraction until it is stored, passed or returned, so int n = i * f * 4; only converts to int once, at the end. When the target is a float, the whole expression is worked out in float, including integer division. So float h = i / 2; with i = 1 gives 0.5, not 0.

Bitwise operators and switch need integer values, so a mixed expression like (i + f) & 3 is a compile error.

Storing a value in a char, byte, short or ushort wraps it to that type’s range, as in C. That happens at every store: a declaration, an assignment, a compound assignment, ++/--, an argument, and a return value. Arithmetic in between is done in int, so a result is only cut down when it’s stored. This makes decoding a signed 16-bit value from two bytes work as expected:

// data holds 9C FF, little-endian
short raw = (data[1] << 8) | data[0];   // -100
short big = 40000;                      // -25536
byte  low = 300;                        // 44

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.
  • A bare return; in top-level code ends the script early. Destructors still run for every class instance declared before it. Returning a value from top-level code is a compile error.

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’s size is not part of the parameter type, so pass the length as a separate argument. A variable index used inside the function is still checked at runtime against the caller’s actual array, the same as any other array (see “Bounds checking” in the Arrays section).

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);
}

Declare an array parameter const when the function only reads it. A const parameter can’t be written inside the function, and it accepts both const and ordinary arrays. A const array can only be passed to a const parameter, since an ordinary parameter could write to it. The same applies when a function passes its own const parameter on.

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

const byte TABLE[3] = {1, 2, 3};
byte buffer[3] = {4, 5, 6};
total(TABLE, 3);    // fine
total(buffer, 3);   // fine

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 an “Array Index Out Of Bounds” error rather than reading or writing whatever happens to sit next to the array:

byte b[8];
int i = 8;
b[i] = 1;             // halts: Array Index Out Of Bounds

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 does not cover what a native function does with an array you pass it. See Native functions.

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 the host’s natives use, and System is always off limits. See Native functions.
  • Math is reserved for the built-in math functions. See Math functions.

14. Preprocessor

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

#include

#include "utils.emc"
  • Path is resolved relative to the including file first. If it is not found there, any include directories set up by the host are searched 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 provided by the host application. They give a script access to the device it runs on, for example printing, timing and communications. Which natives are available depends on the host. The reference set below is a common starting point.

Calling natives

Every native must be called through its namespace:

System::Yield(10);
System::PrintInt(count);
CAN::Send(0x100, frame, 8);

A bare Yield(10) is a compile error.

Most natives are in System. A host can group others under namespaces of its own, such as CAN above.

  • A namespace used by any native is reserved, so a script cannot declare one with the same name. System is always reserved.
  • Only the namespace is reserved, not the names inside it. With CAN::Read available, a script is still free to declare its own Read variable or function, or a Data::Read of its own.

If the script calls a native the host doesn’t provide, it still compiles, but halts with a “Native Function Not Resolved” error when the call runs.

Array arguments

Pass an array to a native by bare name, followed by its length:

byte msg[32];
System::StrCopy(msg, 32, "hello");

The native trusts the length you give it. Its access to the array is not bounds checked, so never pass a length larger than the array.

A native that only reads an array takes it as a const parameter, and accepts both const and ordinary arrays. Passing a const array to a native that isn’t marked const is a compile error.

Callback parameters

Some natives take a script function as an argument, so the host can call back into the script later, for example when a CAN frame arrives. Pass the function’s bare name, with no parentheses:

void onFrame(uint id, byte data[], int length) {
    // data[0] .. data[length - 1] is the frame payload
}

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

A callback always returns void, and its parameters must match what the native expects. The compiler checks the parameter count, each parameter’s type, and whether it is an array, and reports a mismatch at the call site. Some natives accept any void function. A wrong parameter count is then only caught when the host calls it, which halts the script with a “Call Arg Count Error”.

An array parameter in a callback is only valid 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

These natives are all in the System namespace, so Print is called as System::Print("hi").

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(const 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(const byte a[], int capA, const byte b[], int capB)Compare two buffers’ null-terminated contents.
void PrintBuffer(const 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 application can read it after the script finishes. The last call wins. The default is 0.

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

16. Math functions

A set of math functions is built into the language under the Math namespace. They are part of the VM itself, so they are always available, whatever natives the host provides.

float hypot(float a, float b) {
    return Math::Sqrt(a * a + b * b);
}

int clamp(int v, int lo, int hi) {
    return Math::Min(Math::Max(v, lo), hi);
}
FunctionResult
Math::Sqrt(x)Square root.
Math::Pow(base, exp)base raised to exp.
Math::Sin(x), Math::Cos(x), Math::Tan(x)Trigonometric functions. x is in radians.
Math::Asin(x), Math::Acos(x), Math::Atan(x)Inverse trigonometric functions, in radians.
Math::Atan2(y, x)Angle of the point (x, y) in radians, in the range -pi to pi.
Math::Exp(x)e raised to x.
Math::Log(x)Natural logarithm.
Math::Log2(x), Math::Log10(x)Base 2 and base 10 logarithms.
Math::Floor(x), Math::Ceil(x)Round down / up to a whole number.
Math::Round(x)Round to the nearest whole number, halves away from zero.
Math::Fmod(x, y)Floating point remainder of x / y.
Math::Abs(x)Absolute value.
Math::Min(a, b), Math::Max(a, b)Smaller / larger of the two.

All functions take and return float. An int argument is converted to float first, the same as passing it to a float parameter.

Abs, Min and Max are the exception: when every argument is an integer type they work in integers and return int, so int m = Math::Max(3, 7); is exact with no float round trip. If any argument is a float, including a mixed expression like i + 0.5, the whole call is done in float.

A domain error such as Math::Sqrt(-1.0) or Math::Log(0.0) produces NaN or infinity, as in C. It does not halt the VM.


17. 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.
  • No whole-script size ceiling. Function entry points are 32-bit offsets, so a compiled binary can be as large as the host is willing to load. Two 16-bit limits do apply: a single if, else, loop body, or switch cannot span more than 64 KB of bytecode (the compiler reports “Too much code to jump over”), and the VM’s data area (globals plus stack) is capped at 65,535 slots, which is 256 KB.
  • Fixed stack. The host sets the stack size. Deep recursion or large local arrays can exhaust it (“Stack Overflow”).
  • 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 (“Array Index Out Of Bounds”). See “Bounds checking” under Arrays.
  • Division by zero halts the VM (“Division By Zero”) 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.
  • Language version. A compiled binary records the language version it was built for. A VM won’t load a binary from a newer version, for example a 0.3 script on a 0.2 VM, since it may use instructions that VM doesn’t have. Binaries from older versions still load. Recompile a script with the matching compiler, or update the VM.

Runtime errors

Every run ends with a status. “End” means the script finished normally. Anything else is an error that stopped it. The common ones are:

ErrorCause
Division By ZeroAn integer or float division, or a modulus, by zero.
Array Index Out Of BoundsA variable array index was outside the array.
Stack OverflowThe script ran out of stack, usually from deep recursion or large local arrays.
Native Function Not ResolvedThe script called a native the host doesn’t provide.
Call Arg Count ErrorThe host called a callback with the wrong number of arguments.
Stack Underflow, Pointer Out Of Bounds, Unknown InstructionA damaged binary, or a problem in the VM or host rather than in the script.

A binary can also be refused when it’s loaded, before any of it runs: “Unsupported Version” for a binary compiled for a newer language version, and “Invalid Script” for one that is malformed or fails its checksum.


18. 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

Copyright © 2026 Emtron Australia Pty Ltd

Script Management

The TCM hosts a powerful in-house developed scripting language called EmC. This allows users to write their own functionality to do almost anything from advanced CAN integrations to special control functions for transmission or vehicle systems.

EmC IDE EmC IDE

Info

EmC requires a Developer License and must be activated using the Build Manager on each device.


EmC

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

Intellectual Property

EmC is compiled before it reaches the hardware, so developers can distribute their own proprietary compiled code without the user having or requiring access to the source code.
Once an EmC binary is uploaded to the device, it cannot read back and exported for redistribution without the original file.

Because EmC is statically typed, a lot of error checking is done at compile time, reducing the chance of runtime errors or undefined behavior.

See EmC language documentation for more detail.

  • Up to 8 compiled scripts can live in the TCM at once.
  • All scripts total 1MB of compiled binary size.
  • Scripts can be assigned up to 32KB of stack memory each.
  • Each script runs on it’s own thread.
  • Scripts can interact with each other by reading and writing Runtime channels.

Activation

Dealers with a current EmC Developer License can purchase and activate the EmC Application Build on a device.

Once EmC has been enabled for your device’s serial number, use the Build Manager to activate it. Build Manager - EmC Build Manager - EmC


EmC IDE

The EmC development environment is accessed via the “EmC” tab when a cal file is open or a TCM is online. The environment can be opened without a device or cal file from the EmC menu.

EmC Toolbar EmC Toolbar

Compiling

Press Compile on the toolbar to compile the script (along with any emc files it includes). The compiler result is shown on the RHS.

On a successful compilation, the script to be added to the device script management list by clicking Add to Device.

Errors shown in the compiler output will also he highlighted in red in the editor panel. Warnings shown in the compiler output will also he highlighted in yellow in the editor panel.


Device Script Management

Device Script Manager Device Script Manager

The Script Management view shows the scripts either in the device or staged for upload.

Info

In order to upload changes to the script memory, the script system must be stopped.

  • Stop halts the script system, ready to upload changes.
  • Start starts a halted system and starts all scripts running.
  • Read Reads the script memory from the device.
  • Write sends the current script memory and configuration to the device and starts the system.
  • Import imports a compiled EmC binary file (.emcbin). This can be used to import compiled 3rd party scripts without the source files.
  • Delete removes the selected script file from the list.

Each Script has it’s own status runtime. A successfully running script’s status will show “OK”.

alt text alt text

Copyright © 2026 Emtron Australia Pty Ltd

System Functions

This is a reference for every native function callable from a EmC script running on the TCM including:

  • General-purpose functions under System:: for debugging and time management.
  • TCM-specific functions under Runtime::, Torque::, Device::, CAN:: and Output:: for reading live device state, supplying engine torque, talking to the CAN bus and driving script-controlled outputs.

Quick Reference

Strings & Debugging (System::)

FunctionSignaturePurpose
System::Printvoid Print(string str)Debug-print a string, no newline
System::PrintLinevoid PrintLine(string str)Debug-print a string plus newline
System::PrintIntvoid PrintInt(int i)Debug-print an integer
System::PrintFloatvoid PrintFloat(float f)Debug-print a float
System::PrintFormatvoid PrintFormat(string str, float f)Debug-print a format string with one float
System::PrintBuffervoid PrintBuffer(byte buf[], int capacity)Debug-print a byte buffer’s null-terminated content
System::StrLengthint StrLength(byte buf[], int capacity)Length of a buffer’s content up to its null terminator
System::StrCopyvoid StrCopy(byte dest[], int destCapacity, string src)Copy a string constant into a buffer, truncating to fit
System::StrAppendvoid StrAppend(byte dest[], int destCapacity, string src)Append a string constant onto a buffer’s content, truncating to fit
System::IntToStrvoid IntToStr(byte dest[], int destCapacity, int value)Format an integer as decimal text into a buffer
System::StrEqualsbool StrEquals(byte a[], int capacityA, byte b[], int capacityB)Compare two buffers’ null-terminated contents

Time

FunctionSignaturePurpose
System::NowMsuint NowMs()Device uptime in milliseconds (wraps every ~49.7 days)
System::NowUsuint NowUs()Device uptime in microseconds (wraps every ~71.58 min)

Script Yielding

FunctionSignaturePurpose
System::Yieldvoid Yield(uint t)Pause the script for t milliseconds
System::YieldUntiluint YieldUntil(uint lastTime, uint delay)Fixed-period pause that returns the updated lastTime for the next call

Runtime Channels (Runtime::)

FunctionSignaturePurpose
Runtime::Readint Read(uint id)Read a live channel value as an integer with the decimal point removed (123.4 reads as 1234)
Runtime::ReadRealfloat ReadReal(uint id)Read a live channel value as a float in real units
Runtime::Writebool Write(uint id, int value)Write a writeable channel value as an integer with the decimal point removed
Runtime::WriteRealbool WriteReal(uint id, float value)Write a writeable channel value as a float in real units

Torque Model (Torque::)

FunctionSignaturePurpose
Torque::GetSuppliedfloat GetSupplied()Read Engine Torque (Supplied) in Nm
Torque::SetSuppliedvoid SetSupplied(float tqSupplied)Supply Engine Torque (Supplied) when Torque Model Source is Script
Torque::GetAvailablefloat GetAvailable()Read Engine Torque (Available) in Nm
Torque::SetAvailablevoid SetAvailable(float tqAvailable)Supply Engine Torque (Available) when Torque Model Source is Script
Torque::GetDriverDemandfloat GetDriverDemand()Read Driver Demand Torque in Nm
Torque::SetDriverDemandvoid SetDriverDemand(float tqDriver)Supply Driver Demand Torque when Driver Demand Source is Script

CAN Bus (CAN::)

FunctionSignaturePurpose
CAN::SetupNodebool SetupNode(uint node, bool enable, uint bitrateSelect, bool termination, bool listenOnly)Enable/disable a CAN node and set its bitrate, termination and listen-only mode
CAN::Sendvoid Send(uint node, uint id, byte buffer[], int length)Send a raw frame on CAN 1 or CAN 2
CAN::Readint Read(uint node, uint id, byte buffer[], int length)Poll the latest received frame for a node/id pair
CAN::Subscribevoid Subscribe(uint node, uint idMatch, uint idMask, func(uint id, byte data[], int length) handler)Register a callback for received frames matching an id/mask
CAN::Unsubscribevoid Unsubscribe(uint node, uint idMatch, uint idMask)Cancel a subscription registered with the same node/match/mask
CAN::Pollint Poll()Fire the handlers for any frames received since the last call

Output Control (Output::)

FunctionSignaturePurpose
Output::SetActiveLevelvoid SetActiveLevel(byte outputId, int level)Set a script output’s active polarity (0 = active-low, 1 = active-high)
Output::SetEffectiveResistancevoid SetEffectiveResistance(byte outputId, float ohms)Tell current control the load’s resistance
Output::SetFreqvoid SetFreq(byte outputId, float frequency)Set a script output’s PWM/current-chop frequency in Hz
Output::SetDutyvoid SetDuty(byte outputId, float dutyCycle)Drive a script output in PWM mode at a fixed duty (0-100)
Output::SetCurrentvoid SetCurrent(byte outputId, float milliAmps)Drive a script output in closed-loop current control

Device Information (Device::)

FunctionSignaturePurpose
Device::ReadSerialNumberuint ReadSerialNumber()Read the device’s serial number
Device::ReadVendorKeyuint ReadVendorKey(int keyId)Read one of two vendor keys (keyId 1 or 2), typically used to lock a script to a specific device

Calling Convention

Every native function is called through its namespace.

float rpm = Runtime::ReadReal(RT_ENGSPD);
CAN::Poll();
System::Yield(10);

Calling a native bare (Yield(10);, Poll();) or through the wrong namespace (System::Runtime::Read(1);) fails to compile. Function names are case-sensitive and must match the names in this manual exactly.

A callback handler passed to CAN::Subscribe (a func-typed parameter) is the one exception: it’s a bare script function name, not called through any namespace - see CAN Receive Callbacks.

Runtime ids (the uint id arguments to the Runtime:: functions) are plain numbers assigned by the device’s .mdef. Rather than hard-coding them, #include "mtc.emc" and use the RT_ names it defines - see Include Files.

Every CAN:: function takes a node as its first argument, zero indexed: 0 is CAN 1 and 1 is CAN 2. CAN::Subscribe and CAN::Unsubscribe also accept 2 for both buses; everywhere else 2 is rejected. The include file provides CAN1, CAN2 and CAN_BOTH for these.


Included Device Definition Files

Device header file(s) ship along side the compiler, so a script can refer to channels, buses, bitrates, etc by name instead of by number. Include it at the top of the script:

#include "tm16.emc"

The file name is the device’s name in lower case: tm16.emc for the TM16. The compiler already knows where the shipped headers live, so the plain file name works wherever the script itself is saved. Including the same file more than once (e.g. from two of your own include files) is harmless.

GroupNamesMeaning
ConstantsLOW, HIGH0 and 1, for Output::SetActiveLevel
CAN busesCAN1, CAN2, CAN_BOTHThe node argument of the CAN:: functions (0, 1, 2)
CAN BitratesCAN_BITRATE_125K, CAN_BITRATE_250K, CAN_BITRATE_500K, CAN_BITRATE_1M, CAN_BITRATE_CUSTOMThe bitrateSelect argument of CAN::SetupNode
Runtime ChannelsRT_ENGSPD, RT_GEAR, RT_BATTVOLTS, …The Runtime ID of every channel, for the Runtime:: functions

A runtime channel’s name is RT_ followed by its abbreviation in upper case (the “Engine Speed” channel becomes RT_ENGSPD). Each define carries the channel’s full label as a trailing comment, so the quickest way to find a channel is to open the header and search for the channel name.

You are free to define your own constants too.

#include "tm16.emc"

#define MY_SOLENOID 4

float rpm = Runtime::ReadReal(RT_ENGSPD);
CAN::SetupNode(CAN2, true, CAN_BITRATE_500K, true, false);
Output::SetActiveLevel(MY_SOLENOID, LOW);

The header only provides names - it doesn’t change what any function does. A channel that isn’t writeable still refuses Runtime::Write whether it’s addressed by RT_ name or by number.

Info

#define is used in preference to const uint declarations because unused defines have no effect on the compiled output. If you create 100 variable declarations (const or not), you will add 100 variables to the compiled script binary, that must also take up space on the stack when running.


String/Debug Output

Print, PrintLine, PrintInt, PrintFloat, PrintFormat and PrintBuffer all write to the same destination: a debug message sent to a connected PC over the device’s Ethernet port, rate-limited so a busy loop can’t flood it. None of them do anything unless all of the following are true:

  • The PC is connected to the device and has debug output active, AND
  • The specific script has its “Debug” option enabled in its configuration.

[IMPORTANT] Always disable debug when it’s no longer required.

System::Print("state=");
System::PrintInt(state);
System::PrintLine(" (idle)");
System::PrintFormat("battery: %f V\n", batteryVolts);

str in Print/PrintLine/PrintFormat must be a string - a compile-time string constant, not a byte[] buffer. Use PrintBuffer for buffer content instead (see String Buffers).

System::Print

void System::Print(string str)

Prints a string literal with no newline.

System::Print("hello");

System::PrintLine

void System::PrintLine(string str)

Prints a string literal followed by a newline. Use it for the last piece of a line built up from several Print/PrintInt calls. PrintLine("") prints just the newline.

System::PrintLine("hello");
System::PrintLine("");

System::PrintInt

void System::PrintInt(int i)

Prints i as a single integer value.

int i = 100;
System::PrintInt(i); // 100

System::PrintFloat

void System::PrintFloat(float f) 

Prints f as a decimal float (%f format).

float f = 123.4;
System::PrintFloat(f); // 123.4

System::PrintFormat

void System::PrintFormat(string str, float f)

Prints str as a printf-style format string with f as its one and only argument, so only a single %f-style specifier makes sense. Anything else in the string that consumes an argument is undefined, the same way a mismatched printf format is in C.

System::PrintFormat("The value is %f", 123.4); // The value is 123.4

System::PrintBuffer

void System::PrintBuffer(byte buf[], int capacity)

Prints buf’s contents up to its null terminator, or up to capacity bytes, whichever comes first. See StringBuffers.


String Buffers

string constants are immutable and can’t be built or modified at runtime - for text a script assembles itself (formatting a value, building a label from CAN data, etc.) it needs a byte[] buffer instead, plus the five string natives that operate on it. All five are C snprintf-style: every one takes an explicit capacity and truncates rather than overflowing.

byte msg[32];
System::StrCopy(msg, 32, "state=");

byte num[16];
System::IntToStr(num, 16, 42);

// StrAppend's source is a string constant only - buffer-to-buffer append isn't supported
System::StrAppend(msg, 32, "42");

System::PrintBuffer(msg, 32);   // msg is byte[] - use PrintBuffer, not Print/PrintLine

Two easy mistakes to make:

  • StrCopy/StrAppend’s source (src) is always a string constant, never another byte[] buffer. There’s no buffer-to-buffer append.
  • A byte[] buffer can never be passed where a string parameter is expected. A freshly declared buffer is zero-filled, so StrLength on one that’s never been written returns 0.

System::StrLength

int System::StrLength(byte buf[], int capacity)

Scans up to capacity bytes for a \0 and returns how many bytes precede it: 0 if buf starts with \0, capacity if no terminator is found.

System::StrCopy

void System::StrCopy(byte dest[], int destCapacity, string src)

Copies src into dest, truncating and null-terminating to fit within destCapacity.

System::StrAppend

void System::StrAppend(byte dest[], int destCapacity, string src)

Appends src onto dest’s existing null-terminated content, truncating and null-terminating to fit within destCapacity.

System::IntToStr

void System::IntToStr(byte dest[], int destCapacity, int value)

Formats value as decimal text into dest, truncating and null-terminating to fit within destCapacity.

System::StrEquals

bool System::StrEquals(byte a[], int capacityA, byte b[], int capacityB)

Returns true if a and b’s null-terminated contents are identical, each scanned up to its own capacity.


Timing

uint nowMs = System::NowMs();
uint nowUs = System::NowUs();

NowMs/NowUs read the device’s uptime from the same free-running hardware timer, but with different wrap periods.

Tip

Write comparisons the wraparound-safe way (unsigned subtraction) rather than assuming now only ever increases: (uint)(now - start) >= threshold)

System::NowMs

uint System::NowMs()

Uptime in milliseconds. Wraps every ~49.7 days (2^32 milliseconds), which makes it the safer default for a script tracking longer-running state (e.g. time since ignition-on).

System::NowUs

uint NowUs()

Uptime in microseconds. Wraps every ~71.58 minutes (2^32 microseconds). Because the two clocks wrap at different points, NowMs() and NowUs() / 1000 only agree with each other for the first ~71.58 minutes after boot - once NowUs has wrapped, NowMs keeps counting while NowUs has reset near zero, so don’t rely on them staying numerically related.

System::Yield

void System::Yield(uint t)

Pauses the calling script for t milliseconds, letting other threads (and the rest of the script scheduler) run. This is the standard way to pace a script’s own loop instead of spinning:

while (1) {
    // ... do work ...
    System::Yield(20);   // ~50 Hz loop
}

System::YieldUntil

uint System::YieldUntil(uint lastTime, uint delay)

A fixed-period yield: sleeps until lastTime + delay, then returns the new lastTime (lastTime + delay) for the caller to feed back in on the next iteration. Unlike Yield, which always sleeps for a fixed duration from now, this keeps a loop’s period accurate even if the loop body’s own work takes a variable amount of time each iteration:

uint lastWake = System::NowMs();
for (;;) {
    // ... do work ...
    lastWake = System::YieldUntil(lastWake, 20);   // 20 ms period, not 20 ms + work time
}

The function takes and returns lastTime explicitly rather than updating it in place (EmC has no reference parameters) - always reassign the return value back onto the variable you pass in, or the period will drift.


Runtime Channels

Runtime channels are the live values used throughout the TCM (engine RPM, gear, clutch pressures, launch targets and so on). Each is identified by its Runtime ID, available by name as RT_... from the device’s include file.

Tip

See the Runtimes reference document for a list of all runtime channels, their #define names, factors, decimals etc.

Channels come in two flavours from a script’s point of view:

  • Read/Write work in the channel’s integer representation: the real value with its decimal point removed, so a channel displayed as 123.4 (one decimal place) reads as 1234, and writing 1234 sets it to 123.4. A channel with no decimal places reads and writes as-is.
  • ReadReal/WriteReal work in floating point units (123.4) and are the simpler choice unless you specifically want integer maths. The TCM is equipped with an FPU so floating point operations are trivial.

Reads of an ID that doesn’t resolve to anything return 0. Writes only succeed (return true) if the target channel is writeable. Lots of channels are computed outputs, not inputs, and will refuse the write.

Important

Channels driven by Input Functions such as Engine Speed must have their input assignment set to Script or they cannot be written to.

#include "tm16.emc"

void main() {
    // Engine Speed has 1DP precision
    int rpmInt = Runtime::Read(RT_ENGSPD) / 10; // 6500.0 rpm reads as 65000
    float rpm  = Runtime::ReadReal(RT_ENGSPD);  // 6500.0

    Runtime::Write(RT_USERCH1, rpmInt + 100);
    Runtime::WriteReal(RT_USERCH2, rpm + 100.0);
}

main();

Runtime::Read

int Runtime::Read(uint id)

Reads the channel as an integer with the decimal point removed. Returns 0 if id is unknown.

int anv1 = Runtime::Read(RT_ANV1); // 3DP: 1.234V = 1234

Runtime::ReadReal

float Runtime::ReadReal(uint id)

Reads the channel as a float in its real units. Returns 0 if id is unknown.

float anv1 = Runtime::ReadReal(RT_ANV1); // 3DP: 1.234V = 1.234

Runtime::Write

bool Runtime::Write(uint id, int value)

Writes value, an integer with the decimal point removed, to the channel. Returns true if the channel accepted the write, false if id is unknown or the channel is not writeable.

// Vehicle speed is 1DP so 1234 = 123.4 km/h
if (!Runtime::Write(RT_VEHICLESPEED, 1234)) {
    System::Print("Vehicle Speed not assigned to Script");
}

Runtime::WriteReal

bool Runtime::WriteReal(uint id, float value)

Writes value, a float in the channel’s real units. Returns true if the channel accepted the write, false if id is unknown or the channel is not writeable.

if (!Runtime::WriteReal(RT_VEHICLESPEED, 123.4)) {
    System::Print("Vehicle Speed not assigned to Script");
}

Torque Model

A script can supply engine torque to the TCM, e.g. when the ECU sends torque in a format the CAN setup can’t decode directly, or when torque needs to be calculated from other channels. See TCM Torque Model for how the TCM uses these values.

  • Torque::SetSupplied and Torque::SetAvailable only take effect while Torque Model Source is set to Script.
  • Torque::SetDriverDemand only takes effect while Driver Demand Source is set to Script.
  • In any other source mode the value is ignored.

Values are in Nm. The torque correction and offset tables (when enabled) are applied on top of the value the script supplies: Engine Torque Correction and Engine Torque Offset for Supplied and Available, Driver Demand Torque Correction and Driver Demand Torque Offset for driver demand. The Get functions return the final value after correction and offset. A supplied value is held until the script sets a new one, so a script that stops or faults leaves the TCM using its last torque values.

Important

Always set both Supplied and Available torque. If the ECU only provides one torque value, set it to both. If Engine Torque (Available) is left at 0, the TCM will think the engine has no torque available and request a full cut on every upshift.

Note

The torque channels can’t be written with Runtime::Write or Runtime::WriteReal. Use the Torque:: functions instead.

#include "tm16.emc"

// ECU sends engine torque in bytes 0-1 of 0x400: signed, little endian, 0.1 Nm
void onEngineTorque(uint id, byte data[], int length) {
    if (length >= 2) {
        int raw = (data[1] << 8) | data[0];
        if (raw > 32767) {
            raw = raw - 65536;
        }
        float torque = raw * 0.1;
        Torque::SetSupplied(torque);
        Torque::SetAvailable(torque);
    }
}

CAN::Subscribe(CAN1, 0x400, 0x7FF, onEngineTorque);

while (true) {
    CAN::Poll();
    System::Yield(10);
}

Torque::GetSupplied

float Torque::GetSupplied()

Returns Engine Torque (Supplied) in Nm: the torque the engine is producing now, after any ignition retard or cuts. Works in every Torque Model Source mode.

Torque::SetSupplied

void Torque::SetSupplied(float tqSupplied)

Supplies Engine Torque (Supplied) in Nm. Ignored unless Torque Model Source is Script.

Torque::GetAvailable

float Torque::GetAvailable()

Returns Engine Torque (Available) in Nm: the torque the engine would be producing with no retard or cuts. Works in every Torque Model Source mode.

Torque::SetAvailable

void Torque::SetAvailable(float tqAvailable)

Supplies Engine Torque (Available) in Nm. Ignored unless Torque Model Source is Script.

Torque::GetDriverDemand

float Torque::GetDriverDemand()

Returns Driver Demand Torque in Nm. Works in every Driver Demand Source mode.

Torque::SetDriverDemand

void Torque::SetDriverDemand(float tqDriver)

Supplies Driver Demand Torque in Nm. Ignored unless Driver Demand Source is Script.


CAN Bus

Every CAN:: function takes a node as its first argument (0 indexed):

  • 0 = CAN 1,
  • 1 = CAN 2
  • Subscribe/Unsubscribe also accept 2 = both buses.

Node Setup

CAN::SetupNode

bool CAN::SetupNode(uint node, bool enable, uint bitrateSelect, bool termination, bool listenOnly)

Configures and restarts a CAN node from the script, replacing the node’s settings from the calibration. Returns true on success, false if node is not 0 or 1.

  • enable - false turns the node off entirely (no transmit, no receive, termination off). The other arguments are still stored but have no effect until the node is enabled again.
  • bitrateSelect
    • 0 = Custom bit timing
    • 1 = 125 kbit/s
    • 2 = 250 kbit/s
    • 3 = 500 kbit/s
    • 4 = 1 Mbit/s
    • *Any other value leaves the bit timing as it was.
  • termination - switches the node’s on-board 120R termination resistor on or off.
  • listenOnly - true puts the node in silent mode: it receives frames but never transmits or acknowledges, and the node’s configured CAN transmit channels are stopped. Use it to read from a bus the TCM must not disturb.
// CAN 2: enabled, 500 kbit/s, termination on, normal (not listen-only) mode
bool canOk = CAN::SetupNode(CAN2, true, CAN_BITRATE_500K, true, false);

Calling it restarts the node, so any frames in flight on that bus are lost. Call it once at script start, NOT from inside the loop. The new settings are not saved to flash by the call itself, so a script that depends on them should apply them every time it starts.

Raw CAN I/O (polling)

Send/Read are the simple, polling pair. Both target one bus, so node must be 0 or 1. 2 is rejected (Send does nothing, Read returns -1).

#include "mtc.emc"

byte txBuf[8];
byte rxBuf[8];

void doCan() {
    txBuf[0] = 1;
    CAN::Send(CAN1, 0x100, txBuf, 8);

    int n = CAN::Read(CAN2, 0x200, rxBuf, 8);
    if (n > 0) {
        System::PrintInt(rxBuf[0]);
    }
}

doCan();

CAN::Send

void CAN::Send(uint node, uint id, byte buffer[], int length)

Queues a CAN message with identifier id and the first length bytes of buffer (at most 8) for transmission on node. The call returns as soon as the frame is queued; it does nothing if node is not 0 or 1 or length is 0.

byte txBuf[8] = { 0, 1, 2, 3, 4, 5, 6, 7 };
CAN::Send(CAN1, 0x100, txBuf, 8);

CAN::Read

int CAN::Read(uint node, uint id, byte buffer[], int length)

Copies the most recently received frame with identifier id on node into buffer, up to length bytes, and returns the number of bytes copied. Returns -1 if no frame with that id has been seen on that bus yet.

byte rxBuf[8];
int read = CAN::Read(CAN2, 0x200, rxBuf, 8);
if (read > 2) {
    int engineSpeed = (rxBuf[1] << 8) | rxBuf[0]; // 0DP
    Runtime::Write(RT_ENGSPD, engineSpeed * 10); // 1DP
}

The device keeps the latest frame for each node/id pair a script has asked about, and each Read call copies out of that store, so reading a frame doesn’t consume it: the same frame is returned again until a newer one arrives. The system holds a fixed number of node/id pairs (16, shared across all running scripts); once it’s full, Read on a pair it isn’t already tracking returns -1. This is fine for a handful of ids a script cares about. If you need every frame on a range of ids, or lower latency, use the callback API below instead.

CAN Receive Callbacks

CAN::Subscribe registers a script function to be called for every received frame whose id matches an id/mask pair, on CAN 1, CAN 2 or both. One subscription can cover many ids (see Id/Mask Matching). Handlers don’t run the instant a frame arrives: matching frames are queued for the script, and CAN::Poll() - called from the script’s own loop - runs each handler in turn on the script’s own thread. This means a callback can never delay real CAN traffic, but also means it only ever fires when the script calls CAN::Poll().

void onEngineData(uint id, byte data[], int length) {
    if (length >= 2) {
        Runtime::Write(RT_TPS, (data[0] << 8) | data[1]);
    }
}

CAN::Subscribe(CAN1, 0x0CF00400, 0x1FFFFF00, onEngineData);

while (true) {
    CAN::Poll();
    System::Yield(10);
}

CAN::Subscribe

void CAN::Subscribe(uint node, uint idMatch, uint idMask, func(uint id, byte data[], int length) handler)

Subscribes a handler to a range of CAN messages:

  • node - 0 = CAN 1, 1 = CAN 2, 2 = both (CAN1, CAN2, CAN_BOTH from the include file).
  • idMatch/idMask - a frame matches when (frame.id & idMask) == (idMatch & idMask). A mask of 0 matches every id on the node.
  • handler - a bare script function name (no parentheses, no namespace), which must be declared with exactly this signature:
void handler(uint id, byte data[], int length) { ... }

id is the received frame’s identifier, data holds its payload and length is how many of those bytes are valid (0-8). A handler with the wrong parameter count or types is rejected at compile time.

data is only valid until the handler returns. Index it, or pass it straight on to another function that takes a byte array (such as CAN::Send), but copy it into a script-declared array if you need the bytes afterwards. Each frame delivered to a handler also uses a little extra script stack on top of the handler’s own locals, so a script running very close to its stack limit can halt with a stack-overflow fault on delivery rather than silently missing the callback.

Calling Subscribe again with the same node/idMatch/idMask replaces the existing subscription’s handler rather than adding a second one.

CAN::Unsubscribe

void CAN::Unsubscribe(uint node, uint idMatch, uint idMask)

Removes a subscription. Pass the exact same three values it was registered with.

CAN::Poll

int CAN::Poll()

Runs the handlers for every matching frame received since the last call, oldest first, and returns how many handlers were fired. A subscribing script must call it once per loop iteration, before yielding, or queued frames simply sit until the next call.

The queue holds a fixed number of frames per script. If a script falls behind a busy bus (e.g. it’s blocked doing other work), the oldest undelivered frames are dropped rather than the queue growing. Keep the loop tight and call Poll() regularly if you’re subscribing to high frequency message(s) or a wide mask. Calling Poll() from inside a handler (directly or indirectly) does nothing and returns 0.

Id/Mask Matching

The match is a bitwise AND against both sides, not a range check:

(frame.id & idMask) == (idMatch & idMask)
  • Bits set in idMask are the ones that must match.
  • Bits clear in idMask are don’t-care and match any value.

A few common patterns:

CAN::Subscribe(CAN1, 0x100, 0xFFFFFFFF, handler1);   // exact id 0x100 only
CAN::Subscribe(CAN1, 0x100, 0x700,      handler2);   // every id from 0x100-0x1FF
CAN::Subscribe(CAN1, 0,     0,          handler3);   // every id on CAN 1 (not recommended)

The frame’s actual ID is always passed to the handler as its first argument, so a single wide-mask subscription can still tell which specific id triggered each call:

void handler2(uint id, byte data[], int length) {
    if (id == 0x101) { ... }
    else if (id == 0x150) { ... }
}

Output Control

Script outputs are a dedicated pool of up to 24 output channels that can be used to drive solenoids etc.
outputId ranges from 1-24, not 0-based. ID 0 or anything above 24 resolves to nothing and does nothing.
Each output has to be assigned to a physical pin in the device’s configuration before a script can usefully drive it. An unassigned output accepts every call here without error but has no effect on hardware.

Info

The script has no way of knowing which physical output the user assigned to a given Script Output.
Eg: Script Output 1, with ID 1, maybe be assigned to drive Solenoid Output 7 (or any other output pin by the end user).

#include "tm16.emc"

#define CENTRE_DIFF_SOLENOID 6

void SetupSolenoids() {
    Output::SetActiveLevel(CENTRE_DIFF_SOLENOID, LOW);        // Active low
    Output::SetFreq(CENTRE_DIFF_SOLENOID, 2000.0);            // 2000 Hz
}

SetupSolenoids();

float TorqueSplitCurrent() { ... }

// Main Script Loop
while(1) {
    float diffMa = TorqueSplitCurrent();
    Output::SetCurrent(CENTRE_DIFF_SOLENOID, diffMa); // Closed-loop current control, mA
    System::Yield(5); // 200 Hz loop
}
Info

SetDuty and SetCurrent set the output’s drive mode. Calling one switches the output into that mode and zeroes the other’s setpoint, so the two are mutually exclusive per output.
Whichever was called most recently wins.
SetFreq applies to both modes.

Output::SetActiveLevel

void Output::SetActiveLevel(byte outputId, int level)

Sets whether the output is driven active-high (1) or active-low (0). LOW and HIGH definitions are in the device include file.

Output::SetActiveLevel(MY_SOLENOID, LOW);

Output::SetEffectiveResistance

void Output::SetEffectiveResistance(byte outputId, float ohms)

Auxiliary Outputs Only
Tells the current control system the resistance of the connected load. Used to help translate the current target into a drive duty. Only relevant in current-control mode when the output is assigned to an auxiliary output rather than a solenoid output.

void Output::SetEffectiveResistance(MY_SOLENOID, 9.5);
Info

If the output’s effective resistance is set to 0 (Recommended and default), the system will work it out on it’s own. The only drawback is that the first application of the solenoid may take a few extra milliseconds to settle on the current target.

Tip

Where possible, preference using the solenoid outputs, not auxiliary outputs to drive current-controlled loads.

Output::SetFreq

void Output::SetFreq(byte outputId, float frequency)

Sets the output’s switching frequency in Hz:

  • PWM rate in PWM (duty) mode.
  • Base frequency in current-control mode.

PWM Range: 0.5 - 20000 Hz
Current Control Range: See below

Output::SetFreq(MY_SOLENOID, 1000.0); // 1000 Hz
Current Control Frequency

When an output is in current control mode, it’s frequency must be one of the following options:

  • 110 Hz
  • 200 Hz
  • 300 Hz
  • 400 Hz
  • 500 Hz
  • 600 Hz
  • 700 Hz
  • 800 Hz
  • 900 Hz
  • 1000 Hz
  • 2000 Hz
  • 3000 Hz
  • 4000 Hz
PWM Mode Frequency

In PWM mode, any frequency up to 20000 Hz (20 KHz) is fine.
Once the duty cycle has been set, putting the output into fixed PWM mode, the frequency can be changed at will, even without updating the duty cycle again. This makes it possible to do variable frequency outputs such as a tacho output.

Output::SetDuty

void Output::SetDuty(byte outputId, float dutyCycle)

Puts the output in fixed-duty PWM mode at dutyCycle percent (0-100) and clears any current target.

Output::SetDuty(MY_SOLENOID, 12.3); // 12.3%

Output::SetCurrent

void Output::SetCurrent(byte outputId, float milliAmps)

Puts the output in closed-loop current-control mode with a target of milliAmps and clears any duty setpoint.

Output::SetCurrent(MY_SOLENOID, 900.0); // 900mA or 0.9A

Output Current Range

  • Solenoid Outputs in single channel mode can command up to 1.5A.
  • Solenoid Outputs in paired channel mode can command up to 2.7A.
  • Auxiliary Outputs can command up to 5A. (high side or low side).
Info

Solenoid Outputs are the preferred output for current controlled loads.


Device Identity

uint serial = Device::ReadSerialNumber();
uint key1   = Device::ReadVendorKey(1);
uint key2   = Device::ReadVendorKey(2);   // any keyId other than 1 or 2 returns 0

A common use is locking a script to a specific device or vendor: compare the serial number and/or a vendor key that only the vendor knows and gives to the user, against an expected value at script start and refuse to run if it doesn’t match.

bool AuthorizeScript(uint serial, uint key) {
    // Some kind of non trivial cypher...
    const uint expected = ((serial & 0xAA55AA55) << 16) + (serial ^ 0xDEADBEEF);
    return key == expected;
}

const uint serial = Device::ReadSerialNumber();
const uint key1   = Device::ReadVendorKey(1);

if (!AuthorizeScript(serial, key1)) {
    // Invalid key
    System::Print("Key Invalid. Script will not run.");
    return; // Returning from the scripts top level will terminate execution.
}

Device::ReadSerialNumber

uint Device::ReadSerialNumber()

Returns the device’s serial number.

Device::ReadVendorKey

uint Device::ReadVendorKey(int keyId)

Returns one of two general-purpose 32-bit calibration values (“Script Vendor Hardware Key 1/2”), set like any other calibration value. keyId is 1 or 2; any other value returns 0.

Info

The Script Vendor Hardware Key # variables are kept at the device level. This means that uploading a cal file with different values in the keys, will not change the values in the device. They will be ignored from the cal file data. This stops the sharing of cal files that run the same scripts from breaking script execution when uploaded.