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
bytesslot 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.
bdNATTravClientshares 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-refuseandconnect-closed. - The exact byte partition of the protected formatter under the engine’s direct association.
Related pages
- From launch to orbit: SignOn and the BAP handshake as Sunrise answers them.
- The Activity Host protocol: the activity messages that ride bdLobby.
- Bootflow: the order in which the client uses these links.
