This document explains how Move Anything loads third-party code onto Ableton Move hardware.
Move Anything uses a four-layer approach to run custom code on Move:
┌─────────────────────────────────────────────────────────────┐
│ 1. Installation Bootstrap │
│ install.sh deploys files and replaces /opt/move/Move │
└─────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────┐
│ 2. LD_PRELOAD Shim │
│ Intercepts system calls, monitors for hotkey combo │
└─────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────┐
│ 3. Host Runtime │
│ Takes over hardware, runs QuickJS, manages modules │
└─────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────┐
│ 4. Module Loading │
│ dlopen() loads native DSP plugins, JS handles UI │
└─────────────────────────────────────────────────────────────┘
The scripts/install.sh script connects to Move via SSH (at move.local) and performs these steps:
-
Deploy files to
/data/UserData/move-anything/:move-anything(host binary)move-anything-shim.so(LD_PRELOAD library)shim-entrypoint.shandstart.sh(launcher scripts)host/,shared/,modules/directories
-
Backup the original Move binary:
/opt/move/Move → /opt/move/MoveOriginal -
Install the shim entrypoint as the new
/opt/move/Move:LD_PRELOAD=move-anything-shim.so /opt/move/MoveOriginal
-
Copy shim library to
/usr/lib/move-anything-shim.so
After installation, when Move boots normally, it runs the shim entrypoint instead of the original binary.
The shim (src/move_anything_shim.c) is loaded via LD_PRELOAD before the original Move binary runs. It intercepts two system calls:
The shim hooks mmap() to capture the address of the 4KB shared memory region used for SPI communication with the control surface:
void *mmap(void *addr, size_t length, ...) {
void *result = real_mmap(addr, length, ...);
if (length == 4096) {
global_mmap_addr = result; // Capture SPI mailbox address
}
return result;
}The shim hooks ioctl() to monitor MIDI messages from the hardware on every SPI transaction:
int ioctl(int fd, unsigned long request, char *argp) {
midi_monitor(); // Check for hotkey combo
return real_ioctl(fd, request, argp);
}The midi_monitor() function reads incoming MIDI from the shared memory mailbox and watches for a specific combination:
- Shift held (CC 49 = 127)
- Volume knob touched (Note 8 on)
- Jog encoder touched (Note 9 on)
When all three are active simultaneously, the shim:
- Forks a child process
- Closes all file descriptors (releasing
/dev/ablspi0.0) - Executes
/data/UserData/move-anything/start.sh - Kills the parent (original Move) process
The start.sh script:
- Kills any remaining Move processes (
MoveLauncher,Move, etc.) - Launches the Move Anything host binary:
./move-anything ./host/menu_ui.js
The host runtime (src/move_anything.c):
- Opens
/dev/ablspi0.0directly for hardware communication - Maps the SPI mailbox (4KB shared memory for display, MIDI, audio)
- Embeds QuickJS for JavaScript execution
- Loads the menu UI (
host/menu_ui.js) - Initializes the Module Manager for plugin discovery
The host runs a main loop that:
- Renders the display buffer to hardware
- Processes incoming MIDI from pads/knobs/buttons
- Calls JavaScript
tick()function (~60fps) - Routes audio through loaded DSP plugins
All communication with Move hardware happens through a 4KB memory-mapped region accessed via /dev/ablspi0.0. The host calls ioctl() to trigger SPI transactions that exchange data with the control surface.
Offset Size Direction Purpose
──────────────────────────────────────────────────────
0x000 256 Out MIDI to hardware (LEDs, etc.)
0x100 512 Out Audio output (L/R interleaved)
0x300 1024 Out Display framebuffer (128x64 @ 1bpp)
──────────────────────────────────────────────────────
0x800 256 In MIDI from hardware (pads, knobs)
0x900 512 In Audio input (L/R interleaved)
0xB00 1024 In (unused)
The display is a 128x64 pixel monochrome screen. The host maintains a screen_buffer[128*64] in memory:
unsigned char screen_buffer[128*64]; // 1 byte per pixel (0 or 1)JavaScript draws to this buffer via functions like set_pixel(), print(), draw_rect().
Flushing to hardware:
The screen is packed from 8-bit-per-pixel to 1-bit-per-pixel and sent in 6 slices (172 bytes each) to fit the mailbox:
void push_screen(int sync) {
// Pack 8 vertical pixels into 1 byte
for (int y = 0; y < 64/8; y++) {
for (int x = 0; x < 128; x++) {
unsigned char packed = 0;
for (int j = 0; j < 8; j++) {
packed |= screen_buffer[y*128*8 + x + j*128] << j;
}
packed_buffer[i++] = packed;
}
}
// Write slice to mailbox at offset 84
memcpy(mapped_memory + 84, packed_buffer + slice*172, 172);
}Display refresh is rate-limited to ~11Hz to avoid flicker.
Audio runs at 44100 Hz with 128-frame blocks (~2.9ms latency).
Output: DSP plugins render to a buffer, which the host copies to the mailbox:
void mm_render_block(module_manager_t *mm) {
// Plugin renders 128 stereo frames
mm->plugin->render_block(mm->audio_out_buffer, 128);
// Apply host volume
if (mm->host_volume < 100) {
for (int i = 0; i < 256; i++) {
mm->audio_out_buffer[i] = (mm->audio_out_buffer[i] * mm->host_volume) / 100;
}
}
// Copy to mailbox at offset 0x100 (256)
memcpy(mapped_memory + MOVE_AUDIO_OUT_OFFSET, mm->audio_out_buffer, 512);
}Input: Plugins can read audio input directly from the mailbox:
// In plugin code:
int16_t *audio_in = (int16_t *)(host->mapped_memory + host->audio_in_offset);
// audio_in contains 128 stereo samples: [L0, R0, L1, R1, ...]Format: Stereo interleaved int16 (little-endian), range -32768 to +32767.
The shim can bridge Move Everything's mixed output into Move's native sampler input path when native sampling is in use. This is controlled by the Master FX setting Resample Src (Off, Replace).
In Replace mode, the bridge writes a snapshot of the combined Move + Move Everything mix into native AUDIO_IN. The snapshot tap point is:
- after slot mix
- after master FX
- before master-volume attenuation
This bakes Master FX into the captured audio while keeping capture level independent of transient master-volume reads.
The shim still tracks native sampler-source announcements for diagnostics/compatibility, but Replace intentionally applies continuously to avoid dropouts during route/source transitions.
For practical use, Replace with sampler source set to Line In and monitoring set to Off is recommended. Other routing/monitoring combinations can cause feedback loops.
Incoming MIDI (from pads, knobs, buttons) arrives at offset 0x800 (2048) as USB-MIDI packets:
// 4-byte USB-MIDI packet format:
// [cable<<4 | CIN, status, data1, data2]
for (int i = 2048; i < 2048+256; i += 4) {
uint8_t *packet = &mapped_memory[i];
uint8_t cable = packet[0] >> 4;
uint8_t status = packet[1];
uint8_t data1 = packet[2];
uint8_t data2 = packet[3];
if (cable == 0) {
// Internal: pads, knobs, buttons
onMidiMessageInternal([status, data1, data2]);
} else if (cable == 2) {
// External: USB-A connected devices
onMidiMessageExternal([status, data1, data2]);
}
}Outgoing MIDI (to LEDs, external devices) is written to offset 0x000:
void queueMidiSend(uint8_t cable, uint8_t cin, uint8_t *msg) {
mapped_memory[outgoing_midi_counter*4 + 0] = (cable << 4) | cin;
mapped_memory[outgoing_midi_counter*4 + 1] = msg[0];
mapped_memory[outgoing_midi_counter*4 + 2] = msg[1];
mapped_memory[outgoing_midi_counter*4 + 3] = msg[2];
outgoing_midi_counter++;
}Each iteration of the main loop:
- JavaScript
tick()updates display buffer and queues MIDI push_screen()writes display slice to mailboxflush_pending_leds()writes LED MIDI to mailbox- DSP
render_block()writes audio to mailbox ioctl()triggers SPI exchange with hardware- Host reads incoming MIDI from mailbox and dispatches to JS/DSP
Modules live in /data/UserData/move-anything/modules/<id>/. Each module contains:
| File | Purpose |
|---|---|
module.json |
Metadata (id, name, version, capabilities) |
ui.js |
JavaScript UI (optional) |
dsp.so |
Native ARM shared library (optional) |
The Module Manager (src/host/module_manager.c) scans the modules directory:
void mm_scan_modules(module_manager_t *mm) {
// For each subdirectory in modules/
// Parse module.json
// Store module_info_t with paths to ui.js and dsp.so
}When a module is selected:
-
Unload any current module (call
on_unload,dlclose) -
Load the DSP plugin (if present):
mm->dsp_handle = dlopen(info->dsp_path, RTLD_NOW | RTLD_LOCAL);
-
Get the init function (tries v2 first, falls back to v1):
move_plugin_init_v2_fn init_v2 = dlsym(mm->dsp_handle, "move_plugin_init_v2"); if (!init_v2) move_plugin_init_v1_fn init_v1 = dlsym(mm->dsp_handle, "move_plugin_init_v1");
-
Initialize the plugin (passes host API with audio/MIDI callbacks):
mm->plugin_v2 = init_v2(&mm->host_api);
-
Create instance (v2) or call
on_load(v1) with the module directory and JSON defaults -
Load the JavaScript UI via QuickJS
Native DSP plugins implement one of two APIs (both defined in src/host/plugin_api_v1.h):
Plugin API v2 (Recommended) — supports multiple instances, required for Signal Chain:
typedef struct plugin_api_v2 {
uint32_t api_version; // Must be 2
void* (*create_instance)(const char *module_dir, const char *json_defaults);
void (*destroy_instance)(void *instance);
void (*on_midi)(void *instance, const uint8_t *msg, int len, int source);
void (*set_param)(void *instance, const char *key, const char *val);
int (*get_param)(void *instance, const char *key, char *buf, int buf_len);
int (*get_error)(void *instance, char *buf, int buf_len);
void (*render_block)(void *instance, int16_t *out_interleaved_lr, int frames);
} plugin_api_v2_t;The module manager tries move_plugin_init_v2 first, falling back to move_plugin_init_v1 (deprecated singleton API).
Audio specs:
- Sample rate: 44100 Hz
- Block size: 128 frames (~3ms latency)
- Format: Stereo interleaved int16
The Module Store (built-in module) enables installing third-party modules:
The store fetches a JSON catalog from GitHub:
https://raw.githubusercontent.com/charlesvestal/move-anything/main/module-catalog.json
Example catalog entry:
{
"id": "dexed",
"name": "Dexed",
"description": "Dexed FM synth with DX7-compatible sysex support",
"component_type": "sound_generator",
"latest_version": "0.1.0",
"download_url": "https://github.com/charlesvestal/move-anything-dx7/releases/download/v0.1.0/dexed-module.tar.gz"
}- User selects a module in the store
- Store downloads the tarball via
curl - Extracts to a category subdirectory based on
component_type:sound_generator→modules/sound_generators/<id>/audio_fx→modules/audio_fx/<id>/midi_fx→modules/midi_fx/<id>/utility→modules/utilities/<id>/overtake→modules/overtake/<id>/tool→modules/tools/<id>/
- Module appears in the main menu immediately
When the user exits Move Anything (Shift + Jog click, or "Return to Move" in settings):
- Host runtime exits
start.shrestarts/opt/move/MoveLauncher- MoveLauncher starts the shim entrypoint
- Shim runs the original Move with MIDI monitoring active
The cycle continues until the hotkey combo is pressed again.
Shadow Mode is an alternative operating mode that runs Move Anything's audio engine alongside stock Move, rather than replacing it. This allows users to layer additional synths and effects over Move's native instruments.
Shadow Mode is activated via Shift + Volume touch + Track button (1-4). Each track button opens shadow mode and jumps to the corresponding slot's settings. From stock Move, this also launches the shadow UI process. If already in shadow mode, it switches to the selected slot.
Unlike the full host takeover, Shadow Mode uses the shim's in-process capabilities:
┌─────────────────────────────────────────────────────────────┐
│ Stock Move (MoveOriginal) │
│ - Continues running normally │
│ - Handles its own instruments and sequencer │
└─────────────────────────────────────────────────────────────┘
↕ (shim intercepts ioctl)
┌─────────────────────────────────────────────────────────────┐
│ LD_PRELOAD Shim (in-process) │
│ - Filters MIDI: blocks some controls, passes others │
│ - Loads chain DSP plugins via dlopen │
│ - Mixes shadow audio with Move's audio output │
│ - Manages shared memory for UI communication │
└─────────────────────────────────────────────────────────────┘
↕ (shared memory)
┌─────────────────────────────────────────────────────────────┐
│ Shadow UI Process (separate) │
│ - Runs shadow_ui.js via QuickJS │
│ - Draws overlay on display │
│ - Handles jog/back/knob navigation │
└─────────────────────────────────────────────────────────────┘
Shadow Mode uses several shared memory regions for IPC:
| Region | Purpose |
|---|---|
/move-shadow-control |
Control flags, slot selection, request IDs |
/move-shadow-audio |
Shadow's mixed audio output |
/move-shadow-ui-midi |
MIDI messages forwarded to shadow UI |
/move-shadow-display |
Display buffer for overlay rendering |
/move-shadow-ui |
Slot state (names, channels, active status) |
/move-shadow-param |
Parameter read/write requests |
/move-shadow-midi-out |
MIDI output from shadow UI |
/move-shadow-midi-dsp |
MIDI from shadow UI to DSP slots |
/move-shadow-midi-inject |
MIDI inject into Move's MIDI_IN |
/move-shadow-overlay |
Overlay state (sampler/skipback) |
/move-shadow-screenreader |
Screen reader announcements |
/move-display-live |
Live display for remote viewer |
Move uses USB-MIDI cable numbers to separate different MIDI streams:
| Cable | Direction | Purpose |
|---|---|---|
| 0 | In | Move hardware controls (pads, knobs, buttons) |
| 0 | Out | Move UI events (filtered from shadow MIDI output) |
| 2 | Out | Track MIDI output (routed to shadow synths) |
| 15 | Both | Special/system messages |
Important: When processing outgoing MIDI from Move (for shadow synth input), the shim filters cable 0 to prevent Move's UI events from triggering shadow synths. Only cable 2 (track MIDI output) is routed to shadow slots.
The shim filters incoming MIDI based on control type:
Blocked from Move (intercepted for Shadow UI):
- CC 14 (jog wheel) - shadow UI navigation
- CC 3 (jog click) - shadow UI selection
- CC 51 (back button) - shadow UI back navigation
- CC 71-78 (knobs 1-8) - routed to focused slot's DSP
- Notes 0-9 (knob touches) - filtered to avoid confusing Move
Forwarded to Shadow UI AND passed to Move:
- CC 40-43 (track buttons) - switch shadow slots while also switching Move tracks
Passed through to Move (not intercepted):
- All other CCs: transport (play, record), editing (loop, mute, copy, delete), navigation (arrows), shift
- All notes: pads (68-99), steps (16-31)
- Aftertouch, pitch bend
Shadow Mode supports 4 independent chain slots, each with:
- A loaded patch (synth + effects chain)
- A receive channel (MIDI channel to listen on, default 1-4)
- A forward channel (channel remapping for synths that need specific channels)
- Per-slot volume control
- Independent knob mappings
- State persistence (synth, audio FX, MIDI FX states saved/restored)
typedef struct {
void *instance; // Chain DSP instance
int channel; // Receive channel (0-15, 0-based)
int active; // Slot has loaded patch
float volume; // 0.0 to 1.0, applied to audio output
int forward_channel; // -1 = auto (use receive channel), 0-15 = specific channel
char patch_name[64]; // Currently loaded patch name
shadow_capture_rules_t capture; // MIDI controls this slot captures
} shadow_chain_slot_t;Forward Channel: Some synths (like Mini-JV) need MIDI on a specific channel regardless of which slot they're in. The forward_channel setting remaps MIDI before sending to the synth. When set to -1 (auto), MIDI passes through unchanged on the receive channel.
State Persistence: When saving slot configuration, the system queries and stores:
- Synth state via
synth:get_state - Audio FX state via
audio_fx_<n>:get_state - MIDI FX state via
midi_fx_<type>:get_state
States are restored when the patch is reloaded.
Slots can define capture rules that intercept specific Move controls:
typedef struct {
uint8_t notes[16]; // bitmap: 128 notes
uint8_t ccs[16]; // bitmap: 128 CCs
} shadow_capture_rules_t;When a slot is focused and has capture rules:
- Captured notes/CCs are blocked from reaching Move
- Captured MIDI is routed to the focused slot's DSP via
on_midi - Non-captured MIDI follows normal passthrough behavior
Capture rules are parsed from patch JSON ("capture" field) or module.json capabilities (for Master FX).
Knobs 1-8 (CC 71-78) control the currently focused slot:
- Shadow UI tracks
selectedSlot(0-3) - JS calls
shadow_set_focused_slot(slot)to update shared memory - Shim reads
shadow_control->ui_slot - Knob CCs are routed to that slot's chain DSP instance
- Chain DSP applies knob mappings defined in the patch JSON
The shim mixes shadow audio with Move's output post-ioctl:
void shadow_inprocess_mix_audio(void) {
int16_t *mailbox_audio = (int16_t *)(global_mmap_addr + AUDIO_OUT_OFFSET);
int32_t mix[FRAMES_PER_BLOCK * 2];
// Start with Move's audio
for (int i = 0; i < FRAMES_PER_BLOCK * 2; i++) {
mix[i] = mailbox_audio[i];
}
// Add each active shadow slot's output
for (int s = 0; s < SHADOW_CHAIN_INSTANCES; s++) {
if (!shadow_chain_slots[s].active) continue;
plugin->render_block(shadow_chain_slots[s].instance, slot_buffer, FRAMES_PER_BLOCK);
for (int i = 0; i < FRAMES_PER_BLOCK * 2; i++) {
mix[i] += slot_buffer[i];
}
}
// Clamp and write back
for (int i = 0; i < FRAMES_PER_BLOCK * 2; i++) {
mailbox_audio[i] = clamp16(mix[i]);
}
}The shadow UI process draws to a shared display buffer. The shim swaps this buffer into the mailbox just before each ioctl, overlaying shadow UI on top of Move's display when shadow mode is active.
Shadow slot configuration is saved to /data/UserData/move-anything/shadow_chain_config.json:
{
"patches": [
{ "name": "SF2 + Freeverb", "channel": 5 },
{ "name": "Dexed + Freeverb", "channel": 6 },
{ "name": "OB-Xd + Freeverb", "channel": 7 },
{ "name": "Mini-JV + Freeverb", "channel": 8 }
]
}Overtake mode is a special shadow mode variant where a module takes complete control of Move's UI. Unlike regular shadow mode (which overlays a custom UI), overtake modules fully replace Move's display and control all LEDs.
Activation: Accessed via the shadow UI menu (select an overtake module like MIDI Controller or M8).
Lifecycle:
Shadow UI selects overtake module
↓
Host clears all LEDs progressively
(20 LEDs per frame to avoid buffer overflow)
↓
"Loading..." displayed (~500ms)
↓
Module's init() called
↓
Module takes full control
(display, LEDs, MIDI input)
↓
Shift+Vol+Jog Click exits
↓
Returns to shadow UI
Host-Level Escape:
The host tracks shift (CC 49) and volume touch (Note 8) state locally within the shadow UI process. When Shift+Vol+Jog Click is detected, the host exits overtake mode before the module sees the jog click. This ensures escape always works regardless of module behavior.
// In shadow_ui.js
let hostShiftHeld = false;
let hostVolumeKnobTouched = false;
// On MIDI input (before passing to module)
if (ccNumber === MoveShift) {
hostShiftHeld = value === 127;
}
if (isNote && note === 8) {
hostVolumeKnobTouched = velocity > 0;
}
if (ccNumber === MoveMainButton && value > 0) {
if (hostShiftHeld && hostVolumeKnobTouched) {
exitOvertakeMode();
return; // Don't pass to module
}
}LED Buffer Management:
The MIDI output buffer (SHADOW_MIDI_OUT_BUFFER_SIZE = 512 bytes) holds ~128 USB-MIDI packets, but the hardware mailbox MIDI region is 256 bytes (~64 packets). The host clears LEDs progressively:
| LED Type | Addressing | Count |
|---|---|---|
| Pads | Notes 68-99 | 32 |
| Steps | Notes 16-31 | 16 |
| Knob touch | Notes 0-7 | 8 |
| Step icons | CCs 16-31 | 16 |
| Buttons | Various CCs | ~20 |
| Knob indicators | CCs 71-78 | 8 |
Total: ~100 LEDs, cleared in batches of 20 per frame (~5 frames = ~80ms).
Module Initialization:
Modules should also use progressive LED setup to avoid buffer overflow:
const LEDS_PER_FRAME = 8;
let ledInitPending = true;
let ledInitIndex = 0;
globalThis.tick = function() {
if (ledInitPending) {
// Set 8 LEDs per frame
setupLedBatch();
}
drawUI();
};- The shim runs with elevated privileges (setuid) to access hardware
- Modules are native ARM code with full system access
- Only install modules from trusted sources
- The Module Store only fetches from the official catalog
┌─────────────────┐
│ Move boots │
└────────┬────────┘
↓
┌─────────────────┐ ┌──────────────────┐
│ shim-entrypoint │────→│ MoveOriginal │
│ (LD_PRELOAD) │ │ (stock Move) │
└────────┬────────┘ └──────────────────┘
│
│ monitors MIDI via ioctl hook
↓
┌─────────────────┐
│ Shift + Vol + │
│ Jog detected │
└────────┬────────┘
↓
┌─────────────────┐
│ start.sh │
│ kills Move │
└────────┬────────┘
↓
┌─────────────────┐
│ move-anything │
│ (host runtime) │
└────────┬────────┘
│
│ scans modules/, loads via dlopen
↓
┌─────────────────┐
│ Modules │
│ (JS UI + DSP) │
└─────────────────┘