DESTINY 2

The Activity Host protocol

The Activity Host is the authority for one running activity. It owns what exists, who owns it, and what state each authored object is in. The client talks to it over a second bdLobby (BAP) connection, using 59 numbered activity messages.

Message names such as join_result and slot type names such as objective_sensor are the game’s own. Function and field names are descriptive, not the game’s.

Two kinds of authority

The game splits its world between two systems.

systemwhat it ownstransport
peer simulationmoving objects: bipeds, projectiles, damage, entity fieldsengine peer transport over Demonware bdNet (UDP)
Activity Hostactivity state: lifetime, objectives, squads, timers, HUD, scenes, devicesa second bdLobby BAP link (TCP)

The peer side replicates simulation objects between machines. The Activity Host does not simulate. It publishes authored state to “slots” on placed objects, and the client’s own simulation acts on it. The client reports back what it measured.

Three facts matter for a server.

  • The activity and destination catalog is inside the client. No content server is needed.
  • The local player’s own biped is built by a local per-frame call. It needs no peer and no message.
  • The Activity Host link is a plain bdLobby connection. The server chooses its address, so the whole route into an activity runs on a stack a server already speaks.

The route into an activity

The client asks for a host, connects to it, and joins. Each step waits for the one before. C is the client and S is the server.

steplinkdirectionmessage
1primaryC->SbdLobby service 16, 8 bytes: the activity host id it wants
2primaryS->CbdLobby service 17, 16 bytes: echo of that id, IPv4 address and port
3––the client opens a second BAP connection to that address
4secondC->SbdLobby service 6, 7,719 bytes: a protobuf naming the destination
5secondS->CbdLobby service 7, 137 bytes: a non-zero session id
6––the client turns service 7 into activity message 10 inside itself
7secondC->Sactivity message 3 join_request
8secondS->Cactivity message 4 join_result, then message 0 entity_slots_allocated

After step 8 the host sends message 1 global_activity_state, message 12 replicate_membership, and message 5 sensor_auth_update as its state changes.

Field 1 of the step 4 protobuf is the client’s own encoded activity-selection request. It carries the activity, the arrival bubble, the spawn set and the package name. No separate web-service request is sent for this hop.

The activity envelope

Activity messages ride inside two bdLobby services.

servicenamedirectioninner header
8c->ah notclient to host14 bytes: service id, task id, 8-byte account handle
9b->c nothost to client6 bytes: service id, sequence

Service 9 is a push, so no request matches it. Its sequence has no reader. Send zero.

Host to client, service 9

struct ActivityPush {            /* 17 bytes + payload, big-endian */
    uint8_t  discriminator;      /* +0x00  must be 1 */
    uint64_t session_id;         /* +0x01  routes to an activity client slot */
    uint32_t msg_type;           /* +0x09  0 to 58 */
    uint32_t payload_len;        /* +0x0D  must equal the bytes that follow */
    uint8_t  payload[];          /* +0x11  bit-packed body */
};

session_id is a lookup key, not a free field. The client keeps up to three activity clients. It scans them for the one whose stored id matches. On no match the router returns success and drops the message. Nothing is logged.

Two routing arms exist.

armtaken whenid compared against
pre-joinmessage 4, after join_request is sent and before the jointhe id in join_request field 1
establishedafter the client accepts a join_resultthe id join_result stored

Message 4 is the only message the pre-join arm routes. It also sets the id every later push must carry.

payload_len is capped at 0x7D800 bytes. A body that decodes into the simulation queue has a tighter limit: the queue’s per-tick budget of 0x80000 bytes, shared with every other event.

Client to host, service 8

struct ActivityNotification {    /* 13 bytes + payload, big-endian */
    uint8_t  discriminator;      /* +0x00  1; 2 is legal and takes a different arm */
    uint32_t msg_type;           /* +0x01  activity message id */
    uint32_t payload_len;        /* +0x05  bytes that follow */
    uint32_t session_id;         /* +0x09  the client sends 0 */
    uint8_t  payload[];          /* +0x0D  bit-packed body */
};

This header follows the 14-byte inner header. The payload starts 21 bytes into the service 8 body: 8 bytes of account handle, then 13 bytes of this header.

Body encoding

Every activity body is a Bungie tag-reflection bitstream. The same codec writes the Investment storage blob.

  • Bits are packed most significant bit first. Nothing is byte-aligned.
  • A field is written as value + bias in width bits. The reader subtracts the bias.
  • An all-zero field decodes to minus the bias, not to zero. A byte with bias 128 needs raw 0x80 to mean 0.
  • A field with a presence flag is preceded by one bit. 0 omits the value.
  • A field without a presence flag is always on the wire. Skipping it shifts every later field.

A handler that fails to decode drops the message before its consumer runs. Nothing reports it.

Message ids

The client carries all 59 names, ids 0 to 58, in an ordered table. The ids fall into three overlapping sets.

setcountmeaning
inbound handler21the client acts on these when the host sends them
client sender30the client can send these
name only9no sender, no handler, no body schema

An id with no inbound handler is logged as an unknown message type and dropped. The envelope must still be valid.

The main messages

H is the host and C is the client.

idnamedirectionwhat it carries
0entity_slots_allocatedH->Ca 1,024-byte mask, one bit per entity slot (8,192); ORed into the client’s lease
1global_activity_stateH->Cthe activity descriptor, 146 bytes; the client reads the activity name from it
2world_globals_stateH->Cenables the activity clock; only the first 64 bits are posted
3join_requestC->Ha correlation nonce, the activity host id, member identity, player state
4join_resultH->Cresult code, request echo, clock base, initial replication epoch, keepalive interval
5sensor_auth_updateH->Cthe client roster and every slot’s Auth state (below)
6sensor_sense_updateC->HSense state the client measured for its slots (below)
7sensor_messageH->Cwrites to an entity the client already holds; cannot create one
8request_activity_hostlocalnever on the wire; the transport turns it into bdLobby service 6
10start_activity_host_responselocalmade inside the client from bdLobby service 7
12replicate_membershipH->Cactivity membership, 3,696 bytes; needed before a slot grant can apply
15peer_leave_requestC->Hthe player is leaving, with a reason hash
16activity_client_keepalive_requestC->Hone byte, no schema
19incidentbotha gameplay event: a definition index, target indices and a payload
20allocate_entity_indicesC->Hasks for more entity slots; answered with message 0
21free_entity_indicesC->Hreturns slots
22client_authoritative_data_updateC->Hclient-owned state, including the region it reports
23client_identity_updateC->Hthe member identity record
24, 25claim_authority_over_abandoned_entity_slots, purge_abandoned_entity_slotsH->C1,024-byte per-bubble authority masks
28, 29reset_entity_slot_authority_mask, ..._acknowledgementH->C, C->Ha 32-bit token; the client rebuilds its mask and echoes the token
30, 31, 32query_entity_slot_authority_mask and two responsesH->C, C->Ha 32-bit token; the client answers with per-bubble masks, then the full mask
33abdicate_authorityC->Hthe client gives up authority over a bubble
37connectivity_failurebothrecords one peer key; diagnostic only
38membership_acknowledgementC->Hnot described here
39send_client_heartbeatC->Hnot described here
40, 41script_state, script_eventH->Cdecoded and discarded in this build
44advance_replication_epochH->Can 8-bit epoch and one abort bit
45reservations_failedH->Ca count and 64-bit keys; stops the client waiting on reservations
47connection_quality_reportC->Hnot described here
52patch_epochC->H16 bytes, once a second
54bubble host state updateH->Ca 32-row status table; only the debug overlay reads it
56, 57perf_request_kill, perf_request_reflectH->Cdecoded and discarded

In message 0, slot i is byte i/8, bit 1 << (i%8).

The nine name-only ids are 9, 17, 35, 36, 42, 51, 53, 55 and 58. Id 17 is named activity_client_keepalive_response, but the client has no handler for it. The keepalive interval comes from join_result instead.

Hazards for a host

  • Message 44’s abort bit is permanent. A set abort bit, or a consumer failure on messages 24, 25, 28 or 30, sets a sticky give-up flag. While it is set, message 5 is ignored for the rest of the session.
  • Message 44’s epoch must be the previous epoch plus one. A repeat or a skip ends the client with incorrect epoch sequence. Never retry it.
  • Message 0 is OR-only. A later message 0 cannot clear a bit. Send it to answer a join or a message 20, not on a timer.
  • Message 12 is applied without checks. A wrong body is accepted in silence, and one wrong byte can remove the local player from the activity.

join_result, message 4

The level-0 record has 18 fields and no presence bits, so every offset is fixed. The minimum body is 6,053 bits, which pads to 757 bytes.

fieldwire bitwidthmeaning
00570the whole join_request record; bits 0 to 95 are its nonce and host id
15703result, bias 1; only logical 0 accepts
25731,024host session id as a string, element bias 128
61,69364activity clock base, in ticks
71,7578initial replication epoch
81,76516peer-heard window, ms
91,78116keepalive interval, ms
174,0052,048active workspace name, element bias 128

An accepted join_result carries three correlations: the envelope’s session id, the nonce in bits 0 to 31, and the host id in bits 32 to 95. The host id becomes the id every later push must use. Leaving it zero makes the router drop everything after.

The client’s status line prints Status:IEXGM. E is set by an accepted message 4, G by message 1, and M by message 12.

Slots

A slot is one authored place where the host can publish state. Each placed object in a scenario declares a slot table. A slot is named by three values.

struct ClientRef {              /* 8 bytes in memory, 55 bits on the wire */
    uint32_t registry_key;      /* +0x00  32 bits, bias 0; the object's key */
    int8_t   slot_type;         /* +0x04  7 bits, bias 1 */
    uint8_t  pad;               /* +0x05 */
    int16_t  slot_index;        /* +0x06  16 bits, bias 32768 */
};

The empty key is 0x811C9DC5, the FNV-1 offset basis. A type or index that decodes to -1 makes the resolver refuse the reference.

Slot types

The slot type says what the slot does. Each type has a component class on the client, and up to two schemas.

schemadirectioncarried by
Authhost to client: what the host decidedmessage 5
Senseclient to host: what the client measuredmessage 6

A schema of -1 means the slot has no such state. The client names the three buffers it keeps per slot: m_last_received_auth_state, m_last_extracted_sense_state and m_last_received_sense_state.

39 slot types carry an Auth schema. Their names are the engine’s own. Examples:

typenameAuth sizewire bits (min to max)
1squad_sensor196 B24 to 1,313
2combatant_sensor2,392 B11 to 8,228
3objective_sensor28 B2 to 225
8hud_sensor464 B35 to 3,582
13player_spasaha_sensor760 B128 to 4,800
16scoreboard_sensor5,904 B7 to 47,054
17activity_lifetime_sensor1,300 B423 to 2,265
18activity_timer_sensor64 B386
23device_sensor24 B147
24channel_sensor52 B3 to 387
38task_sensor4 B32
43scene_sensor212 B74 to 1,538
67team_side_sensor136 B1,057
68directive_sensor768 B4,802

The maximum comes from the schema. Whether the client accepts a body that large is not verified.

Twelve slot types (12, 44, 45, 46, 47, 48, 54, 60, 61, 64, 69 and 72) never have a descriptor, so they never appear in message 5.

How an Auth table is encoded

Each schema is a list of 40-byte field descriptors. A descriptor gives the struct offset, a type code, a presence flag, a nested schema handle, a bias and a width.

struct FieldDescriptor {        /* 40 bytes */
    int32_t  value_advance;     /* +0x00  struct offset step */
    int32_t  second_advance;    /* +0x04 */
    int32_t  flag_bit_index;    /* +0x08  slot in the presence bitmap, not a stream offset */
    int32_t  fourth_counter;    /* +0x0C */
    uint8_t  type_code;         /* +0x10  selects the read and write handler */
    uint8_t  has_presence_bit;  /* +0x11  1: one bit precedes the value */
    uint8_t  pad[2];            /* +0x12 */
    int32_t  sub_handle;        /* +0x14  nested schema, for type code 1 */
    int32_t  params[4];         /* +0x18  { bias, width, ... } for integers */
};

The walker reads a presence bit first when the descriptor has one, even for a nested field. Then it dispatches on the type code.

codetypewire form
1nested recordthe child’s fields, in order
2bool1 bit
3, 7int8, uint8width bits, biased
4, 8int16, uint16width bits, biased
5, 9int32, uint32width bits, biased
6, 10int64, uint64width bits, biased
11real32quantized to params[2] bits over a float range, or raw 32 bits
13positionthree raw float32, 96 bits; the descriptor has no width
23nullable u321 bit, then 32 bits when present
34schema id, then bodya nullable 32-bit schema id, then a body of the schema it names
38keyed lanetwo 6-bit selectors, then one body from a 10-entry table and one from a 3-entry table
41dynamic schema value1 bit, a 32-bit handle, then a body of that handle’s schema

Codes 13, 34 and 38 carry no width in the descriptor. A walker that reads the width field counts them as zero bits and comes out short.

An array field has a count first. The count’s width can be wider than the array. The client does not clamp it, so an encoder must.

Two sub-records appear in many tables.

  • The ClientRef above, 55 bits. One Auth body uses it to name another slot.
  • A clock window, 353 bits: {bool running, u64 minimum, u64 maximum, u64 value_at_epoch, u64 remaining_at_epoch, u64 epoch, real32 rate}. Types 18 and 35 carry it.

Message 5: Auth, and the client roster

Message 5 does two jobs. It tells the client which objects are active (the roster), and it carries the Auth state for their slots. There is no extra header. The payload is one bitstream.

/* Message 5 body, in wire order. Widths in bits. */
u8        hardwipe_token;      /*   8  checked only if the SignOn feature list enables it */
u64       patch_epoch[2];      /* 128  echo the client's own message 52 */
bit       bubble_block;        /*   1  0 skips the bubble-authority block */
/* ...bubble-authority block, present only when bubble_block is 1... */
u64       token;               /*  64  stored, not validated */
bit       roster_enable;       /*   1  must be 1 */
delta     roster;              /*      group keys, per-key revisions, per-bubble rows */
loop      entity_groups;       /*      see below */
bit       singleton_gate;      /*   1  0 ends the message */

The patch epoch must match what the client sends in message 52. On a mismatch the bits are still read, but the roster change and the object loop are both skipped. It looks like success.

Each entity group, then each object in it:

/* One entity group */
bit   group_present = 1;
u32   group_key;               /* a registry key; 0x811C9DC5 skips the whole group */
u32   filler;                  /* read and discarded */
/* One object block, repeated */
bit   object_present = 1;
ClientRef ref;                 /* 55 bits */
u32   remainder_bits;          /* length of everything below, for skipping */
bit   reset;                   /* only when the slot has an Auth schema; 1 on a first send */
bit   auth_root;               /* only with reset: 1 = a body follows */
...   auth_body;               /* the slot type's Auth fields */
bit   sense_present;           /* only when the slot has a Sense schema; send 0 */
/* ... */
bit   object_present = 0;      /* ends this group's objects */
/* ... */
bit   group_present = 0;       /* ends the groups */

The client reads the Auth and Sense schema from its own record for the slot, not from the wire. So an encoder must know, per slot, whether each schema exists. A wrong guess shifts every later bit in the message.

The 32-bit remainder lets the client skip a block. It skips when the reference does not resolve, or when the object belongs to a bubble other than the current one.

How the client applies an Auth body

void on_sensor_auth_update(Body *b) {
    read_prefix(b);                      /* token, epoch, bubble block, latch */
    if (!epoch_matches(b)) return;       /* silent: nothing below runs */
    apply_roster_delta(b);               /* register or retire groups */

    while (read_bit(b)) {                /* entity groups */
        u32 key = read_u32(b); read_u32(b);
        while (read_bit(b)) {            /* objects */
            ClientRef ref = read_ref(b);
            u32 len = read_u32(b);
            Record *r = resolve(ref);
            if (!r || (r->bubble != current_bubble && r->bubble <= 63)) {
                skip_bits(b, len);       /* silent */
                continue;
            }
            if (r->auth_schema != -1) {
                if (read_bit(b))         /* reset */
                    reset_to_schema_defaults(r->last_received_auth);
                if (read_bit(b))         /* root */
                    decode_delta(b, r->auth_schema, r->last_received_auth);
            }
            if (r->sense_schema != -1 && read_bit(b))
                decode_sense_override(b, r);
            r->dirty = 1;
            r->seeded = 1;
        }
    }

    if (all_records_in_bubble_seeded())  /* one missing object blocks all */
        for (Record *r : dirty_records())
            r->component->apply_auth(r->last_received_auth);  /* per-type applier */
}

Three rules follow.

  • Reset-only is a publication. reset = 1, root = 0 installs the schema’s defaults. It overwrites any body sent earlier. So every message 5 must repeat every Auth body the host still holds.
  • Apply is all or nothing per bubble. One registered object with no block keeps every Auth body in that bubble from applying.
  • The applier sees the whole record. Most types copy the record to a fixed component offset (usually +384). Seven types land elsewhere, and types 3, 39 and 43 scatter it.

Message 6: Sense

The client sends message 6 when a slot’s measured state changes. The body, as Sunrise decodes it:

u64   patch_epoch[2];          /* 128  same value as message 52 */
bit   literal_zero;            /*   1  0 */
bit   root;                    /*   1  Sunrise decodes only the 0 form */
/* One group, repeated */
bit   group_present = 1;
u32   group_key;
u32   group_bits;              /* length of this group's substream */
/*   One object, repeated */
bit   object_present = 1;
ClientRef ref;                 /* its key equals group_key */
...   sense_body;              /* the slot type's Sense fields */
u32   counter;                 /* the client's per-slot counter, plus one */
/*   ... */
bit   object_present = 0;
/* ... */
bit   group_present = 0;
bit   tail = 0;                /* then zero padding to a byte */

The trailing counter belongs to the client. It starts at 0, and each message 6 sends the stored value plus one. A host may overwrite it through message 5’s Sense block. The next report then continues from the written value. The client never compares it to anything.

Key slot types

Type 3, objective_sensor

The objective and task director. It hands an objective’s tasks to squads under authored limits, and it counts completions itself.

struct ObjectiveAuth {          /* 28 bytes; 225 bits when every field is present */
    int8_t  objective[24];      /* +0x00  7 bits each, each with a presence bit */
    int32_t generation;         /* +0x18  31 bits, with a presence bit */
};

The array itself also has one presence bit. A full body is 1 + 24 x (1 + 7) + 1 + 31 = 225 bits.

  • The 24 bytes carry no meaning to the client. The applier compares each one with its stored copy. A difference resets that objective’s task counters. Nothing switches on the value.
  • There is no “leave alone” encoding. A clear presence bit hands the applier a zero. To keep an objective, re-send its current byte.
  • The int32 is a reset generation. A change clears the objective’s latch bits.
  • Progress is computed by the client. A task counter rises when an actor dies, and it goes back to the host through Sense. No Auth field sets progress.

Type 17, activity_lifetime_sensor

The activity’s lifetime. It has 20 fields and no presence bits, so a host sends all of it or none. The minimum body is 423 bits.

struct ActivityLifetimeAuth {   /* 1,300 bytes */
    int8_t   state;             /* +0x000  4 bits, bias 1 */
    int8_t   quality_bypass;    /* +0x001  3 bits, bias 1; 4 skips the network-quality check */
    uint8_t  join_flag_inverted;/* +0x002  1 bit; 0 raises join-policy bit 3 */
    uint8_t  pad0;
    int32_t  marker_revision;   /* +0x004  a change rebuilds the directive markers */
    uint32_t spawn_entry_hash;  /* +0x008  selects an authored spawn entry by name hash */
    int32_t  current_bubble;    /* +0x00C  the bubble the activity treats as current */
    uint8_t  switches[1204];    /* +0x010  count (6 bits) + up to 50 gameplay switches */
    int32_t  unused_1220;       /* +0x4C4  decoded, never read */
    int32_t  spawn_override[6]; /* +0x4C8  three {index, hash} spawn-point pairs */
    uint8_t  cycle_override_on; /* +0x4E0  1 bit */
    uint8_t  pad1[3];
    uint32_t cycle_phase;       /* +0x4E4  a phase in [0, 1); read only when the override is on */
    int32_t  selection_id;      /* +0x4E8  0 to 63 installs a record; -1 clears it */
    ClientRef selection_target; /* +0x4EC */
    uint32_t selection_arg;     /* +0x4F4 */
    uint8_t  definition_refs[28]; /* +0x4F8 */
};

The lifetime state is read at 28 call sites. Known effects:

stateeffect
3the live case; clears the player’s awaiting-spawn bit
4loading presentation shown
6one activity-complete presentation
7screen blurred, movement blocked; 3 or 5 clears it
8client returns to orbit
9section-complete presentation

Some of these effects are not verified in the code. None of them are the game’s names for the states. The rest of the enum is unknown.

Encoding traps:

  • The state has bias 1, so state 3 goes on the wire as 4.
  • Four int32 fields have bias -2^31. Zero bits there decode to INT32_MIN.
  • Turn the spawn-point pairs off with index 0 on the wire and hash 0x811C9DC5. This is the one place where emitting the bias is wrong.
  • Only the first registered type-17 slot is read. Send the same body to every type-17 slot in the roster.

Type 1, squad_sensor

A request for an authored squad. The client already holds the spawner, the member candidates and the spawn points in its packages. The host names the slot and sets the request fields. The client’s own spawner then creates the actors.

The Auth record is 196 bytes with 21 fields. It includes slot references, count-plus-array groups, a positive 31-bit spawn generation, an active field (2 bits, bias 1) and a mode field (3 bits, bias 1).

Two behaviors matter.

  • On the first apply the client seeds part of the component from the last received Sense, not from the Auth.
  • The squad reports its own alive count through Sense. A host learns about deaths that way.

Type 2, combatant_sensor

Actor lifecycle for one combatant. It does not select an AI profile.

fieldshapewhat the client does
.0optional i32spawn-edge revision; a change runs the activate or destroy edge
.12 bits, bias 1nonzero keeps the actor active; zero tears it down
.23 bits, bias 1actor ownership
.3boolmaster enable
.468 byteseight keyed result lanes, each {key, generation}, and a result mask
.5optional 180 bytesactor control: flags, temperaments, a one-time placement, 16 channel values
.6optional 2,064 bytesa 32-step client-side action program
.7optional 72 byteseight referenced slots and a registration generation

Message 5’s apply runs only three parts: the spawn edge, a generation, and the keyed lanes. The actor-control block applies later, when the actor attaches or when the retained state is restored.

The Sense record is 88 bytes. It reports the accepted revisions, the action program’s progress, and two damage-pool fractions in [0, 1]. The fractions carry no attacker and no cause.

Where to read the code

subjectfile
service 9 envelopesrc/middleware/bap/activity_message/activity_message_notification_encoder.cpp
message 5 structuresrc/middleware/bap/activity_message/sensor_auth_update.h
Auth bodies by slot typesrc/middleware/bap/activity_message/scriptable_auth_body.h
type 1 and type 2 bodiessrc/middleware/bap/activity_message/squad_auth_body.h, combatant_auth.h
message 6src/middleware/bap/activity_message/sense_update.h, activity_sense_update_decoder.cpp
schema tablessrc/middleware/bap/activity_message/wire_schema/
host pushessrc/server/bap/encrypted/push/activity/

The source is at github.com/stanuwu/Sunrise.

Open questions

  • The full lifetime state enum. States 0, 1, 2 and 5 have no known effect.
  • Six unnamed join_result fields (10 to 13, 15, 16), and the meaning of field 14.
  • What reads the global selection record that type 17 fields .16 to .18 write.
  • The meaning of each type 1 squad_sensor Auth field.
  • Bodies for the nine name-only ids. None exists in this build.
  • Most Auth tables on this page are not verified against a live client.