Skip to content

Binary Protocol

The SoulSeek network uses a custom binary protocol over TCP. All integers are little-endian. The codec lives in src-tauri/src/soulseek/proto.rs.

Source: src-tauri/src/soulseek/proto.rs


Wire format

Every message is framed as:

[u32 payload_len][payload]

The payload starts with a 4-byte message code (also little-endian u32), followed by the message-specific fields:

[u32 len][u32 code][...fields...]

Strings are length-prefixed:

[u32 len][bytes]

There is no null terminator and no encoding marker — strings are treated as UTF-8 (or lossy-decoded with String::from_utf8_lossy).

Peer handshake framing

Peer connection init messages (PierceFirewall and PeerInit) use a 1-byte code, not 4-byte. These are the first messages exchanged on a new TCP connection before any normal message flow:

PierceFirewall:  [u32 length=5][u8 code=0x00][u32 token]
PeerInit:        [u32 length=...][u8 code=0x01][str username][str type][u32 token]

A raw FileTransferInit is even more unusual: it is not framed at all. The first 4 bytes are a raw u32 transfer token. If those 4 bytes happened to be parsed as a frame length, the value would be enormous (megabytes), which is how the decoder detects it.


Message builder: Msg

Msg is a fluent builder for outbound messages:

pub struct Msg(Vec<u8>);

impl Msg {
    pub fn new(code: u32) -> Self {
        let mut v = vec![0u8; 4]; // length placeholder
        v.extend_from_slice(&code.to_le_bytes());
        Msg(v)
    }

    pub fn u8(mut self, v: u8) -> Self { ... }
    pub fn bool(self, v: bool) -> Self { self.u8(v as u8) }
    pub fn u32(mut self, v: u32) -> Self { ... }
    pub fn u64(mut self, v: u64) -> Self { ... }
    pub fn str(mut self, s: &str) -> Self { ... }  // writes [u32 len][bytes]

    pub fn build(mut self) -> Vec<u8> {
        // patches the 4-byte length prefix, returns the full frame
        let len = (self.0.len() - 4) as u32;
        self.0[..4].copy_from_slice(&len.to_le_bytes());
        self.0
    }
}

Usage:

// FileSearch (code 26): token + query
let msg = Msg::new(26).u32(token).str(&query).build();

// Login (code 1): username + password + version + md5_hash + minor_version
let msg = Msg::new(1)
    .str(&username)
    .str(&password)
    .u32(CLIENT_VERSION)
    .str(&hash)
    .u32(1)
    .build();

Message reader: Buf

Buf<'a> is a cursor over a byte slice for inbound messages:

pub struct Buf<'a> {
    d: &'a [u8],
    pub pos: usize,
}

impl<'a> Buf<'a> {
    pub fn u8(&mut self) -> Option<u8>   { ... }
    pub fn bool(&mut self) -> Option<bool> { self.u8().map(|v| v != 0) }
    pub fn u32(&mut self) -> Option<u32>  { ... }
    pub fn u64(&mut self) -> Option<u64>  { ... }
    pub fn str(&mut self) -> Option<String> { ... }  // reads [u32 len] then [bytes]
    pub fn rest(&self) -> &[u8]           { &self.d[self.pos..] }
    pub fn remaining(&self) -> usize      { ... }
}

All read methods return Option and advance pos. If there aren't enough bytes remaining, they return None. Callers use ? to propagate parse failures.

Strings are decoded with String::from_utf8_lossy — invalid bytes become the replacement character U+FFFD rather than causing a parse error.


Receiving messages

Normal messages

pub async fn recv_msg(r: &mut OwnedReadHalf) -> std::io::Result<Vec<u8>> {
    let mut len_buf = [0u8; 4];
    r.read_exact(&mut len_buf).await?;
    let len = u32::from_le_bytes(len_buf) as usize;
    if len > 8 * 1024 * 1024 {
        return Err(/* "slsk msg too large" */);
    }
    let mut buf = vec![0u8; len];
    r.read_exact(&mut buf).await?;
    Ok(buf)
}

The 8 MB cap guards against corrupted frames or protocol desync eating memory.

Peer init / FileTransferInit ambiguity

On F-type (file transfer) connections the first bytes are ambiguous:

  • If the peer sends a framed PierceFirewall or PeerInit, the 4-byte length will be ≤ 8192.
  • If the peer sends a raw FileTransferInit (4-byte token), those bytes decode to a value in the millions when read as a frame length.

recv_peer_init_or_fti_lead handles this:

const MAX_PEER_INIT_FRAME_PAYLOAD: usize = 8192;

pub async fn recv_peer_init_or_fti_lead(r: &mut OwnedReadHalf)
    -> std::io::Result<PeerFramedOrRawFti>
{
    let len = u32::from_le_bytes(len_buf) as usize;
    if len <= MAX_PEER_INIT_FRAME_PAYLOAD {
        // Normal framed message — read payload
        Ok(PeerFramedOrRawFti::FramedPayload(buf))
    } else {
        // Value too large to be a frame length — must be raw FTI token
        Ok(PeerFramedOrRawFti::RawFileTransferInit(u32::from_le_bytes(len_buf)))
    }
}

If the raw FTI was consumed here, the token is stored in FConnReady.file_transfer_init_consumed so the transfer handler doesn't try to re-read it from the socket.


Known message codes

Code Direction Name
1 C→S Login
2 C→S SetWaitPort
3 C→S / S→C GetPeerAddress (request / response)
18 S→C ConnectToPeer
26 C→S FileSearch
35 C→S SharedFolderFiles
71 C→S HaveNoParent
92 C→S / S→C CheckPrivileges
9 P→P FileSearchResponse
40 P→P TransferRequest
41 P→P TransferResponse
43 P→P QueueUpload
46 P→P UploadFailed
50 P→P UploadDenied

Codes 64, 69, 83, 84, 89–93, 100, 102, 104 are server keepalive/housekeeping messages that neegde receives and discards silently.