Skip to content

Blur online server: full spec

Status 2026-10-17. What a production server for the native Blur port must implement, to replace the MVP in server/. It covers the Blur.exe 1.2 client only. This is Demonware DW2 “Matchmaking Plus” (title ID 0x149b).

Sources:

  • static RE of work/iso/Patch Ver 1.2 (Must Do)/Blur.exe, using objdump plus work/recomp/functions.tsv; VAs are given so claims can be checked.
  • MVP code and logs from the two-instance test.
  • protocol notes in docs/research/online-multiplayer.md.

Status: MVP = implemented in server/; stub = MVP sends an empty success reply; missing = not answered. Confidence:

  • H: verified at runtime, or certain from code (IDs, argument types).
  • M: layout from code, meaning inferred.
  • L: guess.
Component Transport Address the client uses Notes
Auth websocket wss://<host>/socket?svc=auth Demonware auth hostname, redirected by blurhost one connection per login / create-account
LSG (lobby) websocket wss://<host>/socket?svc=lsg Demonware LSG hostname, redirected long-lived; all services in §4
IP / NAT discovery, NAT-traversal introducer UDP 3074 + 3075 stun.{us,eu,au,jp}.demonware.net, resolved to the URL’s host Demonware’s own protocol, not RFC 5389 STUN (§5)
Content-streaming HTTP HTTP(S) URL returned by service 50 from a task reply only needed for photo sharing (§4.11)
Races UDP peer-to-peer (DTLS between clients) — no server involved except NAT traversal

The client’s default endpoint is wss://online.blur.entropylan.party/socket. BLUR_ONLINE_URL and BLUR_ONLINE_KEY override it. The STUN hostnames resolve to the URL’s host, so that host’s IP must receive UDP 3074/3075. A CDN or HTTP-only proxy in front of it breaks NAT discovery. Use a DNS-only record, or split the hosts and change the client.

2. Tunnel contract (src/host/ws_tunnel.mm, ws2_32.cpp)

Section titled “2. Tunnel contract (src/host/ws_tunnel.mm, ws2_32.cpp)”
  • A standard RFC 6455 websocket over TLS. The proxy terminates TLS.
  • Request headers:
    • X-Blur-Key: <key>: reject with 403 on mismatch.
    • User-Agent: BlurNative/1.
    • Query svc=auth|lsg.
  • Client → server: one binary frame per guest send(). Frame boundaries carry no meaning; treat the payload as a byte stream.
  • Server → client: binary frames. The client writes each one into the game’s TCP stream, so boundaries are free. Text frames are accepted as UTF-8 bytes.
  • Close or error means the game sees EOF. Ping/pong is handled by the client’s NSURLSession.
  • The key is embedded in the client, so it is a gate, not a secret. It keeps casual scanners out and allows rotation by shipping a new build. Do not treat it as authentication.

3.1 bdLobbyConnection framing (both services)

Section titled “3.1 bdLobbyConnection framing (both services)”
Item Format Conf
Message frame u32 LE len then body H
Plain body 00 <msg> H
Encrypted body 01 <u32 seed> 3DES-CBC(key, IV = Tiger192(seed as LE u32)[0:8]) of u32 check | msg | zero pad to 8 H
check server → client must be 0xDEADBEEF; client → server is an HMAC-SHA1 (MVP ignores it; production should verify) H / M
Keepalive client sends zero-length frames (00000000) every ~40 s. The client drops LSG after 120 s with no data from the server, so answer each one (MVP sends a connection-ID message) H
LSG preamble 8 raw bytes before the first frame: u32 200, u32 0xffff H
Max frame MVP caps at 1 MiB. Profile uploads are about 4 KB H
  • bdBitBuffer (auth): bits LSB-first. The first bit is the “typed” flag; each typed value has a 5-bit tag before its data.
  • bdByteBuffer (LSG): each value is a 1-byte type tag followed by little-endian data. Verified from bdByteBuffer read/write at 0xcdf180–0xcdfde0.
Tag Type Tag Type
1 bool 10 u64
3 u8 13 f32
6 u16 14 f64
7 i32 16 string, NUL-terminated
8 u32 19 blob: 13 08 <u32 len> <bytes> (typed u32 length)
9 i64 20 “NaN”: written instead of a value equal to the type’s max
100+t array start: u8 100+t, typed u32 byteSize, raw u32 count, raw elements, end marker (M)

Example: setGroups([0]) is sent as 6c 08 04000000 01000000 00000000 00.

  • Tiger/192: 3 passes, state words little-endian, as LibTomCrypt computes it (server/crypto.go).
  • User ID: Tiger(lowercase(name))[0:8] as a LE u64.
  • User key: Tiger(password), 24 bytes. The protocol uses it as the shared secret, so the server must store Tiger(password) itself. It is unsalted by design, so treat the store as a credential database (§6).
  • Licence ID: Tiger(code)[0:8]. Licence key = Tiger(code).
    • No code: ID 0, key DEADBEEF ×4 + 8 zero bytes.

Request layout: u8 type | bitbuf{typed=1, u32 seed, u32 title=0x149b, ...}. Reply type = request type + 1.

Type Request body Reply Status Conf
0 create account u64 licenceID | 3DES(licenceKey, seed)[768 bits] of {u32 0xEFBDADDE, name[64], Tiger(password)[24]} u32 code MVP H
10 user login u64 userID type 11: u32 700 | u32 seed2 | 3DES(userKey, seed2) of 128-byte ticket | LSG ticket[128] MVP H
others unknown; the MVP logs auth: unhandled request type u32 701 missing L
  • Ticket (128 bytes, encrypted with the user key):
    • u32 0xEFBDADDE, u8 0, u32 title, u32 issued, u32 expires, u64 licence, u64 userID, name[64], sessionKey[24], zero pad.
    • The session key becomes the LSG 3DES key.
  • LSG ticket: opaque 128 bytes that the client echoes in LSG connect. The MVP uses 16 random bytes plus zeros as a lookup token.
    • Production should make it unforgeable: random 16 bytes is already 128-bit; a signed token also works.
    • It should also expire, and survive a restart if logins should survive one (§6).
  • Codes: 700 OK; 701 bad request; 704 unknown account; 707 name taken. Other DW codes (bad password, banned) are not mapped yet (L).
    • There is no password check at login. Possession of Tiger(password) decrypts the ticket, so a wrong password simply fails on the client.
    • That means repeated logins cost the attacker nothing on the server side. Rate-limit them (§6).
  • Licence keys: accept any key, or none.
    • patches/Screens/Account/NetRegister.post.lua makes the game always use the blank-field default 1234-1234-1234-1234-1234.
    • The server must know every code it can decrypt for: currently that default plus ID 0.
    • Never reject on licence. A request with an unknown licence ID cannot be decrypted at all; return 701 and log it.
  • Usernames: 1–63 bytes. Case-insensitive identity, because the ID is a hash of the lowercase name.
    • Production should add a charset whitelist and a profanity filter. bdTitleUtilities::verifyString (12/1) exists for in-game text.

Messages:

  • Client → server:
    • first message: type 7 (connect), a bitbuf {typed, u32, u32, LSGticket[1024 bits]}, unencrypted.
    • after that, encrypted: u8 service | typed u8 task | typed args.
  • Server → client: u8 type.
    • 1 task reply
    • 2 push
    • 3 LSG error
    • 4 connection ID (typed u64)
    • 5 async ack
  • Task reply: typed u64 txid | typed u32 error | (if error==0) typed u8 task | typed u32 numResults | (if >0) typed u32 total | results... (H, from the MVP).
    • Each result is the serialized object the client’s task expects, back-to-back (layouts in §4.12).
    • Any non-zero error fails the task.
    • Known codes: 0 OK, 1000 storage file not found.

The table below lists every LSG task the client can send: 59 call sites into startTask, found by tracing taskHdr(buf, service, task) at 0xcf68a0. “Seen” means it was logged during the two-instance online session.

Task Method (VA) Args Result Seen Status
1 proposeFriendship (ce7470) u64 user, blob message — missing
2 acceptProposal (ce7a90) u64 user — missing
3 rejectProposal (ce7c00) u64 user — missing
4 removeFriend (ce7d70) u64 user — missing
5 getFriends (ce7ee0) u32 max (100) bdFriendInfo[] yes stub
6 withdrawProposal (ce7620) u64 user — missing
12 getOutgoingProposals (ce7790) u32 max bdFriendProposal[] missing
14 getIncomingProposals (ce7910) u32 max bdFriendProposal[] missing
16 setRichPresence (ce8060) blob (20 bytes, game-defined) — yes, often stub

Friends must also send push messages when a proposal arrives or is accepted, and on presence changes (§4.13).

4.2 Storage: service 10 (progression lives here)

Section titled “4.2 Storage: service 10 (progression lives here)”
Task Method (VA) Args Result Seen Status
1 uploadFile (cf7220) str name, bool private?, blob data, u64 owner? 1 result, likely bdFileInfo with the new file ID (M) yes stub
4 getFileByID (cf7490) u64 fileID bdFileData missing
5 listFilesByOwner (cf7770) u64 owner, u32 minTime, u16 max, u16 offset, str name bdFileInfo[] yes stub
7 getPublisherFile (cf7a10) str name bdFileData yes (BlurTU1_bin.ts, BlurTU1_loc.ts) MVP: 1000 not found
9 (cf7620, called from content streaming) u64[] file IDs ? missing; probably delete-files (L)

How the game uses storage (H, from logs):

  1. At login it calls listFilesByOwner(me, 0, 1, 0, "MultiplayerProfile.save").
  2. If the file exists, it fetches it with getFileByID (M).
  3. If not, it uploads a fresh one, about 3.9 KB.
  4. It re-uploads the file after XP or rank changes; three uploads with growing content were seen in one session.

So the server must:

  • store one file per (owner, name), overwriting on re-upload;
  • keep file IDs stable;
  • return bdFileInfo on list and bdFileData on get.

With the current stub, online rank and XP are lost at every login. This is the top priority after matchmaking.

Publisher files BlurTU1_*.ts are title updates. Return 1000 unless we ship our own.

Task Method (VA) Args Result Status
1 writeStats (cf0df0) n × bdStatsInfo (Blur subclass) — missing
3 readStatsByEntityID (cf1360) u32 board, u64 entity(s) bdStatsInfo[] missing
4 readStatsByRank (cf0f90) u32 board, u64 firstRank, u32 max bdStatsInfo[] missing
5 readStatsByPivot (cf1170) u32 board, u64 pivotEntity, u32 max bdStatsInfo[] missing

bdStatsInfo:

  • Base serialize (cf86d0): u32 board, u64 entity, u8 writeType, i64 rating.
  • Base deserialize (cf8730): u64 entity, i64 rating, u64 rank, str name, u32 n.
  • Blur’s subclass (serialize d68bd0, deserialize d68d40) follows with up to 64 columns. Each column is one typed i32, i64, f64 or f32, and the column types come from the client’s own schema.
  • The server should store column values verbatim per (board, entity) and return them unchanged.
  • writeType semantics (replace / max / add) are unknown (L). Defaulting to “replace if rating better” is a guess; verify with BLUR_DWLOG=1.

Board IDs:

  • The front-end leaderboard read (b7b58b) uses board = n + 50023 or n + 50026, depending on a flag, and max = 20 from rank 1.
  • In-race writes (d68aea) take their board IDs from session data, up to 20 rows.
  • Full enumeration is open: log all board IDs seen.
Task Method Args Result Status
1 getMailHeaders (cf1c70) u32, u32 (offset, max — M) bdMailHeader[] missing
3 deleteMail (cf2000) u64 mailID — missing
4 sendMail (cf21c0) blob body, u64 recipient(s) — missing
5 getNotifications (cf23f0) u32, u32, bool bdNotification[] missing
9 getMails (cf1e20) bool, u64 mailID bdMailBody[] missing

4.5 Matchmaking: service 21 (core of online play)

Section titled “4.5 Matchmaking: service 21 (core of online play)”
Task Method Args Result Status
1 createSession (ce8230) bdMatchMakingInfo blob sessionID MVP
2 updateSession (ce8390) sessionID, bdMatchMakingInfo — MVP
3 deleteSession (ce84f0) sessionID — MVP
4 findSessionFromID (ce8630) sessionID bdMatchMakingInfo MVP
5 findSessions (ce8780) u32 queryID, u32 start, u32 max, query params bdMatchMakingInfo[] MVP (filters on the first attribute only)
6 / 7 notify join / leave (startNotifyTask ce8e30) sessionID, u64 user — MVP (player count)
8 inviteToSession (ce8980) sessionID, blob attachment, u64 invitee(s) — missing; must push type 6 to invitees
9 submitPerformance (ce8c40) u32 n, n × bdPerformanceValue {u64 user, i64 value} — missing; skill rating input (M)

bdMatchMakingInfo:

  • Client serialize (d8a190): blob hostAddr, u32 gameType, u32 maxPlayers, then title fields: a 16-byte key blob plus attribute values.
  • Deserialize (d8a1e0), which is what the server returns: blob hostAddr, blob sessionID(8), u32 gameType, u32 maxPlayers, u32 numPlayers, then the same title fields.

The MVP filters by the first attribute value (a game/version hash). A full server should:

  • decode query params per queryID and match on all of them (playlist, ranked, region, …), and log unknown query IDs;
  • age out sessions whose host disappeared (the MVP drops them on LSG disconnect);
  • keep player counts consistent if a join or leave notify is lost.
Task Method Args Result Status
1 verifyString (ceb570) str probably a bool or u32 verdict (L) missing
3 recordEvent (ceb730) str event, u32 — missing; telemetry, log it
5 ? (ceb8e0) blob, u32 ? missing
6 getServerTime (ceb9f0) — bdTimeStamp u32 unix MVP

4.7 Groups: service 28 (online player counts)

Section titled “4.7 Groups: service 28 (online player counts)”
Task Method Args Result Seen Status
1 setGroups (cebb30) u32[] groups — yes; values 0, 1, 1983 and others, changing as the player moves through menus/playlists stub
4 getGroupCounts (cebd30) u32[] groups (0..9, 1983) bdGroupCount[] {u32 group, u32 count} yes stub

Implement as “the groups each connected user is in”, counted live. The game shows these counts in the online menus (M).

4.8 Key archive: service 15 (per-user key/value integers)

Section titled “4.8 Key archive: service 15 (per-user key/value integers)”
Task Method Args Status
1 write (cf0620) per entry: u64, bool, u16 key, i64 value, u8 op missing
2 read (cf09b0) u64 entity, bool, bool, u16 key missing
3 readAll (cf0c00) u64 entity, bool, bool missing

Callers are in game code (b77c98 and nearby). The meaning of the bools and op byte is unknown (L). Store (entity, key) → i64 and apply op as set/add.

Task Method Args Result Status
1 getLinkCodes (cf1720) u64 user, u32 bdLink[] {u64, u32, str, str} stub; seen at login. Returning none is probably fine
Svc Task VA Args Notes
35 Twitter 1 registerAccount / 2 post cf25d0 / cf2810 str,str / str photo sharing; dead services, so return an error
36 Facebook 1 register / 2 post / 3 unregister / 4 uploadPhoto / 5 isRegistered cf29e0… strings; uploadPhoto: u64 fileID, str same: return errors, or isRegistered = false
52 (tags? M) 5 cf02f0 u32, u32, bdTag[] {u32,u32} called from b7672a
55 VoteRank 1 submitRating cf0470 serialized rating content rating

4.11 Content streaming: service 50 (user content / photos)

Section titled “4.11 Content streaming: service 50 (user content / photos)”
Task VA Args Seen
1 cec540 u32, u64
2 cec690 u64, u32, u16, u16, u16, str
3 cec890 u32, u16, u16, u16 [, str] yes, at login (0, 1, 0, 1 and 0, 1, 0, 2)
5 cee610 str, u16, u32, u16, blob upload step
6 cee7f0 str, u16, u16, str, u32, u16, blob, u32[] upload step
7 ceeb20 u64, u16, u32
8 ceee90 u16
9 / 10 ceec50 / ceed70 u64, u32
  • Results use bdContentStreamingStatsInfo: bdStatsInfo, then u64, str. bdFileInfo is also used.
  • bdContentStreaming::pumpUploadViaHTTP / pumpDownloadViaHTTP / pumpDeleteViaHTTP move file bodies over HTTP, to URLs returned by these tasks. A full implementation needs an HTTP blob endpoint.
  • Callers: game front end (b76xxx) and photo sharing (bf2xxx–bf3xxx). An empty list for 50/3 is enough for normal play.

4.12 Result object layouts (deserialize, from RTTI vtables)

Section titled “4.12 Result object layouts (deserialize, from RTTI vtables)”
Class Fields, in order VA
bdFileInfo u32 size, u64 fileID, u32 created, u32 modified, bool private, u64 owner, str name(≤128) d8a520
bdFileData blob cf8960
bdFriendInfo u64 user, str name, bool online, u8 ce9430
bdFriendProposal u64 user, str name, blob message ce9270
bdGroupCount u32 group, u32 count cf0200
bdMailHeader u64 mailID, str from, u64 fromID, u32 time, u32 size cf1b00
bdMailBody u64 mailID, blob cf19c0
bdNotification u32, u32, u64, u64, str, u64, str, u64, str cf3750
bdSessionID blob(8) ce9110
bdTimeStamp u32 cebfe0
bdLink u64, u32, str, str cf1590
bdPerformanceValue u64 user, i64 value d89d90
bdSessionInvite u64 from, str name, blob sessionID, blob attachment cf7d80
bdStatsInfo §4.3 cf8730 / d68d40
bdMatchMakingInfo §4.5 d8a1e0

Layout: typed u32 pushType | fields, dispatched by bdLobbyService::handlePushMessage (ce5380) to listener vtable slots. Field layouts are H; meanings are M/L.

Type Fields Listener slot Likely meaning
1, 2, 3 — +0x2c, +0x24, +0x28 connection/account state events (L)
4, 5 u64 user, str name +0x4 (flag 1 for 4, 0 for 5) friend proposal received / friend added (M)
6 bdSessionInvite +0x1c session invite (M); needed for inviteToSession
7, 8 u64, str, u64, blob +0x14
9, 10 u64, str, u64 +0x10
11, 12 u64, str, u64, str +0x8
13 u64, str, blob +0xc mail / notification (M)
14 u64, u64, str, blob +0xc
15 u64, str, blob +0x18 rich presence update (M)
16 u64, str, u64, str, blob +0x18
17 u64, str, (ce9010), blob +0x20
18 — +0x30
19–28 ignored by the client —
29 u64 +0x34

Confirm each meaning by sending a push with BLUR_DWLOG=1 set and watching the game’s behaviour.

5. UDP: Demonware discovery and NAT traversal (not RFC 5389 STUN)

Section titled “5. UDP: Demonware discovery and NAT traversal (not RFC 5389 STUN)”

A standard STUN server such as coturn cannot answer these packets. They are Demonware’s own formats. Addresses are IPv4 (network order) + port (LE u16). Version is u16 LE 2.

Packet Format Server behaviour Status
IP discovery request 1e 02 00 reply from the same socket 1f 02 00 <src addr> MVP, H
NAT test 1 (to :3074) 14 02 00 00 15 02 00 <src addr> <serverIP:3075> MVP, H
NAT test 2 14 02 00 03 reply from a different public IP; reaching the client shows full-cone NAT missing: needs a second IP
NAT test 3 (to :3075) 14 02 00 02 same as test 1, from :3075; the client compares mappings (moderate vs strict) MVP, H
NAT trav INTRO REQ 0d 02 00 | HMAC[10] | u32 | addr | addr (serializer d21140) introducer: forward to the target peer so both sides punch holes missing, M
NAT trav INTRO REPLY 0e ..., same layout (sent at d17ee5 “sent INTRO REPLY to %s”) forward back to the requester missing, M
Peer probe 0a ... (sendStage2 d185e0), 0b, 0c peer to peer; 0c reaching a client logs “Received server packet in client code” —
  • A client accepts types 0x0a–0x0d (bdNATTravClient::receiveFrom d16a30).
  • HMAC failures are logged as “Packet was tampered with, discarding”. The HMAC key comes from the session/security key (bdSecurityKeyMap), so the introducer must forward packets unmodified.
  • In the local test, the MVP saw only 0e packets, from instance 2 to 127.0.0.1:3074. That was probably intended for instance 1 (same host, same port), so it does not show which packets really reach the introducer.
  • Next step: trace with BLUR_DWLOG=1 across two hosts behind different NATs. Look for the sent INTRO REQ to %s lines.
  • Relays: the client also has bdRelaySocketRouter (route add/remove/forward). Whether Blur enables it, and which server relays, is open. Without a relay, symmetric-NAT pairs cannot connect.
  • Bandwidth test: bdBandwidthTestClient exists, but it is unknown whether Blur uses it. No traffic was seen.
Area Requirement
Accounts Durable DB (SQLite is enough): user ID, name, Tiger(password), created, last login, ban flag. File mode 0600, encrypted backups. Tiger(password) is login-equivalent: a leak lets anyone log in as that user, so plan a forced re-register after a breach.
Progression Storage files (§4.2), stats rows (§4.3), key archive (§4.8): durable, per user, backed up daily. Cap file size (64 KB) and file count per user.
Sessions / lobbies In memory is fine. On restart, clients reconnect and hosts recreate sessions. LSG tickets: persist them, or accept re-login (MVP: restart forces re-login).
Restart Graceful drain: stop accepting, let races finish (races are P2P, so only lobbies break). systemd Restart=always.
Scaling One process handles well over 1k concurrent LSG connections. Matchmaking needs a shared view: run one instance, or move sessions to Redis before scaling out. UDP discovery is stateless; NAT test 2 needs a second IP on the same or a paired host.
Abuse Per-IP rate limits: websocket upgrades, auth requests (create-account and login), LSG messages per second. Cap message size and sessions per user. Validate every field length. Ban by user ID and IP. Profanity filter on names.
Key rotation Accept a list of keys (old + new) during a client rollout, then drop the old one. The key is public by nature (§2).
Logging Structured: connect/disconnect, login/create (never passwords or keys), every unhandled service/task with a trimmed hex payload, every unknown board ID / query ID / push. Keep payload dumps opt-in.
Monitoring Metrics: connected users, logins/min, auth failures, sessions open, tasks/s by (svc, task), unhandled count, UDP packets by type. Alerts: process down, TLS expiry, unhandled spike, UDP silent.
TLS Reverse proxy (Caddy/nginx) with websockets; DNS-only record because UDP must reach the same IP (§1).
Privacy Store only what the protocol needs. Document retention. Allow account deletion (admin tool; no in-game delete is known).
  1. Unit:

    • Tiger, 3DES, framing.
    • Every result serializer, round-tripped against the client’s deserialize order in §4.12.
    • Create, login, LSG-connect golden vectors (MVP go test covers these).
  2. Two instances, one Mac (BLUR_PORTMAP for the second), for each:

    • create account (blank, random and real-looking licence key);
    • login with a wrong password (must fail cleanly);
    • online menu counts (groups);
    • create/find/join for every playlist and mode;
    • finish a race, then check stats written, leaderboard read, and profile uploaded;
    • re-login and confirm XP/rank persist;
    • invite (push 6), friends propose/accept, mail.
  3. NAT matrix, two hosts. Pass = the race loads and both cars move.

    open full cone restricted port-restricted symmetric
    open ✓ ✓ ✓ ✓ ✓
    port-restricted needs introducer needs relay
    symmetric needs relay needs relay
  4. Soak: 2 h idle in lobby (keepalives); server restart mid-lobby (clients recover); 100 simulated LSG clients (scripted from the MVP test harness).

  5. Abuse: wrong X-Blur-Key gets 403; oversized frames; malformed bitbuf/bytebuf (fuzz the parsers); login floods.

Each run uses BLUR_DWLOG=1 on the clients plus the server’s unhandled-task log. The goal is zero unhandled tasks on the golden path.

  1. Storage: the exact uploadFile result type, and whether the game re-reads the profile with getFileByID or another call. Check by implementing list and watching the next call.
  2. writeStats writeType semantics, the full set of board IDs, and Blur’s column schema per board (which column is the rank key).
  3. findSessions query IDs and parameter layouts per playlist (ranked, party, private game).
  4. NAT-traversal introducer flow (0d/0e): who sends what to whom, and how the HMAC key is shared. Also whether Blur enables bdRelaySocketRouter, and what a relay server must speak.
  5. Meanings of push types 1–3, 7–18 and 29, and which ones Blur’s listeners act on.
  6. Service 50 (content streaming) and service 52: task names, result types, and the HTTP upload URL contract. Only needed if photo sharing should work.
  7. Auth request types other than 0 and 10 (change password? delete account?), and the full auth error-code table.
  8. Title utilities 12/5 and verifyString’s reply format.
  9. Whether the BlurTU1_*.ts title-update files ever existed in a form we should serve (probably not).
  10. Key archive (15): the meaning of its bools and op byte, and which game data uses it.