Skip to content

Instantly share code, notes, and snippets.

@RandyGaul
Last active December 15, 2025 20:58
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
//--------------------------------------------------------------------------------------------------
// Hacky compat layer (fill in with your own definitions if you want).
#define _CRT_SECURE_NO_WARNINGS
#define _CRT_NONSTDC_NO_DEPRECATE
typedef struct v2 { float x, y; } v2;
#define V2(x,y) ((v2){ x, y })
#define dyna
#define sdyna
#define UNUSED(x) (void)x
#define sintern strdup // omg so hacky
#include <stdint.h>
#include <stdbool.h>
#include <stdio.h>
#include <stdlib.h>
#include <assert.h>
#include <string.h>
#if defined(_MSC_VER)
#include <malloc.h>
# define alloca _alloca
#else
# include <alloca.h>
#endif
#define FREE free
#define MALLOC malloc
#define CALLOC(T) calloc(sizeof(T), 1)
// File IO.
#define CF_File FILE
#define read(file, ptr, size) fread(ptr, size, 1, file)
#define write(file, ptr, size) fwrite(ptr, size, 1, file)
#define close fclose
#define open_for_read(path) fopen(path, "rb")
#define open_for_write(path) fopen(path, "wb")
// Dynamic array.
#define asize(a) ((a) ? ARRAY_HEADER(a)->len : 0)
#define acap(a) ((a) ? ARRAY_HEADER(a)->cap : 0)
#define afree(a) ((a) ? (free(ARRAY_HEADER(a)), (a)=NULL, 0) : 0)
#define apush(a, val) (array__maybegrow(a, 1), (a)[ARRAY_HEADER(a)->len++] = (val))
#define afit(a, n) (array__maybegrow((a), (n) - asize(a)))
#define alen(a) (ARRAY_HEADER(a)->len)
#define ARRAY_HEADER(a) ((ArrayHeader*)a - 1)
typedef struct ArrayHeader
{
int len;
int cap;
} ArrayHeader;
static void* array_grow_impl(void* buf, size_t element_size, size_t add)
{
size_t new_len = buf ? ARRAY_HEADER(buf)->len + add : add;
size_t new_cap = buf ? ARRAY_HEADER(buf)->cap * 2 : 16;
if (new_cap < new_len) new_cap = new_len;
size_t size = sizeof(ArrayHeader) + new_cap * element_size;
ArrayHeader* new_hdr;
if (buf) {
new_hdr = (ArrayHeader*)realloc(ARRAY_HEADER(buf), size);
} else {
new_hdr = (ArrayHeader*)malloc(size);
new_hdr->len = 0;
}
new_hdr->cap = (int)new_cap;
return (void*)(new_hdr + 1);
}
#define array__needgrow(a, n) ((a)==NULL || ARRAY_HEADER(a)->len + (n) > ARRAY_HEADER(a)->cap)
#define array__maybegrow(a, n) (array__needgrow(a, (n)) ? (*(void**)&(a)=array_grow_impl(a, sizeof(*(a)), n)) : 0)
//--------------------------------------------------------------------------------------------------
// 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`.
//--------------------------------------------------------------------------------------------------
// Some temporary test types (delete these).
typedef struct Inner
{
int a;
float b;
} Inner;
typedef struct Outer
{
int x;
Inner inner;
v2 point;
dyna v2* points;
sdyna const char* s;
dyna const char** s_array;
} Outer;
typedef struct Object
{
int data;
} Object;
typedef struct ObjectList
{
dyna Object** objects;
} ObjectList;
//--------------------------------------------------------------------------------------------------
// 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_LATEST_PLUS_ONE
};
#define SV_LATEST (SV_LATEST_PLUS_ONE - 1)
// 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(Inner) \
X(Outer) \
X(Object) \
X(ObjectList) \
// As an optimization we place memcpy-safe types here.
#define SV_MEMCPY_SAFE_TYPES(X) \
X(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); \
afit(o->ARRAY, n); \
alen(o->ARRAY) = n; \
for (int i = 0; i < n; ++i) { \
if (S->saving) { \
SV_WRITE(o->ARRAY[i]); \
} else { \
SV_READ(o->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)
//--------------------------------------------------------------------------------------------------
// 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)
// Context struct passed through all serialization routines.
typedef struct SV_Context
{
int version;
bool saving;
bool loading;
const char* path;
CF_File* file;
int sync_counter;
} SV_Context;
// 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,
#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 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); char* buf = (char*)alloca(len+1); read(S->file, buf, len); buf[len] = 0; *out = sintern(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);
}
//--------------------------------------------------------------------------------------------------
// Temporary test cases (delete these).
SV_SERIALIZABLE(Inner)
{
SV_ADD(SV_INITIAL, a);
SV_ADD(SV_INITIAL, b);
}
SV_SERIALIZABLE(Outer)
{
SV_ADD(SV_INITIAL, x);
SV_ADD(SV_INITIAL, inner);
SV_ADD(SV_INITIAL, point);
SV_ADD_ARRAY(SV_INITIAL, points);
SV_ADD(SV_INITIAL, s);
SV_ADD_ARRAY(SV_INITIAL, s_array);
SV_SYNC();
}
SV_SERIALIZABLE(Object)
{
SV_ADD(SV_INITIAL, data);
}
SV_SERIALIZABLE(ObjectList)
{
SV_ADD_LIST(SV_INITIAL, objects);
}
void serialize_test()
{
{
SV_SAVE_BEGIN("test.bin");
Outer a = { 0 };
a.x = 5;
a.inner.a = 2;
a.inner.b = 3;
a.point = V2(-1,-1);
a.points = NULL;
a.s = "test";
apush(a.s_array, "element 0");
apush(a.s_array, "element 1");
apush(a.s_array, "element 2");
apush(a.points, V2(1,2));
apush(a.points, V2(3,4));
SV_ADD_LOCAL(SV_INITIAL, a);
afree(a.s_array);
afree(a.points);
SV_SAVE_END();
}
{
SV_LOAD_BEGIN("test.bin");
Outer a = { 0 };
SV_ADD_LOCAL(SV_INITIAL, a);
v2 pts[2] = { a.points[0], a.points[1] };
const char* strings[3] = { a.s_array[0], a.s_array[1], a.s_array[2] };
afree(a.s_array);
afree(a.points);
SV_LOAD_END();
}
{
SV_SAVE_BEGIN("object.bin");
dyna Object** objects = NULL;
apush(objects, (Object*)MALLOC(sizeof(Object)));
apush(objects, (Object*)MALLOC(sizeof(Object)));
objects[0]->data = 1;
objects[1]->data = 2;
ObjectList list = { objects };
SV_ADD_LOCAL(SV_INITIAL, list);
FREE(objects[0]);
FREE(objects[1]);
afree(objects);
SV_SAVE_END();
}
{
SV_LOAD_BEGIN("object.bin");
ObjectList list = { 0 };
SV_ADD_LOCAL(SV_INITIAL, list);
Object* o0 = list.objects[0];
Object* o1 = list.objects[1];
FREE(list.objects[0]);
FREE(list.objects[1]);
afree(list.objects);
SV_SAVE_END();
}
}
//--------------------------------------------------------------------------------------------------
// Demo program.
int main()
{
serialize_test();
return 0;
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment