Skip to content

6.1.0: faster interpreter, Bedrock-compatible MoLang and compiler fixes - #58

Merged
dima-dencep merged 13 commits into
mainfrom
release/6.1.0
Oct 4, 2026
Merged

dima-dencep merged 13 commits into
mainfrom
release/6.1.0

Conversation

@dima-dencep

@dima-dencep dima-dencep commented Oct 4, 2026 •

Copy link
Copy Markdown
Member

Summary

  • The interpreter is faster than Mocha's again. Interpreted math.* and @Binding calls went through a method-handle spreader built on every call; an expression with math.sin and math.cos takes 135 ns instead of 304 ns (Mocha 3.0.1: 249 ns).
  • MoLang evaluates like Bedrock for everything checked in the game: grouping of = and ? :, blocks in conditionals, ??, string comparison, math.round, the random functions and empty statements.
  • The compiler gives the interpreter's results and rejects what it can't compile with a clear error, instead of generating invalid bytecode or silently returning 0.
  • Fixes in the network format, Java binding parameters, toString() and @BindExternalFunction; CI now builds and tests every push to main and every pull request.
  • Version 6.1.0.

Every fix has regression tests; 88 tests pass.

Behaviour changes

These change what existing MoLang evaluates to. Each one was decided by Bedrock, tested in the game with a behaviour pack whose animation controllers report each result, or, where Bedrock wasn't tested, by the majority of MolangJS 1.7.1, bridge's molang 2.0.1, bedrockk/MoLang, Moonflower's molang-compiler 3.1.1.19 and molang-py 1.0.1.

Expression 6.0.2 6.1.0 Bedrock
v.x = 1 ? 5 : 6; return v.x; 1 5 5
1 ? 2 : 3 ? 4 : 5 4 2 2
v.w = 0; 1 ? { v.w = 4; } : { v.w = 5; }; return v.w; 0 4 4
v.zero = 0; return v.zero ?? 7; 7 0 0
'a' == 'b' 1 0 0
math.round(-2.5) -2 -3 -3
math.random_integer(1, 3) 1 or 2 1 to 3 1 to 3
math.random(1, 1), math.random(5, 1) throws 1, 1 to 5 1, 1 to 5
math.die_roll(1, 5, 6) 1.25 to 2.5 5 to 6 5 to 6
v.a = 1;; v.b = 2; return v.b; 1, the rest is dropped 2 2
math.abs(-1,), 1 + 1, 0, no error parse error rejected

Kept on purpose: math.sqrt(-1) and math.ln(0) still give 0 (Bedrock gives NaN and -Infinity), because a NaN that reaches a bone breaks the model. 1 / 0 is 0, as in Bedrock.

Interpreter

  • ReflectiveFunction called MethodHandle.invokeWithArguments, which builds a spreader on every call. The handle is now adapted once and called with invokeExact.
  • MochaMath registered a function for every binding, but didn't override set or entries(), so nothing was stored and every math.* call went through reflection. The functions now back the bindings, with the same purity; pi and e stay inlineable constants.
  • warnOnReflectiveFunctionUsage printed a line on every call, even for calls that don't use reflection. It now reports each reflectively called method once. PlayerAnimationLibrary turns this option on.
  • ExecutionContext, Lazy and varargs parameters of Java bindings received null, and primitive varargs threw a ClassCastException.
  • A class with a single @BindExternalFunction bound nothing, because only the repeated form was read.
  • A Function returning null made q.f() + 1 throw a NullPointerException.

Parser

  • An empty statement, a missing operand or a trailing comma made parseSingle return null, which dropped the rest of the script or put null into the arguments.
  • toString() dropped grouping parentheses, so (1 + 2) * 3 printed as 1+2*3. It now prints text that parses back into the same tree, checked on 20,000 random expressions.

Compiler

  • t.* assignments failed verification in functions returning double, int or boolean.
  • Fractions were converted to boolean by truncating them, so 0.5 was false in a && 1, a ? 10 : 20 and !a.
  • Division by zero and NaN or infinite results now give 0, like in the interpreter.
  • variable, properties of ObjectValues, ->, ??, array access, blocks, break, continue, loop, for_each, lambda functions and ExecutionContext/Lazy/varargs parameters now throw an UnsupportedOperationException that names the construct. Before, they generated invalid bytecode, and loop/for_each silently compiled to 0. a ? b without : is now supported.

Network format

  • An expression containing '' couldn't be read back: ProtocolUtils.readString returns null for an empty string.
  • An unknown operator ordinal was silently read as the first operator; it now throws.
  • StatementExpression (break, continue) now implements equals and hashCode.

Build, CI and docs

  • build.yml runs ./gradlew build on pushes to main and on pull requests, without secrets. The manual workflow is now publish.yml and runs ./gradlew build publish, so a release can't be published with failing tests.
  • The test task no longer needs Node.js; CI checks that expectations.txt matches tests.txt.
  • The readme and the group in gradle.properties describe mochafloats instead of Mocha 3.
  • docs/ is removed: the documentation now lives only in PlayerAnimationLibrary/Documentation, published at docs.zigythebird.com/mochafloats.

Performance

JMH 1.37, JDK 25, Apple Silicon, average time per call:

Interpreted 6.0.2 6.1.0 Mocha 3.0.1
5 0.9 ns 0.9 ns 85 ns
math.sin(v.t * 50) * 45 + math.cos(v.t * 20) 304 ns 135 ns 249 ns
@Binding instance method call 174 ns 102 ns 137 ns

Compiled functions stay within about 10% of Mocha's and Moonflower's molang-compiler; the new division and NaN checks cost 0.05 to 0.6 ns per call.

🤖 Generated with Claude Code

dima-dencep and others added 13 commits October 4, 2026 16:47
ReflectiveFunction called MethodHandle.invokeWithArguments, which builds
a new spreader on every call. The handle is now adapted once to
(Object[])Object and called with invokeExact.

MochaMath registered a function for every math binding, but it didn't
override set or entries(), so nothing was stored and JavaObjectBinding
found no backing functions: every interpreted math.* call went through
reflection. The functions are now stored and exposed as backing
functions, with the same purity as their bindings. pi and e stay out of
entries() so their fields are still inlined as constants.

warnOnReflectiveFunctionUsage printed a line on every call, including
calls that don't use reflection. It now reports each reflectively called
method once.

JMH, JDK 25, interpreting pre-parsed expressions (Mocha 3.0.1 / before / after):
  math.sin(v.t * 50) * 45 + math.cos(v.t * 20)  240 / 304 / 132 ns
  jq.speed(v.t) + 1 (instance @binding method)  137 / 174 / 106 ns

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011GF7DmQcK3Qv6knGWZRJdy
ReflectiveFunction wrapped these arguments in a JavaValue and then ran
them through JavaTypes.convert, which returns null for anything but
numbers, booleans and strings. ExecutionContext and Lazy parameters
received null, varargs parameters received null (or a one-element array
through invokeWithArguments), and primitive varargs threw a
ClassCastException.

Values wrapped in a JavaValue are now passed as they are when the
parameter accepts them, varargs are collected into an array of the
component type with each element converted, and a Lazy whose type
argument is itself parameterized (Lazy<ExecutionContext<?>>) no longer
throws.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011GF7DmQcK3Qv6knGWZRJdy
… zero

@BindExternalFunction is repeatable, and Java only wraps it into
BindExternalFunction.Multiple when it's repeated. JavaObjectBinding only
read the container, so a class with a single external function bound
nothing. It now uses getDeclaredAnnotationsByType, which returns both.

A bound Function returning null worked as a whole expression, but
"q.nothing() + 1" threw a NullPointerException because visitCall passed
the null on. It now reads as Value.nil().

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011GF7DmQcK3Qv6knGWZRJdy
…x random ranges

Behaviour decided by comparing MolangJS, bridge's molang, bedrockk/MoLang,
Moonflower's molang-compiler and molang-py:

- In "c ? { ... } : { ... }" neither block ran: the branch evaluated to
  the block's function, which was never called. The chosen block now
  runs, like the block of "c ? { ... }" already did (every library that
  supports both blocks and conditionals).
- "??" fell back on any falsy value, so with v.x = 0, "v.x ?? 7" was 7.
  It now only falls back when the variable or property doesn't exist
  (4 of 5).
- math.random, math.random_integer, math.die_roll and
  math.die_roll_integer threw IllegalArgumentException when the bounds
  were equal or reversed, which escaped MolangInterpreter.eval. No other
  library throws: the bounds can now come in either order and be equal.
- math.random_integer and math.die_roll_integer never returned the upper
  bound. Both bounds are now included: MolangJS, bridge and molang-py
  include it, molang-compiler excludes it, bedrockk throws.
- math.die_roll summed values from low to low + high and divided the sum
  by 4. It now sums random numbers between low and high (3 of 5).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011GF7DmQcK3Qv6knGWZRJdy
Checked in Bedrock with a behaviour pack:

- "'a' == 'b'" is 0 and "'a' != 'b'" is 1 in Bedrock. mochafloats
  compared every string as 0, so two strings were always equal. The
  libraries didn't agree here (only bridge compares the text). Two
  strings now compare by their text; a string and a number still
  compare as numbers.
- math.round(-2.5) is -3 in Bedrock, which rounds halves away from zero.
  Math.round gave -2. Positive values round the same way as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011GF7DmQcK3Qv6knGWZRJdy
… and print parseable text

Behaviour decided by comparing MolangJS, bridge's molang, bedrockk/MoLang,
Moonflower's molang-compiler and molang-py:

- "v.x = c ? 5 : 6" assigned c and then evaluated "(v.x = c) ? 5 : 6".
  Assignment now takes everything on its right (4 of 5 libraries), and
  "v.a = v.b = 1" assigns both.
- "a ? 1 : b ? 2 : 3" was grouped as "(a ? 1 : b) ? 2 : 3". Conditionals
  now group from the right (4 of 5).
- An empty statement (";;"), a missing operand ("1 +") or a trailing
  comma in a call ("math.abs(1,)") made parseSingle return null, which
  silently dropped the rest of the script or put null into the
  arguments. They are now parse errors (3 to 4 of 5). "{}" and a
  semicolon before "}" still parse.

toString() dropped every grouping parenthesis, so "(1 + 2) * 3" printed
as "1+2*3". It now prints parentheses wherever the parser would group
differently without them, and the true branch of a conditional is
wrapped when it ends with a "?" that has no ":". A test parses 20,000
random expressions, prints them and checks that they parse back into
the same tree.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011GF7DmQcK3Qv6knGWZRJdy
The library comparison made ";;" a parse error (3 of 5 libraries), but
Bedrock itself accepts "v.a = 1;; v.b = 2; return v.b;" and skips the
empty statement, as Mocha 3 and MolangJS do. Empty statements, at the
top level and in blocks, are now skipped instead of rejected.

Bedrock does reject a trailing comma ("math.abs(-1,)") and a missing
operand ("1 +"), so those stay parse errors.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011GF7DmQcK3Qv6knGWZRJdy
…erators

ProtocolUtils.writeString writes an empty string and null the same way,
and readString returns null for both, so an expression containing '' was
written fine but threw a NullPointerException when read back. A string
literal is never null, so StringExpression now reads it as "". readString
itself is unchanged, because callers rely on it returning null.

readEnum turned an unknown ordinal into the first constant, so corrupt
data or a newer peer silently changed an operator into AND,
LOGICAL_NEGATION or BREAK. It now throws.

StatementExpression (break, continue) now implements equals and
hashCode like the other expressions, so equal trees compare equal.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011GF7DmQcK3Qv6knGWZRJdy
…n't compile

Temp variables: an assignment compiled its value with the method's return
type and stored it with fstore, so t.* failed verification in functions
returning double, int or boolean. The value is now always a float, and
an assignment keeps it on the stack, so a script ending with one compiles.

Booleans: float, double and long values were converted to boolean by
truncating them to an int first, so 0.5 was false and "a && 1",
"a ? 10 : 20" and "!a" gave the wrong result for fractional values.
They are now compared with zero. Arithmetic results and field reads are
converted to the type the surrounding code expects instead of always
being left as floats.

Numbers: like the interpreter, dividing by zero gives zero, and NaN or
infinite results of arithmetic and of Java methods (unless the binding
sets skipChecking) become zero. This costs about 0.07 ns per call of the
benchmark function.

Unsupported constructs: variable/v, properties of ObjectValues such as a
query made of lambdas, non-static fields, ->, ??, array access, blocks,
break, continue, loop, for_each, lambda functions and methods taking an
ExecutionContext, a Lazy or varargs used to emit invalid bytecode or,
for loop and for_each, silently compile to 0. They now throw an
UnsupportedOperationException that names the construct. loop and
for_each are no longer folded as constants, which is what made them
disappear. "a ? b" without an else branch is now supported.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011GF7DmQcK3Qv6knGWZRJdy
The readme and the docs folder still described Mocha 3: Java 8+,
Javassist, team.unnamed:mocha on Maven Central, Mocha's build badge and
Discord, and an API where MochaEngine evaluates and compiles by itself.
They now describe the modules, the Redlance repository, Java 24 and the
interpreter/compiler API, and link to the full documentation.

gradle.properties set group=team.unnamed; it's now
org.redlance.mochafloats, which the publications already used.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011GF7DmQcK3Qv6knGWZRJdy
…hing

The only workflow ran on workflow_dispatch and ran "./gradlew publish",
which doesn't depend on test: pushes and pull requests were never built,
and a release could be published with failing tests.

- build.yml now runs "./gradlew build" on pushes to main and on pull
  requests. It doesn't use any secret, so it also works for pull
  requests from forks.
- publish.yml keeps the manual trigger, the secrets and the steps of the
  old workflow, and runs "./gradlew build publish".

The test task no longer runs generateExpectations, so the tests don't
need Node.js and use the committed expectations.txt. build.yml
regenerates it with the Node.js the runner already has and fails if it
differs, so a tests.txt change can't be merged without regenerating it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011GF7DmQcK3Qv6knGWZRJdy
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The documentation now lives only in PlayerAnimationLibrary/Documentation,
published at docs.zigythebird.com/mochafloats, which the readme links to.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@dima-dencep
dima-dencep merged commit 9dcee29 into main Oct 4, 2026
1 check passed
@dima-dencep
dima-dencep deleted the release/6.1.0 branch October 4, 2026 10:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant