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:
The payload starts with a 4-byte message code (also little-endian u32), followed by the message-specific fields:
Strings are length-prefixed:
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
PierceFirewallorPeerInit, 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.