Migration guide

This page documents the migration steps for upgrading between major versions of MuJoCo-rs. For a full list of changes, see the Changelog.

Migrating to 5.0.0

Version 5.0.0 updates MuJoCo to 3.9.0, redesigns model-editing element deletion, migrates several rendering APIs to the MjrContext error type, and hardens model-editing setters against out-of-range object types.

MuJoCo upgrade

MuJoCo-rs 5.0.0 links against MuJoCo 3.9.0. Download the matching release and update your library path. See Installation for details.

SpecItem::element_pointer pointer type changed (potentially breaking)

SpecItem::element_pointer now returns *const mjsElement (was *mut mjsElement) and is no longer unsafe. This only affects code that called the model-editing trait methods directly (e.g. to drive MuJoCo’s C model-editing API). When a mutable pointer is required, use SpecItem::element_mut_pointer (also no longer unsafe). Any unsafe block wrapping these calls is now redundant and can be removed.

Before (4.x):

let ptr: *mut mjsElement = unsafe { item.element_pointer() };

After (5.0.0):

// read-only C calls take an immutable pointer:
let ptr: *const mjsElement = item.element_pointer();
// when a mutable pointer is required:
let ptr: *mut mjsElement = item.element_mut_pointer();

Deprecated implementation of removing model-editing elements

Due to the SpecItem::delete method relying on undefined behavior, the said method is now deprecated. It’s replacement — MjSpec::delete_element — now forces the user to drop all existing references of MjSpec and its subsequent elements (geoms, joints, etc.), providing some inconvenience compared to the previous implementation.

Before (4.x):

let mut spec = MjSpec::new();
let body = spec.world_body_mut().add_body();  // &mut MjsBody
unsafe { body.delete().unwrap() } ;

After (5.0.0):

let mut spec = MjSpec::new();
let body = spec.world_body_mut().add_body();  // &mut MjsBody
let body_ptr = body.element_mut_pointer();
unsafe { spec.delete_element(body_ptr).unwrap() }

MjsTendon::wrap / wrap_mut no longer return Option

MjsTendon’s wrap and wrap_mut now return &MjsWrap / &mut MjsWrap (previously Option<&MjsWrap> / Option<&mut MjsWrap>) and panic if the index is out of bounds. The old signature documented None on an out-of-range index, but the underlying C mjs_getWrap aborts the process for such an index and never returns null, so the None arm was unreachable. Drop the .unwrap() on existing calls and ensure the index is in range (< wrap_num()), or use the new fallible try_wrap / try_wrap_mut.

Before (4.x):

let wrap = tendon.wrap(i).unwrap();

After (5.0.0):

let wrap = tendon.wrap(i);

Deprecated fallible tendon-wrap methods

MjsTendon’s try_wrap_site, try_wrap_geom, try_wrap_joint, and try_wrap_pulley are now deprecated. Their MjEditError::AllocationFailed arm is unreachable: on an allocation failure MuJoCo aborts the process (or, under a non-default error configuration, writes through the null pointer before returning), so the failure cannot be recovered soundly. Switch to the panicking wrap_site / wrap_geom / wrap_joint / wrap_pulley, which return &mut MjsWrap directly.

These methods may be undeprecated in the future if MuJoCo’s upstream C++ code is changed to return null recoverably.

Before (4.x):

let wrap = tendon.try_wrap_geom("geom", "sidesite")?;

After (5.0.0):

let wrap = tendon.wrap_geom("geom", "sidesite");

MjrContext::upload_texture parameter type changed

MjrContext::upload_texture now accepts texture_id: usize instead of texid: u32. Update call sites to cast or convert the argument accordingly.

Before (4.x):

context.upload_texture(&model, 0u32);

After:

context.upload_texture(&model, 0usize);

MjrContext::upload_texture returns Result

MjrContext::upload_texture now returns Result<(), MjrContextError> instead of (). Handle or propagate the result at each call site.

Before (4.x):

context.upload_texture(&model, id);

After:

context.upload_texture(&model, id)?;

MjrContext::add_aux / set_aux error type changed

MjrContext::add_aux and MjrContext::set_aux now return Result<(), MjrContextError> instead of Result<(), MjSceneError>. The MjSceneError::InvalidAuxBufferIndex variant has been removed; use MjrContextError::IndexOutOfBounds instead.

Before (4.x):

match context.add_aux(index, width, height, samples) {
    Err(MjSceneError::InvalidAuxBufferIndex { index }) => { /* … */ }
    Ok(()) => { /* … */ }
}

After:

match context.add_aux(index, width, height, samples) {
    Err(MjrContextError::IndexOutOfBounds { id, len }) => { /* … */ }
    Ok(()) => { /* … */ }
}

MjrContext::read_pixels error type changed

MjrContext::read_pixels now returns Result<…, MjrContextError> instead of Result<…, MjSceneError>. MjSceneError::InvalidViewport and MjSceneError::BufferTooSmall have been removed; use MjrContextError::InvalidViewport and MjrContextError::BufferTooSmall instead.

Before (4.x):

match context.read_pixels(Some(&mut rgb_buf), None, &viewport) {
    Err(MjSceneError::InvalidViewport { .. }) => { /* … */ }
    Err(MjSceneError::BufferTooSmall { .. }) => { /* … */ }
    Ok(()) => { /* … */ }
}

After:

match context.read_pixels(Some(&mut rgb_buf), None, &viewport) {
    Err(MjrContextError::InvalidViewport { .. }) => { /* … */ }
    Err(MjrContextError::BufferTooSmall { .. }) => { /* … */ }
    Ok(()) => { /* … */ }
}

MjModelError::InvalidIndex removed

MjModelError::InvalidIndex(usize, usize) has been removed. Use IndexOutOfBounds with named fields instead.

Before (4.x):

match model.try_max_contacts(geom1, geom2, None) {
    Err(MjModelError::InvalidIndex(id, len)) => { /* … */ }
    Ok(count) => { /* … */ }
}

After:

match model.try_max_contacts(geom1, geom2, None) {
    Err(MjModelError::IndexOutOfBounds { id, len }) => { /* … */ }
    Ok(count) => { /* … */ }
}

MjsTuple::set_objtype now takes &[MjtObj] and is fallible

MjsTuple::set_objtype previously accepted &[i32] and returned (). It now takes &[MjtObj] and returns Result<(), MjEditError>: out-of-range object types are rejected with MjEditError::InvalidParameter.

Before (4.x):

tuple.set_objtype(&[MjtObj::mjOBJ_BODY as i32, MjtObj::mjOBJ_GEOM as i32]);

After:

tuple.set_objtype(&[MjtObj::mjOBJ_BODY, MjtObj::mjOBJ_GEOM])?;

MjsSensor::set_objtype / set_reftype are now fallible

MjsSensor::set_objtype and set_reftype previously returned (); they now return Result<(), MjEditError>, rejecting out-of-range object types with MjEditError::InvalidParameter. The builder counterparts with_objtype / with_reftype keep their signature but panic on a rejected value.

Before (4.x):

sensor.set_objtype(MjtObj::mjOBJ_SITE);
sensor.set_reftype(MjtObj::mjOBJ_BODY);

After:

sensor.set_objtype(MjtObj::mjOBJ_SITE)?;
sensor.set_reftype(MjtObj::mjOBJ_BODY)?;

MjsNumeric::set_size is now fallible

MjsNumeric::set_size previously returned (); it now returns Result<(), MjEditError>, rejecting a negative size with MjEditError::InvalidParameter (a negative size triggers an out-of-bounds write in the model compiler).

Before (4.x):

numeric.set_size(8);

After:

numeric.set_size(8)?;

Some index/size vector setters are now unsafe

MjsFlex::set_elemtexcoord, MjsSkin::set_face, MjsMesh::set_userfacetexcoord and MjsMesh::set_userfacenormal are now unsafe fn. Each writes a value the model compiler or renderer later trusts as an unchecked index/count/length, and the correct constraint is cross-field, so it cannot be validated from the setter. Wrap calls in unsafe after ensuring the obligation in each method’s # Safety section (e.g. for set_face, every index is in 0..nvert and the length is a multiple of 3; for set_userfacenormal, every index is in 0..N, where N is the set_usernormal slice length divided by 3).

Before (4.x):

skin.set_face(&faces);

After:

// SAFETY: every index is < nvert and faces.len() is a multiple of 3.
unsafe { skin.set_face(&faces); }

MjData::add_contact is now an unsafe fn

add_contact wraps mj_addContact, an advanced entry point that stores the caller-supplied MjContact verbatim. MuJoCo later uses the stored contact without validation, so a malformed contact can cause undefined behavior. The method is now unsafe; the caller must ensure the contact is valid for the model. The signature is otherwise unchanged, so existing call sites only need an unsafe block.

Before (4.x):

data.add_contact(&contact)?;

After:

// SAFETY: `contact` is a valid contact for this model.
unsafe { data.add_contact(&contact)? };

MjData::reset_debug is now an unsafe fn

reset_debug wraps mj_resetDataDebug, which fills every buffer-resident array with raw debug_value bytes. Arrays that MuJoCo does not re-initialize afterwards keep those bytes, and some safe accessors expose them as types with validity invariants (bvh_active as &[bool], body_awake as a fieldless enum slice), so reading them can be undefined behavior. The method is now unsafe; the caller must not read such accessors before the next reset unless debug_value produces valid bit patterns for them. The signature is otherwise unchanged, so existing call sites only need an unsafe block.

Before (4.x):

data.reset_debug(7);

After:

// SAFETY: no invariant-carrying accessor (bvh_active, body_awake) is read
// before the next reset().
unsafe { data.reset_debug(7) };

MjData::history_mut is now an unsafe fn

history_mut exposes the whole history buffer for mutation. Each slot stores a cursor in buf[1] that mj_readSensor / mj_readCtrl trust as an array index without a bound check, so corrupting it from safe code can cause an out-of-bounds read. The method is now unsafe; the caller must keep every slot’s cursor valid. The signature is otherwise unchanged, so existing call sites only need an unsafe block. The immutable history accessor stays safe.

Before (4.x):

let buf = data.history_mut();

After:

// SAFETY: every history slot's cursor (buf[1]) is kept valid.
let buf = unsafe { data.history_mut() };

MjData::ray / ray_mesh now take &mut self

ray, ray_mesh, and try_ray_mesh now take &mut self (previously &self). Their bounding-volume traversal (mju_rayTree) writes data.bvh_active when the model’s bvactive visualization flag is set, even though the underlying MuJoCo C functions are declared const mjData*. The &self signature therefore let safe code drive shared MjData mutation (a data race under MjData<M>: Sync, or mutation of a live &[bool] obtained from the bvh_active accessor). Callers that held a shared &MjData now need an exclusive borrow. ray_flex and ray_hfield are unaffected; multi_ray already took &mut self.

Before (4.x):

let data = model.make_data();
let (geomid, dist) = data.ray(&pnt, &vec, None, false, None, None);

After:

let mut data = model.make_data();
let (geomid, dist) = data.ray(&pnt, &vec, None, false, None, None);

MjvScene::find_selection now takes &mut MjData

MjvScene’s find_selection now takes data: &mut MjData<M> (previously &MjData<M>). It calls mjv_select -> mj_ray, whose bounding-volume traversal writes data.bvh_active (the same const mjData*-but-mutating path as ray above), so a shared borrow was unsound. Pass an exclusive borrow instead. (MjvScene::update already took &mut MjData.)

Before (4.x):

let data = model.make_data();
let sel = scene.find_selection(&data, &opt, aspect, relx, rely);

After:

let mut data = model.make_data();
let sel = scene.find_selection(&mut data, &opt, aspect, relx, rely);

MjSpec is no longer Sync

MjSpec is now Send but no longer Sync. clone / try_clone make a faithful, independent copy, but the underlying C++ copy constructor is not strictly const on the source (it rewrites transient, normally-empty per-actuator keyframe-cache maps), so two threads cloning a shared &MjSpec concurrently is a data race. MjSpec still moves between threads (Send); only shared cross-thread access is removed. Code that placed a &MjSpec in a Sync-requiring context must transfer ownership (or clone per thread) instead.

Before (4.x):

// relied on &MjSpec being shareable across threads
std::thread::scope(|s| {
    s.spawn(|| { let _ = spec.clone(); });
    s.spawn(|| { let _ = spec.clone(); });
});

After:

// move an owned MjSpec into each thread (clone up front if needed)
let spec2 = spec.clone();
std::thread::scope(|s| {
    s.spawn(move || { let _ = spec; });
    s.spawn(move || { let _ = spec2; });
});

Mjs* element handles are no longer Send / Sync

The Mjs* element handles (MjsBody, MjsGeom, …) and MjsDefault previously carried a blanket unsafe impl Send/Sync. Each is only a raw pointer into one shared mjSpec / mjCModel arena, so moving or sharing a handle across threads was unsound. They are now !Send + !Sync. Code that sent a handle to another thread will no longer compile — move the owning MjSpec instead (it stays Send; it is now !Sync, see above) and derive the per-thread handles from it on the thread that uses them.

Before (4.x):

// relied on Mjs* handles being Send: a handle was moved into another thread
let geom = body.add_geom();
std::thread::scope(|s| {
    s.spawn(move || { let _ = geom.set_name("g"); });
});

After:

// move the owning MjSpec; derive handles on the thread that uses them
std::thread::scope(|s| {
    s.spawn(move || {
        let mut spec = spec;
        let geom = spec.world_body_mut().add_geom();
        let _ = geom.set_name("g");
    });
});

Migrating to 4.0.0

Version 4.0.0 updates MuJoCo to 3.8.0 and follows the upstream API changes in mj_geomDistance and the model-editing specification structs.

MuJoCo upgrade

MuJoCo-rs 4.0.0 links against MuJoCo 3.8.0. Download the matching release and update your library path. See Installation for details.

MjData::geom_distance() now requires &mut self

MuJoCo 3.7.0 changed mj_geomDistance to mutate mjData internally. As a result, both MjData::geom_distance and MjData::try_geom_distance now require mutable access to MjData.

Before (3.x):

let data = MjData::new(&model);
let dist = data.geom_distance(geom1, geom2, dist_max, None);

After (4.0.0):

let mut data = MjData::new(&model);
let dist = data.geom_distance(geom1, geom2, dist_max, None);

MjsJoint and MjsTendon stiffness/damping now use coefficient arrays

MuJoCo 3.7.0 replaced the scalar stiffness and damping fields in the editing API with polynomial coefficients. MuJoCo-rs mirrors that change: MjsJoint and MjsTendon now use coefficient arrays for stiffness and damping. Update call sites to pass coefficient arrays instead of scalar values.

Before (3.x):

joint.with_stiffness(10.0)
     .with_damping(0.5);

tendon.with_stiffness(20.0)
      .with_damping(0.2);

After (4.0.0):

joint.with_stiffness([10.0, 0.0, 0.0])
     .with_damping([0.5, 0.0, 0.0]);

tendon.with_stiffness([20.0, 0.0, 0.0])
      .with_damping([0.2, 0.0, 0.0]);

MjsFlex::vertcollide was removed

MuJoCo 3.7.0 removed the upstream mjsFlex::vertcollide field. The corresponding MjsFlex::vertcollide getter/setter methods are therefore no longer available in MuJoCo-rs 4.0.0.

Before (3.x):

flex.set_vertcollide(true);

After (4.0.0):

// Remove vertcollide access -- the upstream field no longer exists.
let _ = flex;

MjData::model_mut is now unsafe

To prevent a false sense of correctness, method MjData::model_mut is now marked unsafe to prevent users from direct swapping models to incompatible ones.

Before (3.x):

data.model_mut().opt_mut().gravity[2] = half_gravity;
*data.model_mut() = new_model;

After (4.0.0):

// For field modification (safe accessors):
data.model_opt_mut().gravity[2] = half_gravity;

// For full model replacement:
let mut old_model = data.swap_model(new_model);

MjViewer::add_ui_callback closure now receives Box<MjModel>

The passive model stored inside the viewer changed from Arc<MjModel> to Box<MjModel>. The closure passed to MjViewer::add_ui_callback now receives &mut MjData<Box<MjModel>>.

Before (3.x):

viewer.add_ui_callback(|ctx, data: &mut MjData<Arc<MjModel>>| { ... });

After (4.0.0):

viewer.add_ui_callback(|ctx, data: &mut MjData<Box<MjModel>>| { ... });

Type annotations in closure parameters must be updated if present. Closures without an explicit type annotation require no change.

Migrating to 3.0.0

Version 3.0.0 updates MuJoCo to 3.6.0, introduces typed error enums, tightens safety requirements, and removes deprecated APIs.

MuJoCo upgrade

MuJoCo-rs 3.0.0 links against MuJoCo 3.6.0. Download the matching release and update your library path. See Installation for details.

Default features changed

The viewer, viewer-ui, renderer, and renderer-winit-fallback features are no longer enabled by default. If your project relies on the viewer or renderer, add the features explicitly in Cargo.toml:

[dependencies]
mujoco-rs = { version = "3", features = ["viewer-ui", "renderer-winit-fallback"] }

Error handling

The most significant change in 3.0.0 is replacing io::Error and bare return types with typed error enums.

New error types

Six new types in error (all #[non_exhaustive], re-exported from the prelude):

RendererError and MjViewerError gained new variants.

Updating error handling code

Replace io::ErrorKind matches with the specific error enum variants.

Before (2.x):

use std::io;

match model.save_last_xml("out.xml") {
    Ok(()) => {}
    Err(e) if e.kind() == io::ErrorKind::InvalidInput => {
        eprintln!("Invalid path: {e}");
    }
    Err(e) => eprintln!("Save failed: {e}"),
}

After (3.0.0):

use mujoco_rs::prelude::*;

match model.save_last_xml("out.xml") {
    Ok(()) => {}
    Err(MjModelError::SaveFailed(msg)) => eprintln!("Save failed: {msg}"),
    Err(e) => eprintln!("Other error: {e}"),
}

The table below maps the breaking error-type changes:

Type / methods

2.x error type

3.0.0 error type

MjModel: from_xml, from_xml_vfs, from_xml_string, from_buffer, save_last_xml, print, print_formatted

io::Error / NulError

MjModelError

MjVfs: add_from_buffer, delete_file

io::Error

MjVfsError

MjSpec: from_xml, from_xml_vfs, from_xml_string, compile, save_xml, save_xml_string, add_default (now try_add_default)

io::Error

MjEditError

MjData: add_contact

io::Error

MjDataError

MjRenderer: save_rgb, save_depth, save_depth_raw

io::Error

RendererError

MjViewer: render

()

Result<(), MjViewerError>

MjRenderer::rgb / MjRenderer::depth no longer return Result

rgb and depth previously returned io::Result (2.x) / Result<_, RendererError> (intermediate 3.0 dev builds). They now panic on error and return the image directly. Use try_rgb / try_depth for fallible alternatives.

Before (2.x):

let pixels = renderer.rgb::<W, H>()?;
let depth  = renderer.depth::<W, H>()?;

After (3.0.0):

// Panicking (most callers)
let pixels = renderer.rgb::<W, H>();
let depth  = renderer.depth::<W, H>();

// Fallible
let pixels = renderer.try_rgb::<W, H>()?;
let depth  = renderer.try_depth::<W, H>()?;

Newly Result-returning methods

MjData methods now returning Result<_, MjDataError>:

constraint_update, print, print_formatted.

MjvScene / MjvGeom / MjrContext methods now returning Result<_, MjSceneError>:

set_label, add_aux, set_aux.

MjvScene: create_geom now panics on failure (was bare type in 2.x). Use try_create_geom for a fallible alternative.

Before:

let geom = scene.create_geom(MjtGeom::mjGEOM_BOX, None, None, None, None);

After (panicking):

let geom = scene.create_geom(MjtGeom::mjGEOM_BOX, None, None, None, None);

After (fallible):

let geom = scene.try_create_geom(MjtGeom::mjGEOM_BOX, None, None, None, None)?;

MjSpec: add_default now panics on failure (was Result in 2.x). Use try_add_default for a fallible alternative.

Before:

let def = spec.add_default("my_class", None)?;

After (panicking):

let def = spec.add_default("my_class", None);

After (fallible):

let def = spec.try_add_default("my_class", None)?;
MjRenderer methods now returning Result:

set_font_scale returns Result<(), RendererError>; with_font_scale returns Result<Self, RendererError>.

Before:

renderer.set_font_scale(MjtFontScale::mjFONTSCALE_150);
let renderer = renderer.with_font_scale(MjtFontScale::mjFONTSCALE_150);

After:

renderer.set_font_scale(MjtFontScale::mjFONTSCALE_150)?;
let renderer = renderer.with_font_scale(MjtFontScale::mjFONTSCALE_150)?;

Model-editing API changes

set_name now returns Result<(), MjEditError> instead of (). with_name still returns &mut Self but now panics on duplicate names.

SpecItem is now a sealed trait. External implementations are no longer permitted – remove any impl SpecItem for MyType blocks from downstream code.

MjsOrientation::switch_quat no longer has a generic type parameter. Remove turbofish syntax and call it directly.

Before (2.x):

item.set_name("arm_joint");
item.with_name("arm_joint");
orientation.switch_quat::<[f64; 4]>();

After (3.0.0):

item.set_name("arm_joint")?;
item.with_name("arm_joint");
orientation.switch_quat();

MjModel::clone()

MjModel now implements the standard Clone trait. The previous clone() returning Option<MjModel> has been removed.

Before (2.x):

let model_copy = model.clone().expect("clone failed");

After (3.0.0):

let model_copy = model.clone();         // panics on failure
let model_copy = model.try_clone()?;    // fallible

MjModel::save() split

MjModel::save(filename: Option<&str>, buffer: Option<&mut [u8]>) has been replaced by two dedicated methods:

Before (2.x):

model.save(Some("model.mjb"), None);

let mut buffer = vec![0u8; model.size() as usize];
model.save(None, Some(&mut buffer));

After (3.0.0):

model.save_to_file("model.mjb")?;

let mut buffer = vec![0u8; model.size()];
model.save_to_buffer(&mut buffer)?;

Mutable accessor restrictions

*_mut() methods on MjModel, MjData, and MjvScene array fields are now unsafe fn where unrestricted writes can corrupt MuJoCo’s internal state. Two categories of fields are affected.

Structural invariants

Topology, address, and engine-computed arrays that must not be changed at runtime.

  • MjModel: 199 fields — every address array (*adr, *num), topology index (*bodyid, *jntid, …), and physics-invariant.

  • MjData: 43 fields — contact, efc_id, efc_J_*, efc_AR_*, efc_island, iefc_id, iefc_J_*, tree_island, dof_island, tendon_efcadr, island_*, map_*, iM_*, ten_wrapadr, ten_wrapnum, moment_*, body_awake_ind, parent_awake_ind, dof_awake_ind.

  • MjvScene: 12 fields — flexedge, geoms, flexedgeadr, flexedgenum, flexvertadr, flexvertnum, flexfaceadr, flexfacenum, flexfaceused, skinfacenum, skinvertadr, skinvertnum.

Before (2.x):

model.body_parentid_mut()[0] = new_parent;

After (3.0.0):

// SAFETY: caller keeps all companion topology fields consistent.
unsafe { model.body_parentid_mut()[0] = new_parent; }

Companion-index fields

Type/mode fields whose values control which array a companion index (*id, *adr) indexes into. Writing an inconsistent value causes out-of-bounds access inside MuJoCo.

  • MjModel: jnt_type, actuator_trntype, actuator_dyntype, eq_type, eq_objtype, wrap_type, wrap_prm, sensor_type, sensor_objtype, sensor_reftype, skin_matid, tendon_matid, tendon_treeid, body_plugin, actuator_plugin, geom_plugin, sensor_plugin.

  • MjData: efc_type, iefc_type, tree_asleep, wrap_obj.

Before (2.x):

model.jnt_type_mut()[i] = MjtJoint::mjJNT_BALL;

After (3.0.0):

// SAFETY: companion fields (jnt_qposadr, jnt_dofadr, jnt_bodyid) are also
// updated to a state consistent with mjJNT_BALL.
unsafe { model.jnt_type_mut()[i] = MjtJoint::mjJNT_BALL; }

Per-object ViewMut types expose these fields as PointerViewUnsafeMut struct fields. Reading is safe; mutation requires as_mut_slice inside unsafe:

View type

Fields requiring unsafe

MjJointModelViewMut

r#type

MjActuatorModelViewMut

trntype, dyntype

MjEqualityModelViewMut

r#type, objtype

MjSensorModelViewMut

r#type, objtype, reftype

MjSkinModelViewMut

matid

MjTendonModelViewMut

matid, treeid

Before (2.x):

view.r#type[0] = MjtJoint::mjJNT_BALL;

After (3.0.0):

// SAFETY: companion fields are also updated consistently.
unsafe { view.r#type.as_mut_slice()[0] = MjtJoint::mjJNT_BALL; }

Null-terminated string buffer fields

Concatenated c_char arrays where each object’s name or attribute is null-terminated. Overwriting a '\0' byte allows MuJoCo’s C string functions (and CStr::from_ptr inside wrappers such as id_to_name) to scan past the buffer boundary, causing undefined behavior.

  • MjModel: names, plugin_attr, text_data, paths.

Before (2.x):

// Write a replacement name directly (unsafe in 3.0.0).
let buf = model.names_mut();
buf[0] = b'X' as c_char;

After (3.0.0):

// SAFETY: all '\0' terminators within the buffer are preserved; no byte
// at offset nnames-1 (the final terminator) is overwritten.
let buf = unsafe { model.names_mut() };
buf[0] = b'X' as c_char;

MjData::print() and MjModel::print()

Both print and print_formatted now accept AsRef<Path> for the filename and return Result with the appropriate error type.

Before (2.x):

data.print("data.txt");
model.print("model.txt")?;

After (3.0.0):

data.print("data.txt")?;
model.print("model.txt")?;

Type changes

  • MjModel: size() returns usize (was i32).

  • MjModel: state_size() returns usize (was i32).

  • MjModel: name_to_id() returns Option<usize> (was i32; -1 is now None).

  • MjCameraModelView / MjCameraModelViewMut: projection (MjtProjection) replaces the old boolean orthographic field.

  • MjModel: tuple_objtype() returns &[MjtObj] (was &[i32]).

  • MjModel: id_to_name: id takes usize (was i32).

  • MjData: maxuse_threadstack() returns &[MjtSize; mjMAXTHREAD] (was &[MjtSize]).

  • MjData: jac, jac_body, jac_body_com, jac_subtree_com, jac_geom, jac_site, angmom_mat, object_velocity, object_acceleration, geom_distance, local_to_global: index parameters now take usize (was i32). Add as usize at call sites.

  • MjData: ray and multi_ray: bodyexclude changed from i32 (-1 = no exclusion) to Option<usize> (None = no exclusion). Replace -1 with None and body_id with Some(body_id as usize).

  • MjData: ray() returns (Option<usize>, MjtNum) (was (i32, MjtNum)). None means no intersection (previously -1).

  • MjData: try_multi_ray() returns Result<(Vec<Option<usize>>, Vec<MjtNum>), ...> (was (Vec<i32>, Vec<MjtNum>)). multi_ray() panics on error. Each None element means no intersection for that ray (previously -1).

  • MjsTendon: limited and actfrclimited are now MjtLimited tri-state (was bool).

  • MjvCamera: new_fixed, new_tracking, track, fix now take usize (was u32). Remove as u32 casts at call sites.

  • TryFrom<i32> for MjtCamera now uses MjSceneError as its error type (was ()). Replace Err(()) matches with Err(MjSceneError::InvalidCameraType(_)).

  • SceneSelection: body_id, geom_id, flex_id, skin_id are now Option<usize> (was i32). Replace sel.body_id >= 0 with sel.body_id.is_some() or if let Some(id) = sel.body_id.

  • MjData: runge_kutta() now takes n: u32 (was i32). Replace data.runge_kutta(n) with data.runge_kutta(n as u32) at call sites. The function also now panics if n < 1 (previously passed negative or zero values silently to MuJoCo C).

  • MjsTexture::set_data now requires T: bytemuck::NoUninit (add bytemuck to your dependencies if you call this method with a custom type, and derive or implement NoUninit for it).

  • MjTendonDataInfo: J_rownnz, J_rowadr, and J_colind are no longer exposed. These fields moved to mjModel in MuJoCo 3.6.0 and are now accessible via MjTendonModelInfo (i.e. model.tendon()).

    // Before (2.x) -- accessed through data
    let view = data.tendon(0).view(&data);
    let nnz = view.J_rownnz[0];
    
    // After (3.0.0) -- accessed through model
    let view = model.tendon(0).view(&model);
    let nnz = view.J_rownnz[0];
    

MjCameraModelView::projection replaces orthographic

MuJoCo 3.6.0 replaced the old cam_orthographic flag with the cam_projection enum. The Rust camera model views mirror that upstream change, so code that previously read view.orthographic[0] must now read view.projection[0] and compare it against MjtProjection.

Before (2.x):

let view = model.camera("main").unwrap().view(&model);
let is_ortho = view.orthographic[0];

After (3.0.0):

let view = model.camera("main").unwrap().view(&model);
let is_ortho = view.projection[0] == MjtProjection::mjPROJ_ORTHOGRAPHIC;

MjData::reset_keyframe()

MjData::reset_keyframe now takes key: usize (was i32) and returns Result<(), MjDataError> instead of (). Out-of-range keys that previously silently fell back to a plain reset now return Err(MjDataError::IndexOutOfBounds).

Before (2.x):

data.reset_keyframe(0);          // silently ignored if key >= nkey

After (3.0.0):

data.reset_keyframe(0)?;         // returns Err if key >= nkey

Methods now return Result instead of panicking

The following methods previously returned () and panicked on invalid input. They now return Result directly.

2.x call

3.0.0 call

data.reset_keyframe(0)

data.reset_keyframe(0)?

data.set_state(&s, spec)

unsafe { data.set_state(&s, spec)? }

data.copy_visual_to(&mut dst)

data.copy_visual_to(&mut dst)?

data.copy_to(&mut dst)

data.copy_to(&mut dst)?

ctx.read_pixels(rgb, depth, &vp)

ctx.read_pixels(rgb, depth, &vp)?

fig.push(idx, x, y)

fig.push(idx, x, y)?

fig.set_at(idx, pt, x, y)

fig.set_at(idx, pt, x, y)?

Append .unwrap(), .expect(...) or ? at call sites.

Ray-casting parameter changes

MjData::ray gained a new normal_out parameter. The bodyexclude parameter changed from i32 (-1 = no exclusion) to Option<usize> (None = no exclusion). The return type changed from (i32, MjtNum) to (Option<usize>, MjtNum); the geom id is None when the ray misses all geometry (previously -1).

Before (2.x):

let (geom_id, dist) = data.ray(&pnt, &vec, None, true, -1);
if geom_id == -1 { /* miss */ }

After (3.0.0):

let (geom_id, dist) = data.ray(&pnt, &vec, None, true, None, None);
if geom_id.is_none() { /* miss */ }

Similarly, MjData::multi_ray gained normals_out, now panics on invalid input (use try_multi_ray for a fallible alternative), and its geom-id vector changed from Vec<i32> to Vec<Option<usize>>. Pass None for normals_out.

mju_ray_geom also gained normal_out: Option<&mut [MjtNum; 3]>.

Before (2.x):

let dist = mju_ray_geom(&pos, &mat, &size, &pnt, &vec, geomtype);

After (3.0.0):

let dist = mju_ray_geom(&pos, &mat, &size, &pnt, &vec, geomtype, None);

MjData::jac_subtree_com() parameter change

The jacp: bool parameter has been removed (the Jacobian is always computed).

Before (2.x):

let jac = data.jac_subtree_com(true, body_id);

After (3.0.0):

let jac = data.jac_subtree_com(body_id);

MjvPerturb::update_local_pos

MjvPerturb::update_local_pos now takes selection_xyz by reference (&[MjtNum; 3]) instead of by value.

Before (2.x):

perturb.update_local_pos(xyz, &data);

After (3.0.0):

perturb.update_local_pos(&xyz, &data);

MjvPerturb::move_

MjvPerturb::move_ no longer takes a separate model parameter (the model is obtained from data.model()), and data changed from &mut MjData<M> to &MjData<M>.

Before (2.x):

perturb.move_(&model, &mut data, action, dx, dy, &scene);

After (3.0.0):

perturb.move_(&data, action, dx, dy, &scene);

MjvPerturb::start

MjvPerturb::start no longer takes a separate model parameter (the model is obtained from data.model()), and scene changed from &MjvScene<M> to &MjvScene.

Before (2.x):

perturb.start(type_, &model, &mut data, &scene);

After (3.0.0):

perturb.start(type_, &mut data, &scene);

MjvPerturb::apply

MjvPerturb::apply no longer takes a separate model parameter (the model is obtained from data.model()).

Before (2.x):

perturb.apply(&model, &mut data);

After (3.0.0):

perturb.apply(&mut data);

find_selection() return type

MjvScene’s find_selection now returns a SceneSelection named struct instead of a 5-tuple.

Before (2.x):

let (body_id, geom_id, flex_id, skin_id, point) = scene.find_selection(&data, ...);

After (3.0.0):

let sel = scene.find_selection(&data, ...);
println!("{:?} {:?} {:?} {:?} {:?}", sel.body_id, sel.geom_id, sel.flex_id, sel.skin_id, sel.point);

Core Send/Sync bound tightening

MjData<M> now requires M: Send / M: Sync. MjvScene is no longer generic and derives Send + Sync unconditionally. MjViewerCpp::launch_passive now requires M: Send + Sync.

If you were using Rc<MjModel> in threaded code, switch to Arc<MjModel>.

New unsafe requirements

MjrContext::new is now unsafe fn. A valid OpenGL context must be current on the calling thread. In most cases you will not call this directly (MjRenderer and MjViewer call it internally). If you construct MjrContext manually:

Before (2.x):

let context = MjrContext::new(&model);

After (3.0.0):

// SAFETY: a valid GL context has been made current above.
let context = unsafe { MjrContext::new(&model) };

MjData::set_state is now unsafe and returns Result

set_state is now unsafe and returns Result<(), MjDataError> directly.

When spec includes mjSTATE_EQ_ACTIVE, MuJoCo writes raw f64 bytes into the eq_active byte array without booleanization, making a subsequent call to eq_active() undefined behavior. Re-validate by calling mj_forward / mj_step before reading eq_active().

Before (2.x):

data.set_state(&saved, MjtState::mjSTATE_FULLPHYSICS as u32);

After (3.0.0):

// SAFETY: state captured via state(); bools are valid (0 or 1).
unsafe { data.set_state(&saved, MjtState::mjSTATE_FULLPHYSICS as u32) }?;

API renames

Type

Old name

New name

MjModel

get_totalmass()

totalmass()

MjrContext

mjr_set_buffer()

set_buffer()

sync() deprecated

MjRenderer::sync is deprecated. Use MjRenderer::sync_data followed by MjRenderer::render instead. sync_data updates the scene without rendering, giving you the opportunity to insert custom logic (e.g. user-scene geoms) between the sync and render steps.

Before (2.x):

renderer.sync(&mut data);

After (3.0.0):

renderer.sync_data(&mut data)?;
renderer.render()?;

MjRenderer is no longer generic

MjRenderer and MjRendererBuilder are no longer generic over M. Remove the <M> type annotation from all usage sites. The Clone bound on M has also been dropped; any M: Deref<Target = MjModel> is now accepted.

Before (2.x):

let mut renderer: MjRenderer<Arc<MjModel>> = MjRenderer::builder()
    .build(model.clone())
    .expect("failed to initialize renderer");
renderer.sync(&mut data);

After (3.0.0):

let mut renderer: MjRenderer = MjRenderer::builder()
    .build(model.clone())
    .expect("failed to initialize renderer");
renderer.sync_data(&mut data).unwrap();
renderer.render().unwrap();

In practice the type annotation is rarely needed, so in most cases only the MjRenderer<Arc<MjModel>> annotation at the variable declaration (or in a struct field) needs to be updated.

MjvScene is no longer generic

MjvScene is no longer generic over M. Remove the <M> type annotation from all usage sites.

Before (2.x):

let scene: MjvScene<Arc<MjModel>> = MjvScene::new(model.clone(), 1000);
scene.update(&mut data, &opt, &perturb, &mut cam);

After (3.0.0):

let mut scene: MjvScene = MjvScene::new(model.clone(), 1000);
scene.update(&mut data, &opt, &perturb, &mut cam);

Additional details:

  • MjvScene::update, MjvScene::update_with_catmask, and MjvScene::find_selection retain <M: Deref<Target = MjModel>> as method-level generics; call sites are unchanged.

  • MjvPerturb::start / move_ and MjvCamera::move_ now take &MjvScene without a type parameter.

  • vis_common::sync_geoms is now non-generic.

  • MjvScene derives Send + Sync unconditionally (no bound on M required).

MjViewer, MjViewerBuilder, ViewerSharedState are no longer generic

MjViewer, MjViewerBuilder, and ViewerSharedState are no longer generic over M. Remove the <M> type parameter from all usage sites.

Before (2.x):

let viewer: MjViewer<Arc<MjModel>> = MjViewer::builder()
    .build_passive(model.clone())
    .expect("failed to start viewer");

viewer.add_ui_callback(|ctx, data: &mut MjData<Arc<MjModel>>| { ... });

After (3.0.0):

let mut viewer: MjViewer = MjViewer::builder()
    .build_passive(model.clone())
    .expect("failed to start viewer");

viewer.add_ui_callback(|ctx, data: &mut MjData<Arc<MjModel>>| { ... });

Additional details:

  • sync_data, sync_data_full, and build_passive retain <M: Deref<Target = MjModel>> as method-level generics; call sites are unchanged.

  • The closure passed to MjViewer::add_ui_callback now receives &mut MjData<Arc<MjModel>> instead of &mut MjData<M>. If your closure used a different M, update the type annotation to &mut MjData<Arc<MjModel>>.

C++ viewer changes

cpp_viewer::MjViewerCpp is no longer generic; the type parameter M has been removed from the struct. MjViewerCpp::launch_passive now requires M: Send + Sync. Switch to Arc<MjModel> if using Rc<MjModel>.

MjViewerCpp::render is now unsafe fn, must be called from the main thread, no longer accepts update_timer, and returns Result<(), &'static str>.

MjViewerCpp::__raw() has been removed (no replacement).

Before (2.x):

viewer.render(true);

After (3.0.0):

// SAFETY: called from the main thread.
unsafe { viewer.render().unwrap() };

MjViewerCpp::launch_passive() is now unsafe

Callers must ensure the model and data remain alive and at a stable address.

Before (2.x):

let viewer = MjViewerCpp::launch_passive(&model, &data, 100);

After (3.0.0):

// SAFETY: model and data kept alive and at a stable address.
let viewer = unsafe { MjViewerCpp::launch_passive(&model, &data, 100) };

MjsTendon::wrap / wrap_mut no longer return Option

MjsTendon::wrap and MjsTendon::wrap_mut now return &MjsWrap / &mut MjsWrap instead of Option<&MjsWrap> / Option<&mut MjsWrap>, and panic when the index is out of bounds. Drop the .unwrap() and make sure the index is < wrap_num(), or use the new fallible try_wrap / try_wrap_mut.

Before:

let wrap = tendon.wrap(1).unwrap();

After:

let wrap = tendon.wrap(1);
// or, fallibly:
let wrap = tendon.try_wrap(1)?;

Removed deprecated methods

Removed

Replacement

MjData::warning_stats

MjData::warning

MjData::timer_stats

MjData::timer

Type aliases MjJointInfo, MjJointView, MjJointViewMut, MjGeomInfo, MjGeomView, MjGeomViewMut, MjActuatorInfo, MjActuatorView, MjActuatorViewMut

Use the MjJointData*, MjGeomData*, MjActuatorData* types.

MjJointDataViewMut::reset

MjJointDataViewMut::zero

MjModel::name2id

MjModel::name_to_id

MjvCamera::new

MjvCamera::new_free, new_fixed, new_tracking, or new_user

MjViewer::user_scene, user_scene_mut, user_scn, user_scn_mut

ViewerSharedState::user_scene / user_scene_mut via MjViewer::state

MjViewer::sync

MjViewer::sync_data then MjViewer::render

MjvFigure figure()

draw()

get_mujoco_version

~~mujoco_rs::mujoco_version

MjData::get_state

MjData::state

MjvFigure::new

MjvFigure::new_boxed