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:
| Item | Example |
|---|---|
| function | fn area(w: num, h: num) -> num { w * h } |
| constant | const TARGET: num = 293.15; |
| struct | struct Point { x: num, mut y: num } |
| enum | enum VentMode { Outward, Inward } |
| device type | device Lamp("StructureLamp") { mut On: bool } |
| import | use 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
| Type | Values |
|---|---|
num | 64-bit floating point, the only numeric type IC10 has. Literals: 42, 1.5, 2e3, 0xff, 0b101, 1_000. |
bool | true / false (0 / 1 at runtime). |
| device types | GasSensor, LedDisplay, … Checked access to logic values. See Devices. |
Device | An untyped device: any logic value, read and written as num. |
| structs | Your own types, see Structs. |
| enums | Named 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:
| Parameter | Meaning |
|---|---|
x: T | By value, immutable inside the function. |
mut x: T | By value; the function may change its own copy. |
x: &mut T | Changes 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:
| Operators | Operands | Notes |
|---|---|---|
= += … | place, value | assignment, right-associative |
|| | bool | short-circuit: the right side runs only if needed |
&& | bool | short-circuit |
== != < <= > >= | num (== != also bool) | result is bool |
| | num or bool | bitwise or; on bools: logical or without short-circuit |
^ | num or bool | bitwise xor |
& | num or bool | bitwise and |
<< >> | num | shifts (>> is arithmetic) |
+ - | num | |
* / % | num | % is IC10 mod: the result takes the divisor's sign (-1 % 3 == 2) |
as | casts: bool as num, device retyping | |
-x !x | num / bool | ! is logical not on bool, bitwise not on num |
f(x) a.b | calls, 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.Xreads withlsanddev.Slot.X = vwrites withss. The value is checked against the slot type;mutmarks 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.maxuselbs/lbnsand 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.countreads the network'sStackSize.stack[i]usesget/put; onlymut stackcan 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_itemgives the item a fabricator takes for a reagent, or 0 (Milk, Flour, …). A constant reagent needs no instruction; a runtime one usesrmap, 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,.minor.max..avgand.sumgive anum,.minand.maxkeep the logic value's type. countis how many devices there are (the sum ofPrefabHashdivided 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.sumand.min, andninffor.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.
| Function | Description |
|---|
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) emitsalias sensor d0for 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
jalcalls. -
Optimizations. Constant folding, copy propagation, dead code removal,
selectfor simpleifexpressions, 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…