Persist party across saves and flag/warn on corrupt save slots

Party moves off a global singleton onto each save slot, letting
START_BATTLE pick specific active-order members to bring into a fight,
and round-trips through save/load via saveslotver1. A corrupt slot load
now flags the cache and shows a red "Corrupted Save" warning in the
select-save UI instead of silently resetting.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
2026-09-10 14:19:03 -05:00
co-authored by Claude Sonnet 5
parent ed0b8c3911
commit 0566166af4
24 changed files with 441 additions and 33 deletions
+24
View File
@@ -0,0 +1,24 @@
{
"items": [
{
"type": "START_BATTLE",
"encounterType": "REGULAR",
"fleeAvailable": true,
"enemies": [
{
"stats": { "attack": 8, "defense": 4, "magic": 0, "speed": 5, "luck": 2 },
"healthMax": 30,
"mpMax": 0
},
{
"stats": { "attack": 6, "defense": 3, "magic": 0, "speed": 7, "luck": 2 },
"healthMax": 20,
"mpMax": 0
}
]
},
{
"type": "REGULAR_BATTLE"
}
]
}
+6
View File
@@ -73,6 +73,12 @@ msgstr "Select Save"
msgid "ui.select_save.empty"
msgstr "Empty Slot"
msgid "ui.select_save.corrupt"
msgstr "Corrupted Save"
msgid "ui.select_save.corrupt_warning"
msgstr "This save file is corrupted and could not be loaded. Please contact support if this issue persists."
msgid "ui.select_save.slot_format"
msgstr "%s Lv.%d %s"
@@ -1 +1 @@
{"tiles":[[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],null,null,null,null,null,[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],null,null,null,null,null,[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],null,null,null,null,null,[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[6,0],[2,0],[2,0],[2,0],[2,0],[2,0],[2,0],[7,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[3,0],[1,1],[1,1],[1,1],[1,1],[1,1],[1,1],[5,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[3,0],[1,1],[1,1],[1,1],[1,1],[1,1],[1,1],[5,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[3,0],[1,1],[1,1],[1,1],[1,1],[1,1],[1,1],[5,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[8,0],[4,0],[4,0],[4,0],[12,0],[1,1],[1,1],[5,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[3,0],[1,1],[1,1],[5,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[3,0],[1,1],[1,1],[5,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[8,0],[4,0],[4,0],[9,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0]],"meshes":[{"file":"house_5_3.dmf","pos":[1,1,0]}],"entities":[{"type":"global","globalId":3,"pos":[8,8,1]},{"type":"item","itemId":1,"quantity":1,"pos":[12,2,0]}],"areas":[{"min":[11,3,0],"max":[16,9,10],"callbackId":1,"notify":3,"trigger":6}]}
{"tiles":[[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],null,null,null,null,null,[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],null,null,null,null,null,[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],null,null,null,null,null,[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[6,0],[2,0],[2,0],[2,0],[2,0],[2,0],[2,0],[7,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[3,0],[1,1],[1,1],[1,1],[1,1],[1,1],[1,1],[5,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[3,0],[1,1],[1,1],[1,1],[1,1],[1,1],[1,1],[5,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[3,0],[1,1],[1,1],[1,1],[1,1],[1,1],[1,1],[5,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[8,0],[4,0],[4,0],[4,0],[12,0],[1,1],[1,1],[5,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[3,0],[1,1],[1,1],[5,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[3,0],[1,1],[1,1],[5,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[8,0],[4,0],[4,0],[9,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0],[1,0]],"meshes":[{"file":"house_5_3.dmf","pos":[1,1,0]}],"entities":[{"type":"global","globalId":3,"pos":[8,8,1]},{"type":"global","globalId":4,"pos":[12,2,0]}],"areas":[{"min":[11,3,0],"max":[16,9,10],"callbackId":1,"notify":3,"trigger":6}]}
+25 -16
View File
@@ -9,63 +9,72 @@
#include "assert/assert.h"
#include "util/memory.h"
party_t PARTY;
void partyInit(party_t *party) {
assertNotNull(party, "Party cannot be null");
void partyInit(void) {
memoryZero(&PARTY, sizeof(party_t));
memoryZero(party, sizeof(party_t));
for(uint8_t i = 0; i < PARTY_MEMBER_COUNT_MAX; i++) {
PARTY.members[i].id = i;
party->members[i].id = i;
}
for(uint8_t i = 0; i < PARTY_ACTIVE_SIZE_MAX; i++) {
PARTY.order[i] = PARTY_ORDER_EMPTY;
party->order[i] = PARTY_ORDER_EMPTY;
}
}
uint8_t partyGetAvailableMember(void) {
uint8_t partyGetAvailableMember(const party_t *party) {
assertNotNull(party, "Party cannot be null");
for(uint8_t i = 0; i < PARTY_MEMBER_COUNT_MAX; i++) {
if(PARTY.members[i].status == BATTLE_FIGHTER_STATUS_NULL) return i;
if(party->members[i].status == BATTLE_FIGHTER_STATUS_NULL) return i;
}
return 0xFF;
}
battlefighter_t *partyAddMember(
party_t *party,
const battlefighterstats_t stats,
const uint16_t healthMax,
const uint16_t mpMax
) {
const uint8_t index = partyGetAvailableMember();
assertNotNull(party, "Party cannot be null");
const uint8_t index = partyGetAvailableMember(party);
if(index == 0xFF) return NULL;
battlefighter_t *member = &PARTY.members[index];
battlefighter_t *member = &party->members[index];
battleFighterInit(
member, BATTLE_FIGHTER_TEAM_ALLY, BATTLE_FIGHTER_CONTROLLER_PLAYER,
stats, healthMax, mpMax
);
for(uint8_t i = 0; i < PARTY_ACTIVE_SIZE_MAX; i++) {
if(PARTY.order[i] != PARTY_ORDER_EMPTY) continue;
PARTY.order[i] = index;
if(party->order[i] != PARTY_ORDER_EMPTY) continue;
party->order[i] = index;
break;
}
return member;
}
battlefighter_t *partyGetOrderMember(const uint8_t slot) {
battlefighter_t *partyGetOrderMember(party_t *party, const uint8_t slot) {
assertNotNull(party, "Party cannot be null");
assertTrue(slot < PARTY_ACTIVE_SIZE_MAX, "Invalid party order slot");
const uint8_t index = PARTY.order[slot];
const uint8_t index = party->order[slot];
if(index == PARTY_ORDER_EMPTY) return NULL;
return &PARTY.members[index];
return &party->members[index];
}
void partySetOrder(const uint8_t slot, const uint8_t memberIndex) {
void partySetOrder(
party_t *party, const uint8_t slot, const uint8_t memberIndex
) {
assertNotNull(party, "Party cannot be null");
assertTrue(slot < PARTY_ACTIVE_SIZE_MAX, "Invalid party order slot");
assertTrue(
memberIndex == PARTY_ORDER_EMPTY || memberIndex < PARTY_MEMBER_COUNT_MAX,
"Invalid party member index"
);
PARTY.order[slot] = memberIndex;
party->order[slot] = memberIndex;
}
+14 -7
View File
@@ -22,25 +22,27 @@ typedef struct {
uint8_t order[PARTY_ACTIVE_SIZE_MAX];
} party_t;
extern party_t PARTY;
/**
* Initializes the party system with an empty roster and order.
* Initializes the party with an empty roster and order.
*
* @param party The party to initialize.
*/
void partyInit(void);
void partyInit(party_t *party);
/**
* Gets an available (unused) party member slot index.
*
* @param party The party to query.
* @return The index of an available slot, or 0xFF if the party is full.
*/
uint8_t partyGetAvailableMember(void);
uint8_t partyGetAvailableMember(const party_t *party);
/**
* Adds a member to the party roster in the next available slot. Party
* members are always allies controlled by the player. If there is a
* free active order slot, the new member is placed into it.
*
* @param party The party to add the member to.
* @param stats The member's base combat stats.
* @param healthMax The member's maximum health.
* @param mpMax The member's maximum mp.
@@ -48,6 +50,7 @@ uint8_t partyGetAvailableMember(void);
* already full.
*/
battlefighter_t *partyAddMember(
party_t *party,
const battlefighterstats_t stats,
const uint16_t healthMax,
const uint16_t mpMax
@@ -56,18 +59,22 @@ battlefighter_t *partyAddMember(
/**
* Gets the roster member currently occupying an active order slot.
*
* @param party The party to query.
* @param slot The active order slot to query.
* @return Pointer to the member in that slot, or NULL if the slot is
* empty.
*/
battlefighter_t *partyGetOrderMember(const uint8_t slot);
battlefighter_t *partyGetOrderMember(party_t *party, const uint8_t slot);
/**
* Assigns a roster member to an active order slot, replacing whatever
* was there. Use PARTY_ORDER_EMPTY to clear a slot.
*
* @param party The party to modify.
* @param slot The active order slot to assign.
* @param memberIndex The roster member index to place there, or
* PARTY_ORDER_EMPTY to clear the slot.
*/
void partySetOrder(const uint8_t slot, const uint8_t memberIndex);
void partySetOrder(
party_t *party, const uint8_t slot, const uint8_t memberIndex
);
@@ -8,4 +8,5 @@ target_sources(${DUSK_LIBRARY_TARGET_NAME}
cutscenestartbattle.c
cutscenebattlewaitstate.c
cutscenebattleforceaction.c
cutsceneregularbattle.c
)
@@ -0,0 +1,15 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#include "rpg/cutscene/item/cutsceneitem.h"
bool_t cutsceneRegularBattleUpdate(
const cutsceneitem_t *item,
cutsceneitemdata_t *data
) {
return false;
}
@@ -0,0 +1,26 @@
/**
* Copyright (c) 2026 Dominic Masters
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
#pragma once
#include "rpg/cutscene/item/cutsceneitembase.h"
/**
* Updates a regular-battle step. Currently a placeholder - behaves exactly
* like IDLE (see cutsceneidle.h) and never completes on its own.
*
* @param item The cutscene item.
* @param data Runtime data storage.
* @returns false always.
*/
bool_t cutsceneRegularBattleUpdate(
const cutsceneitem_t *item,
cutsceneitemdata_t *data
);
// REGULAR_BATTLE has no fields of its own to parse yet - see
// cutsceneItemLoadNoop (cutsceneitembase.h), used directly as its .load
// callback.
@@ -8,6 +8,7 @@
#include "rpg/cutscene/item/cutsceneitem.h"
#include "rpg/battle/party.h"
#include "scene/scene.h"
#include "save/save.h"
#include "rpg/cutscene/item/json/cutscenejsonbattleencounter.h"
#include "rpg/cutscene/item/json/cutscenejsonfield.h"
@@ -19,8 +20,9 @@ void cutsceneStartBattleStart(
battleInit();
for(uint8_t i = 0; i < PARTY_ACTIVE_SIZE_MAX; i++) {
battlefighter_t *member = partyGetOrderMember(i);
for(uint8_t i = 0; i < config->playerCount; i++) {
battlefighter_t *member =
partyGetOrderMember(&SAVE.slot.party, config->players[i]);
if(member == NULL) continue;
battlefighter_t *fighter = battleAddFighter(
@@ -52,13 +54,15 @@ bool_t cutsceneStartBattleUpdate(
) {
if(BATTLE.result == BATTLE_RESULT_NONE) return false;
const cutscenestartbattle_t *config = &item->startBattle;
// Sync ally HP/MP back to the persistent party roster. Relies on
// ally fighters having been added to BATTLE.fighters in the same
// order partyGetOrderMember() iterates, starting at index 0 (see
// cutsceneStartBattleStart).
// order config->players iterates (see cutsceneStartBattleStart).
uint8_t allySlot = 0;
for(uint8_t i = 0; i < PARTY_ACTIVE_SIZE_MAX; i++) {
battlefighter_t *member = partyGetOrderMember(i);
for(uint8_t i = 0; i < config->playerCount; i++) {
battlefighter_t *member =
partyGetOrderMember(&SAVE.slot.party, config->players[i]);
if(member == NULL) continue;
battlefighter_t *fighter = &BATTLE.fighters[allySlot++];
@@ -89,6 +93,38 @@ errorret_t cutsceneStartBattleLoad(
item->startBattle.fleeAvailable =
cutsceneJsonGetBool(itemObj, "fleeAvailable", true);
yyjson_val *players = yyjson_obj_get(itemObj, "players");
if(players == NULL) {
item->startBattle.playerCount = PARTY_ACTIVE_SIZE_MAX;
for(uint8_t i = 0; i < PARTY_ACTIVE_SIZE_MAX; i++) {
item->startBattle.players[i] = i;
}
} else {
size_t playerCount = yyjson_arr_size(players);
if(!yyjson_is_arr(players) || playerCount > PARTY_ACTIVE_SIZE_MAX) {
errorThrow(
"Cutscene item (START_BATTLE): 'players' must have 0-%u entries",
(uint32_t)PARTY_ACTIVE_SIZE_MAX
);
}
item->startBattle.playerCount = (uint8_t)playerCount;
yyjson_arr_iter playerIter = yyjson_arr_iter_with(players);
yyjson_val *playerVal;
size_t p = 0;
while((playerVal = yyjson_arr_iter_next(&playerIter)) != NULL) {
int64_t index = yyjson_get_int(playerVal);
if(index < 0 || index >= PARTY_ACTIVE_SIZE_MAX) {
errorThrow(
"Cutscene item (START_BATTLE): players[%u] must be 0-%u",
(uint32_t)p, (uint32_t)(PARTY_ACTIVE_SIZE_MAX - 1)
);
}
item->startBattle.players[p] = (uint8_t)index;
p++;
}
}
yyjson_val *enemies = yyjson_obj_get(itemObj, "enemies");
size_t enemyCount = yyjson_arr_size(enemies);
if(!yyjson_is_arr(enemies) || enemyCount == 0 ||
@@ -7,6 +7,7 @@
#pragma once
#include "rpg/battle/battle.h"
#include "rpg/battle/party.h"
#include "rpg/cutscene/item/cutsceneitembase.h"
#define CUTSCENE_START_BATTLE_ENEMY_COUNT_MAX 4
@@ -22,6 +23,13 @@ typedef struct {
bool_t fleeAvailable;
uint8_t enemyCount;
cutscenestartbattleenemy_t enemies[CUTSCENE_START_BATTLE_ENEMY_COUNT_MAX];
// Active party order slots (see party.h) to bring into the battle, in
// the order they should be added. Defaults to every active slot, 0
// through PARTY_ACTIVE_SIZE_MAX - 1 in order, when the item's JSON
// omits "players" - see cutsceneStartBattleLoad.
uint8_t players[PARTY_ACTIVE_SIZE_MAX];
uint8_t playerCount;
} cutscenestartbattle_t;
/**
@@ -327,6 +327,12 @@ cutsceneitemcallbacks_t CUTSCENE_ITEM_CALLBACKS[CUTSCENE_ITEM_TYPE_COUNT] = {
.update = cutsceneUIShowUpdate,
.jsonName = "UI_SHOW",
.load = cutsceneUIShowLoad
},
[CUTSCENE_ITEM_TYPE_REGULAR_BATTLE] = {
.update = cutsceneRegularBattleUpdate,
.jsonName = "REGULAR_BATTLE",
.load = cutsceneItemLoadNoop
}
};
@@ -41,6 +41,7 @@
#include "battle/cutscenestartbattle.h"
#include "battle/cutscenebattlewaitstate.h"
#include "battle/cutscenebattleforceaction.h"
#include "battle/cutsceneregularbattle.h"
#include "save/cutscenesavedevicecheck.h"
#include "save/cutscenesaveloadallslots.h"
#include "audio/cutsceneaudioplay.h"
@@ -118,6 +119,7 @@ typedef enum {
CUTSCENE_ITEM_TYPE_AUDIO_SET,
CUTSCENE_ITEM_TYPE_IDLE,
CUTSCENE_ITEM_TYPE_UI_SHOW,
CUTSCENE_ITEM_TYPE_REGULAR_BATTLE,
CUTSCENE_ITEM_TYPE_COUNT
} cutsceneitemtype_t;
@@ -8,6 +8,7 @@
#pragma once
#include "entityglobaldefs.h"
#include "rpg/cutscene/scene/testcutscene.h"
#include "rpg/cutscene/item/cutsceneitem.h"
ENTITY_GLOBAL_CALLBACK(3) {
create->entity->data.npc.moveType = NPC_MOVE_TYPE_PATH;
@@ -20,11 +21,17 @@ ENTITY_GLOBAL_CALLBACK(3) {
create->entity->interact.data.cutscene = CUTSCENE_REFERENCE(TEST_TWO);
}
ENTITY_GLOBAL_CALLBACK(4) {
create->entity->interact.type = ENTITY_INTERACT_CUTSCENE;
create->entity->interact.data.cutscene = cutsceneCutsceneResolve("battletest");
}
static const entityglobaldef_t ENTITY_GLOBAL_LIST[] = {
ENTITY_GLOBAL(ENTITY_GLOBAL_ID_NULL, ENTITY_TYPE_NULL, NULL),
ENTITY_GLOBAL(ENTITY_GLOBAL_ID_PLAYER, ENTITY_TYPE_PLAYER, NULL),
ENTITY_GLOBAL(3, ENTITY_TYPE_NPC, ENTITY_GLOBAL_REF(3)),
ENTITY_GLOBAL(4, ENTITY_TYPE_NPC, ENTITY_GLOBAL_REF(4)),
};
//EOF
+2 -1
View File
@@ -12,6 +12,7 @@
#include "rpg/cutscene/cutscenesystem.h"
#include "rpg/item/backpack.h"
#include "rpg/battle/party.h"
#include "save/save.h"
#include "ui/rpg/textbox/uitextboxminilist.h"
#include "time/time.h"
#include "rpgcamera.h"
@@ -27,7 +28,7 @@ errorret_t rpgInit(void) {
memoryZero(MAP_AREAS, sizeof(MAP_AREAS));
backpackInit();
partyInit();
partyInit(&SAVE.slot.party);
cutsceneSystemInit();
errorChain(mapInit());
rpgCameraInit();
+1
View File
@@ -246,6 +246,7 @@ errorret_t saveLoadSlot() {
(uint32_t)SAVE.slotCurrent
);
saveSlotInit(&SAVE.slot);
SAVE.slot.cachedData.corrupt = true;
}
+113
View File
@@ -353,6 +353,119 @@
} \
}
/**
* Attaches an already-built mutable value (e.g. a yyjson_mut_arr() of
* per-element objects the caller populated by hand) to the current JSON
* object under the given key.
*
* @param key The key to write to.
* @param value The yyjson_mut_val* to attach.
*/
#define writeValue(key, value) yyjson_mut_obj_add_val(doc, object, key, value)
/**
* Appends a new JSON object to the given mutable array as one element and
* declares `object` in the calling scope pointing at it, shadowing any
* outer `object` - so the writeX macros above, once called inside the loop
* that builds `arr`, populate this element instead of the outer object.
*
* @param arr The yyjson_mut_val* array (from yyjson_mut_arr()) to append to.
*/
#define writeArrayObject(arr) \
yyjson_mut_val *object = yyjson_mut_arr_add_obj(doc, arr)
/**
* Requires a key on the current JSON object to be a JSON array of exactly
* the given size, throwing otherwise. Declares `dest` (a yyjson_val*) in
* the calling scope pointing at the array, ready for the caller to iterate
* (e.g. with yyjson_arr_foreach) - unlike the readXArray macros, this
* doesn't copy the elements out itself, since each element here is a
* nested object the caller parses by hand.
*
* @param key The key to read from.
* @param dest The yyjson_val* variable to declare, pointing at the array.
* @param count The exact number of elements the array must have.
*/
#define requireArrayOfSize(key, dest, count) \
yyjson_val *dest = yyjson_obj_get(object, key); \
if((dest) == NULL) errorThrow("Save JSON missing '%s' key", key); \
if(yyjson_arr_size(dest) != (size_t)(count)) { \
errorThrow( \
"Save JSON array '%s' has wrong length, expected %d", \
key, (int)(count) \
); \
}
/**
* Checks if the given key exists on the current JSON object.
*
* @param key The key to check for.
*/
#define hasUInt8Array(key) (yyjson_obj_get(object, key) != NULL)
/**
* Errors if the given key does not exist on the current JSON object.
*
* @param key The key that must exist.
*/
#define requireUInt8Array(key) \
if(!hasUInt8Array(key)) errorThrow("Save JSON missing '%s' key", key)
/**
* Writes a uint8_t array to the current JSON object.
*
* @param key The key to write to.
* @param value The array to write.
* @param count Number of elements in the array.
*/
#define writeUInt8Array(key, value, count) { \
yyjson_mut_val *saveJsonArr = yyjson_mut_arr(doc); \
size_t saveJsonArrIdx = 0; \
while(saveJsonArrIdx < (size_t)(count)) { \
yyjson_mut_arr_add_uint( \
doc, saveJsonArr, (uint64_t)(value)[saveJsonArrIdx] \
); \
saveJsonArrIdx++; \
} \
yyjson_mut_obj_add_val(doc, object, key, saveJsonArr); \
}
/**
* Reads a uint8_t array from the current JSON object into dest, falling
* back to filling every element with def if the key is missing. Errors if
* the JSON array's length doesn't exactly match count.
*
* @param key The key to read from.
* @param dest The destination array to assign to.
* @param count Number of elements dest holds.
* @param def The default value to fill every element with if the key is
* missing.
*/
#define readUInt8Array(key, dest, count, def) { \
yyjson_val *saveJsonArr = yyjson_obj_get(object, key); \
if(saveJsonArr != NULL) { \
if(yyjson_arr_size(saveJsonArr) != (size_t)(count)) { \
errorThrow( \
"Save JSON array '%s' has wrong length, expected %d", \
key, (int)(count) \
); \
} \
size_t saveJsonArrIdx, saveJsonArrLen; \
yyjson_val *saveJsonArrElem; \
yyjson_arr_foreach( \
saveJsonArr, saveJsonArrIdx, saveJsonArrLen, saveJsonArrElem \
) { \
(dest)[saveJsonArrIdx] = (uint8_t)yyjson_get_uint(saveJsonArrElem); \
} \
} else { \
size_t saveJsonArrIdx = 0; \
while(saveJsonArrIdx < (size_t)(count)) { \
(dest)[saveJsonArrIdx] = (uint8_t)(def); \
saveJsonArrIdx++; \
} \
} \
}
/**
* Checks if the given key exists on the current JSON object.
*
+5
View File
@@ -14,7 +14,10 @@ void saveSlotInit(saveslot_t *slot) {
assertNotNull(slot, "Slot cannot be null");
memorySet(slot, 0, sizeof(saveslot_t));
slot->version = 1;
partyInit(&slot->party);
}
bool_t saveSlotInUse(saveslotcache_t *slot) {
@@ -37,6 +40,8 @@ errorret_t saveSlotWriteJSON(
}
errorret_t saveSlotReadJSON(saveslot_t *slot, yyjson_val *object) {
saveSlotInit(slot);
errorChain(saveSlotCurrentReadJSON(slot, object));
errorOk();
}
+7
View File
@@ -8,6 +8,7 @@
#pragma once
#include "save/savedevice.h"
#include "rpg/overworld/map.h"
#include "rpg/battle/party.h"
#include "time/timeepoch.h"
#include "yyjson.h"
@@ -28,6 +29,11 @@ typedef struct {
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 {
@@ -35,6 +41,7 @@ typedef struct saveslot_s {
uint8_t dataType;
saveslotcache_t cachedData;
party_t party;
} saveslot_t;
/**
+11
View File
@@ -8,6 +8,7 @@
#include "saveslotcurrent.h"
#include "assert/assert.h"
#include "util/string.h"
#include "util/memory.h"
#include "time/time.h"
errorret_t saveSlotCurrentWriteJSON(
@@ -29,6 +30,11 @@ errorret_t saveSlotCurrentWriteJSON(
ver1.playerLevel = slot->cachedData.playerLevel;
stringCopy(ver1.mapName, slot->cachedData.mapName, sizeof(ver1.mapName));
memoryCopy(
ver1.partyMembers, slot->party.members, sizeof(ver1.partyMembers)
);
memoryCopy(ver1.partyOrder, slot->party.order, sizeof(ver1.partyOrder));
errorChain(saveSlotVer1WriteJSON(&ver1, doc, object));
errorOk();
}
@@ -47,5 +53,10 @@ errorret_t saveSlotCurrentReadJSON(saveslot_t *slot, yyjson_val *object) {
slot->cachedData.mapName, ver1.mapName, sizeof(slot->cachedData.mapName)
);
memoryCopy(
slot->party.members, ver1.partyMembers, sizeof(slot->party.members)
);
memoryCopy(slot->party.order, ver1.partyOrder, sizeof(slot->party.order));
errorOk();
}
+57
View File
@@ -24,6 +24,28 @@ errorret_t saveSlotVer1WriteJSON(
writeInt32("playerLevel", slot->playerLevel);
writeString("mapName", slot->mapName);
yyjson_mut_val *partyMembersArr = yyjson_mut_arr(doc);
for(uint8_t i = 0; i < PARTY_MEMBER_COUNT_MAX; i++) {
battlefighter_t *member = &slot->partyMembers[i];
writeArrayObject(partyMembersArr);
writeUInt32("id", member->id);
writeUInt32("status", member->status);
writeUInt32("team", member->team);
writeUInt32("controller", member->controller);
writeUInt32("health", member->health);
writeUInt32("healthMax", member->healthMax);
writeUInt32("mp", member->mp);
writeUInt32("mpMax", member->mpMax);
writeUInt32("statAttack", member->stats.attack);
writeUInt32("statDefense", member->stats.defense);
writeUInt32("statMagic", member->stats.magic);
writeUInt32("statSpeed", member->stats.speed);
writeUInt32("statLuck", member->stats.luck);
}
writeValue("partyMembers", partyMembersArr);
writeUInt8Array("partyOrder", slot->partyOrder, PARTY_ACTIVE_SIZE_MAX);
errorOk();
}
@@ -42,5 +64,40 @@ errorret_t saveSlotVer1ReadJSON(saveslotver1_t *slot, yyjson_val *object) {
"mapName", slot->mapName, "", SAVE_SLOT_VER1_MAP_NAME_MAX - 1
);
requireArrayOfSize(
"partyMembers", partyMembersArr, PARTY_MEMBER_COUNT_MAX
);
size_t memberIdx, memberLen;
yyjson_val *memberObj;
yyjson_arr_foreach(
partyMembersArr, memberIdx, memberLen, memberObj
) {
battlefighter_t *member = &slot->partyMembers[memberIdx];
// Shadows the outer `object` so readUInt32() below (which hardcodes
// that name) reads from this member's object instead.
yyjson_val *object = memberObj;
readUInt32("id", member->id, 0);
readUInt32("status", member->status, BATTLE_FIGHTER_STATUS_NULL);
readUInt32("team", member->team, BATTLE_FIGHTER_TEAM_ALLY);
readUInt32(
"controller", member->controller, BATTLE_FIGHTER_CONTROLLER_PLAYER
);
readUInt32("health", member->health, 0);
readUInt32("healthMax", member->healthMax, 0);
readUInt32("mp", member->mp, 0);
readUInt32("mpMax", member->mpMax, 0);
readUInt32("statAttack", member->stats.attack, 0);
readUInt32("statDefense", member->stats.defense, 0);
readUInt32("statMagic", member->stats.magic, 0);
readUInt32("statSpeed", member->stats.speed, 0);
readUInt32("statLuck", member->stats.luck, 0);
}
readUInt8Array(
"partyOrder", slot->partyOrder, PARTY_ACTIVE_SIZE_MAX,
PARTY_ORDER_EMPTY
);
errorOk();
}
+8 -1
View File
@@ -9,12 +9,16 @@
#include "error/error.h"
#include "time/timeepoch.h"
#include "save/slot/saveslotver.h"
#include "rpg/battle/party.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.
// underneath old save files, even if the live engine constants do. The
// party arrays below are the exception - saveSlotVer1ReadJSON() checks their
// JSON array lengths explicitly, so PARTY_MEMBER_COUNT_MAX/
// PARTY_ACTIVE_SIZE_MAX (rpg/battle/party.h) can be referenced directly.
#define SAVE_SLOT_VER1_NAME_LENGTH 8
#define SAVE_SLOT_VER1_MAP_NAME_MAX 10
@@ -28,6 +32,9 @@ typedef struct saveslotver1_s {
dusktimeepoch_t time;
int32_t playerLevel;
char_t mapName[SAVE_SLOT_VER1_MAP_NAME_MAX];
battlefighter_t partyMembers[PARTY_MEMBER_COUNT_MAX];
uint8_t partyOrder[PARTY_ACTIVE_SIZE_MAX];
} saveslotver1_t;
/**
+13 -1
View File
@@ -57,6 +57,16 @@ void uiSaveSlotSetHighlighted(
saveSlot->highlighted = highlighted;
}
bool_t uiSaveSlotIsCorrupt(const uisaveslot_t *saveSlot) {
assertNotNull(saveSlot, "Save slot cannot be NULL");
return saveSlot->corrupt;
}
void uiSaveSlotSetCorrupt(uisaveslot_t *saveSlot, const bool_t corrupt) {
assertNotNull(saveSlot, "Save slot cannot be NULL");
saveSlot->corrupt = corrupt;
}
uisaveslotdisplaytype_t uiSaveSlotGetDisplayType(const uisaveslot_t *saveSlot) {
assertNotNull(saveSlot, "Save slot cannot be NULL");
return saveSlot->displayType;
@@ -85,7 +95,9 @@ errorret_t uiSaveSlotDraw(
const float_t contentY = y + UI_FRAME_START_Y;
color_t color;
if(saveSlot->highlighted) {
if(saveSlot->corrupt) {
color = COLOR_RED;
} else if(saveSlot->highlighted) {
color = COLOR_RED;
} else if(saveSlot->displayType == UI_SAVE_SLOT_DISPLAY_TYPE_DELETE) {
color = COLOR_ORANGE;
+18
View File
@@ -42,6 +42,7 @@ typedef struct {
spritebatchsprite_t nameSprites[UI_SAVE_SLOT_NAME_SPRITES_MAX];
bool_t highlighted;
bool_t corrupt;
} uisaveslot_t;
/**
@@ -75,6 +76,23 @@ bool_t uiSaveSlotIsHighlighted(const uisaveslot_t *saveSlot);
*/
void uiSaveSlotSetHighlighted(uisaveslot_t *saveSlot, const bool_t highlighted);
/**
* Returns whether the save slot widget is flagged as corrupt.
*
* @param saveSlot The save slot widget to query.
* @returns True if flagged as corrupt.
*/
bool_t uiSaveSlotIsCorrupt(const uisaveslot_t *saveSlot);
/**
* Sets whether the save slot widget is flagged as corrupt - drawn in red
* regardless of highlighted/display-type state, taking priority over both.
*
* @param saveSlot The save slot widget to update.
* @param corrupt The new corrupt state.
*/
void uiSaveSlotSetCorrupt(uisaveslot_t *saveSlot, const bool_t corrupt);
/**
* Returns the save slot widget's display type.
*
+29
View File
@@ -7,6 +7,7 @@
#include "uiselectsave.h"
#include "ui/widget/uiframe.h"
#include "ui/widget/uimodal.h"
#include "ui/dialog/uiconfirm.h"
#include "ui/dialog/keyboard/uikeyboard.h"
#include "ui/overlay/uifatalerror.h"
@@ -31,6 +32,14 @@ void uiSelectSaveFormatSlot(
) {
saveslotcache_t *cache = &SAVE.caches[index];
if(cache->corrupt) {
errorret_t result = localeManagerGetString(
"ui.select_save.corrupt", buffer, bufferSize, 0
);
if(errorIsNotOk(result)) errorCatch(result);
return;
}
if(!saveSlotInUse(cache)) {
errorret_t result = localeManagerGetString(
"ui.select_save.empty", buffer, bufferSize, 0
@@ -58,6 +67,9 @@ void uiSelectSaveRefreshSlot(const uint8_t index) {
UI_SELECT_SAVE.type == UI_SELECT_SAVE_TYPE_DELETE ?
UI_SAVE_SLOT_DISPLAY_TYPE_DELETE : UI_SAVE_SLOT_DISPLAY_TYPE_NORMAL;
uiSaveSlotSetDisplayType(&UI_SELECT_SAVE.slots[index], displayType);
uiSaveSlotSetCorrupt(
&UI_SELECT_SAVE.slots[index], SAVE.caches[index].corrupt
);
}
void uiSelectSaveSetActionLabel(const uiselectsavetype_t type) {
@@ -170,6 +182,18 @@ void uiSelectSaveNameEmptySlot(const uint8_t index) {
uiKeyboardOpen(&open);
}
void uiSelectSaveWarnCorrupt(void) {
const char_t *options[] = { "ui.confirm.confirm" };
// Acknowledging just closes the warning - the player stays on the
// select-save list rather than being pushed into naming/overwriting
// the slot.
uiModalOpen(
"ui.select_save.corrupt", "ui.select_save.corrupt_warning",
options, 1,
NULL, NULL, NULL
);
}
bool_t uiSelectSaveMenuCancelled(const uimenu_t *menu) {
if(UI_SELECT_SAVE.type != UI_SELECT_SAVE_TYPE_DELETE) return false;
@@ -195,6 +219,11 @@ void uiSelectSaveSlotSelected(
return;
}
if(SAVE.caches[index].corrupt) {
uiSelectSaveWarnCorrupt();
return;
}
if(!saveSlotInUse(&SAVE.caches[index])) {
uiSelectSaveNameEmptySlot(index);
return;