IEC 61131-3 STRUCTURED TEXT

A PLC language in a browser. Checked by real compilers.

This simulator runs a subset of IEC 61131-3 Structured Text, and "a PLC in a browser" is a claim worth being suspicious of. So the subset is written down here in full, generated from the engine itself, and every push runs the whole test corpus through 4 checks that are not us: two independent IEC compilers, PLCopen's own schema, and a pile of real files this project did not write.

Within the subset, a program behaves the way a real IEC runtime would, so ST written here copies onto a physical PLC. There is no compliance statement and no PLCopen certification.

184Corpus cases
4External checks
49Features probed
11Named divergences
SEC-01 / THE CLAIM

What this does and does not claim.

A conformance claim you cannot check is marketing. Here is the line, in both directions.

It does claim

  • Within the subset, a program behaves the way a real IEC runtime would, scan for scan.
  • Your logic leaves: a spec-legal .st file, or PLCopen TC6 XML that CODESYS and TwinCAT read.
  • What it will not accept, it says so by name, with the conversion or the substitute to write.
  • Every deliberate difference from the standard is listed on this page, not discovered by you later.

It does not claim

  • Compliance. There is no statement against the standard's feature tables and no PLCopen certification.
  • Completeness. The configuration layer (CONFIGURATION, RESOURCE, TASK, direct addressing) is deliberately absent, so base-level ST is not complete regardless.
  • Vendor dialects. This reads spec-legal IEC; it does not translate CODESYS or Siemens extensions.
  • 3rd-edition object orientation. No CLASS, METHOD, INTERFACE or pointers.
SEC-02 / THE CHECKS

4 things that are not us, on every push.

A test suite written by the same hand as the engine proves the engine agrees with itself. That is the one thing it can never prove, so the corpus is replayed through external toolchains, each pinned to an exact version so a result stays reproducible.

MATIEC (iec2c)IEC 61131-3, 2nd edition

Every corpus program is rendered through the PRODUCT’s exporter and compiled by a real IEC compiler, then the generated C is built, stepped with the corpus inputs, and diffed against the goldens scan for scan.

npm run st-oracle
pinned at 7949c0bda1787de9c7cacaa4876ede49f85262dd
STruC++IEC 61131-3, 3rd edition

The edition boundary. Every case MATIEC refuses only for being a 2nd-edition compiler must BUILD here, which turns "the standard allows this" from a comment into a check.

npm run st-oracle:ed3
pinned at v0.6.2
PLCopen TC6 schemaPLCopen XML interchange

The other export target: every exported document is validated against PLCopen’s own published schema, not against assertions written by the same hand as the emitter.

npm run st-oracle:xml
Real-world import corpusBeremiz projects + IEC’s own Annex F examples

The other DIRECTION: files nobody here wrote go through both importers, and every POU and data type they declare must reach either the program or the notes. Nothing may vanish in silence.

npm run st-oracle:import

What they have and have not reached. The corpus samples the subset rather than covering it, so a real IEC compiler has built AND RUN 14/14 of the scalar types, 10/10 of the standard function blocks and 42/44 of the named functions - compiled, stepped, and the resulting values compared against the expected ones scan for scan. A further 2 were compiled but never run, because the 2nd-edition compiler refuses them by design and only the 3rd-edition one builds them: BCD_TO_INT, INT_TO_BCD. Nothing accepted by the engine is left unwitnessed.

SEC-03 / WHAT THEY CAUGHT

The checks are worth reading because they found things.

A green badge proves nothing about a check nobody has ever seen fail. These are defects the external compilers found in this engine, each fixed, each now covered by a corpus case.

SEC-04 / THE SUBSET

Everything the language has.

Generated from the engine: each snippet below was handed to the compiler while this page was built, and it compiled. The vocabulary lists are the engine's own tables, not a copy.

Types
BOOLSINTINTDINTUSINTUINTUDINTBYTEWORDDWORDREALLREALTIMESTRING
Function blocks
TONTOFTPR_TRIGF_TRIGCTUCTDCTUDSRRS
Functions
ABSACOSADDASINATANBCD_TO_INTCONCATCOSDELETEDIVEQEXPEXPTFINDGEGTINSERTINT_TO_BCDLELEFTLENLIMITLNLOGLTMAXMIDMINMOVEMULMUXNEREPLACERIGHTROLRORSELSHLSHRSINSQRTSUBTANTRUNC

Plus the whole <SRC>_TO_<DST> conversion family over those types.

Keywords
ANDARRAYBOOLBYBYTECASECONSTANTCONTINUEDINTDODWORDELSEELSIFEND_CASEEND_FOREND_FUNCTIONEND_FUNCTION_BLOCKEND_IFEND_REPEATEND_STRUCTEND_TYPEEND_VAREND_WHILEEXITFALSEFORFUNCTIONFUNCTION_BLOCKIFINTLREALMODNOTOFORREALREPEATRETURNSINTSTRINGSTRUCTTHENTIMETOTRUETYPEUDINTUINTUNTILUSINTVARVAR_EXTERNALVAR_GLOBALVAR_INPUTVAR_IN_OUTVAR_OUTPUTVAR_TEMPWHILEWORDXOR

Control flow

IF / ELSIF / ELSE
VAR_INPUT n : INT; END_VAR VAR_OUTPUT q : INT; END_VAR
IF n > 10 THEN q := 2; ELSIF n > 0 THEN q := 1; ELSE q := 0; END_IF;
CASE with lists and lo..hi ranges
VAR_INPUT n : INT; END_VAR VAR_OUTPUT q : INT; END_VAR
CASE n OF
  1, 3: q := 1;
  4..9: q := 2;
  -1: q := 9;
ELSE q := 0;
END_CASE;
FOR with BY
VAR i : INT; sum : INT; END_VAR
FOR i := 10 TO 0 BY -2 DO sum := sum + i; END_FOR;
WHILE and REPEAT ... UNTIL
VAR i : INT; j : INT; END_VAR
WHILE i < 5 DO i := i + 1; END_WHILE;
REPEAT j := j + 1; UNTIL j >= 5 END_REPEAT;
EXIT, CONTINUE, RETURN
VAR i : INT; n : INT; END_VAR
FOR i := 1 TO 10 DO
  IF i = 3 THEN CONTINUE; END_IF;
  IF i = 8 THEN EXIT; END_IF;
  n := n + 1;
END_FOR;
RETURN;

POUs

User FUNCTION_BLOCK, named inputs and => output bindings
FUNCTION_BLOCK Motor
VAR_INPUT Run : BOOL; END_VAR
VAR_OUTPUT Spinning : BOOL; END_VAR
Spinning := Run;
END_FUNCTION_BLOCK

VAR M1 : Motor; lamp : BOOL; END_VAR
M1(Run := TRUE, Spinning => lamp);
Stateless FUNCTION (returns by assigning its own name)
FUNCTION Double : INT
VAR_INPUT x : INT; END_VAR
Double := x * 2;
END_FUNCTION

VAR r : INT; END_VAR
r := Double(21);
A user block may shadow a standard one (TON is not reserved)
FUNCTION_BLOCK TON
VAR_INPUT IN : BOOL; END_VAR
VAR_OUTPUT Q : BOOL; END_VAR
Q := IN;
END_FUNCTION_BLOCK

VAR t : TON; q : BOOL; END_VAR
t(IN := TRUE, Q => q);
Positional arguments, in the block’s own declaration order
VAR t : TON; END_VAR
t(TRUE, T#1s);
VAR_IN_OUT binds a place, so the block writes the caller’s array
FUNCTION_BLOCK Fill
VAR_IN_OUT buf : ARRAY [1..4] OF INT; END_VAR
VAR i : INT; END_VAR
FOR i := 1 TO 4 DO buf[i] := i; END_FOR;
END_FUNCTION_BLOCK

VAR f : Fill; data : ARRAY [1..4] OF INT; END_VAR
f(buf := data);
An array of function-block instances, called by index
VAR eyes : ARRAY [1..4] OF BOOL; delay : ARRAY [1..4] OF TON; jam : ARRAY [1..4] OF BOOL; i : INT; END_VAR
FOR i := 1 TO 4 DO
  delay[i](IN := eyes[i], PT := T#3s);
  jam[i] := delay[i].Q;
END_FOR;
A function-block instance as a struct field
TYPE Merge : STRUCT gap : TON; count : CTU; END_STRUCT; END_TYPE
VAR m : Merge; eye : BOOL; open : BOOL; END_VAR
m.gap(IN := NOT eye, PT := T#500ms);
m.count(CU := eye, PV := 100);
open := m.gap.Q;
Structs in and out: by-value inputs, readable outputs, struct results
TYPE Pkg : STRUCT id : DINT; END_STRUCT; END_TYPE
FUNCTION Make : Pkg
VAR_INPUT id : DINT; END_VAR
Make.id := id;
END_FUNCTION

FUNCTION_BLOCK Latch
VAR_INPUT p : Pkg; END_VAR
VAR_OUTPUT held : Pkg; END_VAR
held := p;
END_FUNCTION_BLOCK

VAR l : Latch; box : Pkg; copy : Pkg; n : DINT; END_VAR
box := Make(7);
l(p := box, held => copy);
n := l.held.id;
A formal FUNCTION call, naming its arguments in any order
FUNCTION Span : INT
VAR_INPUT lo : INT; hi : INT; END_VAR
Span := hi - lo;
END_FUNCTION

VAR n : INT; END_VAR
n := Span(hi := 10, lo := 3);

Declarations

Whole structs and arrays assign as values
TYPE Pkg : STRUCT id : DINT; dest : INT; END_STRUCT; END_TYPE
VAR a : Pkg; b : Pkg; track : ARRAY [0..3] OF Pkg; i : INT; END_VAR
b := a;
FOR i := 3 TO 1 BY -1 DO track[i] := track[i - 1]; END_FOR;
An enumeration value written with its type (Mode#Idle)
TYPE Mode : (Idle, Running); END_TYPE
VAR m : Mode := Mode#Idle; END_VAR
IF m = Mode#Idle THEN m := Mode#Running; END_IF;
Several names on one declaration line
VAR x, y, z : INT := 3; END_VAR
x := y + z;
CONSTANT
VAR CONSTANT Limit : INT := 100; END_VAR VAR n : INT; END_VAR
n := Limit;
VAR_GLOBAL, shared by every POU
VAR_GLOBAL Shared : INT := 5; END_VAR
VAR n : INT; END_VAR
n := Shared;
A function-block instance in VAR_GLOBAL
VAR_GLOBAL t : TON; END_VAR
VAR q : BOOL; END_VAR
t(IN := TRUE, PT := T#1s);
q := t.Q;
TYPE ... STRUCT, nested access paths
TYPE Inner : STRUCT a : INT; END_STRUCT; END_TYPE
TYPE Outer : STRUCT
  inner : Inner;
  data : ARRAY [1..3] OF INT;
END_STRUCT; END_TYPE
VAR p : Outer; n : INT; END_VAR
n := p.inner.a + p.data[2];
Multi-dimensional ARRAY with a repeated initialiser
VAR grid : ARRAY [1..2, 0..3] OF INT := [1, 2, 6(0)]; n : INT; END_VAR
n := grid[2, 1];
Enumerations are their own type
TYPE Mode : (Idle, Running := 10, Fault); END_TYPE
VAR m : Mode := Running; n : INT; END_VAR
CASE m OF
  Idle: n := 0;
  Running: n := 1;
  Fault: n := 2;
END_CASE;
Subranges, enforced on every store
TYPE Percent : INT (0..100); END_TYPE
VAR p : Percent := 50; END_VAR
p := 60;
VAR_EXTERNAL, checked against the VAR_GLOBAL it names
VAR_GLOBAL Shared : INT; END_VAR
VAR_EXTERNAL Shared : INT; END_VAR
Shared := 1;
Located variables at inputs and outputs (AT %IX0.0, AT %QW4)
VAR
  Eye AT %IX100.0 : BOOL;
  Run AT %QX100.0 : BOOL;
  Count AT %IW2 : INT;
END_VAR
Run := NOT Eye;
A plain type alias (TYPE Count : INT)
TYPE Count : INT; END_TYPE
VAR n : Count; END_VAR
A named array type, one type under its name
TYPE Row : ARRAY [1..3] OF INT; END_TYPE
VAR a : Row; b : Row := [1, 2, 3]; END_VAR
a := b;
Array bounds written as named CONSTANTs
VAR CONSTANT ZONES : INT := 4; END_VAR
VAR eyes : ARRAY [0..ZONES - 1] OF BOOL; END_VAR
eyes[ZONES - 1] := TRUE;
Structured initial values: (field := value), and arrays of them
TYPE Pkg : STRUCT id : DINT; dest : INT; END_STRUCT; END_TYPE
VAR p : Pkg := (id := 7, dest := 3); lane : ARRAY [1..2] OF Pkg := [(id := 1), (id := 2)]; END_VAR
p.dest := 4;

Library

** exponentiation (EXPT)
VAR a : REAL; END_VAR
a := 2.0 ** 10.0;
Timers, counters and edge detectors
VAR
  t : TON; f : TOF; p : TP;
  r : R_TRIG; ft : F_TRIG;
  cu : CTU; cd : CTD; ud : CTUD;
  s : SR; rs : RS;
END_VAR
t(IN := TRUE, PT := T#1s);
cu(CU := t.Q, PV := 10);
The <SRC>_TO_<DST> conversion family
VAR i : INT := 7; r : REAL; w : WORD; s : STRING; END_VAR
r := INT_TO_REAL(i);
w := INT_TO_WORD(i);
s := INT_TO_STRING(i);
i := STRING_TO_INT('42');
Functional operator forms, including comparison chains
VAR
  a : INT := 3; b : INT := 2; c : INT := 1;
  q : BOOL; n : INT;
END_VAR
q := GT(a, b, c);
n := ADD(a, b, c);
The nine IEC string functions, 1-based
VAR s : STRING := 'hello world'; part : STRING; at : INT; END_VAR
part := MID(s, 5, 1);
at := FIND(s, 'world');
part := REPLACE(s, 'X', 1, 1);

Types

Signed, unsigned and bit-string families, kept apart
VAR
  si : SINT; i : INT; di : DINT;
  usi : USINT; ui : UINT; udi : UDINT;
  b : BYTE; w : WORD; dw : DWORD;
END_VAR
i := i + 1;
ui := ui + 1;
w := w AND 16#00FF;
REAL (single precision) and LREAL
VAR r : REAL := 0.4; l : LREAL; END_VAR
l := r;
TIME is a duration, not a number
VAR t : TIME := T#1m30s; u : TIME; n : DINT; END_VAR
u := t + T#500ms;
n := TIME_TO_DINT(u);
STRING with a capacity, and IEC $ escapes
VAR s : STRING[20] := 'line$N'; n : INT; END_VAR
s := CONCAT(s, 'more');
n := LEN(s);
STRING compares and sorts, but has no +
VAR
  a : STRING := 'abc';
  b : STRING := 'abd';
  lo : STRING; q : BOOL;
END_VAR
q := a < b;
lo := MIN(a, b);
Based, typed, exponent and separated literals
VAR
  h : WORD := 16#FF;
  bits : BYTE := 2#1010_1010;
  oct : INT := 8#17;
  typed : INT := INT#5;
  r : REAL := 1.0E-3;
END_VAR
h := h OR WORD#16#0F;
0 and 1 are BOOL literals (the standard’s own examples use them)
VAR_OUTPUT Q : BOOL := 0; END_VAR
Q := 1;
& is AND
VAR a : BOOL; b : BOOL; q : BOOL; END_VAR
q := a & b;
Identifiers are case-insensitive
VAR Motor : BOOL; END_VAR
motor := TRUE;
(* ... *) comments, anywhere trivia may go
(* a header comment *)
VAR n : INT; END_VAR
n := 1; (* trailing *)

Tasks and programs

A PROGRAM ... END_PROGRAM, which with no CONFIGURATION runs on the scan time
PROGRAM Main
VAR n : INT; END_VAR
n := 1;
END_PROGRAM
CONFIGURATION / RESOURCE / TASK, each task on its own INTERVAL
PROGRAM Main
VAR n : INT; END_VAR
n := n + 1;
END_PROGRAM

CONFIGURATION Cfg
  RESOURCE Res ON PLC
    TASK Fast(INTERVAL := T#10ms, PRIORITY := 1);
    TASK Slow(INTERVAL := T#100ms, PRIORITY := 2);
    PROGRAM A WITH Fast : Main;
    PROGRAM B WITH Slow : Main;
  END_RESOURCE
END_CONFIGURATION
A PROGRAM instance with no task, which runs continuously
PROGRAM Main
VAR n : INT; END_VAR
n := n + 1;
END_PROGRAM

CONFIGURATION Cfg
  RESOURCE Res ON PLC
    TASK Fast(INTERVAL := T#10ms, PRIORITY := 1);
    PROGRAM A WITH Fast : Main;
    PROGRAM Always : Main;
  END_RESOURCE
END_CONFIGURATION
Code outside every POU beside a CONFIGURATION, run by an instance that names no type
VAR n : INT; END_VAR
n := n + 1;

CONFIGURATION Cfg
  RESOURCE Res ON PLC
    TASK Fast(INTERVAL := T#10ms, PRIORITY := 1);
    PROGRAM Main WITH Fast;
  END_RESOURCE
END_CONFIGURATION
SEC-05 / WHERE IT STOPS

23 things the subset does not have.

All of these are valid ST somewhere. The engine refuses each one BY NAME, because "unexpected character" sends an author hunting a typo that is not there. The right-hand column is what it actually says, captured from the compiler as this page was generated.

Types the subset lacks

What you wroteWhat the engine says
LINT (64-bit signed)
VAR n : LINT; END_VAR
LINT is not supported: 64-bit integers need exact arithmetic a JS number cannot provide; use DINT, or LREAL if the range matters more than the last digits
ULINT (64-bit unsigned)
VAR n : ULINT; END_VAR
ULINT is not supported: 64-bit integers need exact arithmetic a JS number cannot provide; use UDINT, or LREAL if the range matters more than the last digits
LWORD (64-bit bit string)
VAR n : LWORD; END_VAR
LWORD is not supported: 64-bit bit strings need exact arithmetic a JS number cannot provide; use DWORD, or two of them
WSTRING
VAR s : WSTRING; END_VAR
WSTRING is not supported: wide (UTF-16) strings are not implemented; STRING is
A double-quoted (WSTRING) literal
VAR s : STRING; END_VAR
s := "wide";
double-quoted literals are WSTRING, which is not implemented; use a single-quoted STRING
DATE
VAR d : DATE; END_VAR
DATE is not supported: calendar types are not implemented in this subset; TIME (a duration) is
TIME_OF_DAY
VAR t : TOD; END_VAR
TOD is not supported: calendar types are not implemented in this subset; TIME (a duration) is
DATE_AND_TIME
VAR d : DT; END_VAR
DT is not supported: calendar types are not implemented in this subset; TIME (a duration) is

Declarations the subset lacks

What you wroteWhat the engine says
Located variables in memory (AT %MW0)
VAR m AT %MW0 : INT; END_VAR
%MW0 is a memory address, and only inputs (%I) and outputs (%Q) are bound to the scene here; locate 'm' at one of those, or drop the AT

Tasks and programs the subset lacks

What you wroteWhat the engine says
An event task (SINGLE)
VAR_GLOBAL go : BOOL; END_VAR
PROGRAM Main
VAR n : INT; END_VAR
n := 1;
END_PROGRAM

CONFIGURATION Cfg
  RESOURCE Res ON PLC
    TASK OnEdge(SINGLE := go, PRIORITY := 1);
    PROGRAM A WITH OnEdge : Main;
  END_RESOURCE
END_CONFIGURATION
an event task (SINGLE) is not implemented; a TASK here runs on its INTERVAL, and a PROGRAM instance with no task runs continuously
Wiring a PROGRAM instance's inputs and outputs in the CONFIGURATION
VAR_GLOBAL go : BOOL; END_VAR
PROGRAM Main
VAR_INPUT start : BOOL; END_VAR
VAR n : INT; END_VAR
n := 1;
END_PROGRAM

CONFIGURATION Cfg
  RESOURCE Res ON PLC
    TASK Fast(INTERVAL := T#10ms, PRIORITY := 1);
    PROGRAM A WITH Fast : Main (start := go);
  END_RESOURCE
END_CONFIGURATION
wiring a PROGRAM instance's inputs and outputs in the CONFIGURATION is not implemented; the scene binds them by name, as Instance.Variable
More than one RESOURCE in a CONFIGURATION
PROGRAM Main
VAR n : INT; END_VAR
n := 1;
END_PROGRAM

CONFIGURATION Cfg
  RESOURCE A ON PLC
    TASK T1(INTERVAL := T#10ms, PRIORITY := 1);
    PROGRAM P1 WITH T1 : Main;
  END_RESOURCE
  RESOURCE B ON PLC
    TASK T2(INTERVAL := T#10ms, PRIORITY := 1);
    PROGRAM P2 WITH T2 : Main;
  END_RESOURCE
END_CONFIGURATION
2 RESOURCEs are declared; a controller here is one RESOURCE, so declare every TASK and PROGRAM instance inside one

Statements the subset lacks

What you wroteWhat the engine says
A function-block call used as an expression
VAR t : TON; q : BOOL; END_VAR
q := t(IN := TRUE, PT := T#1s);
unknown function 't'
The time-arithmetic library (ADD_TIME, ...)
VAR a : TIME := T#1s; b : TIME; END_VAR
b := ADD_TIME(a, T#500ms);
unknown function 'ADD_TIME'
Vendor string extensions (TO_UPPER, TRIM, ...)
VAR s : STRING := 'abc'; END_VAR
s := TO_UPPER(s);
unknown function 'TO_UPPER'

Other IEC languages

What you wroteWhat the engine says
Sequential Function Chart (STEP / TRANSITION / ACTION)
INITIAL_STEP Idle:
END_STEP
TRANSITION FROM Idle TO Running := start;
END_TRANSITION
INITIAL_STEP is not supported: Sequential Function Chart is a separate IEC language, not implemented here; express the state machine in ST (a CASE on a state variable is the usual translation)

3rd-edition OOP

What you wroteWhat the engine says
CLASS
CLASS Tank
VAR level : INT; END_VAR
END_CLASS
CLASS is not supported: IEC 3rd-edition object orientation is not implemented; use a FUNCTION_BLOCK, which holds state and is what a PLC instantiates
INTERFACE
INTERFACE IDrive
END_INTERFACE
INTERFACE is not supported: IEC 3rd-edition object orientation is not implemented; there is no dynamic dispatch in this subset
METHOD
FUNCTION_BLOCK M
METHOD Start : BOOL
END_METHOD
END_FUNCTION_BLOCK
METHOD is not supported: IEC 3rd-edition methods are not implemented; put the logic in the function block's own body, or in a FUNCTION it calls
PROPERTY
FUNCTION_BLOCK T
PROPERTY Level : INT
END_PROPERTY
END_FUNCTION_BLOCK
PROPERTY is not supported: IEC 3rd-edition properties are not implemented; expose a VAR_OUTPUT instead
EXTENDS
FUNCTION_BLOCK B END_FUNCTION_BLOCK
FUNCTION_BLOCK D EXTENDS B END_FUNCTION_BLOCK
EXTENDS is not supported: IEC 3rd-edition inheritance is not implemented; compose instead: declare the base block as a VAR of the derived one
NAMESPACE
NAMESPACE Plant
END_NAMESPACE
NAMESPACE is not supported: IEC 3rd-edition namespaces are not implemented; names are flat in this subset
Pointers (REF_TO, THIS^, SUPER^)
FUNCTION_BLOCK B
VAR n : INT; END_VAR
THIS^.n := 1;
END_FUNCTION_BLOCK
'^' dereferences a pointer, which is not implemented in this subset (nor are REF_TO, THIS^ and SUPER^)
SEC-06 / REFUSED ON PURPOSE

11 things it refuses so your target does not have to.

A simulator that accepts more than a real compiler is not being generous, it is handing you a program that fails at commissioning. Most of these were found by the external compilers, and the corpus now asserts the refusal so the permissiveness cannot come back.

Refused for portability

What you wroteWhat the engine says
Arithmetic on a bit string
VAR b : BYTE := 255; r : BYTE; END_VAR
r := b + 1;
+ is arithmetic; 'BYTE' is a bit string, which IEC does not do arithmetic on (convert it: BYTE_TO_USINT(...))

Arithmetic is ANY_NUM; BYTE/WORD/DWORD are ANY_BIT. Accepted here until the external oracle rejected it (2026-08-10): it built in the simulator and could not have built on any target.

A bitwise operation on a number
VAR i : INT := 1; j : INT := 2; r : INT; END_VAR
r := i AND j;
AND is a bit-string operation; 'INT' is a number (convert it: INT_TO_WORD(...), or declare the variable BYTE/WORD/DWORD)

The mirror of the rule above. Write INT_TO_WORD(...) and operate on the bit string.

NOT on a number
VAR i : INT := 1; r : INT; END_VAR
r := NOT i;
NOT is a bit-string operation; 'INT' is a number (convert it: INT_TO_WORD(...), or declare the variable BYTE/WORD/DWORD)

Complement is defined on ANY_BIT. MATIEC refuses it too.

Shifting a signed integer
VAR i : INT := 8; r : INT; END_VAR
r := SHR(i, 1);
'SHR' shifts a bit string; 'INT' is a number (convert it: INT_TO_WORD(...))

SHL/SHR/ROL/ROR take a bit string, where the vacated bits are defined.

A missing statement separator
VAR n : INT; END_VAR
IF n > 0 THEN n := 1; END_IF
expected ';'
A narrowing store without the conversion
VAR r : REAL := 1.5; i : INT; END_VAR
i := r;
cannot assign REAL to 'i' of type INT (write REAL_TO_INT(...))

Only IEC’s safe widenings are implicit. The error names the conversion to write.

Mixing BOOL and a number
VAR b : BOOL; n : INT; END_VAR
n := b;
cannot assign BOOL to 'n' of type INT
Assigning to a FOR control variable
VAR i : INT; END_VAR
FOR i := 1 TO 10 DO i := 5; END_FOR;
cannot assign to FOR control variable 'i' inside its own loop

IEC forbids it outright and MATIEC refuses it by name. Found by the external oracle (2026-08-10).

A typed literal that does not fit its type
VAR n : INT := INT#40000; END_VAR
40000 does not fit in INT
Two names differing only in case
VAR Motor : BOOL; motor : BOOL; END_VAR
duplicate declaration 'motor' ('Motor' is already declared; identifiers are case-insensitive)

Identifiers are case-insensitive, so these are one name declared twice.

A duration in microseconds or nanoseconds (T#500us)
VAR t : TIME := T#500us; END_VAR
T#500us: microseconds (us) and nanoseconds (ns) are LTIME units, which TIME here does not take: write T#0.5ms instead

us and ns are IEC 3rd-edition units, for LTIME; MATIEC, and so OpenPLC, read d, h, m, s and ms. TIME here takes fractions of a millisecond, so T#0.5ms says the same thing. It used to read as 0 ms, with no error.

SEC-07 / THE OTHER DIRECTION

Accepted here, with a caveat on the target.

The same portability failure pointed the other way, so it is listed rather than hidden. The export warns about these in the file it hands you.

Accepted hereWhat a target does with it
RETAIN / NON_RETAIN
VAR RETAIN count : INT; END_VAR

The qualifier is consumed and does not change what the simulator does: RETAIN asks a variable to survive a warm restart, and there is no restart here: every run starts from the declared initial value, which is what a retentive variable is given on a cold start. A real target does have a restart, so the word still matters there.

A variable named after a standard function (ADD, GT, LT, ...)
VAR ADD : INT; lt : BOOL; END_VAR
ADD := 1;

IEC does not reserve these, so refusing them would invent a rule the standard lacks. But they are Instruction List OPERATORS, and a compiler sharing one lexer between the languages treats them as keywords in ST too. MATIEC refused VAR_OUTPUT lt : BOOL. Export warns about it.

A POU touching a VAR_GLOBAL without declaring VAR_EXTERNAL
VAR_GLOBAL Shared : INT; END_VAR
FUNCTION_BLOCK Reader
VAR_OUTPUT v : INT; END_VAR
v := Shared;
END_FUNCTION_BLOCK

VAR r : Reader; n : INT; END_VAR
r(v => n);

Semantically identical (the global resolves to the same image either way), but a block that touches a global without the declaration will not build on the target. Export warns about it.

SEC-08 / DIVERGENCES

Where it deliberately differs, and why.

Each is exercised by a corpus case carrying a divergence note, and reported by the external oracle as known rather than failed.

  1. same-width signed/unsigned ties resolve unsigned, both ordersIEC does not define mixing signed with unsigned at all; our rule is that the UNSIGNED type wins the same-width tie, in either operand order, so the result is deterministic. (Bit strings no longer take part: arithmetic on ANY_BIT is rejected outright, which is what removed the other half of this divergence.)
  2. both operands of a BOOL AND are evaluated, so a guard does not protect a divideMATIEC stops at a decided operand, so this program returns FALSE there instead of faulting. CODESYS 3.5 SP21 evaluates both operands and faults, stopping the PLC. A program whose behaviour depends on the difference is non-portable whichever way a simulator answers, so this engine answers the way that SURFACES it: a guard that silently worked here would take a CODESYS line down on download.
  3. FOR evaluates bounds once and owns its iterationThe loop bound is snapshotted at entry here, and BOTH independent IEC compilers whose behaviour could be inspected re-read it every iteration, so this program runs 3 times here and 10 times there. MATIEC: its generated C puts `if (I <= N)` inside the loop, reading the live variable. RuSTy (PLC-lang): its FOR lowering plants the user-written bound expression in the loop's exit test rather than a temporary, and says so in its own comment. Neither is provably right: IEC constrains FOR to a count "determined in advance", but no edition text reachable from here says WHEN the final-value expression is evaluated, and CODESYS, Beckhoff and Fernhill all document the comparison without saying how often the bound is computed. Two compilers agreeing is weaker evidence than it looks, since both lower FOR to a C/LLVM-style loop where re-reading is the lazy path. The snapshot is kept anyway: it is what makes the iteration count knowable at entry, which is the property FOR exists for (WHILE is the construct for a condition re-tested each pass), and the only runaway guard here is a count rather than a clock (the interpreter faults a loop after 1,000,000 iterations), so a body that grows its own bound would run a million iterations in one scan before that fault stopped it. Because it cannot be settled, it is not left silent either: exporting a loop whose body writes its own bound now WARNS (export.ts), which is the honest response to a difference nobody can adjudicate.
  4. R_TRIG / F_TRIG single-scan pulsesthe literal IEC F_TRIG body fires when CLK starts false; we suppress that startup pulse, matching CODESYS/TwinCAT practice
  5. SEL and MUX select; out-of-range MUX yields 0IEC declares an out-of-range MUX selector an error; we return 0
  6. REAL division by zero yields Infinity, not a faultfloat error handling is vendor-defined; REAL division by zero stays IEEE +/-Infinity here, matching PLC float hardware, which flags but does not fault. Only INTEGER division faults the scan
  7. an array index outside its bounds faultsNeither MATIEC nor CODESYS checks an array subscript. MATIEC emits C, which reads past the array and completes the scan, so the replay reports "expected 0 scan lines, got 1" for this case. CODESYS 3.5 SP21 Patch 5, a fresh default project, read index 9 of an ARRAY [0..3] as 16752 and ran on (whether one of its optional implicit-check POUs would change that was not measured). IEC requires a subscript to be within range but says nothing about what a runtime does when it is not, so faulting is a choice: this engine is where the mistake is cheapest to find.
  8. a store outside a subrange faultsNeither toolchain checks. CODESYS 3.5 SP21 Patch 5, a fresh default project, stored 250 into an INT (0..100) and ran on to the end (measured 2026-09-20; it ships optional implicit range-check POUs, which a default project does not have). MATIEC completes the scan too, which is why the replay reports "expected 0 scan lines, got 1" here. IEC declares the range but leaves the runtime free, so faulting is this engine being the place the mistake is cheapest to find, exactly as with an array bound.
  9. an out-of-range length or position clamps instead of erroringIEC calls an out-of-range length/position an error. CODESYS and TwinCAT clamp to the string instead, and so do we — faulting a scan over a short string is not behaviour a controller survives.
  10. REAL_TO_STRING formatting is implementation-definedIEC leaves float-to-text formatting to the implementation, and the vendors disagree (trailing zeros, exponent thresholds). We emit the shortest round-trip form. The VALUE is portable; the exact spelling is not, so do not parse it on the target.
  11. TIME_TO_STRING emits an IEC duration literalThe duration-literal spelling (T#1s500ms) is the CODESYS convention; IEC does not fix a TIME_TO_STRING format. Chosen so the output round-trips back through STRING_TO_TIME.
SEC-09 / IN AND OUT

The program is yours. Both doors are open.

Anything a target may not carry comes back as a warning or a note. A program that quietly means something other than what its author wrote is worse than one that refuses.

Taking a program out

Export
  • .st IEC 61131-3 source (.st). Taken by MATIEC, STruC++, RuSTy, and any vendor ST editor as paste.
  • .xml PLCopen TC6 interchange XML. Taken by CODESYS, TwinCAT and the OEM toolchains built on them.
  • The configuration layer this simulator does not author is generated on the way out, so one program body can be wrapped for several targets. Beyond that wrapper the .st target rewrites nothing: your text crosses verbatim.
  • A program that does not compile is refused rather than exported. Shipping a broken file to a real controller is not a courtesy.

Bringing one in

Import
  • A spec-legal .st file: the CONFIGURATION, RESOURCE and PROGRAM wrappers are peeled, and the TASK interval is adopted as the scan time (this page's own test file imported at 40 ms).
  • A PLCopen TC6 project, which is what the CODESYS family's export button produces.
  • Anything the subset cannot hold is named in a note, never dropped in silence. A variable located in memory, such as AT %MX0.0, comes back as "drop the AT clause or move it to an I/O address", because the scene binds inputs and outputs only.
  • A ladder diagram body comes in as ladder, rung for rung. A rung this engine cannot draw (a bridge, say), or a body in FBD, SFC or IL, leaves that POU out with a note saying why, so you can rewrite it in ST or LD on the source toolchain.
SEC-10 / AS DATA

Everything on this page is machine-readable.

This page and /st-capabilities.json are generated from the same probe run, so an agent writing ST against this simulator reads the same subset a person does. In the repository the whole loop is headless, with real exit codes:

npm run catalogwhat parts exist, their parameters, and the PLC signals each expects
npm run capabilitiesthis manifest: what the ST engine accepts and refuses
npm run check-st -- <path...>an ST file compiles
npm run validate-project -- <path>a project document loads
npm run check-wiring -- <path>every scene tag connects to a program variable
npm run test-project -- <folder>the control logic behaves (JSON tests, real exit codes)