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

# Capturing and decoding traffic

> The exact, reproducible pipeline behind every live-confirmed finding in this dossier: capture real client traffic, find the right TCP stream, reassemble it, and decode it with the same codec the Go client runs on its own connection.

Every "confirmed live via a real packet capture" claim in [Live validation against production](/live-validation) and elsewhere in this dossier was produced by the same four-step pipeline. This page is that pipeline, written down once, so reproducing or extending any of those findings doesn't require re-deriving it from scratch.

It works at all because the SFS2X game socket is **plain TCP with no TLS**. Capturing it is exactly as simple as capturing any other unencrypted traffic, no certificate pinning to defeat, no interception proxy, no MITM setup. `tcpdump` against the real app while it plays through the same network interface as the analysis machine is sufficient.

## Step 1: capture

Run `tcpdump` against the known game/CDN hosts and the SFS2X port, then launch and use the real app (`/Applications/Last War.app` on macOS, or the equivalent iOS/Android install) while it's running:

```bash theme={null}
sudo tcpdump -i any -w capture.pcap \
  'host 104.17.133.15 or host 104.17.132.15 or host 172.65.210.24 or \
   host 3.33.246.23 or host 15.197.233.176 or host 34.145.128.94 or \
   port 19292 or port 17783 or port 6088 or port 25092'
```

The host list is every server IP this project has seen the client actually talk to (GSL HTTP endpoints, CDN, and SFS2X game servers); the port list catches every SFS2X game port seen across captures so far. Keep the filter broad; a capture with some irrelevant traffic in it costs nothing at the decode step, but a filter that's too narrow silently drops the packets you actually need.

<Info>
  **Why the host list has held up better than the port list.** An account can get moved to a different zone server-side, confirmed live via a real server merge that moved this project's account from zone `APS783` to `APS8092` with an entirely different port (`17783` → `25092`) mid-session (see [Live validation against production](/live-validation#a-real-server-merge-exposed-three-real-redirect-following-bugs) for the client-side fix this required). The *port* is zone-specific and will drift every time this happens, there's no way to keep it current in a static list. The *host* IPs above turned out to be shared CDN/infra-level addresses rather than zone-specific ones: the post-merge zone's actual game-server host (`lastwar-game-cf.lastwarapp.net`) resolved to `172.65.210.24`, already on this list. Since the filter is a flat OR, `host 172.65.210.24` alone captured the new zone's traffic on its new port without the port list needing to know about it. That's not guaranteed to hold forever, if you're capturing after your own account has moved zones and traffic isn't showing up, re-resolve the current game-server hostnames (they're in a login response's `serverInfo` field, or already-resolved in your session config after a redirect) and add any genuinely new IP to the host list.
</Info>

**Give it real time.** A rushed capture is the single most common failure mode: the real client's own SFS2X connection can churn through its login/bootstrap burst in under a second, so if you start `tcpdump`, immediately open the app, do one thing, and stop, you'll likely capture only the bootstrap burst and miss the actual action you were trying to observe. Let the app fully settle into its main screen (10–15 seconds after login) before doing anything you actually want captured, and let it sit a few seconds after before closing.

## Step 2: find the game socket

A single capture typically contains several TCP streams: the real game session, a handful of short-lived "losing" connection attempts (the client races several GSL-provided candidate hosts and keeps only one), and unrelated TLS side-channels (chat, logging, CDN). The `pcap` tool lists them, decodable **plain** streams first, then encrypted **tls** ones:

```bash theme={null}
go build -o pcap ./cmd/pcap
./pcap -in capture.pcap -list
```

```text theme={null}
idx  kind  endpoint A                     endpoint B                     bytes
0    plain 192.168.1.80:8883              192.168.1.182:49202               546032  (378 segments)
1    plain 136.107.113.253:10783          192.168.1.182:61637               308125  (620 segments)
2    plain [2001:4860:4860::8888]:443     [2600:1700:...:2920]:64063         66034  (329 segments)
```

The game socket is the **plain** stream to the game server's host and port, port `17783`, or whatever the current zone's port has drifted to (see the port note above). Note its index (`1` above). That's all you need: the tool auto-detects the client side (the connection initiator, or the private-address endpoint) and both directions, so there's no separate stream-index or client-IP lookup to do, and it reads both classic pcap and pcapng, with no `tshark` in the loop.

<Warning>
  If the game-socket stream has only a handful of segments and well under a second of activity, the capture likely caught only a "losing" race candidate or missed the real session, see Step 1's timing note. Re-capture with more time around the action.
</Warning>

## Step 3: reassemble and decode

One command reassembles the chosen stream **by TCP sequence number** (so out-of-order and retransmitted segments land correctly, rather than corrupting the stream the way arrival-order reassembly can) and decodes every SFS2X packet in both directions:

```bash theme={null}
./pcap -in capture.pcap -stream 1 -decode
```

```text theme={null}
[c2s] #233 @offset 25561: {c=1, a=13, p={c=al.science.donate, r=-1, p={scienceId=10011900, option=1, _id=229}}}
```

To keep the raw reassembled bytes (e.g. to re-run the client's own `-decode-stream`), use `-out` instead of `-decode`:

```bash theme={null}
./pcap -in capture.pcap -stream 1 -out stream          # writes stream_c2s.bin / stream_s2c.bin
go build -o lastwar-client ./cmd/lastwar-client
./lastwar-client -decode-stream stream_c2s.bin -decode-label c2s
```

Pass `-client <ip>` to `pcap` to override the auto-detected client side if the heuristic ever picks wrong.

<Tip>
  **The whole pipeline is one Go module, so nothing can drift out of sync.** Both the reassembler (`internal/pcap`) and the decoder are pure Go with no external dependencies; `pcap -decode` and `-decode-stream` frame and decode using the exact same `ReadPacket` (`packet.go`) and `DecodeObject` (`sfsobject.go`) the client uses on its own live connection. Any fix or extension to the real codec is reflected the next time you decode a capture, with no manual sync step.
</Tip>

## Reading the output

Each decoded line is one SFS2X extension-call envelope: `c` is the SFSObject's top-level wrapper (`1` = extension call, `0` = system/heartbeat), `a` is the sub-action, and `p` holds the actual payload; for extension calls, `p.c` is the command name and `p.p` is its params object. Client→server calls almost always carry `_id`, an ever-incrementing per-connection request id; the matching server response echoes the same `_id`. See [Wire protocol](/wire-protocol) for the full packet-envelope and SFSObject binary format this output is decoded from, and [Command reference](/command-reference) for what a given `p.c` command name actually does.

## Known limitation: some captures stop decoding partway through

Server→client decoding has, on several real captures to date, hit either a `DecodeObject error: expected top-level tag 18 (SFS_OBJECT), got ...` or a `zstd decode: ...` error, most often right around the same point in the bootstrap-burst response sequence, but not always at an identical byte offset. Client→server decoding has not shown this problem in any capture so far.

These two error kinds behave differently, and only one of them actually stops the decode. A `DecodeObject` error is non-fatal: `-decode-stream` logs it inline for that one packet and continues decoding every packet that follows in the stream (see `decode_test.go`'s "DecodeObject error on one packet continues to the next" regression test). A `zstd decode` error, by contrast, comes from a lower layer (packet framing/decompression, not the SFSObject decoder) and is fatal, it halts the rest of the stream, so only what was decoded before that point is usable.

The working theory was always that this is a reassembly edge case (a reordering/overlap pattern) rather than a decoder bug, since `-decode-stream` uses the identical codec the live Go client relies on and that connection has never shown equivalent corruption. Replacing the old `tshark`-fed Python reassembler with the pure-Go `internal/pcap` (Step 3 above) supports that theory: on a real August-2026 capture whose server→client stream the old path decoded only partway, the Go reassembler decoded the **entire** stream cleanly, every byte consumed as valid framed packets, \~35 more server→client packets than the old path recovered. The Go reassembler places each segment by its exact sequence offset (rather than relying on `tshark`'s own stream-reassembly opinion), which is what makes it more robust to the out-of-order/retransmit patterns that tripped the old pipeline. It's plausible this closes the limitation outright, but it hasn't been re-tested across enough captures to declare it gone. If a decode still stops partway: a `DecodeObject` error is isolated (that one packet is skipped, everything before and after is valid), enough to confirm a client→server request's exact shape even when its matching response is the corrupt one; confirming that one response instead means replaying the request live through the Go client (e.g. via `-interactive`) and reading its response directly. A `zstd decode` error is fatal to the rest of the stream, and the same live-replay approach then applies to everything after the cutoff.
