Writing

Robotics / Physics Simulation · October 8, 2026 · 9 min read

VASIMOV: Building a Virtual Asimov 1 That the Real SDK Can't Tell Apart

A MuJoCo digital twin of Menlo's Asimov 1 humanoid that speaks the official UDP wire protocol, runs the official 78-obs to 23-action locomotion policy at 50 Hz, and labels every field it emits as real, approximate, or placeholder.

78 obs → 23 acttelemetry · 50 Hzhip_pitch · kneeUDP :8850fig. 02 — MuJoCo twin · Asimov 1

Menlo Research's Asimov 1 is an open humanoid: 25 actuated joints, a published MJCF model, an official Python SDK (menlo-sdk), and a pretrained locomotion policy on Hugging Face. What you don't get, unless you own the robot, is the robot. Everything that talks to Asimov 1 assumes there is firmware on the other end of a UDP socket.

So I built one. VASIMOV (Virtual Asimov 1) is a MuJoCo simulation that pretends to be the robot well enough that the unmodified menlo-sdk and menlo CLI connect to it, stand it up, and make it walk. Then I bolted on everything you can't get from real hardware: ground-truth telemetry, a browser dashboard, manual joint control, procedural environments, and flight-data recording.

I built it over about five days in early October 2026. The rule I set on day one was simple: never fake a number. If the sim can't produce a quantity honestly, it says so.

The goal: a twin that passes the SDK's own checks

There are plenty of ways to make a humanoid wobble in MuJoCo. The interesting constraint here was compatibility. A "digital twin" that needs its own client library is just another simulator. I wanted a developer to point existing Asimov code at 127.0.0.1 and have it work, which meant matching the real system at three levels:

  1. The wire. The SDK talks protobuf over UDP: commands in on port 8850, state out on port 8851 at 10 Hz. VASIMOV speaks exactly that, using the official asimov_protocol message definitions.
  2. The firmware state machine. DAMP → STAND → MOVE, a latched FAULT_DAMP on falls, a 2-second trajectory watchdog, a 2-second ramp into the standing pose. The SDK's preflight checks look at all of this.
  3. The policy contract. The official checkpoint Menlo/asimov1-locomotion-0818 expects a very specific 78-dimensional observation. Get one scale factor wrong and the robot falls over instead of walking.
25
actuated joints (firmware order 0–24)
78 → 23
policy obs → action dims
200 / 50 Hz
physics / policy rate
84
MuJoCo sensors exposed

Architecture: one physics truth, many views

Everything flows from a single MuJoCo instance stepping at 200 Hz (dt = 0.005 s, implicitfast integrator). Every fourth physics step the policy runs, so the control loop sits at 50 Hz, the same decimation the policy was trained with.

  1. Command
    SDK / CLI / dashboard
    UDP protobuf, keyboard, or REST.
  2. EdgeCore
    firmware state machine
    Validation, clipping, watchdogs, fault latches.
  3. Observation
    78-D, every 4th step
    Built from canonical robot state.
  4. ONNX policy
    CPU, 50 Hz
    23 raw actions.
  5. Motor model
    PD + torque envelope
    q_des = q_default + 0.25·a
  6. MuJoCo
    200 Hz
    Contacts, dynamics.
  7. Telemetry
    UDP / WebSocket / binary
    Firmware state + ground truth.

The pieces map cleanly to files: edge/core.py is a zero-I/O state machine (no sockets, no MuJoCo, just state and time, which made it trivially unit-testable), edge/sim.py owns the MuJoCo backend, edge/policy_builder.py builds observations, motor_model.py turns targets into torques, and edge/transports/udp_transport.py is the only thing that knows UDP exists.

The 78-dimensional observation, exactly

The policy is a small MLP: [1, 78] float32 in, [1, 23] float32 out. The 23 outputs cover the legs (12), waist (1), and arms (10). The two neck joints aren't driven by the policy, so the sim holds them at zero with their own gains.

The observation is where most of the subtle bugs live. I wrote one builder and made both the live runtime and a standalone verification harness import it, so there is exactly one place the layout can be wrong:

text
OBS_DIM: int = 78 ACTION_DIM: int = 23 ACTION_SCALE: float = 0.25 ANG_VEL_SCALE: float = 0.25 JOINT_VEL_SCALE: float = 0.10 # ... R = np.asarray(pelvis_xmat, dtype=np.float64).reshape(3, 3) omega_body = R.T @ omega_world grav_body = R.T @ np.array([0.0, 0.0, -1.0]) obs = np.concatenate([ ANG_VEL_SCALE * omega_body, # 3 grav_body, # 3 cmd, # 3 [vx, vy, vyaw] pos_01, pos_23, pos_45, # 9 + 8 + 6 (q - q_default) vel_01, vel_23, vel_45, # 9 + 8 + 6 (qdot * 0.1) act, # 23 previous actions ]).astype(np.float32) assert obs.shape == (OBS_DIM,)

The joint terms aren't in firmware order. They're grouped into three "slots" (9, 8, and 6 joints) that mirror how the training environment's env.yaml declares them. I pulled every scale from that file rather than from memory, then checked the result against the policy on synthetic states in tests/test_policy_contract.py.

On the way out, actions become joint targets with q_des = q_default + 0.25 · a, and those targets go to a PD motor model: tau = Kp·(q_des − q) − Kd·qdot, clamped to each joint's peak torque from the URDF. Legs run at Kp = 250, Kd = 5; arms at 80 / 3; the waist at 100 / 4. An optional layer adds a back-EMF style envelope that shrinks available torque as joint speed approaches its no-load limit.

The ankle problem: two motors, two joints, not the same two

This one took a while to see. In the MJCF model (and in both upstream training sims), each ankle has two orthogonal joints: pitch and roll. On the physical robot, though, each ankle is driven by two parallel pushrod actuators, A and B, and the firmware reports motor positions, not joint angles.

If VASIMOV reported sim joint angles in the firmware slots, any SDK code doing trajectory playback would be quietly wrong. So the adapter translates between the two spaces in both directions, for positions, velocities, and torques:

text
# Official Ankle Kinematics Constants [VERIFIED: menlo.asimov.robots lines 47-50] ANKLE_K_PITCH = 2.02 ANKLE_K_ROLL = 0.8 def ankle_pr_to_ab(pitch: float, roll: float) -> Tuple[float, float]: motor_a = ANKLE_K_PITCH * pitch - ANKLE_K_ROLL * roll motor_b = -ANKLE_K_PITCH * pitch - ANKLE_K_ROLL * roll return float(motor_a), float(motor_b) def ankle_ab_to_pr(motor_a: float, motor_b: float) -> Tuple[float, float]: pitch = (motor_a - motor_b) / (2.0 * ANKLE_K_PITCH) roll = -(motor_a + motor_b) / (2.0 * ANKLE_K_ROLL) return pitch, roll

Torques go through the transpose of the same Jacobian (virtual work: tau_pitch = 2.02·(tau_A − tau_B)), so power is conserved across the mapping. Round-trip tests in tests/test_adapter.py make sure pr → ab → pr comes back exact.

Safety logic that behaves like firmware

Real robots fall over, and the SDK expects the firmware to notice. VASIMOV's fall detector watches the projected gravity vector: once its z-component rises above −0.5 (more than 60° of tilt), the core latches FAULT_DAMP, sets the error bitfield, and refuses STAND until a virtual restart.

text
if gz > FALL_GRAVITY_Z_THRESHOLD: # gz > -0.5 (tilt > 60 deg) self.fault_fall = True if self.fall_latch_enabled and not self.recovery_mode_active: if not self.fault_latched: self.fault_latched = True self.mode = EdgeMode.FAULT_DAMP self.move_submode = MoveSubmode.NONE

There's also an over-temperature trip at 80 °C that clears with hysteresis below 70 °C, a velocity-staleness watchdog (no command for 2 s in policy mode means "hold zero velocity and balance"), and a virtual gantry. The gantry is a MuJoCo weld constraint that pins the pelvis at standing height on boot, so the robot doesn't collapse before the stand ramp finishes. It releases itself one second after the robot is standing.

Honesty as a feature: the fidelity table

The single most useful file in the repo isn't code. It's FIDELITY.md, which classifies every command, protobuf field, and physical subsystem into one of four buckets:

LabelMeaningExample
REAL-EQUIVALENTMatches the official protocol / firmware behaviourjoint_pos in firmware order, ankle coupling, mode enums
APPROXIMATEFunctionally right, physically approximatedMuJoCo instead of hardware, PD motor model, gantry
PLACEHOLDERValid protocol value, subsystem not modelledjoint_current = 0.0 A, joint_temp = 25 °C, battery
NOT IMPLEMENTEDExplicitly absent, never fakedEOL motor calibration, video/audio timestamps

Motor current is a good example. The SDK expects it. Without published torque constants I can't compute it, so the field is a constant zero and labelled as such. Even the neck gains (Kp = 40, Kd = 2) are marked ASSUMED, and the adapter logs a warning at startup saying so. A sim that quietly invents plausible numbers is worse than no sim, because people will tune against them.

Proving it walks

The acceptance bar was the official tooling, unmodified. From the step-5 test run recorded in reports/step_5_summary.md:

2.92 m
forward in 10 s at vx = 0.4 m/s, no fall
3.92°
max body tilt over 10 s of balance
0.808 m
menlo walk --vx 0.4 --duration 3.0

The mean tracked speed in that walk was 0.32 m/s against a 0.4 m/s command. That's a tracking error I'd expect from a policy trained in a different simulator (Isaac Lab) running in MuJoCo, and it's recorded rather than tuned away. The CLI runs menlo balance -y and menlo walk -y --vx 0.4 --duration 3.0 both exited 0 against the virtual robot.

Beyond the robot: the stuff hardware can't give you

Once the twin was faithful, I added the things that only a simulator can do.

MuJoCo native viewer showing the Asimov 1 humanoid standing on a checkered floor, with a joint panel listing live angles for all 25 joints.
The native MuJoCo viewer running alongside the console, with live joint angles for every actuated joint.
Operator side
  • Interactive console with tab completion and a live status prompt
  • Drive mode: a 25 Hz ANSI HUD where W A S D steer and 1–4 shove the robot with 40–250 N
  • Manual and hybrid modes: drive individual joints, or let the policy walk while you pin one knee
  • Emotes: wave, bow, squat, cheer, dance, nod, shake
Engineering side
  • Ground-truth stream on :8854: per-joint torque, effort %, gains, foot contact forces
  • Web dashboard on :8852: live, cameras, motors, sensors, policy I/O, environment, replay, raw JSON workspaces
  • Environments: friction, mass, obstacle and terrain presets, plus drop-in YAML/XML scenes
  • Record and replay: sessions written to JSON and CSV for regression checks

The dashboard's policy workspace shows the full 78-value observation and 23-value action vectors live, grouped by contract term. It's the screen I used most while debugging, because a bad observation is visible there a second before the robot falls.

A binary protocol for clients that aren't Python

The JSON telemetry frame is great for a dashboard and too heavy for a phone at 50 Hz. So I added a compact versioned binary protocol, fixed size so a client can validate a packet by its length before parsing anything:

text
STATE_MAGIC = b"VAS1" COMMAND_MAGIC = b"VAC1" STATE_PACKET_SIZE = 288 COMMAND_PACKET_SIZE = 32 # VAS1: magic, 6x uint8, uint16 reserved, uint32 seq, double time, # 3f pos, 4f quat, 3f lin_vel, 3f ang_vel, 25f qpos, 25f qvel, 3f cmd STATE_FMT = "<4sBBBBBBHId3f4f3f3f25f25f3f" COMMAND_FMT = "<4sBBHId3f" assert struct.calcsize(STATE_FMT) == STATE_PACKET_SIZE

That protocol is what the Asimov 3D teleoperation controller renders from: the phone receives joint angles, not video, and draws the robot itself. The gateway also has a LiveKit/WebRTC path that streams H.264 camera video from the sim's cameras for clients that want pixels.

What broke: the recovery-policy forensics

The worst bug wasn't in the walking policy. It was in integrating OpenHorizon Labs' safe-fall and get-up policies, which are meant to take over when a push knocks the robot down. They loaded fine and did nothing useful. We wrote a forensic analysis (docs/policy/openhorizon-vs-vasimov-forensic-analysis.md) and found a stack of independent faults:

  • The fall latch killed the policy. Tilt past 60° latched FAULT_DAMP and zeroed Kp, which is exactly when a safe-fall policy needs torque. Now a recovery_mode_active flag records the fall without cutting the motors.
  • Gyro double rotation. MuJoCo's freejoint qvel[3:6] is already in the body frame. One layer treated it as world-frame and rotated it with Rᵀ, then the adapter rotated it again. The recovery policy was getting a subtly wrong angular velocity on every tick.
  • Torque starvation. The recovery manifests capped the knees at 25 N·m against a 45 N·m motor limit. A robot can't push itself off the floor with half its knee torque.
  • A "get-up" that was really a teleport. The old getup() snapped the pelvis back to standing height and re-welded the gantry. It looked like recovery but involved no dynamics at all. It now runs getup.onnx on the real physical state.

After the fixes, the default chain is: official locomotion, then safe-fall on a large tilt or angular rate, then get-up once the robot settles on the ground, then back to walking once it's upright. The original pure-locomotion behaviour, fault latch included, is still one flag away (--policy official_locomotion).

Where it led: TwinBench

VASIMOV convinced me that the hard, valuable part of humanoid simulation isn't rendering. It's trust: knowing how far the sim is from the robot and whether a policy change made things better or worse. That's the problem TwinBench at OpenHorizon Labs tackles: sim-to-real calibration and policy regression testing for humanoids, starting with Asimov 1.

My contributions there grew straight out of this project. I built the MuJoCo WASM browser viewer and the PAIR lab room scene, defined a robot log format with open-loop replay and a sim-vs-log report, worked on calibrating the simulator's physics against robot logs, and added importing a policy from a Hugging Face link, one-button log recording from a live session, and shipping the MCP server inside the desktop app.

What's next for VASIMOV

  • Real motor currents and thermals. Both are placeholders until torque constants and thermal parameters are published or measured.
  • Calibration against hardware logs. The fidelity table says "APPROXIMATE" for physics, and the honest fix is fitting against recorded robot data, which is TwinBench territory.
  • The edge-cloud path. The EdgeTelemetry builders are implemented and tested but unused on the UDP transport, which means they're marked IMPLEMENTED-UNEXERCISED.

If you have Python 3.10+, python setup.py and ./run_sim.sh get you a walking humanoid on Linux, macOS, or Windows.

github.com/shubhrgunjan
vasimov
git clone https://github.com/shubhrgunjan/vasimov