Cloud save format - SillyBits/satisfactory-savegame-tool-ng GitHub Wiki
The new cloud save format isn't this different to previous versions besides using chunk compression to reach the goal of yielding smaller saves, but the format after decompression itself was kept as is (with some very minor deviation), so any existing code will work just fine afterwards 👍
Same header format as before:
+--------+---------------+
| Type | Meaning |
+--------+---------------+
| int32 | HeaderVersion |
| int32 | SaveVersion |
| int32 | BuildVersion |
| String | MapName |
| String | MapOptions |
| String | SessionName |
| int32 | PlayDuration |
| int64 | SaveDateTime |
| byte | Visibility |
+--------+---------------+
For cloud saves, SaveVersion
will be at 21
, and BuildVersion
will also state correct engine build (EB) save was created with. If one turns off cloud saves within the launcher, he will get the old, known values for SaveVersion
(20) and BuildVersion
(EB=BuildVersion+34682
).
Right after the header, chunks will start. A chunk contains multiple chunk info blocks before actual data (depicted below), each with different meanings:
+-------+------------------+
| Type | Meaning |
+-------+------------------+
| int64 | CompressedSize |
| int64 | UncompressedSize |
+-------+------------------+
First info block could be called a chunk header as it will contain the typical Unreal package tag 0x9E2A83C1
in CompressedSize
, and UncompressedSize
holds the total amount of uncompressed bytes for this chunk. Currently, always at a 131072
bytes (128kB).
Now the more tricky part, and this might be used in future versions, so one should implement it this way right away for being future-proof 😃
Second info block is sort-of a summary, CompressedSize
contains the actual length of all compressed data blocks in this chunk, and UncompressedSize
being the same as with first info block (131072
).
Third to N-th info block are sub-chunks with their UncompressedSize being summed up until it reaches UncompressedSize from summary. As stated earlier, current save format uses only a single sub-chunk which contains the compressed chunk data as a whole, but this might change.
After all the info blocks were dealt with, actual data starts. This is using standard zlib compression (with window size at a 15 bits).
Summary ... or tl;dr 😄 :
+-----------+-------------------------------+
| ChunkInfo | Header |
| ChunkInfo | Summary |
+-----------+-------------------------------+
| ChunkInfo | Sub chunk #0 |
| |
| ChunkInfo | Sub chunk #N |
+-----------+-------------------------------+
| byte[] | zlib compressed data block #0 |
| |
| byte[] | zlib compressed data block #N |
+-----------+-------------------------------+
At the beginning, I've mentioned "with some very minor deviation", but no reason to be afraid, the first 4 bytes of uncompressed data will just contain the actual size of the save 😄 All the bytes afterwards do represent the actual save, excl. the header as this was given already.
Following some example implementations taken from my savegame handler:
To ease navigation, first thing is to get all those chunk infos together and to get actual size of all the bytes decompressed:
_chunks = new Cloudsave::ChunkList();
Cloudsave::ChunkInfo infos[128];
int count;
__int64 curr_offset = 0;
while (_reader->Pos < _reader->Size)
{
__int64 last_pos = _reader->Pos;
Cloudsave::ChunkInfo tag;
tag.Read(_reader);
if ((unsigned __int32)tag.CompressedSize != Cloudsave::CHUNK_MAGIC)
throw gcnew Exception(String::Format("CloudsaveReader at pos {0:#,#0}: Invalid magic {1}",
last_pos, (unsigned __int32)tag.CompressedSize));
Cloudsave::ChunkInfo summary;
summary.Read(_reader);
count = 0;
while (summary.UncompressedSize > 0)
{
infos[count].Read(_reader);
summary.UncompressedSize -= infos[count].UncompressedSize;
++count;
}
for (int i = 0; i < count; ++i)
{
_chunks->Add(_reader->Pos, infos[i].CompressedSize, curr_offset, infos[i].UncompressedSize);
curr_offset += infos[i].UncompressedSize;
_reader->Seek(infos[i].CompressedSize, IReader::Positioning::Relative);
}
}
// Prepare first chunk
// (this will read chunk stored in "_chunks[0]" into memory, decompresses it and lets "_chunk_buf" point to first byte of uncompressed data)
_Prepare(0);
// First 4 bytes from this first chunk will also contain actual savegame size.
_size = (*((unsigned __int32*)_chunk_buf)) + 4; // +4 to include this int32 we've read
_offset = 4; // To skip save size read already
__int32 retval;
z_stream z;
z.zalloc = &zlibAlloc;
z.zfree = &zlibFree;
z.opaque = Z_NULL;
z.next_in = _io_buff;
z.avail_in = (uInt)(node->_size & 0x7FFFFFFF);
z.next_out = pOutBuffer;
z.avail_out = (uInt)(node->Size & 0x7FFFFFFF);
retval = inflateInit2(&z, /*windowBits=*/15);
if (retval != Z_OK)
throw gcnew Exception(String::Format("ChunkList: Error inflateInit2: {0}", retval));
retval = inflate(&z, Z_FINISH);
if (retval != Z_STREAM_END)
throw gcnew Exception(String::Format("ChunkList: Error inflate: {0}", retval));
__int32 uncompr_result = z.total_out;
retval = inflateEnd(&z);
if (retval < Z_OK)
throw gcnew Exception(String::Format("ChunkList: Error inflateEnd: {0}", retval));
// Check size returned
if (node->Size != uncompr_result)
throw gcnew ArgumentException("ChunkList: Error decompressing chunk");
Same as with decompressing, but using deflate
-methods instead.