Skip to content

Instantly share code, notes, and snippets.

@RandyGaul
Last active January 14, 2026 18:06
Show Gist options
  • Select an option

  • Save RandyGaul/b65cea57a8de7f18de970084a4480dd7 to your computer and use it in GitHub Desktop.

Select an option

Save RandyGaul/b65cea57a8de7f18de970084a4480dd7 to your computer and use it in GitHub Desktop.
SV - Save Version - Saving versioned binary data
// SV - Save Version
// This file has an API for saving binary data with versioning support for backwards compatibility.
// Original API design by Media Molecule.
// See: https://gist.githubusercontent.com/OswaldHurlem/4810ad510669097db872c6de305c9df0/raw/2fdf47eead527e954d29950aa41debf34547e5bd/mmalex_serialization_and_formats.log
//
// Design specs:
// + Very fast reads/writes
// + Backwards compat
// - Not self-describing (serializes opaque data)
// + The code itself describes the data, and versioning, all in one place
// - Not forward tolerant (cannot open newer data version with older application)
// - Uncompressed in serialized form (can do this with an external tool trivially)
// - Doesn't scale well to double digit team size (basically: merge conflicts)
// + Extremely simple (just ~500 loc here, including comments)
// - Slightly error prone with versioning (but simple to notice + fix)
//
// Steps to use:
// 1. Add the type you want to serialize to SV_TYPES table, or to SV_MEMCPY_SAFE_TYPES.
// - Note: SV_MEMCPY_SAFE_TYPES can only be used on types you *will not change*. There is *no*
// backwards compatibility for these types, but, as an optimization they will get memcpy'd
// to disk in a single call, even for arrays of this type. Recommended for vector, transform,
// or other fundamental types that will never change.
//
// 2. Increment the version enum. You will increment this enum every single time you modify a serialization
// routine. This is how the versioning system works -- with a global monotincally incrementing version id.
//
// 3. Create the serialization routine for your type.
// Example:
//
// typedef struct ExampleData
// {
// int a;
// float b;
// const char* c;
// };
//
// SV_SERIALIZE(ExampleData)
// {
// SV_ADD(SV_ADDED_EXAMPLE_DATA, a);
// SV_ADD(SV_ADDED_EXAMPLE_DATA, b);
// SV_ADD(SV_ADDED_EXAMPLE_DATA, c);
// }
//
// Where `SV_ADDED_EXAMPLE_DATA` is the new enum version when `ExampleData` was created. Increment it:
//
// enum
// {
// SV_INITIAL,
// SV_ADDED_EXAMPLE_DATA, // <-- Newly added.
// // --
// SV_LATEST_PLUS_ONE
// };
//
// Then, if we want to remove a member, we use `SV_REM`:
//
// typedef struct ExampleData
// {
// int a;
// float b;
// // const char* c; <-- Removed in SV_REMOVE_C_FROM_EXAMPLE_DATA.
// };
//
// SV_SERIALIZE(ExampleData)
// {
// SV_ADD(SV_ADDED_EXAMPLE_DATA, a);
// SV_ADD(SV_ADDED_EXAMPLE_DATA, b);
// //SV_ADD(SV_ADDED_EXAMPLE_DATA, c);
// SV_REM(SV_ADDED_EXAMPLE_DATA, SV_REMOVE_C_FROM_EXAMPLE_DATA, const char*, c, NULL);
// // If-needed you can use local variable c for migrating to new fields later (not needed here).
// }
//
// If you want to migrate an old value to something new, you can write whatever custom code you want to
// do so (you have access to the object `o` being serialized):
//
// typedef struct ExampleData
// {
// int a;
// float b;
// // const char* c; <-- Removed.
// const char* d;
// };
//
// SV_SERIALIZE(ExampleData)
// {
// SV_ADD(SV_ADDED_EXAMPLE_DATA, a);
// SV_ADD(SV_ADDED_EXAMPLE_DATA, b);
// //SV_ADD(SV_ADDED_EXAMPLE_DATA, c);
// SV_REM(SV_ADDED_EXAMPLE_DATA, SV_REMOVE_C_FROM_EXAMPLE_DATA, const char*, c, NULL);
// o->d = c; // Copy over the missing field (essentially just renaming c to d).
// SV_ADD(SV_ADDED_EXAMPLE_DATA_D, d);
// }
//
// Usually that's it! But, if you want to create a new kind of file:
//
// 4. Open a file with either `SV_SAVE_BEGIN` or `SV_LOAD_BEGIN`. Call `SV_ADD_LOCAL` to recursively serialize.
// When done, `call `SV_SAVE_END` or `SV_LOAD_END`.
// Example:
//
// void save(const char* path, ExampleData data)
// {
// SV_SAVE_BEGIN(path);
//
// SV_ADD_LOCAL(SV_ADDED_EXAMPLE_DATA, data);
//
// SV_SAVE_END();
// }
//
// Other rules:
// - All strings coming out of the serialization layer (when reading) are unique stable strings from
// `sintern` string interning.
// - If you use `SV_ADD_LIST` objects will be allocated with calloc (cleared to zero) and handed back
// to you. You should design your object for valid zero-initialization to play nicely here, or, run
// initialization after serialization. Otherwise, redesign your data layout to flattened arrays and
// simply call `SV_ADD_ARRAY`.
//--------------------------------------------------------------------------------------------------
// Public API.
// Global version id. Increment this each time you alter any serialiation routines.
// ...Add to the end, but make sure `SV_LATEST_PLUS_ONE` is last.
enum
{
SV_INITIAL,
SV_SIMPLIFIED_PROXY, // Removed pal_slot, sprite, bb from Proxy (now recreated from MAKE_PROXY on load).
SV_ADD_ROOM_PALETTES, // Added tile_palette to RoomFile.
SV_ADD_GEM_INVENTORY, // Added GemInventory for player save data.
SV_AUTOTILE, // Replaced Tile with SolidTile, added tilesets array to RoomFile.
// --
SV_LATEST_PLUS_ONE
};
#define SV_LATEST (SV_LATEST_PLUS_ONE - 1)
// @JANK - Forward typedefs for compilation simplicity. Would be nice to find another way.
typedef struct RoomFile RoomFile;
typedef struct Tile Tile;
typedef struct SolidTile SolidTile;
typedef struct GemInventory GemInventory;
// Extend this table whenever you add a new type to serialize.
// ...For any type that's memcpy safe add it below to the `SV_MEMCPY_SAFE_TYPES` table.
#define SV_TYPES(X) \
X(Var) \
X(Property) \
X(CF_Sprite) \
X(Proxy) \
X(RoomFile) \
X(Tile) \
X(SolidTile) \
X(GemInventory) \
// As an optimization we place memcpy-safe types here.
#define SV_MEMCPY_SAFE_TYPES(X) \
X(CF_Aabb) \
X(CF_V2) \
// Intended use pattern:
// void save(const char* path, ExampleData data)
// {
// SV_SAVE_BEGIN(path);
//
// SV_ADD_LOCAL(SV_ADDED_EXAMPLE_DATA, data);
//
// SV_SAVE_END();
// }
#define SV_SAVE_BEGIN(path) SV_Context ctx = sv_make(path, true), *S = &ctx;
#define SV_SAVE_END() sv_destroy(S)
#define SV_LOAD_BEGIN(path) SV_Context ctx = sv_make(path, false), *S = &ctx;
#define SV_LOAD_END() sv_destroy(S)
// Add a struct member.
#define SV_ADD(VERSION, MEMBER) \
do { \
if (S->saving) { \
SV_WRITE(o->MEMBER); \
} else if (S->loading && S->version >= VERSION) { \
SV_READ(o->MEMBER); \
} \
} while (0)
// Add a local variable.
// ...Does not support arrays.
#define SV_ADD_LOCAL(VERSION, LOCAL_VAR) \
do { \
if (S->saving) { \
SV_WRITE(LOCAL_VAR); \
} else if (S->loading && S->version >= VERSION) { \
SV_READ(LOCAL_VAR); \
} \
} while (0)
// Add an array (as a struct member).
#define SV_ADD_ARRAY(VERSION, ARRAY) \
do { \
if (S->saving || S->loading && S->version >= VERSION) { \
if (SV_IS_MEMCPY_SAFE(o->ARRAY)) { \
if (S->saving) { \
SV_WRITE(o->ARRAY); \
} else if (S->loading && S->version >= VERSION) { \
SV_READ(o->ARRAY); \
} \
} else { \
int n = asize(o->ARRAY); \
SV_ADD_LOCAL(VERSION, n); \
if (n) { \
if (S->loading) { \
afit(o->ARRAY, n); \
alen(o->ARRAY) = n; \
memset(o->ARRAY, 0, sizeof(o->ARRAY[0]) * n); \
} \
for (int i = 0; i < n; ++i) { \
if (S->saving) { \
SV_WRITE(o->ARRAY[i]); \
} else { \
SV_READ(o->ARRAY[i]); \
} \
} \
} \
} \
} \
} while (0)
// Add a local dynamic array.
#define SV_ADD_LOCAL_ARRAY(VERSION, ARRAY) \
do { \
if (S->saving || (S->loading && S->version >= (VERSION))) { \
if (SV_IS_MEMCPY_SAFE(ARRAY)) { \
if (S->saving) { \
SV_WRITE(ARRAY); \
} else { \
SV_READ(ARRAY); \
} \
} else { \
int n = asize(ARRAY); \
SV_ADD_LOCAL((VERSION), n); \
if (S->loading) { \
afit((ARRAY), n); \
alen((ARRAY)) = n; \
memset(o->ARRAY, 0, sizeof(o->ARRAY[0]) * n); \
} \
for (int i = 0; i < n; ++i) { \
if (S->saving) { \
SV_WRITE((ARRAY)[i]); \
} else { \
SV_READ((ARRAY)[i]); \
} \
} \
} \
} \
} while (0)
// Same as `SV_ADD_ARRAY` but works for arrays of pointers, such as: struct ObjectList { dyna Object** objects; };
#define SV_ADD_LIST(VERSION, ARRAY_OF_PTRS) \
do { \
int n = asize(o->ARRAY_OF_PTRS); \
SV_ADD_LOCAL(VERSION, n); \
afit(o->ARRAY_OF_PTRS, n); \
alen(o->ARRAY_OF_PTRS) = n; \
for (int i = 0; i < n; ++i) { \
if (S->loading) { \
void* v = CALLOC(o->ARRAY_OF_PTRS[0][0]); \
memcpy(o->ARRAY_OF_PTRS + i, &v, sizeof(void*)); \
} \
SV_ADD(VERSION, ARRAY_OF_PTRS[i][0]); \
} \
} while (0)
// Remove something from the serialization. This needs to increment the global version and passed in as `VERSION_REMOVED`.
// ...T is the type of the value removed.
// ...A local variable called `NAME` is created and data is read into it. You can then freely
// use this local variable to handle conversion to your newer format (if-needed).
// ...DEFAULT is a default value to use for the removed value in case it isn't present in the file version.
// ...Remove the prior `SV_ADD` for the removed data.
#define SV_REM(VERSION_ADDED, VERSION_REMOVED, T, NAME, DEFAULT) \
T NAME = (DEFAULT); \
if (S->loading && S->version >= VERSION_ADDED && S->version < VERSION_REMOVED) { \
SV_READ(NAME); \
}
// Define a serialization routine for a user struct.
#define SV_SERIALIZABLE(T) void serialize_##T(SV_Context* S, T* o)
// Optional sync check. Asserts if serialization is out of sync.
// ...Place this at the end of a `SV_SERIALIZABLE` routine to narrow down issues.
// ...Will assert when loading to catch (likely) bugs when saving.
// ...This adds an int counter to your struct, so you *must* bump global version enum.
// Common issues:
// - Missing ADD/REM
// - Wrong version specified
// - Field reordered/removed
// - Wrong array count/loop
// - Struct changed w/o bumping version
#define SV_SYNC() \
do { \
int e = S->sync_counter; \
if (S->saving) { SV_WRITE(e); } \
else { int g; SV_READ(g); assert(g == e); } \
S->sync_counter++; \
} while (0)
// Context struct passed through all serialization routines.
typedef struct SV_Context
{
int version;
bool saving;
bool loading;
const char* path;
File* file;
int sync_counter;
} SV_Context;
//--------------------------------------------------------------------------------------------------
// Private implementation details.
// Tells whether a type can be safe memcpy'd. Used for optimizing serialization for certain types.
// Arrays of these types can be dumped all at once.
#define SV_MEMCPY_TYPE(T) T: true,
#define SV_IS_MEMCPY_SAFE(T) \
_Generic(T, \
SV_MEMCPY_SAFE_TYPES(SV_MEMCPY_TYPE) \
default: false \
)
// Create declarations for all serializable types for compilation simplicity.
#define SV_SERIALIZE_DECL(T) void serialize_##T(struct SV_Context* S, T* o);
SV_TYPES(SV_SERIALIZE_DECL)
// Create definitions for functions to dump full arrays for the types in SV_MEMCPY_SAFE_TYPES.
#define SV_SERIALIZE_BY_MEMCPY(T) \
void serialize_##T(SV_Context* S, T* o) \
{ \
if (S->saving) write(S->file, o, sizeof(T)); \
else read(S->file, o, sizeof(T)); \
} \
void serialize_##T##_array(SV_Context* S, T** a) \
{ \
T* o = *a; \
if (S->saving) { \
int sz = asize(o) * sizeof(T); \
write(S->file, &sz, sizeof(sz)); \
write(S->file, o, sz); \
} else { \
int sz; \
read(S->file, &sz, sizeof(sz)); \
int n = sz / sizeof(T); \
afit(o, n); \
alen(o) = n; \
read(S->file, o, sz); \
} \
*a = o; \
}
SV_MEMCPY_SAFE_TYPES(SV_SERIALIZE_BY_MEMCPY)
// Internally used read/write function overloads for all types.
#define SV_TYPE(T) T: serialize_##T, T*: serialize_T_array_noop,
#define SV_MTYPE(T) T: serialize_##T, T*: serialize_##T##_array,
#define SV_READ(V) \
_Generic(V, \
SV_TYPES(SV_TYPE) \
SV_MEMCPY_SAFE_TYPES(SV_MTYPE) \
uint8_t: read_uint8, \
uint16_t: read_uint16, \
uint32_t: read_uint32, \
uint64_t: read_uint64, \
int8_t: read_int8, \
int16_t: read_int16, \
int32_t: read_int32, \
int64_t: read_int64, \
bool: read_bool, \
float: read_float, \
double: read_double, \
const char*: read_string, \
char*: read_string, \
const char**: read_string_noop, \
char**: read_string_noop \
)(S, &V)
#define SV_WRITE(V) \
_Generic(V, \
SV_TYPES(SV_TYPE) \
SV_MEMCPY_SAFE_TYPES(SV_MTYPE) \
uint8_t: write_uint8, \
uint16_t: write_uint16, \
uint32_t: write_uint32, \
uint64_t: write_uint64, \
int8_t: write_int8, \
int16_t: write_int16, \
int32_t: write_int32, \
int64_t: write_int64, \
bool: write_bool, \
float: write_float, \
double: write_double, \
const char*: write_string, \
char*: write_string, \
const char**: write_string_noop, \
char**: write_string_noop \
)(S, &V)
void serialize_T_array_noop(SV_Context* S, void* v) { UNUSED(S); UNUSED(v); assert(!"Here just to compile -- matches array semantics for user structs."); }
void read_uint8(SV_Context* S, uint8_t* v) { read(S->file, v, sizeof(*v)); }
void read_uint16(SV_Context* S, uint16_t* v) { read(S->file, v, sizeof(*v)); }
void read_uint32(SV_Context* S, uint32_t* v) { read(S->file, v, sizeof(*v)); }
void read_uint64(SV_Context* S, uint64_t* v) { read(S->file, v, sizeof(*v)); }
void read_int8(SV_Context* S, int8_t* v) { read(S->file, v, sizeof(*v)); }
void read_int16(SV_Context* S, int16_t* v) { read(S->file, v, sizeof(*v)); }
void read_int32(SV_Context* S, int32_t* v) { read(S->file, v, sizeof(*v)); }
void read_int64(SV_Context* S, int64_t* v) { read(S->file, v, sizeof(*v)); }
void read_bool(SV_Context* S, bool* v) { uint8_t t; read(S->file, &t, sizeof(t)); *v = t != 0; }
void read_float(SV_Context* S, float* v) { read(S->file, v, sizeof(*v)); }
void read_double(SV_Context* S, double* v) { read(S->file, v, sizeof(*v)); }
void read_string(SV_Context* S, const char** out) { uint32_t len; read_uint32(S, &len); dyna char* buf = atmp(char, len+1); read(S->file, buf, len); buf[len] = 0; *out = sintern(buf); afree(buf); }
void read_string_noop(SV_Context* S, const char*** out) { UNUSED(S); UNUSED(out); assert(!"SV_ADD_ARRAY performs array loop itself, this is just here to compile."); }
void write_uint8(SV_Context* S, const uint8_t* v) { write(S->file, v, sizeof(*v)); }
void write_uint16(SV_Context* S, const uint16_t* v) { write(S->file, v, sizeof(*v)); }
void write_uint32(SV_Context* S, const uint32_t* v) { write(S->file, v, sizeof(*v)); }
void write_uint64(SV_Context* S, const uint64_t* v) { write(S->file, v, sizeof(*v)); }
void write_int8(SV_Context* S, const int8_t* v) { write(S->file, v, sizeof(*v)); }
void write_int16(SV_Context* S, const int16_t* v) { write(S->file, v, sizeof(*v)); }
void write_int32(SV_Context* S, const int32_t* v) { write(S->file, v, sizeof(*v)); }
void write_int64(SV_Context* S, const int64_t* v) { write(S->file, v, sizeof(*v)); }
void write_bool(SV_Context* S, const bool* v) { uint8_t b = *v ? 1 : 0; write(S->file, &b, sizeof(b)); }
void write_float(SV_Context* S, const float* v) { write(S->file, v, sizeof(*v)); }
void write_double(SV_Context* S, const double* v) { write(S->file, v, sizeof(*v)); }
void write_string(SV_Context* S, const char** v) { uint32_t len = (uint32_t)strlen(*v); write_uint32(S, &len); write(S->file, *v, len); }
void write_string_noop(SV_Context* S, const char*** out) { UNUSED(S); UNUSED(out); assert(!"SV_ADD_ARRAY performs array loop itself, this is just here to compile."); }
SV_Context sv_make(const char* path, bool saving)
{
SV_Context ctx = { 0 };
SV_Context* S = &ctx;
ctx.saving = saving;
ctx.loading = !saving;
ctx.path = sintern(path);
if (saving) {
ctx.file = open_for_write(path);
assert(ctx.file);
ctx.version = SV_LATEST;
SV_WRITE(ctx.version);
} else {
ctx.file = open_for_read(path);
assert(ctx.file);
SV_READ(ctx.version);
}
return ctx;
}
void sv_destroy(SV_Context* S)
{
close(S->file);
}
//--------------------------------------------------------------------------------------------------
// Registration of some fundamental types.
SV_SERIALIZABLE(Var)
{
SV_ADD(SV_INITIAL, type);
switch (o->type) {
case VAR_TYPE_NONE: break;
case VAR_TYPE_INT: SV_ADD(SV_INITIAL, i); break;
case VAR_TYPE_FLOAT: SV_ADD(SV_INITIAL, f); break;
case VAR_TYPE_STRING: SV_ADD(SV_INITIAL, s); break;
case VAR_TYPE_BOOL: SV_ADD(SV_INITIAL, b); break;
}
}
SV_SERIALIZABLE(Property)
{
SV_ADD(SV_INITIAL, k);
SV_ADD(SV_INITIAL, v);
}
SV_SERIALIZABLE(CF_Sprite)
{
SV_ADD(SV_INITIAL, name);
if (S->loading) {
*o = cf_make_sprite(o->name);
}
}
SV_SERIALIZABLE(Proxy)
{
if (S->saving) {
if (!o->alive) return;
}
SV_ADD(SV_INITIAL, type);
SV_ADD(SV_INITIAL, position);
// These are now recreated from MAKE_PROXY on load.
SV_REM(SV_INITIAL, SV_SIMPLIFIED_PROXY, int, pal_slot, 0);
SV_REM(SV_INITIAL, SV_SIMPLIFIED_PROXY, CF_Sprite, sprite, (CF_Sprite){0});
SV_REM(SV_INITIAL, SV_SIMPLIFIED_PROXY, CF_Aabb, bb, (CF_Aabb){0});
SV_ADD_ARRAY(SV_INITIAL, properties);
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment