Add versioned save slot/settings schemas with strict version checks

Splits saveslot_t/savesettings_t into a stable, version-independent
in-memory struct (save/slot/, save/settings/) and a separate frozen
per-schema-version struct (saveslotver1_t/savesettingsver1_t) used only
for JSON serialization, converted between manually. Every write now
stamps a "version" field (SAVE_SLOT_CURRENT/SAVE_SETTINGS_CURRENT), and
every read requires it to match exactly, rejecting anything else -
laying the groundwork for a real migration path instead of silently
misreading old/incompatible save data.
This commit is contained in:
2026-09-08 12:49:06 -05:00
parent 327c2bec70
commit 8139a0ea54
28 changed files with 634 additions and 115 deletions
+42 -5
View File
@@ -6,7 +6,7 @@
*/
#include "dusktest.h"
#include "save/saveslot.h"
#include "save/slot/saveslot.h"
#include "save/savejson.h"
#include "util/memory.h"
#include "util/string.h"
@@ -174,7 +174,9 @@ static void test_saveSlotReadJSON_missingFieldsUseDefaults(void **state) {
slot.cachedData.playerLevel = 999;
errorret_t ret = slotFromJSON(
&slot, "{\"time\":{\"time\":1.0,\"timeZone\":0.0,\"offsetTime\":0.0}}"
&slot,
"{\"version\":1,"
"\"time\":{\"time\":1.0,\"timeZone\":0.0,\"offsetTime\":0.0}}"
);
assert_true(errorIsOk(ret));
@@ -186,7 +188,9 @@ static void test_saveSlotReadJSON_missingTimeKey_errors(void **state) {
saveslot_t slot;
saveSlotInit(&slot);
errorret_t ret = slotFromJSON(&slot, "{\"name\":\"Hero\",\"playerLevel\":5}");
errorret_t ret = slotFromJSON(
&slot, "{\"version\":1,\"name\":\"Hero\",\"playerLevel\":5}"
);
assert_true(errorIsNotOk(ret));
errorCatch(ret);
}
@@ -197,7 +201,7 @@ static void test_saveSlotReadJSON_nameTooLong_errors(void **state) {
errorret_t ret = slotFromJSON(
&slot,
"{\"name\":\"WayTooLongAName\","
"{\"version\":1,\"name\":\"WayTooLongAName\","
"\"time\":{\"time\":1.0,\"timeZone\":0.0,\"offsetTime\":0.0}}"
);
assert_true(errorIsNotOk(ret));
@@ -209,12 +213,43 @@ static void test_saveSlotReadJSON_nonObjectRoot_errors(void **state) {
saveSlotInit(&slot);
// A non-object root has no keys, so every field falls back to its
// default - except "time", which is not optional and errors instead.
// default - except "version" and "time", which are required and error
// instead.
errorret_t ret = slotFromJSON(&slot, "[]");
assert_true(errorIsNotOk(ret));
errorCatch(ret);
}
// ============================================================
// saveSlotReadJSON - version enforcement
// ============================================================
static void test_saveSlotReadJSON_versionMissing_errors(void **state) {
saveslot_t slot;
saveSlotInit(&slot);
errorret_t ret = slotFromJSON(
&slot,
"{\"name\":\"Hero\","
"\"time\":{\"time\":1.0,\"timeZone\":0.0,\"offsetTime\":0.0}}"
);
assert_true(errorIsNotOk(ret));
errorCatch(ret);
}
static void test_saveSlotReadJSON_versionMismatch_errors(void **state) {
saveslot_t slot;
saveSlotInit(&slot);
errorret_t ret = slotFromJSON(
&slot,
"{\"version\":999,\"name\":\"Hero\","
"\"time\":{\"time\":1.0,\"timeZone\":0.0,\"offsetTime\":0.0}}"
);
assert_true(errorIsNotOk(ret));
errorCatch(ret);
}
int main(void) {
assertInit();
const struct CMUnitTest tests[] = {
@@ -234,6 +269,8 @@ int main(void) {
cmocka_unit_test(test_saveSlotReadJSON_missingTimeKey_errors),
cmocka_unit_test(test_saveSlotReadJSON_nameTooLong_errors),
cmocka_unit_test(test_saveSlotReadJSON_nonObjectRoot_errors),
cmocka_unit_test(test_saveSlotReadJSON_versionMissing_errors),
cmocka_unit_test(test_saveSlotReadJSON_versionMismatch_errors),
};
return cmocka_run_group_tests(tests, NULL, NULL);