It always puzzles me that this is somehow becoming a lost art. Binary serialization in C is very straightforward, it simply requires a bit of planning and organization.
If you're wanting to create compact save files then you'll want a header, an optional data table, and a payload. The header can begin with a fourcc of your own choosing and can be used both to verify that the file is intended to represent the format indicated and that the endianness matches (otherwise the character order will mismatch predictably). You can create a fourcc in C as follows:
unsigned int fourcc = 'SAVF';
Following that you may wish to place a 16 bit major version number and a 16 bit minor version number. If you choose to include a data table (more on this later) you can indicate the post-header file length and the table length also.
For data of known length you can simply pack it into a struct and write it to the file. This works for quite a lot of things, but there are often cases where data of variable length needs to be recorded. For example, a string containing the name of the player's character could have a variable length. Alternatively, you may need to save the player's inventory, which would contain some variable number of items.
One approach to this is to place maximum lengths on these sets of data and simply zero out the unused data sections. This is fast and simple, but a bit wasteful, and imposing arbitrary limits may not suit your taste. As an example, if your maximum name length is 10 bytes then you could store it as an array of 10 char or an array of 11 char. In the 10 char case you must be sure to load it into an 11 char array and append a zero at load time, in case the name uses all 10 characters. In the 11 char case you simply include the terminating zero in the save file. Likewise, if you can describe your inventory entries as item ID and item quantity then a simple array of structs can be used to describe the whole inventory. If the length is fixed then this is POD data and you can simply read and write it to the file without alteration.
Alternatively, if you want to save a bit of space, or if you really want to avoid fixed-lengths, you can use either a data table or data segment prefixes. A data table would come immediately after the file header and would be a list of offsets into the payload in a known order.
Alternatively, you could store it as a list of section lengths. If you store offsets then you can read the entire data section into a single allocation and then add the address of that allocation to all the offsets (loaded into a struct) in order to cheaply create a table of contents for the loaded data. If you store lengths then you can easily create separate allocations for each segment of data and then load them one at a time.
Segment prefixes work similarly to a table of lengths, but instead of using a table you simply prefix each segment with an unsigned int representing its length. When loading the file you read in the length, allocate for the segment according to that value, read the segment, then simply repeat the process for the next segment.
Here's a small example. I've foregone error checking on the file I/O and memory allocations for the sake of brevity, but you should definitely use error checking when you write code that you intend to make use of. The strategy here was to use structs that are arranged for easy reading and writing. Any POD (plain-old-data == no pointers) struct is very easy to work with because you can simply read and write it directly to the file. Arrays of PODs are the same. For non-POD structs I made sure to put the non-POD entries at the end of the struct, declared a constant expressing the length of the POD section of the struct, and then wrote load/save functions for that struct that handle the allocation and serialization. In this way a complex struct containing all the data for the party in an RPG can be saved and loaded with single function calls. The functions simply read or write the POD section, then delegate to the helper functions of their non-POD members.
#define _CRT_SECURE_NO_WARNINGS
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <assert.h>
//make sure that there's no element padding, which would interfere with the layout of structures in memory
#pragma pack(1)
typedef struct {
//lead with known-length data
unsigned char str;
unsigned short hp;
unsigned short hpmax;
//put variable-length data at the end
char* name;
} PartyMember;
size_t PARTYMEMBER_POD_SECTION_LENGTH = sizeof(unsigned char) + (sizeof(short) * 2);
void saveString(const char* str, FILE* fp) {
unsigned int len = strlen(str);
//record string length
fwrite(&len, sizeof(unsigned int), 1, fp);
//record string
fwrite(str, sizeof(char), len, fp);
}
void loadString(char** pstr, FILE* fp) {
//get length
unsigned int len;
fread(&len, sizeof(unsigned int), 1, fp);
//allocate and read
char* str = malloc(len + 1); //allocate an additional byte
fread(str, sizeof(char), len, fp);
str[len] = 0; //remember to add the terminating zero
*pstr = str;
}
void savePartyMember(PartyMember* member, FILE* fp) {
fwrite(member, PARTYMEMBER_POD_SECTION_LENGTH, 1, fp);
saveString(member->name, fp);
}
void loadPartyMember(PartyMember* member, FILE* fp) {
fread(member, PARTYMEMBER_POD_SECTION_LENGTH, 1, fp);
loadString(&member->name, fp);
}
typedef enum {
NONE = 0,
POTION = 1,
SWORD = 2,
BACON = 3
} ItemID;
typedef struct {
unsigned int id;
unsigned int quantity;
} Item;
typedef struct {
unsigned int gold;
int memberCt;
int itemCt;
Item* inventory;
PartyMember* members;
} Party;
size_t PARTY_POD_SECTION_LENGTH = sizeof(unsigned int) + (sizeof(int) * 2);
void saveParty(Party* party, FILE* fp) {
fwrite(party, PARTY_POD_SECTION_LENGTH, 1, fp);
//Item is POD, so we can just dump the whole array
fwrite(party->inventory, sizeof(Item), party->itemCt, fp);
//Party members have variable length strings, so we need to save them individually
for(int i = 0; i < party->memberCt; i++) {
savePartyMember(&party->members[i], fp);
}
}
void loadParty(Party* party, FILE* fp) {
fread(party, PARTY_POD_SECTION_LENGTH, 1, fp);
//allocate for the inventory and just read it all in at once, since it's POD
party->inventory = malloc(sizeof(Item) * party->itemCt);
fread(party->inventory, sizeof(Item), party->itemCt, fp);
//load party members individually since they're non-POD
party->members = malloc(sizeof(PartyMember) * party->memberCt);
for(int i = 0; i < party->memberCt; i++) {
loadPartyMember(&party->members[i], fp);
}
}
typedef struct {
unsigned short major;
unsigned short minor;
} VersionNumber;
const VersionNumber VERSION = { 1, 0 };
typedef struct {
unsigned int fourcc;
VersionNumber ver;
} SaveHeader;
void saveGame(Party* party, const char* filename) {
SaveHeader header;
header.fourcc = 'SAVF';
memcpy(&header.ver, &VERSION, sizeof(header.ver));
FILE* fp = fopen(filename, "wb");
//write header
fwrite(&header, sizeof(header), 1, fp);
//write party data
saveParty(party, fp);
fclose(fp);
}
void loadGame(Party* party, const char* filename) {
FILE* fp = fopen(filename, "rb");
SaveHeader header;
fread(&header, sizeof(header), 1, fp);
if(header.fourcc == 'FVAS') {
printf("Endian mismatch!\n");
exit(1);
}
if(header.fourcc != 'SAVF') {
printf("Invalid savefile!\n");
exit(1);
}
if(memcmp(&header.ver, &VERSION, sizeof(VERSION)) != 0) {
printf("Savefile version mismatch!\n");
exit(1);
}
loadParty(party, fp);
}
void addItem(Party* party, ItemID id, unsigned int quant) {
Item* curItems = party->inventory;
size_t oldLen = sizeof(Item) * party->itemCt;
party->itemCt++;
size_t newLen = sizeof(Item) * party->itemCt;
Item* newItems = malloc(newLen);
if(curItems) {
memcpy(newItems, curItems, oldLen);
}
newItems[party->itemCt - 1].id = id;
newItems[party->itemCt - 1].quantity = quant;
party->inventory = newItems;
free(curItems);
}
int main(int argc, char** argv) {
Party a;
memset(&a, 0, sizeof(a));
a.gold = 42;
addItem(&a, BACON, 3);
addItem(&a, SWORD, 1);
addItem(&a, POTION, 12);
a.memberCt = 2;
a.members = malloc(sizeof(PartyMember) * a.memberCt);
a.members[0].str = 5;
a.members[0].hp = 50;
a.members[0].hpmax = 100;
a.members[0].name = malloc(5);
sprintf(a.members[0].name, "Derp");
a.members[1].str = 15;
a.members[1].hp = 200;
a.members[1].hpmax = 200;
a.members[1].name = malloc(7);
sprintf(a.members[1].name, "Hamlet");
saveGame(&a, "save.bin");
Party b;
loadGame(&b, "save.bin");
assert(a.gold == b.gold);
assert(a.itemCt == b.itemCt);
for(int i = 0; i < a.itemCt; i++) {
assert(a.inventory[i].id == b.inventory[i].id);
assert(a.inventory[i].quantity == b.inventory[i].quantity);
}
assert(a.memberCt == b.memberCt);
for(int i = 0; i < a.memberCt; i++) {
PartyMember* ma = &a.members[i];
PartyMember* mb = &b.members[i];
assert(ma->hp == mb->hp);
assert(ma->hpmax == mb->hpmax);
assert(ma->str == mb->str);
assert(strcmp(ma->name, mb->name) == 0);
}
printf("Tested OK!\n");
return 0;
}
This test succeeded for me in VS2015CE. Here's the file that was produced (note that the fourcc appears "backward" in the file because I'm on a little endian system):