Load the save's map on select, seed a real new-game slot, fix readString off-by-one

New games now go through saveSlotNewGame (starting party + default map
name) instead of the empty saveSlotInit, and selecting a save loads its
stored map instead of the hardcoded boot-time overworld. Save loading now
requires an in-use slot to have a non-empty map name, treating a blank one
as corruption instead of silently faking a default.

Also fixes a real off-by-one in the readString save-JSON macro: it passed
its maxLength (documented as excluding the null terminator) straight
through as stringCopy's destSize, which needs +1 for the terminator - so a
string exactly at the length limit (e.g. "overworld", 9 chars) always
asserted "src is too long".

Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
2026-09-10 23:14:44 -05:00
co-authored by Claude Sonnet 5
parent 0566166af4
commit 83e646cba9
8 changed files with 66 additions and 19 deletions
-3
View File
@@ -33,9 +33,6 @@ errorret_t rpgInit(void) {
errorChain(mapInit()); errorChain(mapInit());
rpgCameraInit(); rpgCameraInit();
// Init test world
errorChain(mapSetMap("overworld"));
// The player is the one entity that isn't sourced from map/chunk data - // The player is the one entity that isn't sourced from map/chunk data -
// every other entity (NPCs, items) and map area comes from the loaded // every other entity (NPCs, items) and map area comes from the loaded
// chunks' own spawn data (see rpg/overworld/map.c mapChunkLoaded). // chunks' own spawn data (see rpg/overworld/map.c mapChunkLoaded).
+3 -3
View File
@@ -512,12 +512,12 @@
); \ ); \
} \ } \
stringCopy( \ stringCopy( \
saveJsonStringBuffer, yyjson_get_str(saveJsonStrVal), (maxLength) \ saveJsonStringBuffer, yyjson_get_str(saveJsonStrVal), (maxLength) + 1 \
); \ ); \
} else { \ } else { \
stringCopy(saveJsonStringBuffer, (def), (maxLength)); \ stringCopy(saveJsonStringBuffer, (def), (maxLength) + 1); \
} \ } \
stringCopy((dest), saveJsonStringBuffer, (maxLength)); \ stringCopy((dest), saveJsonStringBuffer, (maxLength) + 1); \
} }
/** /**
+19
View File
@@ -9,6 +9,7 @@
#include "save/slot/saveslotcurrent.h" #include "save/slot/saveslotcurrent.h"
#include "assert/assert.h" #include "assert/assert.h"
#include "util/memory.h" #include "util/memory.h"
#include "util/string.h"
void saveSlotInit(saveslot_t *slot) { void saveSlotInit(saveslot_t *slot) {
assertNotNull(slot, "Slot cannot be null"); assertNotNull(slot, "Slot cannot be null");
@@ -20,6 +21,24 @@ void saveSlotInit(saveslot_t *slot) {
partyInit(&slot->party); partyInit(&slot->party);
} }
void saveSlotNewGame(saveslot_t *slot) {
assertNotNull(slot, "Slot cannot be null");
saveSlotInit(slot);
stringCopy(
slot->cachedData.mapName, SAVE_SLOT_MAP_NAME_DEFAULT,
sizeof(slot->cachedData.mapName)
);
// TEMPORARY: placeholder starting party, same stats shape as
// rpg/battle/testbattle/testbattle.c's mock allies - replace once
// there's a real starting-party/character-creation flow.
const battlefighterstats_t startingStats =
{ .attack = 10, .defense = 5, .magic = 0, .speed = 10, .luck = 0 };
partyAddMember(&slot->party, startingStats, 30, 10);
}
bool_t saveSlotInUse(saveslotcache_t *slot) { bool_t saveSlotInUse(saveslotcache_t *slot) {
assertNotNull(slot, "Slot cannot be null"); assertNotNull(slot, "Slot cannot be null");
return slot->name[0] != '\0'; return slot->name[0] != '\0';
+14 -12
View File
@@ -17,29 +17,19 @@
#define SAVE_SLOT_COUNT 3 #define SAVE_SLOT_COUNT 3
#endif #endif
// The stable, version-independent in-memory shape of a save slot - what the #define SAVE_SLOT_MAP_NAME_DEFAULT "overworld"
// 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 { typedef struct {
char_t name[SAVE_SLOT_NAME_LENGTH + 1];// 8 characters + null terminator char_t name[SAVE_SLOT_NAME_LENGTH + 1];// 8 characters + null terminator
dusktimeepoch_t time; dusktimeepoch_t time;
int32_t playerLevel; int32_t playerLevel;
char_t mapName[MAP_NAME_MAX]; char_t mapName[MAP_NAME_MAX];
// Set when this slot's on-disk file failed to load (corrupt or an
// incompatible schema version) and was reset to an empty slot instead -
// see saveLoadSlot() (save/save.c).
bool_t corrupt; bool_t corrupt;
} saveslotcache_t; } saveslotcache_t;
typedef struct saveslot_s { typedef struct saveslot_s {
uint8_t version; uint8_t version;
uint8_t dataType; uint8_t dataType;
saveslotcache_t cachedData; saveslotcache_t cachedData;
party_t party; party_t party;
} saveslot_t; } saveslot_t;
@@ -53,6 +43,18 @@ typedef struct saveslot_s {
*/ */
void saveSlotInit(saveslot_t *slot); void saveSlotInit(saveslot_t *slot);
/**
* Sets up the save slot for a brand new game: calls saveSlotInit, then
* seeds it with a starting party. Use this (not saveSlotInit) whenever a
* new save is actually being created for a player - saveSlotInit alone
* leaves the party empty, which every other caller (resetting a slot
* before a load attempt, filling in an untouched slot during a raw
* device write, wiping a deleted slot) wants.
*
* @param slot The save slot to set up.
*/
void saveSlotNewGame(saveslot_t *slot);
/** /**
* Checks if the save slot is in use, this is determined by checking if the * Checks if the save slot is in use, this is determined by checking if the
* player name is set or not. * player name is set or not.
+4
View File
@@ -43,7 +43,11 @@ errorret_t saveSlotCurrentReadJSON(saveslot_t *slot, yyjson_val *object) {
assertNotNull(slot, "Slot cannot be null"); assertNotNull(slot, "Slot cannot be null");
assertNotNull(object, "Object cannot be null"); assertNotNull(object, "Object cannot be null");
// Zeroed up front: saveSlotVer1ReadJSON returns early (leaving everything
// past "name" untouched) for a blank/never-used slot, so this must not be
// left as uninitialized stack garbage.
saveslotver1_t ver1; saveslotver1_t ver1;
memoryZero(&ver1, sizeof(ver1));
errorChain(saveSlotVer1ReadJSON(&ver1, object)); errorChain(saveSlotVer1ReadJSON(&ver1, object));
stringCopy(slot->cachedData.name, ver1.name, sizeof(slot->cachedData.name)); stringCopy(slot->cachedData.name, ver1.name, sizeof(slot->cachedData.name));
+14
View File
@@ -58,8 +58,22 @@ errorret_t saveSlotVer1ReadJSON(saveslotver1_t *slot, yyjson_val *object) {
requireVersion(SAVE_SLOT_CURRENT); requireVersion(SAVE_SLOT_CURRENT);
readString("name", slot->name, "", SAVE_SLOT_VER1_NAME_LENGTH); readString("name", slot->name, "", SAVE_SLOT_VER1_NAME_LENGTH);
if(slot->name[0] == '\0') errorOk();
readTime("time", slot->time); readTime("time", slot->time);
readInt32("playerLevel", slot->playerLevel, 1); readInt32("playerLevel", slot->playerLevel, 1);
// A blank/never-used slot returned early above (see saveSlotInUse -
// keyed off "name"), so name is guaranteed non-empty here - and an
// in-use slot always has a map name too (see saveSlotNewGame). Missing,
// null, or empty means this save is corrupt.
yyjson_val *mapNameVal = yyjson_obj_get(object, "mapName");
if(
mapNameVal == NULL || !yyjson_is_str(mapNameVal) ||
yyjson_get_len(mapNameVal) == 0
) {
errorThrow("Save JSON 'mapName' must be a non-empty string");
}
readString( readString(
"mapName", slot->mapName, "", SAVE_SLOT_VER1_MAP_NAME_MAX - 1 "mapName", slot->mapName, "", SAVE_SLOT_VER1_MAP_NAME_MAX - 1
); );
+1 -1
View File
@@ -138,7 +138,7 @@ void uiSelectSaveNameEntered(
const uint8_t index = UI_SELECT_SAVE.pendingNameIndex; const uint8_t index = UI_SELECT_SAVE.pendingNameIndex;
saveslot_t slot; saveslot_t slot;
saveSlotInit(&slot); saveSlotNewGame(&slot);
stringCopy(slot.cachedData.name, text, SAVE_SLOT_NAME_LENGTH); stringCopy(slot.cachedData.name, text, SAVE_SLOT_NAME_LENGTH);
// No save device available - proceed with an in-memory-only slot rather // No save device available - proceed with an in-memory-only slot rather
+11
View File
@@ -20,6 +20,7 @@
#include "scene/scene.h" #include "scene/scene.h"
#include "asset/asset.h" #include "asset/asset.h"
#include "save/save.h" #include "save/save.h"
#include "rpg/overworld/map.h"
#define UI_MAIN_MENU_INDEX_START_GAME 0 #define UI_MAIN_MENU_INDEX_START_GAME 0
#define UI_MAIN_MENU_INDEX_OPTIONS 1 #define UI_MAIN_MENU_INDEX_OPTIONS 1
@@ -58,6 +59,16 @@ void uiMainMenuSelectSaveResult(const uint8_t slotIndex, void *user) {
return; return;
} }
assertStrLenMin(
SAVE.slot.cachedData.mapName, 1, "Save slot has no map name"
);
errorret_t mapResult = mapSetMap(SAVE.slot.cachedData.mapName);
if(errorIsNotOk(mapResult)) {
errorCatch(errorPrint(mapResult));
uiFatalErrorOpen(mapResult.state->message);
return;
}
sceneSet(SCENE_TYPE_OVERWORLD); sceneSet(SCENE_TYPE_OVERWORLD);
} }