Skip to content

Replay track format ​

A track is a recording of where everything was during one replay: units, the pieces they aim with, projectiles, features, terrain and build queues. Coilbox records it in one headless run of the replay and plays it back with time going in either direction.

This page is the design of format version 1. It is written so that a second person can write a reader from it. The Rust crate crates/coilbox-track (issue #3781) reads and writes it. crates/coilbox-track-thin (issue #3782) makes a track from the recorder's raw dump. The engine side is the two milestones after "Replay rewind: record a match".

A track file has the extension .cbtrack. The same file is read by coilbox and by Lua inside the engine, so there is no export step and no second copy on disk.

The numbers on this page come from three places: the engine source, the two benchmark recordings from the spike in #3778, and measurements made for this design on those recordings. How the measurements were made lists the scripts.

What the engine's Lua can read ​

The format is shaped by what Lua in the engine can do. Each point was checked in RecoilEngine at commit 212c369b4c.

  • Numbers are 32 bit floats. rts/lib/lua/include/luaconf.h sets LUA_NUMBER to float. A whole number is exact only up to 16,777,216, which is 2 to the power 24. So no single value in a track is larger than 16,777,215, and a 32 bit word that packs several fields together cannot be read in one call.
  • VFS.UnpackU8, UnpackU16, UnpackU32, UnpackS8, UnpackS16, UnpackS32 and UnpackF32 take a string, a position that counts from 1, and a count. With a count they return a table of that many values in one call. They read fixed widths only, in the machine's byte order. UnpackType in rts/Lua/LuaVFS.cpp returns nothing when the position is the last byte of the string or beyond it.
  • VFS.ZlibDecompress is the only decompressor. It calls zlib's uncompress, so it reads a zlib stream as RFC 1950 describes, with the two byte header and the Adler-32 checksum. It does not read raw deflate or gzip. It raises a Lua error on a bad stream, and also when the result is empty.
  • zlib::inflate in rts/System/StringUtil.cpp starts with a 262,144 byte buffer. When the result does not fit it doubles the buffer and decompresses again from the start.
  • The io library is opened for unsynced Lua only (rts/Lua/LuaLibs.cpp). Synced Lua has VFS.LoadFile, which returns a whole file as one string.
  • file:seek takes its offset as a Lua number. An offset above 16,777,216 is exact only when it is a multiple of a large enough power of two.

File layout ​

All values are little endian. VFS.Unpack* reads in the machine's byte order and does not swap, so the engine's Lua can read a track on a little endian machine only. x86-64 and arm64 both are.

header          96 bytes
window index    16 bytes for each window
info chunk      names, teams and the map
features chunk  every feature of the match
window 0        main chunk, then a terrain chunk if terrain changed
window 1        ...

Each chunk is a zlib stream that starts at a multiple of 64 bytes from the start of the file. The gap before it is filled with zero bytes. The header and index are not compressed.

The file is cut into windows of a fixed number of frames. A window holds everything a reader needs for any frame inside it, so a seek reads one window and never the whole match.

Chunks start on 64 byte blocks so that Lua can address them. A block number below 16,777,216 times 64 is an offset that a 32 bit float holds exactly, which reaches 1,073,741,824 bytes. The padding this costs was 3,915 bytes in the larger benchmark.

96 bytes at offset 0.

OffsetTypeField
0char[4]magic, CBTK
4u16format version, 1
6u16header size in bytes, 96
8u16recorder version
10u16frames per second of the simulation. The engine's GAME_SPEED is 30
12u32first frame recorded
16u32last frame recorded
20u32window length in frames, 1 to 65,535, so that an offset in a window fits 16 bits
24u32number of windows
28f32tolerance in elmos. No recorded sample is further than this from the track, measured as the tolerance says
32u8position shift. The position step is 1 elmo divided by 2 to this power
33u8velocity shift. The velocity step is 1 elmo per frame divided by 2 to this power
34u8rotation bits, 8 to 16
35u8frames between the recorder's unit samples. For information only
36u32block number of the info chunk
40u32length of the info chunk in bytes
44u32block number of the features chunk
48u32length of the features chunk in bytes
52u32number of units in the match
56u32number of projectiles in the match
60u32number of features in the match
64u8[16]the replay's game id, copied from the replay header
80f32resource tolerance. No recorded team number that is thinned is further than this from the track, unless the resource fraction allows it more. 0 in a track from before recorder version 4
84f32resource fraction. A recorded team number may be this part of its own size from the track, when that is more than the resource tolerance. 0 in a track from before recorder version 4
88u8[8]reserved, zero

A block number times 64 is the chunk's offset in the file. Every u32 in the header and index is at most 16,777,215.

The number of windows is the frame span divided by the window length, rounded up, and at least 1. The frame span is the last frame minus the first, so a span of exactly one window length is one window. Window k starts at first frame + k * window length. It ends one window length later, or at the last frame if that comes first. A frame on the boundary between two windows is in both, and both give the same values for it.

The engine version, the game and the map are text and live in the info chunk.

Versions ​

A reader refuses a file whose magic is not CBTK or whose format version it does not know, and names the field and the value it found. This is what require_known_layout does for replay headers in crates/tauri-plugin-coilbox-content/src/demo.rs.

The header size is a minimum. A later writer may add fields after byte 96, so a reader finds the index at the header size the file states, not at 96.

A change that adds a column or a header field keeps version 1, because readers skip what they do not know. A change to the meaning of anything on this page is a new version.

The recorder version is separate. It changes when the recorder would produce different keys from the same replay, and when a track gains something a reader depends on. Issue #3786 uses it to mark a track as stale, and a stale track is recorded again.

Recorder versionWhat changed
2Pose keys are relative to the parent piece (issue #3783)
3The info chunk has a footprint for each unit definition, columns 181 and 182 (issue #3807). The format version is still 1, because a column was added and nothing changed its meaning
4The main chunk has the team numbers, columns 104 to 108, the header has their tolerance and fraction in bytes that were reserved, and the info chunk names the rules parameters, columns 183 to 186 (issue #3982). The format version is still 1. Columns and header fields were added, one of them of a new type, and a reader skips a type it does not know. Playback depends on the numbers, so a track without them is stale

Window index ​

The index starts at the header size. It has one 16 byte entry for each window, in order.

OffsetTypeField
0u32block number of the main chunk
4u32length of the main chunk in bytes
8u32block number of the terrain chunk, or 0
12u32length of the terrain chunk in bytes, or 0 when no terrain changed in the window

A reader that has the header and the index knows where every window is without touching one.

Chunks ​

A chunk is one zlib stream. Decompressed, it is a list of columns. A column is an array of one type: an integer of one size, or a 32 bit float.

u32   number of columns
      then for each column, 12 bytes:
u16     column id
u16     type: 1 u8, 2 u16, 3 s16, 4 u32, 5 s32, 6 f32
u32     offset of the column's data from the start of the decompressed chunk
u32     number of values
      then the data of each column
      then 4 zero bytes
  • Columns are listed in rising order of id, and an id appears once.
  • Each column's data starts at a multiple of 4 bytes and is padded with zero bytes to the next one.
  • A column that is not listed has no values. For a column with one value per unit, that means 0 for every unit.
  • A reader ignores a column id it does not know, and a column of a type it does not know.
  • A writer lists only columns that have values. It also leaves out a count column (ids 10, 11 and 12) whose value is 0 for every unit, as the worked example does.
  • A decompressed chunk is at most 16,777,215 bytes, so every offset in it is exact in Lua.
  • The 4 zero bytes at the end mean no column ends on the last byte of the chunk, which VFS.Unpack* cannot read from.
  • A zlib stream ends with a checksum of the data, and uncompress checks it. A chunk that fails to decompress is an error for that window only. The header, the index and every other window still read.

In Lua, one column is one call: VFS.UnpackS16(chunk, offset + 1, count).

How a column stores values ​

Columns of the same kind sit together, so position differences are beside position differences. That is what lets zlib compress them. Four ways of storing a value are used.

Plain. The value itself. Every f32 column is plain: the four bytes of the float, little endian, which is what VFS.UnpackF32 reads. Only the team numbers are floats. They are the engine's own floats and have no step to count in, since a team can hold a fraction of a unit of metal and more than 16,777,215 of it.

Time. A u16 number of frames since the previous key of the same curve. For a curve's first key in a window it is the number of frames since the window's first frame. Two keys of one curve never share a frame.

Difference with an escape. Two columns: a narrow s16 one with a value for every key, and a wide s32 one. To read a curve, keep a running value. For each key, take the next narrow value d. If d is -32768, the key's value is the next unread value of the wide column. Otherwise the key's value is the running value plus d. The first key of every curve in a window is written with the escape, so the wide column always gives the starting value. A wide value is between -16,777,215 and 16,777,215.

Wrapping difference. A u16 column. Keep a running value that starts at 0 for each curve. For each key the value is the running value plus the stored one, modulo 65,536. Where the value is signed, a result of 32,768 or more stands for that result minus 65,536. No escape is needed, because the values never leave 16 bits.

Units of measurement ​

  • A position, a piece offset and a height are whole numbers of position steps. The step is 1 / 2^position shift elmo.
  • A velocity is a whole number of velocity steps. The step is 1 / 2^velocity shift elmo per frame.
  • A piece angle is a whole number of 65,536ths of a turn. That is the engine's own unit for piece angles, COBSCALE in rts/Sim/Units/Scripts/CobInstance.h.
  • A frame is a simulation frame. A reader may ask for a time between two frames.

Rotation ​

A unit's rotation is a unit quaternion (x, y, z, w). It turns the unit's resting directions, front (0, 0, 1) and up (0, 1, 0), into its front and up directions in the world:

front = ( 2(xz + wy),  2(yz - wx),  1 - 2(xx + yy) )
up    = ( 2(xy - wz),  1 - 2(xx + zz),  2(yz + wx) )

A writer builds it from the recorded front and up. It takes right = up x front, makes the rotation matrix whose columns are right, up and front, and converts that to a quaternion in the usual way.

Three of the four components are stored. The one with the largest size is dropped, and the quaternion is negated first if that component is negative. The reader gets it back as sqrt(1 - a*a - b*b - c*c). The three kept ones, in the order x, y, z, w with the dropped one left out, are each stored as a signed whole number n:

n         = round( component * (2^(rotation bits - 1) - 1) / sqrt(1/2) )
component = n * sqrt(1/2) / (2^(rotation bits - 1) - 1)

A kept component is never larger in size than sqrt(1/2), so n fits in the number of bits the header gives.

Info chunk ​

One chunk, read once. A list of strings is two columns: the length of each string in bytes as u16, and the bytes of all of them end to end as u8. Strings are UTF-8 with no terminator.

IdTypeColumn
160, 161stringstrack text. In order: engine version, game name and version, map name, the name and version of the program that wrote the file
162, 163stringsunit definition names
164, 165stringsweapon definition names
166, 167stringsfeature definition names
168u16for each unit definition with posed pieces, its index in the unit definition names
169u16for each of those, how many posed piece names it has
170, 171stringsposed piece names, grouped in the order of column 168
172u8team id
173u8ally team of each team
174u8team flags. Bit 0 is set for an AI team
175u8team colour, three values for each team: red, green, blue
176, 177stringsside name of each team
178, 179stringsleader name of each team
180u32two values, the map's size along x and z in elmos
181u16for each unit definition, the size of its footprint across the unit, in elmos
182u16for each unit definition, the size of its footprint along the way the unit faces, in elmos
183, 184stringsnames of the team rules parameters the track has, as the game spells them
185, 186stringsnames of the game rules parameters the track has

Every other chunk refers to a definition by its index in these lists, counting from 0. A definition is stored by name because its id changes between builds of a game (issue #1176). A piece is stored by name for the same reason.

Only the definitions and pieces the track uses are listed. A writer may list more.

A footprint is the ground a unit takes up, and it is what the map view on the replay page sizes a unit's mark from (issue #3807). Columns 181 and 182 are in the order of the unit definition names. They are the engine's xsize and zsize for the definition, each times 8. The engine counts both in heightmap squares, a square is 8 elmos (SQUARE_SIZE in rts/Sim/Misc/GlobalConstants.h), and xsize is twice the definition's footprintX (rts/Sim/Units/UnitDef.cpp). A track written before recorder version 3 has neither column. A reader that needs a footprint treats such a track as stale and does not guess one.

The text of a definition is not stored. The game the track is played back in has it. The benchmark files spend 3,101,936 and 1,123,360 bytes on definitions and map data as text. The info chunks measured here are 2,130 and 619 bytes.

Features chunk ​

One chunk for the whole match, with one entry for each feature. Features appear and go but are not recorded as moving, so one list is smaller than repeating them in each window.

IdTypeColumn
128u16the engine's feature id
129u16feature definition
130u8team, or 255 for none
131u8flags. Bit 0: it was there before the first frame. Bit 1: it was still there after the last frame
132u32first frame it exists
133u32last frame it exists
134, 135, 136s32position x, y, z, plain
137u8rotation: which component is dropped
138, 139, 140u16rotation: the three kept components, plain. Each is the signed whole number n as a 16 bit two's complement value

A feature exists at frame f when first <= f <= last.

Main chunk ​

One for each window. Offsets in it are frames counted from the window's first frame.

Units ​

One entry for each unit that exists at any frame of the window.

IdTypeColumn
1u16the engine's unit id. The engine's MAX_UNITS is 32,000
2u32serial number, counting from 0 in order of first appearance in the match
3u16unit definition
4u8team
5u8flags. Bit 0: it existed before this window. Bit 1: it exists after this window
6u16offset of the first frame in this window at which it exists
7u16offset of the last frame in this window at which it exists
8u16number of transform keys
9u16number of status keys
10u8number of target tracks
11u16number of pose tracks
12u16number of build queue keys

The engine reuses unit ids, so the serial number is what identifies a unit across windows. Two entries in one window can share a unit id when one unit died and a later one took its id.

Every key column below holds the keys of the first unit, then the second, and so on. Within a unit, keys are in time order. Every key of a unit lies between its first and last offset.

Transform keys ​

At least one for each unit.

IdTypeColumn
16u16time
17, 18, 19s16position x, y, z, difference with an escape
20, 21, 22s32the wide columns for x, y and z
23u8rotation: which component is dropped, 0 to 3 for x, y, z, w
24, 25, 26u16rotation: the three kept components, wrapping difference, signed

Between two keys the position is a straight line. The rotation is the two quaternions blended in the same proportion and then scaled back to unit length, with the second one negated first when the dot product of the two is negative. A blend does not run at an even speed, so a window that starts part way along a blend, with a key added at its edge, matches the whole curve only to within rounding when the two keys are close in rotation. Keys kept by thinning are.

Status keys ​

At least one for each unit.

IdTypeColumn
32u16time
33u16health as a fraction of the unit's maximum at that frame, 65,535 for full. Wrapping difference
34u16build progress, 65,535 for complete. Wrapping difference
35u8flags, plain. Bit 0: the unit is switched on

Health and build progress run in a straight line between keys. Flags hold until the next key.

A writer clamps health to the range before storing it. In the larger benchmark 850 of 22,467 status keys had health above the maximum and 172 had it below zero.

Target tracks ​

A unit has one target track for each weapon that had a target in the window, and one for its builder work if it did any. A track that is missing means no target for the whole window. So a unit with one weapon costs one track, and a second weapon costs nothing until it has a target of its own.

Column 10 says how many tracks each unit has. The tracks of the first unit come first.

IdTypeColumn
40u8for each track, the weapon number from 1, or 0 for builder work. The engine's MAX_WEAPONS_PER_UNIT is 32
41u16for each track, its number of keys
42u16time
43u8kind
44u32reference, 0 when the kind has none
45u8for builder work, the build power in use, 255 for full. 0 for a weapon
46, 47, 48s32position x, y, z in position steps, plain. One entry for each key whose kind is 128 or more
KindMeaningReference
0nothing
1a unitindex in this window's units
2a featureindex in the features chunk
4a projectileindex in this window's projectiles
16building or repairing a unitindex in this window's units
17reclaiming a unitindex in this window's units
18reclaiming a featureindex in the features chunk
19resurrecting a featureindex in the features chunk
20capturing a unitindex in this window's units
128a positionnone. Takes the next entry of columns 46 to 48
129terraforming at a positionnone. Takes the next entry of columns 46 to 48

A target holds until the next key. A reader treats a kind it does not know as nothing, and still takes a position entry for it when the kind is 128 or more.

A writer refuses a key whose reference is not in the window it is written into, since the file cannot say what it points at.

The engine reports build power as a count out of 15 (MASK_BITS in rts/Sim/Misc/NanoPieceCache.h). A writer stores that count times 17.

A target that stops existing is followed by a key of kind 0 on the frame after its last. The engine goes on answering with a dead unit's id until the weapon picks again, so a recording does not say the target has gone, and without the key a later window would start with a target that is not in it.

Kind 129 needs a position. The engine does not tell Lua where a builder is terraforming, and the recorder has a place only for a builder whose own order names one. A builder that is helping another terraform is written as kind 0.

Pose tracks ​

A pose track is one aiming piece of one unit. It holds what a unit script set on the piece: three turn angles and three offsets, both relative to the piece's parent. They are the values a Lua unit script passes to Turn and Move for that piece, and gets back from GetPieceRotation and GetPieceTranslation.

Column 11 says how many tracks each unit has. A track that is missing means the piece is at rest for the whole window, with all six values 0.

The engine places a piece in its parent's space in two steps (S3DModelPiece::ComposeTransform in rts/Rendering/Models/3DModelPiece.cpp, at the commit named above):

  1. It moves the piece to the model's rest offset for it plus the three offsets.
  2. It turns the piece about its own origin by the three angles: about z first, then about x, then about y, each about the parent's axes. That is CQuaternion::FromEulerYPR in rts/System/Quaternion.cpp. As matrices that act on a column it is Ry * Rx * Rz, where each is the usual right handed turn about that axis.

A piece's place in the unit's model space is its parent's, then those two steps. Spring.GetUnitPieceMatrix gives that as a matrix, and it is what the recorder writes. coilbox-track-thin (issue #3783) takes the parent's matrix off, takes the rest offset off the translation, and takes the rotation apart into the three angles.

A reader sets the six values on the piece and gets the recorded pose. Four things to know:

  • A COB script's own numbers are not these. The engine negates a COB turn about z and a COB move along x before it sets them (CCobInstance::Turn and Move in rts/Sim/Units/Scripts/CobInstance.h). The track holds the values after that.
  • A rotation has two sets of three angles, (x, y, z) and (half a turn - x, y + half a turn, z + half a turn), and each angle can be any number of whole turns further on. The writer picks the set nearest the sample before, so the angles of two keys in a row are never far apart unless the piece turned that far. The engine itself keeps each angle between 0 and one turn, so a key's angles are the same rotation as the script's and not always the same numbers.
  • When the angle about x is a quarter turn, the rotation depends on the angles about y and z only through their difference or their sum. The writer keeps the angle about z it had on the sample before.
  • A model in a format that bakes a rest rotation into a piece, which Assimp and glTF models can do, has that rotation between the two steps, and the engine has no Lua call that reads it. For such a model the angles hold the rest rotation and the script's turn together. They still give the recorded pose when composed as above, and they are not what to pass to Turn. 3do and s3o models have no rest rotation, and every unit with a posed piece in the two benchmark matches has one of those. coilbox-track-thin reports the unit definitions this applies to.
IdTypeColumn
56u16for each track, the piece: an index into the posed piece names of the unit's definition
57u16for each track, its number of keys
58u16time
59, 60, 61s16angle about x, y, z, difference with an escape
62, 63, 64s32the wide columns for the angles
65, 66, 67s16offset x, y, z from the piece's rest position, difference with an escape
68, 69, 70s32the wide columns for the offsets

All six values run in a straight line between keys. An angle is not reduced to one turn when it is read. If two keys are a turn and a half apart, the piece turns a turn and a half between them.

An angle cannot be stored without limit, because a piece that spins all match long passes 16,777,215 steps. The largest angle in the larger benchmark is 887.44 radians, which is 141 turns. So in each window a writer shifts every key of a track by the same whole number of turns, chosen so that the track's first key in that window is between -32,768 and 32,767. The shift is chosen for each of the three angles on its own, so the three angles of one key may be shifted by different numbers of turns. The shift does not change what is drawn. A reader never compares an angle in one window with an angle in another.

Build queue keys ​

Column 12 says how many keys each unit has. A unit with none has an empty queue for the whole window.

IdTypeColumn
72u16time
73u8for each key, the number of items in the queue
74u16for each item, the unit definition
75, 76s32for each item, position x and z in position steps, plain
77u8for each item, the facing, 0 to 3

A queue holds until the next key.

Projectiles ​

One entry for each projectile that exists at any frame of the window.

IdTypeColumn
80u32serial number, counting from 0 in order of first appearance in the match
81u16weapon definition, or 65,535 for a projectile that has none
82u8team, or 255 for none. A piece of debris can have none
83u8flags. Bit 0: it existed before this window. Bit 1: it exists after this window
84u16the unit that fired it, as an index in this window's units, or 65,535. A unit that is not in the window counts as none
85u16offset of the first frame in this window at which it exists
86u16offset of the last frame in this window at which it exists
87u16number of keys
88u16time
89, 90, 91s16position x, y, z, difference with an escape
92, 93, 94s32the wide columns for position
95, 96, 97s16velocity x, y, z, difference with an escape
98, 99, 100s32the wide columns for velocity

Coilbox's writer puts the entries in order of serial number, which is the order they were born in, so their first offsets never fall. That is not a rule of the format. A reader that searches the entries by first offset has to check the order first, as the Lua reader does.

The engine's projectile id is not stored. The engine's MAX_PROJECTILES is 128,000, which does not fit 16 bits, and playback makes its own projectiles. The serial number is what identifies one across windows.

A projectile with no weapon definition is a piece of debris. They are 1,895 of the 20,609 projectiles in the larger benchmark and 349 of 627 in the smaller.

A projectile has a key at its first offset and at its last. Between two keys n frames apart, with positions p0 and p1 and velocities v0 and v1, the position at the fraction s of the way from the first to the second is the cubic of issue #3784:

p(s) = (2s^3 - 3s^2 + 1) p0 + (s^3 - 2s^2 + s) n v0 + (-2s^3 + 3s^2) p1 + (s^3 - s^2) n v1

The velocity is the slope of that curve, divided by n.

Team numbers ​

What each team holds, the rest of its economy, and the rules parameters a game keeps its own numbers in (issue #3982). Each number of each team is one track: a list of keys, each a frame and a value. Playback sets the engine's own team resources from them, so a resource bar shows the match and not the playback.

IdTypeColumn
104u8for each track, the team's id, or 255 for a number that is the game's and no team's
105u16for each track, the code of the number
106u16for each track, its number of keys
107u16time
108f32value, plain

The tracks of a window are in rising order of team and then of code, and a team and code appear once. The keys of all the tracks follow each other in columns 107 and 108, in the order of the tracks.

CodeNumber
0metal the team holds
1metal storage
2metal pull
3metal income
4metal expense
5metal share level, 0 to 1. The engine hands what a team holds above this part of its storage to its allies
6metal sent to other teams
7metal received from other teams
8metal excess, which is what was wasted because storage was full
16 to 24the same nine for energy
256 and upa rules parameter: 256 plus its index in the info chunk's names, counting from 0. Column 183 for a team's and column 185 for the game's

Codes 0 to 8 are in the order Spring.GetTeamResources gives its nine numbers. Pull, income, expense, sent, received and excess are the engine's own figures, which are sums over the 30 frames before it last added a team's economy up (TEAM_SLOWUPDATE_RATE in rts/Sim/Misc/GlobalConstants.h). They are not rates per second.

The recorder reads every team on every frame and writes a number when it changes. A reader holds a number until it changes, so every frame has a sample of every number.

A value at a frame between two keys is on the straight line between them. Before a track's first key in a window its value is that key's, and after its last key that key's, with one exception: a rules parameter has no value before the frame of its first key. That is a parameter the match had not set yet, which is not the same as one set to 0.

A resource with no track in a window is 0 on every frame of it. A team with nothing has no tracks at all. A rules parameter with no track in a window had not been set by the window's last frame.

A parameter that was set before a window has a key on the window's first frame, even where the general rule for a writer would leave that key out. Without it a reader would take the parameter as not set until the window's first key.

How the keys are chosen depends on the number.

  • Storage, share level and every rules parameter are kept exactly. The track has the recorded value on every frame. These are numbers that change in steps, and a supply of 12 is not nearly 13.
  • The other numbers are thinned. A recorded sample may be the header's resource tolerance from the track, or the resource fraction of its own size when that is more: max(tolerance, fraction * |sample|). Coilbox writes a tolerance of 0.5 and a fraction of 1/1024. A resource bar draws whole numbers, so half a unit is the least it can show. The fraction is for a late game, where a team holds a hundred thousand and half a unit is far below anything drawn.

A rules parameter is kept only when it is a number. The engine holds a rules parameter as a boolean, a 32 bit float or text (rts/Lua/LuaRulesParams.h), and a resource as a 32 bit float, so an f32 column holds every value exactly as the engine had it.

A reader of a track from before recorder version 4 finds none of these columns, no names in the info chunk, and zero in the header's two fields.

  • The Rust reader gives a window with no team numbers and an info with no parameter names. Header::value_allowance is then 0 for every sample.
  • The Lua reader gives a window whose valueCount is 0, so Window:valueOf is nil for every team and code, and resourceTolerance and resourceFraction of 0.
  • The TypeScript reader in the app does not read the file. It reads windows the Rust side has decoded and sent again with only the columns the map view draws, which have never included these. It reads a track of either age the same.

The player sets no team's resources from a track older than recorder version 4, and says so in the log. Coilbox takes such a track as stale and records the replay again.

Terrain chunk ​

One for each window in which the heightmap changed. It has one row for each height that changed, sorted by frame, then z, then x.

IdTypeColumn
112u16frames since the previous row. For the first row, frames since the window's first frame
113u16heightmap x, as a wrapping difference from the previous row
114u16heightmap z, as a wrapping difference from the previous row
115s32height before the change in position steps, as a difference from the previous row's height before. The first row's is a difference from 0
116s32height after minus height before. Never 0

Heightmap x and z count heightmap corners, which are 8 elmos apart (SQUARE_SIZE). All three running values start at 0 at the top of each chunk.

A row belongs to one window only. Window 0 holds the rows at frames from the first frame to its last frame, and every later window the rows after its first frame up to its last frame. So a reader that has shown frame a and wants frame b reads the terrain chunks of the windows from the one holding a to the one holding b, and applies the rows between the two frames.

A writer keeps three rules.

  1. At most one row for a point in a frame.
  2. A change that rounds to nothing is not written. At a step of an eighth of an elmo that drops 58,084 of the 168,736 rows in the larger benchmark.
  3. A row's height before is the height after of the previous row for that point, in any window. For a point's first row it is the map's own height. The writer works the height before out from its own record of what it has written, and does not copy it from the recorder.

Rule 3 is what makes playing backwards land where playing forwards started. The benchmark's recorder does not keep it: in 75,480 of its 168,736 rows the height before is not the height after of the row before it at that point, and the largest gap is 16.86 elmos.

To move from frame a to a later frame b, a reader sets each row's point to its height after, for every row with a frame after a and up to b, in order. To move back it sets each point to its height before, in reverse order. A seek across several windows reads the terrain chunks in between and not their main chunks. That is why terrain has a chunk of its own.

Terrain is not the same from run to run ​

Two recordings of one replay gave 169,161 and 168,736 terrain rows, though the simulation was identical. The cause in the benchmark's recorder is not known.

One fact from the engine may explain it. The engine tells Lua about heightmap changes through UnsyncedHeightMapUpdate, and CReadMap::UpdateDraw sends at most 128 rectangles each time it draws (MAX_UHM_RECTS_PER_FRAME in rts/Map/ReadMap.cpp). A recorder that listens there hears about a change at a time that depends on drawing, not on the simulation. Whether the benchmark's recorder does that was not checked.

The format does three things about it.

  • Terrain is kept out of the main chunks. Nothing in a main chunk depends on terrain, so two recordings of one replay can be compared byte for byte on their main chunks.
  • The rules above remove the differences that are only noise: rows that round to nothing, and a height before that does not follow from the row before.
  • It does not promise that two recordings have the same terrain rows. A test compares the heights a track ends with, not its rows.

Coilbox's own recorder, issue #3780, listens there, and its rows differ from run to run for that reason: two recordings of the larger benchmark match had 272,135 and 271,996 terrain rows. It also reads the whole heightmap on the first frame, on every 900th frame and at the end, so the ground at those frames is the same in every recording. lua/rewind-recorder/README.md has the detail.

Reading a frame ​

To give every value at frame f:

  1. Work out the window: k = floor((f - first frame) / window length), and the last window if k is past the end.
  2. Read that window's main chunk if it is not the one already loaded. Seek to its block number times 64, read its length in bytes, and decompress.
  3. A unit or projectile exists when first offset <= f - window start <= last offset.
  4. For each curve, find the keys either side of f. Before a curve's first key in the window its value is that key's value. After its last key its value is that key's value.
  5. A feature exists when its first and last frame say so.
  6. For terrain, apply the rows between the frame last shown and f.
  7. A team number is its track's value at f. A resource with no track in the window is 0. A rules parameter with no track, or with a first key after f, is not set.

A reader that holds a whole track as one Lua string can only address the first 16,777,216 bytes of it exactly. Past that it either needs io, with file:seek to the block and file:read of the length, or has to cut the string into pieces short enough to address. The Lua reader in lua/rewind-player takes the way bytes are fetched as a function. Its string fetch cuts the string, since the player runs in synced Lua, which has no io.

Rules for a writer ​

  • For each curve and each window the unit or projectile exists in, copy the keys that fall in the window. If no key sits on the first offset at which it exists in the window, work out the curve's value there and add it as a key, unless the first copied key rounds to the same value. Do the same at the last offset. A transform curve and a status curve always have at least one key. A projectile always has a key at both ends.
  • A reader must get the same value for any frame of a window from that window alone as it would from the whole curve. The added keys are what make that true.
  • The check in issue #3782, that no dropped sample is further from the track than the tolerance, runs against the rounded values a reader will see. It does not run against the values before rounding. coilbox-track-thin reads each window of the file it has made and measures every recorded sample against what the reader's curve functions give from that window's keys.
  • A recorded sample that falls on a window's edge is kept as a key. When none falls on an edge, the samples either side of it are kept. Then no window has a value between two samples that was worked out from the curve and rounded a second time, which could put a sample up to one position rounding, 0.108 elmo, further from the track than thinning measured.
  • Of three keys in a row with the same value, the middle one is left out, for every curve that runs in a straight line between keys. A unit that stands still for the whole match has one transform key, and the keys above give it one in each window.
  • A value that does not fit is an error, not a silent wrap. That covers a position, a velocity, an offset, a height and every value a column stores, including a difference from the key before. Nothing is clamped except health and build progress, which go to the range 0 to 65,535 as the status keys say.
  • A team number's track is cut into windows the same way: the keys that fall in the window, and a key worked out from the whole curve at an end no key sits on. A resource track that is 0 for the whole of a window is left out of it. A rules parameter has no key before the frame it was first set on, and has one on the first frame of every window after that.
  • A team number that is not a finite number is an error. So is a rules parameter the info chunk does not name, and two tracks for one team and code.
  • Any zlib level reads the same. Level 9 was 1.6% smaller than level 6 on the larger benchmark.

What the tolerance measures ​

The header's tolerance is one distance in elmos. How far a recorded sample is from the track depends on what was sampled.

  • A unit. The distance between the recorded position and the track's, plus the unit's radius times the angle in radians between the recorded rotation and the track's. No point within the radius of the unit's centre is further than that from where it was recorded, so one tolerance holds at a unit's centre and at the end of its barrel. The radius is the unit definition's, UnitDefs[id].radius, which the recorder writes in the dump's header.
  • A piece. The distance between the recorded offset and the track's, plus the piece's reach times the angle in radians between the recorded rotation and the track's. Both are relative to the piece's parent, as a pose key is. The reach is how far the model's geometry gets from the piece's origin, counting the piece's own geometry and that of every piece below it, with all of them at rest. So no point of that geometry is further than the tolerance from where a script's values for this piece put it. A piece with no geometry on it or below it, such as the point a barrel fires from, has no reach of its own and takes the unit's radius. The recorder writes each piece's rest offset and the box round its geometry in the dump's piece tree.
  • A piece is measured against its parent and not against the unit. A barrel on a sleeve on a turret is drawn with the error of all three, so in the unit's model space a point on it can be up to three tolerances from where it was recorded. How far the pieces of the benchmark tracks are in model space was not measured.
  • A projectile. The distance between the recorded position and the cubic's. Velocity has no tolerance. Its worst error is reported, and Projectile velocity has what it came to.
  • Health and build progress. These are fractions and not distances, so the header's tolerance does not apply. A sample is within one step of the 65,535 a fraction is stored in.
  • Flags, targets, build queues, teams, and the frames a unit or a projectile exists on have no tolerance. The track has what was recorded or it is wrong.
  • A team number. These are amounts of metal and energy and not distances, so they have the header's resource tolerance and resource fraction and not its tolerance in elmos. A thinned sample is within max(resource tolerance, resource fraction * |sample|) of the track. Storage, share level and rules parameters are not thinned, and the track has what was recorded. Team numbers has which is which.

A track that is further from a recorded sample than its tolerance is a failed recording. coilbox-track-thin writes no file for one and names the sample.

Choosing the steps and the window length ​

The steps and the window length are header fields, so a writer can change them without a new format version. These are the rules a version 1 writer uses, and the measurements behind them. The tolerance of both benchmarks is 0.5 elmo.

Position step ​

Rule: the largest power of two step whose rounding moves a point no further than a quarter of the tolerance. Rounding each of three axes by up to half a step moves a point by up to step * sqrt(3) / 2. For a tolerance of 0.5 that gives a step of one eighth of an elmo, and a position shift of 3.

A coarser step makes each key smaller but uses up tolerance, so thinning keeps more keys. Both were measured on the larger benchmark. The keys column is unit positions thinned to 0.5 elmo with the kept keys rounded to the step. The size column is the whole track laid out as this page describes.

Step in elmosLargest rounding errorPosition keys keptAgainst no roundingTrack size in bytes
no rounding0254,777
1/320.0271254,987+0.08%5,264,256
1/160.0541255,248+0.18%5,037,056
1/80.1083255,812+0.41%4,805,376
1/40.2165258,035+1.28%4,569,728
1/20.4330265,497+4.21%4,329,344

Each halving of the step costs between 227,200 and 240,384 bytes and saves a fraction of a percent of keys, so size alone favours a coarse step. The quarter rule is a limit on how much of the stated tolerance is rounding. At one eighth the worst case is 22% of it. At one quarter it would be 43%.

At one eighth of an elmo a 16 bit difference reaches 4,095 elmos. The largest move between two keys of one unit is 841 elmos in the larger benchmark and 1,795 in the smaller, so no position needed an escape after a unit's first key in either.

Rotation bits ​

Rule: the smallest width at which rounding cannot move a point at the unit's edge further than a quarter of the tolerance. Rounding turns a unit by at most sqrt(6) / (2^(bits - 1) - 1) radians. The writer multiplies that by the largest radius of any unit in the match, since a unit's radius is the length issue #3782 weights its rotation by, and picks the smallest width that keeps the result within a quarter of the tolerance. A unit definition that the game has and the match does not use is not counted: the largest radius of any definition is 150 elmos in BAR and 228 in Splinter Faction.

Measured over every transform key of the larger benchmark, with the unit's radius as the length:

BitsLargest turn from rounding, radiansThe rule's bound, radiansLargest movement at the unit's radius, elmos
100.0035500.0047930.0987
110.0018120.0023940.0389
120.0008680.0011970.0182
130.0004370.0005980.0122
160.0000550.0000750.0016

The largest unit radius is 85 elmos in the larger benchmark and 130 in the smaller. The rule gives 12 bits for the first and 13 for the second. Going from 11 bits to 12 cost 98,240 bytes on the larger benchmark, and from 12 to 13 cost 95,424.

Rotation rounding costs almost no keys. Issue #3782 thinned both recordings at each width, with everything else the same. 16 bits is the widest the format has and stands in for no rounding.

BitsBAR transform keys keptAgainst 16 bitsBAR track in bytesSplinter Faction transform keys keptSplinter Faction track in bytes
10368,826+0.35%7,418,39129,120330,729
11367,992+0.12%7,532,75929,070338,071
12367,658+0.03%7,646,67929,040345,159
13367,554+0.00%7,755,28729,044352,362
14367,518-0.01%7,863,63929,040358,714
16367,5478,079,06329,042371,924

Each bit from 10 to 14 costs between 108,352 and 114,368 bytes on the BAR track and saves at most 834 keys, so size alone favours a narrow width, as it does for the position step. The quarter rule is what keeps the width up.

Velocity step ​

Rule: the largest power of two step whose rounding moves the cubic no further than a quarter of the tolerance over a gap of one window length. Rounding both velocities by up to half a step moves the curve by up to window length * step * sqrt(3) / 8. For a tolerance of 0.5 and a window of 900 frames that gives a step of 1/2048 elmo per frame, and a velocity shift of 11.

The fastest projectile in the larger benchmark moves 608 elmos in a frame. A wide value reaches 8,191 elmos per frame at this step.

Window length ​

Rule: 900 frames, which is 30 seconds.

A window stands alone, so every unit alive at its start costs a starting value in each window. Short windows pay that often. Measured on the larger benchmark:

Window length in framesTrack size in bytesLargest main chunk, decompressed
1508,509,504186,996
3006,365,056293,316
4505,626,752263,068
6005,218,176334,032
9004,805,376462,824
1,2004,581,504597,460
1,8004,342,784857,956
3,6004,066,6241,496,812

900 is the longest of these at which the largest window still fits the engine's second buffer of 524,288 bytes, so the engine decompresses it at most twice. No length in the table fits the first buffer of 262,144 bytes in every window.

How long the engine's Lua takes to unpack a window was not measured, and that is what should set the length in the end. The length is in the header so it can change when the playback milestone measures it.

Worked example ​

One window of a track with a window length of 900, a position shift of 3 and 12 rotation bits. It holds one unit, engine id 7983, that exists for the whole window and beyond it. The unit stands at (724, 220.5, 800) facing along z. At offset 300 it starts to move. At offset 420 it is at (784, 221.25, 800) and has turned 60 degrees towards x. It is at full health, complete and switched on.

The columns, as a writer builds them:

IdTypeValuesWhat they are
1u167983unit id
2u320serial number
3u160unit definition
4u80team
5u82flags: exists after this window
6u160first offset
7u16900last offset
8u163transform keys
9u161status keys
16u160 300 120time
17s16-32768 0 480x
18s16-32768 0 6y
19s16-32768 0 0z
20s325792wide x
21s321764wide y
22s326400wide z
23u83 3 3dropped component: w
24u160 0 0kept component x
25u160 0 1447kept component y
26u160 0 0kept component z
32u160status time
33u1665535health
34u1665535build progress
35u81flags: switched on

Columns 10, 11 and 12 are left out, so the unit has no target tracks, pose tracks or queue keys.

The chunk before compression is 420 bytes: 4 for the column count, 288 for 24 directory entries, 124 of data and 4 zero bytes.

0000  18 00 00 00 01 00 02 00 24 01 00 00 01 00 00 00
0010  02 00 04 00 28 01 00 00 01 00 00 00 03 00 02 00
0020  2c 01 00 00 01 00 00 00 04 00 01 00 30 01 00 00
0030  01 00 00 00 05 00 01 00 34 01 00 00 01 00 00 00
0040  06 00 02 00 38 01 00 00 01 00 00 00 07 00 02 00
0050  3c 01 00 00 01 00 00 00 08 00 02 00 40 01 00 00
0060  01 00 00 00 09 00 02 00 44 01 00 00 01 00 00 00
0070  10 00 02 00 48 01 00 00 03 00 00 00 11 00 03 00
0080  50 01 00 00 03 00 00 00 12 00 03 00 58 01 00 00
0090  03 00 00 00 13 00 03 00 60 01 00 00 03 00 00 00
00a0  14 00 05 00 68 01 00 00 01 00 00 00 15 00 05 00
00b0  6c 01 00 00 01 00 00 00 16 00 05 00 70 01 00 00
00c0  01 00 00 00 17 00 01 00 74 01 00 00 03 00 00 00
00d0  18 00 02 00 78 01 00 00 03 00 00 00 19 00 02 00
00e0  80 01 00 00 03 00 00 00 1a 00 02 00 88 01 00 00
00f0  03 00 00 00 20 00 02 00 90 01 00 00 01 00 00 00
0100  21 00 02 00 94 01 00 00 01 00 00 00 22 00 02 00
0110  98 01 00 00 01 00 00 00 23 00 01 00 9c 01 00 00
0120  01 00 00 00 2f 1f 00 00 00 00 00 00 00 00 00 00
0130  00 00 00 00 02 00 00 00 00 00 00 00 84 03 00 00
0140  03 00 00 00 01 00 00 00 00 00 2c 01 78 00 00 00
0150  00 80 00 00 e0 01 00 00 00 80 00 00 06 00 00 00
0160  00 80 00 00 00 00 00 00 a0 16 00 00 e4 06 00 00
0170  00 19 00 00 03 03 03 00 00 00 00 00 00 00 00 00
0180  00 00 00 00 a7 05 00 00 00 00 00 00 00 00 00 00
0190  00 00 00 00 ff ff 00 00 ff ff 00 00 01 00 00 00
01a0  00 00 00 00

Reading it:

  • The first four bytes, 18 00 00 00, say there are 24 columns.
  • The first directory entry is 01 00 02 00 24 01 00 00 01 00 00 00: column 1, type 2 which is u16, at offset 0x124, with 1 value. At 0x124 is 2f 1f, which is 7983.
  • The entry for column 17 is at 0x7c: 11 00 03 00 50 01 00 00 03 00 00 00. Column 17, type 3 which is s16, at offset 0x150, with 3 values. At 0x150 is 00 80 00 00 e0 01, which is -32768, 0 and 480.
  • Key 0. Time 0, so offset 0. The x value is the escape, so x is the first wide value, 5792 steps, which is 724 elmos. In the same way y is 1764 steps, 220.5 elmos, and z is 6400 steps, 800 elmos. The kept components are 0, 0 and 0, and w is dropped, so the quaternion is (0, 0, 0, 1). Front is (0, 0, 1).
  • Key 1. Time 300, so offset 300. Every difference is 0, so nothing has changed.
  • Key 2. Time 120, so offset 420. The x value is 5792 + 480 = 6272 steps, 784 elmos. The y value is 1764 + 6 = 1770 steps, 221.25 elmos. The kept y component is 0 + 1447, and 1447 * sqrt(1/2) / 2047 is 0.49985. So w is sqrt(1 - 0.49985^2), 0.86611, and front is (0.86585, 0, 0.50031). The unit was recorded with a front of (0.86603, 0, 0.5).
  • At offset 360, half way between keys 1 and 2, x is 754 elmos.
  • From offset 420 to 900 the unit holds the value of key 2. In a real track it would have a key at offset 900 if it were still moving.

Compressed at level 9 the chunk is 194 bytes and starts 78 da. The Adler-32 of the 420 bytes is 3f8d1533.

Projected size ​

Both benchmark recordings were laid out as this page describes and compressed, using a window of 900 frames, a position shift of 3, a velocity shift of 11 and zlib level 9. Rotation is 12 bits for BAR and 13 for Splinter Faction, by the rule above.

The keys are the ones the benchmark's own thinning kept. The key counts are the spike's. "Written" is higher than the spike's count because of the keys added at window boundaries.

BAR, 82,496 frames ​

WhatCount in the spikeWrittenBytes each before compressionBytes beforeBytes compressed
Unit entries, 92 windows1,367 units24,62023566,260175,366
Transform keys328,679353,03115, and 12 more for a unit's first in a window5,590,9052,224,426
Status keys22,46745,6867319,80292,222
Target keys33,06435,1468, and 3 for each track291,51564,854
Pose keys422,060433,67614, and 24 more for a track's first in a window, and 4 for each track6,484,256811,962
Build queue keys and items1,325 and 1,1762,888 and 3,5053 and 1147,21929,604
Projectile entries20,60921,13916338,224134,341
Projectile keys145,332146,15814, and 24 more for a projectile's first in a window2,553,5481,039,457

The compressed figure for each row is that row's columns compressed on their own, to show where the bytes go. The total below is from compressing each window whole, which is what a track does.

PartBytes
92 main chunks, 16,279,132 bytes before compression4,641,024
43 terrain chunks, 110,652 rows of 14 bytes150,080
Features chunk, 636 features10,496
Info chunk2,176
Header and index1,600
Total4,805,376

The benchmark file for the same match is 37,187,696 bytes. 37,187,696 / 4,805,376 is 7.74.

The features chunk that was measured stores a heading where this page stores a rotation and a team. That is 6 more bytes a feature before compression, 3,816 bytes for this match.

Splinter Faction, 23,091 frames ​

WhatCount in the spikeWrittenBytes beforeBytes compressed
Unit entries, 26 windows57 units82919,0676,753
Transform keys26,17626,966414,438183,972
Status keys1,5582,26915,8834,170
Target keys1,7911,81714,7852,648
Pose keys125,284125,9151,785,77025,156
Build queue keys and items38 and 7254 and 1051,317963
Projectile entries and keys627 and 5,926631 and 5,930108,75648,327
PartBytes
26 main chunks, 2,378,424 bytes before compression283,648
Terrain chunks0
Features chunk, 172 features2,432
Info chunk640
Header and index512
Total287,232

The benchmark file for the same match is 5,965,824 bytes. 5,965,824 / 287,232 is 20.77.

The target ​

A track is 7.7 times smaller than BAR-Rewind's file for the BAR benchmark match, at 4.8 MB against 37.2 MB, and 20.8 times smaller for the Splinter Faction one, at 287 KB against 6.0 MB. That is 105 KB for each minute of the BAR match against 811 KB.

BAR-Rewind's README gives 108 MB for a 53 minute match. If the BAR multiple held for that match its track would be 14 MB. That match was not measured.

What will move these figures ​

  • Projectiles as cubics, issue #3784. A throwaway thinner run on the raw projectile samples kept 54,426 keys with the cubic against 146,192 with straight lines for BAR, and 1,759 against 5,836 for Splinter Faction. With those keys the BAR track is 4,342,656 bytes, 8.56 times smaller than the benchmark file, and the Splinter Faction track is 261,824 bytes, 22.79 times smaller.
  • The thinning of issue #3782 is not the benchmark's, and keeps a different number of keys. The next section has what it kept.
  • The poses of issue #3783 are not the benchmark's. This projection uses the six values the benchmark stores for each pose key. Measured with the poses of issue #3783 has what they came to.
  • A target curve for each weapon, issue #3790. The benchmark records one weapon for each unit. Measured with the projectiles of issue #3784 and the weapons of issue #3790 has what it came to.

Where Splinter Faction's pose keys went ​

The spike found that Splinter Faction's poses hardly thinned: 90.45% of samples survived, against 10.41% in BAR.

The measurements explain what the keys are. 125,157 of the 125,284 pose keys, 99.90%, belong to one unit definition, fedstorm, and to four of its pieces: rthigh, rfoot, rleg and pelvis. There were 19 of that unit. 99.79% of the keys fall while the unit is moving, 99.70% come within 6 frames of the key before, and at 69.79% of them a piece's turn reverses. That is a walk cycle sampled every 3 frames. A straight line cannot stand in for a swing that reverses every few samples, so nearly every sample is kept.

BAR's pose keys are mostly the same thing: 90.32% fall while the unit is moving and 72.15% come within 6 frames of the key before.

Why the recorder treats leg pieces as aiming pieces was not looked at.

The format does nothing special for this and does not need to. A walk cycle repeats, and zlib is good at repeats. The pose columns compress 74 times for Splinter Faction, from 1,782,490 bytes to 23,935, and 8 times for BAR, from 6,426,272 to 787,232. Poses are 58.80% of the Splinter Faction benchmark file and 8.8% of its projected track.

Measured with the thinning of issue #3782 ​

coilbox-track-thin made a track from a recording of each benchmark replay by coilbox's own recorder, at a tolerance of 0.5 elmo, on 13 October 2026. Every recorded sample was then measured against the file. These are real files and not projections.

The poses in these two tracks were a stand-in: each piece's rotation and position in the unit's model space. The next section has the same two matches with the poses this page describes, and those are the figures to use.

The recordings are bar-3.dump, 661,354,318 bytes, and sf-3.dump, 23,124,566 bytes. They are new recordings of the two benchmark replays, made because thinning needs each unit's radius and the dumps before them did not have it.

"Kept" is what thinning kept over the whole match. "Written" is what the file holds, counted over every window, so it has the keys added at window edges and counts a unit once for each window it is in.

BAR keptBAR writtenSplinter Faction keptSplinter Faction written
Units, or unit entries1,36824,61957829
Transform keys367,658389,96529,04429,730
Status keys35,85558,0432,1812,919
Target tracks and keys664 and 23,2153,769 and 26,98424 and 1,58083 and 1,663
Pose tracks and keys4,312 and 1,503,67950,689 and 1,546,293245 and 136,1163,828 and 139,388
Build queue keys and items1,508 and 1,1683,296 and 3,51138 and 7260 and 105
Projectiles and keys20,609 and 54,65421,113 and 55,158627 and 1,769631 and 1,773
Features637637173173
Terrain rows271,409155,8594,9394,311

The samples they were thinned from: 6,998,315 unit samples, 4,332,299 piece rows and 480,653 projectile rows for BAR, and 233,770, 149,811 and 26,859 for Splinter Faction.

BARSplinter Faction
Track7,646,679 bytes352,362 bytes
Projected above4,805,376 bytes287,232 bytes
BAR-Rewind's file for the same match37,187,696 bytes, 4.86 times the track5,965,824 bytes, 16.93 times the track
Main chunks7,404,163 bytes, from 32,181,872340,490 bytes, from 2,636,620
Terrain chunks220,569 bytes6,452 bytes
Largest main chunk, decompressed937,916 bytes319,548 bytes
Furthest unit sample from the track0.49999689 elmo0.49989960 elmo
Furthest piece sample0.49999757 elmo0.49999905 elmo
Furthest projectile sample0.49983939 elmo0.49954246 elmo
Furthest health or build progress sample, in steps of 1/65,5350.9960.566
Time to scan, thin, check and write, on an Apple M1 Pro10.16 s0.80 s
Peak memory, the maximum resident set size from /usr/bin/time -l193,036,288 bytes32,358,400 bytes

Each section's columns compressed on their own, as in the projection:

SectionBARBAR projectedSplinter FactionSplinter Faction projected
Unit entries49,209175,3661,9856,753
Transform keys2,368,0792,224,426189,619183,972
Status keys86,55592,2223,5704,170
Target keys52,00964,8541,6272,648
Pose keys3,780,198811,96290,37425,156
Build queue keys and items16,40829,604404963
Projectile entries and keys693,2761,173,79822,93848,327

Where the real figures differ from the projection ​

  • Poses are the whole of it. The BAR track is 2,841,303 bytes over its projection, and its pose columns alone are 2,968,236 bytes over theirs. The Splinter Faction track is 65,130 bytes over, and its pose columns 65,218. Thinning kept 1,503,679 pose keys for BAR where the benchmark's own thinning kept 422,060.
  • The poses in these tracks are a stand-in: a pose key holds the piece's rotation and position in the unit's model space. In model space a piece moves whenever any piece above it does, and that is why it keeps three and a half times the keys. The next section measured it.
  • Transform keys are 6.5% over for BAR and 3.1% over for Splinter Faction. The projection used the benchmark's keys, which were thinned by another program to a rule this page does not have.
  • Projectiles are thinned against the cubic, since that is what a reader draws, so they are already at the size issue #3784 was expected to bring: 54,654 keys for BAR where a throwaway thinner kept 54,426, and 1,769 where it kept 1,759.
  • Terrain is 70,489 bytes over for BAR. Coilbox's recorder writes more terrain rows than the benchmark's did, 271,409 against 168,736, because it also reads the whole heightmap once a window.
  • The largest BAR window decompresses to 937,916 bytes. The window length of 900 frames was chosen so that the largest stayed under the engine's second buffer of 524,288 bytes, and with these poses it does not. The pose columns are 23,084,538 of the 32,181,872 bytes before compression. With the poses of #3783 it is 454,968 bytes.

Measured with the poses of issue #3783 ​

Both replays were recorded again on 13 October 2026, as bar-4.dump, 661,462,501 bytes, and sf-4.dump, 23,140,583 bytes, because the piece trees of the dumps before them had no rest offsets. coilbox-track-thin made a track from each at a tolerance of 0.5 elmo, with each pose relative to its parent piece. Nothing else about the thinning changed, and every key count but the poses is the same as in the section above.

BAR beforeBAR afterSplinter Faction beforeSplinter Faction after
Pose keys kept1,503,679470,973136,116124,271
Pose tracks kept4,3122,148245105
Pose tracks and keys written50,689 and 1,546,29314,847 and 480,8613,828 and 139,3881,273 and 125,204
Pose columns before compression23,084,538 bytes7,154,666 bytes2,058,616 bytes1,788,500 bytes
Pose columns compressed on their own3,780,198 bytes875,757 bytes90,374 bytes28,377 bytes
Track7,646,679 bytes4,713,582 bytes352,362 bytes289,328 bytes
Projected above4,805,376 bytes287,232 bytes
BAR-Rewind's file, as a multiple of the track4.867.8916.9320.62
Main chunks7,404,163 bytes, from 32,181,8724,470,488 bytes, from 16,251,884340,490 bytes, from 2,636,620277,633 bytes, from 2,366,536
Largest main chunk, decompressed937,916 bytes454,968 bytes319,548 bytes290,592 bytes
Furthest piece sample0.49999757 elmo0.5 elmo0.49999905 elmo0.49999905 elmo
Furthest unit sample0.49999689 elmo0.49999689 elmo0.49989960 elmo0.49989960 elmo
Time to scan, thin, check and write, on an Apple M1 Pro10.16 s7.57 s0.80 s0.71 s
Peak memory, the maximum resident set size from /usr/bin/time -l193,036,288 bytes156,663,808 bytes32,358,400 bytes31,932,416 bytes

The piece samples thinned are 4,332,308 for BAR and 149,812 for Splinter Faction. That is the dump's 4,332,299 and 149,811 piece rows, and 9 and 1 more for a piece whose parent has a row on a frame when it has none.

What the figures say:

  • The BAR track is now 91,794 bytes under its projection, and the Splinter Faction track 2,096 bytes over. The spike's thinning kept 422,060 and 125,284 pose keys for the same matches, against 470,973 and 124,271 here.
  • The largest BAR window is back under the engine's second buffer of 524,288 bytes, by 69,320 bytes. So the window length stays at 900 frames. The buffer is not a limit: zlib::inflate in rts/System/StringUtil.cpp doubles its buffer and decompresses again from the start until the result fits, so a window over 524,288 bytes costs a third pass and still reads. A longer or busier match than this one will go over.
  • Taking the parent off is the whole of the saving. A pose track that never leaves rest is not written, which is what halves the tracks: most watched pieces sit between a muzzle and the root and are never moved by a script.
  • Weighting a piece's turn by its reach and not by the unit's radius saved nothing. With the unit's radius in its place the same two dumps keep 467,736 and 119,631 pose keys, which is 3,237 and 4,640 fewer. The pieces with the most keys are the ones a walk moves, the root and the hips, and their reach is more than the unit's radius because a model is larger than the radius its definition gives. The reach is kept because it is the length that makes the tolerance true of the geometry.
  • A spin is kept as a spin. The largest angle in a window of the BAR track is 543,810 steps, 8.30 turns, on the fan of corsnap. In the Splinter Faction track it is 27,045 steps, 0.41 of a turn, on a turret.

Which pieces the pose keys are on ​

Most of what is left is not aiming. Counted over the keys written to each track, by what the unit was doing at the key's frame:

BARSplinter Faction
Pose keys written480,861125,204
While the unit is moving or turning445,476, 92.64%124,416, 99.37%
While it stands still and a weapon has a target16,912, 3.52%277, 0.22%
While it stands still and no weapon has a target18,473, 3.84%511, 0.41%
On a piece of a unit definition that has under 1% of its keys while standing still with a target371,271, 77.21%, on 73 of 206 pieces123,931, 98.98%, on 6 of 10 pieces
Keys whose three offsets are all 0314,880, 65.48%94,451, 75.44%

A unit counts as moving or turning at a frame when the track gives it a different position or rotation 3 frames before and 3 frames after.

The track cannot say which script turned a piece, so "a piece no weapon turns" is read here as one that has under 1% of its keys while its unit stands still with a target. A weapon aims whether or not its unit is moving, and a walk does not, so a piece that only a walk moves has almost no such keys: a leg settling after a stop is all there is. The 1% line is a choice made for this table and nothing in the code uses it.

In Splinter Faction the four pieces rthigh, rfoot, pelvis and rleg of fedstorm hold 123,774 of the 125,204 keys, and its turret and barrel1 hold 1,140. In BAR the pieces named base, pelvis and torso hold 276,748 of the 480,861, and the pieces named turret hold 21,173.

Whether to record such pieces at all is issue #3941.

Measured with the projectiles of issue #3784 and the weapons of issue #3790 ​

Nothing in the thinning changed for these two issues. coilbox-track-thin made a track from bar-4.dump and sf-4.dump again on 13 October 2026, and each came out byte for byte the same as the track of the section above: 4,713,582 bytes for BAR and 289,328 for Splinter Faction. So the largest BAR window is still 454,968 bytes decompressed.

The section sizes below are from the section_bytes example in coilbox-track-thin. It reads each window's main chunk, puts the columns of one section in a chunk of its own, compresses it at level 9 and adds up the windows. It also writes each window again from its keys, and gets the same bytes as the file has: 4,470,488 for BAR and 277,633 for Splinter Faction. The earlier tables on this page that say "compressed on their own" came from scripts that are not in the repository, and this method does not give the same figure for the projectile and target sections: 722,410 bytes for BAR projectiles here, where the section for #3782 has 693,276. Compare the figures in this section with each other and not with those.

Projectiles ​

Keys at a tolerance of 0.5 elmo. "Straight lines" is the throwaway thinner in the first comment on #3784, run on the raw samples of the same two matches with keys rounded to the track's steps. The crate has no straight line thinning, so the straight line figure is a key count only and has no byte figure of its own.

BARSplinter Faction
Projectile samples in the dump480,65326,859
Keys with straight lines146,1925,836
Keys with the cubic, kept54,6541,769
Keys with the cubic, written to the file55,1581,773
Projectile entries written21,113631
Projectile columns before compression1,640,420 bytes51,808 bytes
Projectile columns compressed on their own722,410 bytes24,024 bytes
The track4,713,582 bytes289,328 bytes
Furthest projectile sample from the track0.49983939 elmo0.49954246 elmo

The cubic keeps 37.4% of the keys straight lines needed for BAR and 30.3% for Splinter Faction. 54,654 / 146,192 and 1,769 / 5,836.

Projectile velocity ​

The tolerance covers position only. coilbox-track-thin now also measures, for every recorded projectile sample thinning dropped, the distance between the recorded velocity and the slope of the track's curve, in elmos per frame. A sample that is a key holds the velocity it was given, to within rounding, so it is not counted. The report prints the worst as "projectile velocity".

BARSplinter Faction
Worst velocity error at a dropped sample929.996 elmos per frame, projectile 2658 on frame 315440.4927 elmo per frame, projectile 466 on frame 23036
Checks of a dropped sample, counted once for each window it is in425,99925,090
Of them over 0.5 elmo per frame11,7980
Of them over 100 elmos per frame10,2530
Median error0.0610.048
99th percentile480.20.077

The counts and percentiles are from a temporary line that printed each check, taken out again. The worst figures are the ones the report prints.

Splinter Faction is well under half an elmo a frame. BAR is not, and the cause is not noise. The samples with a large error are projectiles whose position does not move while their recorded velocity is large. On frames 31543 to 31548 one place in the dump has several projectiles of weapon definition 217 with the same position and the same velocity of about 620 elmos per frame. The weapons with the most checks over 0.5 are named corllt_cor_lightlaser, corhllt_hllt_top, armwar_armwar_laser, corhal_cor_canlaser, corexp_hllt_bottom, corhllt_hllt_bottom, corerad_cor_erad and armsh_armsh_weapon. Several are lasers by name. What the engine puts in a beam's velocity was not looked up.

Two keys with the same position and velocity are not flat between them. The curve swings away from the position and is back on it half way between the keys, whatever the gap. The slope there is -0.5 of the velocity at the keys, so a dropped frame in the middle is 1.5 times the velocity away. Position is right and velocity is not.

No bound was added. A bound is only worth having if it comes from what playback does with the velocity, and playback does not exist yet. The nearest ground is that a velocity error that lasts one frame moves a projectile by that distance, which would make 0.5 elmo per frame the limit. BAR is over that limit at 11,798 checks, and the ones looked at were projectiles that a velocity does not move, so the limit would force keys for no reason that can be named yet. Velocity is stored and read back, and its worst error is reported.

Target tracks for every weapon ​

Before issue #3790 a unit had target tracks for its first weapon and for builder work. Now it has one for each weapon that had a target in the window. The "before" figures are what the file would hold without the tracks for weapon 2 and above. They come from taking those tracks out of each window as read and writing the window again, so they are the file as it would have been if the recorder had stopped at the first weapon. Builder work is weapon 0 and stays. The target columns are 10, which counts each unit's tracks, and 40 to 48.

BARSplinter Faction
Target tracks and keys written, with every weapon3,769 and 26,98483 and 1,663
Of them for a weapon after the first324 and 2,0270 and 0
Target columns before compression, first weapon only243,780 bytes16,712 bytes
Target columns before compression, every weapon261,000 bytes16,712 bytes
Target columns compressed on their own, first weapon only66,240 bytes3,755 bytes
Target columns compressed on their own, every weapon69,946 bytes3,755 bytes
Main chunks compressed, first weapon only4,466,579 bytes277,633 bytes
Main chunks compressed, every weapon4,470,488 bytes277,633 bytes

Weapons after the first cost the BAR track 3,909 bytes of main chunks, which is 0.08% of its 4,713,582. They cost the Splinter Faction track nothing, since none of its units had a target on a second weapon.

The file stores each chunk in whole blocks of 64 bytes, so the difference in the size of the file can be up to 63 bytes a window different from the difference in main chunks. The track as it is, 4,713,582 and 289,328 bytes, is the "after".

A unit with one weapon has one target track, and a second weapon that never had a target has none. The tests a_unit_with_one_weapon_costs_one_target_track_and_a_weapon_without_a_target_none and two_weapons_aimed_at_different_targets_each_have_a_target_curve in coilbox-track-thin check both. The second also covers a weapon aimed at a projectile and at a position, which neither benchmark recording has.

Measured with the team numbers of issue #3982 ​

Both benchmark replays were recorded again on 13 October 2026 with the recorder that reads the teams, and coilbox-track-thin made a track from each. The tracks without team numbers are those of the section above. The Splinter Faction recording asked for the three rules parameters its adapter names, supplyUsed, supplyMax and researchPoints. BAR has no adapter, so its recording has none.

BAR, 82,496 framesSplinter Faction, 23,091 frames
Teams, Gaia included33
Rows in the dump for what a team holds162,17216,413
Rows in the dump for the rest of a team's economy5,930176
Rows in the dump for rules parameters01,567
The dump664,492,186 bytes23,503,213 bytes
Samples the thinning read, one for each number on each frame4,454,8381,328,322
Tracks and keys kept34 and 178,80536 and 20,407
Tracks and keys written, over every window2,936 and 181,035905 and 21,186
Team number columns before compression1,107,388 bytes133,488 bytes
Team number columns compressed on their own509,351 bytes30,160 bytes
Main chunks compressed, without and with them4,470,488 and 5,012,794 bytes277,633 and 309,081 bytes
The track without team numbers4,713,582 bytes289,328 bytes
The track with them5,255,917 bytes321,116 bytes
What they cost542,335 bytes, 11.5%31,788 bytes, 11.0%
Furthest thinned sample from the track, and what it was allowed0.5 and 0.51.7438 and 1.7459

What a team holds is nearly all of it. In the Splinter Faction track 5,196 of the 21,186 keys are metal held, 10,870 are energy held and 3,211 are the three rules parameters. Storage has 88 keys for each resource, and every other number between 78 and 279.

The tolerance and the fraction were chosen from these runs of the same two dumps.

Resource toleranceResource fractionBAR trackSplinter Faction track
0.0505,448,109 bytes324,764 bytes
0.505,380,333 bytes322,900 bytes
0.51/40965,358,701 bytesnot measured
0.51/10245,255,917 bytes321,116 bytes
0.51/2565,031,597 bytes314,159 bytes

A tolerance ten times tighter costs 67,776 bytes more for BAR, 1.3% of the track, so the tolerance buys little, because what a team holds seldom runs in a straight line for long. Half a unit was kept as the least a bar that draws whole numbers can show. The fraction does more for BAR, whose teams hold thousands. 1/1024 was chosen over 1/256, which saves another 224,320 bytes, because a resource bar prints the figure as well as drawing it. At 1/1024 a team that holds 10,000 reads within 9.8 of the recording, and at 1/256 within 39. Neither was looked at on a screen for BAR.

Thinning the BAR dump took 8.57 seconds with the team numbers in it. What it took without them was not measured on the same day.

Not measured: a match with many teams. Both benchmarks have two playing teams and Gaia. The cost is by team, so a 16 team match would cost several times as much for the same length, and what an 8 against 8 costs is not known.

Savings considered and rejected ​

Each was measured on the BAR benchmark against the 4,805,376 byte projection unless it says otherwise.

SavingMeasuredWhy not
The quaternion packed into one 32 bit word, 10 bits a component4,702,208 bytes, against 4,608,192 for the same 10 bits in three 16 bit columnsIt is 2.0% larger than the columns. A packed word cannot be stored as a difference from the key before, and the columns can. Lua also cannot read a 32 bit word exactly. At 10 bits, rounding moved the edge of the largest Splinter Faction unit by 0.16 elmo
Three turn angles for a unit's rotation in place of a quaternion4,564,224 bytes at 4,096 steps a turn, 5.0% smallerWhen a unit points straight up or down two of the three angles have no fixed value, and a straight line between angles does not follow the rotation near there. Thinning can only answer that with more keys, and how many was not measured. Both readers would also have to apply the angles in the engine's order
Each 16 bit column split into a column of low bytes and a column of high bytes4,640,768 bytes, 3.4% smallerLua would need two unpack calls and a multiply and add for every value, where one call now gives the values
Storing a position as the error of a guess that the unit keeps its last speed4,663,104 bytes, 3.0% smallerThe guess is a multiply and a divide that Lua's 32 bit floats and Rust must round the same way for every key. One disagreement moves every later key of that unit. The same goes for guessing a projectile's position from its velocity, which was not measured
Time as an offset from the window start in place of a difference5,183,360 bytes, 7.9% largerLarger
Rotation as plain values in place of differences4,900,800 bytes, 2.0% largerLarger
Terrain columns as plain values in place of differences4,854,912 bytes, 1.0% largerLarger
Longer windows4,066,624 bytes at 3,600 frames, 15.4% smallerThe largest window grows to 1,496,812 bytes, which the engine decompresses four times over, and a seek has to unpack all of it
A coarser position step of a quarter of an elmo4,569,728 bytes, 4.9% smaller, with 0.87% more position keysRounding would be 43% of the stated tolerance
Listing every definition of the game, not only those used9,408 bytes largerNot needed. A writer may do it
Time in 8 bitsNot measured0.78% of the gaps between a unit's transform keys in the BAR benchmark are over 255 frames, so it would need an escape. The 16 bit column already compresses to under half a byte a key
Variable length integersNot measuredThe engine's Lua has no call that reads them, so each value would be read one at a time
Another compressorNot measuredVFS.ZlibDecompress is the only one the engine's Lua has
A snapshot of the terrain in each windowNot measured93,245 heightmap points had changed by the end of the BAR benchmark, so the last window's snapshot alone would hold that many
Features listed in each windowNot measured636 features in 92 windows, where one list holds them once
Health as a number of hit pointsNot measuredThe fraction is what a health bar draws and what playback sets. A unit's maximum changed during the match for 409 of 1,367 units in the BAR benchmark, so hit points would need the maximum stored beside them

The issue's guide was a 12 byte transform key before compression. This design's is 15, because the rotation is three 16 bit columns and a byte in place of 4 packed bytes. The packed form was measured and is larger once compressed.

Not measured ​

  • The time the engine's Lua takes to decompress and unpack a window. A test widget that loads a window of the BAR projection would measure it.
  • A human BAR match, and a full Splinter Faction match. The spike could not record either.
  • How far a piece is from where it was recorded in the unit's model space, where the errors of the pieces above it add to its own.
  • A model with a baked rest rotation. Neither benchmark game has one.
  • Why terrain rows differ between runs.

How the measurements were made ​

The scripts are in /Users/tomjn/dev/coilbox-rewind-bench/track-format/ on the machine that ran the spike, beside the benchmark files. They are throwaway and are not in this repository.

  • stats.py prints value ranges and key spacing from a benchmark file.
  • poses.py breaks pose keys down by unit definition and piece.
  • proto.py lays a benchmark file out as a track and compresses it. Every size on this page is its output.
  • thin_pos.py and thin_proj.py thin the raw unit and projectile samples with rounded keys.
  • rotprec.py measures rotation rounding.
  • example.py builds the worked example and reads it back.

The benchmark files are BAR-Rewind's output. The scripts read the files by the section table the spike documented and by the values in them. BAR-Rewind's source was not read, and nothing on this page describes its layout.

MIT-licensed, and the Lua that runs in the engine is MIT or GPL-2.0-or-later. Bundles pr-downloader (GPL-2.0-or-later, © the Spring/Recoil authors).