ExMujoco

MuJoCo physics from Elixir, through a NIF generated from MuJoCo's own headers.

{:ok, model} = ExMujoco.load_xml("model/scene.xml")
data = ExMujoco.data(model)

for _ <- 1..1000, do: ExMujoco.step(data)

ExMujoco.Data.qpos(data) |> ExMujoco.Tensor.to_list()
ExMujoco.Model.geom_size(model) |> ExMujoco.Tensor.to_list()

No Python, no pythonx, no wheel: a C++ NIF built with Fine, linked against the MuJoCo library and compiled for the machine it runs on.

Why

Monty — the Thousand Brains Project's sensorimotor framework — reaches MuJoCo through the Python bindings, and in this house that means a pythonx interpreter inside the BEAM. That works, and it costs: JSON between the layers, a thread that owns the GL context, and a second runtime to ship. The physics is a C library. This package calls it.

The plan is phased — physics first, then scene building, then rendering — and docs/plan.md says what each phase is for and what it is waiting on.

Install

def deps do
  [{:ex_mujoco, "~> 0.1.1"}]
end

Building it wants a C++17 compiler and make — the same thing Fine wants, and the toolchain an Elixir install already has (Xcode command line tools on macOS, build-essential on Linux, MSVC on Windows). MuJoCo itself is fetched for you: the official release artifact for the platform, checked against the checksum published beside it, in about ten seconds.

$ mix compile
ex_mujoco: Fetching MuJoCo 4.0.1 for darwin-universal
ex_mujoco: MuJoCo 4.0.1 is in _build/mujoco/4.0.1-darwin-arm64

Three ways to point it at a MuJoCo, in the order they are tried:

how command when
your own install MUJOCO_DIR=/opt/mujoco mix compile Conan, vcpkg, a build of your own, a system package
from source, at any ref EX_MUJOCO_SOURCE_REF=main mix compile the latest MuJoCo, or a commit that fixes something
from a checkout EX_MUJOCO_SOURCE_DIR=~/src/mujoco mix compile you are working on MuJoCo
the pinned release mix compile everything else

A release pins one MuJoCo: mujoco.version names it, the official artifact's published checksum is checked before it is unpacked, and the release workflow refuses to build a tag against anything else. The three overrides above are the development paths — main included — and mix ex_mujoco.gen --check tells you when they have moved past the committed bindings.

The source build runs CMake and takes a few minutes; the release fetch takes seconds. docs/building.md has the detail, including what happens to the shared library afterwards and why.

Windows needs nothing set up: the library MuJoCo ships is loaded from priv/ by a second, small library, because Windows will not look beside a library for the libraries it imports. The one prerequisite is MuJoCo's own — the Visual C++ runtime, which ships with Visual Studio.

CI builds the tarball Hex would ship, unpacks it, and installs it in an empty directory on Linux and Windows — compiling MuJoCo from scratch there — because a package that only builds in its own checkout is a package nobody can use.

What you get

ExMujoco is the curated surface — a model, its data, stepping, state:

ExMujoco.load_xml/1, load_string/1 compile MJCF, from a file or from memory
ExMujoco.data/1 an mjData for a model
ExMujoco.forward/1, step/1, reset/1 the three things you do to data
ExMujoco.state/1,2, put_state/3 save and restore the integration state
ExMujoco.contact_force/2 one contact's six numbers

Underneath, generated from the headers:

Reading a field copies it, so the value you hold does not change when the next step runs.

iex> ExMujoco.Model.geom_size(model)
#ExMujoco.Tensor<{2, 3} :f64 6 values>

iex> ExMujoco.Api.mj_name2id(model, ExMujoco.Types.Obj.geom(), "floor")
0

What is not here yet

Both are in docs/plan.md with the reason they are their own phase:

docs/coverage.md lists every MuJoCo function this package does not bind, and why. Nothing is dropped in silence.

Errors

MuJoCo reports a fatal error by not returning — inside a NIF that would abort the VM. The NIF intercepts it (c_src/mj_call.hpp), and it arrives as ExMujoco.Error with MuJoCo's own message:

iex> ExMujoco.load_string("<mujoco></mujoco>")
{:error, %ExMujoco.Error{message: "mj_parseXMLString: ..."}}

How the bindings are made

MuJoCo generates its own Python bindings by reading its headers. This package does the same: mix ex_mujoco.gen parses MUJOCO_DIR/include/mujoco/*.h — functions, struct members with the extents from mjxmacro.h, and enums — and writes the C++ that Fine registers and the Elixir that calls it. The output is committed, so a package build needs neither the generator nor a network, and mix ex_mujoco.gen --check (which CI runs) fails when the two disagree.

c_src/manifest.exs is the one hand-edited decision: which C functions are bound 1:1, and which are bound by hand because each call needs an answer no signature gives.

Releasing

VERSION is the one place the version lives; mix.exs reads it. To publish:

$ ./scripts/tag_and_push_version.sh --dry-run   # what it would do
$ ./scripts/tag_and_push_version.sh             # v0.1.1, pushed

It refuses to tag a commit whose CI is not green, and a commit CI never saw — a docs-only push, since the workflow ignores markdown — is compared against the last run that did succeed. The tag is what .github/workflows/release.yml publishes from: it builds the package, publishes it to Hex and creates the GitHub release.

Licence

MPL-2.0 for this package. MuJoCo is Apache-2.0 and is linked, not modified; see NOTICE.