> ## Documentation Index
> Fetch the complete documentation index at: https://lastwar.tech/llms.txt
> Use this file to discover all available pages before exploring further.

# SFS2X wire protocol

> How the game's hand-rolled SmartFoxServer 2X packet envelope, header flags, XOR obfuscation, and binary SFSObject codec work on the wire, validated against the official SmartFoxServer JS SDK.

<Info>
  **Live-tested findings on this page were confirmed against production on 2026-07-03**, with the reconnect/collection path re-confirmed still working in 2026-08 (including with the shipped placeholder `ta` sub-fields), using client identity `com.lastwar.ios` v1.0.344 (build 786) for the reconnect/collection path and `com.fun.lastwar.gp` v1.0.351 for the bootstrap/guest-login path. This documents an unofficial third-party game server's behavior, which the operator can change at any time without notice — treat anything marked **Confirmed** or **Resolved** on this page as true as of that date, not a permanent guarantee. If a command, field, or error code described here stops matching reality, re-verify against a fresh packet capture before assuming this documentation is still correct.
</Info>

The client bundles the real SmartFoxServer 2X SDK but never actually uses its socket layer. Every byte on the wire is written by a hand-rolled reimplementation that only reuses SFS2X's data model — and the "encryption" bit is a length-derived XOR, not a cipher.

## Transport

Plain TCP socket, **no TLS**. Host/port/zone/connection-type arrive dynamically from the GSL response. Two connection types exist:

* `connectionType 0` — raw TCP, straight into the packet framing below.
* `connectionType 1` — a one-shot literal HTTP/1.1 `Upgrade: websocket` preamble (with a **hardcoded, non-random** `Sec-WebSocket-Key`; only the HTTP status line is checked on response — the `Sec-WebSocket-Accept` challenge/response is never validated) is sent first, purely to satisfy load balancers that require a valid HTTP handshake — then the exact same raw SFS2X binary framing runs on the same socket. **This is not a real WebSocket**: there's no RFC 6455 frame masking anywhere in the read/write path. A Go client can dial TCP directly, or replay the literal handshake bytes if a load balancer demands them.

## Packet envelope — three nested layers

```text theme={null}
[1-byte header][2/4-byte length][ optional zlib compress → XOR "encrypt" ]
  body = SFSObject {
    "c": BYTE    controller id   (0 = System, 1 = Extension)
    "a": SHORT   action id       (1 = Login, 13 = CallExtension, 29 = PingPong)
    "p": SFS_OBJECT  content
  }
```

For every gameplay `cmd`, the `"p"` content is itself another SFSObject:

```text theme={null}
SFSObject {
  "c": UTF_STRING   cmd        e.g. "mail.read"
  "r": INT          roomId     always -1 (SFS "Rooms" are unused entirely)
  "p": SFS_OBJECT   params     the command's own fields, plus an injected "_id" int
}
```

So a full `mail.read` request is schematically `{c:1, a:13, p:{c:"mail.read", r:-1, p:{uid:..., type:..., toUser:..., _id:42}}}`.

## Packet header bits

| Bit    | Flag                | Behavior                                                                                                                                                                                                                                                                                                                                                                                                                                                           |
| ------ | ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `0x80` | binary              | always set                                                                                                                                                                                                                                                                                                                                                                                                                                                         |
| `0x40` | encrypted           | always set — see XOR "encryption" below                                                                                                                                                                                                                                                                                                                                                                                                                            |
| `0x20` | compressed          | set iff serialized payload > 1024 bytes (zlib deflate, level 9)                                                                                                                                                                                                                                                                                                                                                                                                    |
| `0x10` | useLZ4 (repurposed) | dead on send in this build; on receive the "LZ4" bit actually triggers **Zstandard** decompression, not LZ4 — leftover naming from a migration. **Implemented** — `ReadPacket` (`packet.go`) previously detected this branch correctly but just returned an error instead of decompressing, which read as connection-closed; now decodes via `github.com/klauspost/compress/zstd`, confirmed live against the server's \~313KB Zstd-compressed init bootstrap push |
| `0x08` | bigSized            | set iff payload > 65535 bytes → 4-byte length instead of 2 (`BitSwarmManager.WriteBinaryData`, `Smartfox2xLw.decompiled.cs:13563` — the Unity SFS2X SDK this game actually embeds; see the correction note below)                                                                                                                                                                                                                                                  |
| `0x04` | forward             | off by default; appends a 2-byte source-shard id                                                                                                                                                                                                                                                                                                                                                                                                                   |

<Warning>
  **The "encryption" is not encryption**

  ```go theme={null}
  func Encrypt(data []byte) []byte {
      key := uint32LE(len(data))          // the on-wire length field — sent in cleartext
      for i := range data {
          data[i] ^= byte(key >> (8 * (i % 4)))
      }
      return data
  }
  ```

  The XOR keystream is derived entirely from the packet's own (cleartext) length field. No secret material, no negotiation, no per-session state. This is obfuscation, not confidentiality, and needs zero cryptographic material to implement in Go — just XOR the body against the little-endian length, computed after compression.
</Warning>

## `SFSObject` binary format

Big-endian throughout. A standalone serialized object is self-describing — it starts with its own tag byte and count.

| Tag      | Type         | Encoding                                                                                                                                                                                                                                                                               |
| -------- | ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 0        | `NULL`       | —                                                                                                                                                                                                                                                                                      |
| 1        | `BOOL`       | 1 byte                                                                                                                                                                                                                                                                                 |
| 2        | `BYTE`       | 1 byte                                                                                                                                                                                                                                                                                 |
| 3        | `SHORT`      | 2 bytes, signed                                                                                                                                                                                                                                                                        |
| 4        | `INT`        | 4 bytes, signed                                                                                                                                                                                                                                                                        |
| 5        | `LONG`       | 8 bytes, signed                                                                                                                                                                                                                                                                        |
| 6        | `FLOAT`      | 4 bytes, IEEE-754                                                                                                                                                                                                                                                                      |
| 7        | `DOUBLE`     | 8 bytes, IEEE-754                                                                                                                                                                                                                                                                      |
| 8        | `UTF_STRING` | u16 byte-length + UTF-8 (max 65535 B)                                                                                                                                                                                                                                                  |
| 10       | `BYTE_ARRAY` | i32 count + N × element — the one array type that skips the shared size helper; the decompiled Unity SDK's `BinDecode_BYTE_ARRAY` calls a bare 4-byte `ReadInt` instead of `GetTypedArraySize`. A genuine SFS2X wire-format asymmetry, confirmed decoding the live 313KB init payload. |
| 9, 11–16 | `*_ARRAY`    | i16 count + N × element (`GetTypedArraySize`)                                                                                                                                                                                                                                          |
| 17       | `SFS_ARRAY`  | i16 count, then N × (tag + payload), recursive                                                                                                                                                                                                                                         |
| 18       | `SFS_OBJECT` | i16 key count, then N × (UTF\_STRING key + tag + payload), recursive                                                                                                                                                                                                                   |
| 19       | `CLASS`      | *not implemented* — never emitted                                                                                                                                                                                                                                                      |
| 20       | `TEXT`       | i32 byte-length + UTF-8 (uncapped)                                                                                                                                                                                                                                                     |

*`BIG_NUM`, present in some SFS2X SDK builds, doesn't exist in this game's enum at all — no gap in the numbering. Not needed.*

## Validated against the official client — `sfs2x-api` (npm)

SmartFoxServer publishes its own JS client SDK (`npm install sfs2x-api` — the official reference implementation, not a third-party reimplementation). Pulling it and diffing its packet/codec internals against everything above turned into a genuinely useful correctness check:

<table>
  <thead><tr><th>Checked against the official SDK</th><th>Result</th></tr></thead>

  <tbody>
    <tr><td>SFSObject type tags (0 NULL … 20 TEXT, full range)</td><td><strong>Exact match</strong></td></tr>
    <tr><td>Envelope shape <code>\{c: BYTE, a: SHORT, p: SFS\_OBJECT}</code></td><td><strong>Exact match</strong></td></tr>
    <tr><td>Action ids — <code>Login=1</code>, <code>PingPong=29</code></td><td><strong>Exact match</strong></td></tr>
    <tr><td>zlib compression, header bits <code>0x80</code>/<code>0x20</code>/<code>0x08</code></td><td><strong>Exact match</strong></td></tr>
    <tr><td>bigSized threshold specifically</td><td><strong>Correction, not a match</strong> — the JS SDK's own <code>IoHandler.onPacketWrite</code> uses <code>65335</code>, which this dossier briefly (and wrongly) adopted as a "fix." The <em>actual</em> reference for this game is the Unity SFS2X SDK it embeds (<code>Smartfox2xLw\.decompiled.cs:13563</code>), which uses the standard <code>65535</code> — the JS client's <code>65335</code> is an idiosyncratic quirk of that specific (different) implementation, not a shared protocol constant. Reverted after a later audit pass caught the discrepancy; a good reminder that "official" only means authoritative for the exact client it ships with.</td></tr>
    <tr><td>Encryption flag (<code>0x40</code>) / XOR obfuscation</td><td><strong>Refined</strong> — the bit itself is real vanilla SFS2X: the Unity SDK sets it exactly when a session-negotiated <code>CryptoKey</code> (a real AES key+IV pair, via <code>Handshake</code>) is present (<code>bitSwarm.CryptoKey != null</code>, <code>Smartfox2xLw\.decompiled.cs:13557</code>). This game never negotiates a <code>CryptoKey</code> at all, yet the server always sets <code>0x40</code> anyway and expects the length-derived XOR instead of real AES — a deliberate server-side override of the vanilla semantics, not evidence the bit is undocumented. The JS SDK's read/write path (which never sets or checks this bit) simply never implements the CryptoKey path at all, consistent with this reading.</td></tr>
    <tr><td><code>0x10</code> LZ4-repurposed-as-Zstandard flag</td><td><strong>Confirmed game-specific</strong> — same story, absent from the official client entirely</td></tr>
  </tbody>
</table>

The official SDK's connection sequence also revealed a real gap: it sends a `Handshake` request (action `0`, fields `api`/`cl`, a step this dossier previously called out below as unused) *before* every Login, receiving back a session token / max-message-size / compression-threshold. Sending it against this game's real server got a clean, correctly-shaped response — `{ct=3072, ms=1000000, tk=<32-hex>}` — so the server-side handshake handler is real and working, contradicting the "never instantiated" framing below at least on the server side (whether the shipped Android/iOS client itself exercises this path wasn't re-confirmed). Practically, though, adding it to the Go client changed nothing: at the time this was tested, the init-push and reconnect-block problems were still unresolved, and this `Handshake` step made no difference to either — guest login and email-bind behaved identically with or without it. Both problems have since been resolved by unrelated fixes (a Zstd decompression bug and a token-identity mismatch, respectively — see [Live validation against production](/live-validation)), confirming the `Handshake` step was never the missing piece.

## Heartbeat & reconnect

A `PingPongRequest` (system controller, action 29, payload `{clientTime}`) fires every **4000 ms**. Client-perceived timeout is **12s** since the last pong, at which point the client disconnects and re-runs the entire connect→login sequence — there is no lightweight session-resume. No exponential backoff was found; retry cadence is a fixed timer.

<Check>
  **What's genuinely unused — partially revised, see above**

  SFS2X's SDK ships a full `Handshake` request (session token negotiation + a real AES `CryptoKey`, via `DefaultPacketEncrypter`) and a complete MMORoom/AOI (area-of-interest) streaming subsystem. The MMORoom/AOI claim stands untouched. The Handshake claim needs an asterisk: this game's own decompiled SDK (`Smartfox2xLw.decompiled.cs:4363`, `SendHandshakeRequest`) does contain a working implementation, called from the socket's own `OnSocketConnect` — and the live server answers it correctly when sent (see above). Whether `LoginState`'s actual connect path invokes that code or bypasses it in favor of jumping straight to `LoginMessage.Send` (as this dossier's original read of `LoginState.OnEnter` suggested) remains unsettled; either way, a reimplementation can skip it — it demonstrably makes no difference to anything tested live. The client reimplements its own point-id-addressed query model on top of plain extension requests instead of AOI streaming (see [World map](/world-map)).
</Check>
