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 pluswork/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.
1. Topology
Section titled “1. Topology”| 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. Framing, crypto, auth
Section titled “3. Framing, crypto, auth”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 |
3.2 Serializers
Section titled “3.2 Serializers”- 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
bdByteBufferread/write at0xcdf180–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.
3.3 Crypto and identity (H)
Section titled “3.3 Crypto and identity (H)”- 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.
- No code: ID 0, key
3.4 Auth service (?svc=auth)
Section titled “3.4 Auth service (?svc=auth)”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.luamakes the game always use the blank-field default1234-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.
- Production should add a charset whitelist and a profanity filter.
4. LSG
Section titled “4. LSG”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.
- first message: type 7 (connect), a bitbuf
- 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.
4.1 Friends: service 9
Section titled “4.1 Friends: service 9”| 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):
- At login it calls
listFilesByOwner(me, 0, 1, 0, "MultiplayerProfile.save"). - If the file exists, it fetches it with getFileByID (M).
- If not, it uploads a fresh one, about 3.9 KB.
- 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.
4.3 Stats and leaderboards: service 4
Section titled “4.3 Stats and leaderboards: service 4”| 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 + 50023orn + 50026, depending on a flag, andmax = 20from 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.
4.4 Messaging: service 6
Section titled “4.4 Messaging: service 6”| 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.
4.6 Title utilities: service 12
Section titled “4.6 Title utilities: service 12”| 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.
4.9 Link codes: service 57
Section titled “4.9 Link codes: service 57”| 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 |
4.10 Other services the client can call
Section titled “4.10 Other services the client can call”| 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/pumpDeleteViaHTTPmove 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 |
4.13 Push messages (type 2)
Section titled “4.13 Push messages (type 2)”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::receiveFromd16a30). - 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
0epackets, from instance 2 to127.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=1across two hosts behind different NATs. Look for thesent INTRO REQ to %slines. - 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:
bdBandwidthTestClientexists, but it is unknown whether Blur uses it. No traffic was seen.
6. Operations
Section titled “6. Operations”| 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). |
7. Test plan
Section titled “7. Test plan”-
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 testcovers these).
-
Two instances, one Mac (
BLUR_PORTMAPfor 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.
-
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 -
Soak: 2 h idle in lobby (keepalives); server restart mid-lobby (clients recover); 100 simulated LSG clients (scripted from the MVP test harness).
-
Abuse: wrong
X-Blur-Keygets 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.
8. Open questions
Section titled “8. Open questions”- 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.
- writeStats
writeTypesemantics, the full set of board IDs, and Blur’s column schema per board (which column is the rank key). - findSessions query IDs and parameter layouts per playlist (ranked, party, private game).
- NAT-traversal introducer flow (
0d/0e): who sends what to whom, and how the HMAC key is shared. Also whether Blur enablesbdRelaySocketRouter, and what a relay server must speak. - Meanings of push types 1–3, 7–18 and 29, and which ones Blur’s listeners act on.
- Service 50 (content streaming) and service 52: task names, result types, and the HTTP upload URL contract. Only needed if photo sharing should work.
- Auth request types other than 0 and 10 (change password? delete account?), and the full auth error-code table.
- Title utilities 12/5 and verifyString’s reply format.
- Whether the
BlurTU1_*.tstitle-update files ever existed in a form we should serve (probably not). - Key archive (15): the meaning of its bools and op byte, and which game data uses it.