Belfast: Reverse engineering a mobile game server
tl;dr summary
I started Belfast because I wanted to stop playing a game and learn mobile reverse engineering. Somewhere along the way, I ended up building a server emulator, a tooling ecosystem, and an LLM-driven testing loop.
table of contents
In December 2024, I decided to quit a game.
Instead of uninstalling it, I reverse engineered the protocol.
This write-up focuses on what made the project survivable: packet framing, login sequencing, state persistence, and tooling that turned reverse engineering from guesswork into a repeatable process.
Belfast is a Go server that speaks Azur Lane’s client protocol, with:
- Custom TCP transport and packet framing
- Protobuf request/response handling
- Gameplay handlers grouped by domain
- PostgreSQL persistence (migrations + SQLC)
- Embedded admin API (Iris + Swagger)
- Packet analysis and implementation metrics
It grew in two phases: an early proof-of-concept, then a second pass where architecture and tooling became mandatory.
It’s not (just) about anime girls and lewdness
The motivation was educational: learn mobile reverse engineering on a real, stateful system.
Early week realities:
- PCAPs with no obvious structure
- Fragmented ADB/Unity logs
- Partial clues from extracted client resources
- A lot of wrong assumptions
The key method shift was to lock deterministic layers in order:
- Frame boundaries
- Packet IDs
- Payload encoding
- State transitions
- Gameplay semantics
Without that ordering, everything looks random.
Wire protocol, decoded
The first practical breakthrough came from a tiny header sample:
0x01 0x89 0x00 0x2a 0x31 0x00
0x2a31 is 10801 in decimal (SC_10801). From there, the frame model became clear.
In Belfast (internal/packets/magic.go), the frame contract is:
2 bytes: packet size1 byte: sentinel (0x00)2 bytes: packet ID2 bytes: packet indexN bytes: protobuf payload
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 egress, headers are rebuilt explicitly (no hidden middleware), which keeps packet reproduction deterministic:
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 subtle detail: packet index is often 0x0000, but sometimes 0x0001 in multi-packet frames. Ignoring it creates painful “almost works” failures.
Bootstrap flow (real packets, real handlers)
After framing, correctness depends on sequence and coherent state.
Typical startup path:
CS_10800->SC_10801(Update check)CS_10700->SC_10701(Gateway info)CS_10020->SC_10021(Auth confirm + server list)CS_10022->SC_10023(Join server)CS_10024->SC_10025(Create player, if needed)CS_11001-> initial sync fan-out
CS_10020 / SC_10021: identity bootstrap
HandleAuthConfirm (internal/answer/auth_confirm.go) binds login input to account identity, emits a server ticket, and can create accounts on the skip_onboarding path:
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))
if err != nil && db.IsNotFound(err) && config.Current().CreatePlayer.SkipOnboarding {
accountID, err := client.CreateCommander(uint32(intArg2))
if err != nil {
return 0, 10021, err
}
protoValidAnswer.AccountId = proto.Uint32(accountID)
}
This packet decides account-creation strategy, not just “auth yes/no”, so it shapes downstream expectations.
CS_10022 / SC_10023: session coherence
JoinServer (internal/answer/join_server.go) resolves identity from account_id, device_id, and server ticket, loads commander state, and enforces one active 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)
}
}
Kicking stale sessions avoids impossible races from duplicate active clients.
CS_10024 / SC_10025: account creation guardrails
CreateNewPlayer (internal/answer/onboarding/create_new_player.go) validates name bounds, starter ship IDs, and device/account binding before provisioning:
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)
}
This keeps onboarding deterministic and protects reconnect flow through stable device mapping.
Transport and dispatch architecture
The networking stack is intentionally explicit. For binary protocol work, clever abstractions usually hide bugs.
Server side (internal/connection/server.go)
- Accept TCP connection
- Validate maintenance/private-client constraints
- Read socket into ring buffer
- Parse packet size first, then body
- Enqueue frames into per-client queue
Client side (internal/connection/client.go)
- Bounded queue (
packetQueueSize = 512) - Reusable packet buffer pool (
packetPoolSize = 128) - Dedicated dispatch loop goroutine
- Backpressure when queue is full
- Runtime metrics (queue depth, blocks, errors, packets)
Dispatch layer (internal/packets/handler.go)
Dispatch resolves handlers by packet ID, applies all handlers, then flushes buffered writes once per pass.
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
}
}
}
Single flush per dispatch cycle reduces syscall churn while preserving ordering.
Region-aware routing instead of region spaghetti
Azur Lane differs by region (CN/EN/JP/KR/TW). Belfast resolves this at handler registration, not inside every code path.
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},
})
Result: packet logic stays behavior-focused; region variance stays centralized.
Persistence and migration discipline
Gameplay packets are state transitions, so persistence must be strict and boring.
Stack:
- PostgreSQL
- SQLC-generated query layer
- ORM/domain loading helpers
- Embedded migration runner with advisory locks + checksums
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
}
That pair prevents migration races and silent drift across environments.
Game data ingestion as a first-class system
Most gameplay handlers depend on external datasets (ships, chapters, shops).
misc.UpdateAllData orchestrates importers that fetch JSON from belfast-data and upsert through SQLC, in a deterministic 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 ordered ingestion makes reseeding reproducible after client updates.
Chapter system deep dive (where this became real)
Chapter flow is where packet emulation becomes simulation.
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)
Start/tracking (CS_13101)
ChapterTracking computes oil cost, validates resources, builds CURRENTCHAPTERINFO, and persists:
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 {
if err := client.Commander.ConsumeResource(2, oilCost); err != nil {
return 0, 13102, err
}
}
Move/action (CS_13103)
Movement uses BFS on walkable grids, then updates 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
Ambush math mirrors client-side formulas documented in code, because statistical drift is quickly visible to players:
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)
The decoder reconstructs TCP streams, parses frames, auto-decodes protobuf payloads through reflection, and emits JSON lines:
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 {
record.JSON, _ = protojson.MarshalOptions{EmitUnpopulated: true}.Marshal(msg)
}
}
Gateway dumper (cmd/gateway_dump)
I also built a tiny gateway dumper to enumerate gateway-exposed server lists across targets.
Flow:
- Dial gateway (
--addr host:port) with timeout (--timeout-ms) - Send
CS_10018with empty payload - Read one framed response and assert
SC_10019 - Unmarshal protobuf server list and print JSON entries (
ids,name,ip,port,state, proxy fields)
Core request path:
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)
}
A friend and I used it with IP-range scans plus targets recovered from client constants, then compared returned server lists by region/build. One result was an Audit server entry (likely store-submission/QA). We connected once, completed the tutorial, created accounts with our nicknames, then stopped there.
Coverage/progress metrics (cmd/packet_progress)
This solved a chronic problem: “coverage is good” without measurable criteria. The command walks packet registrations, parses handler ASTs, scores heuristics, and emits machine-readable reports.
Status model:
implemented: strong request/response + behavior signalspartial: meaningful logic present, likely incompletestub: minimal acknowledgment behaviorpanic: known bad pathmissing: known packet with no effective implementation
const (
statusImplemented = "implemented"
statusPartial = "partial"
statusStub = "stub"
statusPanic = "panic"
statusMissing = "missing"
)
Scoring is weighted (SendMessage, protobuf parse/setters, commander/ORM usage, DB writes) rather than binary:
Weights: heuristicWeights{
SendMessage: 3,
ResponseType: 2,
RequestType: 1,
ProtoSetter: 1,
RequestParse: 1,
CommanderUse: 2,
ORMUsage: 2,
DBWrite: 2,
},
Thresholds: heuristicThresholds{ImplementedMin: 4}
This made roadmap planning concrete: prioritize missing high-value packets, catch regressions after refactors, and separate intentional stubs from silent breakage.
The LLM testing loop
As protocol and gameplay coverage grew, manual Android navigation became the bottleneck.
Belfast already had ADB primitives (internal/debug/adb_watcher.go): interactive controls, logcat lifecycle, PID tracking, optional restart automation.
I layered an external MCP-style loop on top:
- Capture screenshot
- Model infers current UI state
- Model selects tap target
- Send ADB input
- Observe logcat + server behavior
- Repeat until scenario completes or breaks
I treat the model as a repeatable integration-test operator, not as a source of truth.
What I learned
- Reverse engineering is mostly systems engineering, not movie-style breakthroughs.
- Packet correctness is necessary; gameplay semantics are the actual finish line.
- Boring layers win: deterministic transport, strict persistence, measurable coverage.
- Stubs are scaffolding, not failure.
- When testing is too manual, automation quality dominates delivery speed.
And yes, one major breakthrough was still converting hex to decimal in GNOME Calculator.
What comes next
Belfast now has reliable boot flow, broad gameplay coverage, and architecture that can absorb incremental updates. The remaining challenge is update integration without maintenance-tax spiral.
Current roadmap has four tracks:
-
Protocol diff pipeline
- Compare old/new client protobuf surfaces automatically.
- Detect new/renamed packet IDs and field drift.
- Generate change candidates before manual reverse engineering.
-
Data synchronization hardening
- Version imports by region.
- Add strict import validation (missing IDs, shape drift, bad references).
- Keep rollback-friendly snapshots for fast bisect.
-
Scenario-based regression suite
- Convert manual routes into scripted scenarios (login, chapter start, battle result, shop).
- Pair server-side packet assertions with client UI/log assertions.
- Keep deterministic pass/fail checks server-side, with the LLM loop as driver.
-
Handler maintenance ergonomics
- Extend packet coverage metrics with domain tags.
- Rank missing packets by runtime frequency and player impact.
- Generate focused implementation backlogs instead of flat missing lists.
The goal is simple: make new client versions boring to integrate.
I started this project to stop playing a game and ended up building a protocol emulator, data pipeline, and AI-assisted test harness. That is the fun part of this work: curiosity turns a small reverse-engineering experiment into a real software system.