Skip to content
AuthorPascal DateAugust 10, 2026 Rev1.2

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 installr1ctl up the same two commandsr1ctl 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.


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:

platform/linux.sh    a robot host or a Linux box  |  platform/darwin.sh   a macOS laptop (RoboStack)

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 asksLaunch 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:

  1. Set either one and the other follows — they cannot disagree by omission any more.
  2. Set both to different paths and r1ctl exits, naming which script reads which. That state is unresolvable: the sim would be built in one workspace and sourced from the other.
  3. Set neither and the inherited /home/ros/colcon_ws isn't creatable by you, and up says so before anything long runs. The failure it replaces was install reaching install_sim_deps.sh and dying on mkdir: 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:

conda config --set auto_activate_base false     # then re-run ./r1ctl up

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:

  1. binabik-r1-host/sim/rosdep_env.sh removes the root requirement. r1ctl install points rosdep at a user-writable sources.list.d and exports ROSDEP_SOURCE_PATH, which install_sim_deps.sh inherits — no rosdep init, no /etc, no sudo for resolution. A host whose /etc/ros/rosdep is initialised keeps its own config.
  2. install_sim_deps.sh repeats the verdict at the end, next to Done., naming the symptom rather than the exit code. ROSDEP_STRICT=1 makes 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:

sudo apt install ros-jazzy-trac-ik-kinematics-plugin

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, so r1pro_moveit_config builds "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-select build says those packages compile; it says nothing about whether this host is set up. That is what install_sim_deps.sh is for (BIN-470).

  • --symlink-install works again, because setuptools is pinned. The sim requirements pull setuptools ≥ 80, which removed the setup.py develop that --symlink-install needs — so install_sim_deps.sh used to defeat its own build. It now pins setuptools<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, perception in the robot session (plus a monitor shell created first, so DISPLAY/BINABIK_SECRETS_FILE/SAM3_SERVER_URL can be pushed into the session before the others exist). Camera topics are /head_camera/image (+ depth_image, + left_/right_wrist_camera/*), bridged gz→ROS by gz_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 are binabik-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=1 configures all three sim cameras in one switch; R1_VISION_RGB_TOPIC / R1_VISION_CLOUD_TOPIC still 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_cameras now separates them by publisher count, so "nothing publishes this topic" is a distinct answer from "nothing has arrived yet". - install is no longer quietly tolerant of a broken galaxea checkout. A failed galaxea install used to go into a parenthetical with install done. three lines later — and then up met a missing :8001/:8003/:8004 with the cause long scrolled away. Those are the ports up calls required, so it is fatal now. Two specific repairs came with it: install passes SIM_PKGS_DIR to 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 without open3d/scipy/google-genai), and it heals a submodule that git calls fine but whose working tree is empty — the state an interrupted clone --recurse-submodules leaves, which git submodule update --init --recursive is a documented no-op against, because it compares commits and never looks at the files. Only --force rewrites them, and that is what install now 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).

  • sim delegates to sim/mac_sim_run.sh, so every macOS-specific fix — the gz server/GUI shim, the loopback discovery pin, the plugin path, SIM_PHYSICS_HZ — lives in exactly one place.
  • moveit is move_group.launch.py backend:=gazebo. RViz is off by default here (Linux has it on): RVIZ=1 opens it.
  • nav2 is nav2.launch.py in SLAM mode, guarded on r1pro_nav2 being built. Bring-up is slow — the lifecycle manager configures serially and bt_navigator alone can take ~15 s, so allow ~2 minutes and expect goals refused before that.
  • No perception window, deliberately. r1pro_perception consumes 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:

source sim/mac_ros_env.sh
ros2 launch r1pro_moveit_config move_group.launch.py backend:=gazebo
ros2 launch r1pro_moveit_config laptop_rviz.launch.py

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::RenderSIGABRT), 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.

  1. No set -u. RoboStack's activate.d hooks — which mamba re-runs after any install — reference unbound variables (CONDA_BUILD, MAMBA_ROOT_PREFIX); nounset aborts on them. A fresh non-login bash also needs MAMBA_ROOT_PREFIX + MAMBA_EXE pinned before the shell hook.
  2. GZ_SIM_SYSTEM_PLUGIN_PATH must include $CONDA_PREFIX/lib. sim.launch.py hardcodes the Linux /opt/ros/jazzy/lib for the gz_ros2_control-system plugin; on a Mac that dylib is in the conda lib dir.
  3. Build without --symlink-install. RoboStack ships setuptools 83, which removed setup.py develop --editable; the one ament_python package (r1pro_controller) fails otherwise. tests/test_sim_scripts.sh guards that the flag stays absent. (The Linux path has the same bug and no guard — BIN-144.)
  4. Pin GZ_IP=127.0.0.1 and ROS_AUTOMATIC_DISCOVERY_RANGE=LOCALHOST. This is the hardest failure to diagnose: gz-transport and DDS discover over UDP multicast, and with Tailscale up the default 224.0.0.0/4 route points at a utun* interface, so multicast fails (Can't assign requested address). The gz server then never advertises its services, ros_gz_sim create hangs on /world/pick_place/create, and the robot never spawns. If nothing spawns, check this first.
  5. The server and GUI are two processes. gz sim cannot run both in one process on macOS (gz-sim#44), but sim.launch.py calls gz sim -r (combined) and dies with exit 255. The run script puts a gz shim on PATH that forces -s (server only) for the launch's call, then starts gz sim -g separately — so closing the Gazebo window leaves the sim running; Ctrl-C stops it.
  6. RGB-only cameras — see above.
  7. 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.sh rewrites the step size (build tree only) via SIM_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.
  8. fcl gets rebuilt, or move_group SIGSEGVs on startup. conda-forge ships a libfcl built against the Eigen 3.4 ABI while RoboStack's MoveIt is built against Eigen 5 — so CollisionEnvFCL dies the moment it touches a BVH model, and the symptom is a moveit window 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 rebuilds fcl against the live Eigen when it fails. It needs Xcode Command Line Tools for clang++, and pins the macOS SDK explicitly, because under an activated conda env system clang's SDK autodetection can fail to find libc++. If move_group dies instantly, re-run bash sim/mac_sim_setup.sh — that is what up's own timeout hint says too.
  9. Probe for a binary with a path test, never a bare command -v. micromamba shell hook leaves a micromamba shell function behind, so on a second source command -v micromamba answers with the bare word rather than a path and [ -x … ] is false. mac_ros_env.sh used 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 uses case /* 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:

bash sim/gazebo_sim.sh --kill        # Linux
bash sim/mac_robot_stack.sh --kill   # macOS

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.png and 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 up starts 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 --robot when both live on one host).
  • On macOS, the same r1ctl up starts 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). Everything brainctlRunning 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.0the 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:

# r1.env — FATAL
VISION_PYTHON="$( . sim/mac_ros_env.sh >/dev/null 2>&1; command -v python3 )"

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:

VISION_PYTHON="$( . sim/mac_ros_env.sh >/dev/null 2>&1; command -v python3 )" ./r1ctl up

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 pip during 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 found building r1pro_perception, on a workspace that built fine earlier — an unconstrained pip install replaced 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 stale build/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:

ln -sf "$PWD"/sim/fish/*.fish ~/.config/fish/functions/   # simsetup · simrun · mcprun · r1all
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).