Skip to content

Running current Erlang/OTP BEAM files on JErlang

JErlang's interpreter was written against OTP 20-era BEAM files. This document records what a module compiled by a current compiler needs, what had to change to run one, and what still does not work.

Compiling

JErlang ships a compiler, and it still loads .beam files produced by erlc from an Erlang/OTP installation. Either way the result is loaded from the working directory or from another directory on the code path:

erlc -o src/test/resources src/test/erl/bridge_demo.erl

src/test/resources/bridge_demo.beam is the result of compiling src/test/erl/bridge_demo.erl with Erlang/OTP 29.1.2, and ErlangSourceBridgeTest runs it.

Conformance pass rate

The metric is the fraction of real BEAM files the runtime can load and, of those, the fraction whose every instruction has a handler. Both are needed before a module's code can run, so a module counts as a pass only if it satisfies both. Opcode counts are not the metric.

ConformanceReport loads every .beam below a directory and classifies each file:

  • load failure — the loader rejected the file: an unknown opcode number, a chunk it can not read, a missing code chunk, or a file that is not BEAM.
  • opcode gap — the file loaded, but at least one instruction has no handler. The gap is named per opcode. This is the allowed failure form for a language-level gap; a module should not stop with "Unsupported opcode".
  • pass — loaded, and every instruction has a handler.

Loading is read-only. Nothing is registered, so a run over an OTP tree does not install it, and execution is not part of the metric: calling arbitrary exported functions needs the standard library, which is 0.6.

Against the modules that ship with Erlang/OTP 29.1.2, in the conformance CI job:

Corpus Modules Loaded Passed Opcode gaps Load failures
OTP 29.1.2 lib 1366 100% 100% 0 0

The number is reproducible with an OTP installation and any JDK:

mvn -Dtest=PassRateTest -Djerlang.conformance.dir="$OTP/lib" test

The directory may be a path list and is searched recursively. The run prints the rate; the conformance CI job passes -Djerlang.conformance.min=0.99, so a drop below that floor fails the build. The checked-in corpus is gated at 100% by the test job.

What a modern BEAM file contains that the loader did not expect

Atoms are UTF-8 and may use compact lengths

OTP 20 introduced the AtU8 chunk. OTP 28 changed its format: the atom count is written as a negative number, and each atom length is encoded with the compact instruction encoding rather than a single byte. The reader now handles Atom (latin-1) and AtU8 (UTF-8), including the negative count.

Literals may be uncompressed

OTP 28 stopped compressing the LitT chunk. A zero word after the chunk header now means "the literals follow uncompressed"; the older format still starts with the uncompressed size and a zlib stream. Both are read.

New chunks carry no code

Meta, Dbgi and Type are written by current compilers. They are not needed to execute a module, so they are skipped. A chunk that fails to load now only loses its own data instead of failing the whole module.

The line table stores filename indexes

Line items are encoded with the a tag for the filename and the i tag for the line number, but a carries an index into the chunk's own filename table, not an index into the atom chunk. Decoding it with the instruction argument reader dereferenced a null atom chunk and aborted the load. The reader now decodes the tags directly and resolves filenames after reading the table.

Opcode numbers are not enum positions

Opcode numbers come from lib/compiler/src/genop.tab, and new opcodes are inserted between existing ones. Opcode.decode used the enum position, which happens to work only while no opcode has been inserted. It now looks up the declared code, and an opcode this interpreter does not know fails with its number. The maxOpcode header in the Code chunk is reported, but no longer rejects the module before decoding.

Instructions that had to be added

Opcode Number Emitted for Notes
put_tuple2 164 Tuple construction (OTP 22+) Replaces put_tuple/put pairs
swap 169 Register swaps (OTP 22+)
make_fun3 171 Anonymous functions (OTP 24+) 3 operands in the file; the disassembler expands them
init_yregs 172 Frame initialization (OTP 22+) Sets Y registers to nil
call_fun2 178 Calling anonymous functions (OTP 24+) Tag and type operands are ignored
build_stacktrace 160 try ... catch Class:Reason:Stack (OTP 22+) Normalises the recorded frame list
raw_raise 161 erlang:raise/3 in a catch clause (OTP 22+) Class, reason and stacktrace are re-raised unchanged
is_tagged_tuple 159 Tuple patterns and record tests (OTP 22+) Checks arity and the first element
recv_marker_bind 173 A second receive in one function (OTP 28+) Records nothing: Process.receivePosition tracks the scan position
recv_marker_clear 174 Leaving a marked receive Nothing to clear, for the same reason
recv_marker_reserve 175 A second receive in one function (OTP 28+) Allocates the marker reference
recv_marker_use 176 A second receive in one function (OTP 28+) Falls through to the loop_rec that follows it
bs_start_match3 166 Starting a binary match (OTP 22+) The source is stored as a one element list; a match context is copied
bs_start_match4 170 Starting a match on a known matchable value (OTP 23+) no_fail and resume modes; a label fails like bs_start_match3
bs_match 182 Binary matching (OTP 26+) Carries the get, skip and literal compare operations as a flat command list
bs_get_position 167 Saving a match position (OTP 22+) The position is the bit offset
bs_set_position 168 Restoring a match position (OTP 22+)
bs_get_tail 165 Capturing the rest of a match (OTP 22+) A binary when byte aligned, otherwise a bitstring
get_hd 162 List patterns and hd optimizations (OTP 22+) Raises badarg for a non-list or empty list
get_tl 163 List patterns and tl optimizations (OTP 22+) Raises badarg for a non-list or empty list
bs_create_bin 177 Binary construction (OTP 25+) Reads the flat segment list, including the string table offsets
nif_start 179 Start of a -nifs() function No-op: this runtime has no NIFs
badrecord 180 A record pattern that does not match Raises error:{badrecord,Value}
update_record 181 Record#name{field = Value} (OTP 26+) Applies the flat [Index, Value] update list
executable_line 183 Line information for coverage (OTP 27+) Sets the current frame's location
debug_line 184 Breakpoint locations (OTP 28+) Sets the current frame's location
bif3 185 Three-argument BIF calls that can fail (OTP 29+) Arguments and destination, like bif2
is_any_native_record 186 Native-record tests (OTP 29) Always fails: no native record values exist in this runtime
is_native_record 187 Native-record tests (OTP 29) Always fails
get_record_elements 188 Native-record field access (OTP 29) Always fails
put_record 189 Native-record construction (OTP 29) Raises a named native records are not supported error
is_record_accessible 190 Native-record access checks (OTP 29) Always fails
get_record_field 191 Native-record field access (OTP 29) Jumps to the fail label, or raises badarg when the label is 0

put_list also had to accept a literal tail, which is how an empty list is encoded when building a list from a literal and a register.

A literal tuple is an operand too. move used to treat any two element tuple source as a register, so a function returning {ok, Value} or {[], []} failed with "Cannot convert to integer". Operands now go through toArg, which treats only {x,N}, {y,N} and {fr,N} as registers and returns any other tuple unchanged.

Instruction arguments also gained the extended tag 5, a typed register {tr,Register,Type}. The type is only used by the JIT, but it has to be decoded to stay in sync with the instruction stream.

OTP 28 also made the heap need of allocate_heap (and test_heap) a list of heap fragments such as {alloc, [{words, 0}, {floats, 0}, {funs, 1}]} instead of a word count. The JVM manages the heap, so the interpreter reads only the stack need and the live-register count and ignores the fragment description.

Binary matching

OTP 26 stopped emitting the individual bs_get_integer2 and bs_get_binary2 instructions for a match and folded them into one bs_match instruction with a command list. The interpreter reads that list in order: ensure_at_least, ensure_exactly, skip, get_tail, =:=, integer, binary and float. A command whose field does not fit jumps to the fail label of the instruction.

Fields can be signed, little endian or big endian, and a match can start at a bit offset: <<A:3, B:5, Rest/bitstring>> works because get_tail returns a bitstring when the rest is not a whole number of bytes. The position instructions save the bit offset, so a failed match restores the start and bs_get_tail can capture the original value for the failure reason.

Limitations: little endian fields must be a whole number of bytes, native is treated as little endian, and UTF fields still go through their own bs_get_utf*/bs_skip_utf* instructions rather than through bs_match commands.

Binary construction

bs_create_bin carries a destination and a flat list of six-word segments: [Type, Seg, Unit, Flags, Value, Size]. Type is integer, float, binary, append, string, utf8, utf16 or utf32; flags are little, signed and unsigned where they apply. The interpreter writes each segment into a BitStringOutputStream, so an unaligned bitstring segment stays unaligned.

A string segment is not inline data: beam_asm encodes it as an offset into the module's StrT chunk, and Size is the byte count. The interpreter reads that chunk, which is why binary construction needed the string table that bs_match_string already used.

Function clauses

A clause that fails its pattern does not jump to a normal label: it jumps back to the label in front of the function's func_info. The interpreter used to re-enter the function and loop forever. It now recognises those entry labels and raises error:function_clause, so a single-clause function called with the wrong argument terminates the process instead of hanging a scheduler.

Comparisons

A comparison in a function body compiles to a bif instruction that calls erlang:'<'/2 and friends, while the same test in a guard compiles to is_lt, is_ge, is_eq, is_ne, is_eq_exact or is_ne_exact. The operands may be literals, X registers or Y registers, on either side: X < 12, 12 < X and A < B all appear in compiled code.

Both paths share TermOrder, which implements the Erlang term order (number < atom < reference < fun < port < pid < tuple < map < nil < list < bitstring). == and /= compare numbers by value, so 1 == 1.0 is true, while =:= and =/= stay exact. The guard instructions used to handle only one literal/register shape and raised Can not compare for every other order.

Receive scan position

loop_rec_end used to remove the head of the mailbox. When the first message did not match a receive clause, it was therefore lost before a later receive could see it. Process now keeps receivePosition while a receive scans the mailbox: loop_rec_end advances it, remove_message removes the message at that position and resets the scan, and a timeout resets it as well. This is what lets spawn_opt(..., [link, monitor]) receive the DOWN that follows an EXIT in the same mailbox.

Built-in modules

JErlang ships a few modules in Java so that programs run without an OTP installation. For lists, a lists.beam in the working directory now wins: the module is loaded from disk, and the built-in implementation is only used for functions the BEAM file declares but does not implement.

OTP ships its BIFs and NIFs that way. lists:reverse/2 and lists:member/2, for example, are exported by lists.beam, but their body only calls erlang:nif_error/1; the runtime has to provide them. The loader recognises that placeholder and adopts the built-in function, so the real Erlang code in lists.beam (sort, usort, sum, last and the rest) runs while the primitives stay native. lists:reverse/2 and lists:member/2 were added to the built-in module for this. A function adopted that way has no instruction stream, so a call from Java is dispatched straight to it instead of spawning a process.

Other built-in modules still take precedence over a same-named BEAM file, because some of them (for example erlang) only declare BIFs in their BEAM form and would remove the runtime's implementations of them.

Fun calls

make_fun3 carries a lambda index, a destination and an explicit list of free variables, rather than reading the free variables from X0 to X(NumFree-1) the way make_fun2 does. The lambda table entry still starts with the function's atom, arity, label, index, free-variable count and unique value, so the label and the free-variable count come from the same table the older instruction uses; only the instruction encoding changed. The disassembler prints five operands because it resolves the atom and the unique value into the instruction.

call_fun and call_fun2 were also setting the continuation pointer to index + 1, while call sets it to the index of the call instruction and the interpreter loop advances past it. Every call through a fun therefore skipped the instruction after the call: in funs_demo, run/0 skipped the swap that moved the fun into the argument register and passed a number instead.

Fun identity on the wire

term_to_binary/1 writes an internal fun as NEW_FUN_EXT, which names the module version the fun belongs to by a 16 byte digest and the index of the lambda in the module's function table. The digest is the value beam_lib:md5/1 returns, so it has to be computed the way beam_lib computes it: over the significant chunks in the order md5_chunks/0 lists them, not in file order, with the OldUniq of every lambda table record replaced by zero. OldIndex and OldUniq themselves are ordinary integer terms in the term stream. A receiver checks the digest against the module it has loaded and rejects a mismatch with badfun, so a node that holds an older compile of a module can not run a fun that was built from a newer one.

Cross-module calls and tail calls

A cross-module non-tail call runs the callee's instruction stream as a nested activation: Module.apply pushes the callee's frame, clears the continuation pointer, and puts the caller's pointer back when the callee returns. Clearing it is what makes a return end the callee's own stream instead of jumping to an index that belongs to another module.

call_ext_last and call_ext_only replace their activation. When the target is BEAM code and no catch frame is active, the interpreter does not nest a Java call: it restores the continuation pointer, releases the activation's stack words, records where the replaced activation has to return inside its caller, replaces the stack frame, and continues at the target's first instruction in the same loop. The recorded continuations belong to their interpreter frame and are carried across a wait or a yield, so a tail chain that parks is resumed at the right place. This trampoline is what lets an alternating cross-module tail loop yield instead of growing the JVM stack.

When the target is a built-in, or an exception handler is active, the old nested path runs instead. There the callee runs first and the frame and stack cleanup happens afterwards, so a raise inside it still reports the tail-calling frame; doing the cleanup first is what broke the crash report and monitor reason tests. call_last is a local tail call and stays a label jump.

apply_last with no continuation pointer can use the same trampoline. With a non-zero continuation pointer it still ends the invocation: it tears down its own frame, and returning to the pointer double-deallocates.

A fun whose lambda lives in another module cannot be reached by jumping to a label, because a label is resolved in the stream that runs it. call_fun and call_fun2 run such a fun as its own activation; a fun from the current module remains a label jump, which is what funs_demo exercises.

Suspension

A parked process loses the Java frames of the activations it was running in, so a wait records the module and the index of the wait instruction, and every activation that makes a non-tail cross-module call pushes a resume point: the module and the index after the call. On wakeup execute() resumes the parked stream first, then re-enters the recorded activations innermost first, with the callee's result in X0, until one of them parks again or the process ends. This is what lets proc_lib:start/3 wait in its own module while its caller sits above it, and what keeps OTP's proc_lib running once erlang:apply/3 has entered the behaviour's init function.

Exceptions

An OTP 29 try is compiled into try (install a handler), try_end (leave the block normally), try_case (enter the catch clauses) and, when the class does not match, raise, which re-raises the class left in X0, taking the stacktrace and then the reason as operands. catch Class:Reason:Stack additionally emits build_stacktrace (turn the raw stacktrace in X0 into a list) and raw_raise (re-raise X0/X1/X2 unchanged).

JErlang's Error now carries the exception class (error, throw or exit), the reason and the stacktrace, and handlers put them in X0, X1 and X2, which is what try_case reads. Handlers form a chain, so a nested try restores the enclosing one when it is entered or left.

An exception raised in a called function unwinds to the handler that installed the try: the interpreter drops the callee's stack frame and restores the continuation pointer saved at the try, so the catch clause returns to the right caller. A catch expression has no handler code of its own and continues after the caught call, so it leaves the frame alone.

An exception that reaches the top of a process terminates that process with its reason instead of stopping the virtual machine. The scheduler then sends the reason to the process's links as exit signals and to its monitors as DOWN messages.

The scheduler also prints an Erlang-style error report, on standard output like OTP's default logger handler, with the stacktrace captured at the raise:

=ERROR REPORT==== 2026-10-28::17:00:00.000000 ===
Error in process <0.33.0> with exit value:
{{badmatch,2},[{crash_demo,crash_now,1,[{file,"crash_demo.erl"},{line,19}]}]}

The reason carried to links and monitors is the same value printed here. An uncaught error terminates the process with {Reason, Stacktrace}, and an uncaught throw with {{nocatch, Value}, Stacktrace}; erlang:throw/1 raises the throw class that makes the nocatch wrapper possible. exit/1 and a process killed by an exit signal keep their plain reason, and because they are normal ways to stop, print nothing.

Stack frames

Process keeps one activation per Erlang function call. A frame is recorded when a function is entered through the export table (Module.apply) or through a local, fun or dynamic call, and is dropped by return, by a tail call that replaces the activation, or by an exception that unwinds to a handler. The stacktrace at a raise is therefore a real list, youngest call first, with each frame shaped like the ones the BEAM emulator produces:

{try_demo,caught,1,[{file,"try_demo.erl"},{line,14}]}
{try_demo,run,0,[{file,"try_demo.erl"},{line,67}]}

The location comes from the module's line table. Line references are 1-based and 0 means "no source location"; when the line table has no filename table, the base name of the compile info chunk's source entry is used. BIFs do not get a frame, matching the BEAM, and raw_raise re-raises the frames captured at the original raise.

try_demo.erl covers all of this, and TryCatchTest runs it.

Timers

Every VM timer is scheduled on one hashed timer wheel: 512 one-millisecond slots, a single daemon thread and one lock. receive ... after, erlang:send_after, erlang:start_timer, timer:send_after, erlang:cancel_timer and erlang:read_timer all use it. A TimerReference wraps the wheel's handle, so a cancelled or already-fired timer still answers cancel and read_timer the way OTP does. The action runs outside the wheel lock, which lets a timer callback schedule or cancel another timer.

A slot can contain a long timer and a short one whose deadlines share the same slot index. The wheel only fires a task whose deadline has arrived; the long task stays in its slot and is looked at again one wrap later. The wheel wakes at the earliest deadline, with a 100 ms cap so a cancellation or a newly added timer is noticed promptly. A 10 ms timer is checked against a 250 ms bound by Milestone7Test, through all four entry points.

The timer module loads OTP's timer.beam when it is on the code path, so its full API wins over the runtime's fallback. The fallback exports send_after/2,3 and cancel/1; send_after returns OTP's {ok, {send_local, TRef}} shape, and cancel/1 accepts that wrapper. timer:apply_after, the interval timers and the {abs, ...} timer options are not implemented by the fallback.

Process information and flags

process_info/1,2 reports registered_name, dictionary, message_queue_len, messages, links, trap_exit, status, current_function, reductions, memory, heap_size, stack_size, max_heap_size and message_queue_data. memory is an estimate over the runtime's arrays and mailbox, not a measured JVM object graph; heap_size and stack_size stay zero for the same reason. current_function is the youngest stack frame, or undefined outside a function.

process_flag/2 accepts trap_exit, monitor_nodes, priority, message_queue_data (on_heap or off_heap), and max_heap_size (a map with size, kill and error_logger). The heap limit and the queue mode are stored and reported but not enforced yet. The OTP flags async_dist, sensitive, save_calls, min_heap_size, min_bin_vheap_size and error_handler are accepted as no-ops so a real module that sets one does not crash; an unknown flag is a badarg.

Scheduling and priorities

Each scheduler has four run queues, in strict order: max, high, normal and low. spawn_opt(Fun, [{priority, Level}]) sets the priority before the process is queued, and process_flag(priority, Level) moves a process that is still queued to the new queue. A process that is already running finishes its slice and is queued at the new priority. There is no load balancing between schedulers.

Tracing

erlang:trace/3 gives a process the call, send, receive and return_to flags and a tracer process; the tracer is the caller unless a {tracer, Pid} option says otherwise. erlang:trace_pattern/3 installs a match spec for an MFA, a module, or all. true and a non-empty spec list enable tracing; false and the empty list disable it. The body is evaluated as an action list. {message, Term} supplies the event payload, {return_trace} and {exception_trace} select return_from and exception_from events, {silent, Bool} controls the process trace mode, and display, enable_trace, disable_trace and trace actions are supported.

A call event is {trace, Pid, call, {Module, Function, Args}}, a return event is {trace, Pid, return_from, {Module, Function, Arity}, Value}, an exception event is {trace, Pid, exception_from, {Module, Function, Arity}, {Class, Reason}}, a send event is {trace, Pid, send, Message, To}, and a receive event is {trace, Pid, receive, Message}. Local tail calls (call_only, call_last), cross-module tail calls that use the trampoline, and calls that run through Module.apply all report a call event. Local returns report their own return_from and return_to events. Trace messages are never traced themselves, so a tracer that reads them cannot feed itself.

What still blocks other programs

Every opcode a current erlc emits has a handler: 191 opcodes are known, 30 of them are the old deprecated arithmetic and bit-syntax instructions, and the remaining 161 all execute. There is no "unsupported opcode" list for OTP 29 programs any more; the conformance corpus in src/test/erl/corpus runs in CI and is the exit test for this.

The one language feature still missing is native records, the experimental OTP 29 data type declared as -record #name{...}. A native record is a distinct runtime type that captures its field definition; implementing it needs a new term type, not another opcode. The type-test opcodes therefore always fail, field extraction jumps to its fail label, and construction raises error:"native records are not supported by this runtime". Modules that do not use native records are unaffected.

Elixir and Gleam, measured

The earlier text on this page called Elixir's standard library "the remaining barrier". That was a prediction. The measurement below replaces it with what the runtime actually did on 2026-11-04, using Elixir 1.20.5 (built for OTP 29), Gleam 1.19.2, gleam_stdlib 1.0.5 and Erlang/OTP 29.1.2.

ConformanceReport over the compilers' own standard libraries:

Corpus Modules Loaded Every instruction handled
Elixir lib/elixir/ebin 271 271 271
Gleam gleam_stdlib 20 20 20

Loading is not running, so a small module was compiled by each compiler and called through Node.apply/3. The Elixir GenServer and supervisor calls also had OTP's stdlib ebin on the code path:

Call Elixir 1.20.5 Gleam 1.19.2
integer arithmetic 5 5
binary/string concatenation <<"hello world">> <<"hello world">>
tuple construction {1,2} {1,2}
recursive list sum 6 6
spawn/send/receive 3 not tested
standard-library map Enum.map -> [2,4,6] list.map -> [2,4,6]
GenServer start/call/stop {true, 5} not tested
supervised crash and restart {2, 0} not tested

The Elixir GenServer row starts a registered server, calls it, and stops it. The supervisor row starts a GenServer under a one_for_one supervisor, adds 2 to its state, kills it with Process.exit/2, waits, and reads the restarted server; {2, 0} is the pre-crash state and the reset state after restart. That is the failure model the project exists to test, running from Elixir source.

The measurement found one instruction gap on the path to a working supervisor. OTP's compiler stores a map in a Y register when the map survives a later call, and both put_map_assoc and put_map_exact only wrote X destinations. An Elixir Supervisor start failed with Not implemented: {put_map_assoc, ...} until both handlers accepted Y. They now do, and put_map_y.beam, compiled by OTP 29.1.2, is the regression fixture. This was a runtime gap, not an Elixir or Gleam language gap. A separate crash-report path in the Java supervision example still exits with a badmatch on its logger metadata; that example records the remaining gap.

This is an investigation, not a checked-in example and not a CI gate: CI does not install Elixir or Gleam, and the probe used local toolchains. The useful next step is a Mix application or a Gleam OTP actor, because single processes, supervision and both standard libraries now run. A compatibility example should wait until those toolchain versions are pinned and the remaining OTP surface (applications, releases, Logger backends) has its own checks.

Not changed

  • The shell still accepts only zero-argument m:f(). expressions.

receive ... after inside an inbound distribution call

The defect described here was fixed after 1.0.0. The measurements below are from 1.0.0 as published, and the ones after them are from the fixed build, both against a real OTP 29 peer.

In 1.0.0, a process created to serve an erpc call that arrived over distribution did not park in a receive with an after clause: it returned [] immediately when the message was not already in the mailbox.

Call on 1.0.0 From an inbound erpc:call From Java, in-process
receive never -> got after 200 -> timed_out end [] in 3.6 ms timed_out in 214 ms
a child sends, the parent receives with after 200 [] in 0.9 ms {got,42} in 0.9 ms
send to a registered process, receive the reply with after 200 [] in 1.1 ms [a,b] in 0.6 ms

A receive whose message is already in the mailbox worked in both cases, so the defect was the waiting, not delivery. The consequence was concrete: a stateful module served to a cluster answered [] instead of its state, because every request/reply pattern is written this way.

The cause: erpc:execute_call/4 is a Java function, and it ran the target with erlang:apply/3 inside its own Java frame. Suspension unwinds that frame, because only the interpreter's own activations carry a resume point, so the process finished without the result and the monitor reported what was left in x0.

The fix hands the reply to the exit path. execute_call/4 records the result reference when the call parks instead of throwing a reply it cannot build, Interpreter leaves a parked process WAITING rather than marking it exited, and the scheduler builds {Ref, return, x0} when the process finally stops running. A failure raised after the resume becomes {Ref, error, Reason} by the same route.

Call on the fixed build From an inbound erpc:call From Java, in-process
receive never -> got after 200 -> timed_out end timed_out in 222 ms timed_out in 213 ms
a child sends, the parent receives with after 200 {got,42} in 4.3 ms {got,42} in 1.4 ms
send to a registered process, receive the reply with after 200 [a,b] in 2.7 ms [a,b] in 1.5 ms
message already in the mailbox, receive with after 200 got_it in 2.8 ms got_it in 0.7 ms

1.0.1 contains this fix, and io.github.slayyyyer:jerlang:1.0.1 resolves from Maven Central. 1.0.0 is published and cannot change, so anything still pinned to it keeps the old behaviour.