Skip to content

Latest commit

 

History

History
295 lines (235 loc) · 17.2 KB

File metadata and controls

295 lines (235 loc) · 17.2 KB

VirtualDJ Remote Protocol

Wire protocol between VirtualDJ (desktop) and the VirtualDJ Remote companion app. This is not the HTTP Control Interface — different transport, different port, different semantics. Where that channel is request/response and poll-only, this one is a push subscription channel: the device registers VDJScript queries by id and VirtualDJ streams values back as they change.

All rows Local test unless marked otherwise, captured 2026-07-27 against VirtualDJ 2026 on macOS with a real iOS Remote app (app build 8515) on the LAN. Nothing here is official documentation; the protocol is undocumented and this is black-box observation.

Transport and discovery

Fact Detail
Roles Inverted from the obvious guess. The device is the TCP server; VirtualDJ is the client and dials out.
Discovery The device advertises Bonjour/mDNS service type _vdjremote8._tcp (SRV → device host, port 4243 observed). VirtualDJ browses for it.
Port 4243, from the iRemoteDefaultPort setting in settings.xml (configurable).
Device list settings.xml <vdjRemoteDevices> is the discovered/paired list by instance name; entries appear in Config → Controllers → Phone/tablet with a liveness suffix ((Waiting), (Not here)).
Auto-connect Governed by the per-device "Connect automatically" checkbox in Config → Controllers → Phone/tablet. Checked: VirtualDJ redials ~5 s after a session ends, and dials on a fresh mDNS appearance. Unchecked: the device sits at (Waiting) until someone clicks Connect, no matter how the advert is cycled.
Who speaks first The device. Across 8+ sessions VirtualDJ opened the connection and sent zero bytes, holding the socket open for minutes. A fake device that stays silent learns nothing; a fake device must speak first. Eight candidate openers (newline, text, HTTP, XML, four binary framings) all drew silence — the real opener is required.

Consequence for tooling: any third-party client impersonates a device — advertise _vdjremote8._tcp, listen on the port, and send the opener. You do not connect to VirtualDJ; it connects to you. No root and no packet capture is needed anywhere in this flow, because the device end is a plain TCP server you can dial directly.

Framing

Every message is one frame:

+--------+--------+------------------+
| "8JDV" | u32 LE | payload          |
| 4 bytes| total  | total - 8 bytes  |
+--------+--------+------------------+
  • Magic is the ASCII bytes 38 4a 44 56 = 8JDV on the wire — a fourcc 'VDJ8' stored little-endian.
  • The length field is the total frame size including the 8-byte header, not the payload size. A minimal frame is 0c 00 00 00 (12) with a 4-byte payload.
  • Payload begins with a u16 LE message type. Frames are packed back-to-back in the stream, several per TCP segment, so a reader must buffer and split on the length field.

Deck scope is a fourcc stored little-endian, so it reads reversed in a hexdump: 74 66 65 6c is 'left', 72 69 67 68 is 'righ' (i.e. right), all-zero means unscoped/global.

Message types: device → desktop

From a real device's 1184-byte opening burst (49 frames), sent immediately on connect:

Type Name (working) Payload after the u16 type Meaning
0x0001 SUBSCRIBE u16 id, fourcc scope, ASCII VDJScript Register a query under id; VirtualDJ pushes its value when it changes.
0x0003 KIND u16 kind, u32 id Declares the value type for id. Observed 0 (numeric: pitch, volume, get_vu_meter, crossfader), 1 (string: get_artist, get_title, get_bpm, get_status), 2 (search_options, sampler_used, automix).
0x0024 PANEL ASCII name Declares an available view: playlist, sampler, sidelist, karaoke, and one empty.
0x0040 INFO u32, then XML Device announcement (see below).
0x0009, 0x000c, 0x0027, 0x0029, 0x0034 unresolved fixed-size numeric blobs, mostly u16 deck, u16 deck, u32, then values; sent once per deck (1 and 2) Per-deck setup/capability negotiation. Not decoded.

The INFO frame is plain XML and identifies the device and its skin:

<?xml version="1.0" encoding="UTF-8"?>
<info build="8515" skin="DeathDisco Grave Raver Remote" width="1179" height="2556" dpi="3.0" />

So the device tells VirtualDJ which Remote skin it is running plus its pixel geometry and DPI — the desktop does not push a skin down. This matches the Remote-skin deployment model in Application Internals.md.

The subscription model

The heart of the protocol, and the reason it matters: the device sends pairs of SUBSCRIBE + KIND frames, one per value it wants to display. Verbatim from the capture:

id=0   scope='left'  get_artist            kind=1
id=1   scope='left'  get_title             kind=1
id=2   scope='left'  get_bpm               kind=1
id=3   scope='left'  pitch                 kind=0
id=4   scope='righ'  get_artist            kind=1
id=7   scope='righ'  pitch                 kind=0
id=8   scope='left'  deck left volume      kind=0
id=10  scope='left'  deck left get_vu_meter kind=0
id=12  scope=global  crossfader            kind=0
id=13  scope=global  search_options        kind=2
id=14  scope=global  get_status            kind=1
id=15  scope=global  sampler_used          kind=2
id=16  scope=global  automix               kind=2

These are ordinary VDJScript query strings — the same grammar the HTTP /query endpoint and skin query="" attributes evaluate, including deck-scoped forms (deck left volume). The device picks what it wants; there is no fixed schema of pushed fields. That is what makes this a general event channel rather than a fixed remote-control API: subscribe to get_vu_meter and you get a meter feed, subscribe to get_title and you get track changes.

Push semantics were confirmed independently by traffic sampling during a real session: idle seconds carry 0 bytes, while a deck load pushed ~249 KiB desktop→device in one second with no inbound request.

Message types: desktop → device

Replaying a captured opener is sufficient to hold a session. Verified 2026-07-27: a fake device advertising _vdjremote8._tcp that sends the 1184-byte capture verbatim is accepted by VirtualDJ, which then streams state — 6.7 KB in 106 frames within seconds, with no pairing token, nonce, or challenge. The handshake is stateless replay.

Type Name (working) Payload after the u16 type Meaning
0x0005 VALUE u16 id, fourcc kind, value The answer to subscription id. Kind fourccs observed: valfloat32 LE; txtu32 length + UTF-8; fail → the query produced no value.
0x0025 FOLDER u16, u32, XML or a ZIP blob Browser folder content. Small listings are <foldercontent path= name= hasorder= haschildren= canmove= candelete= total= start= nb= /> XML; larger ones arrive as a PKZip archive containing data.xml.
0x0036 SETTING u16, u32 index, u32 len, key+value text Config push, one per setting: vinylMode/yes, pitchRange/33.0, skinWaveformType/colors, automixMode/smart, keepPlayingPastEnd/no, … 50 frames in one session.
0x003f SELECTFOLDER u16, u32, XML Current browser location: <selectfolder selectedfolderpath="root:/All Files.vdjfolder" selectedfolderidx="0" selectedsideviewname="remixes" />.
0x002b, 0x003b unresolved mostly-zero fixed blocks, one per deck/index Not decoded; likely per-deck state or waveform scaffolding.

Worked examples from a live session, matching the subscription ids above:

id=1  left get_title  txt  len=35  "Drag a song on this deck to load it"   (empty deck)
id=2  left get_bpm    val  00 00 f0 42  -> 120.0
id=8  left volume     val  00 00 80 3f  -> 1.0
id=0  left get_artist fail                                                  (no track)

So a third-party client gets exactly what it asks for: subscribe to any VDJScript query and VirtualDJ pushes a typed value whenever it changes. Combined with the browser folder frames, this is enough to build an external browser or full alternate interface — the direction the HTTP Control Interface cannot serve because it has no push.

Action frames (device → desktop)

Captured 2026-07-27 by impersonating VirtualDJ to a real device: dial the device, answer its subscriptions with synthesized values so its UI activates, then log what its controls produce. (A passive man-in-the-middle relay does not work — the device accepts only one session at a time and VirtualDJ auto-connects to it directly, so the relay must claim the slot first. Impersonating the desktop side avoids the race entirely.)

Type Name (working) Payload after the u16 type Meaning
0x0031 SCRIPT u16 deck, ASCII VDJScript An action expressed as VDJScript texttouchwheel_touch on, touchwheel +0.00000ms.
0x0002 CONTROL u16 control id, u32 phase, optionally fourcc kind + value A skin control event, addressed by numeric id rather than script.
0x0026 LOAD u16 deck, absolute path Load a file — the device sends a full filesystem path, e.g. /Users/…/01. RUMPUS - Up In Here (Extended Mix).flac.
0x0014, 0x0015 unresolved 00 00 Emitted around browser operations; markers of some kind.

The 0x02 phase field is the useful discovery, and it is not a deck number: 1 begins the gesture, 0 is a continuous update, 2 ends it. Buttons send only 1 then 2; continuous controls send 1, a stream of 0 updates each carrying a val float32, then 2. Correlated against a scripted press sequence on the device:

18:12:30  id=0xc6 phase=1 / phase=2     play pressed
18:12:33  id=0xc6 phase=1 / phase=2     play pressed again (pause)
18:12:37  id=0xc7 phase=1 / phase=2     cue pressed
18:12:42  id=0x41 phase=1, ~11x phase=0 (val 0.57…0.74), phase=2    crossfader swept
18:12:44  id=0x36 phase=1, ~40x phase=0, phase=2                    volume fader swept

So 0xc6 is play, 0xc7 cue, 0x41 crossfader, 0x36 a deck volume fader on this device's skin. Whether that id space is a fixed VirtualDJ action enumeration or is skin-defined is unresolved — do not treat these numbers as portable constants yet.

For third-party clients the 0x31 SCRIPT frame is the one that matters: it carries arbitrary VDJScript, so a client need not reverse the numeric id space at all. It is also how the device sends things with no fixed control id, such as jog-wheel movement.

Verified: a fake device can drive VirtualDJ

Local test, 2026-07-27. A device impersonator (captured opener prefix + one get_clock subscription, no real device involved) sent:

frame(0x31, u16 deck=1 + b"deck 1 play")

Deck 1 was loaded and paused. Within two seconds deck 1 play queried over HTTP flipped noyes and get_position began advancing. A second run sent three different kinds of action down one session, each confirmed by HTTP readback:

Action sent as 0x31 Before After
deck 1 pause deck 1 play = yes no
deck 2 load_next deck 2 loaded = no yes
crossfader 100% crossfader = 0.5 1

Transport, a browser load, and a continuous control all work, so the Remote channel is fully bidirectional for third-party clients: subscribe to any VDJScript query for typed pushes, and send any VDJScript action as a 0x31 frame — one socket, no HTTP interface required.

The u16 before the script mirrors what a real device sends (deck 1 here); a fully deck-qualified script such as deck 1 play works regardless. Note play starts playback rather than toggling it — sending it twice left the deck playing — so use the verb whose semantics you actually want, exactly as on any other VDJScript surface.

Subscriptions accept arbitrary VDJScript

Probed 2026-07-27 by substituting synthetic SUBSCRIBE frames into a replayed opener (the setup/info/panel prefix is reused verbatim; only the subscription block changes). All rows Local test:

Subscribed script Scope Result
get_bpm left val 120
get_clock global txt '05:29 PM'
get_version global txt '2026'
deck 1 get_bpm, deck 2 …, deck 3 … global val 120 each — deck scoping works inside the script, not just via the fourcc
get_effect_name 1 global txt 'Phaser' — FX introspection is reachable
get_bpm 0 ? get_bpm : get_version global txt '2026' — full grammar, ternaries, same result as the HTTP channel
deck 1 get_loaded_song 'fullpath' global full path once a track is loaded; fail on an empty deck
zzz_not_a_real_verb global fail

Conclusions:

  • The subscription vocabulary is all of VDJScript, not a fixed remote-control schema. Queries never used by any Remote skin (get_version, get_effect_name) work fine.
  • The KIND frame is a hint, not a request. Subscribing get_bpm twice — once with kind=0, once with kind=1 — returned val both times. VirtualDJ picks the value type from the query, so a client must handle whichever kind arrives rather than trusting its own declaration.
  • fail means "no value right now", not "bad query". get_loaded_song 'fullpath' returns fail on an empty deck and the real path once loaded — the same query, both outcomes. It is indistinguishable from a bogus verb, exactly like E_FAIL on the HTTP channel, so fail is never evidence that a verb does not exist.

Push behavior measured

Live session with state driven over the HTTP interface (2026-07-27, Local test):

  • Change-driven, same second. deck 1 load "<path>" produced pushes for get_title ('Body Lang'), get_artist ('Balanka'), get_bpm (127.999), fullpath and filename within the same second as the HTTP call. Unloading pushed all of them back to the empty-deck values (get_artistfail, get_bpm → the 120 default).
  • Continuous where the value is continuous. With the deck playing, get_position streamed at a steady 33–34 pushes per second; it was silent while paused. So the channel is happy to carry position/meter feeds at UI frame rates without polling.
  • Idle subscriptions cost nothing: get_clock pushed once a minute, everything else stayed quiet.

225 pushes were logged in one 75-second session covering load, play, and unload.

Reproducing a capture

The device is a plain TCP server that speaks first, so no interception is required — open the Remote app, leave it on its connect screen, and dial it:

python3 tools/vdjremote_dial.py <device-ip> 4243 25    # capture + decode the opener

Run tools/vdjremote_dial.py --decode <file> on a saved capture to re-print the frame listing. A raw reference capture is stored at tests/vdjremote-opener.bin.

To subscribe your own queries and watch values arrive — no device needed, since the capture supplies the prefix:

python3 tools/vdjremote_subscribe.py tests/vdjremote-opener.bin 'left:get_title' 'get_clock' &
dns-sd -R "iPad" _vdjremote8._tcp . 4243        # any name VirtualDJ already lists

VirtualDJ dials in and starts pushing. If it does not, check "Connect automatically" for that device in Config → Controllers → Phone/tablet: with it ticked, dropping and re-adding the dns-sd registration triggers a redial without touching the UI; with it unticked, nothing but a manual Connect click will do.

Open questions

  • The 0x02 numeric control id space is uncharacterized beyond four ids on one skin (see above), and may be skin-defined rather than global. 0x31 makes this mostly moot for third-party clients.
  • Reconnect depends on the per-device "Connect automatically" checkbox in Config → Controllers → Phone/tablet, not on anything the client does. With it ticked, VirtualDJ redials roughly 5 s after a session ends and also dials on a fresh mDNS appearance, so dropping and re-adding the dns-sd advert is enough to get a new session unattended. With it unticked, the device sits at (Waiting) showing "This device is ready to be connected" and only a manual Connect click will start a session — no amount of re-advertising helps. Check that box before assuming a client is being refused.
  • Mid-session subscribe/unsubscribe is untested; only opening-burst registration has been exercised.
  • Waveform data has not been located in any frame; check inside the 0x25 ZIP payloads.
  • The device→desktop numeric types (0x09, 0x0c, 0x27, 0x29, 0x34) are undecoded, as are desktop→device 0x2b and 0x3b. They are sent once per deck and look like capability/state scaffolding; a session is accepted whether or not they are understood, since replay reproduces them verbatim.
  • Waveform data has not been identified in any frame. If it rides this channel it is probably inside the 0x25 ZIP payloads or the undecoded blocks.
  • Whether subscriptions can be added or removed mid-session (rather than only in the opening burst) is untested; a client that changes views would want this.

See TODO task 8 for the current plan.