Skip to content

Repository files navigation

To see full list of supported features you can check test cases here: src/test/java/com/zergatul/scripting/tests/compiler

General Notes

Although this scripting language was designed for Minecraft mod, it doesn't have any dependencies on Minecraft. The only dependency it has is ASM library (for emitting Java bytecode). The language itself can be used for something else.

This scripting language allows mod users to create custom scripts, bind them to keys, automate tasks, react to in-game events by using C#/Java-like syntax. It is lightweight, async-friendly, and designed to interop with Java APIs where needed.

Short list of supported features

Hello World

debug.write("Hello World!");

Basic Types

  • boolean
  • char
  • int8 (only use it for Java interop)
  • int16 (only use it for Java interop)
  • int (synonym: int32)
  • long (synonym: int64)
  • float32 (corresponds to float in Java, only use it for Java interop)
  • float (corresponds to double in Java, synonym: float64)
  • string

Unsigned integer types are not supported. Language is not well adapted for using int8/int16/float32. If you can, you should better use int and float instead.

string

The built-in string type is directly backed by java.lang.String on the JVM.

  • At runtime, a value of type string is a normal Java java.lang.String instance.
  • The language adds its own conveniences on top of it:
    • Indexer: s[i] instead of s.charAt(i)
    • Read-only length property: s.length
  • Because of this, you should treat string as the canonical text type in this language.

For low-level interop you can still expose the raw Java type, e.g.:

typealiasJString=Java<java.lang.String>;

Both string and Java<java.lang.String> share the same underlying Java class. All type checks and casts (is / as) are based on that underlying Java class, so they are compatible:

Java<java.lang.Object>obj= ...;if(objisstring){// true if obj actually contains a java.lang.String}if(objisJString){// also true in the same case}strings1=objasstring;JStrings2=objasJString;

Use string for normal scripting and only use Java<java.lang.String> (or its alias) when you specifically need to call Java APIs that require an explicit java.lang.String type, or you are doing low-level reflection.

Variables

intx;// will have value 0 assigned implicitlyfloatf=11.25;lets="qq";// variable "s" will have "string" type

Expressions

Supported binary operators: +, -, *, /, %, &&, ||, ==, !=, <, >, <=, >=, &, |, is, as, in

Supported unary operators: +, -, !

in operator

in operator is just a syntactic sugar for contains method that returns boolean and accepts single argument. You can also declare extension contains method and in operator will work for this:

extension(int){booleancontains(intvalue)=>value.toString()inthis.toString();}
boolean b1=12in123;// true, equivalent: "123".contains("12")booleanb2=34in123;// false, equivalent: "123".contains("34")

Arrays

int[]array1;// will have zero length array assigned implicitlyint[]array2=newint[5];letarray3=newint[]{10,20,30,40,50};letarray4=[1,2,3];letarray5=[];// not allowed, cannot infer type of array5int[]array6=[];// allowedint[][]array7=newint[][10];// array of 10 arrays, by default with null values// array concatenationint[]a1=[1,2,3];int[]a2=[7,8,9];int[]a3=a1+a2+10;// a3=[1,2,3,7,8,9,10]

Array length is static, it cannot be resized. "+" operation always creates new array.

Static variables

Static variables should be defined in the beginning of the script, along with functions/classes. These variables preserve values across multiple script invocations (meaning when mod engine executes script in response to some event). Imagine below script bound to some key:

staticintx1;intx2;x1++;x2++;debug.write(x1.toString());// increases each time you press a keydebug.write(x2.toString());// always logs 1

Control Flow

if/else Statements

if(api.getCount()>10){debug.write("OK");}else{debug.write("NOT OK");}

Conditional Expressions

intx=123;inty=x>100?x-100:x+100;

Loops

letarray=[1,2,3];for(leti=0;i<array.length;i++){debug.write(i.toString());}

foreach loop works only with arrays.

letarray=[1,2,3];foreach(letxinarray){debug.write(x.toString());}

continue, break statements are supported. do/while loops are not supported.

Parsing Strings

intx;if(int.tryParse("123",refx)){// success// x is 123 here}else{// fail}

Functions

Simple Functions

Functions should be defined in the beginning of the script, before all script statements:

intfactorial(intvalue){if(value<=1){return1;}else{returnvalue*factorial(value-1);}}debug.write(factorial(10).toString());

You can also use arrow syntax if function is short:

intsum(inta,intb)=>a+b;

Function overloading is supported:

intmax(inti1,inti2)=>i1>i2?i1:i2;intmax(inti1,inti2,inti3)=>max(i1,max(i2,i3));intmax(inti1,inti2,inti3,inti4)=>max(max(i1,i2),max(i3,i4));

Lambda Functions

Subscribing to event:

events.onTickEnd(()=>{debug.write("Tick: #"+game.getTick());});

Using lambda for filtering:

inventory.findAndMoveToHotbar(1,(stack)=>{returnstack.item.id=="minecraft:wooden_sword";});// orinventory.findAndMoveToHotbar(1, stack =>stack.item.id=="minecraft:wooden_sword");

Lambda functions with explicit types are not supported:

// not supportedinventory.findAndMoveToHotbar(1,(ItemStackstack)=>stack.item.id=="minecraft:wooden_sword");

Function as Values

booleanfilterSword(ItemStackstack){returnstack.item.id=="minecraft:wooden_sword";}inventory.findAndMoveToHotbar(1,filterSword);

Class methods can be used as functions:

classMyClass{voidmethod(intx){debug.write((x*x).toString());}}voidtest(fn<int=>void>func){func(10);}letmy=newMyClass();test(my.method);// prints "100", captures "my" variable into closure

Functional Types

You can describe functional types like this: fn<() => void> / fn<int => string> / fn<(int, int, int) => fn<int => int>>

You can use them as function parameters:

voidrun(fn<()=>void>func,inttimes){for(leti=0; i <times; i++){func();}}run(()=>debug.write("a"),3);// writes "a" to debug 3 times

As local variables:

intx=3;fn<int=>int>add3= a =>a+x;debug.write(add3(5).toString());// writes 8

Functions can be cast to functional type:

voidwrite(stringvalue){debug.write(value);}fn<string=>void>f=write;f();

async/await

In async context you can use await statements:

for(leti=0;i<10;i++){awaitdelay.ticks(1);ui.systemMessage("Iteration "+i);}

You can declare your own async functions:

asyncvoidloop(){for(leti=0;i<5;i++){ui.systemMessage(game.getTick().toString());awaitdelay.ticks(10);}}if(api.getSomething()){awaitloop();}else{ui.systemMessage("no");}

You can call async function without await. In this case function will run in "background":

asyncvoidloop(){for(leti=0;i<5;i++){ui.systemMessage(game.getTick().toString());awaitdelay.ticks(10);}}loop();loop();// 2 loops will run at the same time

Async functions can also return any type:

asyncintwaitForChestAndCountItems(stringitemId){while(containers.getMenuClass()!="net.minecraft.world.inventory.ChestMenu"){awaitdelay.ticks(1);}intcount=0;intslots=containers.getSlotsSize();for(leti=0;i<slots;i++){letstack=containers.getItemAtSlot(i);if(stack.item.id==itemId){count+=stack.count;}}returncount;}

Type Check/Cast

letx=api.getSomething();if(xisItemStackstack){debug.write(stack.item.name);}

This works for basic types like int, string, for defined classes, and for Java interop types, like Java<java.lang.Object>.

as-expression features:

  • if expression result can't be cast to target type, it evaluates as default value:
    letx=1asfloat;// 1 is int, it can't be cast to float, x is set 0.0lety=newJava<java.lang.Object>()asstring;// y is set to null
  • transparently handles value types and their boxed variants:
    typealiasObject=Java<java.lang.Object>;typealiasInteger=Java<java.lang.Integer>;ObjectgetInt()=>10;leta=getInt()asint;// a is 10, unboxedletb=20asInteger;// b is 20, boxed

Use #cast(<expr>, <type>) expression for strong check cast. If expression can't be cast to type, ClassCastException is thrown.

typealiasObject=Java<java.lang.Object>;ObjectgetInt()=>10;letx= #cast(getInt(),int);// x is 10

is operator and pattern matching

is operator supports basic pattern matching (similar to C#):

typealiasObject=Java<java.lang.Object>;// ...Objecto=func();// constant patternsif(oisnull){/* ... */}if(ois not null){/* ... */}if(ois100){/* ... */}if(ois not 200){/* ... */}if(ois"hello"){/* ... */}if(ois not "world"){/* ... */}// type patternsif(oisint){/* ... */}if(oisstring){/* ... */}// declaration patternif(oisstringstr){// str is defined here}else{// but not here}if(oisinti){// i is not defined here}else{// i can be used here}if(ois not floatf){return;}// f can be used here

Multiple declaration patterns are support in single condition:

Objecto1=func1();Objecto2=func1();if(o1isstringstr&&o2isintx){// str and x can be used here}

However, pattern variables can't be used in the same expression:

Objecto1=func1();// will not work, str is not defined hereif(o1isstringstr&&str.length>5){}

null

It is advised that APIs to be used from scripting language should not return or expect null. This way it should be more beginner-friendly to not get NullReferenceException. But for Java interop you most likely have to work with null a lot.

strings=null;if(s==null){debug.write("s is null");}

Null-coalescing operator is supported:

strings1=null;strings2="hello";strings3=s1??s2;// s3 is "hello" herestrings4=s1??thrownewJava<java.lang.RuntimeException>();

Boxing

Wrapper classes (Java terminology) or boxed classes (C# terminology) are described like this: Boxed<int> (however you can't use such syntax in the code, you have to use Java<java.lang.Integer> instead). Normally you don't need to use them explicitly, but you may often see them as parameters or return types when working with Java interop. Language supports automatic boxing/unboxing.

For example, API method may return Future<Boxed<boolean>>, because Future type corresponds to CompletableFuture under the hood, and it actually has java.lang.Boolean as type parameter. But you can write code like this:

booleanresult=awaitgame.connect("my.server.example.com");

Reflection

letx="123";lettype= #typeof(x);// type is instance of Typedebug.write(type.name);// logs "string"if(type== #type(string)){// compare 2 types}

Classes

Classes should be defined in the beginning of the script, before all script statements. Class can have fields, constructors, methods, operator overloads. Class without constructors receives implicit parameterless constructor:

classMyClass{intx;inty;}letc=newMyClass();c.x=1;c.y=2;debug.write((c.x+c.y).toString());

For constructor/method bodies you can use square brackets, or arrow if method is short:

classMyClass{intx;constructor(intx){this.x=x;}// or// constructor(int x) => this.x = x;intgetX(){returnx;}// or// int getX() => x;voidinc(){x++;}// or// void inc() => x++;}

Fields, constructors, and methods can use public, protected, or private visibility. Omitting the modifier remains equivalent to public:

classMyClass{privateintvalue;protectedconstructor(intvalue)=>this.value=value;publicintgetValue()=>value;}

Async methods supported.

Inheritance supported:

classBaseClass{virtualintcalc()=>12;}classChildClass:BaseClass{overrideintcalc()=>base.calc()+3;}

Using virtual/override is required for polymorphism. Methods/fields shadowing is not allowed.

You can also extend Java classes:

classMyList:Java<java.util.Vector>{voidadd(intvalue)=>base.add(value);intget2(intindex)=>base.get(index)asint;}classFakeList:Java<java.util.Vector>{overrideintsize()=>0;}

Constructor initializers:

classBaseClass{constructor(intvalue){}}classChildClass:BaseClass{constructor(inta,intb):base(a+b){}constructor(inta,intb,intc):this(a,b+c){}}

Operator overloads:

classVec2{floatx;floaty;constructor(floatx,floaty){this.x=x;this.y=y;}operator[+]Vec2(Vec2vec)=>vec;operator[+]Vec2(Vec2left,Vec2right){returnnewVec2(left.x+right.x,left.y+right.y);}operator[-]Vec2(Vec2vec)=>newVec2(-vec.x,-vec.y,-vec.z);operator[-]Vec2(Vec2left,Vec2right){returnnewVec2(left.x-right.x,left.y-right.y);}operator[==]boolean(Vec2left,Vec2right)=>left.x==right.x&&left.y==right.y;operator[!=]boolean(Vec2left,Vec2right)=>left.x!=right.x||left.y!=right.y;}letv1=newVec2(1,2);letv2=newVec2(3,4);letv3=v1+v2;if(v2==newVec2(0,0)){/* ... */}

Limitations:

  • access modifiers (private/public/etc.) are not supported
  • abstract classes not supported
  • static members are not supported
  • generics not supported

Extensions

Extensions should be defined in the beginning of the script, before all script statements.

Extension blocks can have methods inside:

extension(int){intnext()=>this+1;intmore(intx)=>this+x;}inta=(10).next();// 11intb=a.more(5);// 16
extension(int[]){booleancontains(intvalue){for(inti=0; i <this.length; i++){if(this[i]==value){return true;}}return false;}}
boolean b1 =1in[1,2,3];// truebooleanb2=4in[1,2,3];// false

Extension blocks can have operator overloads inside:

extension(string){operator[+]int(stringstr){intvalue;if(int.tryParse(str,refvalue)){returnvalue;}else{returnint.MIN_VALUE;}}}string s ="100"
int x =+s;// 100
extension(string){operator[/]string[](stringstr,charch)=>str.split(ch);}letstr="hello world";let parts = str /' ';// ["hello", "world"]

Java Interop

Generic type syntax is not supported.

lettable=newJava<java.util.Hashtable>();table.put(false,100);table.put(200,true);table.put("qq","ww");debug.write(table.get("qq").toString());// wwdebug.write(table.get(200).toString());// truedebug.write(table.get(false).toString());// 100// if you need to cast Object to specific typeletobj=table.get("qq");// type: Java<java.lang.Object>letstr=objasstring;// cast to stringdebug.write(str);

Accessing Private Members

To write complex scripts you often have to access private members. Language supports unique syntax to simplify this process:

typealiasMinecraft=Java<net.minecraft.client.Minecraft>;letmc=Minecraft.#instance;// accessing package-private static fieldmc.#rightClickDelay =0;// modifying private instance fieldmc.#handleKeybinds();// calling private method

Any "private" modifier is considered private by this syntax: private, package-private, protected. This syntax doesn't switch the language into dynamic type mode (like dynamic keyword in C#), every private access/call is validated during compilation.

Auto-completion is supported: obj.#<cursor> should pull private-only members.

Accessing private members is only allowed on Java<...> types.

Under the hood such calls are highly optimized by using MethodHandle objects. Above example compiles to something like this in Java code:

classMethodHandleCache {
publicstaticfinalVarHandleinstance_var_handle;
publicstaticfinalVarHandlerightClickDelay_var_handle;
publicstaticfinalMethodHandlehandleKeybinding_method_handle;
staticMethodHandleCache() {
varcaller = MethodHandles.lookup();
varmcPrivateLookup = MethodHandles.privateLookupIn(Minecraft.class, caller);
instance_var_handle = mcPrivateLookup.findStaticVarHandle(Minecraft.class, "instance", Minecraft.class);
rightClickDelay_var_handle = mcPrivateLookup.findVarHandle(Minecraft.class, "rightClickDelay", int.class);
handleKeybinding_method_handle = mcPrivateLookup.findVirtual(Minecraft.class, "handleKeybinds", MethodType.methodType(void.class));
}
}
classScript {
publicvoidrun() {
varmc = (Minecraft) MethodHandleCache.instance_var_handle.get();
MethodHandleCache.rightClickDelay_var_handle.set(mc, 0);
MethodHandleCache.handleKeybinding_method_handle.invokeExact(mc);
}
}

Type Aliases

Type aliases should be defined in the beginning of the script, before all script statements. Example:

typealiasstr=string;voidlog(strs)=>debug.write(s);strx1="s1";log(x1);stringx2="s2";log(x2);

The most useful case for it is Java interop:

typealiasMinecraft=Java<net.minecraft.client.Minecraft>;typealiasLocalPlayer=Java<net.minecraft.client.player.LocalPlayer>;typealiasClientLevel=Java<net.minecraft.client.multiplayer.ClientLevel>;LocalPlayerplayer=Minecraft.instance.player;ClientLevellevel=Minecraft.instance.level;

Exceptions

3 variants supported:

try{ ...}catch{ ...}try{ ...}finally{ ...}try{ ...}catch{ ...}finally{ ...}

catch-block may have optional variable where it will store Throwable instance:

try{(newint[0])[1]=0;}catch(e){// e is IndexOutOfRangeException}

Only single catch-block supported. You can't filter exceptions by their types, like in Java or C#.

You can rethrow exception from catch-block:

try{// ...}catch(e){// ...throw;}

To throw exception use throw statement:

typealiasRuntimeException=Java<java.lang.RuntimeException>;thrownewRuntimeException();

throw expressions

In few cases throw behaves like expression, and not like statement:

// in lambdatypealiasRuntimeException=Java<java.lang.RuntimeException>;voidlog(fn<()=>int> func)=>debug.log(func().toString());log(()=>thrownewRuntimeException());
// in conditional expressiontypealiasRuntimeException=Java<java.lang.RuntimeException>;booleanb=api.getSomething();intvalue=b?100:thrownewRuntimeException();
// in null-coalescing expressiontypealiasRuntimeException=Java<java.lang.RuntimeException>;stringvalue=api.getStr()??thrownewRuntimeException();value??=thrownewRuntimeException();

Limitations

  • Java interop with parameterized types (generics) is not supported

Comparison Table

C#Scripting Language
varlet
(int)x#cast(x, int)
x as intx as int
x is ClassAx is ClassA

About

Java/C#-like strong-typed, compiled scripting language

Resources

Stars

7 stars

Watchers

3 watching

Forks

Releases

Packages

Contributors

Languages