Moonlink integration guide

About 4 minutes to read

#Movement and clicks

The latest Moonlink supports movement, clicks, button holds, and releases through its named-pipe interface. A click is a press followed by a release. Reading a physical mouse button with GetAsyncKeyState does not send a click.

Your Windows application writes to \\.\pipe\moonlink_input. Moonlink forwards that input to its connected hardware output without using SendInput or moving the source PC's Windows cursor through the pipe.

This guide covers integration with the latest Moonlink release.

#Quick start

  1. Open the latest Moonlink and configure its hardware/serial connection.
  2. Connect one integration client to the pipe. The listener starts at application launch; connecting does not require pressing Capture. A successful pipe connection alone does not confirm hardware output is ready.
  3. Write one complete packet per write operation.
  4. For a click, send button-down, wait briefly, then send button-up. Release injected buttons before closing.

#Protocol

All deltas are signed 16-bit little-endian integers, from -32768 through 32767. Positive dx moves right; positive dy moves down.

OffsetSizeField
02 bytesdx
22 bytesdy
41 byte, 5-byte packets onlyComplete injected button-state bitmask

Use these two packet forms:

  • 4 bytes: movement only. Leaves the existing injected button state unchanged.
  • 5 bytes: movement plus the complete injected button state. Zero deltas are valid for button-only changes.

Do not add a length prefix, newline, serial framing, or padding. Send exactly one complete 4-byte or 5-byte packet per write. Do not split a packet across writes or batch several packets into a single write.

#Button masks

ButtonMaskBit
Left0x010
Right0x021
Middle0x042
Back / Mouse40x083
Forward / Mouse50x104
Release all injected buttons0x00—

Combine held buttons using bitwise OR: 0x03 holds left and right. Every 5-byte packet replaces the previous injected mask; it is not a toggle or an individual-button command. Use only bits 0–4.

For example, while right is held, press left with 0x03, then release left with 0x02. Sending 0x00 releases both injected buttons. A subsequent 4-byte movement packet does not release anything.

Moonlink ORs pipe button state with physical and other injected button state. Releasing a pipe button cannot release the same button if another input source is still holding it. Moonlink clears pipe button state when it detects a disconnect, but clients should explicitly release during normal cleanup.

There is no wheel-injection field in these 4-byte or 5-byte packets.

#Python — complete movement and click example

This example uses only Python's standard library on Windows. Copy and run it with Moonlink open and your hardware connected.

import struct
import time

PIPE = r"\\.\pipe\moonlink_input"
LEFT, RIGHT, MIDDLE, BACK, FORWARD = 1, 2, 4, 8, 16

with open(PIPE, "wb", buffering=0) as pipe:
    def send(dx=0, dy=0, buttons=None):
        # None sends movement only, preserving any currently held buttons.
        if buttons is None:
            packet = struct.pack("<hh", dx, dy)
        else:
            if not 0 <= buttons <= 0x1F:
                raise ValueError("buttons must be a five-button bitmask")
            packet = struct.pack("<hhB", dx, dy, buttons)
        if pipe.write(packet) != len(packet):
            raise OSError("Incomplete Moonlink write; reconnect before continuing")

    try:
        send(buttons=0)           # Start with no pipe buttons held.
        send(10, -5)              # Move right 10, up 5.

        send(buttons=LEFT)        # Left button down.
        time.sleep(0.05)
        send(buttons=0)           # Left button up: completes the click.

        send(buttons=RIGHT)       # Hold right.
        time.sleep(0.05)
        send(20, 0)               # Move while right remains held.
        time.sleep(0.05)
        send(buttons=RIGHT | LEFT)
        time.sleep(0.05)
        send(buttons=RIGHT)       # Release left, keep right held.
        time.sleep(0.05)
        send(buttons=0)           # Release right.
    finally:
        # Best effort: a broken connection may prevent this final write.
        try:
            send(buttons=0)
        except OSError:
            pass

The 50 ms press duration is an example, not a protocol requirement. Allow the output loop and target application time to observe each transition; back-to-back down/up writes can collapse before an output report observes the press. A successful write confirms submission to the pipe, not delivery to the target.

For an existing Python wrapper, add button support by writing struct.pack("<hhB", dx, dy, mask) in one unbuffered write. Maintain the full held-button mask inside the wrapper: press ORs bits into it, release clears bits, and release_all sends zero. Serialize calls so concurrent movement and click operations cannot overwrite each other's button state.

#C / C++ — complete movement and click example

#include <windows.h>
#include <cstdint>
#include <cstdio>

bool send_state(HANDLE pipe, int16_t dx, int16_t dy, uint8_t buttons) {
    if (buttons > 0x1F) return false;
    const auto x = static_cast<uint16_t>(dx);
    const auto y = static_cast<uint16_t>(dy);
    const uint8_t packet[5] = {
        static_cast<uint8_t>(x), static_cast<uint8_t>(x >> 8),
        static_cast<uint8_t>(y), static_cast<uint8_t>(y >> 8), buttons
    };
    DWORD written = 0;
    return WriteFile(pipe, packet, sizeof(packet), &written, nullptr)
        && written == sizeof(packet);
}

int main() {
    HANDLE pipe = CreateFileW(L"\\\\.\\pipe\\moonlink_input",
        GENERIC_WRITE, 0, nullptr, OPEN_EXISTING, 0, nullptr);
    if (pipe == INVALID_HANDLE_VALUE) {
        std::fprintf(stderr, "Moonlink connection failed: %lu\n", GetLastError());
        return 1;
    }

    bool ok = send_state(pipe, 10, -5, 0); // Move; no buttons held.
    if (ok) ok = send_state(pipe, 0, 0, 0x01); // Left down.
    if (ok) Sleep(50);
    const bool released = send_state(pipe, 0, 0, 0); // Left up / cleanup.
    CloseHandle(pipe);
    return ok && released ? 0 : 1;
}

For movement without changing held buttons, write exactly the first four bytes. Avoid sending native structs whose alignment could add padding.

#C# — movement and click (.NET 6+ on Windows)

using System;
using System.Buffers.Binary;
using System.IO;
using System.IO.Pipes;
using System.Threading;

using var pipe = new NamedPipeClientStream(".", "moonlink_input", PipeDirection.Out);
pipe.Connect(3000);

void Send(short dx, short dy, byte buttons) {
    if (buttons > 0x1F) throw new ArgumentOutOfRangeException(nameof(buttons));
    byte[] packet = new byte[5];
    BinaryPrimitives.WriteInt16LittleEndian(packet.AsSpan(0, 2), dx);
    BinaryPrimitives.WriteInt16LittleEndian(packet.AsSpan(2, 2), dy);
    packet[4] = buttons;
    pipe.Write(packet, 0, packet.Length);
}

try {
    Send(10, -5, 0);
    Send(0, 0, 0x01); // Left down.
    Thread.Sleep(50);
    Send(0, 0, 0);    // Left up.
}
finally {
    try { Send(0, 0, 0); }
    catch (IOException) { /* Disconnected: release could not be sent. */ }
}

#Rust — movement and click (Windows)

use std::{fs::{File, OpenOptions}, io::{self, Write}, thread, time::Duration};

fn send(pipe: &mut File, dx: i16, dy: i16, buttons: u8) -> io::Result<()> {
    if buttons > 0x1f {
        return Err(io::Error::new(io::ErrorKind::InvalidInput, "invalid button mask"));
    }
    let x = dx.to_le_bytes();
    let y = dy.to_le_bytes();
    let packet = [x[0], x[1], y[0], y[1], buttons];
    // One write preserves the message boundary. Do not retry a partial suffix.
    match pipe.write(&packet)? {
        5 => Ok(()),
        _ => Err(io::Error::new(io::ErrorKind::WriteZero, "incomplete Moonlink write")),
    }
}

fn main() -> io::Result<()> {
    let mut pipe = OpenOptions::new().write(true)
        .open(r"\\.\pipe\moonlink_input")?;
    let result = (|| -> io::Result<()> {
        send(&mut pipe, 10, -5, 0)?;
        send(&mut pipe, 0, 0, 0x01)?;
        thread::sleep(Duration::from_millis(50));
        send(&mut pipe, 0, 0, 0)
    })();
    let cleanup = send(&mut pipe, 0, 0, 0);
    result.and(cleanup)
}

#Reading physical buttons is separate from injecting clicks

An integration may poll local Windows button state to decide when to act. This does not send button state through Moonlink and does not report the remote target's button state.

import ctypes

get_key_state = ctypes.windll.user32.GetAsyncKeyState
get_key_state.argtypes = [ctypes.c_int]
get_key_state.restype = ctypes.c_short

right_is_held = bool(get_key_state(0x02) & 0x8000)

Windows virtual-key codes and pipe masks are different identifiers:

ButtonWindows virtual-key codePipe mask
Left0x010x01
Right0x020x02
Middle0x040x04
Back / Mouse40x050x08
Forward / Mouse50x060x10

#Integration troubleshooting

  • Movement works, clicks do not: send five bytes in one write, and include both press and release with a visible hold interval. A four-byte move() method cannot initiate clicks.
  • Button stays held: send a five-byte packet with mask zero. Four zero bytes only send zero movement. Check whether the physical mouse or another input source is holding the same button.
  • Cannot connect: launch Moonlink, allow startup to finish, and close other pipe clients. Only one integration client can connect at a time. Retry startup/busy errors with a bounded delay and timeout.
  • Pipe disconnects: close the stale handle and reconnect with bounded retries. Reset local held-button state and send a zero mask on the new connection. Do not automatically replay an interrupted click: delivery is unknown.
  • Writes succeed but the target does nothing: check the connected hardware, selected serial port, and output configuration. Pipe writes have no end-to-end acknowledgement.
  • Unexpected movement: verify little-endian signed deltas and packet boundaries. Do not send serial packets directly to this pipe.

Stuck on a step? Ask in the Moonlight Discord and a staff member will help.