The .butter format is originally based on the work on .milk by Exhibit. The goals of this format are as follows:
- Smallest possible file size
- Minimal data loss
- Some precision is lost through lower bit length numbers
- Precision in timing of events should not be compromised
- Data complete
- All parts of the API response should be included
- Currently team names are intentionally not included
In general, data for each frame in this format is only stored as a diff of the previous frame, however, a full reset happens every N frames. This allows decompression of partial replays and streaming, as well as prevents precision errors from building up over time. Keyframe intervals can be customized depending on the recording rate and use case, but keyframes cannot be spaced more than 1 minute of real time apart due to the timestamp encoding. If there is more than 1 minute between keyframes, a keyframe should automatically be inserted.
The start of a keyframe is denoted by the 0xFEFC
header instead of the normal 0xFEFE
header.
Each frame represents the data retrieved from a single call to the game's API. Any numerical value in a frame is only stored as the difference from the previous frame, except for keyframes.
Bytes | Data |
---|---|
(27-45) + (18-36) * n | File Header (n=total number of players in the file |
(4) * n | Chunk Header (N=total number of chunks |
??*N | N Frames |
Bytes | Data |
---|---|
1 | .butter version |
2 | Keyframe interval, ushort |
2-20 | client_name ASCII string |
1 | null byte |
16 | sessionid Session ids are made of 5 hexadecimal words, containing 8-4-4-4-12 digits each, so 32 hexadecimal digits makes 16 bytes |
4 | sessionip IPv4 addresses can be stored as 4 bytes |
1 | Total number of players in this file. Since players can join or leave, this can be more than 15. |
(2-20)*n | ASCII array of null-terminated player names, where n=number of players in the match at any time. |
8*n | Player userid s |
1*n | Player number s |
1*n | Player level s |
1 | total_round_count |
.5 | blue_round_score << 0 |
.5 | orange_round_score << 4 |
1 | Map Byte (described below) |
1 | Chunk compression method (0=none, 1=gzip, 2=zstd) |
Bytes | Data |
---|---|
4 | Number of chunks (int) |
4 * n | uint for the size of each chunk in order |
*
denotes fields that may or may not be included as a result of the inclusion bitmasks.
Bytes | Data |
---|---|
2 | 0xFEFC or 0xFEFE static header |
2, 8 | Unix time in milliseconds (long) (keyframe), or milliseconds since last frame (ushort). |
4 | game_clock float |
1 | Inclusion bitmask |
1* | game_status See Game Status table |
1* / 2* | Arena: blue_points 1 byte / Combat: blue_points 2-byte Half |
1* / 2* | Arena: orange_points 1 byte / Combat: orange_points 2-byte Half |
1* | Inputs |
6* | Pause and Restarts. |
7* | Last Score No continuous data |
26* | Last Throw No continuous data |
10* | VR Player |
16* | Disc |
9* | Combat Payload State (Only in Combat) |
1 | Team data bitmask |
??*3 | Team data (includes player data) |
1+?? | Bone data |
Below are estimated sizes for each frame. To get approximate full uncompressed file size, multiply the number by the number of frames. Average frame sizes are around 380 bytes experimentally.
Min | Max (8 players) | |
---|---|---|
Keyframe | 28 | 1112 |
Normal Frame | 23 | 1106 |
Below is the bit flags structure for the Map Byte
Bit number | Field name |
---|---|
0 | Public or Private, Public = 0, Private = 1 |
1-3 | Map name, see coding table below |
4-7 | Currently Unused Bits |
Index | Map Name |
---|---|
0 | uncoded |
1 | mpl_lobby_b2 |
2 | mpl_arena_a |
3 | mpl_combat_fission |
4 | mpl_combat_combustion |
5 | mpl_combat_dyson |
6 | mpl_combat_gauss |
7 | mpl_tutorial_arena |
2a/4c
represents 2 bytes in Arena, 4 bytes in Combat
Bit | Value | Bytes Saved |
---|---|---|
0 | game_status / blue/orange points / Inputs |
1+2a/4c+1 |
1 | Pause and restarts | 6 |
2 | Last Score | 7 |
3 | Last Throw | 26 |
4 | VR Player | 10 |
5 | Disc | 16 |
6 | Combat Payload State | 9 |
7 | Rules Changed | 4 + ASCII name |
Index | Map Name |
---|---|
0 | uncoded |
1 | --empty string-- |
2 | pre_match |
3 | round_start |
4 | playing |
5 | score |
6 | round_over |
7 | post_match |
8 | pre_sudden_death |
9 | sudden_death |
10 | post_sudden_death |
Bit | Value |
---|---|
0 | blue_team_restart_request |
1 | orange_team_restart_request |
2-3 | paused_requested_team See Team indices table |
4-5 | unpaused_team See Team indices table |
1-byte | paused_state See Paused state table |
2-byte | paused_timer |
2-byte | unpaused_timer |
This is 1 byte, but it only needs 3 bits
Index | Value |
---|---|
0 | unpaused |
1 | paused |
2 | unpausing |
3 | pausing??? |
4 | none (Combat) |
5 | unknown |
This is two bits
Index | Team |
---|---|
0 | Blue team |
1 | Orange Team |
2 | Spectator |
3 | None |
This is three bits
Index | Team |
---|---|
0 | unknown |
1 | BOUNCE_SHOT |
2 | INSIDE_SHOT |
3 | LONG_BOUNCE_SHOT |
4 | LONG_SHOT |
5 | SELF_GOAL |
6 | SLAM_DUNK |
7 | BUMPER_SHOT |
8 | HEADBUTT |
Bit | Value |
---|---|
0-1 | team See Team indices table |
2 | point_amount 0 = 2-pointer, 1 = 3-pointer |
3-7 | goal_type See Goal Type table |
1-byte | person_scored index of person who scored |
1-byte | assist_scored 0 = 2-pointer, 1 = 3-pointer |
2-byte | disc_speed half |
2-byte | distance_thrown half |
2-byte half-precision floats for each of:
- arm_speed
- total_speed
- off_axis_spin_deg
- wrist_throw_penalty
- rot_per_sec
- pot_speed_from_rot
- speed_from_arm
- speed_from_movement
- speed_from_wrist
- wrist_align_to_throw_deg
- throw_align_to_movement_deg
- off_axis_penalty
- throw_move_penalty
A bitmask containing 1 bit for each of:
- left_shoulder_pressed
- right_shoulder_pressed
- left_shoulder_pressed2
- right_shoulder_pressed2
It would possible to eliminate the velocity component and only make use of frame diffs and timing to calculate velocity, but velocity
Bytes | Value |
---|---|
2 | x position |
2 | y position |
2 | z position |
4 | Orientation |
2 | x velocity |
2 | y velocity |
2 | z velocity |
This could be reduced more if the max values of some of these were taken into account.
Bytes | Value |
---|---|
1 | contested |
1 | payload_checkpoint |
1 | payload_defenders |
2 | payload_multiplier |
2 | payload_distance |
2 | payload_speed |
Sum = 9
VR player is a simple pose value - 10 bytes.
This is used for any position and rotation value
Bytes | Value |
---|---|
2 | x position |
2 | y position |
2 | z position |
4 | Orientation |
Orientations are usually presented in the API as 3 sets of XYZ direction vectors, but this can be encoded in 4 bytes with minimal precision loss by using smallest three compression. The orientation is converted to a Quaternion, then the component with the smallest absolute value is replaced by its index (in 2 bits). The other three values are recorded using 10 bits each.
Bit | Value |
---|---|
0 | Blue team possession |
1 | Orange team possession |
2 | Blue team stats included |
3 | Orange team stats included |
4 | Spectator team stats included |
5 | unused |
6 | unused |
7 | unused |
Min | Max (4 players) | Max (5 players) |
---|---|---|
5 | 339 | 420 |
Bytes | Value |
---|---|
14* | Team Stats |
1 | Number of players on team (K) |
(4 to 81) * K | Player data |
Example JSON:
{
"stats": {
"points": 18,
"possession_time": 561.90625,
"interceptions": 0,
"blocks": 0,
"steals": 5,
"catches": 0,
"passes": 0,
"saves": 11,
"goals": 0,
"stuns": 169,
"assists": 8,
"shots_taken": 29
}
}
- Assists
- Blocks
- Catches
- Goals
- Interceptions
- Passes
- Points
- Saves
- Steals
- Shots Taken
- Possession Time (half-precision float)
- Stuns
4-69 bytes depending on what is included
Bytes | Value |
---|---|
1 | Player index (for this file) |
1 | playerid |
1 | Player state bitmask |
14* | Player Stats (if included) |
2* | Ping (if included) |
2* | Packet loss ratio (half) (if included) |
1* | Holding Left. If holding a player, the userid is converted to the player's file id (if included) |
1* | Holding Right If holding a player, the userid is converted to the player's file id (if included) |
6* | Velocity (if included) |
1 | Player pose bitmask |
10* | Head Pose |
10* | Body Pose |
10* | Left Hand Pose |
10* | Right Hand Pose |
1* | Combat loadout bitmask (only in Combat) |
Possession, Blocking, Stunned, Invulnerable
Bit number | Field name | Saved Bytes |
---|---|---|
0 | possession |
- |
1 | blocking |
- |
2 | stunned |
- |
3 | invulnerable |
- |
4 | is_emote_playing |
- |
5 | Contains changed stats | 14 |
6 | Contains changed ping/packetlossratio/holding_left/right Holding is never included in Combat |
2+2+1+1 |
7 | Contains changed velocity | 6 |
Bit number | Field name |
---|---|
0 | Head position changed |
1 | Head rotation changed |
2 | Body position changed |
3 | Body rotation changed |
4 | LHand position changed |
5 | LHand rotation changed |
6 | RHand position changed |
7 | RHand rotation changed |
Case | Value |
---|---|
none |
255 |
geo |
254 |
disc |
253 |
playerid |
The file id of the player |
Bit number | Field name |
---|---|
0-1 | Weapon |
2-3 | Ordnance |
4-5 | TacMod |
6 | Arm |
7 | unused |
Index | Weapon | Ordnance | TacMod | Arm |
---|---|---|---|---|
0 | rocket | stun | sensor | Left |
1 | blaster | det | wraith | Right |
2 | scout | burst | heal | |
3 | assault | arc | shield |
Bytes | Value |
---|---|
1-bit | Inclusion bit for all bone data |
5-bits | Number of players |
2-bits | unused |
??*N | Player bone data, where N is the number of players |
Bone data is stored as a diff from the previous frame. Positions are already local to the player, so no need to convert.
Bytes | Value |
---|---|
3 | Inclusion bitmask for this player's 23 bone positions |
3 | Inclusion bitmask for this player's 23 bone rotations |
0-138 | Player bone positions |
0-92 | Player bone rotations as smallest-three |
Future optimizations:
- Diff rotations
- Diff body/hands relative to head
- Reduce storage precision of diffed values if possible
- Use curve fitting for bone data