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.
| 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.
Every message is one frame:
+--------+--------+------------------+
| "8JDV" | u32 LE | payload |
| 4 bytes| total | total - 8 bytes |
+--------+--------+------------------+
- Magic is the ASCII bytes
38 4a 44 56=8JDVon 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 LEmessage 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.
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 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.
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: val → float32 LE; txt → u32 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.
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 text — touchwheel_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.
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
no → yes 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.
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_bpmtwice — once withkind=0, once withkind=1— returnedvalboth times. VirtualDJ picks the value type from the query, so a client must handle whichever kind arrives rather than trusting its own declaration. failmeans "no value right now", not "bad query".get_loaded_song 'fullpath'returnsfailon an empty deck and the real path once loaded — the same query, both outcomes. It is indistinguishable from a bogus verb, exactly likeE_FAILon the HTTP channel, sofailis never evidence that a verb does not exist.
Live session with state driven over the HTTP interface (2026-07-27, Local test):
- Change-driven, same second.
deck 1 load "<path>"produced pushes forget_title('Body Lang'),get_artist('Balanka'),get_bpm(127.999),fullpathandfilenamewithin the same second as the HTTP call. Unloading pushed all of them back to the empty-deck values (get_artist→fail,get_bpm→ the120default). - Continuous where the value is continuous. With the deck playing,
get_positionstreamed 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_clockpushed once a minute, everything else stayed quiet.
225 pushes were logged in one 75-second session covering load, play, and unload.
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 openerRun 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 listsVirtualDJ 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.
- The
0x02numeric control id space is uncharacterized beyond four ids on one skin (see above), and may be skin-defined rather than global.0x31makes 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-sdadvert 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
0x25ZIP payloads. - The device→desktop numeric types (
0x09,0x0c,0x27,0x29,0x34) are undecoded, as are desktop→device0x2band0x3b. 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
0x25ZIP 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.