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());
rpgCameraInit();
// Init test world
errorChain(mapSetMap("overworld"));
// 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
// chunks' own spawn data (see rpg/overworld/map.c mapChunkLoaded).
+3 -3
View File
@@ -512,12 +512,12 @@
); \
} \
stringCopy( \
saveJsonStringBuffer, yyjson_get_str(saveJsonStrVal), (maxLength) \
saveJsonStringBuffer, yyjson_get_str(saveJsonStrVal), (maxLength) + 1 \
); \
} 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 "assert/assert.h"
#include "util/memory.h"
#include "util/string.h"
void saveSlotInit(saveslot_t *slot) {
assertNotNull(slot, "Slot cannot be null");
@@ -20,6 +21,24 @@ void saveSlotInit(saveslot_t *slot) {
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) {
assertNotNull(slot, "Slot cannot be null");
return slot->name[0] != '\0';
+14 -12
View File
@@ -17,29 +17,19 @@
#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.
#define SAVE_SLOT_MAP_NAME_DEFAULT "overworld"
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];
// 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;
} saveslotcache_t;
typedef struct saveslot_s {
uint8_t version;
uint8_t dataType;
saveslotcache_t cachedData;
party_t party;
} saveslot_t;
@@ -53,6 +43,18 @@ typedef struct saveslot_s {
*/
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
* 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(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;
memoryZero(&ver1, sizeof(ver1));
errorChain(saveSlotVer1ReadJSON(&ver1, object));
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);
readString("name", slot->name, "", SAVE_SLOT_VER1_NAME_LENGTH);
if(slot->name[0] == '\0') errorOk();
readTime("time", slot->time);
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(
"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;
saveslot_t slot;
saveSlotInit(&slot);
saveSlotNewGame(&slot);
stringCopy(slot.cachedData.name, text, SAVE_SLOT_NAME_LENGTH);
// No save device available - proceed with an in-memory-only slot rather
+11
View File
@@ -20,6 +20,7 @@
#include "scene/scene.h"
#include "asset/asset.h"
#include "save/save.h"
#include "rpg/overworld/map.h"
#define UI_MAIN_MENU_INDEX_START_GAME 0
#define UI_MAIN_MENU_INDEX_OPTIONS 1
@@ -58,6 +59,16 @@ void uiMainMenuSelectSaveResult(const uint8_t slotIndex, void *user) {
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);
}