IC10 Language Reference

Introduction

This is a small, statically typed language with Rust-like syntax that compiles to IC10, the assembly language of Stationeers' programmable chips. It gives you functions, structs, typed devices and checked logic values, and produces compact code: an IC10 chip only accepts 128 lines.

Code samples marked compiled show the IC10 the compiler produces for them, generated live by the same compiler the playground uses.

A first program

Every program has a fn main(). Scripts usually loop forever and call yield() once per tick (0.5 s).

// Turn a heater on below 19 °C and off above 21 °C.
const room: GasSensor = d0;      // typed device ports
const heater: WallHeater = d1;

fn main() {
    let mut band = hysteresis(celsius_to_kelvin(19), celsius_to_kelvin(21));
    loop {
        heater.On = switch_below(&mut band, room.Temperature);
        yield();
    }
}

GasSensor, WallHeater, hysteresis and friends come from the libraries, which are loaded by default.

Program structure

A source file (.icr) is a list of items, in any order:

ItemExample
functionfn area(w: num, h: num) -> num { w * h }
constantconst TARGET: num = 293.15;
structstruct Point { x: num, mut y: num }
enumenum VentMode { Outward, Inward }
device typedevice Lamp("StructureLamp") { mut On: bool }
importuse devices::sensors;

Comments are // line and /* block */. Comments directly above a declaration, or at the end of a field's line, show up as documentation in the editor.

Types

TypeValues
num64-bit floating point, the only numeric type IC10 has. Literals: 42, 1.5, 2e3, 0xff, 0b101, 1_000.
booltrue / false (0 / 1 at runtime).
device typesGasSensor, LedDisplay, … Checked access to logic values. See Devices.
DeviceAn untyped device: any logic value, read and written as num.
structsYour own types, see Structs.
enumsNamed numbers like modes and colors, see Enums.
()"Nothing", the result of functions without a return type.

There are no implicit conversions: if 1 { } and 1 + true are errors. Convert explicitly:

let on: bool = temperature > 300;
let as_number = on as num;           // bool -> num (0 or 1)
let is_set = value != 0;             // num -> bool: compare
let sensor = d0 as GasSensor;        // untyped device -> typed device

Variables and mutability

let a = 1;              // immutable
let mut b = 2;          // mutable
b += a;
let c: num = b * 2;     // with a type annotation

Variables are immutable unless declared let mut. Their type is inferred from the initializer; an annotation is optional. Compound assignment works for all arithmetic and bitwise operators: += -= *= /= %= &= |= ^= <<= >>=.

Structs

struct Thermostat {
    target: num,          // readonly: fixed once constructed
    mut heating: bool,    // mutable
}

let mut t = Thermostat { target: 293.15, heating: false };
t.heating = true;         // ok: `t` is mutable and `heating` is `mut`
t.target = 300;           // error: field `target` is readonly
t = Thermostat { target: 300, heating: false };  // ok: replaces the whole value

let target = 293.15;
let u = Thermostat { target, heating: false };   // shorthand for `target: target`

Fields are readonly by default. Assigning a.b.c = v needs a mutable variable a and every field on the path marked mut. Structs are values: assigning or passing one copies it. They can be nested, but not recursive.

Readonly fields also help the compiler: a readonly field of a constant struct is folded into the code instead of taking up a register.

Enums

enum VentMode {
    Outward,        // 0
    Inward,         // 1
}

enum Level {
    Low = 10,       // explicit value
    Mid,            // 11: the previous value + 1
    Off = -1,
}

vent.Mode = VentMode::Inward;              // `ActiveVent.Mode` has type `VentMode`
if vent.Mode == VentMode::Outward { }
let n = VentMode::Inward as num;           // 1
let m = n as VentMode;                     // unchecked: any number
fn describe(level: Level) -> num { level as num * 2 }

Enums are C-style: named numbers. At runtime a variant is just its value, so they cost nothing, but each enum is its own type. vent.Mode = 1 or vent.Mode = Color::Red are errors, and == / != only compare variants of the same enum. Values count up from 0, or from the previous explicit value.

Logic values can have enum types (mut Mode: VentMode in a device type), and the libraries use them for modes and colors: DisplayMode, Color, VentMode, AcMode, SolarAngleMode, SmeltResult. The editor suggests the right variants after vent.Mode = .

Functions

fn area(w: num, h: num) -> num {
    w * h                 // the last expression is the result
}

fn clamp_to_zero(x: num) -> num {
    if x < 0 { return 0; }
    x
}

Parameters come in three kinds:

ParameterMeaning
x: TBy value, immutable inside the function.
mut x: TBy value; the function may change its own copy.
x: &mut TChanges are written back to the caller's variable. Pass &mut place.
struct Counter { step: num, mut value: num }

fn tick(c: &mut Counter) {
    c.value += c.step;
    if c.value > 10 { reset(c); }   // an `&mut` parameter is passed on as is
}

fn reset(c: &mut Counter) {
    c.value = 0;
}

fn main() {
    let mut counter = Counter { step: 4, value: 0 };
    tick(&mut counter);
}

&mut parameters work by copying in and out: the function receives the values and the caller receives the mut fields back. They can only change mut fields, can't be stored, and two &mut arguments of one call may not overlap.

There are no methods or impl blocks: write functions that take structs. Recursion is not supported (IC10 has no efficient call stack).

Expressions and operators

From lowest to highest precedence:

OperatorsOperandsNotes
= += …place, valueassignment, right-associative
||boolshort-circuit: the right side runs only if needed
&&boolshort-circuit
== != < <= > >=num (== != also bool)result is bool
|num or boolbitwise or; on bools: logical or without short-circuit
^num or boolbitwise xor
&num or boolbitwise and
<< >>numshifts (>> is arithmetic)
+ -num
* / %num% is IC10 mod: the result takes the divisor's sign (-1 % 3 == 2)
ascasts: bool as num, device retyping
-x !xnum / bool! is logical not on bool, bitwise not on num
f(x) a.bcalls, field and logic value access

Bitwise operators work on the value truncated to a 64-bit integer, like IC10. Blocks and if are expressions:

let mode = if temperature > 300 { 2 } else { 1 };
let doubled = { let x = read(); x * 2 };

Control flow

if pressure > MAX {
    vent.On = true;
} else if pressure < MIN {
    vent.On = false;
}

while level < 10 { level += 1; }

for i in 0..5 {          // 0, 1, 2, 3, 4
    if i == 2 { continue; }
    total += i;
}

for dev in [d0, d1, d2] { // once per element (unrolled)
    dev.On = true;
}

loop {                   // forever, until `break` or `return`
    if done() { break; }
    yield();
}

for iterates over a half-open range start..end of numbers, or over an array (see Constants). A loop without break never ends, which is the usual shape of a script's main.

Devices

d0 … d5 are the housing's six device ports (set with a screwdriver) and db is the device the chip is in. Reading and writing logic values uses field syntax:

const sensor: GasSensor = d0;
const display: LedDisplay = d1;

fn main() {
    display.Mode = DisplayMode::Kelvin;
    loop {
        display.Setting = sensor.Temperature;   // `l` then `s`
        display.Color = if sensor.Combustion { Color::Red } else { Color::Green };
        yield();
    }
}

Giving a port a device type (in a const at the top of the file, a let, a parameter or a struct field) makes the compiler check every access: reading a value the device doesn't have, writing a read-only value, or writing 1 to a bool value are errors. Plain d0 or the Device type accept any logic value as num.

Declaring device types

// The prefab name is optional; it enables batch instructions.
device Lamp("StructureLamp") {
    mut On: bool,          // writable
    Power: bool,           // read-only
    mut Setting: num,
}

Logic values are num, bool or an enum; mut marks the writable ones. The bundled device types live in lib/devices/ and can be edited without rebuilding the compiler, see the library reference.

Slots

Item slots belong to a device: a furnace's import and export slots, a filtration unit's filters, a gas tank storage's canister. What a slot reports depends on what it holds, so slot types are declared like device types, and devices list their slots by number, name and type:

slot CanisterSlot {
    Occupied: bool,
    Pressure: num,         // kPa, of the canister's atmosphere
}

device GasTankStorage("StructureGasTankStorage") {
    slot 0 CanisterSlot: CanisterSlot,
    Pressure: num,
}
const tank: GasTankStorage = d0;
const furnace: AdvancedFurnace = d1;

fn main() {
    if tank.CanisterSlot.Occupied && tank.CanisterSlot.Pressure > 5000 {
        furnace.Activate = 1;
    }
    let ore = furnace.slot[0].Quantity;                    // by number
    let life = Filtration::FilterLeftSlot.Quantity.min;    // all filtration units
    db.Setting = ore + life;
}
  • dev.Slot.X reads with ls and dev.Slot.X = v writes with ss. The value is checked against the slot type; mut marks writable ones.
  • dev.slot[n] picks a slot by number (also computed at runtime) and accepts any slot logic value.
  • Batch reads like Type::Slot.X.max use lbs / lbns and need a mode.

Networks and memory

Every cable network has 8 channels, shared by all devices on it, and a list of its devices. Device types name their cable connections with net and their memory with stack:

device ICHousing("StructureCircuitHousing") {
    net 0 DataNetwork: CableNetwork,
    net 1 PowerNetwork: CableNetwork,
    mut stack 512,
}
const housing: ICHousing = db;

fn main() {
    let mut sum = 0;
    for i in 0..8 {
        sum += housing.PowerNetwork.channel[i];      // l r1 db:1 r1
    }
    housing.PowerNetwork.channel[0] = sum;           // s db:1 Channel0 r2
    for i in 0..housing.DataNetwork.device.count {
        let dev = housing.DataNetwork.device[i];
        db.stack[i] = dev.PrefabHash;                // put db r1 r2
    }
}
  • Channels read NaN until written and reset when the network changes or the world loads: use them to pass values between chips.
  • device[i] is an untyped device (a ReferenceId); device.count reads the network's StackSize.
  • stack[i] uses get / put; only mut stack can be written. Constant indexes are range-checked.

Logic types as values

Logic and SlotLogic are enums of the game's logic type numbers. A device is like an array of every logic type, so dev.logic[t] indexes it with a value that can be stored, passed to a function or computed at runtime. A constant index is checked like the name.

const sensor: GasSensor = d0;

fn copy(from: Device, to: Device, t: Logic) {
    to.logic[t] = from.logic[t];                  // l r1 d0 r0 ; s d1 r0 r1
}

fn main() {
    let t = if db.On > 0 { Logic::Temperature } else { Logic::Pressure };
    copy(d0, d1, t);
    db.Setting = GasSensor::logic[t].max;         // lb … r0 Maximum
    db.Ratio = sensor.logic[Logic::Temperature];  // l r0 d0 Temperature
}

Reagents

Machines hold materials as reagents (Iron, Copper, Flour, Milk, …). Device types declare reagents: ReagentMixture (furnaces, centrifuges, …) or reagents: FabricatorReagents (printers), which adds required, recipe and to_item:

const furnace: AdvancedFurnace = d0;
const printer: Autolathe = d1;

fn main() {
    let iron = furnace.reagents.contents(Reagent::Iron);    // lr r? d0 Contents HASH("Iron")
    let all = furnace.reagents.total;                       // lr r? d0 TotalContents 0
    let need = printer.reagents.required(Reagent::Copper);  // lr … Required HASH("Copper")
    let fuel = printer.reagents.to_item(Reagent::Hydrocarbon);  // a constant: Item::SolidFuel
    db.Setting = iron / all + need + fuel as num;
}
  • to_item gives the item a fabricator takes for a reagent, or 0 (Milk, Flour, …). A constant reagent needs no instruction; a runtime one uses rmap, which needs a pin.

Batch access

Batch instructions reach every device of a prefab on the network, without a port. Use a device type (with a prefab name) and :: for all of them, or ::named("...") for the devices labelled with that name. Type::… always means several devices; a single device comes from a port, id(x) or .first:

const west = SolarPanel::named("Panel West");  // a group; a constant or a variable

fn main() {
    SolarPanel::Horizontal = 90;          // every panel
    west.Vertical = 45;                   // the panels named "Panel West"
    let t = GasSensor::Temperature.avg;   // reads combine the devices
    let p = GasSensor::named("Tank").Pressure.max;
    let on = WallHeater::On.sum;          // how many heaters are on
    db.Setting = t + p + on + SolarPanel::count;
}
  • Writes go to every matching device. Reads need a mode: .avg, .sum, .min or .max. .avg and .sum give a num, .min and .max keep the logic value's type.
  • count is how many devices there are (the sum of PrefabHash divided by the hash).
  • The name can be computed at runtime too: SolarPanel::named(name_hash).
  • Logic values are checked like for a single device. With no matching devices the game returns NaN for .avg, 0 for .sum and .min, and ninf for .max.

Devices by ReferenceId

Every device has a unique ReferenceId, and l/s accept it in place of a port. id(x) makes a device from one; .first finds one by name:

const furnace: AdvancedFurnace = id(0x1A2B);   // copied from the tablet

fn main() {
    let tank = GasSensor::named("Tank").first;   // lbn … ReferenceId Maximum
    furnace.SettingInput = tank.Pressure;
}

A hardcoded id changes when the device is rebuilt; looking it up by name keeps working. When a device is passed to a function that isn't inlined, or kept in a variable, the register holds its ReferenceId too (l r1 d0 ReferenceId), so this works for db as well.

The lower-level batch_load(…) / batch_store(…) built-ins are still available for hashes computed at runtime.

Constants

const TARGET: num = 20 + ZERO_CELSIUS;          // evaluated at compile time
const PANEL: num = hash("StructureSolarPanel"); // CRC-32, like IC10's HASH("...")
const GREETING: num = pack_ascii("HELLO");      // LED display string mode
const sensor: GasSensor = d0;                   // a device port
const west = SolarPanel::named("West");         // the type is optional
const TEMPS: [num; 3] = [273.15, 300, 320];      // an array
const INGOTS = [Item::InvarIngot, Item::SteelIngot];

Constants are evaluated when compiling and inlined where they're used, so they cost no lines or registers. They may use other constants, arithmetic, comparisons, hash and pack_ascii. The type can be left out when the value's type is clear. Hashes (from hash("...") or a device type's prefab) are written as HASH("...") in the output, so the script stays readable.

Arrays exist only at compile time. Use them in for x in TEMPS { .. }, which repeats the body once per element with the element as a constant, as TEMPS[i] with a constant i, or as TEMPS.len. They can't be stored in a let, passed to functions or indexed at runtime.

Built-in functions

Logic type arguments are written as names (Temperature); batch modes are Average, Sum, Minimum or Maximum.

FunctionDescription

Libraries and the prelude

use a::b; loads a/b.icr. It looks next to the importing file first, then in -L <dir> directories, then $IC10_PATH, then the bundled lib/ directory. Each file is loaded once.

lib/prelude.icr is loaded before every program, so everything it uses (all bundled device types and the standard library) is available without an import. Only code a program actually calls ends up in the IC10, and small functions are inlined.

Your own definitions replace library ones with the same name. If you define fn clamp(x: num) -> num, your code uses yours, while library code keeps using the library version.

Compiler output

  • Line numbers or labels. Jumps use line numbers by default, since each label costs one of the 128 lines. --labels (playground: Labels) emits readable labels instead.
  • Aliases. --aliases (playground: Aliases) emits alias sensor d0 for each device constant the code uses and uses the names in the instructions, like hand-written scripts. Names that would clash with an instruction, register, logic type or label are skipped.
  • Registers. Values live in r0–r15, allocated for the whole program at once; nothing is saved to the stack. More than 16 values alive at the same time is an error.
  • Functions. Small functions and functions called once are inlined. Others become jal calls.
  • Optimizations. Constant folding, copy propagation, dead code removal, select for simple if expressions, and branches on comparisons without intermediate values.
const sensor: GasSensor = d0;
const heater: WallHeater = d1;

fn main() {
    loop {
        heater.On = sensor.Temperature < celsius_to_kelvin(20);
        yield();
    }
}

Library reference

Generated from the files in lib/ as they are right now. Read-only logic values are marked read-only, writable ones writable.

Loading…