~/posts/azur-lane-reverse-game-server

Belfast: I quit a game by writing its server

16 min read 3142 words
reverse-engineeringgoprotobufnetworkingtoolingllm

tl;dr

Belfast is a Go server that speaks Azur Lane's client protocol: custom TCP framing, protobuf handlers grouped by domain, PostgreSQL persistence, and region-aware routing. The tooling around it (a PCAP decoder, a coverage scorer and an LLM driving the Android client over ADB) is what made it survivable.

In December 2024, I decided to quit a game.

Instead of doing the healthy thing and uninstalling it, I reverse engineered the protocol.

The game is Azur Lane, and the result is Belfast: a Go server that speaks the game client’s protocol, so the unmodified client can boot, log in and play against it. It has:

  • a custom TCP transport and packet framing,
  • protobuf-based request/response handling,
  • gameplay handlers grouped by domain,
  • PostgreSQL persistence (migrations + sqlc),
  • an embedded admin API (Iris + Swagger),
  • packet analysis and progress tooling.

It didn’t grow in a straight line. There was a proof-of-concept phase, a long pause, and then a second phase where architecture and tooling stopped being optional.

This is the write-up I wish I’d had when I started: how the packets are framed, how the login flow fits together, how game state is stored, and which choices kept the project alive.

It’s not (just) about anime girls and lewdness

The motivation was learning: mobile reverse engineering, on a real system with real constraints.

The first week looked like this:

  • PCAPs with no obvious structure,
  • fragmented ADB/Unity logs,
  • partial hints from extracted client resources,
  • a lot of wrong assumptions.

What changed things was an ordering rule: stop trying to understand gameplay first. Pin down the layers that are deterministic, in this order:

  1. frame boundaries,
  2. packet IDs,
  3. payload encoding,
  4. state transitions,
  5. gameplay semantics.

Without that ordering, every experiment looks random.

How a packet is framed

A game server like this is just a program on the other end of a TCP connection. The client sends requests (CS_ packets, client → server), the server answers (SC_, server → client), and each one carries a protobuf message. The first job is finding where one packet ends and the next begins.

The breakthrough came from a tiny header sample:

0x01 0x89 0x00 0x2a 0x31 0x00

0x2a31 is 10801 in decimal (hex, big-endian), which maps to SC_10801. Once that clicked, the whole frame made sense:

  • 2 bytes: packet size
  • 1 byte: sentinel (0x00)
  • 2 bytes: packet ID
  • 2 bytes: packet index
  • N bytes: protobuf payload
0x010x890x000x2a0x310x00…protobuf …sizesentinelpacket idindex393always 0108010 or 1N bytes→ SC_10801
The header sample, byte by byte. The first two bytes read as a size of 393, then the sentinel, then the packet ID 0x2a31 = 10801. The sample stops after the first index byte.

Belfast treats that layout as a strict contract (internal/packets/magic.go):

func GetPacketId(offset int, buffer *[]byte) int {
	var id int
	id = int((*buffer)[3+offset]) << 8
	id += int((*buffer)[4+offset])
	return id
}

func GetPacketSize(offset int, buffer *[]byte) int {
	var size int
	size = int((*buffer)[0+offset]) << 8
	size += int((*buffer)[1+offset])
	return size
}

On the way out, headers are rebuilt explicitly instead of relying on some middleware, so every outgoing packet is reproducible and easy to debug:

func GeneratePacketHeader(
	packetId int, payload *[]byte, packetIndex int,
) []byte {
	var buffer bytes.Buffer
	payloadSize := len(*payload) + 5
	buffer.Write([]byte{
		byte(payloadSize >> 8), byte(payloadSize),
	})
	buffer.Write([]byte{0x00})
	buffer.Write([]byte{byte(packetId >> 8), byte(packetId)})
	buffer.Write([]byte{
		byte(packetIndex >> 8), byte(packetIndex),
	})
	return buffer.Bytes()
}

One detail that matters: the packet index is usually 0x0000, but it can be 0x0001 in multi-packet frames. Ignore it and you get “almost works” behaviour, which is the most painful kind to debug.

The login flow

Once packets can be read, they have to be answered in the right order, with state that stays consistent from one packet to the next. A normal boot looks like this:

game clientBelfast1. update checkCS_10800SC_108012. gateway infoCS_10700SC_107013. auth confirm + server listCS_10020SC_100214. join serverCS_10022SC_100235. create player (if needed)CS_10024SC_100256. initial state syncCS_11001fan-out of sync packets
The boot and login sequence. Each step is a request the client sends and the answer Belfast must give before the client moves on. The last step fans out into many state-sync packets.

CS_10020 / SC_10021: who are you

HandleAuthConfirm ties the login input to an account, then sends back a server ticket and the server list:

intArg2, err := strconv.Atoi(payload.GetArg2())
if err != nil {
	return 0, 10021, fmt.Errorf(
		"failed to convert arg2 to int: %s", err.Error())
}
client.AuthArg2 = uint32(intArg2)
protoValidAnswer.ServerTicket = proto.String(
	formatServerTicket(client.AuthArg2))

yostarusAuth, err := orm.GetYostarusMapByArg2(uint32(intArg2))
skip := config.Current().CreatePlayer.SkipOnboarding
if err != nil && db.IsNotFound(err) && skip {
	accountID, err := client.CreateCommander(uint32(intArg2))
	if err != nil {
		return 0, 10021, err
	}
	protoValidAnswer.AccountId = proto.Uint32(accountID)
}

This packet isn’t just “auth yes/no”. It’s where the account creation strategy is decided (the skip_onboarding path), and that changes which packets the client expects next.

CS_10022 / SC_10023: one session per commander

JoinServer finds the account from several sources (account_id, device_id, the server ticket), loads the commander’s state, and kicks any older session:

if client.Server != nil {
	existingKicked := client.Server.DisconnectCommander(
		client.Commander.CommanderID,
		consts.DR_LOGGED_IN_ON_ANOTHER_DEVICE,
		client,
	)
	if existingKicked {
		logger.LogEvent("Server", "LoginKick",
			fmt.Sprintf("kicked previous session for commander %d",
				client.Commander.CommanderID),
			logger.LOG_LEVEL_INFO)
	}
}

This prevents a lot of weirdness: two live sessions on the same commander can race each other into impossible states.

CS_10024 / SC_10025: creating a player

CreateNewPlayer checks the name, the starter ship and the device/account binding before creating anything:

nameLength := utf8.RuneCountInString(nickname)
if nameLength < createPlayerNameMin {
	response.Result = proto.Uint32(2012)
	return client.SendMessage(10025, &response)
}
if nameLength > createPlayerNameMax {
	response.Result = proto.Uint32(2011)
	return client.SendMessage(10025, &response)
}

if _, ok := starterShipIDs[shipID]; !ok {
	response.Result = proto.Uint32(1)
	return client.SendMessage(10025, &response)
}

That keeps onboarding deterministic, and the stable device mapping keeps reconnects working later.

Transport and dispatch

The networking code is deliberately explicit. When you’re reverse engineering a binary protocol, clever transport abstractions hurt more than they help.

The server side (internal/connection/server.go) accepts the TCP connection, checks the maintenance and private-client rules, reads the socket into a ring buffer, parses the packet size first and the body second, and queues frames per client.

The client side (internal/connection/client.go) has:

  • a bounded queue (packetQueueSize = 512),
  • a reusable packet buffer pool (packetPoolSize = 128),
  • a dedicated dispatch loop goroutine,
  • backpressure when the queue is full,
  • runtime metrics (queue depth, blocks, errors, packets).

Dispatch (internal/packets/handler.go) looks up handlers by packet ID, runs all of them, and flushes buffered writes afterwards. Unknown packets get an SC_10998 back instead of silence:

handlers, ok := PacketDecisionFn[packetId]
headerlessBuffer := (*buffer)[offset+HEADER_SIZE:]
if !ok {
	_, _, err := client.SendMessage(10998, &protobuf.SC_10998{
		Cmd:    proto.Uint32(uint32(packetId)),
		Result: proto.Uint32(1),
	})
	if err != nil {
		return
	}
} else {
	for _, handler := range handlers {
		_, _, err := handler(&headerlessBuffer, client)
		if err != nil {
			client.CloseWithError(err)
			return
		}
	}
}

Handlers write into a client buffer and dispatch flushes once per pass. That cuts down on syscalls and keeps the order of outgoing packets deterministic within one processing cycle. That choice paid off.

Regions, in one place

Azur Lane behaves differently per region (CN/EN/JP/KR/TW). Belfast handles that when handlers are registered, not deep inside every handler:

packets.RegisterLocalizedPacketHandler(13101,
	packets.LocalizedHandler{
		CN: &[]packets.PacketHandler{answer.ChapterTracking},
		EN: &[]packets.PacketHandler{answer.ChapterTracking},
		JP: &[]packets.PacketHandler{answer.ChapterTracking},
		KR: &[]packets.PacketHandler{answer.ChapterTrackingKR},
		TW: &[]packets.PacketHandler{answer.ChapterTracking},
	})

Handlers stay about behaviour, and the regional differences live in one predictable place.

Storing game state

Gameplay packets are state transitions, so persistence has to be boring and strict. The stack is PostgreSQL, a sqlc-generated query layer, ORM/domain loading helpers, and an embedded migration runner with checksums.

The migration runner takes a Postgres advisory lock and refuses to run if an applied migration changed:

if _, err := lockConn.ExecContext(acquireCtx,
	`SELECT pg_advisory_lock($1, $2)`,
	migrationAdvisoryLockClassID,
	lockObjectID,
); err != nil {
	return err
}

if appliedChecksum, ok := applied[m.Version]; ok {
	if appliedChecksum != m.Checksum {
		return fmt.Errorf(
			"migration %d already applied but checksum changed",
			m.Version)
	}
	continue
}

The lock stops two instances from migrating at once, and the checksum catches migrations that silently drifted between environments. Not glamorous, but it works.

Game data

Most handlers depend on the game’s own datasets: ships, chapter templates, shop data and so on. misc.UpdateAllData runs importers that fetch JSON from belfast-data and upsert it through sqlc, in one transaction and a fixed order:

err := db.DefaultStore.WithTx(ctx, func(q *gen.Queries) error {
	for _, key := range order {
		fn := dataFnSQLC[key]
		if fn == nil {
			return fmt.Errorf("missing sqlc importer for %s", key)
		}
		if err := fn(ctx, region, q); err != nil {
			return err
		}
	}
	return nil
})

Centralized and ordered means reseeding after a game update is reproducible.

Chapters: where emulation became simulation

Chapters are the game’s maps: fleets move over a grid, fight, get ambushed and spend resources. This is where answering packets turned into simulating the game. The core handlers in internal/answer/chapter:

  • CS_13101 → SC_13102 (tracking/start)
  • CS_13103 → SC_13104 (actions)
  • CS_13106 → SC_13105 (battle result request)
  • SC_13000 (base sync)

Starting a chapter (ChapterTracking) computes the resource cost, checks the inventory, builds CURRENTCHAPTERINFO and saves it:

baseOil := template.Oil
oilCost := uint32(float64(baseOil) * rate)
if !client.Commander.HasEnoughResource(2, oilCost) {
	response := protobuf.SC_13102{Result: proto.Uint32(1)}
	return client.SendMessage(13102, &response)
}

if oilCost > 0 {
	err := client.Commander.ConsumeResource(2, oilCost)
	if err != nil {
		return 0, 13102, err
	}
}

Moving uses a breadth-first search over the walkable grid cells (findMovePath), then updates the fleet position and step counters:

start := chapterPos{
	Row: group.GetPos().GetRow(),
	Column: group.GetPos().GetColumn(),
}
end := chapterPos{
	Row: payload.GetActArg_1(),
	Column: payload.GetActArg_2(),
}
path := findMovePath(grids, start, end)
if len(path) == 0 {
	response := protobuf.SC_13104{Result: proto.Uint32(1)}
	return client.SendMessage(13104, &response)
}

stepDelta := uint32(len(path) - 1)
group.Pos = buildPos(end)
group.StepCount = proto.Uint32(
	group.GetStepCount() + stepDelta)
current.MoveStepCount = proto.Uint32(
	current.GetMoveStepCount() + stepDelta)

Ambush rates mirror the client-side formulas documented in the codebase. This is one of the highest-fidelity parts, because players notice statistical drift quickly:

rate := 0.05 + posExtra + globalExtra
if step > 0 {
	denom := inv + investSums
	if denom > 0 {
		rate += (inv / denom) / 4 * float64(step)
	}
}
if posExtra == 0 {
	rate -= calculateFleetEquipAmbushRateReduce(group, client)
}
rate = clampChance(rate)
return uint32(rate * chapterChanceBase)

Tooling that paid for itself

PCAP decoder

cmd/pcap_decode rebuilds TCP streams from a capture, parses the frames, decodes protobuf payloads automatically through reflection, and prints JSON lines (protojson):

packetID := int(binary.BigEndian.Uint16(buffer[3:5]))
packetIndex := int(binary.BigEndian.Uint16(buffer[5:7]))
payload := buffer[packets.HEADER_SIZE:frameSize]

if constructor, ok := s.registry[packetID]; ok {
	msg := constructor()
	if err := proto.Unmarshal(payload, msg); err != nil {
		record.Error = err.Error()
		record.RawHex = hex.EncodeToString(payload)
	} else {
		opts := protojson.MarshalOptions{EmitUnpopulated: true}
		record.JSON, _ = opts.Marshal(msg)
	}
}

Gateway dumper

I also built a tiny gateway dumper for one specific reconnaissance task. It:

  1. dials a gateway address (--addr host:port) with a bounded timeout,
  2. sends CS_10018 with an empty payload (the handler doesn’t need any request fields),
  3. reads exactly one framed reply and checks it’s SC_10019,
  4. unmarshals the server list and prints each server as JSON (ids, name, ip, port, state, optional proxy fields).

It’s strict on purpose: it sets a connection deadline, checks the frame size before reading the body, and exits on any packet-ID mismatch instead of trying to recover. The defaults are made for quick scans (--addr 127.0.0.1:80, --timeout-ms 5000, optional --pretty JSON).

payload := []byte{}
header := connection.GeneratePacketHeader(10018, &payload, 0)
if _, err := conn.Write(header); err != nil {
	return nil, fmt.Errorf("write CS_10018: %w", err)
}

pkt, err := readOnePacket(conn)
if err != nil {
	return nil, err
}
packetID := packets.GetPacketId(0, &pkt)
if packetID != 10019 {
	return nil, fmt.Errorf(
		"unexpected response packet id %d (expected 10019)",
		packetID)
}

With it, a friend and I scanned IP ranges, cross-checked targets against constants embedded in the game client, and compared the server lists we got back across regions and builds. One entry was an Audit server, probably used for store submission or QA.

We connected once, did the in-game tutorial, created accounts with our nicknames, and stopped there. Mostly a nerdy “oh wow, that’s real” moment before backing out.

Packet recorder

debug.InsertPacket stores payloads for post-mortem analysis:

func InsertPacket(packetId int, payload *[]uint8) {
	if packetId == 8239 {
		return
	}
	err := orm.InsertDebugPacket(len(*payload), packetId, *payload)
	if err != nil {
		logger.LogEvent("Debug", "InsertPacket", err.Error(),
			logger.LOG_LEVEL_ERROR)
	}
}

Coverage scoring

cmd/packet_progress was one of the most useful tools I built. It solves a chronic reverse-engineering problem: everyone says “coverage is pretty good”, but nobody can say good by what metric.

It walks the packet registrations, parses each handler’s AST, scores it with heuristics, and writes machine-readable reports. It never runs the handlers; it infers how complete they are from signals in the source. Each packet gets a status:

  • implemented: strong request/response and behaviour signals,
  • partial: real logic, but probably incomplete,
  • stub: acknowledges and does little else,
  • panic: known bad path,
  • missing: a known packet with no effective implementation.
const (
	statusImplemented = "implemented"
	statusPartial     = "partial"
	statusStub        = "stub"
	statusPanic       = "panic"
	statusMissing     = "missing"
)

The score is weighted, not binary. Calling SendMessage, using the response and request types, protobuf setters and parsing, touching the commander or the ORM, and writing to the database all add confidence:

Weights: heuristicWeights{
	SendMessage:  3,
	ResponseType: 2,
	RequestType:  1,
	ProtoSetter:  1,
	RequestParse: 1,
	CommanderUse: 2,
	ORMUsage:     2,
	DBWrite:      2,
},
Thresholds: heuristicThresholds{ImplementedMin: 4}

The report includes packet- and handler-level details (score, signals, file, line) and overrides for known exceptions. In practice:

  • I can sort by high-value missing packets,
  • I can catch regressions when a refactor drops implementation signals,
  • I can tell a known stub apart from something silently broken.

Planning went from gut feeling to a repeatable process.

The LLM testing loop

Once protocol and gameplay coverage grew, the bottleneck became tapping through the same Android UI by hand, over and over.

Belfast already had solid ADB primitives (internal/debug/adb_watcher.go): interactive controls, logcat start/stop/flush/dump, process PID tracking, and optional game restarts.

On top of that I added an external MCP-style loop:

  1. capture a screenshot,
  2. the model works out where it is in the UI,
  3. the model picks what to tap,
  4. send the tap over ADB,
  5. watch logcat and the server’s behaviour,
  6. repeat until the scenario completes or breaks.

I don’t treat the model as a source of truth. I treat it as a repeatable test operator for tedious client flows.

What stuck with me

  1. Reverse engineering is mostly systems engineering, not cinematic breakthroughs.
  2. Packet-level correctness is necessary, but gameplay semantics are the real finish line.
  3. Boring layers win: deterministic transport, strict persistence, measurable coverage.
  4. Stubs aren’t failure; they’re scaffolding.
  5. Once your test loop is too manual, automation quality decides how fast you ship.

And yes, one of the biggest breakthroughs was still converting hex to decimal in GNOME Calculator.

What’s next

Belfast now boots the client reliably, covers a lot of gameplay, and has an architecture that can take updates one at a time. The question isn’t “can this packet be implemented?” anymore, it’s “can game updates land without a growing maintenance tax?”

I’m working towards four things:

  1. A protocol diff pipeline: compare old and new client protobuf surfaces automatically, detect new or renamed packet IDs and field-level drift, and list change candidates before any manual reverse engineering starts.
  2. Sturdier data sync: version game data imports per region, validate imports strictly (missing IDs, shape drift, broken cross-references), and keep snapshots so a regression can be bisected quickly.
  3. Scenario-based regression tests: turn manual test routes (login, chapter start, battle result, shop flows) into scripted scenarios, pair server-side packet assertions with client-side UI/log assertions, and use the LLM loop as the driver while keeping pass/fail checks deterministic on the server.
  4. Easier handler maintenance: tag coverage metrics by domain (auth/chapter/shop/world), rank missing packets by how often they’re sent and how visible they are to players, and generate focused backlogs instead of one flat “missing” list.

The goal is simple: make new client versions boring to integrate.

None of this was the plan. I started because I wanted to stop playing a game, and ended up with a protocol emulator, a data pipeline and an AI-assisted test harness. Stay curious long enough and a small reverse-engineering experiment turns into real software.

hash: b43
EOF