Behaviors
The compiled logic in the packages: object behaviors on entities, perk programs, unlock expressions and UI bindings. How each runs, and how to decompile and recompile it.
Content logic in the packages is compiled data, and native code runs it. No script interpreter for content is known in the client. Four kinds carry the logic of entities, perks and screens:
kind
root
installed
runs when
class 0x8080941E
1,518 roots in 102 packages
the owning object’s update reaches it
class 0x808040B5, 208 bytes
1,691
the perk receives an event
expression records
inline in records, plus 6,541 shared rows
code or the UI reads a gate
class 0x808046D8, 56 bytes
19,587
the UI updates
Object behaviors and perk programs share one small expression bytecode, the expression VM. Unlock expressions have their own stack machine. UI bindings are plain data rows.
Other compiled data has its own page:
- Material programs (TFX) are on Rendering. They share opcode numbers with the expression VM but run on a larger interpreter.
- Particle emitter programs are on Effects and particles.
- Actor commands are on Actors and AI.
Two things look like programs and are not. The class 0x80800000 entries are not scripts; see Schema handles. And the packages hold no Lua chunks. Destiny 1’s Lua-style chunk format does not occur.
Container rules shared by all four, such as typed arrays, are on Packages.
The expression VM
A small stack machine over float4 values. Object behavior conditions, counters and channel writes use it, and so do perk value expressions and entity value providers.
The record
An expression sits inside a wrapper that lists its inputs:
struct ExpressionWrapper { /* class 0x80809419, 64 bytes */
uint64_t input_count; /* +0x00 */
int64_t inputs_rel; /* +0x08 rows {u8 selector; u8 pad[3]; u32 channel hash} */
Expression expression; /* +0x10 */
};
struct Expression { /* 48 bytes */
uint64_t code_count; /* +0x00 */
int64_t code_rel; /* +0x08 bytecode, 1-byte elements */
uint64_t constant_count; /* +0x10 */
int64_t constants_rel; /* +0x18 float4 constants, 16 bytes each */
uint64_t input_count_copy; /* +0x20 the wrapper's input count, in installed behaviors */
uint32_t active; /* +0x28 1 for a non-empty program, 0 for an empty one */
int32_t native_override; /* +0x2C 0 runs the bytecode */
};
A nonzero native_override selects a native evaluator instead of the bytecode. Every installed program holds 0.
An input row’s selector picks the object to read. For object behaviors, 0 is the current target, 1 the context’s primary object and any other value its secondary object. The channel hash names a float4 channel on that object. Perk expressions read different inputs; see Perk programs.
Opcodes
opcode
width
effect
01
1
add
02
1
subtract
03
1
multiply
08
1
minimum
09
1
maximum
0C
1
merge two vectors
12
1
multiply-add: a * b + c
13
1
clamp: clamp(a, b, c)
22 xx
2
permute lanes; xx packs four 2-bit lane selectors
34 xx
2
push constant xx
3C xx
2
push input xx
3D xx
2
push from a second input array
3E xx
2
pop into output xx
Arithmetic works on all four lanes at once. The interpreter refuses an opcode above 0x3E. The table lists the opcodes installed behaviors use; value providers use more of the cases. There is no return opcode: the program ends at the end of its bytecode. Callers turn a NaN or infinite result into zero.
The installed object behaviors hold 1,380 expressions. The 1,351 non-empty ones are 4 to 19 bytes long, and there are only 22 distinct byte strings. Examples:
x + 1 3C 00 34 00 01 3E 00
x * 100 3C 00 34 00 03 3E 00
a * 45 + b 3C 00 34 00 3C 01 12 3E 00
clamp(a - b, c, d) 3C 00 3C 01 02 3C 02 3C 03 13 3E 00
Object behaviors
Objects in activities and destinations carry behavior programs. A program runs on the client, with no server or wire trigger. The object’s own per-frame update starts it once the object exists.
Behavior is object-scoped. There is no behavior field on an activity itself. It is always on a placed object inside the activity or destination. Each root belongs to an object class (0x80809C0F) through a component config (0x80809C36).
roots
package family
content
853
w64_activities
strikes, dungeons, arenas, forges, modifiers
352
w64_raids
raid encounter mechanics
125
w64_sandbox
abilities, weapons, talents, status effects
102
w64_environments
destination environment behaviors
33
w64_globals
shared
They are the client half of a mechanic: animation, effects, local status, conditions and local spawns. The encounter sequence is host policy and is not in these programs.
The program tree
class
size
role
0x8080941E
24
root; element array at +0x08
0x8080941F
16
element array wrapper
0x80809420
16
element: name hash, then a relative pointer to a node at +0x08
0x80809422
–
common node base
Each node’s class word sits 4 bytes before its payload. A node that has children holds its own root records, so the tree nests.
How a program starts
Components do not post a “run behavior” message. The engine calls a fixed method slot on the component class. Each class has a table of 16-byte records:
struct ClassMessageRecord { /* 16 bytes */
void *handler; /* +0x00 */
uint32_t msg_id; /* +0x08 hash; not compared on dispatch */
uint16_t argc; /* +0x0C */
uint16_t flags; /* +0x0E */
};
A bound method is {class, slot index, target}. Dispatch reads table[slot]. The message id is never compared, so one table can hold the same id twice.
Two ids are known: 0x67774671 is construct and 0x3088896A is destruct.
void Object_Update(Object *o)
{
/* bound methods set from the authored definition at construct */
for (BoundMethod *m : o->update_methods) {
ClassMessageRecord *r = &class_table(m->cls)[m->slot];
r->handler(resolve(m->target));
}
}
/* One submitter: a timeline behavior */
void TimelineBehavior_Update(Component *c)
{
float t = now() - c->start_time; /* stamped at construct */
for (Threshold *th : c->def->thresholds)
if (crossed(th, t))
BehaviorRoot_Execute(th->root, c);
}
Which slot submits depends on the component subtype. Of 30 subtypes in the shipped programs, 13 submit, 16 are passive config holders, and 1 is unverified. A program on a passive subtype never runs through that path.
A program also runs when a state variable changes. A state-variable commit runs every program row whose value range contains the new value.
What stops a program running
stage
what refuses
spawn
the object was never spawned
construct
a component fails and the whole object is dropped
submit
the component subtype has no submitting slot
condition
the condition’s input channel has no producer
interaction
the interaction eligibility check refuses
Inputs are object-local channels
A condition reads a named float4 channel on an object, found by name hash. Global gameplay switches do not feed these channels. The known server route is the combatant_sensor slot’s Auth body. It carries up to 16 {channel hash, float} rows, and the client writes them into the actor’s channels. See The Activity Host protocol.
The source text
The packages ship the designer’s source text beside the node that uses it. 986 of the 1,518 programs carry some:
light_mote_value > 9
dark_mote_value + 1
taken_cleanse_available == 1
The client never reads this text. It runs the compiled expression next to it.
Each identifier in the text hashes, with 32-bit FNV-1, to the variable id the program uses. dark_mote_value hashes to 0xFD8B2877, the target hash of the assignment that writes it. The identifiers in all source strings name 227 of the 270 distinct variable ids.
A program that writes a variable another program reads can start it. 21 variables have a writer and a different reader. The match is by name only, so two objects that use the same variable name cannot be told apart.
Node types
The engine registers 27 node types. The names are the game’s own.
node
class
what it does
add_hop_on
0x80804E41
creates one object per target from a spawn entry
channel_link
0x80802DA6
links two objects and attaches them at named nodes
counter
0x80804E40
writes a clamped integer; runs programs whose range covers the new value
defer
0x80804E3F
runs its children with the same context
exit_seat
0x80802A6E
queues a seat-exit record on linked objects
external
0x80804E3E
runs another object’s element list
filter
0x80804E3D
splits targets into pass and fail sets by its conditions
global
0x80804E3C
builds a target list from all players and/or all actors
incident
0x80804E3B
posts an incident
investment_unlock_flag
0x80804E39
branches on an account unlock flag
kill
0x80804E3A
destroys the target
navpoint
0x80804E38
posts a navpoint record
player_custom_interaction
0x80804E35
submits action kind 70 per target
player_custom_interaction_extended
0x80802A66
the same, with a candidate list
redirect
0x80804E31
rebinds the context, then runs children
relative_teleportation
0x80802907
moves an object by a relative transform
remove_hop_on
0x80804E30
attaches a pooled record; name not confirmed by its code
resource
0x80804E27
queues a resource change
search
0x80804E23
spatial query; feeds found objects to children
select
0x80804E1C
scores and sorts targets; top n run one branch, the rest another
spawn
0x80804E19
spawns one object per target
spawn_projectile
0x80804E17
spawns, aimed at a second object
spawn_random
0x80802DA7
spawns at a random, checked position
storage_channel
0x80802A6F
writes an expression result into a named channel
target
0x80804E10
adds, removes, clears or iterates a target roster of at most 16
timer
0x80804E0F
creates a keyed pooled record; name assumed
town_presence
0x80804E0E
writes one byte into a per-player structure
Action kind 70 and the other action kinds are on Actors and AI.
counter and storage_channel hold a target hash at +0x00, a relative pointer to their source text at +0x08 and an expression wrapper at +0x10. filter is 104 bytes: its conditions at +0x08, the pass branch’s root at +0x28 and the fail branch’s root at +0x48.
target holds a resource name hash and a relative pointer to one of four arms. The arm’s class word sits 4 bytes before it:
arm
class
what it does
add
0x80804E15
adds the context’s object to the roster and runs the join program
remove
0x80804E14
removes it and runs the leave program
clear
0x80804E13
runs the leave program for all members, then empties the roster
for-each
0x80804E12
runs an inline program over every member
Dispatch
Each registry row carries a flag. With the flag set, the dispatcher first drops targets whose object slot is not allocated, caps the list at 32, and passes the result. Eight types manage their own targets and take the raw list: external, filter, global, incident, redirect, relative_teleportation, search and select.
void BehaviorNode_Dispatch(NodeType *t, void *payload, Context *ctx)
{
if (!t->prefilter)
return t->invoke(payload, ctx);
Context c = *ctx;
c.count = 0;
for (uint32_t id : ctx->ids)
if (SObject_SlotAllocated(id) && c.count < 32)
c.ids[c.count++] = id;
t->invoke(payload, &c);
}
Object filters
The filter node holds a list of conditions. There are 21 condition types: 19 leaf filters and two that nest.
filter
filter
filter
activity_flag
dead
object_label
actor
distance
object_type
airborne
faction
player
channel
faction_relationship
resource
has_parent
hop_on
same_object
damage_owner
line_of_sight
scoreboard_stat
team_side
composite
external
channel is verified. The other leaf names are assumed, from registration order. Six of them match what their code does.
composite holds a nested list, which is how AND and OR trees nest. external evaluates another object’s list.
struct PredicateList {
uint8_t mode; /* +0x00 0 = AND, 1 = OR */
uint8_t pad[7];
uint64_t count; /* +0x08 */
int64_t elems_rel; /* +0x10 self-relative, 16-byte elements */
};
bool ObjectFilter_EvaluateList(PredicateList *l, Context *ctx)
{
if (l->count == 0)
return true; /* an empty list is true */
for (uint64_t i = 0; i < l->count; i++) {
bool r = evaluate(element(l, i), ctx);
if (l->mode == 0 && !r) return false;
if (l->mode == 1 && r) return true;
}
return l->mode == 0;
}
The channel filter (class 0x80804D7D, 144 bytes) compares two expressions:
struct ChannelFilter { /* 144 bytes */
int64_t source_rel; /* +0x00 the source text */
ExpressionWrapper lhs; /* +0x08 */
ExpressionWrapper rhs; /* +0x48 */
uint8_t mode; /* +0x88 */
};
It compares the X lane of each result:
mode
test
0
abs(lhs - rhs) < 0.001
1
abs(lhs - rhs) >= 0.001
2
lhs <= rhs
3
lhs >= rhs
4
lhs < rhs
5
lhs > rhs
Perk programs
A perk program (class 0x808040B5, 208 bytes) is a graph of native operations. Each condition and effect is fixed native code. The program picks the operations, sets their inputs and puts them in phases. Its arrays define phases, not free execution edges. How a perk reaches its program is on Weapons and perks.
root offset
content
row class
16
feedback rows: what shows in the HUD buff tray
0x80804083
32
activation conditions
0x808040BA
56
effects while active
0x808040AC
72
deactivation conditions
0x808040BA
88
conditions for leaving cooldown
0x808040BA
104
stack-specific phase records
–
128
an optional object-property hash for the perk counter
–
136, 144, 152
activation, deactivation and cooldown event masks
–
160
bit i set when root effect i is opcode 32
–
184
a native context selector byte
–
200
the highest stack-branch index, -1 for none
–
Each phase array holds 8-byte relative pointers. Each points at a payload whose class word sits 4 bytes before it.
A condition starts with a probability float, a stat-override byte, an event opcode at +5, a “has state” byte at +6 and a condition index at +7, unique within the program. State records are handed out in array order, only to conditions with +6 set, so a wrong +6 shifts every later condition’s record. A phase’s event mask must hold the opcode bit of every condition in that phase, or the client refuses the event. An opcode 35 condition also needs its inner condition’s bit, and an opcode 26 counter needs the bit of every row. All 1,691 installed programs satisfy this.
An effect starts with its opcode byte. Byte +1 is set exactly when the opcode allocates a 64-byte effect state. As with conditions, a wrong +1 shifts every later effect’s state. Several opcodes are decoded:
effect
what it does
1, 2, 3
spawn an object, once or driven by an expression
5
spawn orbs
8
add ability energy to a slot (grenade, super, melee, class ability; the slot order is assumed)
10
write an object property: set, add, subtract or multiply, with optional restore
11
modify ammo drops per category: interval, chance and repeat chance
13
drop ammo
14 to 16
add or refill ammo
32
extend timers
40
a damage multiplier, filtered by element and labels
42
add to or set the perk counter
48
names an object behavior root (class 0x8080941E); that it runs the root is assumed
Every installed condition and effect kind has a decoded layout. The meaning of many is known only from field names.
condition
event
0
always true
1
a timer, duration at +8, at least 0.0001 s
2
a kill, with victim and damage filters
3
kill credit
4
damage dealt
5
the owner is damaged
6
ammo picked up
13 to 18
counter tick, item equipped, unequipped, readied, stowed and item event
19
reload
20, 35
while a state property is in a range; 35 also gates an inner condition
21
another player killed
26
a counter over other conditions
27
a shot resolved
31
all of several condition groups
42
a finisher
Opcode 20 and 35 conditions name a property by FNV-1 hash. Ten names are known: super_active, is_thermal, is_arc, is_void, reloading, nearby_enemy_count, melee_energy, equipped_item_magazine_fraction, is_guarding_with_sword and melee_overcharge_state.
Perk expressions
Effects such as 2, 8, 10, 16 and 53 hold a 48-byte expression in the expression VM format, with no input wrapper. An input selector byte in the effect picks what the expression reads. Selector 0 is the perk counter. Selectors 1 (missing rounds), 2 (rounds in the magazine) and 11 (a value of the target) are assumed.
A constant needs one float4 constant and the bytecode 34 00 3E 00.
Label filters
Damage labels are bits in a 306-label table, with 22 named label groups. A label set holds four arrays of labels. Each array’s mask is the OR of its labels and groups. The set compiles into one of two filter kinds:
kind
masks
0
any-of and none-of; used when arrays 1 and 3 are empty
1
any-of, all-of, none-of and not-all-of
0xFF
no filter; all four arrays are empty
Label 9 is precision. Named label groups include weapon, vehicle, biped, grenade, melee, super, ability, player, ammo and turret.
The perk lifecycle
A perk enters state 0 when it is granted. An activation event passes when its phase mask admits it and the conditions pass. The effects then activate. Deactivation removes the active effects and evaluates cooldown. An empty cooldown phase returns the perk to state 0, which re-creates an activation timer when the program has one. So a timer program repeats.
A perk with an always-true activation and no deactivation conditions stays active until it is removed. Removal still runs each effect’s deactivation.
HUD buff feedback
A perk shows in the HUD buff tray only through a feedback row. Effects alone never reach it. A status row holds a phase mask, a status hash, a priority, two stack display floats, a counter selector and a timer flag. The status hash keys both an icon library and the label text: 0xF2ED5B38 is “Ammo Finder”. The status component keeps at most four entries, by priority, merging equal hashes. That this component is the drawn tray is assumed.
A row shows by perk state: state 0 needs mask bit 0x01, state 1 0x06, state 2 0x08, and bit 0x10 always shows.
Ammo-drop modifiers
Effect 11 adds and removes the same floats for Primary, Special and Heavy ammo: an interval reduction, a chance-scale bonus and a repeat chance. The drop logic applies the interval factor (1 - reduction) and the chance factor (1 + bonus). These change drop generation. They never fill a weapon’s magazine.
Unlock expressions
Much of what the client shows is gated by unlock expressions over flags and values. The flags and values themselves are on Items, characters and inventory.
An expression record is {u32 count; ...; i64 offset}. Its instructions are 8 bytes each: the opcode byte at +0 and a u16 operand at +4. The evaluator is a stack machine.
opcode
meaning
opcode
meaning
1
push a flag
13
a > b
2
NOT
14
a >= b
3
OR
15
a < b
4
AND
16
a <= b
5
NOR
17
a + b
6, 9
not equal
18
a - b
7
NAND
19
a * b
8
equal
20
a / b
10
push a value
21
a % b
11
push a constant
24
FNV-1 combine
12
evaluate a shared-pool row
25, 26, 27
&, |, ^
The signedness and divide-by-zero behavior of opcodes 20 and 21 are unverified.
bool unlock_eval(const EvalState *s, const ExprRecord *e)
{
if (!e || e->count == 0)
return true; /* callers treat "no expression" as a pass */
int64_t stack[64];
int sp = 0;
const Instr *ins = expr_instructions(e);
for (uint32_t i = 0; i < e->count; i++) {
switch (ins[i].op) {
case 1: stack[sp++] = s->flags[(int16_t)ins[i].operand] == 2; break;
case 10: stack[sp++] = s->values[(int16_t)ins[i].operand]; break;
case 11: stack[sp++] = ins[i].operand; break;
case 12: stack[sp++] = pool_eval(s, ins[i].operand); break; /* raw result */
case 2: stack[sp - 1] = !stack[sp - 1]; break;
default: sp--; stack[sp - 1] = binary_op(ins[i].op, stack[sp - 1], stack[sp]); break;
}
}
return sp == 1 && stack[0] != 0;
}
The shared pool holds numeric helpers, not only gates. It has 6,541 rows of 24 bytes: a name hash at +0 and an expression at +8. Opcode 12 pushes the pooled row’s number, and the enclosing expression decides what it means.
UI logic
Screens hold no program code. Their logic is node bindings and, for account state, one component that publishes an unlock expression. The widget tables the bindings sit in are on The UI format.
Node bindings
A node binding reads one node’s property and writes another’s.
struct UiNodeBinding { /* class 0x808046D8, 56 bytes */
uint16_t node_a, node_a_copy; /* +0x00 source node id */
uint32_t pad0;
int64_t slot_a_rel; /* +0x08 self-relative, to a slot */
uint64_t selector_a; /* +0x10 */
uint16_t node_b, node_b_copy; /* +0x18 destination node id */
uint32_t pad1;
int64_t slot_b_rel; /* +0x20 */
uint64_t selector_b; /* +0x28 */
int64_t transform_rel; /* +0x30 self-relative; 0 copies the value unchanged */
};
Node ids in a binding are local to the resource. When a hierarchy imports a resource at a nonzero start, the screen’s node id is the local id plus that start. Local node 0 of an import is its placeholder in the parent.
The optional transform turns the value on the way through. 1,682 of the 19,587 installed bindings have one. Its class word sits 4 bytes before it:
class
kind
record
0x808046E0
0, not
no operand
0x808046DC
1, compare integer
code at +1, operand at +4
0x808046DB
2, compare float
same
0x808046DA
3, compare hash
same
The compare codes are ==, !=, <, <=, >= and >, numbered 0 to 5, with the source value on the left. That the integer and float kinds use the same table is assumed.
A value crosses types through ordinary conversions. Int to float is numeric, so values past 24 bits can round. Int to bool tests nonzero.
Investment expression results
cui::c_investment_unlock_expression_component exposes one shared-pool unlock expression’s integer result to the UI:
selector
value
0
the expression’s name hash, writable
1
the integer result
2
whether a non-empty expression was found
Selector 1 holds the raw integer, not a bool. A binding then converts it. The result refreshes when the account’s family-4 objects reach a new version.
Decompiling
Every kind can be read without running it. A reader needs the typed array rules and three more:
- A payload’s class word sits 4 bytes before it. Dispatch on that word, as the game does.
- Pointers inside a tag are relative to the field that holds them. 0 means none.
- Two pointers can share one payload. Visit each offset once.
Expressions decompile by running the stack on text instead of numbers. Each push becomes a name or a number, and each operation joins its operands:
3C 00 34 00 3C 01 12 3E 00
push input 0 -> x
push constant 0 -> x, 45
push input 1 -> x, 45, y
multiply-add -> (x * 45 + y)
pop into output 0 -> output = x * 45 + y
A program is well formed when the stack holds nothing extra at the end and output 0 was written. Every installed behavior expression meets that, and every operand index is in range.
Names come from the shipped source text. Hash each identifier in a program’s source strings with FNV-1 and match the hash to the program’s channel and variable ids. Ids that no source string spells stay as hashes.
Unlock expressions decompile the same way into infix, with opcode 12 rows expanded from the shared pool. UI bindings need no decompiling: each row reads as “node A’s property, through the transform, into node B’s property”.
Sunrise writes the decoded behavior tables into its generated SDK as sunrise/behaviors.lua. In game, the Sunrise menu’s Behaviors page lists the compiled roots; see Finding things.
Recompiling
Writing a program back follows from the format:
- Write the bytecode, not the text. The client runs the compiled expression. A changed source string changes nothing.
- Keep the native operations. Perk conditions and effects, behavior nodes and filters are native code. A program only chooses them, sets their inputs and orders them.
- Append instead of moving. Existing relative pointers stay valid when nothing moves. Write new payloads and arrays at the end of the tag and point at them. A perk program and a UI hierarchy store their own size at
+0x00; update it. - Keep the counters in step. A perk needs a unique
+7per condition, the+6and+1bytes that match their opcodes, and phase masks that hold every condition’s opcode bit. A wrong value shifts every later state record. - Close the expression. Emit postfix: an input is
3C n, a constant is34 n, and each operation follows its operands. End with3E 00. Keepnative_overrideat 0.
A label filter is rebuilt from its label rows. All 1,005 installed compiled filters rebuild byte for byte this way. An unlock expression compiles from infix to the 8-byte postfix form. A new value slot also needs a descriptor; see Value slots need a descriptor.
Sunrise Studio decompiles object behaviors into a read-only graph. It decompiles and recompiles perk programs; see Edit perk behavior.
Open questions
- The submitting slot of one behavior subtype whose handlers are not analyzed.
- The engine names of 18 of the 19 leaf filters.
- The meaning of most perk effect and condition opcodes not listed here.
- What the perk program root bytes
+204and+205do. No reader is known. - The 43 variable ids no source string names.
Related pages
- Actors and AI – the objects these programs run on.
- Weapons and perks – how a perk reaches its program.
- The UI format – the screens bindings live in.
- Edit perk behavior – editing perk programs in Sunrise Studio.
