DESTINY 2

Activities and destinations

An activity is one playable thing: a mission, a patrol zone, the Tower, a raid. A destination is the place it happens. The game describes both in package tables. It describes the level itself in a separate scenario tag, split into bubbles.

Field and function names are descriptive, not Bungie’s, unless a section says a name was shipped.

The catalog

Four catalogs describe what exists. Each one lives under the investment root blob. The first three also have a display half with the strings, with the same rows in the same order.

catalogrowsrow form
activities117016-byte index rows to variable-length records
activity types54inline rows; 16 bytes in the logic half, 128 in the display half
destinations48inline rows of 56 bytes
places27inline u32 definition hashes

A row index is the id the rest of the game uses. The objects and messages carry the index, not the hash.

The activity record

The logic half of an activity record holds these fields:

struct ActivityRecord {                 /* variable length, at least 320 bytes */
    uint32_t definitionHash;            /* +0x000 */
    uint8_t  unmapped0[4];
    uint64_t requirementGroupCount;     /* +0x008 */
    int64_t  requirementGroups;         /* +0x010  relative; u32 group hashes */
    uint8_t  unmapped1[80];
    int64_t  internalName;              /* +0x068  relative; for example "raid_gluttony_0" */
    uint8_t  unmapped2[8];
    uint64_t flagWriteCount;            /* +0x078 */
    int64_t  flagWrites;                /* +0x080  relative; {i16 slot, u8 value} rows */
    uint8_t  unmapped3[24];
    uint8_t  releaseRows[16];           /* +0x0A0  release-band gates; reader unknown */
    int32_t  requiredLevel;             /* +0x0B0 */
    int32_t  requiredPower;             /* +0x0B4 */
    int32_t  secondTierLevel;           /* +0x0B8 */
    int32_t  secondTierPower;           /* +0x0BC */
    uint8_t  unmapped4[8];
    uint64_t onwardLinkCount;           /* +0x0C8 */
    int64_t  onwardLinks;               /* +0x0D0  relative; 32-byte rows */
    uint8_t  unmapped5[2];
    uint8_t  activityType;              /* +0x0DA  row of the activity type table */
    uint8_t  unmapped6[5];
    uint8_t  destination;               /* +0x0E0  row of the destination table */
};

Notes:

  • The destination link is one byte at +0x0E0. A search for a 16-bit field misses it.
  • Power follows the item power curve: requiredPower = max(requiredLevel * 10, 750). It holds on all 700 rows that set a requirement. So an activity’s power compares directly with a character’s.
  • Type 7 is Raid and type 46 is Dungeon, read from both the activity rows and the type table.
  • There is no fireteam size field. The size is a display string on the activity type row.
  • Entering an activity writes unlock flags. Every value in the write list is 2, which is “true”.

For example, Leviathan is activity 565, internal name raid_gluttony_0, type 7, destination 11. Its variants need power 750.

The activity type and its kind byte

The display-half type row is 128 bytes. Its signed byte at +120 is a kind. The client maps it to an action mode:

kindaction mode
12
31
anything else0

Kind 1 covers one type, Social: the Tower and a few social spaces.

Only one type row has kind 0. It is the type of activity 0, orbit. The client uses that to ask “is this an orbit activity”.

Destinations and places

struct DestinationRow {                 /* 56 bytes, display half */
    uint32_t definitionHash;            /* +0x00 */
    uint32_t nameBank;                  /* +0x04  string ref: name */
    uint32_t nameHash;                  /* +0x08 */
    uint32_t subtitleBank;              /* +0x0C  string ref: subtitle */
    uint32_t subtitleHash;              /* +0x10 */
    uint8_t  unmapped[20];              /* +0x14 */
    uint64_t bubbleCount;               /* +0x28 */
    int64_t  bubbles;                   /* +0x30  relative; 24-byte elements */
};

In the logic half the same row has the place index at +0x04, a u32 into the place table. The place table is 27 bare definition hashes. No display half is known for it.

The destination row lists bubbles, but this list is not what the world loads. The world loads the bubbles named by the activity’s scenario tag. For the Tower the two lists share only 4 of 8 hashes, in a different order.

The start-destination table

At sign-in the client picks where to go first from an 8-row table. Each row holds an activity index and an optional unlock expression. The client takes the first row with no expression or a passing one.

The last row has no expression and holds activity 0, orbit. So the pick always ends, and it ends on orbit unless an earlier row passes. The Tower row passes when two per-character flags are both true. A row before it passes when only the first is true, so the two flags move together.

Gates on an activity

Several gates exist. None of them blocks a launch in this build.

gatewherewhat it does
onward linksactivity +0x0C8the launch check; all 457 shipped rows have an empty expression
requirement groups29 rows, named by hash from activity +0x008pick the “why is this locked” message
release rowsactivity +0x0A0lead with a release-band flag; reader unknown
type stringsactivity type rowdisplay text only

The requirement-group selector returns a message index, not a verdict. -1 means nothing failed. All callers are UI components.

The expression format is on Items, characters and inventory.

From activity to level data

The activity root

Each activity has a root tag of class 0x80808AAE, exactly 0x48 bytes. Two fields matter:

fieldtarget
+0x40the scenario tag, class 0x80809994
+0x44a 0x30-byte tag, class 0x80809BA3; meaning unknown

The client also finds tags by name. It builds names from the activity’s internal name and a fixed suffix list, then looks the name up as a named tag.

suffixuse
nonethe activity root
:scenario_clientthe scenario the client loads
:scenario_fah, :scenario_gah, :globals_host, :globals_clientother scenario and globals tags
:spaceflight_in, _filler, _outtravel sequences
:same_dest_in, _filler, _outtravel inside one destination
:no_ship_in, _filler, _outtravel without the ship

For example, city_tower_social_d2:scenario_client is the Tower’s scenario tag.

Bubbles in the scenario

The scenario tag holds the bubble list the world uses:

struct ScenarioBubbleTable {            /* inside the scenario tag */
    uint8_t  header[80];
    uint64_t bubbleCount;               /* +0x50  0 to 64 */
    int64_t  bubbles;                   /* +0x58  relative; 24-byte elements */
};

struct ScenarioBubble {                 /* 24 bytes, class 0x8080924D */
    uint32_t bubbleHash;                /* +0x00 */
    uint8_t  pad[4];
    uint64_t stateCount;                /* +0x08  0 means the bubble has no state 0 */
    int64_t  states;                    /* +0x10  relative; 76-byte records */
};

struct SliceSetState {                  /* 76 bytes, class 0x8080924F */
    uint8_t  enabled;                   /* +0x00 */
    uint8_t  pad0[3];
    uint32_t stateNameHash;             /* +0x04 */
    uint8_t  unmapped[20];
    uint32_t mapBubbleIndex;            /* +0x1C  the bubble's number on its map */
    uint8_t  flags[32];                 /* +0x20  ORed into the world object */
    uint32_t ownerBubbleHash;           /* +0x40 */
    uint32_t sliceSetTag;               /* +0x44 */
    uint8_t  pad1[3];
    uint8_t  skipFlags;                 /* +0x4B  0 applies the flag block */
};

A bubble’s states each take one slice set. A bubble owns a run of 8 slice-set indices, so its first state’s index is bubble * 8. Sunrise has the same rule in src/middleware/content/packages/tables/region_reader.h:

constexpr uint32_t region_index(uint32_t bubbleIndex) {
    return bubbleIndex * kSliceSetIndexFactor;      /* kSliceSetIndexFactor == 8 */
}

When the activity starts, the server sends one state byte per bubble. Only two values are legal:

byteeffect
0registers the bubble’s first state and adds the bubble
-1skips the bubble

State 0 is refused when the bubble has no states. So the rule per bubble is state = (stateCount > 0) ? 0 : -1. That is also the client’s own default. For the Tower the eight values are 0 0 0 0 -1 -1 0 0.

Map bubbles and containers

A bubble also has a number on its map, separate from its ordinal in the scenario. Two activities on one map can give the same bubble different ordinals. The slice-set state record carries the map number at +0x1C.

A map root tag lists one record per bubble. Each record names a list of containers. A container carries a 32-byte bit mask, one bit per map bubble, and a list of member tags:

struct Container {                      /* header, class 0x80808A54 */
    uint64_t size;                      /* +0x00 */
    uint8_t  bubbleMask[32];            /* +0x08  one bit per map bubble index */
    uint64_t memberCount;               /* +0x28 */
    int64_t  members;                   /* +0x30  relative; bare tag handles */
};

The mask decides in which bubbles the container’s members load. The client code that tests the mask is unknown, so this rule is not verified in code. It comes from the data: two tag classes agree on the numbering in every map measured.

Spawn selection

Spawn points and spawn sets

Player spawn points live in package tags of class 0x80809162. The install holds 386 such tags and 8892 points.

struct SpawnPoint {                     /* 48 bytes */
    float    rotation[4];               /* +0x00 */
    float    position[4];               /* +0x10 */
    uint32_t setNameHash;               /* +0x20  FNV-1 of the lowercase set name */
    uint8_t  reserved[12];              /* +0x24  zero in every point */
};

Points group into named spawn sets. Two names matter:

namehash
default0x2EA8FB98
none0x2CA33BDB

A spawn set is wrapped by a component, and the component is a member of a container. So the container’s bubble mask decides which bubbles offer the set. When a component loads, it registers its set with the spawn-point manager. The manager keeps a flat list. It never asks which bubble a set belongs to: the list is whatever loaded.

A set also attaches only when the package that holds it is one the destination loads. The mask and the package are two separate tests.

Picking a point

The spawn filter is one hash. The server’s move message and the activity start message carry it. The client replaces an empty or none hash with default.

void set_spawn_filter(SpawnPointMgr *mgr, uint32_t hash)
{
    if (hash == 0x811C9DC5 || hash == HASH_NONE)
        hash = HASH_DEFAULT;
    mgr->filter = hash;
}

const SpawnPoint *pick_spawn_point(const SpawnPointMgr *mgr)
{
    static const int categories[] = { 0, 3, 4, 5 };
    for (int c = 0; c < 4; c++) {
        for (const SpawnPoint *p = first_point(mgr, categories[c]); p; p = next_point(mgr, p)) {
            switch (categories[c]) {
            case 0: if (has_type67_reference(p)) return p; break;
            case 3: if (p->setNameHash == mgr->filter) return p; break;
            case 4: if (p->setNameHash == mgr->filter ||
                        (mgr->filter == HASH_DEFAULT && p->setNameHash == 0x811C9DC5))
                        return p;
                    break;
            case 5: return p;                   /* accepts anything */
            }
        }
    }
    return NULL;
}

This is why a wrong set fails silently. If no point matches, category 5 hands back the first point that loaded, and the player lands in the wrong place. There is no error.

Maps differ in whether they declare a set named default:

contentdeclare default
campaign missions64 of 65
adventuresall
social spacesall
raids1 of 11
Crucible maps1 of 30

So a raid needs an explicit set. For free-roam destinations the client picks the arrival bubble and set itself. For raids and dungeons it sends the unset hash, and the server’s start message decides.

Example: the Leviathan entrance

The Leviathan entrance uses bubble 2 and spawn set 0x8029E4B4.

  • Bubble ordinal 2 is map bubble 13.
  • The activity package holds a copy of set 0x8029E4B4 whose container carries bit 13.
  • The loaded slice set is 16, whose package name is raid_gluttony_berth.

A set that is valid package data but not bound to bubble 2 places the player elsewhere. The client then requests the gardens slice set, not the entrance.

Sunrise applies the same two tests in src/state/activity/destination/activity_destination_spawn_binding.cpp. It uses the bubble mask, through the map-index table, to pick a bubble that offers the set (bounds checks removed here):

const std::size_t mapIndex = layout.bubbleMapIndices[bubble];
return (row.bubbleMask[mapIndex / 8] >> (mapIndex % 8) & 1U) != 0;

It also drops a set whose package the destination does not load, and sends the unset hash instead. A set named by an explicit override skips this package test.

How a campaign mission is authored

A campaign mission is a chain of small objects, one per objective step. Each step object’s slot table names the directive it shows, the dialogue cue it plays, the music it selects, the volume that ends it, and the volumes its line waits in. Reading those tables gives the step order and the presentation.

The rule that connects one step to the next is not in the packages. The client ships every part a mission needs, each named, and no wiring between them. The host supplies the order.

The object and its slot table

Every scenario object is a package tag of class 0x80809462.

struct ScenarioObject {                 /* header */
    uint8_t  unmapped0[8];
    uint32_t mapNameHash;               /* +0x08  FNV-1 of the map name; 0x29930BA4 is "edz" */
    uint32_t registryKey;               /* +0x0C  the key every slot reference uses */
    uint8_t  unmapped1[16];
    uint64_t slotCount;                 /* +0x20  field width not verified */
    int64_t  slotHeader;                /* +0x28  relative, from +0x28, to a 16-byte header */
};

struct SlotRow {                        /* 8 bytes, at slotHeader + 16 */
    uint32_t slotType;
    uint32_t nameHash;
};

A row’s position is the slot index. For an ordinary slot the name hash is FNV-1 of the slot’s development name:

fnv1("_directive_trigger") == 0x2162521A;
fnv1("_nav_point")         == 0x3769A1A0;

So a guessed name is checked by hashing it. A name that hashes right is exact.

Proxy slots

Three slot types have no descriptor. Their name-hash word holds a reference instead.

typeengine namethe word holds
69directive_proxya directive hash in the scenario’s directive table
54dialog_event_proxya cue hash in the scenario’s dialogue table
12music_section_proxya music section hash

No client code that reads proxy slots is known. So proxy slots are authoring data, and the host acts on them.

Volumes

A step object usually carries several trigger volumes (type 60). They have three roles:

  • Trigger volume. The step’s player-trigger slot (type 31) names it. It reports when the player enters.
  • Filter volume. The step’s dialogue line waits in it. The host sends the cue with this volume as its filter, and the client plays the line when the player walks in.
  • Waypoint volume. The host sends it with the directive. The client hides the step’s marker while the player is inside.

The dialogue table

The dialogue slot (type 53) names a table of class 0x80808D54.

  • The cue array has rows of {u32 cue hash, float}. A row’s position is the cue index that a dialogue body plays.
  • Each line is a 72-byte record, in cue order:
struct DialogueLine {                   /* 72 bytes, class 0x80808D23 */
    uint8_t  unmapped0[8];
    uint32_t cueHash;                   /* +0x08 */
    uint8_t  unmapped1[8];
    float    pauseBefore;               /* +0x14  not verified */
    uint8_t  unmapped2[8];
    uint32_t stringContainer;           /* +0x20  localized string container */
    uint32_t stringHash;                /* +0x24 */
    float    duration;                  /* +0x28  matches the spoken length; not verified */
    uint8_t  unmapped3[20];
    uint32_t speakerHash;               /* +0x40 */
    uint8_t  unmapped4[4];
};

The directive slot (type 68) names a directive table the same way. Both tables hold string references, so the mission’s text is package data.

Walking a step chain

The start and end rules below are not verified. They are inferred from the behavior of one mission.

/* Steps are read in the order of their directives' text. */
void run_step_chain(Host *host, const Step *steps, int count)
{
    for (int i = 0; i < count; i++) {
        const Step *s = &steps[i];

        /* On start: objective text, marker and waypoint, then the line in its filter volume. */
        send_directive(host, s->directive, s->navPoint, s->waypointVolume);
        if (s->hasCue)
            send_dialogue_cue(host, s->cue, s->filterVolume);
        if (s->trigger)
            arm_trigger(host, s->trigger);

        /* A step ends when its trigger fires, or when its fight is won. A fight is skippable
           unless a barrier holds the player, so a later trigger also ends it. */
        wait_until(host, trigger_fired(s->trigger) ||
                         fight_won(s) ||
                         any_later_trigger_fired(steps, i + 1, count));
    }
    clear_directives(host);             /* removes the last marker */
}

Two rules for the host:

  • A marker needs its object registered on the client. Arming the step’s trigger registers it.
  • A trigger fires once. To reuse it, the host arms it again with a newer generation.

Not authored in the step objects: the order of steps that share a directive, cues no step names, fights and spawns, and the choice between two filter volumes on one object.

Example: A Deadly Trial

The campaign mission A Deadly Trial is scenario 0x80B2E043, adventure_ginger. It has ten step objects. Three of them:

stepobjectdirectivecuetrigger
town0x80B2ECF30xEC2177790slot 7, the town exit
roadblock0x80B2E6DB0x6FB8A85A–none; ends when the Walker tank is destroyed
revive0x80B2EC6C0x708B93517, 8none; ends on a Ghost scan

Some cues are named by no step object. Two of them play from a device’s own scene.

Example: the Leviathan berth

The Leviathan berth, the raid’s first area, shows the same pattern at a larger scale. Its bubble has two states:

statehashwhat it holds
ordinal 00xD1B16771the playable world
ordinal 10xE95965DFthe opening cinematic; no squads, no devices

Every observable in the berth maps to a named slot: squads, doors, levers, the directive, dialogue and music. The order of the lever puzzle is not on the client. Only 27 slot-to-slot references exist in all of Leviathan, and 26 of them point at a trigger volume. The extraction behind that count models only references to trigger volumes, so it cannot show other edges. The behavior programs and slot types also carry no mission state.

The extra cutscene state is a pattern across the install. All 90 extra state rows carry the same marker flags, and 88 of them hold a cinematic slot. The flags’ reader is unknown, so the marker’s meaning is not verified.

The Director’s activity graphs

The Director (the map screen) is built from activity graphs. The catalog holds 26 graphs.

struct ActivityGraph {                  /* 152 bytes fixed, class 0x80805E79 */
    uint32_t blobLength;                /* +0x00 */
    uint8_t  pad0[4];
    uint32_t artKey;                    /* +0x08  shared by graphs of one destination */
    uint8_t  name[8];                   /* +0x0C  string ref */
    uint8_t  subtitle[8];               /* +0x14  string ref */
    uint8_t  pad1[4];
    uint8_t  displayProgressions[16];   /* +0x20 */
    uint8_t  displayObjectives[16];     /* +0x30 */
    uint8_t  linkedGraphs[16];          /* +0x40  64-byte rows: the tiles */
    uint8_t  nodes[16];                 /* +0x50  80-byte rows: activity nodes */
    uint8_t  connections[16];           /* +0x60 */
    uint8_t  artElements[16];           /* +0x70 */
    uint8_t  pad2[16];
    uint32_t floatBlock;                /* +0x90  tag of a float block */
    uint8_t  pad3[4];
};

The member names display_progressions, display_objectives, linked_graphs, nodes, connections and art_elements are Bungie’s. They are recovered from FNV-1 hashes.

  • A nodes row holds the activities a destination’s own map page offers.
  • A linked_graphs row is one tile on a page. Only the top-level solar-system page has a full list, 15 tiles.

How a tile picks its target

A tile row holds a list of candidate targets. Each candidate names a graph or a node, and carries an unlock expression. The tile takes the first candidate that passes.

struct TileCandidate {                  /* 24 bytes */
    uint8_t  graphIndex;                /* +0x00  0xFF means no graph */
    uint8_t  pad;
    uint16_t nodeIndex;                 /* +0x02  0xFFFF means no node */
    uint8_t  pad2[4];
    uint8_t  unlockExpression[16];      /* +0x08 */
};

const TileCandidate *pick_tile_target(const EvalState *s, const TileRow *row)
{
    for (uint64_t i = 0; i < row->candidateCount; i++) {
        const TileCandidate *c = &row->candidates[i];
        if (c->graphIndex == 0xFF && c->nodeIndex == 0xFFFF)
            continue;
        if (unlock_eval(s, (const ExprRecord *)c->unlockExpression))
            return c;                   /* a graph opens a map page; a node points at one activity */
    }
    return NULL;
}

Most tiles have one ungated candidate: the destination’s map page. Two tiles carry two:

  • The Moon’s first candidate points at its intro mission while a campaign flag is false. The second is the map page, taken once the flag is true.
  • Io has the same two-candidate shape. Its first candidate points at the intro mission and passes only while two flags are set. With those flags false it takes the map page.

The tile row also has its own display expression, which decides whether the tile is drawn at all. Seven patrol tiles share one account flag in that expression. The flag’s name is unknown.

A scenario’s selection graph

A scenario can also group the activities it can launch into a selection graph. One example, the Infinite Forest graph 0xE6013100, has 11 nodes. Each node offers one or more activity hashes. The infinite_abyss node offers two variants: Haunted Forest (activity 78) and Firewalled Haunted Forest (activity 79).

  • The nodes in this data have no edges. It is a grouping, not a flow.
  • Each node carries a short sequence of native state values. What those values mean is unknown.

Open questions

  • the meaning of the activity root’s +0x44 tag
  • the client code that tests a container’s bubble mask
  • what pulls in an activity package that no scenario names
  • what the release rows at activity +0x0A0 gate
  • a client reader of proxy slots
  • the meaning of the extra cutscene state’s marker flags
  • the meaning of a selection graph’s native state values