diff --git a/src/dusk/save/CMakeLists.txt b/src/dusk/save/CMakeLists.txt index 50551e1d..e75182ea 100644 --- a/src/dusk/save/CMakeLists.txt +++ b/src/dusk/save/CMakeLists.txt @@ -8,6 +8,8 @@ target_sources(${DUSK_LIBRARY_TARGET_NAME} PUBLIC save.c savedevice.c - saveslot.c - savesettings.c ) + +# Subdirs +add_subdirectory(slot) +add_subdirectory(settings) diff --git a/src/dusk/save/save.h b/src/dusk/save/save.h index 88d86f38..2d13e701 100644 --- a/src/dusk/save/save.h +++ b/src/dusk/save/save.h @@ -7,8 +7,8 @@ #pragma once #include "savedevice.h" -#include "saveslot.h" -#include "savesettings.h" +#include "save/slot/saveslot.h" +#include "save/settings/savesettings.h" typedef struct { // File state diff --git a/src/dusk/save/savedevice.c b/src/dusk/save/savedevice.c index 6f705e59..91f4131e 100644 --- a/src/dusk/save/savedevice.c +++ b/src/dusk/save/savedevice.c @@ -12,8 +12,8 @@ #if defined(SAVE_DEVICE_DATA_RAW) #include "yyjson.h" #include "save/savejson.h" - #include "save/saveslot.h" - #include "save/savesettings.h" + #include "save/slot/saveslot.h" + #include "save/settings/savesettings.h" #include "util/crypt.h" #include "util/endian.h" #include diff --git a/src/dusk/save/savedevice.h b/src/dusk/save/savedevice.h index f2f20e70..543f5054 100644 --- a/src/dusk/save/savedevice.h +++ b/src/dusk/save/savedevice.h @@ -164,10 +164,10 @@ errorret_t saveDeviceDispose(savedevice_t *device); #if defined(SAVE_DEVICE_DATA_RAW) -// Mirrors the same guarded default in saveslot.h - can't #include that -// header here to get it directly, since saveslot.h itself includes this -// header (savedevice.h), and relying on include order to resolve the cycle -// correctly would be fragile. +// Mirrors the same guarded default in save/slot/saveslot.h - can't +// #include that header here to get it directly, since it itself includes +// this header (savedevice.h), and relying on include order to resolve the +// cycle correctly would be fragile. #ifndef SAVE_SLOT_COUNT #define SAVE_SLOT_COUNT 3 #endif diff --git a/src/dusk/save/savejson.h b/src/dusk/save/savejson.h index 1cd064fd..82a8cd87 100644 --- a/src/dusk/save/savejson.h +++ b/src/dusk/save/savejson.h @@ -76,6 +76,33 @@ (dest) = hasInt32(key) ? \ (int32_t)yyjson_get_int(yyjson_obj_get(object, key)) : (int32_t)(def) +/** + * Writes a schema version into the current JSON object's "version" key. + * Pair with requireVersion() on the read side. + * + * @param version The version constant to write (e.g. SAVE_SLOT_CURRENT). + */ +#define writeVersion(version) writeInt32("version", (version)) + +/** + * Requires the current JSON object's "version" key to exist and exactly + * match the given version constant, throwing otherwise. Used to reject a + * save file outright rather than trying to migrate an old schema. + * + * @param version The expected version constant (e.g. SAVE_SLOT_CURRENT). + */ +#define requireVersion(version) { \ + requireInt32("version"); \ + int32_t saveJsonVersion; \ + readInt32("version", saveJsonVersion, 0); \ + if(saveJsonVersion != (version)) { \ + errorThrow( \ + "Save JSON version mismatch (expected %d, got %d)", \ + (int)(version), (int)saveJsonVersion \ + ); \ + } \ +} + /** * Checks if the given key exists on the current JSON object. * diff --git a/src/dusk/save/settings/CMakeLists.txt b/src/dusk/save/settings/CMakeLists.txt new file mode 100644 index 00000000..d1beefa8 --- /dev/null +++ b/src/dusk/save/settings/CMakeLists.txt @@ -0,0 +1,12 @@ +# Copyright (c) 2026 Dominic Masters +# +# This software is released under the MIT License. +# https://opensource.org/licenses/MIT + +# Sources +target_sources(${DUSK_LIBRARY_TARGET_NAME} + PUBLIC + savesettings.c + savesettingscurrent.c + savesettingsver1.c +) diff --git a/src/dusk/save/settings/savesettings.c b/src/dusk/save/settings/savesettings.c new file mode 100644 index 00000000..cac48463 --- /dev/null +++ b/src/dusk/save/settings/savesettings.c @@ -0,0 +1,38 @@ +/** + * Copyright (c) 2026 Dominic Masters + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +#include "savesettings.h" +#include "save/settings/savesettingscurrent.h" +#include "assert/assert.h" +#include "util/memory.h" + +void saveSettingsInit(savesettings_t *settings) { + assertNotNull(settings, "Settings cannot be null"); + + memorySet(settings, 0, sizeof(savesettings_t)); + + settings->audioMasterVolume = 1.0f; + audiomixerchannel_t channel = 0; + while(channel < AUDIO_MIXER_CHANNEL_COUNT) { + settings->audioChannelVolume[channel] = 1.0f; + channel++; + } +} + +errorret_t saveSettingsWriteJSON( + savesettings_t *settings, + yyjson_mut_doc *doc, + yyjson_mut_val *object +) { + errorChain(saveSettingsCurrentWriteJSON(settings, doc, object)); + errorOk(); +} + +errorret_t saveSettingsReadJSON(savesettings_t *settings, yyjson_val *object) { + errorChain(saveSettingsCurrentReadJSON(settings, object)); + errorOk(); +} diff --git a/src/dusk/save/savesettings.h b/src/dusk/save/settings/savesettings.h similarity index 70% rename from src/dusk/save/savesettings.h rename to src/dusk/save/settings/savesettings.h index 9ff0be3d..91c8ec70 100644 --- a/src/dusk/save/savesettings.h +++ b/src/dusk/save/settings/savesettings.h @@ -1,19 +1,24 @@ /** * Copyright (c) 2026 Dominic Masters - * + * * This software is released under the MIT License. * https://opensource.org/licenses/MIT */ #pragma once -#include "time/timeepoch.h" -#include "savedevice.h" -#include "yyjson.h" +#include "save/savedevice.h" #include "audio/mixer/audiomixerchannel.h" +#include "yyjson.h" +// The stable, version-independent in-memory shape of save settings - what +// the rest of the engine actually reads/writes, using live engine constants +// (AUDIO_MIXER_CHANNEL_COUNT) freely. Every save schema version +// (savesettingsver1_t, ...) redefines its own frozen copy of these fields +// for its JSON wire format; saveSettingsCurrentWriteJSON()/ReadJSON() +// (savesettingscurrent.c) manually copy field-by-field between this and +// whichever version is current, so a version's on-disk shape can never +// drift just because a live engine constant changes. typedef struct savesettings_s { - int32_t someSetting; - // Persisted mixer volume preferences - see audiomixerchannelstate_t's own // comment on why these live here rather than on the mixer itself. float_t audioMasterVolume; @@ -24,7 +29,7 @@ typedef struct savesettings_s { * Inits the save settings with the default state, this is functionally "new * game" but will not set the player name, as that is what we use to determine * if a save slot is "in use" or not. - * + * * @param settings The save settings to init. */ void saveSettingsInit(savesettings_t *settings); @@ -50,4 +55,4 @@ errorret_t saveSettingsWriteJSON( * @param object The JSON object to read from. * @return Error state if any. */ -errorret_t saveSettingsReadJSON(savesettings_t *settings, yyjson_val *object); \ No newline at end of file +errorret_t saveSettingsReadJSON(savesettings_t *settings, yyjson_val *object); diff --git a/src/dusk/save/settings/savesettingscurrent.c b/src/dusk/save/settings/savesettingscurrent.c new file mode 100644 index 00000000..e16c0df7 --- /dev/null +++ b/src/dusk/save/settings/savesettingscurrent.c @@ -0,0 +1,66 @@ +/** + * Copyright (c) 2026 Dominic Masters + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +#include "savesettingscurrent.h" +#include "assert/assert.h" + +errorret_t saveSettingsCurrentWriteJSON( + savesettings_t *settings, + yyjson_mut_doc *doc, + yyjson_mut_val *object +) { + assertNotNull(settings, "Settings cannot be null"); + assertNotNull(doc, "Doc cannot be null"); + assertNotNull(object, "Object cannot be null"); + + savesettingsver1_t ver1; + ver1.audioMasterVolume = settings->audioMasterVolume; + + // Bounded by the smaller of the two counts, in case a future live channel + // count ever diverges from this frozen v1 schema's count. + uint8_t channelCount = AUDIO_MIXER_CHANNEL_COUNT; + if(SAVE_SETTINGS_VER1_AUDIO_CHANNEL_COUNT < channelCount) { + channelCount = SAVE_SETTINGS_VER1_AUDIO_CHANNEL_COUNT; + } + for(uint8_t i = 0; i < channelCount; i++) { + ver1.audioChannelVolume[i] = settings->audioChannelVolume[i]; + } + for( + uint8_t i = channelCount; i < SAVE_SETTINGS_VER1_AUDIO_CHANNEL_COUNT; i++ + ) { + ver1.audioChannelVolume[i] = 1.0f; + } + + errorChain(saveSettingsVer1WriteJSON(&ver1, doc, object)); + errorOk(); +} + +errorret_t saveSettingsCurrentReadJSON( + savesettings_t *settings, + yyjson_val *object +) { + assertNotNull(settings, "Settings cannot be null"); + assertNotNull(object, "Object cannot be null"); + + savesettingsver1_t ver1; + errorChain(saveSettingsVer1ReadJSON(&ver1, object)); + + settings->audioMasterVolume = ver1.audioMasterVolume; + + uint8_t channelCount = AUDIO_MIXER_CHANNEL_COUNT; + if(SAVE_SETTINGS_VER1_AUDIO_CHANNEL_COUNT < channelCount) { + channelCount = SAVE_SETTINGS_VER1_AUDIO_CHANNEL_COUNT; + } + for(uint8_t i = 0; i < channelCount; i++) { + settings->audioChannelVolume[i] = ver1.audioChannelVolume[i]; + } + for(uint8_t i = channelCount; i < AUDIO_MIXER_CHANNEL_COUNT; i++) { + settings->audioChannelVolume[i] = 1.0f; + } + + errorOk(); +} diff --git a/src/dusk/save/settings/savesettingscurrent.h b/src/dusk/save/settings/savesettingscurrent.h new file mode 100644 index 00000000..38e8d75a --- /dev/null +++ b/src/dusk/save/settings/savesettingscurrent.h @@ -0,0 +1,40 @@ +/** + * Copyright (c) 2026 Dominic Masters + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +#pragma once +#include "save/settings/savesettings.h" +#include "save/settings/savesettingsver1.h" + +/** + * Converts the given save settings to whichever schema SAVE_SETTINGS_CURRENT + * (savesettingsver.h) selects and writes it out to the given JSON object. + * Update this (only) when bumping to a new SAVE_SETTINGS_VER_N. + * + * @param settings The save settings to write. + * @param doc The mutable JSON document that owns the object. + * @param object The mutable JSON object to write into. + * @return Error state if any. + */ +errorret_t saveSettingsCurrentWriteJSON( + savesettings_t *settings, + yyjson_mut_doc *doc, + yyjson_mut_val *object +); + +/** + * Reads whichever schema SAVE_SETTINGS_CURRENT (savesettingsver.h) selects + * from the given JSON object and converts it into the given save settings. + * Update this (only) when bumping to a new SAVE_SETTINGS_VER_N. + * + * @param settings The save settings to read into. + * @param object The JSON object to read from. + * @return Error state if any. + */ +errorret_t saveSettingsCurrentReadJSON( + savesettings_t *settings, + yyjson_val *object +); diff --git a/src/dusk/save/settings/savesettingsver.h b/src/dusk/save/settings/savesettingsver.h new file mode 100644 index 00000000..c4035ecb --- /dev/null +++ b/src/dusk/save/settings/savesettingsver.h @@ -0,0 +1,15 @@ +/** + * Copyright (c) 2026 Dominic Masters + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +#pragma once +#include "time/timeepoch.h" +#include "save/savedevice.h" +#include "yyjson.h" + +#define SAVE_SETTINGS_VER_1 1 + +#define SAVE_SETTINGS_CURRENT SAVE_SETTINGS_VER_1 diff --git a/src/dusk/save/savesettings.c b/src/dusk/save/settings/savesettingsver1.c similarity index 51% rename from src/dusk/save/savesettings.c rename to src/dusk/save/settings/savesettingsver1.c index 91d5afac..69b2a837 100644 --- a/src/dusk/save/savesettings.c +++ b/src/dusk/save/settings/savesettingsver1.c @@ -1,30 +1,16 @@ /** * Copyright (c) 2026 Dominic Masters - * + * * This software is released under the MIT License. * https://opensource.org/licenses/MIT */ -#include "savesettings.h" +#include "savesettingsver1.h" #include "assert/assert.h" -#include "util/memory.h" -#include "savejson.h" +#include "save/savejson.h" -void saveSettingsInit(savesettings_t *settings) { - assertNotNull(settings, "Settings cannot be null"); - - memorySet(settings, 0, sizeof(savesettings_t)); - - settings->audioMasterVolume = 1.0f; - audiomixerchannel_t channel = 0; - while(channel < AUDIO_MIXER_CHANNEL_COUNT) { - settings->audioChannelVolume[channel] = 1.0f; - channel++; - } -} - -errorret_t saveSettingsWriteJSON( - savesettings_t *settings, +errorret_t saveSettingsVer1WriteJSON( + savesettingsver1_t *settings, yyjson_mut_doc *doc, yyjson_mut_val *object ) { @@ -32,26 +18,30 @@ errorret_t saveSettingsWriteJSON( assertNotNull(doc, "Doc cannot be null"); assertNotNull(object, "Object cannot be null"); - writeInt32("someSetting", settings->someSetting); + writeVersion(SAVE_SETTINGS_CURRENT); writeFloat("audioMasterVolume", settings->audioMasterVolume); writeFloatArray( "audioChannelVolume", settings->audioChannelVolume, - AUDIO_MIXER_CHANNEL_COUNT + SAVE_SETTINGS_VER1_AUDIO_CHANNEL_COUNT ); errorOk(); } -errorret_t saveSettingsReadJSON(savesettings_t *settings, yyjson_val *object) { +errorret_t saveSettingsVer1ReadJSON( + savesettingsver1_t *settings, + yyjson_val *object +) { assertNotNull(settings, "Settings cannot be null"); assertNotNull(object, "Object cannot be null"); - readInt32("someSetting", settings->someSetting, 0); + requireVersion(SAVE_SETTINGS_CURRENT); + readFloat("audioMasterVolume", settings->audioMasterVolume, 1.0f); readFloatArray( "audioChannelVolume", settings->audioChannelVolume, - AUDIO_MIXER_CHANNEL_COUNT, 1.0f + SAVE_SETTINGS_VER1_AUDIO_CHANNEL_COUNT, 1.0f ); errorOk(); -} \ No newline at end of file +} diff --git a/src/dusk/save/settings/savesettingsver1.h b/src/dusk/save/settings/savesettingsver1.h new file mode 100644 index 00000000..8f9c24a9 --- /dev/null +++ b/src/dusk/save/settings/savesettingsver1.h @@ -0,0 +1,55 @@ +/** + * Copyright (c) 2026 Dominic Masters + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +#pragma once +#include "error/error.h" +#include "save/settings/savesettingsver.h" +#include "yyjson.h" + +// This schema's own frozen channel count - never reference +// AUDIO_MIXER_CHANNEL_COUNT (or any other live constant) here. Once +// shipped, this file's on-disk shape must never change underneath old save +// files, even if the live engine constant does. +#define SAVE_SETTINGS_VER1_AUDIO_CHANNEL_COUNT 7 + +// Independent from savesettings_t (save/settings/savesettings.h) by design +// - this is what version 1 actually put on disk, frozen forever. +// saveSettingsCurrentWriteJSON()/ReadJSON() (savesettingscurrent.c) manually +// copy field-by-field between the two, since they're separate types that +// merely happen to currently look alike. +typedef struct savesettingsver1_s { + float_t audioMasterVolume; + float_t audioChannelVolume[SAVE_SETTINGS_VER1_AUDIO_CHANNEL_COUNT]; +} savesettingsver1_t; + +/** + * Writes the given v1 save settings data out to the given JSON object, + * stamping SAVE_SETTINGS_CURRENT as the "version" field. + * + * @param settings The v1 save settings data to write. + * @param doc The mutable JSON document that owns the object. + * @param object The mutable JSON object to write into. + * @return Error state if any. + */ +errorret_t saveSettingsVer1WriteJSON( + savesettingsver1_t *settings, + yyjson_mut_doc *doc, + yyjson_mut_val *object +); + +/** + * Reads v1 save settings data in from the given JSON object. Throws if the + * JSON's "version" is missing or doesn't match SAVE_SETTINGS_CURRENT. + * + * @param settings The v1 save settings data to read into. + * @param object The JSON object to read from. + * @return Error state if any. + */ +errorret_t saveSettingsVer1ReadJSON( + savesettingsver1_t *settings, + yyjson_val *object +); diff --git a/src/dusk/save/slot/CMakeLists.txt b/src/dusk/save/slot/CMakeLists.txt new file mode 100644 index 00000000..de4c3dcd --- /dev/null +++ b/src/dusk/save/slot/CMakeLists.txt @@ -0,0 +1,12 @@ +# Copyright (c) 2026 Dominic Masters +# +# This software is released under the MIT License. +# https://opensource.org/licenses/MIT + +# Sources +target_sources(${DUSK_LIBRARY_TARGET_NAME} + PUBLIC + saveslot.c + saveslotcurrent.c + saveslotver1.c +) diff --git a/src/dusk/save/saveslot.c b/src/dusk/save/slot/saveslot.c similarity index 52% rename from src/dusk/save/saveslot.c rename to src/dusk/save/slot/saveslot.c index bea3f860..ee09c40a 100644 --- a/src/dusk/save/saveslot.c +++ b/src/dusk/save/slot/saveslot.c @@ -1,15 +1,14 @@ /** * Copyright (c) 2026 Dominic Masters - * + * * This software is released under the MIT License. * https://opensource.org/licenses/MIT */ #include "saveslot.h" +#include "save/slot/saveslotcurrent.h" #include "assert/assert.h" #include "util/memory.h" -#include "savejson.h" -#include "time/time.h" void saveSlotInit(saveslot_t *slot) { assertNotNull(slot, "Slot cannot be null"); @@ -33,29 +32,11 @@ errorret_t saveSlotWriteJSON( yyjson_mut_doc *doc, yyjson_mut_val *object ) { - assertNotNull(slot, "Slot cannot be null"); - assertNotNull(doc, "Doc cannot be null"); - assertNotNull(object, "Object cannot be null"); - - // Update time - slot->cachedData.time = timeGetEpoch(); - - writeString("name", slot->cachedData.name); - writeTime("time", slot->cachedData.time); - writeInt32("playerLevel", slot->cachedData.playerLevel); - + errorChain(saveSlotCurrentWriteJSON(slot, doc, object)); errorOk(); } errorret_t saveSlotReadJSON(saveslot_t *slot, yyjson_val *object) { - assertNotNull(slot, "Slot cannot be null"); - assertNotNull(object, "Object cannot be null"); - - char_t saveJsonStringBuffer[SAVE_JSON_STRING_BUFFER_SIZE]; - - readString("name", slot->cachedData.name, "", SAVE_SLOT_NAME_LENGTH); - readTime("time", slot->cachedData.time); - readInt32("playerLevel", slot->cachedData.playerLevel, 1); - + errorChain(saveSlotCurrentReadJSON(slot, object)); errorOk(); -} \ No newline at end of file +} diff --git a/src/dusk/save/saveslot.h b/src/dusk/save/slot/saveslot.h similarity index 75% rename from src/dusk/save/saveslot.h rename to src/dusk/save/slot/saveslot.h index afad4945..6920f204 100644 --- a/src/dusk/save/saveslot.h +++ b/src/dusk/save/slot/saveslot.h @@ -1,13 +1,14 @@ /** * Copyright (c) 2026 Dominic Masters - * + * * This software is released under the MIT License. * https://opensource.org/licenses/MIT */ #pragma once +#include "save/savedevice.h" +#include "rpg/overworld/map.h" #include "time/timeepoch.h" -#include "savedevice.h" #include "yyjson.h" #define SAVE_SLOT_NAME_LENGTH 8 @@ -15,10 +16,18 @@ #define SAVE_SLOT_COUNT 3 #endif +// The stable, version-independent in-memory shape of a save slot - what the +// rest of the engine actually reads/writes. Every save schema version +// (saveslotver1_t, ...) redefines its own copy of these same fields for its +// JSON wire format; saveSlotCurrentWriteJSON()/ReadJSON() (saveslotcurrent.c) +// manually copy field-by-field between this and whichever version is +// current, so a version's frozen on-disk shape can never drift just because +// this stable struct (or a live engine constant like MAP_NAME_MAX) changes. typedef struct { char_t name[SAVE_SLOT_NAME_LENGTH + 1];// 8 characters + null terminator dusktimeepoch_t time; int32_t playerLevel; + char_t mapName[MAP_NAME_MAX]; } saveslotcache_t; typedef struct saveslot_s { @@ -32,7 +41,7 @@ typedef struct saveslot_s { * Inits the save slot with the default state, this is functionally "new game" * but will not set the player name, as that is what we use to determine if a * save slot is "in use" or not. - * + * * @param slot The save slot to init. */ void saveSlotInit(saveslot_t *slot); @@ -40,7 +49,7 @@ void saveSlotInit(saveslot_t *slot); /** * Checks if the save slot is in use, this is determined by checking if the * player name is set or not. - * + * * @param slot The save slot to check. * @return True if the save slot is in use, false otherwise. */ @@ -48,7 +57,7 @@ bool_t saveSlotInUse(saveslotcache_t *slot); /** * Returns whether or not the given save slot has ever saved (has an epoc non 0) - * + * * @param slot The save slot to check. * @return True if the save slot has ever saved, false otherwise. */ @@ -75,4 +84,4 @@ errorret_t saveSlotWriteJSON( * @param object The JSON object to read from. * @return Error state if any. */ -errorret_t saveSlotReadJSON(saveslot_t *slot, yyjson_val *object); \ No newline at end of file +errorret_t saveSlotReadJSON(saveslot_t *slot, yyjson_val *object); diff --git a/src/dusk/save/slot/saveslotcurrent.c b/src/dusk/save/slot/saveslotcurrent.c new file mode 100644 index 00000000..d0e51692 --- /dev/null +++ b/src/dusk/save/slot/saveslotcurrent.c @@ -0,0 +1,51 @@ +/** + * Copyright (c) 2026 Dominic Masters + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +#include "saveslotcurrent.h" +#include "assert/assert.h" +#include "util/string.h" +#include "time/time.h" + +errorret_t saveSlotCurrentWriteJSON( + saveslot_t *slot, + yyjson_mut_doc *doc, + yyjson_mut_val *object +) { + assertNotNull(slot, "Slot cannot be null"); + assertNotNull(doc, "Doc cannot be null"); + assertNotNull(object, "Object cannot be null"); + + // Stamped on the stable struct (not the throwaway ver1 copy below) so the + // fresh timestamp survives the write - saveSlotHasSaved() depends on it. + slot->cachedData.time = timeGetEpoch(); + + saveslotver1_t ver1; + stringCopy(ver1.name, slot->cachedData.name, sizeof(ver1.name)); + ver1.time = slot->cachedData.time; + ver1.playerLevel = slot->cachedData.playerLevel; + stringCopy(ver1.mapName, slot->cachedData.mapName, sizeof(ver1.mapName)); + + errorChain(saveSlotVer1WriteJSON(&ver1, doc, object)); + errorOk(); +} + +errorret_t saveSlotCurrentReadJSON(saveslot_t *slot, yyjson_val *object) { + assertNotNull(slot, "Slot cannot be null"); + assertNotNull(object, "Object cannot be null"); + + saveslotver1_t ver1; + errorChain(saveSlotVer1ReadJSON(&ver1, object)); + + stringCopy(slot->cachedData.name, ver1.name, sizeof(slot->cachedData.name)); + slot->cachedData.time = ver1.time; + slot->cachedData.playerLevel = ver1.playerLevel; + stringCopy( + slot->cachedData.mapName, ver1.mapName, sizeof(slot->cachedData.mapName) + ); + + errorOk(); +} diff --git a/src/dusk/save/slot/saveslotcurrent.h b/src/dusk/save/slot/saveslotcurrent.h new file mode 100644 index 00000000..a4666fb8 --- /dev/null +++ b/src/dusk/save/slot/saveslotcurrent.h @@ -0,0 +1,37 @@ +/** + * Copyright (c) 2026 Dominic Masters + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +#pragma once +#include "save/slot/saveslot.h" +#include "save/slot/saveslotver1.h" + +/** + * Converts the given save slot to whichever schema SAVE_SLOT_CURRENT + * (saveslotver.h) selects and writes it out to the given JSON object. + * Update this (only) when bumping to a new SAVE_SLOT_VER_N. + * + * @param slot The save slot to write. + * @param doc The mutable JSON document that owns the object. + * @param object The mutable JSON object to write into. + * @return Error state if any. + */ +errorret_t saveSlotCurrentWriteJSON( + saveslot_t *slot, + yyjson_mut_doc *doc, + yyjson_mut_val *object +); + +/** + * Reads whichever schema SAVE_SLOT_CURRENT (saveslotver.h) selects from the + * given JSON object and converts it into the given save slot. Update this + * (only) when bumping to a new SAVE_SLOT_VER_N. + * + * @param slot The save slot to read into. + * @param object The JSON object to read from. + * @return Error state if any. + */ +errorret_t saveSlotCurrentReadJSON(saveslot_t *slot, yyjson_val *object); diff --git a/src/dusk/save/slot/saveslotver.h b/src/dusk/save/slot/saveslotver.h new file mode 100644 index 00000000..2774e9ee --- /dev/null +++ b/src/dusk/save/slot/saveslotver.h @@ -0,0 +1,17 @@ +/** + * Copyright (c) 2026 Dominic Masters + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +#pragma once + +// One constant per save slot JSON schema this codebase has ever shipped. +// Never remove or renumber an existing entry - old save files on disk may +// still declare it. +#define SAVE_SLOT_VER_1 1 + +// The schema saveSlotCurrentWriteJSON()/saveSlotCurrentReadJSON() use today. +// Bump this to a new SAVE_SLOT_VER_N when the slot JSON shape changes. +#define SAVE_SLOT_CURRENT SAVE_SLOT_VER_1 diff --git a/src/dusk/save/slot/saveslotver1.c b/src/dusk/save/slot/saveslotver1.c new file mode 100644 index 00000000..c11b3ffd --- /dev/null +++ b/src/dusk/save/slot/saveslotver1.c @@ -0,0 +1,46 @@ +/** + * Copyright (c) 2026 Dominic Masters + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +#include "saveslotver1.h" +#include "assert/assert.h" +#include "save/savejson.h" + +errorret_t saveSlotVer1WriteJSON( + saveslotver1_t *slot, + yyjson_mut_doc *doc, + yyjson_mut_val *object +) { + assertNotNull(slot, "Slot cannot be null"); + assertNotNull(doc, "Doc cannot be null"); + assertNotNull(object, "Object cannot be null"); + + writeVersion(SAVE_SLOT_CURRENT); + writeString("name", slot->name); + writeTime("time", slot->time); + writeInt32("playerLevel", slot->playerLevel); + writeString("mapName", slot->mapName); + + errorOk(); +} + +errorret_t saveSlotVer1ReadJSON(saveslotver1_t *slot, yyjson_val *object) { + assertNotNull(slot, "Slot cannot be null"); + assertNotNull(object, "Object cannot be null"); + + char_t saveJsonStringBuffer[SAVE_JSON_STRING_BUFFER_SIZE]; + + requireVersion(SAVE_SLOT_CURRENT); + + readString("name", slot->name, "", SAVE_SLOT_VER1_NAME_LENGTH); + readTime("time", slot->time); + readInt32("playerLevel", slot->playerLevel, 1); + readString( + "mapName", slot->mapName, "", SAVE_SLOT_VER1_MAP_NAME_MAX - 1 + ); + + errorOk(); +} diff --git a/src/dusk/save/slot/saveslotver1.h b/src/dusk/save/slot/saveslotver1.h new file mode 100644 index 00000000..48c696aa --- /dev/null +++ b/src/dusk/save/slot/saveslotver1.h @@ -0,0 +1,56 @@ +/** + * Copyright (c) 2026 Dominic Masters + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +#pragma once +#include "error/error.h" +#include "time/timeepoch.h" +#include "save/slot/saveslotver.h" +#include "yyjson.h" + +// This schema's own frozen copies of save/slot/saveslot.h's field sizes - +// never reference SAVE_SLOT_NAME_LENGTH/MAP_NAME_MAX (or any other live +// constant) here. Once shipped, this file's on-disk shape must never change +// underneath old save files, even if the live engine constants do. +#define SAVE_SLOT_VER1_NAME_LENGTH 8 +#define SAVE_SLOT_VER1_MAP_NAME_MAX 10 + +// Independent from saveslotcache_t (save/slot/saveslot.h) by design - this +// is what version 1 actually put on disk, frozen forever. saveSlotCurrent +// WriteJSON()/ReadJSON() (saveslotcurrent.c) manually copy field-by-field +// between the two, since they're separate types that merely happen to +// currently look alike. +typedef struct saveslotver1_s { + char_t name[SAVE_SLOT_VER1_NAME_LENGTH + 1]; + dusktimeepoch_t time; + int32_t playerLevel; + char_t mapName[SAVE_SLOT_VER1_MAP_NAME_MAX]; +} saveslotver1_t; + +/** + * Writes the given v1 save slot data out to the given JSON object, stamping + * SAVE_SLOT_CURRENT as the "version" field. + * + * @param slot The v1 save slot data to write. + * @param doc The mutable JSON document that owns the object. + * @param object The mutable JSON object to write into. + * @return Error state if any. + */ +errorret_t saveSlotVer1WriteJSON( + saveslotver1_t *slot, + yyjson_mut_doc *doc, + yyjson_mut_val *object +); + +/** + * Reads v1 save slot data in from the given JSON object. Throws if the + * JSON's "version" is missing or doesn't match SAVE_SLOT_CURRENT. + * + * @param slot The v1 save slot data to read into. + * @param object The JSON object to read from. + * @return Error state if any. + */ +errorret_t saveSlotVer1ReadJSON(saveslotver1_t *slot, yyjson_val *object); diff --git a/src/dusk/ui/dialog/save/uiselectsave.h b/src/dusk/ui/dialog/save/uiselectsave.h index d916c016..ccc5bf31 100644 --- a/src/dusk/ui/dialog/save/uiselectsave.h +++ b/src/dusk/ui/dialog/save/uiselectsave.h @@ -11,7 +11,7 @@ #include "ui/widget/uimenu.h" #include "ui/widget/uiscrolling.h" #include "ui/dialog/save/uisaveslot.h" -#include "save/saveslot.h" +#include "save/slot/saveslot.h" #define UI_SELECT_SAVE_TITLE_TEXT_MAX 64 #define UI_SELECT_SAVE_TITLE_SPRITES_MAX UI_SELECT_SAVE_TITLE_TEXT_MAX diff --git a/src/dusklinux/save/savedevicelinux.c b/src/dusklinux/save/savedevicelinux.c index 1aafd1d0..9f586b0f 100644 --- a/src/dusklinux/save/savedevicelinux.c +++ b/src/dusklinux/save/savedevicelinux.c @@ -7,8 +7,8 @@ #include "save/savedevice.h" #include "save/savedevicelinux.h" -#include "save/saveslot.h" -#include "save/savesettings.h" +#include "save/slot/saveslot.h" +#include "save/settings/savesettings.h" #include "save/savejson.h" #include "asset/asset.h" #include "assert/assert.h" diff --git a/test/save/test_save.c b/test/save/test_save.c index 8cc2f6f2..07396e51 100644 --- a/test/save/test_save.c +++ b/test/save/test_save.c @@ -57,7 +57,7 @@ static void test_saveInit_setsDefaults(void **state) { assert_int_equal(SAVE.slotCurrent, 0xFF); assert_false(SAVE.findingAvailableDevice); assert_false(SAVE.noAvailableDeviceFound); - assert_int_equal(SAVE.settings.someSetting, 0); + assert_true(SAVE.settings.audioMasterVolume == 1.0f); assert_int_equal(SAVE.devices[0].state, SAVE_DEVICE_STATE_UNKNOWN); for(uint8_t i = 0; i < SAVE_SLOT_COUNT; i++) { @@ -229,7 +229,7 @@ static void test_saveSaveSettings_notDirty_isNoop(void **state) { // if someone changes the no-op condition without meaning to. makeDeviceAvailable(); assert_false(SAVE.settingsDirty); - SAVE.settings.someSetting = 123; + SAVE.settings.audioMasterVolume = 12.0f; errorret_t ret = saveSaveSettings(); assert_true(errorIsOk(ret)); @@ -238,7 +238,7 @@ static void test_saveSaveSettings_notDirty_isNoop(void **state) { saveSettingsInit(&onDisk); errorret_t readRet = saveDeviceSettingsRead(&SAVE.devices[0], &onDisk); assert_true(errorIsOk(readRet)); - assert_int_equal(onDisk.someSetting, 0);// never actually written + assert_true(onDisk.audioMasterVolume == 1.0f);// never actually written } static void test_saveSaveSettings_dirtyForced_writesAndClearsFlag( @@ -246,7 +246,7 @@ static void test_saveSaveSettings_dirtyForced_writesAndClearsFlag( ) { makeDeviceAvailable(); SAVE.settingsDirty = true;// nothing in real code sets this - forced here - SAVE.settings.someSetting = 55; + SAVE.settings.audioMasterVolume = 5.5f; errorret_t ret = saveSaveSettings(); assert_true(errorIsOk(ret)); @@ -256,7 +256,7 @@ static void test_saveSaveSettings_dirtyForced_writesAndClearsFlag( saveSettingsInit(&onDisk); errorret_t readRet = saveDeviceSettingsRead(&SAVE.devices[0], &onDisk); assert_true(errorIsOk(readRet)); - assert_int_equal(onDisk.someSetting, 55); + assert_true(onDisk.audioMasterVolume == 5.5f); } static void test_saveLoadSettings_noCurrentDeviceAsserts(void **state) { @@ -268,16 +268,16 @@ static void test_saveLoadSettings_readsAndClearsDirtyFlag(void **state) { savesettings_t onDisk; saveSettingsInit(&onDisk); - onDisk.someSetting = 99; + onDisk.audioMasterVolume = 9.25f; errorret_t writeRet = saveDeviceSettingsWrite(&SAVE.devices[0], &onDisk); assert_true(errorIsOk(writeRet)); SAVE.settingsDirty = true; - SAVE.settings.someSetting = 0; + SAVE.settings.audioMasterVolume = 0.0f; errorret_t ret = saveLoadSettings(); assert_true(errorIsOk(ret)); - assert_int_equal(SAVE.settings.someSetting, 99); + assert_true(SAVE.settings.audioMasterVolume == 9.25f); assert_false(SAVE.settingsDirty); } @@ -470,7 +470,7 @@ static void test_saveUpdate_foundDevice_rewritesSettingsUnconditionally( // it, regardless of settingsDirty - independent of (and unguarded by) the // same dirty-flag check saveSaveSettings() itself honors. resetFindCallbackState(); - SAVE.settings.someSetting = 321; + SAVE.settings.audioMasterVolume = 3.25f; assert_false(SAVE.settingsDirty); saveFindAvailableDevice(findAvailableCallback, NULL); @@ -482,7 +482,7 @@ static void test_saveUpdate_foundDevice_rewritesSettingsUnconditionally( saveSettingsInit(&onDisk); errorret_t readRet = saveDeviceSettingsRead(&SAVE.devices[0], &onDisk); assert_true(errorIsOk(readRet)); - assert_int_equal(onDisk.someSetting, 321); + assert_true(onDisk.audioMasterVolume == 3.25f); } int main(void) { diff --git a/test/save/test_savedevice.c b/test/save/test_savedevice.c index 5b94f3dc..909aa06c 100644 --- a/test/save/test_savedevice.c +++ b/test/save/test_savedevice.c @@ -8,8 +8,8 @@ #include "dusktest.h" #include "savetestfixture.h" #include "save/savedevice.h" -#include "save/saveslot.h" -#include "save/savesettings.h" +#include "save/slot/saveslot.h" +#include "save/settings/savesettings.h" #include "util/memory.h" #include "util/string.h" @@ -224,7 +224,7 @@ static void test_saveDeviceSettings_dispatchesToPlatform(void **state) { savesettings_t written; saveSettingsInit(&written); - written.someSetting = 88; + written.audioMasterVolume = 0.25f;// exactly representable, safe to compare errorret_t writeRet = saveDeviceSettingsWrite(&device, &written); assert_true(errorIsOk(writeRet)); @@ -234,7 +234,7 @@ static void test_saveDeviceSettings_dispatchesToPlatform(void **state) { errorret_t readRet = saveDeviceSettingsRead(&device, &read); assert_true(errorIsOk(readRet)); - assert_int_equal(read.someSetting, 88); + assert_true(read.audioMasterVolume == 0.25f); } // ============================================================ diff --git a/test/save/test_savedevicelinux.c b/test/save/test_savedevicelinux.c index 4369f75a..10df93f5 100644 --- a/test/save/test_savedevicelinux.c +++ b/test/save/test_savedevicelinux.c @@ -9,8 +9,8 @@ #include "savetestfixture.h" #include "save/savedevice.h" #include "save/savedevicelinux.h" -#include "save/saveslot.h" -#include "save/savesettings.h" +#include "save/slot/saveslot.h" +#include "save/settings/savesettings.h" #include "util/memory.h" #include "util/string.h" #include @@ -298,7 +298,7 @@ static void test_settingsWriteRead_roundTrip(void **state) { savesettings_t written; saveSettingsInit(&written); - written.someSetting = 314; + written.audioMasterVolume = 3.25f;// exactly representable, safe to compare errorret_t writeRet = saveDeviceLinuxSettingsWrite(&device, &written); assert_true(errorIsOk(writeRet)); @@ -308,7 +308,7 @@ static void test_settingsWriteRead_roundTrip(void **state) { errorret_t readRet = saveDeviceLinuxSettingsRead(&device, &read); assert_true(errorIsOk(readRet)); - assert_int_equal(read.someSetting, 314); + assert_true(read.audioMasterVolume == 3.25f); } static void test_settingsRead_noFileYet_leavesUntouched(void **state) { @@ -317,11 +317,11 @@ static void test_settingsRead_noFileYet_leavesUntouched(void **state) { savesettings_t settings; saveSettingsInit(&settings); - settings.someSetting = 777; + settings.audioMasterVolume = 7.75f; errorret_t ret = saveDeviceLinuxSettingsRead(&device, &settings); assert_true(errorIsOk(ret)); - assert_int_equal(settings.someSetting, 777); + assert_true(settings.audioMasterVolume == 7.75f); } static void test_settingsRead_corruptFile_errors(void **state) { diff --git a/test/save/test_savesettings.c b/test/save/test_savesettings.c index 84c3c55a..4d761fb8 100644 --- a/test/save/test_savesettings.c +++ b/test/save/test_savesettings.c @@ -6,7 +6,7 @@ */ #include "dusktest.h" -#include "save/savesettings.h" +#include "save/settings/savesettings.h" #include "save/savejson.h" #include "util/memory.h" #include @@ -51,14 +51,14 @@ static errorret_t settingsFromJSON( // saveSettingsInit // ============================================================ -static void test_saveSettingsInit_zeroesSomeSetting(void **state) { +static void test_saveSettingsInit_resetsAudioMasterVolume(void **state) { savesettings_t settings; memoryZero(&settings, sizeof(settings)); - settings.someSetting = 42; + settings.audioMasterVolume = 42.0f; saveSettingsInit(&settings); - assert_int_equal(settings.someSetting, 0); + assert_true(settings.audioMasterVolume == 1.0f); } static void test_saveSettingsInit_nullAsserts(void **state) { @@ -85,7 +85,6 @@ static void test_saveSettingsReadJSON_nullAsserts(void **state) { savesettings_t settings; saveSettingsInit(&settings); writeInit(); - yyjson_mut_obj_add_int(doc, object, "someSetting", 1); size_t len; char_t *json = yyjson_mut_write(doc, 0, &len); @@ -105,7 +104,7 @@ static void test_saveSettingsReadJSON_nullAsserts(void **state) { static void test_saveSettingsWriteReadJSON_roundTrip(void **state) { savesettings_t written; saveSettingsInit(&written); - written.someSetting = 12345; + written.audioMasterVolume = 2.5f; char_t *json; size_t len; @@ -114,35 +113,59 @@ static void test_saveSettingsWriteReadJSON_roundTrip(void **state) { savesettings_t read; saveSettingsInit(&read); - read.someSetting = -1;// sentinel, should be overwritten + read.audioMasterVolume = -1.0f;// sentinel, should be overwritten errorret_t readRet = settingsFromJSON(&read, json); assert_true(errorIsOk(readRet)); - assert_int_equal(read.someSetting, 12345); + assert_true(read.audioMasterVolume == 2.5f); free(json); } -static void test_saveSettingsReadJSON_missingKeyDefaultsToZero(void **state) { +static void test_saveSettingsReadJSON_missingKeyUsesDefault(void **state) { + savesettings_t settings; + saveSettingsInit(&settings); + settings.audioMasterVolume = 999.0f;// sentinel + + errorret_t ret = settingsFromJSON(&settings, "{\"version\":1}"); + assert_true(errorIsOk(ret)); + assert_true(settings.audioMasterVolume == 1.0f); +} + +// ============================================================ +// saveSettingsReadJSON - version enforcement +// ============================================================ + +static void test_saveSettingsReadJSON_versionMissing_errors(void **state) { savesettings_t settings; saveSettingsInit(&settings); - settings.someSetting = 999;// sentinel errorret_t ret = settingsFromJSON(&settings, "{}"); - assert_true(errorIsOk(ret)); - assert_int_equal(settings.someSetting, 0); + assert_true(errorIsNotOk(ret)); + errorCatch(ret); +} + +static void test_saveSettingsReadJSON_versionMismatch_errors(void **state) { + savesettings_t settings; + saveSettingsInit(&settings); + + errorret_t ret = settingsFromJSON(&settings, "{\"version\":999}"); + assert_true(errorIsNotOk(ret)); + errorCatch(ret); } int main(void) { assertInit(); const struct CMUnitTest tests[] = { - cmocka_unit_test(test_saveSettingsInit_zeroesSomeSetting), + cmocka_unit_test(test_saveSettingsInit_resetsAudioMasterVolume), cmocka_unit_test(test_saveSettingsInit_nullAsserts), cmocka_unit_test(test_saveSettingsWriteJSON_nullAsserts), cmocka_unit_test(test_saveSettingsReadJSON_nullAsserts), cmocka_unit_test(test_saveSettingsWriteReadJSON_roundTrip), - cmocka_unit_test(test_saveSettingsReadJSON_missingKeyDefaultsToZero), + cmocka_unit_test(test_saveSettingsReadJSON_missingKeyUsesDefault), + cmocka_unit_test(test_saveSettingsReadJSON_versionMissing_errors), + cmocka_unit_test(test_saveSettingsReadJSON_versionMismatch_errors), }; return cmocka_run_group_tests(tests, NULL, NULL); diff --git a/test/save/test_saveslot.c b/test/save/test_saveslot.c index 5a6c2b33..9e1f4adc 100644 --- a/test/save/test_saveslot.c +++ b/test/save/test_saveslot.c @@ -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);