Architecture & porting playbook¶
This is the source of truth for how roqsim is built and how to rework existing MuJoCo code into it. It documents both what exists today and designs that are planned / not yet implemented (so marked). A large external MuJoCo codebase is expected to be reworked into this structure — the Porting playbook (section 6) is the section to read for that.
Status legend: [planned] means designed but not yet built. Everything else is implemented.
1. Overview & goals¶
roqsim is a lightweight, plugin-driven MuJoCo simulator for mobile robots, robot arms, and mobile manipulators (plus extras like conveyor belts). A MuJoCo step loop plus plugins that hook into well-defined lifecycle points, all loaded and configured from a single YAML file.
Two ways to run, one engine:
Standalone driver (
runner.py) — owns the loop; windowed by default, headless for containers; real-time / factor / as-fast-as-possible pacing; can record world state over time.scenario-execution driver (
scenario_adapter.py) — aSimulationInterfacesubclass; scenario-execution owns the loop and callsdt/setup/reset/step/shutdown. It also publishescontext, the seam an in-process scenario action reads (§12, Reaching a plugin from outside).
Both wrap the same Engine, so behaviour is identical across the two.
Tick pipeline (one step()):
external threads ──post(cmd)──▶ [command queue]
│ (drained on physics thread)
physics thread: drain ─▶ pre_step(all) ─▶ mj_step ─▶ post_step(all) ─▶ snapshot
(write ctrl) (physics) (read/publish) (cross-thread reads)
Non-goals: roqsim is not a general game engine or a physics fork; it orchestrates MuJoCo. It does not recompile the model at runtime (see anti-patterns).
2. Lifecycle reference¶
A plugin (roqsim.plugin.Plugin) implements any subset of six hooks. The engine only calls hooks a plugin actually overrides (an un-overridden hook costs nothing and is absent from the timing table).
Hook |
When |
May touch |
Typical use |
|---|---|---|---|
|
once, pre-compile |
|
add bodies/geoms/sensors/assets |
|
once, post-compile |
|
resolve body/site ids, open resources, advertise services, register |
|
every reset, after |
|
re-home arm, respawn objects |
|
each tick, before |
|
apply commands |
|
each tick, after |
|
publish, record |
|
once, teardown (reverse order) |
— |
close nodes, flush files |
Full-run sequence:
setup(): build(p0)…build(pN) → spec.compile() → MjData → configure(p0)…configure(pN)
reset(): drain → mj_resetData → mj_forward → on_reset(p0)…on_reset(pN) → mj_forward → gates.reset()
step(): drain → pre_step(p0…pN) → mj_step → post_step(p0…pN) → publish_snapshot
shutdown(): shutdown(pN)…shutdown(p0) (best-effort; a failure is logged, others still run)
Ordering rule: within a hook, plugins run in YAML order; shutdown runs in reverse. Cross-plugin dependencies are expressed by ordering + the blackboard, never by importing another plugin.
Reference implementation: roqsim/src/roqsim/engine.py.
3. API contracts¶
Plugin (plugin.py)¶
__init__(self, config: dict | None, *, name: str | None)— receives its YAMLconfig:section.validate_config(self, config) -> list[str]— return error strings (empty = valid).expand(cls, spec, world, base_dir) -> list[PluginSpec](classmethod, optional) — extra specs to splice in right after this one at config load; used by spawn plugins to pull in a model’s manifest (see §4). What it returns is expanded in turn. A plugin lists the config keysexpandreads inexpansion_keys, so a too-late override of one is refused. Default: none.Hooks as in §2.
parallel_safe: boolmarks a read-onlypost_stepfor the future parallel executor.transport_only: boolmarks a plugin that builds no geometry and holds no state (BridgeBaseand its subclasses), so the scene-only consumers —roqsim render, the review window, the exporters — drop it and can therefore build a*_rosworld without its middleware installed;roqsim simkeeps it unless asked for--no-communication, which warns that the run then publishes and receives nothing (see Available plugins › Transport plugins).
SimContext (context.py)¶
Passed to every hook. Key members:
spec(build phase),model,data,dt,sim_time.config— the full parsed YAML dict.blackboard: Blackboard—set/get/require/__contains__; typed cross-plugin store.entities: EntityRegistry—add/remove/get/names/allofEntity(name, kind, body, meta); backssimulation_interfacesdiscovery.render— aRenderService(lazily set; see §8). [planned]post(cmd)/drain_commands()— the thread-safe command queue (§7).publish_snapshot(d)/read_snapshot()— immutable snapshot for cross-thread readers.register_gate(name, role)/gates()— step-gate API for synchronous mode (§10); inert now.
RobotHandle (context.py)¶
RobotHandle(name, drive(vx,vy,w), read_odom()->(x,y,yaw,vx,vy,w)). A controller plugin puts one on the blackboard (key convention robot:<name>); a bridge looks it up. Both callables run on the physics thread.
Registry (registry.py)¶
resolve_plugin(ref, base_dir) -> type[Plugin]. See §4.
Config (config.py)¶
load_config(path) / load_config_from_dict(raw, base_dir) → SimConfig. instantiate_plugins(cfg) -> list[Plugin] resolves classes, expands manifests (Plugin.expand; see §4), constructs, and runs aggregated validation.
4. Config & registry¶
Single world YAML, two sections; plugins order = execution order:
sim:
timestep: 0.004 # optional; else from the model
pacing: realtime # realtime | {factor: 4.0} | asap [planned: honoured by runner]
world: empty_room # built-in name OR a path to an MJCF file; default empty_room (see below)
integrator: implicitfast # euler | rk4 | implicit | implicitfast
noslip_iterations: 10 # solver effort; see "Solver options" below
sync: {enabled: false} # foreseen lockstep mode (§10); inert
components:
- floorplan: # (1) entry-point short name -- the ref *is* the key
mesh: envs/x.stl
collision: true
name: ground # optional instance name (reserved sibling key)
- "my_pkg.mod:MyPlugin": { ... } # (2) importable module:Class (PYTHONPATH)
- "./plugins/x.py:Foo": { ... } # (3) file path:Class (relative to this YAML)
Each entry is a mapping with exactly one plugin-ref key (its value is the config map) plus an
optional reserved name: sibling, defaulting to the ref. name: or components: found inside
the config map is refused (parse_plugin_entry), because no plugin reads either from its config.
Three plugin-ref resolution forms (resolve_plugin):
Short name →
roqsim.pluginsentry-point group.``module.path:Class`` →
importlib.import_module(any package onPYTHONPATH).``path/to/file.py:Class`` →
importlib.util.spec_from_file_location(relative to the config dir).
Forms 2 and 3 contain a colon, and the ref is the entry’s key, so quote it
(- "my_pkg.mod:MyPlugin": {...}) — unquoted it parses only while no space follows the colon, so a
stray key: value space would silently truncate the ref. Short names have no colon and need no quotes.
Order: no : → must be an entry-point (else error). Has : → file if the left side ends in .py or exists on disk, else module. Every failure raises PluginError naming the attempted form.
Validation is delegated to each plugin (validate_config); instantiate_plugins aggregates all errors across all plugins and raises once, namespaced [name (ref)] message.
World definition (sim.world)¶
The static environment the robots/props stand in — ground + lighting — is a world definition, chosen with the sim.world key (roqsim/world.py). It is separate from robot-family scene plugins so a fixed cell (an arm, a conveyor) never has to depend on the mobile package for a floor. Semantics:
sim.worldis either a built-in world name or a path to an MJCF file. The only built-in isempty_room(a checker ground plane namedfloor, a ceiling light, and four perimeter walls — a bounded, lit room); unset ⇒empty_room. A value that ends in.xml/.mjcfor contains a path separator is loaded as the base scene (MjSpec.from_file, resolved relative to the world YAML) — e.g. a baked scene likedepot/depot.xml(seeroqsim_scenes). Anything that is neither the built-in nor a resolvable file is a fail-fast error.The engine builds/loads the world into the
MjSpecbefore any pluginbuild, so plugins attach onto it.A scene plugin that builds its own ground+lighting sets the class attribute
provides_world = True(the mobilefloorplan, which also adds lidar walls). When such a plugin is present the engine skips the world definition; ifsim.worldwas also set explicitly the engine logs a warning and lets the plugin win. Sofloorplanis the mobile scene,sim.worldis the fixed-cell default, and they never double up the floor.
Policy specs (roqsim.policy)¶
A policy-driven robot needs its observation assembled in exactly the layout its checkpoint was trained
on, and getting that wrong does not raise – the robot twitches. Three plugins (g1_locomotion,
oli_locomotion, spot_locomotion) each hand-assemble that vector, against two mutually
incompatible config schemas (g1.yaml flat, oli/walk_param.yaml nested), so a fourth policy would
mean a third bespoke reader.
roqsim.policy makes the layout data: a PolicySpec YAML beside the checkpoint lists the observation
terms in order, the actuated joints, the joints that are observed but not commanded, the control gains,
and the envelope the policy was trained for. PolicySpec.build_observation is then the only thing that
assembles an observation.
It sits in core rather than a robot-family package because every family needs it, and it costs core
nothing: it describes checkpoints and never loads them, so it imports only numpy and yaml.
Checkpoint loading stays in the family plugins, which is where torch/onnxruntime belong.
Generality is measured, not asserted. The format is tested against the two policies that already ship – the G1’s 47-dim walk observation (with its gait phase) and Spot’s 48-dim Isaac observation (with base linear velocity, which the humanoids do not use) – by rebuilding each from a spec and comparing element-wise with the plugin’s own builder. Neither plugin is migrated: they work and are covered, and swapping a live observation builder would risk a silent regression for no present gain.
Those tests also show that Unitree’s get_gravity_orientation and Isaac
Lab’s quat_rotate_inverse(q, [0,0,-1]) are bit-identical (max difference 0 over 500 random
quaternions), so one projected_gravity term serves a humanoid and a quadruped alike.
Solver options (sim.solver and friends)¶
sim carries five optional passthroughs to MuJoCo’s <option>: solver (newton/cg/pgs), iterations, ls_iterations, noslip_iterations and impratio. Each is left at MuJoCo’s default unless a world sets it, because the right value is a property of the experiment, not of the framework: a navigation world wants the cheapest solve that keeps wheels stable, a manipulation world needs contacts that hold.
A grasping world must set ``noslip_iterations``. MuJoCo defaults it to 0, which leaves friction contacts a residual tangential drift. Measured on the G1/Dex1 pick: a 0.5 kg parcel gripped at 20 N between two pads crept out of the jaws at 0.119 m/s and was dropped within two seconds; with noslip_iterations: 10 the creep is 0.0009 m/s and the lift holds indefinitely — a 137× reduction. iterations and ls_iterations alone changed nothing measurable, because this is the solver’s dedicated slip-removal pass rather than general convergence. The failure mode is worth knowing because it presents as insufficient friction and is not: sweeping the sliding coefficient from 0.4 to 3.0 moved the creep rate by 17%, while halving the payload moved it by 80×.
Contact overrides (sim.contact_override, sim.cone, sim.gravity)¶
Three more <option> passthroughs, added for contact-rich worlds where the contact is the
measurement rather than an incidental.
Note
sim.density, sim.viscosity and sim.wind join these passthroughs, added for aerial
worlds. MuJoCo defaults the first two to 0 – a vacuum – which is right for a ground robot
and wrong for anything that flies: a quadrotor still hovers there, but nothing damps it, so a
lateral step rings forever and reads as badly tuned gains rather than as missing air. Before they
existed an aerial model had no honest option but to pin <option> itself, and thereby
reconfigure every world it was spawned into. wind is inert without a medium, since MuJoCo
feeds it into the drag terms.
sim.contact_override sets MuJoCo’s global o_solref / o_solimp / o_friction, which
replace every contact’s own parameters:
sim:
cone: elliptic # pyramidal (MuJoCo's default) | elliptic
gravity: [0.0, 0.0, 0.0]
contact_override:
solref: [0.02, 1.0] # (timeconst, dampratio)
solimp: [0.9, 0.95, 0.001, 0.5, 2.0] # (dmin, dmax, width, midpoint, power)
friction: [0.3, 0.005, 0.0001] # (slide1, slide2, spin, roll1, roll2)
They are here for fidelity first. A published model that enables MuJoCo’s global override has to
be reproducible as published, and these three are the values such a model states – and often the ones
it randomizes, since the flag makes them the only contact parameters in play. One corpus
reconstruction turns on exactly that, and its spec records the flag as required for the three to
have any effect at all. That a sweep over them is then an ordinary experiment factor – needing no
bespoke plugin and no hand-edited MJCF per cell, the same reason spawn_model’s
mass/friction exist – is the second reason rather than the first.
Global, and before compile; both halves are load-bearing. Per-geom solref/solimp stay in
the model. A change aimed at named geoms, during a run, is the model_override plugin (§9.2):
this key cannot be aimed – it replaces every contact’s parameters, including the ones a model tuned
– and cannot move once the model is built.
Setting any of the three enables MuJoCo’s override flag, and that coupling is why they are
one key rather than three. Without the flag MuJoCo ignores all three silently: the model compiles,
runs, and quietly uses the untouched defaults, so a world that sets o_solref and observes no
effect reads as a solver that does not respond to tuning. Nothing in the resulting model shows the
missing flag.
A short vector is padded from MuJoCo’s current value rather than zero-filled, so
{solref: [0.05]} varies the contact time constant and leaves the damping ratio alone – which is
what sweeping one element means. Unknown keys and over-long vectors are rejected at config load, not
at compile: a typo here is otherwise invisible.
The contact time constant has a floor at 2 * sim.timestep, and it is the step that moves it.
MuJoCo clamps a smaller one there and reports nothing, so at the default 2 ms step a world asking for
0.5 ms, 1 ms or 2 ms gets 4 ms and a contact bit-identical in all three – while its own configuration
still reads 0.5 ms. A time constant below the floor is therefore refused, naming the floor that
applies, because tightening a fit is exactly the reason to reach for this key and a silent clamp
turns the attempt into a wrong conclusion about the solver. To go tighter, lower sim.timestep:
halving it halves the floor and roughly doubles the wall time. Only the positive form is a time
constant – a negative solref is MuJoCo’s direct (-stiffness, -damping) parameterisation, to
which no floor applies.
Worth knowing before tuning for penetration: the floor is not usually what limits a fit. A 5 kg mass resting on a plate at the shipped defaults penetrates on the order of nanometres, four orders below the 0.1 mm a tight assembly cares about. Where a peg sits proud of its bore, measure before reaching for the solver – an arm’s own contact stiffness, and the servo behind it, are the more likely cause.
sim.cone selects the friction cone. Pyramidal is MuJoCo’s default and cheaper; elliptic is the
physically correct one and matters when the tangential force is the measurement – an insertion
benchmark reads F_x/F_y directly, and a pyramidal cone quantises their direction to the pyramid’s
facets.
sim.gravity is a world key rather than a model property because a fixed-base contact experiment
often runs at zero g deliberately: a simulated force-torque sensor is not tared, so with gravity on
every wrench sample carries a constant tool-weight bias that a force-energy metric is dominated by.
Ending a run from a plugin (ctx.request_stop)¶
A trial that knows it is finished – the goal was reached, the episode failed, the recording is
complete – can say so with ctx.request_stop(reason). The standalone driver polls
ctx.stop_requested and leaves its loop cleanly, so shutdown still runs and files still flush.
Without it a world is padded out to a wall-clock --seconds, guessed high enough for the slowest
cell and then wasted on every faster one. It is a request: the engine does not
act on it, so an embedding driver (scenario-execution, a test harness) may ignore it and keep
stepping. Physics-thread only, like every other write on SimContext; the first reason wins.
Actuator overrides (actuators:)¶
A model ships one set of actuator gains, and they are that model’s own sizing — the ur5e’s servo is softer than the ur10e’s because it is a 5 kg-payload arm. An experiment reproducing a published controller needs its gains, which are a property of the experiment rather than of the robot. Saying so by editing the shared MJCF would leak: every other world that spawns the model silently inherits the edit, and the value that ran is recorded nowhere. actuators: on a spawn plugin (spawn_arm, spawn_robot) states the law and the gains instead, and roqsim/actuators.py rewrites the model’s own actuators to match.
The vocabulary is the robot’s, not MuJoCo’s: control names a ros2_control command interface and the gains are the ones a real controller’s yaml carries, because a port transcribing a paper reads its numbers out of a controller config and a key it has to translate is a key it can get wrong. effort_limit is URDF’s <limit effort=>, which export_urdf already emits from actuator_forcerange.
- spawn_arm:
model: ur5e
actuators:
control: impedance # applies to every actuator this model declares
stiffness: 2.0 # N*m/rad
damping: 0.02 # N*m*s/rad
each: # per-actuator, on top of the shared keys
wrist_3: {control: position, p: 2000, d: 500}
Four laws:
|
commands |
gains |
compiles to |
|---|---|---|---|
|
joint position |
|
|
|
joint velocity |
|
|
|
joint torque |
— |
|
|
joint position |
|
the affine form above, its gains named as a compliance controller states them |
impedance is not a second spelling of position: it states the joint the way a compliance controller is specified (Franka’s joint_impedance, a UR in force mode), so a paper that gives a stiffness is transcribed without first converting it into somebody’s servo gain.
A modelled drive carries its own weight. A position or impedance joint names hardware whose own loop supplies whatever torque the mass below it demands — a UR5e commanded to a pose holds it — so the bodies it holds up get body_gravcomp. The gain then says how hard the joint resists a disturbance, which is what a gain means on real hardware, rather than doubling as a load rating. Without it the servo trades position error for holding torque and the arm settles below where it was sent: measured on the ur5e at its shipped 2000 N·m/rad, 9 mm at the flange, and metres of arc at a compliance gain. Nothing reports that — the pose is wrong, the model compiles, the run succeeds — which is why it is the default rather than an option.
Which bodies, and why not all of them. A body is compensated when the chain from the world down to it passes such a joint — everything whose weight a drive is holding up. That deliberately excludes the load path to the ground: a mobile base hangs off nothing, and its wheels are driven by velocity, so neither is compensated and the robot still presses on the floor. Compensate those and it stands on nothing — upright, so nothing says so. The arm bolted to that base is compensated, which is what makes spawn_arm and spawn_robot give the same Panda arm the same physics. velocity is excluded for that reason alone: it is how a wheel is driven, and no bundled arm ships it.
effort is the other exception: a torque-commanded joint applies the torque it is handed, and supplying the gravity term is the controller’s job — frequently the very thing an experiment is comparing. A drive that really has no gravity term (a hobby servo, a backdrivable joint) is a real machine too; spawn_arm’s gravity_compensation: states either case explicitly, and true there means the whole mechanism, for a controller handed its own gravity term.
A compensated joint still reports its torque. body_gravcomp supplies the holding term outside the actuator, so qfrc_actuator alone would show a motor doing nothing while the arm hangs off it. arm_controller’s effort report adds qfrc_gravcomp back, which is what the joint physically carries and what a real drive’s torque sensor reads — it matches an uncompensated arm’s reading in the same pose. In a world at zero gravity every one of these coincides, and a reader should not conclude the mode did nothing.
Where each half is applied, and why they differ. The actuator rewrite runs on the child MjSpec right after apply_assets and before an end effector is grafted on: actuators: names the actuators this model declares, and the graft puts a gripper’s tendon actuator into the same spec, so a shared control: resolved after it would fall on a tendon — which has no joint stiffness — and refuse a block whose gains were only ever about the arm. The gravity-compensation half runs after the graft, for the opposite reason: body_gravcomp is per body and does not cascade, so an arm compensated before its tool was attached would sag by exactly the tool’s weight. Compensating the tool is also the right physics — a real controller is told its payload and holds that too. Both run before spec.attach, so the whole thing is pre-compile and the file on disk is never touched; a world that declares nothing compiles a byte-identical model.
Refusals name their replacement, the way motion: does: MuJoCo’s own spellings (kp, kv, kd, forcerange) and its actuator types (motor, pd) are refused rather than translated, because these plugins take config maps they do not fully own and a key merely not read would be accepted in silence. Keys under each: are actuator names; a joint name is refused naming the actuator that drives it, rather than resolved silently, so a reader of someone else’s world can tell what a key is without opening the MJCF. roqsim catalog model <model> lists the names.
A shape error (an unknown key, a gain the chosen law does not read) is found by validate_config at roqsim check’s config stage, before anything is compiled. Anything needing the model — an unknown actuator, a joint name, a tendon, a missing ctrlrange — is raised in build() and lands at the build stage. That split is a contract rather than a detail: an external validator that checks a world before spending compute on it does so by compiling — roqsim scenes describe --entities is that call — so both kinds of mistake reach its author rather than a trial. ctrlrange is required only when the command unit changes (rad ↔ rad/s ↔ N·m); position → impedance keeps the unit, so the model’s own ctrlrange stays correct and is not refused.
The resolved table — every actuator, its final law and gains, and whether each value came from the model or the world — is published on SimContext.actuator_tables at configure and written into the run’s provenance beside world_model (capture.py). It carries every actuator rather than only the changed ones, because “what did this joint run under” is a question about the run and not about the diff. The addition needs no FORMAT_VERSION bump: Recording reads world_model by name and ignores keys it does not know.
Not part of this: spawn_arm’s rail: {kp, damping}. That parameterises a carriage drive roqsim synthesises, where actuators: overrides actuators a model declares.
Asset de-duplication (sim.dedup_assets)¶
spec.attach deep-copies a child model’s meshes, textures, and materials under a unique prefix, with no sharing — so a world that spawns N copies of one prop carries N copies of its assets, and spec.compile() parses every OBJ, decodes every PNG, and builds a convex hull for each. Between the build loop and compile the engine runs a de-duplication pass (roqsim/assets.py, on by default; sim.dedup_assets: false opts out) that merges byte-identical file-backed meshes and materials and drops the textures left unreferenced. It keys on the resolved absolute file path (sound because apply_assets already absolutized every ref) plus the attributes that change compiled output, and retargets references onto the survivor. It rewrites only the name-string references (geom.meshname, .material on geoms/sites/meshes/skins) that survive attach — a material’s texture-role vector is immutable once attached, so identical materials are merged rather than repointing textures directly. Builtin/procedural assets and non-2D textures (skyboxes, cube maps) are never touched. On the os world (240 identical trays) this takes compile from ~2.5 s to ~0.15 s and texture RAM from ~930 MB to ~60 MB; the --profile load report shows it as the dedup_assets phase.
Model plugin manifests (expand)¶
A model bundles the plugins intrinsic to it (a mobile base → diff_drive + lidar; an arm → arm_controller) in a <model>.manifest.yaml manifest next to its MJCF, so a world only spawns the model instead of re-declaring them. Mechanism:
Before instantiation,
expand_document(config.py) resolves each plugin class and calls itsexpand(spec, world, base_dir)classmethod, splicing the returned specs in immediately after the plugin that produced them (so a spawn’s controller/sensors land before a laterros2_bridgethat reads their endpoints).worldis every spec declared or injected so far; core does no dedupe of its own.Expansion is recursive and depth-first. Each returned spec is expanded in turn, so a robot manifest can mount a device model (
spawn_sensor) whose own manifest brings its capture plugin. Build order stays the document’s shape: robot, its first component, the mounted device, the device’s components, the robot’s next component.An injected spec whose owner has not been placed yet waits for that owner and follows it. This happens when a robot manifest nests an override under a device the world declares itself. A carrier therefore always builds before what it carries.
A chain that reaches the same model file twice is refused, naming the chain. So is one that nests deeper than eight expansions.
Ownership is the full address. An injected spec’s
entityis its producer’s address (robot.scan_front), so_as_tree, overrides (components.robot.scan_front.lidar.rays),enabledcascades and the run record all work at any depth. Two robots each mounting ascan_frontnever collide.A late override of an expansion input is refused. A component a manifest injected is first addressable after it has expanded. An override of a key its
expandreads (the plugin’sexpansion_keys:model,prefix,frame_id, …) would leave what it brought in built from the old value, so it is refused. Declaring that component in the world, nested under its carrier, lands the value before expansion.
Expansion runs while the document loads, not inside the engine, so
SimConfig.pluginsis the effective component list – what the document declares plus what its models’ manifests contribute – in build order.SimConfig.declaredkeeps the entries a reader actually wrote. That is what lets a consumer see what will run:roqsim scenes describecan name a sensor the document never declared, andworld_sourcessees a manifest-supplied component’s files (it did not, soprop_trajectory’s CSV resolved against the caller’s working directory rather than the world’s).A run records the components that ran, and a recording is rebuilt by reading them. The provenance carries the resolved tree and the
simblock beside the recipe (the world reference and the override document, which stay because they are what a reader needs to see how the run was asked for, and what an external consumer uses as world identity). Replay does not re-run the load path, so no later change to how an override resolves can make a recording rebuild a different world while looking correct.format_versionis read: a newer record is refused rather than partly understood, and an older one still reads – its samples, times and clock are intact – but will not rebuild from overrides that would resolve differently today.Loading stays tolerant; running does not. A ref that will not import is recorded on
SimConfig.unresolvedand its entry is kept, unexpanded, so a scene-only consumer (roqsim render, the exporters,describe) still gets a world whose transport is not installed.instantiate_pluginsis where that is refused, with the same report as before – including the case that says a world failed solely on its bridges and names the two ways on. Dropping the transport prunes the matching deferred failures, or a consumer would remove the bridge it cannot import and be refused for it anyway.Spawn plugins implement
expandin one line via the sharedroqsim.manifest.expand_manifest(spec, world, *, base_dir=None). It resolves the model (see Model discovery below), reads the<model>.manifest.yamlbeside the resolved file, and injects each entry as a component of the spawn. There is no per-family wiring key any more: an entry belongs to the entity whose entry it sits under, so a mobile spawn and an arm spawn wire their components identically. Distinct entities (two arms) never collide.A manifest entry that itself provides an entity (a
spawn_sensorin a robot manifest) gets the spawn’s prefix asattach_prefixrather thanprefix, and derives its own from that. Its nestedcomponents:are kept, owned by its address and merged by label like everything else. Precedence is nearer-wins all the way down: the world’s value, then the robot manifest’s, then the device manifest’s.A mounted device’s
prefixdefaults to<carrier prefix><label>_, and itsnamespaceto the carrier’s.A device manifest may use
{frame_id}and{parent_frame}placeholders, and no others.{frame_id}is the mount’sframe_id, else the manifest’s top-levelframe_id:(the vendor’s default scan-frame name,roqsim.manifest.manifest_frame_id). A device whose vendor names none declares none, and a mount of it without aframe_idis refused rather than given a made-up name. Two mounts on one carrier with the sameframe_idare refused too, since they share its namespace.Vendor fixed links a model flattened are a
frames:block (roqsim.frames). It is built as sites and published as static transforms. Thespawn_sensorandspawn_robotdocstrings (docs/plugins.rst) have the details.
When the owner already declares a component with the same label (its
name:, else its plugin ref), the manifest default is not injected — the world’s entry is the one that runs — but the manifest’s config is merged underneath it: per key, the world’s value wins and missing keys are filled from the manifest. This is what makes a partial override work (a nesteddiff_drive: {test_cmd: [...]}adds a scripted command and keeps the model’s wheel geometry and actuator names). Keying on the label rather than the ref is load-bearing: a model may ship two of a kind (tiago_pro’s front and rear lidars), and keying on the ref would collapse them onto one entry and silently lose a sensor. The merge is shallow on purpose: a nested value the world sets replaces the manifest’s whole mapping rather than being deep-merged. The world’s spec is mutated in place, which is safe because plugins are constructed only after expansion completes — so declaration order does not matter.Note
Skipping the manifest entry whole instead of merging under it would make a partial override fall back to the plugin’s generic defaults for everything the world does not restate — a Husky’s
diff_driveinheriting TurtleBot wheel radius and actuator names, then failing to resolve them against its own MJCF. If you rely on a world entry starting from the plugin’s defaults rather than the model’s, usedefault_plugins: falseand declare it fully.Opt out per spawn with
default_plugins: false; a model with no manifest yields nothing.
Any new spawn plugin reuses this by calling expand_manifest with the config key its downstream plugins use to name the entity.
Model discovery (roqsim.models)¶
A spawn plugin’s model: string is resolved by roqsim.models.resolve_model, which mirrors plugin resolution (roqsim.registry) so a model may live in any installed package — not just the spawn plugin’s own:
Short name (
ur10e,turtlebot4,conveyor) — searched across every package that registers a provider in theroqsim.modelsentry-point group. The caller need not know which package ships it.Package-qualified
<package>:<model>(roqsim_manipulation_assets:ur10e) — resolved within that provider; use it to disambiguate a name shipped by two packages, or to self-document a dependency. A dottedmodule:nameexposingMODELS_DIRalso works for models outside the group.Filesystem path (absolute, or relative to
base_dir) — loaded directly.
resolve_model returns a ModelAsset(path, meshdirs, texturedirs). Crucially, the asset dirs follow the model to its own package (a provider exposes MODELS_DIR and optionally MESHES_DIR/TEXTUREDIR): a spawn plugin calls apply_assets to rewrite the child spec’s mesh/texture refs to absolute paths found across those dirs, so it does not force its own package’s meshdir onto a foreign model — which would make a cross-package model impossible. A model that reuses other packages’ meshes adds an assets: key to its <model>.manifest.yaml naming the provider(s) to borrow from — a single name or a list. Search order: the model’s own declared meshdir always comes first — its assets can never be shadowed by same-named files elsewhere (Menagerie-style link names recur across robots, e.g. the Oli and the G1 both ship a left_hip_yaw_link.STL) — then the borrowed providers in order, then the provider default and the model file’s dir. Since MuJoCo allows only one meshdir per model, resolving to absolute paths is what lets meshes from several packages coexist. A package registers its models with:
[project.entry-points."roqsim.models"]
roqsim_assets = "roqsim_assets.models" # a module exposing MODELS_DIR
Example: a downstream package can ship only a custom arm variant — say ur10e_custom.xml + ur10e_custom.manifest.yaml, no mesh copies — whose manifest assets: [roqsim_manipulation_assets, roqsim_sensors] borrows the stock arm meshes from one package and a camera mesh from another. spawn_arm: {model: ur10e_custom} then places it even though spawn_arm lives in a third package, roqsim_manipulation — which ships no models at all.
5. Plugin-type catalog¶
Types are roles (which hooks you implement), not separate base classes:
Scene/init (
build): floorplan/environment, spawn robot/arm, pedestrians, conveyor geometry.Controller (
pre_step): diff-drive (cmd_vel), arm trajectory, conveyor velocity, ped ORCA.Sensor (
post_step, mayberender): lidar (mj_multiRay, no GL), camera (RGB-D, GL), IMU.Transport/bridge (
configure+pre/post_step+shutdown): ROS 2 bridge,simulation_interfaces.Recording/observability (
post_step): metrics/CSV (a task plugin’s own series). Recording MuJoCo state is not a plugin — it is the driver’s--record(see §8).Render (future): viewer overlays, debug markers.
Perturbation: a sensor’s own noise config, switchable mid-run through its
fault:block (§9.1), ormodel_overridechanging a named model value mid-run on an external trigger (§9.2). There is no shared error-model framework, on purpose (§9.1).
DummyPlugin (plugins/dummy.py) is a minimal end-to-end example implementing every hook.
6. Porting playbook¶
The incoming codebase is monolithic MuJoCo scripts. Rework them into plugins as follows.
6.1 Decision tree — “code that does X → hook Y”¶
The old code… |
→ hook |
Notes |
|---|---|---|
builds/edits the model XML, adds bodies, meshes, sensors |
|
use |
looks up |
|
model exists here |
sets |
|
the only place to write actuation |
reads |
|
read-only w.r.t. the sim |
re-homes/reseeds state at episode start |
|
after |
closes resources |
|
reverse order |
receives async input (ROS cb, socket, GUI) |
wrap in |
never touch |
6.2 Recipes¶
New robot / arm: a scene plugin whose
buildattaches the robot MJCF intospec(spec.attach/ add body), registers anEntity(kind="robot")and aRobotHandleinconfigure.New sensor: a
post_stepplugin that readsdata(or renders viactx.render), optionally adds its own noise (§9), and hands the reading off (blackboard/bridge). Register a producer gate (§10) if it should participate in sync mode.New controller: a
pre_stepplugin that consumes a target (from blackboard /ctx.post) and writesdata.ctrl. Expose aRobotHandleso a bridge can command it. It must honourctx.manual_control(§7): when set, the human ownsdata.ctrlfor the run — return frompre_stepwithout writing it, so the viewer’s control sliders drive the actuators. Writingctrlonce inon_resetstays right, and a controller should do it in every mode: a reset zeroesdata.ctrl, so until the controller writes the commands that hold its pose, the state the engine’s closingmj_forwardderives – and anything a sensor reads or tares before the first step – is the robot pulled toward zero. In manual mode the same write opens the sliders at the home pose; the rule is about the per-tick write. This is what the runner’s--manual-controlswitches, world-wide, for every controller at once — hence a run-level flag rather than per-plugin config.Environment/floorplan loader: a
buildplugin that adds a mesh + collision geoms tospec.External transport (ROS/other): a transport plugin —
configurespins the client thread, callbacksctx.post(...),post_steppublishes fromdata,shutdownstops the client.Moving part (conveyor):
buildadds an invisible belt body on a slide joint;pre_stepforces its velocity and wraps position; a contact pair tunes belt↔object friction.Injected fault (model_override): a plugin that in
configureresolves a named selection of geoms/bodies/actuators, saves their current values and publishes a handle plus astd_srvs/SetBoolservice endpoint;set_activewrites the target rows andon_resetwrites them back. Nopre_stepat all – the change rides onctx.postfrom the service, andpost_stepruns only on the step after a change, to check the fault actually landed (§9.2).Articulated + commandable prop (door): the
doorplugin (roqsim_assets) is both —buildhangs a leaf on a hinge joint with a force-limited position actuator;pre_stepdrives it toward a target openness and, if the leaf stalls against an obstacle, backs off (a gentle automatic door);configureregistersEntity(kind="door"), aDoorHandle, and — whencontrollable—std_msgs/Float64cmd/stateendpoints plus acontrol_msgs/GripperCommandaction (reusing the generic 1-DOF handler via itsstate_keyhint). The natural home for a door in a floorplan world is the opening the generator already cut (floorplan_to_world.py --doors-map).
6.3 Decomposition guidance¶
Split one big feature into cooperating plugins that talk through the blackboard/entity registry, not direct imports. Example: a monolithic “robot + lidar + ROS” script → spawn_robot (build) + diff_drive (control, publishes RobotHandle) + lidar (sensor) + ros2_bridge (transport, looks up the handle). Each is independently testable and reorderable in YAML.
6.4 Worked example [planned — filled when the first real feature is ported]¶
Will show a concrete before (monolithic script) → after (plugins + world YAML), step by step.
7. Concurrency & threading model¶
Who commands an arm. arm_controller is the only writer of an arm’s actuators, and that
write is the hardware: it never stops, or the joints would fall. What switches is its trajectory
role. A Cartesian controller does not write ctrl; it computes joint targets that the arm
pulls once per step, stamped with the step it ran for, so whichever of the two reaches that
point first does the work and declaration order cannot decide whether a command lands this step or
the next. One command source per arm; a second is refused naming both.
roqsim.controllers holds the single source of truth for every controller’s name, type,
lifecycle state and claims – transport-free, because those are facts about the robot. A plugin asks
it whether it is active, the bridge serves controller_manager_msgs out of it, and a task plugin
switches through it. Its scope is the robot: two arms may each have a controller of one name, as
two real robots do, and claims are compared within a robot because joint names are unprefixed and
identical across arms.
Single-writer rule: only the physics thread (the one calling engine.step()) ever touches model/data. This is non-negotiable — MjData is not thread-safe.
External input (ROS callbacks, simulation_interfaces services, GUI) must not mutate data directly. It enqueues a callable via ctx.post(cmd); the engine drains the queue at the start of ``pre_step``, so every mutation happens on the physics thread, in FIFO order, deterministically. ctx.post is the substrate the ROS bridge (Phase 5) and synchronous mode (§10) build on.
For readers on other threads, the engine publishes an immutable snapshot after each step (publish_snapshot/read_snapshot). The default path, though, is to read in post_step on the physics thread — no snapshot needed.
reset/shutdown drain/flush the queue so no command targets a half-rebuilt model.
Anti-patterns (do not do)¶
❌ Mutating
ctx.datafrom a ROS callback / worker thread. Usectx.post.❌ Recompiling the model mid-run. Modify
speconly inbuild; at runtime use mocap/qpos writes,roqsim.presenceto make a compiled entity appear and disappear, or themodel_overrideplugin to change a named model value (§9.2). Those three are not recompiles: they write fields the compiled model already has, and put back exactly what they found.❌ Blocking inside
pre_step/post_stepwaiting on external I/O (except deliberately in sync mode).❌ Plugin importing another plugin. Cooperate via the blackboard/entity registry.
8. Rendering¶
A RenderService on the context that owns all GL/EGL contexts and camera renderers – created lazily and shared, so multiple sensor/render plugins never fight over contexts – is [planned]. Everything else in this section is built.
Lidar uses
mujoco.mj_multiRay— no GL, works headless everywhere (M1 uses this; nav2 needs aLaserScan).Camera / RGB-D uses
mujoco.Renderer(offscreen). Cameras render only when consumed (lazy), and their expensive endpoints are not published to nobody either:image,image_compressed,depth,depth_compressedandpointssetEndpoint.lazy, so a subscriber-less stream costs neither a GL pass nor a serialisation. The two checks answer different questions — the render gate ORs over every endpoint one pass feeds, the publish gate is per-endpoint — so a consumer of the compressed topic alone still gets frames, and a consumer of the raw topic alone does not pay for the JPEG (or, for depth published as16UC1, for the RVL of itscompressedDepthcompanion – an order dearer than the JPEG, since RVL has no C library behind it).The offscreen backend is bound by ``import mujoco``, so it is chosen by ``import roqsim``.
MUJOCO_GLis read exactly once, insidemujoco/rendering/classic/gl_context.py, while mujoco is being imported; it assignsGLContextthere and then, and an unset value is not an error but a choice — it falls through to glfw. Everything that follows from that is the reasonroqsim.gl.select_offscreen_gl()is called from the package__init__rather than from a driver’smain: a driver module’s own imports reach mujoco before itsmainbody runs, so a selection made there sets a variable nobody will read again. This is not hypothetical — the call lived inroqsim.runner.mainand was inert for every headless run, invisibly, because a world with no camera never constructs aRendererand therefore never instantiates the mis-bound backend. It surfaced the first time a camera world was dispatched to a cluster, asmujoco.FatalError: gladLoadGL errorfrom insideMjrContext. A node with a DRI render device getsegl, one without getsosmesa; an explicitMUJOCO_GLalways wins andROQSIM_NO_GL_SELECTopts out. Both backends are installed in the container images for the same reason the choice is deferred: which one works is a property of the node, not of the image. The one case the package__init__cannot cover — a consumer importingmujocofirst — is caught byroqsim.rendering.check_gl_backend(), which every renderer in the tree funnels through and which names the cause and the fix rather than leaving MuJoCo’s message to stand.The interactive viewer (
mujoco.viewer.launch_passive) is a driver concern, separate from the offscreenRenderService, so windowed vs headless is a driver switch, not a plugin change. The standalone runner sets the viewer’s initial free camera from the world’s optionalsim.viewblock (lookat/distance/azimuth/elevation; any subset — omitted keys keep MuJoCo’s model-derived default).sim.viewis the camera and only the camera: it is schema-checked on load, so an unknown key fails the run rather than being silently dropped. The two Simulate side panels are deliberately not expressible there — they are run-level flags (--left-ui/--right-ui, passed tolaunch_passive’sshow_left_ui/show_right_ui, both hidden by default), on the same footing as--manual-control: a world describes the experiment, whereas panels and hand-driving describe one interactive session. All of it is windowed-only (ignored underheadless) and is not a plugin concern. The keys roqsim adds to that window are declared once (roqsim.keys): the handlers take their keycodes from those records and the F1 list is rendered from them, so what the window says a key does and what it does are one declaration – and the list a run shows is merged from the handlers that run actually wired, so it never offers a key this window lacks. The window’s text overlay is owned by slot (roqsim.overlay) becauseset_textsreplaces the whole set: the camera-mode notice and the key list are two writers, and either alone would take the other down.Rendering an image is a driver concern too, and it is a separate process.
roqsim render(roqsim.render, the tool;roqsim.renderingis the library it drives) compiles a target through the same dispatch asroqsim sim(roqsim.runner.config_for_input()) and writes one frame offscreen, so a world renders exactly as it simulates. It runs in its own process with its own GL context and touches no running simulation, which is why the picture path has no performance question attached to it. Three deliberate choices: the camera comes from the world’ssim.view, layered by--viewthrough the same override path--setuses (sosim.viewkeeps one validator and one frozen key set — there is no second camera grammar); the headless camera is built by handing a shim toroqsim.viewer.setup_camera(), sotrack/follow_heading/preview framing are inherited rather than reimplemented; and a model’shomekeyframe is preferred overqpos0, shared withroqsim assets render-thumbnailsso a model’s thumbnail and itsroqsim renderoutput are the same picture. Raw meshes are accepted here (wrapped in the preview sceneroqsim.mesh_previewowns – in the core, not beside the prop pipeline that motivated it, because a capabilityroqsim render --helpadvertises cannot depend on an optional sibling being installed) even thoughroqsim simrefuses them: loose geometry cannot be meaningfully simulated, but rendering it is both harmless and useful. Stdout is exactly one line of JSON, a machine contract rather than a convenience — it reports the camera in--view’s own vocabulary, so a shot can be reproduced by copying it back.Closing the window is a wait, not a request. MuJoCo runs the window on threads it owns and
Handle.close()only setsexitrequest, so the window, its GL context and its X drawable are destroyed after the call returns. A process that closed and then exited promptly (a world that fails to load,--steps 1, a short--seconds) raced its own teardown: Python’satexitranglfw.terminateunder the render thread, whose in-flightglXSwapBuffersthen hit a destroyed drawable, and Xlib’s default handler calledexit()from that thread while the main thread was already finalizing — the process hung after its last line of output (or segfaulted). Both drivers therefore open withroqsim.viewer.launch_viewer()and close withroqsim.viewer.close_viewer(), which joins those threads (~10 ms) so the teardown is ordered; neverHandle.close()orwith handle:directly. Relatedly, the cosmetic X11 window branding (roqsim.window_branding) polls other clients’ windows, where a window closing mid-pass is normal, so it installs an ignoring X error handler for the duration — Xlib’s default one would kill the process over a window title.Windowed run + cameras — two GL contexts, two backends. The viewer window is always glfw:
mujoco.viewerimports and initialises glfw directly, independent ofMUJOCO_GL, which selects only the offscreenRendererbackend. So a windowed run of a camera world holds a glfw window context and an offscreen render context at once, and they must not both be glfw — two glfw contexts in one process collide and MuJoCo aborts withgladLoadGL error. The runner (seeroqsim.viewer.prepare_viewer_gl()) resolves this before any GL loads, for windowed launches only, with two overridable defaults: it preloads the system libGLEW (the glfw window context needs it in the global symbol namespace on many Linux/GL-driver combinations) via anLD_PRELOADre-exec —LD_PRELOADis read only at process startup — and defaultsMUJOCO_GL=eglso the offscreen cameras get their own context. Override withMUJOCO_GL/ROQSIM_NO_GL_PRELOAD. Caveat: a hand-exportedLD_PRELOAD=…libGLEWleft in the shell drags GLX into the process alongside MuJoCo’s PyOpenGL EGL backend and crashesimport mujocowithundefined symbol: eglQueryString— roqsim preloads libGLEW itself, so do not also export it.
9. Sensor noise¶
9.1 Sensor noise¶
Design note: there is no generic, composable error-model framework (a registry wired into sensors through an error_model: config key). Noise is sensor-specific — its parameters, where it applies, and how it degrades only make sense in the context of one sensor — so a generic cross-sensor abstraction adds indirection without real reuse.
Instead, each sensor owns its noise as plain config:
Lidar (
roqsim_sensors):range_stddevadds zero-mean Gaussian noise to every hit (0 = off), andrange_stddev_relativemakes that sigma a fraction of the true distance at and beyondrange_stddev_relative_from– the shape a triangulation scanner’s datasheet states (the LDS-01: +-10 mm below 0.5 m, +-3.5 % above). The noisy distance is floored at 0 and quantised torange_resolution(the S300’s 10 mm steps). It reaches a measured return and a 2D scan’srawtoo-close return; a constanttoo_close/no_returnvalue carries none.dropout_percentrandomly drops that percentage of rays per scan to a no return (the scan’sno_returnvalue,+infby default). A systematic per-instance bias is not modelled: the datasheets state it as a bound with no distribution. Determinism: seeded from the run’s seed (sim.seedin the world, or--seed, or the value the scenario adapter resolves fromROQSIM_SEED– an explicit one wins; with none of them, one is drawn and announced) throughroqsim.context.SimContext.rng_for(), which returns a counter-based (Philox) generator keyed on(seed, episode, sim_time, sensor name). Theepisode—episode, advanced byreset()— is what stops a process that serves several trials from replaying one noise sequence over and over:mj_resetDataputsdata.timeback to zero, so keying on simulated time alone would make trial 2 re-draw trial 1’s noise step for step, and repeated trials of one configuration duplicates wearing the clothes of samples. It occupies a counter slot rather than perturbing the key, so the key still means “the run’s seed”, the generator stays randomly accessible, the whole series stays reproducible from one base seed, and trial i of two different runs draws the same noise when their seeds match — which is what makes two configurations comparable trial-for-trial rather than only in distribution. A recording carries the episode beside the seed, because the right seed at the wrong episode reproduces noise that never happened, and entirely plausible-looking. Counter-based rather than stateful for a specific reason: a shared stateful generator’s position depends on how many draws happened before it — sensor rates, step count, and for cameras whether anyone was subscribed — so it is not a function of the world at all, and a value drawn at t = 12.5 could not be reproduced without replaying the whole run. Keying on simulated time rather than a step counter is what lets a sensor be re-run from a recording and produce the same noise the live run published, because a restored state carries itssim_timeand nothing carries a step count. A sensor re-run from a recording (roqsim state --sensor) is therefore deterministic and noise-correct for the restored state, but not bit-identical to what the live run published at that timestamp: live,post_stepruns at the physics rate, so a sensor’s ownrate_hzgate fires between recorded samples and the endpoint holds a scan computed slightly earlier than the sample that recorded it. Measured, about a quarter coincide exactly. Recovering the rest would mean recording every firing — bagging the topic, at ~26x the size of the state recording — which is the trade this design refuses. An unresolved seed raises (roqsim.seed.SeedError) rather than standing in a 0: the seed is the driver’s to resolve, so a run without one is missing a required input, and the failure lands at the first draw instead of on every trial afterwards. It is raised fromrng_forand not fromsetup()because that is the only place that knows a draw is happening – a geometry export or ascenes describebuilds the same world and needs no seed. A preview – a driver that compiles a world in order to LOOK at it and never steps it into a run whose numbers anyone keeps – says so withEngine(cfg, preview=True), which pins the fixedroqsim.seed.PREVIEW_SEED: a picture is not a measurement, and two renders of one world must be the same picture. That is a flag rather than a line each such tool remembers, because forgetting it is invisible until someone points the tool at a world that happens to draw: without it,roqsim render,roqsim check,roqsim scenes describe, the map and MoveIt exports and the scene preview would each refuse such a world, naming a seed their caller has no way to supply – and a campaign’s world check would refuse a perfectly good randomised world before spending anything on it. A driver may still assignctx.seeditself afterwards; the flag sets the default, not the policy. Where a world is run repeatedly to sample a distribution, leavesim.seedunset – a stated one gives every repetition the same draws, which is reproducibility bought at the price of the variation those repetitions exist to measure.Wheel odometry (
roqsim_mobile):diff_drive’sodom_noiseputs a multiplicative bias (linear_scale,angular_scale) and zero-mean white noise (linear_stddev,angular_stddev) on the velocities read off the wheels, before they are integrated, so the reported pose drifts the way real odometry does while the base itself moves exactly as the physics says. One draw per physics step fromrng_for, under the same seed and episode rules as the lidar; omitted, nothing is drawn.Ground-truth physics stays clean for sensor noise: only the reported value is perturbed. A fault that is physical – a grasp that slips, a wheel that loses traction – is the opposite case, and is §9.2 rather than this.
Switching it mid-run. The values above are the sensor’s nominal config. A sensor may also carry a fault: block – the values it takes on while degraded – which a scenario applies and restores by the sensor’s address (set_sensor_override(instance: 'robot.lidar')), over the same std_srvs/SetBool endpoint shape model_override uses. It is not a plugin: a component belongs to the entry it is nested under and a sensor registers no entity, so a separate fault entry could only have named its target in a config key – the ownership-as-a-value pattern §5 removed. Severity stays configured (so components.robot.lidar.fault.dropout_percent is an ordinary experiment factor) and only one bit crosses the wire; the world never owns when. Only keys the sensor reads per frame may be written, declared per sensor as an allowlist and refused by name otherwise – rays changes a LaserScan’s length, which is the §9.2 geom_size failure in sensor form: a write that lands, does nothing, and reads back as though it had. Implementation: roqsim_sensors/live_config.py.
When a future sensor needs a different noise shape, add it to that sensor’s config, not to a shared framework. Reference: roqsim_sensors/src/roqsim_sensors/plugins/lidar_common.py — the shared base every ray-casting range sensor derives from (the 2D lidar, livox_mid360, and seyond_robin_w1g), which owns the rate gate, the detection limits and the noise so the devices cannot drift apart on them: the far limit and the presence mask are applied there once, for every device.
One raycast seam: ``roqsim.raycast``. roqsim.raycast.cast() is the only mj_multiRay
caller in the tree — the lidar base, the coverage engine and sampler, spawn_sensor’s build-time
occlusion probe, roqsim.rendering’s line-of-sight test and roqsim_assets’ moving_box all
go through it. Three things live there because they must be decided once:
The visibility mask is the default.
mj_multiRay’sgeomgroup=Nonemeans every group, includingroqsim.presence.ABSENT_GEOM_GROUP, so a caller who omits the mask sees entities that have been made absent — an absent entity reported as a return, with nothing downstream able to tell. Making the mask the default turns “forgot the mask” into an explicitgeomgroup=None.It is single-threaded on purpose, and that is measured. Splitting a batch across threads looks free — rays are independent,
mj_multiRayreleases the GIL, and the output is bit-identical (1.5x at 360 rays, 3.1x at 20160). It is not:mj_multiRayallocates from ``mjData``’s stack (96 bytes, whatever the ray count), and that stack is one bump pointer in the sharedmjData. Hammering onemjDatawith 8 threads × 40000 casts, a sampler sawpstackpeak at 2112 bytes and return 1632 instead of 0 — MuJoCo guarantees a function restorespstack, and concurrency breaks it. The leak is monotonic, so a long trial walks toward stack exhaustion; the failure it produces is a core dump. Every one of those 320000 casts returned the right answer, which is the point: the race corrupts an allocator invariant, not the output, so it survives any amount of parity testing and then crashes in a long run. MuJoCo’s own guidance is onemjDataper thread and the ray functions are not exempt. If 3D-lidar throughput ever becomes binding, the route is a pool of per-workermjDataclones with geom poses copied in — which needs themjDatafields the ray path reads pinned down and kept pinned across MuJoCo upgrades. A project, not a flag.No GPU backend. No available GPU raycaster expresses what roqsim needs. MJWarp’s
mjw.ray/mjw.raysdocuments nogeomgroup, nobodyexcludeand noflg_static, and a returned nearest-hit cannot be post-filtered into the right answer — rejecting a hit does not reveal what is behind it, so an absent obstacle would read as “no return” rather than “the wall behind it”. Honouring exclusion on a GPU means keeping excluded geometry out of the BVH, i.e. per-sensor BVHs refitted on every presence change. Measured against a 2D-lidar sweep the payoff is ~0: the simulator there is pacedrealtimewith 30-40x headroom, and raycasting is ~2% of one core against a container that is mostly ROS transport. Cameras are unaffected either way — they rasterize throughmujoco.Rendereron the GPU already, and their depth is that render’s z-buffer, not a raycast.
The test for which of the two a perturbation is, is where the failure lives. A lidar that mis-measures a wall it can see is noise. A gripper that stops holding is not a measurement at all, and no perturbation of a report can produce it.
Reaching a sensor’s config from outside the world. A sensor usually arrives from a model manifest – spawn_robot: {model: turtlebot4}
brings the lidar with it – so a world that only spawns a robot never names it. Two
consequences, and they differ:
A world may set one key of a default without restating the rest. Declare it where the manifest puts it – for a TurtleBot 4, under the robot’s
rplidarmount;expand_manifestmerges the manifest’s config underneath that entry, per key, so:components: - spawn_robot: {model: turtlebot4} name: robot components: - spawn_sensor: {} # the manifest's RPLIDAR mount, by its label name: rplidar components: - lidar: {range_stddev: 0.05} # keeps the device's rays, max_range, frame_id
Restating the rest would be a duplicate that silently diverges the day the manifest changes. The entry carries no
robot:key: it belongs to the mount because it sits inside it.An override reaches a default the document never declared. Expansion runs while the document loads, so
--set components.robot.rplidar.lidar.range_stddev=0.05resolves against what will actually run – no entry, no stub. It is still applied before compile, so the value is in the compiled model and therefore in the run’s provenance.An address is a path through the document: consume a segment while it names a component, and the first that does not begins the path into that component’s config. So the two spellings are the same assignment, which they have to be – a sweep flattens a path onto a command line, a saved override set writes a document, and they must not diverge:
--set components.robot.rplidar.lidar.rays=720 components: {robot.rplidar.lidar: {rays: 720}}
Assignments are applied twice: once against what the document declares, then again once its models’ manifests have contributed. The first pass is what lets a structural override work –
components.robot.model=husky_a200lands before expansion readsmodel:, so the husky’s manifest is what expands – and the second is what reaches a component only a manifest supplies. An assignment that matched in neither pass is refused, which is the failure that matters: silently ignoring a swept parameter would let a sweep change nothing while every run looked healthy. The refusal names what the document does have, searching refs as well as addresses – a barelidaragainst a robot carrying two is a no-match rather than an ambiguity, and the useful answer is both their addresses.
Proximity is a separate observable, and a separate plugin. contact_monitor answers did it touch; clearance_monitor answers how close did it come. Contact is the honest failure criterion and a poor optimisation target – a bit gives every non-touching configuration one score – so the pair exists to supply a verdict and a gradient over one geometry, with one owner each. The distance is measured in the simulator rather than derived afterwards from recorded poses for three reasons that cannot be fixed downstream: the closest approach falls between pose samples, and the faster the pass the more is missed; a footprint radius would put a calibration constant between the geometry and the result, which is the objection §9.2 and the contact plugin both raise against proximity proxies; and an articulated obstacle’s nearest part is a limb rather than its origin. mj_geomDistance measures the real shapes, so the limb is what it finds and names. Collision masks matter here and MuJoCo’s distance query does not apply them: a render-only geom would otherwise be reported as clearance to something the robot passes straight through (a pedestrian model carries six of those beside fifteen solid ones), so candidates and watched geoms are filtered by MuJoCo’s own pairing rule on both sides. It never ends a trial – two plugins reporting one failure by different rules is how a trial starts disagreeing with itself, and a clearance threshold is exactly the tunable number the contact oracle avoids; a scenario that wants to stop on a near-miss reads the endpoint and decides, with the threshold stated in the experiment. Measuring is a distance query per geom pair, so compute_rate_hz (default 200) is decoupled from the publish rate: every physics step cost about a fifth of the step budget on a nav world, against a budget the simulator may already be over, while 200 Hz resolves ~1.5 mm at walking pace.
9.2 Physical faults (model_override)¶
The runtime counterpart to --set: name a model field, name the objects, name a target value, and let an external trigger switch between nominal and target. It is what makes “the robot loses the object it is carrying, at this instant” a property of the world rather than something scripted into whatever drives the robot. Save/restore is exact, from the values read at configure, the way roqsim.presence restores what it hid.
Not every model field can be written at runtime, and the ones that cannot fail silently – so the plugin ships a curated allowlist, one row per field with its object namespace, its write class and its caveat, and refuses everything else by name with the reason. Two write classes, both measured against MuJoCo 3.11.0:
live– in effect on the next step:geom_friction,geom_contype/geom_conaffinity,actuator_forcerange.needs_setconst–body_mass, whose write the dynamics ignore untilmj_setConstrecomputes the derived inertia (measured: the mass matrix does not move, andbody_invweight0/body_subtreemasskeep their compile-time values). The plugin calls it.
geom_size is refused: geom_rbound is cached at compile, so a box grown 0.05 → 0.4 m kept rbound at 0.0866 and, while overlapping the floor, produced ncon = 0 – geometry that renders big and collides as if small. Three further refusals are decisions rather than safety, and the plugin says so: geom_priority (it also governs condim/solref/solimp, so it would swap the contact’s stiffness model at the instant of the fault), MuJoCo’s own sensor_noise (§9.1 gave that to per-sensor config), and every opt.* global (sim.contact_override and the sim: block own those, before compile, so they are in the compiled model and in the run’s provenance – a runtime write would make the recorded value differ from the one that ran).
That refusal governs a fault-injection hook poking a global whose recorded value is a single
number; it is not a ban on writing an opt.* global. roqsim_aerial’s wind_field owns
opt.wind and writes it every tick, because constant wind is the one wind a controller never
has to reject – it trims against it once and the error goes to zero – so a world that can only
state a constant cannot test disturbance rejection at all. The provenance argument does not carry
across: the wind there is not a value but a signal, fully determined by the plugin’s declared
parameters plus the run’s seed, both recorded with the world, and reproducible tick for tick. What
keeps that inside the rule rather than around it is one owner per knob: wind_field refuses
to load beside a sim.wind rather than merging with it, because two owners of one global is
exactly the situation where the compiled model and the first tick disagree and the run records the
value that was overwritten. A plugin that wants an opt.* global takes it over completely, says
so, and fails loudly when something else has claimed it – it does not share it.
The plugin never decides when to fire. A fault’s timing is the experiment’s independent variable, and severity is the configured target value, so both stay sweepable: what crosses the wire is one bit. The inbound endpoint is therefore a std_srvs/SetBool service rather than a topic – a command whose outcome the caller needs – and its reply carries the verdict, so a scenario’s service_call() can fail a trial that failed to inject its fault.
An override that lands in the model and changes nothing is the failure that would otherwise produce plausible wrong data. MuJoCo takes a contact’s friction from the geom with the higher priority, and at equal priority the element-wise maximum of the two, so lowering one side cannot bring a pair below the other side’s value. configure raises for what is certain (an unknown name, an empty selection, an explicit <pair> covering a selected geom, a geom roqsim.presence has made absent, an all-visual-only selection, a geom_contype override without its geom_conaffinity partner – measured to be a no-op alone). The rest is caught at runtime by reading the applied contact rather than predicting it: one step after a change, verified reads landed, no_effect (with a warning, and a failed service reply) or untested. Deliberately not done: a configure-time analysis of which geom governs which pair, which would re-derive MuJoCo’s mixing rule inside a generic field writer and could only ever warn.
The allowlist is also the plugin’s documentation and its discovery surface: roqsim scenes describe reports it as overridable.fields (world-independent, so it costs no model build), and --overridable GLOB adds overridable.targets – the names an override can select and their current values – for a caller that is not a roqsim process.
10. Synchronous / lockstep mode [planned — seams exist, behaviour inert]¶
Later, as in CARLA’s synchronous mode (ref: github.com/carla-simulator/ros-bridge), the tick must be able to block until external consumers react: don’t advance until ROS 2 returns a control command computed from this tick’s sensor data, and/or until every sensor due this tick has published. This makes the loop a deterministic sensor-in → control-out lockstep, independent of wall-clock pacing.
Design:
Gates (
ctx.register_gate(name, role)), two roles:Producer (sensor): calls
gate.satisfy()once it has published this tick; the engine computes the due set from sensor rates and, in sync mode, waits for all due producers.Consumer (controller/bridge): gate stays pending until the expected input arrives via
ctx.post.
Blocking step: in sync mode
step()publishes (post_step), then blocks on the barrier (Event/Condition) until all gates clear or a timeout fires; the queued control is applied in the nextpre_step. Composes with both drivers (scenario-execution’sstep()just takes longer; pacer bypassed).Config:
sim.sync = {enabled, timeout_s, wait_for: [sensors, control]}; default off.GetSimulatorFeaturesreports whether sync stepping is active.Safety: timeout + a deadlock diagnostic (which gate never fired) prevents a missing/late node from hanging the sim.
The command-queue model (§7) is the substrate: sync mode adds a wait on named gates, not a new concurrency scheme. Today register_gate/gates exist and are reset each reset(), but nothing waits on them.
11. Performance & benchmarking¶
With Engine(profile=True) (the runner’s --profile) the engine times every overridden hook and accumulates per-plugin, per-hook wall-time; profiling off means no timing work at all. Engine.timing_report() → {plugin: {hook: avg_µs}}; Engine.format_timing() prints a table. Use it to answer “is a plugin slowing the sim?” — total plugin overhead should be a small fraction of mj_step. The same flag records one-shot load phases (plugin resolution, world parse, compile, data creation): Engine.load_report()/format_load_report(), printed by the runner right after setup — this answers “why does the world load slowly?”. For the practical workflow — measuring RTF, py-spy thread attribution, and the GIL caveat — see Profiling & performance debugging.
Parallel post-step [planned]: consistent with the single-writer rule, only post_step is safe to parallelize (read-only). Plugins opt in with parallel_safe = True; a future thread-pool executor runs the safe ones concurrently while pre_step (which drains the queue and writes data) stays sequential. M1 runs everything sequentially; the only genuine second thread is the ROS executor, mediated by the command queue.
12. Conventions¶
Layout: the framework core is its own pip package
roqsim/(engine, plugin API, drivers, the genericdummyplugin, and the driver-level capture/render modules). Generic, robot-family-agnostic sensors live inroqsim_sensors/(lidar,oakd_camera,realsense_d435,imu,gnss). Robot-family plugins + assets live in further sibling packages — wheeled bases inroqsim_mobile/(floorplan/spawn_robot/diff_drive/omni_drive, models, worlds); arms and manipulators are further siblings, aerial vehicles inroqsim_aerial/(quadrotor_controller,multirotor_motors,px4_sitl, models, worlds), and a robot belonging to two families goes in a package depending on both (roqsim_mobile_manipulation/) rather than widening one family’s dependencies. The ROS bridge and the nav2 example are colcon packages underros2_ws/src/(roqsim_ros_bridge,roqsim_nav2_example). Keep the core ROS-free.Naming: plugin classes
PascalCase, entry-point namessnake_case; blackboard handles under<kind>:<name>(robot:<name>,metrics:<name>,model_override:<name>,contact_monitor:<name>,door:<name>:state).Reaching a plugin from outside: a plugin an out-of-process or out-of-package driver must reach publishes a blackboard handle in
configure— a small callable or dataclass under<kind>:<name>, where<name>is the instance’s world-YAMLname:. Consumers resolve that key; they never iterateengine.pluginsand never match on a class name, which breaks silently on a rename and cannot distinguish two instances of one plugin. Publish a callable when the value is replaced each step (contact_monitor.read_state) rather than the object it returns. The in-process seam a driver starts from isMujocoSim.context(aSimContext, i.e. exactly the rights a plugin has, single-writer rule included) — not theEngine, sopluginsandconfigstay the engine’s own. Over a transport the same capability is anEndpoint(§13); the two are the same declaration read two ways, which is what lets one scenario action serve a stepped run and a ROS run.Assets: record upstream license for any vendored MJCF/mesh next to it (see
roqsim_mobile/.../husky_a200/husky_a200_LICENSEfor the pattern). Where a provider ships its models flat, one directory holds several vendors’ terms and “the file beside it” attributes the wrong one, so the model names its own withlicense:in its manifest (roqsim.manifest.manifest_license(), read by both the docs catalog androqsim catalog models).Model headline: open a model’s MJCF with a comment whose first sentence says what the model is (
Clearpath Ridgeback (4-wheel mecanum, holonomic) for MuJoCo.). The docs model catalog describes each model with that sentence and reads the licence out of the sidecar’s own text, so neither is prose anyone maintains twice; a model without one falls back to repeating its package’s summary, whichroqsim/tests/test_model_docs.pyfails on.Model layout: a provider ships its models either one folder per model —
models/<name>/<name>.xmlwith the manifest, licence, port log and thumbnail beside it and its meshes inmodels/<name>/meshes/, referenced bare via<compiler meshdir="meshes">(roqsim_manipulation_assets,roqsim_assets,roqsim_sensors,roqsim_mobile) — or flat,models/<name>.xmlwith meshes namespaced undermodels/meshes/<name>/(roqsim_humanoid,roqsim_quadruped).resolve_modelaccepts both; follow whichever the package you are adding to already uses. Either way the meshes are per-model, never pooled in one dir, because Menagerie link names recur across robots. One consequence to know when borrowing: a folder-per-model provider setsMESHES_DIRto its models root, so a foreign model borrowing it viaassets:names the mesh<model>/meshes/<file>(Frankie does this for the Panda’s meshes), whereas a flat provider’s is<model>/<file>.New plugin checklist: subclass
Plugin; implement only the hooks you need; addvalidate_config; register via theroqsim.pluginsentry-point (built-ins) or reference bymodule:Class/file.py:Class; add a test.
13. Robot interface & transport bridges¶
A robot’s I/O is self-describing and transport-neutral, so it is not duplicated in a per-backend bridge and can be wired to any transport. It has three layers:
1. Endpoints (neutral, in the robot packages). In configure a plugin registers
Endpoints on ctx.interface (see roqsim.context.Endpoint): a name,
direction ("out" → provide read; "in" → provide write), an owner (the entity),
a namespace (a plain scope string each backend attaches to the endpoint’s topics/frames/actions),
an optional rate_hz, and a backend dict of inert per-backend hints keyed by backend name. The
read/write callables traffic in neutral payloads (numpy arrays, tuples, small dataclasses)
and run on the physics thread. Crucially the robot packages import nothing transport-specific — the
message type is named as a string ("sensor_msgs.msg.LaserScan") under the backend hint.
2. ``BridgeBase`` (backend-agnostic, in ``roqsim/bridge.py``). Shared machinery for every
transport: it iterates ctx.interface, applies the optional owner filter, rate-gates each out
endpoint, runs the per-tick publish loop on the physics thread, and marshals inbound data onto the
physics thread via ctx.post (single-writer rule intact, §7). A backend implements a few hooks:
_setup / _make_output / _make_input / _publish / _now / _tick / _teardown.
The rate gate is tested once per physics step, so the publish rates a world can hold are exactly
physics_rate / k for integer k — a request between two of them is served at one of them, as a
different constant for the whole run rather than as jitter that averages out. The requested rate is
therefore snapped to the nearest achievable one when the endpoint is bound (roqsim.rates, the
same grid arithmetic a capture rate uses, and the same
SNAP_QUIET / SNAP_NOTABLE bands for announcing the move in
proportion to its size, naming the nearby achievable rates where it is large). Nearest, not rounded
down: a snapped rate may be the faster neighbour, so a rate that is meant as a ceiling has to be one
this world can hold. A gate built that way then counts steps rather than comparing sim-time — the
spacing is the step count it was snapped to, which no accumulated float time can move.
The alternative — keeping the requested rate and advancing the deadline BY the period, so the spacing
alternates (17, 17, 16, …) and the mean comes out right — is deliberately not taken: the spacing is
what a consumer differentiates and what a filter’s dt is set from, so a rate that is right on
average and wrong at every step buys a mean nobody observes with an alias somebody reads as the
robot’s behaviour. It is the same alias the ROS bridge warns about when a coarse /clock quantises
an output’s stamps.
Both numbers reach the run’s record: a recording’s provenance carries endpoint_rates, one row per
gated publication — every bound output, plus the streams a backend publishes itself (a merged
joint_states, /clock), which appear in no world document at all — with its requested_hz,
its realised_hz and the every_steps behind it. So a rate quoted from the world document can be
checked against the one the run actually published at without measuring arrival times.
3. A concrete backend (transport-aware, in its own package). roqsim_ros_bridge provides
Ros2Bridge(BridgeBase) plus a registry (roqsim_ros_bridge/registry.py): resolve_type turns the
type string into a class via importlib (cached); converters keyed by that string fill an outbound
message in place, decoders turn an inbound message into a neutral payload, and a reflective path
(msg.data = payload) covers primitive std_msgs with no registered converter. One converter per
wire format, not per producer: the same rendered frame is published as sensor_msgs/Image or as
sensor_msgs/CompressedImage purely by which type string an endpoint names, so a camera plugin
offers a compressed stream without importing a codec (the encoder itself is
roqsim_ros_bridge/image_codec.py, kept ROS-free so it is testable on its own). Actions follow
the same pattern one level up: an in endpoint whose ros2 hint block carries action (an action
type string, e.g. control_msgs.action.FollowJointTrajectory) is served as an ActionServer
whose goal-execution policy comes from a handler registry (roqsim_ros_bridge/actions.py) — so even
MoveIt2 trajectory execution needs no dedicated bridge and no ROS import on the producer.
That policy includes what counts as done, which is a property of the joints rather than of the
clock. FollowJointTrajectory grades its result on where the arm actually ended: the goal’s own
goal_tolerance first, then the controller’s goal_tolerance config, then a deliberately loose
default, and missing it aborts with GOAL_TOLERANCE_VIOLATED. Succeeding as soon as the last
waypoint has been fed — which this did — makes a blocked or saturated arm indistinguishable from one
that did the job, and MoveIt forwards that verdict unchanged, so the caller sees a clean execution
against a scene that never moved.
Injection, not authoring. A world does not declare its transport. with_transport appends the
bridge at load time (roqsim sim --ros; ROQSIM_ROS for the scenario-execution adapter), which is
the exact inverse of drop_transport_plugins and is what keeps a checked-in world ROS-free and
therefore runnable in a pip-only environment where the bridge is not registered at all. Transport
describes how a run is deployed; the world describes the experiment. Idempotent, so a world that
does declare one is left alone.
Namespacing. One bridge serves the whole world. Each endpoint carries the namespace its
producer declared (usually the spawn plugin’s namespace: config, inherited via the entity meta),
and the backend attaches it to that endpoint’s topic (/robot1/odom), TF frames
(robot1/base_link), and action name. The bridge-level namespace config is an optional global
outer prefix for the whole sim; the owner filter remains for deliberately splitting endpoints
across several transports.
Hardwired topics. A producer can pin an endpoint’s topic to an absolute name that ignores the
namespace, so the sim matches an external / hardware topic layout exactly. Any endpoint-producing
plugin accepts a topics: map keyed by endpoint role name — topics: {image:
/camera/color/image_raw, joint_states: /joint_states} — read via Plugin.topic_override(name)
(roqsim/plugin.py) when it fills the backend topic. An absolute topic (leading /) is
published verbatim by the ROS backend (_resolve_topic in ros2_bridge.py), bypassing
ep.namespace (and the node namespace); a relative topic renames the endpoint inside its
namespace — how a robot manifest gives a second scanner the vendor’s scan2 under the robot’s
namespace. Only the topic is overridden — TF frames stay namespaced. For example, a robot model can hardwire /joint_states
and /camera/color/image_raw in its manifest so a sim world is a drop-in for the matching real
robot + its operator UI (at the cost of being single-arm; see the manifest note).
Zero-copy / FPS. Message objects are preallocated once per endpoint and refilled each tick
(reuse_messages, safe for inter-process subscribers); numeric arrays are handed to the message as
matching-dtype numpy buffers (one C-level copy) instead of per-element Python loops. Adding a new
backend (zenoh, zmq) is a new BridgeBase subclass + its own registry — robots and worlds are
unchanged.
14. Glossary & FAQ¶
spec vs model vs data:
MjSpecis the editable scene description (mutate inbuild);MjModelis the compiled, immutable model;MjDatais the mutable simulation state.build phase: everything before
spec.compile()— the only time to change scene structure.driver: the code that owns the loop and calls the engine (standalone runner or the scenario-execution adapter).
gate: a named barrier for synchronous mode (§10).
Why a command queue instead of a lock? So plugin authors never reason about locks and mutation order is deterministic; the physics thread is the sole writer.
Can I spawn arbitrary bodies at runtime? Not by recompiling. A world declares everything a trial may bring in, and
SpawnEntity/DeleteEntityactivate one of those rather than creating it: seeroqsim.presence. An absent entity keeps its pose and is excluded from raycasts, rendering and contacts, and from whatGetEntitieslists. Parking it out of sight instead is the obvious alternative and is worse – a free body accelerates under gravity for as long as it is away, so it returns with whatever velocity it accumulated. An entry that registers an entity — any of them, which is whatprovides_entitydeclares — takespresent: falseto start it absent, which is how a world provides the spares for something that appears mid-trial; the declared value is re-applied on reset, because presence is amodelfield andmj_resetDatarestoresdata. Both are done from the engine, for the plugin’s declared entity, rather than by each plugin: only two ever implemented it, and the rest accepted the key and dropped it. A plugin whose entity is registered by something under it — a population likeboxes, whose instances are the entities — forwards it to them, since the engine sees the entry and not what the entry made. Absence also freezes the entity — compensated gravity, zeroed velocity — since taking a free body out of the contact set otherwise puts it in the very free fall the parking trick was rejected for. The gravity half is armed once per world, before compile, because MuJoCo decides there whetherbody_gravcompis live at all.