Manual/Transport & Bootstrap/SFS2X wire protocol
v1.0.364 notes · build 2026-11-07 12:10 UTC

SFS2X wire protocol

plain TCP · header flags · length-XOR · gateway race · SFSObject codec
#protocol #sfs2x

Plain TCP, no TLS. connectionType 0 = raw TCP; connectionType 1 = a one-shot literal HTTP/1.1 Upgrade: websocket preamble (to satisfy load balancers) then identical raw framing — not real WebSocket, no RFC 6455 masking. Preamble fixed static: Sec-WebSocket-Key: cmFuZG9t (base64 of random), User-Agent: LastWarWebSocket, fixed Cache-Control/Pragma/Accept-Encoding/TE/Content-Length, path from ws://host/path (default /); accepts 101/any 2xx/304, never checks Sec-WebSocket-Accept. Sockets: NoDelay, 2 MiB recv, 64 KiB send, 30 s timeouts static. KCPMode always "0"; the game socket is never UDP.

Gateways & the gateway race

  • GSL serverList[].ip = |-delimited list; live: lastwar-game-tcp-aws-1.lastwargame.com|lastwar-game-tcp-gcp-1.lastwargame.com|lastwar-game-tcp-cf-1.lastwarapp.net, port per zone (10783 for APS783, 10072 for APS72) live.
  • Lua ProxyList fallbacks (Global/EnumType.lua, via CSharpCallLuaInterface.GetAllProxy()): lastwar-game-us-aws-ali.lastwargame.com, lastwar-game-us-cf-ali.lastwarapp.com static. CommonUtil.GetPortId(zoneId) = 17000 + zoneId.
  • Gateway race: 1) parallel TCP to all gateways; 2) send CustomPingPong (c=0,a=29) on each; 3) first pong wins, others closed, saved as SERVER_IP; 4) only then Login. Probe p = a fixed masked Android-login-shaped template with only the real deviceId + clientTime spliced; reply {p:{serverTime, clientTime}} (ms). Login timeout 7 s → gateway blacklisted, retry. NetLineSpeedTester re-probes ≤1 per 25 s, analytics only, never switches static.

Envelope

[1-byte header][2/4-byte length][optional zlib → XOR]; body {c:BYTE (0=System, 1=Extension), a:SHORT (1=Login, 13=CallExtension, 29=PingPong), p:SFS_OBJECT}; gameplay p = {c:UTF_STRING cmd, r:INT roomId (always -1, Rooms unused), p:SFS_OBJECT params + injected "_id" int}. Example: {c:1,a=13,p:{c:"mail.read",r:-1,p:{uid:...,_id:42}}}. The real client puts _id on every extension request (Login=1, then 2,3,…); the server echoes _id and adds _time live.

BitFlag / behavior
0x80binary, always set
0x40"encrypted" (XOR below); the client always sets it; server frames (0x80,0xB0,0xB8) don't
0x20compressed iff uncompressed payload > 1024 B (zlib level 9)
0x10receive-only: triggers Zstandard decompression (not LZ4); with 0x20, a 4-byte BE uncompressed length follows (live ~313 KB Zstd init push decoded)
0x08bigSized iff payload > 65535 B → 4-byte length (NetPacket, BaseUtils.decompiled.cs:33284-33324; SDK SFSIOHandler same threshold)
0x04forward: 2-byte BE server id (= zone, ParseSid("APS783") = 783) after the header; set on every send when client_config switch 29 + Lua EnableServerRangeRouting static; captures show 0x030F = 783 on all client frames live
  • Send = legacy NetPacket (0x80|0x40 + optional 0x20/0x08/0x04, XORed body, max 1,000,000 B); receive = NetPacketNew (0x20+0x10 → Zstd; 0x20 alone → zlib) static.
  • The XOR "encryption" = key = uint32LE(len(data)); data[i] ^= byte(key >> (8*(i%4))) — the keystream derives entirely from the cleartext length field (computed post-compression); no secret material.

SFSObject codec

Big-endian, self-describing. Tags: 0 NULL; 1 BOOL; 2 BYTE; 3 SHORT; 4 INT; 5 LONG; 6 FLOAT; 7 DOUBLE; 8 UTF_STRING (u16 len, encoder caps 32,767 B); 10 BYTE_ARRAY (i32 count — the one array type skipping the shared size helper, a real SFS2X asymmetry); 9,11–16 typed arrays (i16 count); 17 SFS_ARRAY; 18 SFS_OBJECT (i16 key count, key+tag+payload, recursive); 19 CLASS (unused); 20 TEXT (i32 len). No BIG_NUM.

  • SDKs: SmartFox2X 1.7.8 (official data model + codec only, never its socket stack) + BaseUtils (the real transport); Smartfox2xLw = a dead 2024 fork. noUpdateAssemblies.json pins SmartFox2X to the APK. Cross-checked against the npm sfs2x-api: all match except the JS SDK's bigSized quirk 65335 — the real threshold is 65535. Vanilla 0x40 semantics = CryptoKey AES-CBC from /BlueBox/CryptoManager; the game never fetches a CryptoKey yet always sets 0x40 with length-XOR. The server answers an optional Handshake (a=0, fields api/cl) with {ct=3072, ms=1000000, tk=<32-hex>} live, but the shipped client never sends it — reimplementations should skip it.

Heartbeat & timers

PingPongRequest every 4000 ms, nested {c:0,a:29,p:{p:{clientTime}}} live; sync (Timer.SyncServerTime) keeps the shortest-RTT pong (or any < 200 ms), server time = serverTime + RTT/2. Timers static: pong/response timeout 12 s → full reconnect (no session resume, no backoff); ConnectGame 6 s × 3; Login 7 s × 3; init push 13 s × 3; GSL HTTP 5 s × 4 alternating UnityWebRequest/BestHTTP.

Errors

Failed extension requests carry errorCode (UtfString, code or locale key) + optional errorPara2 (UtfStringArray); E000000 = generic (SeverErrorCode, ConstDefine.lua). Login rejections use SFS2X ec (short) + ep (UTF string array); ec=28 = "{0}" passthrough carrying E0xx or numeric codes. Fatal: push.user.off (kicked, no reconnect), push.server.stop, push.server.tostop, connection loss.

Transport switches (client_config, index → name) static

4 DISABLE_NEW_NET_PACKET; 5 DISABLE_ZSTD (stops sending isUseLz4 in Login); 25 ShuMei on; 26 ENABLE_SERVER_WS_CONNECTION (ws_ip:80, type 1); 29 ENABLE_CROSS_FORWARD; 44 ENABLE_CONNECT_GAME_WEBSOCKET_FALLBACK (every 2nd retry WS).

PvP mini-games (BiuBiu, GGGo; Joker.Share assembly) static

Matchmaking via activity.shoot.pvp.*; reply names ip,port,sessionidgame,sessionidplayer (GameLift-like). TCP frame u32 LE length | i32 opcode | i32 jsonLen | JSON; UDP datagrams start KCPH + channel id, kcp2k-style Connect/Data/Disconnect, MTU 470.

notes build 2026-11-07 12:10 UTC · lw-notes@9c47e1b · sources: own capture/analysis cross-checked against the public dossier