Skip to content
7323 words 37 min read

LCUI Script Syntax ​

NOTE

The following content is taken from the .lcui script language implementation of LOICollectionA 1.17.1 and may differ in later versions.

Overview ​

.lcui is the script language used by LOICollectionA to write in-game GUIs. All built-in interfaces of modules (such as blacklist.lcui, statistics.lcui) as well as Menu/Shop's menu.lcui and shop.lcui are written in this language.

The complete processing flow of a script is:

text
ScriptLoader (import resolution and merging) -> Lexer (lexical analysis) -> Parser (syntax analysis) -> SemanticAnalyzer (semantic analysis) -> Compiler (bytecode compilation) -> Optimizer (optimization) -> VM (execution)
  • When the plugin starts or after the /xxx reload command is executed, GUIManager::load reads the .lcui file, first resolves the import graph (see "Multi-file Imports"), and then completes the compilation flow above; besides being cached in memory, the compiled bytecode is also written to disk as a .lcp file (see "Bytecode Package").
  • When a player opens a GUI, GUIManager::open(id, formId, type[, ctx]) executes the cached script for that player; forms created in the script with new CustomForm(...), new PaginatedForm(...), etc. are registered with GUIManager, and then the form corresponding to formId is switched to.

For the methods and lifecycle of the native form classes, see Native UI; for Menu/Shop-specific forms and default variables, see LOICollectionAPI.

A Minimal Script ​

lcui
let form = new CustomForm("example.main", {tr("example.gui.title")}) {
    label({tr("example.gui.hello")}, new TextOptions());
    button({tr("generic.gui.close")}, on: func () -> void {
        form.close();
    }, new ButtonOptions());
    closeButton();
    show();
};

Declarative UI Blocks ​

When creating a form, a { ... } block may follow the constructor call to declare controls in display order. Method calls without a receiver inside the block are automatically applied to the form under construction. The minimal script above is fully equivalent to the manually expanded imperative form:

lcui
let form = new CustomForm("example.main", {tr("example.gui.title")});
form.label({tr("example.gui.hello")}, new TextOptions());
form.button({tr("generic.gui.close")}, on: func () -> void {
    form.close();
}, new ButtonOptions());
form.closeButton();
form.show();

Key points:

  • Receiver: in var = new FormClass(args) { ... }, bare calls inside the block (e.g. label(...)) desugar to var.label(...). The receiver variable is bound before the block body runs, so lambdas defined inside the block (such as button handlers) may reference the form under construction by name, like form.close() above.
  • on: named argument: inside a block (and only inside a block), methods that take a handler such as button accept the handler via on: handler, in the same argument position as the explicit form.
  • Control flow: statements such as if are allowed inside the block; which controls get added follows the actual execution order. For example, in the built-in market_store.lcui, the audit button is only added for admins when reviews are enabled:
lcui
let mineForm = new CustomForm("market.store.mine", {tr("market.gui.title")}) {
    /* ... other buttons ... */
    if (GUIManager::request("market.isAdmin", [])[0] && GUIManager::request("market.store.review.enabled", [])[0]) [
        button({tr("market.gui.store.mine.audit")}, on: func () -> void {
            navigateAudit.value = true;

            mineForm.close();
        }, auditOption);
    ]

    closeButton();
};
  • Form classes only: declarative blocks are only allowed on CustomForm, MessageBox, PaginatedForm, and ScriptForm; using one on another class (e.g. new GlobalValue() { ... }) is a compile error.
  • Plain function fallback: bare calls inside the block that are not form methods fall back to ordinary function resolution, so imported helpers (e.g. pagePrevious(), saveLabel()) can be called directly inside a block.

Components ​

component abstracts repeated form-control combinations into reusable fragments. A component is expanded at its use site as a compile-time macro (hygienic expansion): parameters bind by name, there is zero runtime cost, and bare method calls inside the component body inherit the implicit form receiver of the use site:

lcui
component ConfirmBar(confirmText, onConfirm) {
    button(confirmText, on: onConfirm, new ButtonOptions());
    closeButton();
}

let form = new CustomForm("wallet.main", {tr("wallet.gui.title")}) {
    label({tr("wallet.gui.label")}, new TextOptions());
    ConfirmBar(saveLabel(), func () -> void {
        form.close();
    });
    show();
};

At compile time the above is equivalent to writing directly inside the block:

lcui
let form = new CustomForm("wallet.main", {tr("wallet.gui.title")}) {
    label({tr("wallet.gui.label")}, new TextOptions());
    button(saveLabel(), on: func () -> void {
        form.close();
    }, new ButtonOptions());
    closeButton();
    show();
};

Rules:

RuleDescription
Definition siteComponents may only be defined at script top level; defining one inside a function body or block is a compile error
Use siteA component call may only appear as a statement inside a declarative UI block; calling it outside a block or using it as an expression is a compile error
Parameter bindingThe number of call arguments must match the component parameters; they bind positionally to the parameter names
Hygienic expansionLocal variables declared inside the component body are automatically renamed; they neither capture nor shadow same-named outer variables at the use site, and each use site expands independently
No recursionA component calling itself (directly or indirectly) is a compile error
Block capabilitiesControl flow and local variables (block-scoped) are allowed inside the component body, with exactly the same meaning as if written by hand inside the declarative block
Receiver inheritanceExpansion happens at the use site, so bare calls inside the component body act on the form receiver of the use site (form above)
Cross-file reuseComponent definitions are top-level definitions; they may live in an import-ed file (e.g. generic.lcui) and be shared by many scripts

The shared control combinations of the built-in scripts are defined in generic.lcui: PageControls() (previous/next/jump pagination buttons), SectionHeader() (spacer + divider), and ConfirmBar(confirmText, onConfirm) (confirm button + close button).

Multi-file Imports ​

The top of a script may use import to pull in the top-level definitions (class / func / using / component) of other .lcui files for cross-file reuse. The shared GUI vocabulary of the built-in scripts (pagination buttons, save/cancel labels, etc.) is extracted into generic.lcui:

lcui
import "generic.lcui";

let quote = new PaginatedForm("market.quote", {tr("market.gui.title")}, GUIManager::value("market.quote.items"), 10) {
    label({tr("market.gui.quote.list.label")}, new TextOptions());
    PageControls();
    closeButton();
    show();
};

Rules:

RuleDescription
Path resolutionimport paths are resolved relative to the directory of the entry script
Merge orderThe import graph is expanded dependencies-first (topological order); each file's definitions are merged exactly once. Diamond imports (A and B both import C) are legal, and C appears once
Definition conflictsSame-named class / func / using / component in different files raise an error listing the file and line of both definitions
Definitions onlyImported files may only contain class / func / using / component / import — no executable statements (imports must not have side effects); the entry file is exempt
Circular importsCircular imports (e.g. A -> B -> A) raise an error with the full cycle path
Missing filesAn import whose file is missing or unreadable raises an error

Comments, Literals, and Statement Separation ​

Comments ​

Line comments // and block comments /* ... */ are supported:

lcui
// This is a line comment
/* This is a
   block comment */

Literals ​

LiteralExampleDescription
Integer42, -132-bit signed integer
Float3.14, .5, 5.The leading 0 before the decimal point may be omitted
String"hello", 'world'Wrapped in single or double quotes
Booleantrue, false—
NullNoneOnly usable in an optional context
Array[1, "a", true]Element types may be mixed

Strings have no escape characters; the content inside the quotes is preserved as-is. If a string needs to contain a certain kind of quote, wrap it with the other kind of quote, e.g. 'say "hi"'; strings may also span multiple lines.

Statement Separation ​

Statements can be separated with ; or with line breaks. Multiple statements on the same line must be separated with ;; it is recommended to end every statement with ;:

lcui
let a = 1; let b = 2;   // A semicolon is required on the same line

let c = 3           // The semicolon can be omitted with a line break
let d = 4;

Variables and Types ​

Variable Declarations (let) ​

Every variable binding must be declared with let; the keyword-less implicit creation via a bare assignment is no longer supported:

lcui
let a = 1;          // mutable variable, dynamically typed
let count: int = 1; // mutable variable with a type annotation

A let variable can be reassigned at any time and its type may still change dynamically. const is not a statement-level keyword — it only declares associated constants inside trait / impl bodies (for example const G = 9.8;). This rule only constrains local and global variable bindings — function parameters, class fields, and optional/variant members keep their original structural declaration syntax.

Dynamic Variables ​

A let declaration without a type annotation is dynamically typed and can be assigned values of other types at any time:

lcui
let a = 1;
a = "test";     // Valid: dynamically typed
let b = [1, 2, 3];

Type Annotations ​

Use let variableName: type = value to declare a typed variable; a type declaration must also provide an initial value:

lcui
let count: int = 1;
let name: string = "LOICollection";
let flag: bool = true;
let score: float = 1.5;

Type annotations are checked on assignment; assigning a value of a mismatched type, or assigning a value of another type after declaration, raises an error.

Built-in Types ​

TypeDescription
intInteger
floatFloat
stringString
boolBoolean
voidUsed when a function has no return value
ClassNameA custom class or native class type
optional<T>Optional value; may be empty
variant<T1, T2, ...>Union value; can only hold one of the declared types

Function parameters, return values, and variables without type annotations are all dynamically typed. None is a null literal, not a writable type name (when optional is empty, its .type returns none).

optional (Optional Values) ​

optional<T> can hold a value of type T, or the null value None:

lcui
let value: optional<int> = 1;
let empty: optional<string> = None;

value.has_value;    // true
value.value;        // 1
empty.has_value;    // false
empty.value;        // Error: optional is empty

optional supports three special members:

MemberDescription
has_valueWhether it has a value (boolean)
valueThe current value; accessing it when empty raises an error
typeThe type name of the current value; none when empty

None can only appear in an optional context (e.g. the initial value of an optional variable, optional parameters, optional return values); using it elsewhere raises an error. An empty optional directly participating in operations such as arithmetic or comparison also raises an error; it is recommended to check with if (value) or has_value first.

variant (Union Values) ​

variant<T1, T2, ...> can only hold one of the types in the declaration list; assignment is validated:

lcui
let data: variant<string, bool, int> = 1;
data.type;      // "int"
data.value;     // 1
data = "test";  // Valid
data = 1.5;     // Error: float is not in the declaration list

variant requires at least two type parameters (e.g. variant<int> is invalid).

Type Aliases with using ​

using can define an alias for a type; it is only allowed at the top level of a script:

lcui
using Value = variant<string, bool, int>;
using Count = int;

let a: Value = 1;
let b: Count = 3;

Aliases can be chained (e.g. using B = A;), but cannot be self-referencing or defined more than once.

Arrays ​

Arrays are created with [element1, element2, ...] and support arbitrary mixed types:

lcui
let items = [1, "a", true];
items[0];       // 1
items.length;   // 3

items.push(2);  // Appends an element and returns the new length
items[1] = "b"; // Modifies an existing element (assigning at index == length also appends)

Array indices start at 0; out-of-bounds reads and negative indices raise an error. Array ==/!= compares references (whether they are the same array), not contents. An array field of a class is isolated between instances (it is copied when an instance is created); modifying the array of one instance does not affect other instances.

Operators ​

Arithmetic Operators ​

OperatorDescription
+Addition / string concatenation
-Subtraction
*Multiplication
/Division (dividing two integers also produces a float)
%Modulo (integers only)
^Power (right-associative)
+/- (unary)Positive sign / negative sign
lcui
1 + 2 * 3;   // 7
2 ^ 3 ^ 2;   // 512 (right-associative)
10 / 3;      // 3.33333...
10 % 3;      // 1
"Hello " + "World";  // "Hello World"

When + is used with strings and numeric, boolean, and other types, everything is converted to string concatenation. % can only be used with integers; division by zero, integer overflow, and modulo on non-integer types all raise errors.

Comparison Operators ​

==, !=, >, <, >=, <= can be used for numeric, string, and boolean comparisons; numeric int/float values can be compared directly. String comparison rules roughly follow C++. ==/!= on mismatched types raises an error (e.g. 1 == "1").

Objects, arrays, and functions support ==/!=, but comparison is by reference:

lcui
let a = [1];
let b = [1];
a == b;   // false: same contents but not the same array
let c = a;
a == c;   // true

None == None is true.

Logical Operators ​

&& and || support short-circuit evaluation: false && anyExpression does not evaluate the right side, and true || anyExpression likewise does not evaluate the right side. ! is logical negation.

Truthiness Rules ​

Conditional checks use the following truthiness rules:

ValueTruthiness
intTruthy if not 0
floatTruthy if the absolute value is greater than 0
string"", "false", "FALSE" (case-insensitive) are falsy; all other non-empty strings are truthy
boolItself
arrayTruthy if non-empty
Object / functionAlways truthy
NoneAlways falsy

Flow Control ​

if / else ​

if uses square brackets [ ... ] as the branch block, : separates the else branch, and the else branch may be omitted:

lcui
if (count > 0) [
    mc::runCmd("say positive");
:
    mc::runCmd("say not positive");
]

if (flag) [
    mc::runCmd("say flag is true");
]

if itself is also an expression; the last value in a branch is returned as the result and can take part in operations or assignments:

lcui
let result = if (count > 0) [ 10 : 20 ];
let total = if (flag) [ 1 : 2 ] + 5;

while ​

lcui
let i = 0;
while (i < 5) [
    i++;
]

for ​

lcui
let s = 0;
for (let i = 0; i < 10; i++) [
    if (i % 2 == 0) [ continue; ];
    s += i;
]

for (;;) allows all three clauses to be omitted, and is used together with break.

break / continue ​

break exits the current loop, and continue skips the remainder of the current iteration; both can only appear inside a loop, and with nested loops they only affect the innermost one.

match (Pattern Matching) ​

match (expression) dispatches on the scrutinee by arm pattern, following the semantics of Rust's match. An arm is written as pattern [ body ]; the first matching arm wins and the value of the last expression in its body becomes the result:

lcui
let name = match (code) [
    0 [ "zero" ]
    1 [ "one" ]
    n [ "many: " + n ]
];
PatternExampleNotes
Literal1, -2, 3.5, "text", true, NoneMatches by equality; None matches an empty optional; int / float literals match interchangeably
TypeCircle cDispatches via instanceof; c binds the matched object inside the arm
BindnMatches any value and makes n available inside the arm
Wildcard_Matches any value without binding
Guardn if (n > 10)Extra test applied after the pattern matches; may reference the arm's binder
lcui
match (shape) [
    Circle c [ println("circle r=" + c.r); ]
    Square s if (s.s > 5) [ println("big square"); ]
    Square s [ println("square"); ]
    _ [ println("unknown"); ]
]
  • match is an expression: it can initialize a let, take part in arithmetic, or feed a return; an arm body may also return early from the enclosing function.
  • Guards are evaluated only after the pattern matches, so Square s if (...) never tests a non-Square value.
  • Exhaustiveness is mandatory: an unguarded binding or _ arm must exist (a bool scrutinee may instead be covered by both a true and a false arm), otherwise compilation fails.
  • Binders are visible only inside their own arm and may shadow outer variables; type-pattern class names are checked at compile time.

Functions ​

Named Functions ​

func defines a function; parameters and return values may be annotated with types or omitted (omitted means dynamically typed):

lcui
func add(a: int, b: int) -> int {
    return a + b;
}

func pick(x) {
    return x;
}

let result = add(1, 2);

Functions support recursion and mutual recursion, and also support overloading by parameter type:

lcui
func id(x: int) -> int {
    return x;
}

func id(x: string) -> string {
    return x;
}

id(7);      // Calls the int version
id("s");    // Calls the string version

Function and class definitions can only appear at the top level of a script; they cannot be nested inside functions or other blocks. return can only appear in a function body; a function without an explicit return returns the null value.

Anonymous Functions (Lambdas) ​

func (parameters) -> returnType { ... } defines an anonymous function; it can be assigned to a variable, passed as a parameter, and captures outer local variables, parameters, and this:

lcui
let double = func (x: int) -> int {
    return x * 2;
};

form.button("Button", func () -> void {
    GUIManager::callback("example.submit", [ double(21) ]);
}, new ButtonOptions());

Classes and Inheritance ​

Defining Classes ​

lcui
class Animal {
public:
    name = "unknown";
    count: int = 0;

    Animal(name) {
        this.name = name;
    }

    func speak() -> string {
        return "...";
    }
}

let dog = new Animal("dog");
dog.name;   // "dog"

Fields and Methods ​

  • A field can have a default value, a type annotation, both, or neither (dynamically typed).
  • Fields without a default value must be assigned in the constructor.
  • The constructor's name must match the class name, and each class can have at most one constructor; when no constructor is defined, the default constructor is used.
  • Methods are defined with func methodName(parameters) -> returnType { ... } and support overloading.

Access Control ​

Use public: and private: to declare access sections; the default (before the first access section) is public:

lcui
class SecretBox {
private:
    secret = 42;
public:
    func getSecret() -> int {
        return secret;
    }
}

let box = new SecretBox();
box.getSecret();    // 42
box.secret;         // Error: private field

Private members are not inherited; a subclass cannot access the base class's private members via this or bare names.

Static Members ​

static declares a static field or static method, accessed via ClassName::member (instance members use ., static members use :: to distinguish):

lcui
class Counter {
public:
    static total = 0;

    static func inc() -> int {
        total += 1;
        return total;
    }
}

Counter::inc();  // 1
Counter::total;  // 1

Static fields are shared across all instances; this cannot be used in a static method, and instance objects cannot call static methods directly. Static members support inheritance and overriding.

Inheritance ​

extends inherits from a base class:

lcui
class Dog extends Animal {
    func speak() -> string {
        return "Woof";
    }
}

let dog = new Dog("dog");
dog instanceof Animal;  // true

Related keywords:

KeywordDescription
extendsDeclares the base class
super(...)Calls the base class constructor
super.method(...)Calls a base class method
instanceofChecks whether an object is an instance of the specified class (including derived classes)
thisThe current object

Construction rules:

  • If a subclass constructor does not explicitly call super(...), the base class's parameterless constructor (or default constructor) is called implicitly.
  • When the base class only has a parameterized constructor, the subclass constructor must explicitly call super(...), otherwise an error is raised.
  • A class name may be declared before or after use; multi-level inheritance and polymorphism are supported. Circular inheritance, duplicate class names, and unknown base classes raise errors.

Object ==/!= is also a reference comparison.

Traits and Impls ​

A trait declares a set of method contracts, and impl supplies the implementation for a class, so a capability can be attached to a class without going through inheritance:

lcui
trait Printable {
    func describe() -> string;
}

class Card {
    // ...
}

impl Printable for Card {
    func describe() -> string {
        return "card";
    }
}
  • impl Trait for Class implements a trait for a class; every method declared by the trait must be implemented. An unknown trait, an impl target that is not a class, or a missing required method raises an error.

  • impl Class is an inherent implementation (not bound to a trait) and also merges its methods into the target class.

  • An impl block may contain static methods and const associated constants:

    lcui
    impl Card {
        const Kind = "card";
    
        static func create() -> Card {
            return new Card();
        }
    }
  • The methods and constants of an impl are merged into the target class; they are called exactly like the class's own methods (card.describe()), with no extra dynamic dispatch.

  • Colliding with a method of the same name and arity already defined in the class, or a duplicate associated constant, raises an error.

  • Generic impl blocks (such as impl<T> ...) are not supported yet.

  • A typical use is the iteration protocol: giving a class length() and element(int) (either in the class body or in an impl) makes it usable with for-in; see "for-in Loops".

Example: Making a Custom Class Iterable ​

lcui
trait Iterable {
    func length() -> int;
    func element(i: int) -> string;
}

class Deck {
    cards = [];
}

impl Iterable for Deck {
    func length() -> int {
        return this.cards.length;
    }

    func element(i: int) -> string {
        return this.cards[i];
    }
}

let deck = new Deck();
for (i, card in deck) [
    println(i + ": " + card);
]

for-in only checks whether the target provides length() and element(int), no matter whether they are declared in the class body or in an impl block, so Deck becomes iterable without extending any base class.

Common Errors ​

ErrorReason
Cannot implement unknown trait 'X'The trait is not defined, or is defined after the impl
impl of trait 'X' for 'Y' is missing required method 'Z'Not every method required by the trait is implemented
Method 'Z' is already defined in class 'Y'Clashes with a method of the same name and arity in the class
impl target 'Y' is not a classThe impl target is not a class

TIP

impl merges its methods and constants into the target class during semantic analysis, so there is no trait object and no dynamic dispatch: the capability is attached at compile time and calls cost exactly the same as the class's own methods. Use extends inheritance when you need runtime polymorphism, and impl when you need to attach a capability to an existing class.

Built-in Namespaces and Macros ​

Built-in Namespaces ​

NamespaceFunctionsDescription
mathabs, min, max, sqrt, pow, log, sin, cos, randomMath functions
stringlength, upper, lower, substr, trim, replaceString functions
stdformat(formatStr, array)Formats a string with the values in an array (fmt style)
mcrunCmd(command)Executes a command as the current player
GUIManagervalue(id), request(id, args), callback(id, args), open(id, formId, type[, ctx]), switchTo(id, type)Interacts with GUI data

The call format is Namespace::function(parameters), for example:

lcui
mc::runCmd("say hello");
GUIManager::callback("example.submit", [ "data" ]);
let text = std::format({tr("example.gui.info")}, [ 1, "two" ]);

The type in GUIManager::open and GUIManager::switchTo is an integer: 1 is CustomForm, 2 is MessageBox, 3 is PaginatedForm, 4 is ScriptForm.

NOTE

When GUIManager::open navigates to another script, the target script id must be declared in scripts.<id>.gui.navigations in gui/permission.json, otherwise the call is denied. Opening a script's own form needs no declaration.

Macros {...} ​

Call a macro with {name} or {name(parameters)}, for example {tr("language.gui.title")}. tr returns the translated text for the current player's language; default variables such as {player}, {server_tps}, and {score(name)} are also used in macro form; see LOICollectionAPI for the full list.

lcui
let title = {tr("example.gui.title")};
let label = {player} + " -> " + {tr("example.gui.hello")};

Passthrough and Blocking with $ ​

$ is used to pass through raw text: the content between $ and } (or ;) is kept as-is and does not participate in parsing:

lcui
let raw = $custom content};

The ${variable} form can block variable/macro replacement, keeping the content in literal form; for example, ${team} in {player_realname} + '->' + ${team} is output verbatim as {team}.

Native Classes ​

The following native classes can be directly newed in a script (overview):

ClassPurposeDetailed docs
GlobalValueGlobal value container, field valueapi.md
CtxValueReads the ctx array passed to GUIManager::open, field valueSee below
ObservableString / ObservableNumber / ObservableBoolean / ObservableUIRawMessageObservable data for two-way binding of controlsnative-ui.md
UIRawMessageConstructs rich text / translated textnative-ui.md
CustomForm / MessageBox / PaginatedFormNative formsnative-ui.md
TextOptions / ButtonOptions / TextFieldOptions / DropdownOptions / DropdownItem / SliderOptions / ToggleOptions / DividerOptions / SpacingOptionsControl optionsnative-ui.md
MenuData / MenuItemData / MenuControlData / MenuForm / MenuMessageBoxMenu data and formsapi.md
ShopData / ShopItemData / ShopFormShop data and formsapi.md
ScoreRequirementScore requirement, fields objective, valueapi.md

CtxValue ​

When a script is opened via GUIManager::open(id, formId, type, ctx) and a ctx array is passed in, new CtxValue(index) can be used to read the corresponding element:

lcui
let detail = new CtxValue(0);
form.label(detail.value, new TextOptions());

Using CtxValue with an out-of-bounds index or when no ctx was passed in raises an error.

Complete Example ​

A comprehensive example using variables, types, functions, classes, flow control, and native forms:

lcui
class Item {
public:
    name = "";
    price: int = 0;

    Item(name, price) {
        this.name = name;
        this.price = price;
    }

    func format() -> string {
        return name + " (" + price + ")";
    }
}

func makeItems() {
    return [ new Item("Apple", 3), new Item("Banana", 5) ];
}

let items = makeItems();
let selected = new GlobalValue();
selected.value = "";

let form = new CustomForm("example.shop", {tr("example.shop.title")}) {
    label({tr("example.shop.list")}, new TextOptions());
    button(items[0].format(), on: func () -> void {
        selected.value = items[0].name;

        form.close();
    }, new ButtonOptions());
    button(items[1].format(), on: func () -> void {
        selected.value = items[1].name;

        form.close();
    }, new ButtonOptions());
    closeButton();
    show();
};

Script permission: trust level and capabilities ​

The script sandbox authorizes sensitive calls through a single gate; the full field reference is in the permission.json section of Data Files. Each script first receives a set of capabilities from its trust level (coarse gate), then is matched against the per-script id allowlist under scripts.<id> (fine gate); both must pass.

  • Trust levels are defined in trustLevels as capability-name arrays; defaultTrust is the fallback and a single script may override it with trust.
  • Built-in levels today: user (only GuiRead / GuiWrite), and admin / builtin (also ServerCommand plus player-data / storage read-write).
  • The old Network / Filesystem capabilities were never granted to any script and have been removed from the enum.

NOTE

The coarse gate keeps scripts without a declared trust level to GUI only: even under defaultPolicy: "allow" they cannot run commands or read/write player data — least privilege by default.

Execution Budget and Call Depth ​

To keep runaway scripts from hanging the server, the VM meters fuel on every script execution (including script re-entry such as form callbacks): each instruction costs 1 point, and wall clock, native calls, objects and memory each have their own quota. Exhausting any one of them aborts execution immediately with an error.

QuotaDefaultAllowed rangeBehavior on breach
Instructions1,000,00010,000 ~ 100,000,000Execution aborts with Execution budget exhausted
Wall clock1,000 ms10 ~ 30,000 msExecution aborts with Execution timeout
Call depth256 frames16 ~ 4,096Execution aborts with Call stack depth limit exceeded
Native calls100,0001,000 ~ 10,000,000Execution aborts with Execution budget exhausted
Objects100,0001,000 ~ 10,000,000same as above
Array elements100,0001,000 ~ 10,000,000same as above
Single string1 MiB4 KiB ~ 256 MiBExecution aborts with String size budget exhausted
Cumulative memory64 MiB1 MiB ~ 1 GiBExecution aborts with Total byte budget exhausted

Ordinary GUI scripts stay far below these caps; a loop that forgot its step (e.g. while (true)) or an unbounded recursion is aborted in time instead of dragging the server down.

Tuning the budget per script ​

The defaults live in code and can be overridden per script in gui/permission.json — the root-level budget sets the global default, scripts.<id>.budget applies to that one script only, and any field left out falls back to the level above:

json
{
    "defaultPolicy": "deny",
    "budget": { "maxInstructions": 250000, "maxWallTimeMs": 750 },
    "scripts": {
        "market": {
            "enabled": true,
            "budget": { "maxInstructions": 4000000 },
            "gui": { "values": [], "requests": [], "callbacks": [] }
        }
    }
}
  • The keys are maxInstructions, maxWallTimeMs, maxFrames, maxNativeCalls, maxObjectCount, maxArrayElements, maxStringBytes, maxTotalBytes; only maxWallTimeMs carries the Ms suffix (milliseconds), everything else is a plain count.
  • Values outside the allowed range are clamped to the bound: 0 does not switch the sandbox off, and 99999999 ms does not become 27 hours.
  • Non-numeric or negative values (e.g. "1000", -5) are ignored and fall back to the level above.
  • An unknown script id uses the root-level budget.

TIP

Prefer raising the budget of a single script over raising the global default — the point of a quota is to contain the script that runs away, and relaxing it globally loosens every script at once.

Bytecode Optimization ​

After compilation and before execution, the bytecode passes through an optimizer. Optimization only changes how something is computed, never what is computed — runtime errors still fire exactly as written, with their original source locations intact. Everything below is transparent to script authors, but knowing it helps write scripts that naturally benefit:

OptimizationExampleEffect
Constant folding1 + 2 * 3Folded to PUSH_INT 7 at compile time; nothing computed at runtime
Variable forwardinglet x = 1; if (x == 1) [...]Later reads of x are replaced by the known value, and the branch folds away
Pure-function foldingmath::abs(-3), math::pow(2, 10)math:: pure functions with all-constant arguments evaluate at compile time; impure ones like math::random never fold
Super-instructionslet x = 1 (DUP + STORE_VAR)Hot instruction pairs fuse into a single DUP_STORE / DUP_IS_NONE, doing two jobs in one dispatch
Jump peepholeA conditional jump immediately followed by an unconditional jump to the same targetThe double jump collapses and the unreachable block in between is removed
Dead-code eliminationwhile (false) [...], if (false) [...]Unreachable branches are removed wholesale

The optimizer iterates these passes until the bytecode stops changing (a fixed point), so opportunities exposed by one rewrite (e.g. variable forwarding turning a condition into a constant) are picked up by the next round.

At runtime there is an additional layer of inline caching: when a script calls the same native function or native method repeatedly (e.g. math::abs(...) or arr.push(...) inside a loop), the VM remembers the previous resolution and skips signature matching and registry lookups on a hit. This affects speed only, never behavior — on invalidation (e.g. a plugin reload mutating the registry) the VM falls back to the full lookup.

Bytecode Package ​

After compiling a script, GUIManager::load serializes the bytecode to a <script>.lcp file next to the source (e.g. market.lcui maps to market.lcui.lcp):

  • Subsequent loads read the .lcp package first; on a hit, lexing/parsing/semantic analysis and compilation are skipped and the bytecode is reused directly.
  • The package header records the script id and an ABI fingerprint — a hash over the shape of every registered native class, function and macro. A plugin upgrade that changes any native signature alters the fingerprint, which invalidates old packages and triggers recompilation.
  • When the source is present, the package additionally records the SHA-256 of the entry file and of every file in the import graph; a change to any of them triggers recompilation.
  • The serialized data carries a checksum; a corrupted package is discarded and recompiled.
  • A package is self-contained: with the .lcui source removed, the script still loads as long as the ABI fingerprint matches. This is how closed-source scripts are distributed.
  • Debug information lives in a separate .lcp.dbg file; when it is missing the bytecode still executes, only line/column information is lost.

The current package format version is v2.

Common Constraints and Pitfalls ​

  • Classes, named functions, using, and components can only appear at the top level of a script; they cannot be nested.
  • Declarative UI blocks are only allowed on form classes (CustomForm / MessageBox / PaginatedForm / ScriptForm); the on: named argument is only available inside a block.
  • Component calls may only appear as statements inside a declarative UI block; component recursion (direct or indirect) is a compile error.
  • Imported files may only contain top-level definitions (class / func / using / component / import); same-named definitions in different files conflict and raise an error.
  • Every script execution is bound by the execution budget (1,000,000 instructions / 256 call frames by default); exceeding it aborts the execution. The budget is tunable per script via budget in permission.json — see Execution Budget and Call Depth.
  • return can only be used inside a function; break/continue can only be used inside a loop.
  • A type declaration (let x: int = 0;) must also provide an initial value; class fields without a default value must be assigned in the constructor.
  • None can only be used in an optional context; directly reading, performing arithmetic on, or comparing an empty optional raises an error.
  • % only supports integers; division by zero and integer overflow raise errors.
  • Array indices must be int; out-of-bounds reads and negative indices raise errors; an index equal to the length appends an element.
  • Strings have no escape characters; use the other kind of quote to include a quote character.
  • A function call supports at most 100 arguments; exceeding this fails to parse.
  • The GUIManager::value / request / callback IDs registered by different modules differ; refer to the .lcui file of the corresponding module.

For common errors of the script language and their solutions, see Common Error Meanings.

LOICollectionA · A ready-to-use multifunctional plugin set for LeviLamina