Network stack

The client's network protocols: SignOn, the bdLobby services, the web-service RPC, replicated objects, matchmaking and the peer transport.

The client speaks seven network protocols. All live services are Demonware, over IPv4 only. There is no bungie.net REST API in this build. Web-service calls travel over bdLobby instead.

Demonware class names such as bdSocketRouter, bdLobby class names, and peer message names such as membership-update are the game’s own. Other names are descriptive, not the game’s.

The protocols

protocol

transport

role

SignOn

HTTPS POST, libcurl on Schannel, pinned TLS

bootstrap: returns a session token and the BAP server address

bdLobby BAP

TCP, big-endian framing

login, storage, matchmaking, and the Activity Host link

engine peer transport

UDP through Demonware bdNet, or Steam Datagram Relay

peer sessions and gameplay replication

STUN, NAT traversal, QoS

UDP

find the public address, open NAT, probe a host before joining

DNS

c-ares, UDP then TCP

name resolution; curl’s only resolver

gameplay replication

the upper layer of the peer transport

simulation objects and their fields

UPnP / SSDP

UDP multicast plus plain HTTP

NAT port mapping

A telemetry HTTP path also exists. It fails soft and does not block play.

The engine’s socket layer has a kind on every socket. It decides the path a datagram takes.

kind

path

0

Demonware bdNet: bdSocketRouter, then a DTLS-style association, then Winsock

1, 2, 3, 5

plain engine UDP

4

TCP; only the BAP connection uses it

6, 7

Steam Datagram Relay

SignOn

SignOn is one HTTPS POST. The build number and platform are in the query string. The request body is protobuf with 21 root fields. It reports client state and asks for nothing.

request field

content

1

protocol version, 16 on this build

2

the platform credential; on PC, a sub-message with the 90-byte Steam encrypted app ticket

3, 19

UI and audio language, for example en

5, 6

the content version and the build string

12

the cookie from a previous queued response, echoed byte for byte

15

owned entitlements, up to 64

17

the SKU type, for example north_america_digital

20

a report of the previous failed attempt, present only after one

22

the Steam account: id, name, a signed proof and the country

23

the client’s half of the peer key exchange; the same message type as success field 12

24

how many throttled responses came in a row

Fields 9, 10, 14 and 16 do not exist. The client holds nothing that can check the Steam ticket or the account proof. Only the real backend could validate them.

The response is protobuf, decoded by the same nanopb runtime bdLobby uses. Field 1 selects the outcome.

field 1

outcome

0

success

1

queued

2

error with a numeric code

3

denied, user action required

4

throttled, retry later

A success body carries an opaque session token, the BAP server’s IPv4 address and port, and material for the secure channel. The response also carries up to eight feature names. That list is the only source of feature flags, so every named feature is off unless SignOn names it. Details of the token and channel setup are on From launch to orbit.

The success fields

Fields 1 to 7 of the success message are required. A missing one fails the whole parse.

field

content

client use

1

up to 16 bytes

never read; the official server sends it empty

2, 3

16 bytes each

the AES-128 key and the HMAC-SHA256 key for the BAP secure hello

4

the session token, up to 568 bytes

the image calls it cs_signon_token

5

an absolute expiry, epoch seconds

stored in a slot nothing reads

6, 7

IPv4 and port of the BAP server

the next hop

9

three fixed32 values

read as floats; copied and logged only

11

a relative expiry, seconds

overrides field 5 into the same unread slot

12

the peer key-exchange block

relayed into the peer handshake

13

a bool

blocks five UI launch handlers; the official server omits it

14 to 16

three strings

city, country and ISP; copied into a report only

Field 12’s f4 must be exactly 128 bytes. Any other length is silently discarded. The client does not verify its contents.

The top-level response also carries a silo id (printed as Silo ID: N), the client’s external IPv4 (which it saves as its address), a client-config sub-message and six capability bits. The capability bits only feed an account-status report and gates on the next request. They do not decide whether the lobby connects.

The other outcomes

Each non-success arm needs its own sub-message, except access-denied.

  • Queued. Position, time remaining and retry delay are required. A zero in any of them keeps the previous value. A server-offline flag picks the message the player sees. A cookie is echoed back on the next request.
  • Throttled. A message and an optional retry delay. With no delay the client waits exactly 60 seconds. An explicit zero means zero.
  • Error. The reason picks a terminal state. The names are the client’s own.

reason

state

reason

state

1

_suspended

10

_internal_server_error

4

_prelaunch

11

_sms_validation_required

5

_platform_error

12

_free_license_limit_exceeded

6

_configuration_error

13

_korea_cinderella_law

7

_commercialization_error

14

_excessive_mtx_debt

8

_version_mismatch_error

other

_denied

The denial screen shows the error’s title and body only when both are present.

The content manifest

After SignOn the client fetches a content manifest over HTTP. The body is a 264-byte signature followed by protobuf. The signature prefix is [u32 big-endian 1][u32 0] and a 256-byte RSA-2048 signature: PSS padding, SHA-256, a 16-byte salt, over the body. The client holds one public key, and the same key signs sku_config.txt. On failure it records a code: 1 decode failed, 2 HTTP failed, 5 body shorter than 264 bytes, 6 no key verified the signature, 8 HTTP 404.

bdLobby BAP

BAP is a TCP link to one server. The client opens it after SignOn. It later opens a second BAP link per activity, to an address the server chooses.

The frame

Every frame starts with a 6-byte outer header. All integers are big-endian.

struct BapOuterHeader {         /* 6 bytes */
    uint8_t  magic;             /* +0x00  1 */
    uint8_t  frame_type;        /* +0x01  0 or 2 plaintext, 1 encrypted */
    uint32_t length;            /* +0x02  bytes after this header */
};

An encrypted frame (type 1) then carries a 16-byte tag and AES-128-GCM ciphertext. length counts the tag. A plaintext frame carries the inner message directly.

The inner message starts with a header whose size depends on the service.

struct BapInnerHeader {         /* 6, 8 or 14 bytes */
    uint16_t service_id;        /* +0x00  must be below 308 */
    uint32_t task_id;           /* +0x02  a response echoes the request's value */
    uint16_t status;            /* +0x06  responses only; must be 200 */
};

header

used by

6 bytes

requests and pushes

8 bytes

responses: adds the status

14 bytes

service 8 only: adds an 8-byte account handle after the task id

TCP gives reliability, so BAP has no sequence or acknowledgement of its own. One unanswered request jams the link, so every request needs its response, with a body its decoder accepts. An empty body is not a neutral answer.

Sunrise parses and builds frames in src/middleware/bap/frame.h and bap_frame.cpp.

The service registry

The client registers 71 services. Each is a singleton with a fixed id and a body codec. Ids 308 and above are dropped at the header, so no newer service can reach this build.

id

name (enum or class)

direction

body

6

c->ah mgr req

C->S

7,719 bytes: discriminator 3, length, protobuf naming the destination

7

c->ah mgr rsp

S->C

137 bytes: discriminator 2, a non-zero u64 session id, 128 opaque bytes

8

c->ah not

C->S

an activity message to the Activity Host

9

b->c not

S->C

an activity message from the Activity Host

10, 11

c->ws req, c->ws rsp

both

the web-service RPC envelope

12, 13

subscribe request, response

both

9 bytes in, empty body back

16, 17

c->b_ahp req, rsp

both

8 bytes in (host id), 16 bytes back (id, IPv4, reserved, port)

19

client_config_response

S->C

protobuf

21, 22

purchased offers

both

request is send-only; the response is protobuf

23, 24

account id translation

both

12 bytes in; a bit-packed list back; an empty reply kills the link

25, 26

secure hello

both

SignOn token in; 84-byte channel setup back

28

server_challenge_request

S->C

protobuf inside an authenticated envelope; meaning unknown

30, 31

channel start

both

128 bytes back, not inspected

32, 33

user message

both

protobuf back

42, 43

matchmaking

both

protobuf; request field 2 picks one of eight kinds

47

NAT punch relay intro

S->C

protobuf

110, 112

web service, server role

both

same envelope as 10 and 11

121, 122

queuez register

both

4 bytes in, empty body back

123

qz->c update not

S->C

replicated object store updates

124

qz->c sub lost not

S->C

14 bytes

250, 251

echo

both

a u32 in, empty body back; the keepalive

302, 303

relay client registration

both

protobuf in, empty body back

304, 305

Steam certificate signing

both

protobuf

The full list is ids 6 to 51, then 100, 102, 110, 112, 121 to 124, 150, 151, 153, 160 to 162, 171, 250, 251 and 300 to 307. Ids 36 to 41 and 48 to 51 have no enum name and print as UNKNOWN. Sunrise keeps its own catalog in src/middleware/bap/service_catalog.h.

The body codec takes one of four shapes.

shape

services

what it does

send-only

18, 21, 23, 32, 34, 36, 38, 40, 46, 171

the client cannot parse a reply on this id; never send one

empty body

13, 15, 122, 251, 303

accepts any body and reads nothing

nanopb protobuf

26 services

decodes against a pb_field_t[] schema

hand-written

the rest

fixed-length or bit-packed readers

Nineteen services have a fixed body length, and each rejects any other size.

Pushes

A server-initiated message is a push. The client accepts pushes on exactly 15 ids: 6, 8, 9, 20, 27, 28, 47, 51, 100, 123, 124, 150, 160, 161 and 301.

  • Ids 6 and 161 have no handler behind them. A push that decodes on either id crashes the client.
  • An id outside the 15 is a null call, not an ignored message.
  • That leaves 13 usable ids. Twelve of them do real work.

Web-service RPC, services 10 and 11

Inventory, character and most other account actions ride services 10 (request) and 11 (response). The server-role pair 110 and 112 uses the same codec. The body is not protobuf. It is a Bungie bitstream holding an opcode-dispatched RPC. Bungie’s own name for this layer is networking:investment:server_message_interface.

/* ws message, bit-packed, MSB first */
u16  wire_opcode;      /* looked up in a descriptor table; an unknown opcode aborts */
u32  transaction_id;
...  body;             /* the request or response definition the opcode names, walked bit by bit */
bit  blob1_present;    /* then u16 length and bytes */
bit  blob2_present;    /* then u16 length and bytes */

The client knows 115 opcodes. Each descriptor holds a wire opcode, a request definition and a response definition. The wire opcode is an arbitrary u16. The table index is local and never goes on the wire. 36 opcodes have a known client sender. The 44 opcodes 1201 to 1245 have no sender at all: the client only receives them.

opcode

what the client sends

206

subscribe to a replicated object family

402

a generic item action: item instance id, definition index, a row value and an action byte

403

equip: the item instance id, nothing else

501, 502

create and delete a character

503

the Investment sign-in request

505

change character

601

loot pickup: a pickup kind and a loot identity

801, 802, 804

socket actions: item, socket index, and for 804 a source item

901

buy a vendor sale row

904

a three-index investment table action

1801

claim a triumph reward: a record index

1820

pull an item from Collections: a collectible row index

1901

a batch of up to 12 plug insertions into one item: plug definition index and socket per entry

No item action carries a plug hash or a shader hash. The socket actions 801, 802 and 804 name only an item instance and a socket index, and the server works out the rest. 1901 names each plug by its definition index. The labels in this table come from the client’s own senders. They are not the backend’s names for the operations.

The reply’s version barrier

75 of the 115 response definitions inherit the same 37-bit pair: a 5-bit status, bias 1, then a 32-bit value with bias INT32_MIN.

  • Logical status 0 or 7 completes the operation. Status 16 is a content-table patch checksum soft rejection. Anything else is an error.
  • The value is a revision of the account’s family-4 objects. When it is not -1, the client parks the operation until its own copy of those objects reaches that revision.
  • Logical -1 means “nothing to wait for”. A zero on the wire decodes to INT32_MIN, which is a revision request, not -1.

So a reply to a change must name the revision of the object update that publishes the change, and that update must follow. A reply to anything else sends -1, including an error reply.

Replies 1103, 2200 and 2300 write the same 37 bits inline instead of inheriting the pair. Their second field is not a revision, and the client does not wait on it.

Replicated objects, service 123

Service 123 (qz->c update not) delivers Investment state. The game calls it queuez. It is a subscription-based replicated object store. There is no bulk read anywhere. The plain-text error Buffer is too small. WorldServer may have incompatible build. names the producer: the WorldServer.

The body is big-endian:

u32 family_update_count;          /* 0 is an error */
/* per family, 21 bytes, no padding */
u32 family_type;                  /* 0 to 7 */
u64 family_root_id;
i32 version;                      /* -1 is an error */
u8  flags;                        /* bit 0: full snapshot */
u32 object_count;                 /* 0 is legal */
/* per object, 20 bytes, then the payload */
u32 object_id;                    /* a definition id, not a slot index */
u64 object_key;                   /* must equal the payload's first qword */
u32 payload_len;                  /* bytes; 0 deletes the object */
u32 encoding;

The store has 8 families of 6 object slots. object_id is matched against a value inside the definition each slot is bound to, so it is fixed per slot. For example, the family-4 account object uses 0x32D7B974 and the character object 0xE5E86992.

encoding

payload

1

the slot’s schema, bit-packed; applied in place as a delta, so absent fields stay

2

a binary diff against the previous object bytes

3

raw bytes, copied with no clamp to the slot size

4

Oodle; must expand to exactly the slot’s object size

A version must be the previous version plus one. A full snapshot deletes every object it does not name. A payload over 96,280 bytes is fatal. The account object is exactly 96,280 bytes, so it goes out compressed or as a delta.

A whole service-123 message also has a size limit. Its exact value is unverified: a 46,973-byte message works, and a 143,285-byte message ends the BAP link.

The client keeps the first error it sees:

code

cause

1

a ws send failed

2

no record for that family; the client never subscribed

3

object_id matches no slot

4

no room in the client’s own slot table

5

the payload’s first qword is not the object key

6

a version gap

Codes 2 to 6 surface as bootflow reasons 176 to 180, code + 174. Code 1 surfaces as nothing.

Matchmaking, services 42 and 43

Every matchmaking request is service 42 with the same 2,624-byte body. Field 2 is the request kind, and the reply on service 43 must match it.

field 2

request

1

session search

2

update the session advertisement

3

delete the session advertisement

4

matchmaking configuration

5, 6

update or delete the rejoin advertisement

7

locate a session

8

live matchmaking stats

The reply carries no correlation field. The BAP task id does that.

  • Configuration, kind 4. Accepted on field 4’s presence alone. A useful reply also sets a fair queue base value, in seconds. The client adds the seconds since it arrived and reports the result in every later advertisement, with the time of its first search reply and its first non-empty one. Reading this as a wait-time queue is assumed.
  • Search, kind 1. Field 3 holds up to 50 results and a maximum acceptable ping. The client keeps each result’s 128-byte join descriptor and two scores. A result with no ping estimate is kept. A session the client failed to join is refused for a while, so a server should not reuse its id.
  • Advertisement, kind 2. A success reply sets field 1 to 2 and assigns a non-zero advertisement id.

The search request’s wire matchmaking type is 1 only for the internal type PUBLIC activity target. The internal names are PRIVATE activity, PUBLIC activity and PUBLIC activity target. Matchmaking is enabled by a local config byte that no server message writes.

Protobuf in the client

All protobuf decoding goes through one embedded copy of nanopb. It is decode-only. A separate hand-written encoder walks the same schema tables to write the few protobuf messages the client sends.

decoded roots

count

bdLobby services, schema on the service object

16

bdLobby services, schema inside the decoder

8

SignOn response

1

session membership, inside peer message 30

1

content manifest

1

services 28 and 29, inside an authenticated envelope

2

total

29

The client also encodes seven bdLobby request roots it never decodes. The bdLobby protobuf surface is 33 roots: 26 decoded and 7 encoded.

A nanopb field descriptor is 32 bytes.

struct PbField {                /* 32 bytes; tag 0 ends the array */
    uint32_t tag;               /* +0x00  field number */
    uint32_t flags;             /* +0x04  low 4 bits: LTYPE; next 4 bits: HTYPE */
    uint8_t  data_offset;       /* +0x08  step after the previous field */
    int8_t   size_offset;       /* +0x09  has-bit address, relative to the value */
    uint8_t  pad[2];
    uint32_t data_size;         /* +0x0C  one element's size */
    uint32_t array_size;        /* +0x10  capacity, for arrays */
    uint32_t pad2;
    const void *submsg;         /* +0x18  child schema, or a callback */
};

LTYPE is varint, svarint, fixed32, fixed64, bytes, string or submessage. HTYPE is required, optional, array or callback. Three rules matter to a server.

  • A missing required field fails the whole message.
  • Strings and byte arrays have fixed capacities. An oversized value is rejected, not cut short.
  • A bytes slot stores its length in its first 8 bytes. A slot declared as 96 bytes holds 88.

Sunrise’s protobuf reader and writer are in src/middleware/protobuf/.

The engine peer transport

The peer transport carries group sessions and gameplay replication between machines. It has three layers.

layer

what it is

association

a four-packet Demonware handshake, then encrypted records

message channel

a bitstream of numbered peer messages, 45 ids

upper handlers

replication views, voice, and the group session

Which path a peer takes is set by byte +93 of the 128-byte join descriptor, the transport method. 6 and 7 select Steam Datagram Relay. Every other value selects Demonware bdNet.

The peer address

Many peer messages carry a serialized bdCommonAddr. All 86 bytes take part in equality, padding included.

struct NetAddr {                /* 86 bytes; ports low byte first */
    uint8_t  local_ipv4_0[4];   /* +0x00  first local endpoint, dotted order */
    uint16_t local_port_0;      /* +0x04 */
    uint8_t  local_more[24];    /* +0x06  four more optional 6-byte endpoints */
    uint8_t  public_ipv4[4];    /* +0x1E  observed public endpoint */
    uint16_t public_port;       /* +0x22 */
    uint32_t address_id;        /* +0x24  identity hash of the endpoint */
    uint8_t  nat_type;          /* +0x28  1 open, 2 moderate, 3 strict */
    uint8_t  reserved[44];      /* +0x29  zero */
    uint8_t  transport_method;  /* +0x55  0 to 5 bdNet, 6 and 7 Steam relay */
};

On the bdNet path the transport reads only the first 41 bytes, as a serialized bdCommonAddr. An address with no public endpoint is sent to NAT traversal instead of being dialed.

The join descriptor

A session is joined through a 128-byte join descriptor. It is exactly full: four fields, no padding.

offset

size

field

+0

8

machine id

+8

86

host NetAddr; its last byte, +93, is the transport method

+94

16

join key

+110

18

online session id; its low 8 bytes are the Steam lobby id

The game’s debug overlay prints two of these itself, as ID: (the machine id) and KEY: (the join key). On the wire the descriptor is length-prefixed, and the length must read 128.

On the Steam arm, the client joins the lobby named at +110. Any delivered lobby callback commits the session, whatever its result field says. Only a JoinLobby call that returns no handle blocks the join.

The association handshake

Every bdNet peer connection opens with four packets. Nothing above runs until they complete.

type

name

direction

size

1

init

requester -> responder

18 bytes

2

init ack

responder -> requester

52 bytes

3

cookie echo

requester -> responder

209 bytes

4

cookie ack

responder -> requester

116 bytes

Every field is a plain byte append. Multi-byte integers go low byte first. There is no length prefix and no checksum. Types 10 to 13 on the same port belong to NAT traversal.

Every packet opens with the same 8-byte header.

struct AssocHeader {            /* 8 bytes */
    uint8_t  type;              /* +0x00  1 to 4, or 6 for data */
    uint8_t  constant_2;        /* +0x01  2, never read back */
    uint16_t addressed_tag;     /* +0x02  the tag this packet is for */
    uint32_t sequence;          /* +0x04  0 on handshake packets */
};

struct AssocInit {              /* 18 bytes */
    AssocHeader hdr;            /* +0x00  type 1, tag 0 */
    uint16_t requester_tag;     /* +0x08  chosen by the requester */
    uint8_t  security_id[8];    /* +0x0A */
};

struct AssocInitAck {           /* 52 bytes */
    AssocHeader hdr;            /* +0x00  type 2, tag = requester_tag */
    uint32_t free_running;      /* +0x08  stored, never checked */
    uint8_t  cookie[16];        /* +0x0C  the responder's own value */
    uint16_t responder_tag;     /* +0x1C */
    uint16_t responder_tag2;    /* +0x1E  same value again */
    uint16_t requester_tag;     /* +0x20 */
    uint16_t old_responder_tag; /* +0x22  0 on a fresh association */
    uint16_t old_requester_tag; /* +0x24  0 on a fresh association */
    uint8_t  observed_addr[6];  /* +0x26  requester address as the responder saw it */
    uint8_t  security_id[8];    /* +0x2C  copied from the init */
};

struct AssocCookieEcho {        /* 209 bytes */
    AssocHeader hdr;            /* +0x00  type 3, tag = responder_tag */
    AssocInitAck echoed;        /* +0x08  the init ack, unchanged */
    uint8_t  requester_addr[41];/* +0x3C  serialized address claim */
    uint8_t  security_id[8];    /* +0x65 */
    uint8_t  public_key[100];   /* +0x6D  exported ECC public key, zero padded */
};

struct AssocCookieAck {         /* 116 bytes */
    AssocHeader hdr;            /* +0x00  type 4, tag = requester_tag */
    uint8_t  public_key[100];   /* +0x08 */
    uint8_t  security_id[8];    /* +0x6C */
};

The security id routes every packet. The socket router keys its association table on it. A packet whose id matches nothing is dropped before any association sees it, and nothing is logged. The requester chooses the id, and every reply repeats it.

The key exchange is ECDH on the curve secp224r1, using LibTomCrypt. Each side exports its public key in the cookie echo or cookie ack. The association then derives its record keys.

After the handshake, data travels in type-6 records.

struct AssocData {              /* 18 bytes + ciphertext */
    AssocHeader hdr;            /* +0x00  type 6; sequence at +0x04 */
    uint8_t  mac[8];            /* +0x08  truncated MAC */
    uint16_t plain_len;         /* +0x10 */
    uint8_t  ciphertext[];      /* +0x12  rounded up to the cipher block */
};

Records use AES-128 in CBC mode. Each record is an independent CBC run. The MAC covers the header and everything from the length on, truncated to 8 bytes. A 32-slot sliding window rejects replays.

Sunrise implements this layer in src/middleware/gameplay/dtls/.

The engine also has its own separate association layer, with a 3-bit opcode set (connect request, key-exchange offer, key-exchange response, reject, keepalive). The join descriptor cannot select it. Its opcode-0 body is unknown.

The peer message channel

One UDP payload carries a chain of peer messages in one bitstream, most significant bit first.

/* Peer message chain, widths in bits */
bit  packet_marker;            /* 1   written once per packet */
/* repeated: */
bit  more;                     /* 1   1 = a message follows, 0 = end */
u6   message_id;               /* 6   0 to 44 */
u18  struct_size;              /* 18  the id's fixed struct size, not the bits used */
...  payload;                  /*     the id's own writer */

struct_size is a constant per id. The receiver uses it only to size a scratch buffer, and it drops a message whose declared size does not match the table. A reader failure ends the whole chain.

There are two paths.

path

carries

framing

out of band

connect and join negotiation: ids 0, 4, 5, 6, 7, 9, 10, 11, 13, 14, 16, 27, 28, 29, 42

a 16-bit length, then the chain

reliable connection

everything in a session: 12, 26, 30, 31, 34, 37, and more

an established datagram with ACKs and two fragment queues

An established datagram opens with a 0 bit, a fragmentation bit, and the connection sequence modulo 4. Then come the handlers in a fixed order: the connection ACK, reliable queue A (32-byte fragments), reliable queue B (6-byte fragments), and then external handlers such as voice. A datagram over the address limit (1,228 bytes on direct IPv4) is split into at most eight pieces.

The receiver accepts a packet sequence at most 128 ahead of its window. A sender further ahead is read as a whole ring behind, and the connection never recovers.

Peer message ids

The channel has 45 ids, 0 to 44.

id

name

carries

0, 1

ping, pong

a 16-bit sequence and a 64-bit timestamp, echoed

2, 3

broadcast-search, broadcast-reply

LAN discovery; id 3 has no dispatcher in this build

4

packets-discarded

how many packets the receive window dropped, at most 31

5

connect-request

channel id, initial sequence, requester address

6

connect-response

echoes the request, adds the responder’s channel id, sequence and address

7

connect-refuse

echoes the request, a 3-bit reason

8

connect-establish

the first reliable message on a connection

9

connect-closed

both channel ids, a 5-bit reason

10

join-request

protocol version, build bounds, group session id, join id, joining peers and members

11

peer-connect

protocol version, machine id, group session id

12

join-complete

group session id, join id, membership revision

13

join-abort

group session id and the joining peer’s machine id; cancels a join

14

join-refuse

group session id, join id, one of 30 named reasons

15, 16

leave-session, leave-acknowledge

the group session id

17, 18

session-disband, session-boot

session id, machine id, reason

19 to 25

host handoff, transition, re-establish, decline

host migration

26

peer-establish

the joining peer’s own session is up

27, 28

election, election-refuse

host election with candidate addresses and reachability masks

29

time-synchronize

a four-timestamp clock exchange

30

membership-update

the member and player tables (below)

31

peer-properties

one peer’s 304-byte property row

32, 33

delegate-leadership, boot-machine

leadership change, removal

34 to 37

player-add, player-refuse, player-remove, player-properties

player rows and their 232-byte profile

38, 39

parameters-update, parameters-request

group-session parameters

40

view-establishment

opens a replication view in six ordered stages

41

voice-chat-user-registration

16 bytes; no producer in this build

42

mayday

a failed state change, 9 bits, bias 1

43, 44

test, test_force_host_machine_name

test controls; never send

Clock sync (id 29) computes offset = ((t2 - t1) + (t3 - t4)) / 2. The receive time t4 is never on the wire. A reply is terminal: answering one loops forever.

What a host checks in a join request

The host side of join-request runs these checks in order:

check

on failure

the protocol version matches

no reply at all

the named group session exists and is in state 6 to 9

join-refuse reason 4 not-found

the joiner’s high build bound is at least 86657

reason 27 peer-version-too-low

the joiner’s low build bound is at most 86657

reason 28 host-version-too-low

the executable type is 5

reason 29 executable-type-mismatch

Admission can still refuse with 4, 6 not-joinable, 7 session-full or 26 rejected-by-host. The 30 refusal reasons are named in the client, from 0 no-reason-given to 29 executable-type-mismatch. Others include 5 privacy-mode, 8 no-ambassador-reservation, 17 wrong-silo-id, 18 wrong-payload-type and 22 player-not-online-enabled.

Nothing authenticates the host. The one RSA verify on the receive path discards its result, and a peer that leaves its identity blob out skips it entirely. Both key-exchange keys are made by the peers themselves.

Sunrise’s peer codecs are in src/middleware/gameplay/peer/ and src/middleware/gameplay/group/.

Session membership, peer message 30

The group-session host sends membership-update to tell members who is in the session. A joining peer waits for it. The body is one 31,104-byte struct in four regions.

offset

size

region

+0

8

host machine id, 64 raw bits

+8

5,944

a nanopb message: the session state

+5952

8

base revision and one copied value

+5960

4

two counts: peer deltas and player deltas

+5968

32 x 344

peer-table deltas

+16976

32 x 440

player-table deltas

+31056

40

four optional tail fields

+31096

4

a hash over the sender’s session state

On the wire the protobuf region is a 13-bit byte length and that many bytes. The rest is bit-packed. This is the only place the client nests protobuf inside a Bungie bitstream.

A base revision of 0 means a complete snapshot. Otherwise the update is a delta against the revision the receiver holds.

Root fields

tag

type

meaning

1

u32

membership revision; 0 means none published yet

2

u32

host member index

3

u32

host-succession candidate index (unverified)

4

u32

member count

5

u64

member-slot bitmask; low 32 bits used

6

repeated

the member table, up to 32 entries

The member record

Each protobuf field is copied straight into the receiver’s session state at the same offset. So a wrong value becomes the client’s own state.

struct MembershipMember {       /* 184 bytes, nanopb layout */
    bool     has_addr;          /* +0x00 */
    uint64_t addr_len;          /* +0x08  must be 86 */
    uint8_t  addr[88];          /* +0x10  tag 1: the peer's NetAddr */
    bool     has_machine_id;    /* +0x68 */
    uint64_t machine_id_len;    /* +0x70  must be 8 */
    uint8_t  machine_id[8];     /* +0x78  tag 2 */
    bool     has_join_id;       /* +0x80 */
    uint64_t join_id;           /* +0x88  tag 3: the join attempt's id; 0 when empty */
    bool     has_party_id;      /* +0x90 */
    uint64_t party_id;          /* +0x98  tag 8: 0 and all-ones both mean none */
    bool     has_player_count;  /* +0xA0 */
    uint32_t player_count;      /* +0xA4  tag 9 */
    uint64_t player_slot_count; /* +0xA8  tag 10, at most 1 */
    uint32_t player_slot;       /* +0xB0  index into the player table */
    bool     has_leave_pending; /* +0xB4 */
    uint8_t  host_leave_pending;/* +0xB5  tag 11, a bool */
    bool     has_leave_done;    /* +0xB6 */
    uint8_t  host_leave_done;   /* +0xB7  tag 12, a bool */
};

Tags 8 and 9 group members into parties for matchmaking. Tags 11 and 12 drive a rejoin after the host leaves.

The join id (tag 3) is checked first. The receiver finds the entry whose address equals its own. If that entry’s join id is not the one its current join sent, the update is “from a stale join” and is dropped with no other effect.

Peer and player deltas

A peer delta names a member index (6 bits), a changed flag, a connection state (4 bits), and optional connection data. The member states are named in the client:

value

state

value

state

0

_none

6

_joining

1

_rejoining

7

_joined

2

_reserved

8

_waiting

3

_reserved_ambassador

9

_ready

4

_disconnected

10

_established

5

_connected

A player delta names a player slot, an owning member, a player-add counter, and an optional profile. The profile carries the player’s name and the account and character ids. An activity join reads those ids from this row, so a row without a profile costs the peer its player.

What a bad update costs

The receiver applies the update first and validates after. On most failures it clears its member and player tables and rebuilds a minimal two-member session. The hash at +31096 is the last check. It is a Bob Jenkins lookup3 hash over the receiver’s own 28,768-byte session state after applying. A mismatch ends in no membership information, forcing disconnect, and the join retries.

Sunrise builds a replica of the session state to compute this hash, in src/middleware/gameplay/group/session_state.h.

QoS probe and the session advertisement

Before joining a session found by matchmaking, the client probes it over UDP. An unanswered session is marked unsuitable and never joined. The probe uses Demonware’s bdQoSProbe. Both packets are little-endian and unframed.

struct QosRequest {             /* 18 bytes */
    uint8_t  type;              /* +0x00  40 */
    uint64_t send_time;         /* +0x01  echoed back */
    uint32_t probe_id;          /* +0x09  routes the reply */
    uint32_t security_id;       /* +0x0D  echoed back */
    uint8_t  last_probe;        /* +0x11  1: put the payload in the reply */
};

struct QosReply {               /* 27 bytes + payload */
    uint8_t  type;              /* +0x00  41 */
    uint32_t probe_id;          /* +0x01  from request +0x09 */
    uint64_t send_time;         /* +0x05  from request +0x01 */
    uint8_t  accepted;          /* +0x0D  0 is an explicit refusal */
    uint32_t payload_len;       /* +0x0E  must equal the bytes after +0x1B */
    uint32_t security_id;       /* +0x12  from request +0x0D */
    float    hold_time;         /* +0x16  seconds, subtracted from the round trip */
    uint8_t  has_data;          /* +0x1A */
    uint8_t  payload[];         /* +0x1B  the session advertisement */
};

The payload is required. An empty or undecodable one marks the session unsuitable, with a named reason such as qos-payload-empty or qos-incompatible-versions.

The session advertisement

The payload is a 13,184-byte session advertisement, bit-packed. LAN peer message 3 carries the same block, and both use one decoder. Its header comes first.

bits

field

rule

64

session id

from the running session

16

protocol version

must equal the client’s, 0xA4F8 on this build

3

kind

must be 5

31

build high bound, bias -1

must be at least 86657

31

build low bound, bias -1

must be at most 86657

2

payload type

must equal what the tracker expects

Payload type 1 is a full advertisement: topology kind, host mode, join controls, the 128-byte join descriptor, five 6-bit slot counts, and an optional extension with up to 32 member rows. Payload type 2 is a 688-byte group-session descriptor. Matchmaking in this build expects type 2.

Every field comes from live session state. A zero block fails the range checks.

STUN, NAT, UPnP and DNS

  • STUN. The client walks a 16-entry STUN host table and needs one valid entry. If none is valid it falls back, but it then needs at least one local address, or UPnP refuses to start.
  • NAT traversal. bdNATTravClient shares the association port and uses packet types 10 to 13.
  • UPnP. SSDP discovery, one description GET, and four SOAP actions. A failed mapping falls back to a direct or relayed path.
  • DNS. c-ares runs its own DNS over its own sockets. It sends one IPv4 A query, UDP first, TCP on truncation, with retries. curl keeps a 60-second host cache and ignores DNS TTL.

Gameplay replication

Replication runs on the reliable peer connection. Its model is the simulation object (sobject) plus network facets and component blocks. Dispatch is computed from reflection data, so there is no numeric opcode switch on the wire.

A replication view opens through peer message 40 in six ordered stages. At stage 2 the initiator publishes its scheduler signature. A mismatch logs view signature mismatch, no replication. Stages 4 and 5 open provisional and then full replication.

The local player’s biped does not need replication. It is built by a local call. Replication is what other entities need.

Open questions

  • The body of the engine association’s opcode 0.
  • The meaning of service 28’s fields.
  • The leaf layout of the 304-byte peer-property block (peer message 31).
  • The reason enums of connect-refuse and connect-closed.
  • The exact byte partition of the protected formatter under the engine’s direct association.