Running the R1 simulator¶
The R1 Pro in Gazebo Harmonic on ROS 2 Jazzy, on hardware you control. Two supported platforms — Linux (a box or a VM) and macOS on Apple silicon — with different capabilities, and one launcher since BIN-226.
Despite the repo being called galaxea_isaac_moveit, this is Gazebo, not Isaac. The sim
lives on that repo's main branch — the unified tree since BIN-276, selecting per host by
$ROS_DISTRO. It used to be jazzy, back when main was an older Ignition Fortress / ROS 2
Humble port that could not start on Jazzy; that port is gone, and jazzy was merged into main
and deleted upstream (BIN-478), so a git checkout jazzy from an older runbook now fails
with a missing-ref error. frozen/jazzy is the snapshot.
Which platform do you want?¶
| Linux (bare metal or VM) | macOS (Apple silicon) | |
|---|---|---|
| How | r1ctl install → r1ctl up |
the same two commands — r1ctl picks a backend from uname |
| ROS | system /opt/ros/jazzy |
micromamba env (RoboStack), ROS under $CONDA_PREFIX |
| Rendering | OpenGL on an X display (:0) + a GPU |
Metal, no X server, no VM |
| RGB cameras + lidar | ✅ | ✅ |
| Depth cameras / point clouds | ✅ | ❌ not available — see why |
Grasp perception (perceive, grasp) |
✅ (needs depth) | ❌ — do it on Linux or on the robot |
| Real-time factor | ~1× | ~0.27× at the default 100 Hz physics |
| The robot-side MCP servers beside it | ✅ | ✅ — same session, same ports |
| A brain beside it | ✅ | ✗ — the brain runs elsewhere and connects over the tailnet |
Short version: if you need grasping or the Visual Command overlay, you need the Linux path. If you want to iterate on navigation, MoveIt, controllers or the planner with a real robot model in the loop, the Mac path is far more convenient and runs entirely on a laptop.
- Standing up the whole stack (sim + robot MCP servers + a brain) → Set up your own robot + sim.
- The robot-side servers and every
r1ctlknob → the R1 robot stack.
One launcher, two backends¶
There is no macOS launcher and no separate sim script to run by hand. r1ctl reads
uname and sources one backend:
Behaviour differences are plat_* function hooks, data differences are PLAT_* variables, and
the darwin backend is what drives the sim/mac_*.sh scripts — you do not call them yourself on
the normal path. R1_PLATFORM=linux|darwin forces a backend. So every command on this page is
the same command on both:
./r1ctl install # clone/pull the services + the sim, build the venvs, write the .env files
./r1ctl up # (re)launch everything — also the reset; auto-installs on a fresh host
./r1ctl status # a SERVICE / PORT / STATE table
./r1ctl logs [win] # tail 60 lines (default: grasp)
./r1ctl down # stop the MCP layer — deliberately LEAVES the sim running
This replaced three overlapping launchers (r1ctl, a 498-line sim/mac_r1ctl fork, and
sim/mac_mcp_run.sh, which launched the same seven ports sharing zero function names with
either — so no diff ever revealed the duplication). Both forks are deleted. sim/fish/*.fish
are thin wrappers over r1ctl, not an alternative to it.
A window that died left its command behind as a file
r1ctl never types a launch command into a pane. It writes each one to
$R1_CMD_DIR/r1_<window>.cmd.sh (default /tmp) and sends a single bash <file> line.
So the post-mortem for any window — on either platform, sim or MCP — is:
cat /tmp/r1_moveit.cmd.sh # exactly what that window ran; survives the window dying
bash /tmp/r1_adapter.cmd.sh # edit it, re-run it by hand: the fastest way to debug a launch
Each body sets set -m and traps HUP TERM INT to kill its whole process group, so closing a
window takes down what it started (BIN-227 #2). A side effect worth knowing when you read
tmux list-panes: every pane's current command is bash, the wrapper — which is why the
dead-window probe asks whether the pane's shell has children rather than treating a shell as
death (BIN-229).
Two platform differences are real and deliberate, not accidents waiting to be unified: on macOS
r1ctl starts galaxea's MCP servers itself (so down frees their ports; on Linux galaxea's own
start_mcp_local.sh owns them), and "is the sim installed?" means a cloned repo on Linux but
a built colcon workspace on macOS.
Linux — a box or a VM¶
The sim is managed by r1ctl, exactly as on the robots: with SIM_ENABLE=1 (the default),
r1ctl up asks — Launch the Gazebo sim now? [y/N], defaulting to no — and on y
brings up the bundled sim/gazebo_sim.sh in a robot tmux session. It reuses a running
session if it finds one, and deliberately leaves it up on r1ctl down (it is slow to boot).
Off a TTY it launches only with SIM_AUTOLAUNCH=1, so a scripted bring-up that omits
that flag skips the sim and then fails the MCP port assert.
Follow Set up your own robot + sim for the ordered walkthrough. Two things decide whether it works at all, and only one of them is this page's:
Cameras need a GPU and an X display on :0
Gazebo's camera sensors render on the GPU through X. A headless VM, or one without GPU
passthrough, runs the sim perfectly — physics, MoveIt, Nav 2, the robot spawns — and
publishes no camera frames at all. Nothing fails loudly. sim/gazebo_sim.sh pushes
DISPLAY into the sim session and warns when it isn't reachable, but continues.
RViz needs it too (it dies with no Qt platform plugin "xcb" without one).
The other is the colcon-workspace path — historically the single most common fresh-machine
failure, because /home/ros/colcon_ws is hardcoded as the default and two different variables
select it: sim/gazebo_sim.sh reads COLCON_WS, galaxea's install_sim_deps.sh reads WS. That
belongs to the step where you edit r1.env:
Set up your own robot + sim → the simulator and the robot-side servers.
One workspace, two names — a disagreement now stops the launcher (BIN-149)
r1ctl used to pass neither variable, so off the RAP layout you had to set both, by hand,
to the same path, and a mismatch was invisible until every ROS window died with
package 'r1pro_sim' not found. Three things changed, and none of them invents a location:
- Set either one and the other follows — they cannot disagree by omission any more.
- Set both to different paths and
r1ctlexits, naming which script reads which. That state is unresolvable: the sim would be built in one workspace and sourced from the other. - Set neither and the inherited
/home/ros/colcon_wsisn't creatable by you, andupsays so before anything long runs. The failure it replaces wasinstallreachinginstall_sim_deps.shand dying onmkdir: cannot create directory '/home/ros', forty lines into pip output.
With neither set on a host where the default is right (rap-0/rap-1, where the user really is
ros), nothing changes.
The python3 a tmux pane resolves is not the one your shell resolves
Every ROS Python node starts #!/usr/bin/env python3, so it runs whatever python3 the pane
resolves — and a tmux pane starts a fresh interactive bash that re-sources ~/.bashrc. On a
host whose rc auto-activates conda, that is conda's interpreter, which has no PyYAML:
[rosbridge_websocket-1] import yaml
[rosbridge_websocket-1] ModuleNotFoundError: No module named 'yaml'
rosbridge died and :9090 never opened while the other four galaxea servers came up fine —
they run their venv's interpreter by path. The operator's instinct does not work here:
conda deactivate in the shell that runs r1ctl changes nothing, and neither does anything
r1ctl exports, because the pane re-reads the rc file either way. Measured on the host, a
pane's /proc/<pid>/environ looked clean while its prompt read (base).
So up preflights the question the way the pane will ask it (bash -ic), before launching
the ROS servers, names the interpreter an interactive shell resolves, and gives the remedy that
does work:
Fatal, with R1_ROS_PYTHON_CHECK=0 to opt out. The probe runs under setsid with stdin from
/dev/null: bash -i enables job control and calls tcsetpgrp on the controlling terminal,
which left r1ctl outside the foreground process group and stopped the whole run with
SIGTTOU — with the sim launched and nothing else. No setsid (macOS has none), no probe: it
skips with a notice. This is a Linux-only hook (PLAT_ROS_PYTHON_FROM_SHELL) not because the
hazard is Linux-specific but because every macOS pane sources sim/mac_ros_env.sh and
activates its own env, so the ambient shell's python is never what runs a node there.
rosdep is the whole dependency mechanism — and it used to need root to work at all
The sim's ROS dependencies come from the packages' own package.xml declarations, resolved by
rosdep install --from-paths. That used to require a one-time sudo rosdep init, because
rosdep's sources live under /etc. On a clean Ubuntu 24.04 host where it had never run, sudo
wanted a password, install_sim_deps.sh ran it as ${SUDO} rosdep init 2>/dev/null || true, and
the failure vanished. rosdep then resolved nothing while the build succeeded and the sim
started.
How that presents, hours later: r1ctl up exits 0, all seven MCP ports listen,
sim ROS stack up. is printed, /clock advances, joint_state_broadcaster is active,
MoveIt advertises /move_action — and no arm or gripper controller is loaded, so every
trajectory is accepted and nothing moves. Bringing the sim up that way took six apt packages
found one failure at a time, and five of the six were correctly declared
(BIN-231). Nothing was missing from the
declarations; the thing that reads them could not run.
Two things now stand between you and that afternoon:
binabik-r1-host/sim/rosdep_env.shremoves the root requirement.r1ctl installpoints rosdep at a user-writablesources.list.dand exportsROSDEP_SOURCE_PATH, whichinstall_sim_deps.shinherits — norosdep init, no/etc, no sudo for resolution. A host whose/etc/ros/rosdepis initialised keeps its own config.install_sim_deps.shrepeats the verdict at the end, next toDone., naming the symptom rather than the exit code.ROSDEP_STRICT=1makes an unusable resolver a non-zero exit for provisioning scripts.
The same mechanism runs on macOS. RoboStack ships rosdep with a conda installer plugin, so
rosdep install resolves depth_image_proc to ros-jazzy-depth-image-proc and installs it with
micromamba. mac_sim_setup.sh used to carry a hand-written 21-package list instead; it was a
second source of truth beside package.xml and had drifted, missing nav2_behaviors, moveit_py
and two more. One mechanism, one list, both platforms.
An ERROR from the rosdep step now means something — it did not before (BIN-470)
Until 2026-09 every run printed six ERROR: the following packages/stacks could not have
their rosdep keys resolved lines, then #All required rosdeps installed successfully, then
exited 0. All six were expected, so the block taught everyone to skim it — and that is the
defect, because a seventh, real one printed in the same place, with the same words, and
exited 0 too. rosdep install is run with -r (robust), and robust covers resolution failures
as well as install failures, so the exit code was never the signal.
Three of the six were not even ours: install_sim_deps.sh resolved the whole $WS/src, and the
sim image's colcon workspace also holds another robot's summit_xl_* packages, whose catkin
and robotnik_sensors keys are a ROS 1 build tool and a Robotnik-internal repo. It now resolves
only the sim repo's own packages, minus the ones this host does not build. The two that are
ours and genuinely resolve nowhere — hdas_msg (Galaxea's private messages) and
python3-google-genai (installed by the pip step) — print as one summary line.
Anything else that fails to resolve is now named in the end-of-run banner as
rosdep: unresolved, next to Done., where the other dependency verdicts are read;
ROSDEP_STRICT=1 makes it a non-zero exit for provisioning and CI. So: if you see a rosdep
ERROR block today, read it — nothing on it is expected.
The arm groups' IK solver is an apt package, and rosdep structurally cannot install it
kinematics.yaml gives six of its seven solver-configured planning groups — every group
with an arm in it — trac_ik_kinematics_plugin/TRAC_IKKinematicsPlugin; only torso uses KDL.
A host without ros-jazzy-trac-ik-kinematics-plugin therefore has no arm IK solver at all,
and the symptom is a long way from the cause: move_group starts, advertises /move_action
and plans joint-space goals fine, while every pose goal comes back with no IK solution — which
reaches you as grasp-service IK found no solution / pre_grasp_failed, i.e. as a
grasp-tuning problem.
r1pro_moveit_config declares the dependency correctly, and rosdep still cannot act on it,
for two deliberate reasons that each suffice alone: --ignore-src finds the repo's vendored
trac_ik/ sources — that is the key — and treats it as satisfied from source; and the sim
must not compile those sources, because that would put a second provider of the plugin in the
overlay ahead of the apt one. install_sim_deps.sh therefore installs the apt package itself
(idempotently) and says so loudly at the end of the run if it could not. By hand, on a container
you set up yourself:
Then restart move_group — the solver is read when the plugin loads, so a rebuild alone does
not pick it up.
Also worth knowing before you build:
-
Do not check your work with
colcon build --packages-select r1pro_*. Narrowing the build makes colcon stop enforcing dependency ordering for everything you left out, sor1pro_moveit_configbuilds "successfully" whether or not its dependencies exist — and a missing runtime plugin like TRAC-IK, which nothing links against, cannot fail a build at all. A green--packages-selectbuild says those packages compile; it says nothing about whether this host is set up. That is whatinstall_sim_deps.shis for (BIN-470). -
--symlink-installworks again, because setuptools is pinned. The sim requirements pull setuptools ≥ 80, which removed thesetup.py developthat--symlink-installneeds — soinstall_sim_deps.shused to defeat its own build. It now pinssetuptools<80(and drops the flag with a warning if something forces a newer one), which is what keeps launch/config edits taking effect on the robot without a rebuild (BIN-144). Building by hand, pin it the same way. - RMW is the default FastDDS. The Zenoh/CycloneDDS configs in the tree are unused; don't chase a DDS setting for a missing camera.
- What comes up: windows
sim,moveit(with RViz),nav2,perceptionin therobotsession (plus amonitorshell created first, soDISPLAY/BINABIK_SECRETS_FILE/SAM3_SERVER_URLcan be pushed into the session before the others exist). Camera topics are/head_camera/image(+depth_image, +left_/right_wrist_camera/*), bridged gz→ROS bygz_ros_bridge. -
The sim's camera names are not the robot's, and the vision service defaults to the robot's. Enumerated on rap-1 (BIN-302): the sim publishes three cameras — head, left wrist, right wrist — each with RGB, depth and an organized cloud. The real R1's head camera is the ZED (
/zed2i/…, BIN-256), and those arebinabik-r1-vision's defaults, so on a Gazebo host the service subscribes to topics that will never publish unless you say otherwise.R1_VISION_SIM_CAMERAS=1configures all three sim cameras in one switch;R1_VISION_RGB_TOPIC/R1_VISION_CLOUD_TOPICstill configure the head camera alone.The symptom of getting this wrong is silence, not an error: the subscription is made, the callback never fires, and the old code said "no camera frame received yet" — which is also what a camera that is merely warming up says.
list_camerasnow separates them by publisher count, so "nothing publishes this topic" is a distinct answer from "nothing has arrived yet". -installis no longer quietly tolerant of a broken galaxea checkout. A failed galaxea install used to go into a parenthetical withinstall done.three lines later — and thenupmet a missing:8001/:8003/:8004with the cause long scrolled away. Those are the portsupcalls required, so it is fatal now. Two specific repairs came with it:installpassesSIM_PKGS_DIRto galaxea's installer (its own default is a path from the machine it was written on, which exists on no r1ctl-managed host, so every fresh install silently went withoutopen3d/scipy/google-genai), and it heals a submodule that git calls fine but whose working tree is empty — the state an interruptedclone --recurse-submodulesleaves, whichgit submodule update --init --recursiveis a documented no-op against, because it compares commits and never looks at the files. Only--forcerewrites them, and that is whatinstallnow runs.
Full detail — every window, port and env var, and the RAP-specific settings — the R1 robot stack.
macOS, Apple silicon¶
A native path via RoboStack (micromamba, ROS 2 Jazzy, Gazebo Harmonic built for osx-arm64). No X server, no Docker, no VM — Gazebo renders through Metal. Verified end-to-end: the robot spawns, physics steps, three RGB cameras and the lidar publish, arm and gripper controllers activate, MoveIt and Nav 2 run.
First, micromamba — and that is the only prerequisite¶
brew install micromamba
# …or: curl -Ls https://micro.mamba.pm/api/micromamba/osx-arm64/latest | tar -xvj bin/micromamba
mac_sim_setup.sh creates the ROS 2 Jazzy env itself when it is missing — ros-jazzy-desktop
from robostack-jazzy with conda-forge alongside (verified against a working install at
0.11.0), built for osx-arm64. On a machine that already has one it finds it rather than
assuming a name (next box); ROS_ENV overrides.
This changed, and it changed because it cost someone an afternoon
The script used to only micromamba activate ros_env and this page told you to create the
env — in a paragraph above the code block people copy. So a fresh Mac failed on
micromamba activate, three paragraphs away from its own fix, and read as "the sim doesn't
work on my machine". The script now creates it and only asks you to do it by hand if that
fails (BIN-222).
Don't hardcode ros_env — source sim/mac_ros_env.sh instead
A machine that has both a Humble and a Jazzy RoboStack env very often has the Humble one
called ros_env, and activating it gives you a workspace that cannot find its own packages —
with nothing in the error pointing at the env name (ros-jazzy-moveit =* * does not exist, for
all 16 packages, because ROS_DISTRO came out humble and the channel became
robostack-humble). sim/mac_ros_env.sh autodetects the env that really is Jazzy, via
RoboStack's distro-stamped activate.d hook, and every script and every tmux pane resolves the
env through it. In a shell of your own:
source sim/mac_ros_env.sh # activates the Jazzy env + the workspace overlay + the gz pins
ros2 topic hz /head_camera/image
It is idempotent, which it needed to be: r1ctl sources it twice for the vision window
(once in the pane prelude, once through VISION_ROS_SETUP).
ROS_ENV still overrides the name.
Then two commands — the same two as on a robot¶
cd <your binabik-r1-host checkout>
./r1ctl install # once. Several GB, idempotent, safe to re-run to refresh + rebuild.
./r1ctl up # the sim + its ROS stack, then the MCP layer — both in tmux
install is the whole setup, and the darwin backend routes it: it clones the service repos,
builds the adapter and grasp venvs, runs sim/mac_mcp_install.sh for galaxea's three venvs, and
runs sim/mac_sim_setup.sh for the ROS env, the rosdep pass, the colcon build, the camera
downgrade and the fcl repair. The first run pulls Gazebo Harmonic and ~300 packages; re-runs are
quick no-ops. up auto-installs on a fresh host, so ./r1ctl up alone also works.
mac_sim_setup.sh lays the workspace out at ~/binabik-sim/colcon_ws (SIM_WS) and builds the
r1pro_* packages — the closure up to r1pro_nav2, and not quite all of them.
SIM_ENV_REPAIR=1 (the
default) removes and rebuilds a partial env — one with no interpreter, which is what an
interrupted install leaves; 0 stops instead.
It needs SSH access to the org — and there is a path if you don't have it yet
r1ctl install clones from $GITHUB_ORG (git@github.com:binabik-ai) and sets a global
url.…insteadOf rewrite so uv can fetch the private robot-mcp-kit. Without a key, run
bash sim/mac_sim_setup.sh directly — it clones over HTTPS on purpose, because
git@github.com: Permission denied (publickey) on a machine whose HTTPS fetch had just
succeeded is a confusing way to learn you have no key (BIN-223). That gets you the sim and the
MCP repos, but not the adapter/grasp venvs; follow up with ./r1ctl install once you have a
key, or point GITHUB_ORG at the HTTPS base and gh auth login.
Where the repos live. mac_sim_setup.sh asks, suggesting the directory that already contains
binabik-r1-host — your existing checkouts, so an edit is testable by restarting the MCP layer,
with nothing to copy and nothing to keep in sync. An existing git checkout is used as-is: no
fetch, no reset, so uncommitted work is safe; only what is missing gets cloned. The answer is
remembered in ~/binabik-sim/mcp_root, which platform/darwin.sh reads as its PLAT_RAP_ROOT
default. MCP_SRC_ROOT answers the prompt for a scripted run; an explicit RAP_ROOT in r1.env
wins over both. Note r1ctl install clones before the question is asked, using that same
default — so the two agree unless you answer with somewhere else, in which case set RAP_ROOT
explicitly. Accepting the default ~/binabik-sim/mcp gives you an independent set of clones,
which is all a newcomer needs.
Two of galaxea's servers are git submodules
If you do point setup at a workspace several people share, note that galaxea_agent carries
submodules pointing at external repos. Initialising those inside a shared tree is the
collision the workspace rules exist to prevent — fine on a
single-owner laptop, use the default on a shared box.
What comes up on a Mac¶
Session robot: monitor · sim · moveit · nav2. Session r1: the MCP servers,
detached (below).
simdelegates tosim/mac_sim_run.sh, so every macOS-specific fix — thegzserver/GUI shim, the loopback discovery pin, the plugin path,SIM_PHYSICS_HZ— lives in exactly one place.moveitismove_group.launch.py backend:=gazebo. RViz is off by default here (Linux has it on):RVIZ=1opens it.nav2isnav2.launch.pyin SLAM mode, guarded onr1pro_nav2being built. Bring-up is slow — the lifecycle manager configures serially andbt_navigatoralone can take ~15 s, so allow ~2 minutes and expect goals refused before that.- No
perceptionwindow, deliberately.r1pro_perceptionconsumes depth and point clouds, and this platform has neither; the window would start and produce nothing.
sim/mac_sim_run.sh alone is Gazebo ONLY — r1ctl deliberately never picks it
Run it (or the simrun wrapper) when you want the sim's output in front of you. What you give
up: no move_group and no nav2, so the MCP layer binds every port and every manipulation
and navigation call fails against a stack that looks healthy. A brain cannot attach to it at
all — Gazebo speaks ROS, a brain speaks MCP. r1ctl launches sim/mac_robot_stack.sh, which
runs mac_sim_run.sh in its own window and adds MoveIt and nav2 around it.
Driving Gazebo by hand, MoveIt and RViz are separate launches:
Useful knobs: SIM_HEADLESS=1 (server only, no GUI window), SIM_PHYSICS_HZ, SIM_WS,
ROS_ENV, RVIZ=1.
No depth cameras on the Mac¶
The R1's three head/wrist sensors are rgbd_camera, and the depth pass crashes OgreNext's
Metal backend (Ogre2DepthCamera::Render → SIGABRT), taking the whole gz server with
it. So mac_sim_setup.sh rewrites them to plain colour cameras — in the build tree only,
never committed, so Linux and the robots keep depth.
Consequence: no depth images and no point clouds locally, therefore no geometric grasp
perception. RGB cameras and the gpu_ray lidar render fine. Do grasp work on Linux or on the
robot.
Why the scripts look odd — nine macOS-specific things they handle¶
Do not "clean these up"; each one is a failure that was diagnosed the hard way.
- No
set -u. RoboStack'sactivate.dhooks — which mamba re-runs after any install — reference unbound variables (CONDA_BUILD,MAMBA_ROOT_PREFIX);nounsetaborts on them. A fresh non-login bash also needsMAMBA_ROOT_PREFIX+MAMBA_EXEpinned before the shell hook. GZ_SIM_SYSTEM_PLUGIN_PATHmust include$CONDA_PREFIX/lib.sim.launch.pyhardcodes the Linux/opt/ros/jazzy/libfor thegz_ros2_control-systemplugin; on a Mac that dylib is in the conda lib dir.- Build without
--symlink-install. RoboStack ships setuptools 83, which removedsetup.py develop --editable; the oneament_pythonpackage (r1pro_controller) fails otherwise.tests/test_sim_scripts.shguards that the flag stays absent. (The Linux path has the same bug and no guard — BIN-144.) - Pin
GZ_IP=127.0.0.1andROS_AUTOMATIC_DISCOVERY_RANGE=LOCALHOST. This is the hardest failure to diagnose: gz-transport and DDS discover over UDP multicast, and with Tailscale up the default224.0.0.0/4route points at autun*interface, so multicast fails (Can't assign requested address). The gz server then never advertises its services,ros_gz_sim createhangs on/world/pick_place/create, and the robot never spawns. If nothing spawns, check this first. - The server and GUI are two processes.
gz simcannot run both in one process on macOS (gz-sim#44), butsim.launch.pycallsgz sim -r(combined) and dies with exit 255. The run script puts agzshim onPATHthat forces-s(server only) for the launch's call, then startsgz sim -gseparately — so closing the Gazebo window leaves the sim running; Ctrl-C stops it. - RGB-only cameras — see above.
- Physics rate, for real-time factor. Gazebo's ODE physics is single-threaded, so
more cores don't help — only per-step cost does. The shared world steps at 250 Hz, giving
only ~0.11× RTF on an M4.
mac_sim_setup.shrewrites the step size (build tree only) viaSIM_PHYSICS_HZ: 100 (default) → ~0.27× and matches the 100 Hz controller, 50 → ~0.55× with coarser contacts, 250 → original RAP-grade accuracy. The server pins about one core, so it is physics-bound, not render-bound. fclgets rebuilt, ormove_groupSIGSEGVs on startup. conda-forge ships alibfclbuilt against the Eigen 3.4 ABI while RoboStack's MoveIt is built against Eigen 5 — soCollisionEnvFCLdies the moment it touches a BVH model, and the symptom is amoveitwindow that vanishes instantly with no useful message. Setup probes the exact FCL call path with a tiny compiled program (a segfaulting probe is the expected signal, not an error) and rebuildsfclagainst the live Eigen when it fails. It needs Xcode Command Line Tools forclang++, and pins the macOS SDK explicitly, because under an activated conda env system clang's SDK autodetection can fail to find libc++. Ifmove_groupdies instantly, re-runbash sim/mac_sim_setup.sh— that is whatup's own timeout hint says too.- Probe for a binary with a path test, never a bare
command -v.micromamba shell hookleaves a micromamba shell function behind, so on a second sourcecommand -v micromambaanswers with the bare word rather than a path and[ -x … ]is false.mac_ros_env.shused to then declare the binary missing on a machine that has it — putting a false, alarming diagnosis at the top of a window's log, above the real cause (an unresolvable Gemini key). The first thing an operator reads sent them to the wrong file. It usescase /*now, and returns early when the env it would activate is already active.
Only two files are rewritten in the build tree now — and the third fix went to both platforms (BIN-235)
Setup rewrites the camera sensors and the physics timestep, and nothing else. nav2_params.yaml
used to be a third: setup stripped the stvl_layer costmap plugin (spatio_temporal_voxel_layer
has no osx-arm64 build, and a configured-but-unloadable plugin takes all of nav2 down with it)
and sed'd the behaviour-tree XML paths from /opt/ros/<distro>/share/… to the conda prefix.
r1pro_nav2/launch/nav2.launch.py now does both at launch time — conditionally, so a path that
does resolve and a deliberate override both survive.
Two consequences beyond the tidiness: a tracked file is no longer permanently modified in
your checkout, and Linux gets the same repair, where that layer is equally absent until
rosdep has installed it — which is exactly why nav2 sat inactive on a fresh Ubuntu test host.
Two ways the old failure presented, both far from the cause: an unloadable costmap plugin, and
bt_navigator failing on activate (not configure) with
Couldn't open input XML file: /opt/ros/jazzy/share/nav2_bt_navigator/…. In the second,
planner_server and controller_server are ACTIVE and /navigate_to_pose is even listed by
ros2 action list, while every goal is refused with "Action server is inactive. Rejecting the
goal." — so navigate_to_named just answers ok: false.
What the Mac workspace does and doesn't build¶
The closure is --packages-up-to r1pro_nav2
colcon build --packages-up-to r1pro_nav2 — description, controller, moveit_config, sim and
nav2 — so macOS matches the Linux path, where a bare colcon build means gazebo_sim.sh can
launch its nav2 window unconditionally. Building only up to r1pro_sim, as setup once did,
silently left r1pro_nav2 out, so navigation was simply absent and every nav MCP call
answered nav reported no base pose.
Still excluded on purpose: r1pro_perception (consumes the depth this platform has not, and
pulls pip-only open3d/scikit-learn/google-genai unverified on osx-arm64) and
r1pro_pickplace. Nothing in the Mac stack needs them — r1-manip-mcp imports
r1pro_perception lazily and answers a clean "package not found". SIM_BUILD_UP_TO overrides
the closure if you want to try.
A comma decimal in the world file kills physics invisibly
SIM_PHYSICS_HZ is turned into a step size by awk, and awk's %g uses the locale's
decimal separator. Under a locale like fi_FI that emitted 0,01, which SDF cannot parse — so
the sim came up looking entirely healthy while physics never stepped: no /clock advance, no
controllers, no /joint_states, and every downstream failure pointing somewhere else. The setup
script pins LC_ALL=C for that computation and verifies the result, but the failure mode is worth
knowing because it is the same shape as the orphaned-server one below: the sim reports success and
behaves wrongly, which costs far more than a sim that fails loudly. If physics looks frozen, check
the timestep in $SIM_WS/src/galaxea_isaac_moveit/r1pro_sim/worlds/pick_place.world first.
Stopping the sim means stopping its processes
tmux kill-session is not enough on either platform: ros2 launch spawns a gz sim -s
server that outlives the pane, so the next launch adds a second server on the same gz/DDS topics
— and one of them can be serving a world that was repaired on disk minutes ago. Use the
launcher's own teardown, which reaps the processes and tells you whether any survived:
The shared galaxea_isaac_moveit checkout must stay on main
mac_sim_setup.sh creates a detached git worktree at origin/main under
$SIM_WS/src, off your existing local checkout — it does not re-clone. Since BIN-276
main is the unified tree, so the worktree and the shared checkout want the same branch
and there is nothing to switch. (Before the collapse this said origin/jazzy, and warned
against git checkout jazzy in the shared checkout because that clone is single-branch.)
Re-running setup refreshes the worktree with reset --hard (not checkout), so the
camera rewrite never blocks a re-run.
Log noise that is benign¶
Do not chase these — all confirmed harmless:
Ogre Plugin … Rendering will not be possible— a conda-forge quirk; Metal loads and cameras render.- Missing world textures (
cardboard_box.pngand friends) — never shipped, cosmetic. gz_frame_id/vertical_fov"not defined in SDF" warnings.- A
[Dbg]line about a gripper collision that couldn't be created — a pre-existing model/ODE mesh quirk, also present on Linux; the fingers still collide.
Where the brain fits¶
The simulator is only the bottom layer. Above it sit the robot's MCP servers and then a brain:
- On Linux,
r1ctl upstarts the sim and every robot-side server, and you can run a brain on the same machine — Set up your own robot + sim, which covers the one non-obvious part (what to pass as--robotwhen both live on one host). - On macOS, the same
r1ctl upstarts the sim and every robot-side server too — a brain can drive the Mac sim, it just has to live elsewhere and connect over the tailnet (see below). Everythingbrainctl→ Running a brain.
A brain on the Mac sim¶
r1ctl up gives you the sim, its ROS stack and the MCP layer — which is what a brain needs,
because a brain speaks MCP and Gazebo speaks ROS. The ports and their bind addresses are the same
as on a robot:
| Port | Bound to | |
|---|---|---|
| rosbridge | 9090 | localhost — perception_mcp and ros_mcp reach the ROS graph through it |
nav2_mcp · perception_mcp · r1_manipulation · ros_mcp |
8001 · 8003 · 8004 · 8005 | localhost only — these are the robot's raw, stateful tools; only the adapter calls them |
r1-abstraction |
9220 | 0.0.0.0 — the brain connects here (127.0.0.1 when a serve proxy owns the port — why) |
grasp-service |
9210 | the same |
./r1ctl up # the sim + the MCP layer; prints the brainctl line with your
# tailnet name filled in
./r1ctl status # SERVICE / PORT / STATE
./r1ctl logs adapter # or grasp, sim, moveit, nav2, …
./r1ctl down # stop the MCP layer (the sim keeps running)
bash sim/mac_sim_run.sh # or the two halves: Gazebo alone, output in front of you …
SIM_ENABLE=0 ./r1ctl up # … then the MCP layer over it (see the warning above:
# Gazebo alone has no move_group and no nav2)
A sim that is already up is left alone across resets — r1ctl asks whether the robot session
exists, and reuses it. With the fish wrappers installed, r1all is the whole thing from any
directory. Nothing here touches tailscale serve — see the firewall case for when
you need that.
The MCP servers run detached, in tmux r1, so Ctrl-C does not stop them: use ./r1ctl down. Stop
the sim with its launcher, bash sim/mac_robot_stack.sh --kill — not tmux kill-session, which leaves
the gz sim server running and gives your next launch two of them (BIN-227).
Then on the brain host: brainctl up <you> --robot <mac-tailnet-name> --port 8100. Pick a port well
clear of the robot stack — r1ctl up frees nine ports on every reset (9090 8001 8003 8004
8005 9220 9210 9240 9230), so a brain published on one of those is killed by the next bring-up.
brainctl resolves the robot name at container-create time, so if the Mac's tailnet address
changes the instance has to be re-created — a docker restart will not pick it up.
No grasp on the Mac — and it is not a configuration problem
grasp-service starts here and answers; what it cannot get is depth, which this platform
cannot render (see No depth cameras). So perceive, locate_3d, capture_scene
masks and pixel_to_3d return nothing usable, and grasp needs all of them. There is no flag
to make this work and none to hide it: the tool is offered, and it fails on the perception step.
Works locally: navigation, waypoints, whole missions, planning, teaching, ask-back,
move_arm / goto_named / grippers (MoveIt is present), the Visual view's live RGB frame,
and — since KOE-33 — AprilTag marker poses (locate_marker / detect_markers), which
are the one 3-D answer this platform can give. Does not: anything grasp- or
depth-shaped, and the click-to-pick overlay (the frame arrives, the object list is empty).
Grasp verification stays on rap-1.
Markers are the exception for a reason worth knowing: their pose comes from mono corners + intrinsics + a known tag size, so it needs no depth at all. That was a design constraint rather than a happy accident — the Mac has no depth, so a cloud-based marker pose could not have been developed on the machine the work happens on. Measured here 2026-09-10: 1.7 mm of range error at 0.59 m against the world's ground-truth pose.
The segmenter chip says \"No vision\" here, and that is correct
binabik-r1-vision (:9230) is opt-in and off by default (VISION_ENABLE=0) on every
platform, so on a stock Mac bring-up nothing starts it and r1-abstraction serves its
image-only fallback: a frame with no objects. The brain reports that chain as
unavailable, whose badge is No vision — while the Robot dot stays green. It is not a
lost connection, and it is not the robot dropping out after having worked once.
Enabling it buys little on a Mac for scene capture: with no CUDA GPU and Modal off, its
fallback chain lands on the box-polygon tier, so every capture pays a grounder VLM call for
2-D boxes that pixel_to_3d still cannot lift into 3-D without depth.
It does buy markers, which is new with KOE-33 and is the reason to turn it on here:
locate_marker / detect_markers need no GPU, no grounder and no depth, and the sim world
carries a 36h11 tag at 1.60 m for exactly this. Note the head camera went 320x240 →
1280x960 with that work, so an 80 mm tag is detectable to ~0.75 m with OpenCV and ~3 m with
the optional pupil-apriltags extra; at the old resolution it was ~0.3 m, inside the
robot's own footprint. The larger frames need FASTDDS_BUILTIN_TRANSPORTS=UDPv4 — Fast DDS
drops any Image over 512 kB through shared memory, silently — and both the sim launch and
the vision service set it themselves.
And note what enabling it now commits you to (BIN-243): an opt-in you asked for and that died
is a failed bring-up. VISION_ENABLE=1 with a dead :9230 is a FAILED and a non-zero exit,
exactly like the adapter — the banner names the service, the pane to read and the flag to turn
off if you have changed your mind. That is why a missing :8005 only warns: nobody asked for it.
A service whose worker owns no port is checked the same way but through a dead-window
probe, so "it is up" can never mean a query server answers while the half doing the work is
dead. Nothing opt-in has such a window today (the episodic recorder, which did, was retired in
BIN-306) — the mechanism is kept for the next one.
The Robot dot stays red although every port is up¶
./r1ctl status shows every server listening, curl http://127.0.0.1:9220/sse answers on the
Mac, and the brain still shows the robot red. Ask the brain, not the Mac — loopback never
crosses a firewall, so the Mac will always tell you it is fine:
docker exec brain-<name> python3 -c "import urllib.request; print(urllib.request.urlopen('http://<mac-tailnet-name>:9220/sse', timeout=5).status)"
ConnectionResetError [Errno 104]— accepted, then reset. That is macOS's per-application firewall, which a managed configuration profile can enable with no per-app prompt for you to allow. Nothing is misconfigured in the stack.- a timeout, or
Connection refused— a different problem: the server really is down, or the Mac is not on the tailnet (tailscale status).
For the reset case, put a binary the firewall already trusts on the listener and let it forward to the server over loopback:
tailscale serve --bg --tcp 9220 tcp://127.0.0.1:9220
tailscale serve --bg --tcp 9210 tcp://127.0.0.1:9210
Idempotent, survives an r1ctl down / up cycle, and changes nothing brain-side — the brain still
connects to <mac-tailnet-name>:9220. tailscale serve status lists them, tailscale serve --tcp
9220 off removes one. Creating them is deliberately not done by r1ctl: on a machine without
the firewall the plain 0.0.0.0 bind is what you want, and a script that quietly rewires your
tailnet's port mapping is worse than two lines of setup you can see.
Never run r1ctl up as root on a host with the proxies up
The reset runs a kill over every port it manages, and the holder of a proxied port is root's
tailscaled. As an ordinary user that kill is EPERM and harmless (verified); as root it
SIGKILLs the daemon and drops the tailnet out from under you. r1ctl does not guard against
this yet.
These proxies and a 0.0.0.0 bind fight over the port — and the restart order is the losing one
A serve proxy binds the tailnet addresses, v4 and v6. A server that then binds the wildcard
0.0.0.0 over them gets EADDRINUSE — errno 48 on macOS, 98 on Linux — and dies
instantly with error while attempting to bind on address ('0.0.0.0', 9220).
So the order matters: server first, then proxy works. Proxy already up, then r1ctl up
kills the adapter and grasp. And because the proxies are created with --bg they persist across
reboots, so every subsequent bring-up gets the failing order (BIN-234).
r1ctl up handles this for you now — it probes tailscale serve status --json per service and
passes R1_ABSTRACTION_HOST=127.0.0.1 / GRASP_HOST=127.0.0.1 when a proxy owns the port,
announcing it as it launches. Loopback under a proxy is strictly more private (only
tailscaled can reach the servers) and the brain connects through the proxy either way.
The probe is best-effort and fails open — no tailscale, no --json, an unresponsive daemon
all mean "no proxy" and the wildcard as before. When it misses, up fails loudly rather than
reporting success over a down table, because the symptom lies:
lsof -nP -iTCP:9220 -sTCP:LISTEN # nothing — non-root lsof cannot see tailscaled's
# sandboxed system extension on macOS
netstat -an | grep '\.9220 ' # shows it. Use this when a port looks free but a bind fails.
netstat -van -p tcp | grep '\.9220 ' # names the holder: io.tailscale.ipn
On Linux the listeners are plainly visible — ss -ltnp shows them against the tailnet
addresses with tailscaled's pid — so this only reads as a mystery on macOS. The collision
itself is not macOS-specific; it was reproduced on Ubuntu 24.04 on 2026-08-06.
If you would rather manage it by hand: take the proxies down before r1ctl up and put them back
afterwards, or set R1_SERVE_PROXY_DETECT=0 to keep the wildcard unconditionally.
install or up fails¶
r1ctl prints nothing at all and exits non-zero. That is r1.env, which is sourced first
thing, under set -euo pipefail, before r1ctl prints a word — so a line in it that fails used
to take the launcher down in total silence, and every mistake in the one file you are invited to
edit looked identical to "r1ctl is broken". It now names the file, the line number and the
command. The specific trap, hit for real on this platform:
A command substitution's subshell inherits set -e, so any non-zero command inside it aborts
the assignment — and mac_ros_env.sh probes a local_setup.bash that need not exist. The
identical line works written in front of the command, where it runs in your own shell:
Prefer a literal value in r1.env. R1_ENV=/dev/null ./r1ctl status is the quickest way to
confirm the file is the cause.
A window died and the log's first line is a lie. Read the pane body
(above) and be sceptical of a diagnosis at the top of the log: mac_ros_env.sh used
to open a dead vision window's log with "no micromamba binary found" on a machine that has it, above
the real cause. Fixed, but the shape recurs.
Both of these are fixed in mac_sim_setup.sh (BIN-222) and only affect a machine set up before it:
No module named pipduring the MCP dependency step — the ROS env ships without pip.micromamba install -y -n <env> -c conda-forge pip.fatal error: 'numpy/ndarrayobject.h' file not foundbuildingr1pro_perception, on a workspace that built fine earlier — an unconstrainedpip installreplaced conda's numpy 2.x with 1.26, whose C headers live elsewhere. Restore it (micromamba install -y -n <env> -c conda-forge numpy), delete the stalebuild/r1pro_perception, and rebuild.
The fish wrappers — shortcuts, not an alternative interface¶
r1ctl is not installed anywhere, so without them you need the repo path. Symlink them so they
follow the repo rather than drift from it:
| command | equals | reach for it when |
|---|---|---|
r1all |
SIM_ENABLE=1 SIM_AUTOLAUNCH=1 r1ctl up |
the one-command path, from any directory. SIM_AUTOLAUNCH=1 is the consent r1ctl up would otherwise stop to ask for |
mcprun (+ any r1ctl subcommand) |
SIM_ENABLE=0 r1ctl … |
a sim is already running and you want only the MCP layer. mcprun down / logs / status pass through, and --status is still translated for the deleted fork's spelling |
simrun |
bash sim/mac_sim_run.sh — Gazebo alone, which r1ctl deliberately never picks |
you want the sim's output in front of you, and you accept no move_group and no nav2 |
simsetup |
bash sim/mac_sim_setup.sh — the SSH-free half of r1ctl install |
you have no key for the org yet |
Symlinked and not copied on purpose — and the functions resolve their own path through
realpath, because status --current-filename reports the symlink, so a plain dirname lands
in ~/.config/fish/functions and finds no script (BIN-222).