From a79c4c92e2bcfcbefb7e782d593f98682fbcfae5 Mon Sep 17 00:00:00 2001 From: Geralt <84459734+MathisMartin31@users.noreply.github.com> Date: Sun, 10 May 2026 21:39:54 +0200 Subject: [PATCH] Updating the save mechanism by creating an intermediary SaveData struct (#472) * Make save changes their own branch * Make SaveData tags char[] instead of u32[] * Make Game and Options data saving independent * removed unused functions * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * mark save sections as valid only after they are actually written to SRAM * fix comparison between different integer types * Implement some suggestions from @ricfehr3 * Add extensive documentation on the structure of the savefile * fix doxygen doc generation * fix it again --------- Co-authored-by: MathisMartin31 Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- include/save.h | 41 +++++- source/game.c | 2 - source/game/options_menu.c | 2 +- source/main.c | 2 + source/save.c | 280 +++++++++++++++++++++++++++++++++---- 5 files changed, 290 insertions(+), 37 deletions(-) diff --git a/include/save.h b/include/save.h index fdfc4f8a..ca940d63 100644 --- a/include/save.h +++ b/include/save.h @@ -2,6 +2,27 @@ * @file save.h * * @brief Utils functions to save/load data structures from/to the SRAM. + * + * Here is an overwiew of the contents of a valid save file. + * + * ``` + * ┌─────────────┐ <-- SaveHeader + * │ Header │ + * ├─────────────┤ <-- SaveOptions + * │ Options │ + * ├─────────────┤ <-- SaveGame + * │ Engine vars │ + * │ Jokers │ + * └─────────────┘ + * ``` + * + * SaveHeader indicates save validity by its presence. + * SaveOptions contains options values set in the corresponding menu and apply to the game itself. + * SaveGame contains values tied to a run with round, ante, money, owned Jokers etc... + * + * @sa SaveHeader, SaveOptions, SaveGame + * + * I strongly recommend using `xxd -l 512 -e -g 4 ` to view the contents of the save */ #ifndef SAVE_H #define SAVE_H @@ -11,15 +32,27 @@ #include /** - * @brief Save game data to SRAM. + * @brief Save current run data to SRAM. */ -void save_game(); +void save_game(void); /** - * @brief Load game data from SRAM. + * @brief Load previous run data from SRAM. * * @sa save_game */ -void load_game(); +void load_game(void); + +/** + * @brief Save options values to SRAM. + */ +void save_options(void); + +/** + * @brief Load options values from SRAM. + * + * @sa save_options + */ +void load_options(void); #endif // SAVE_H diff --git a/source/game.c b/source/game.c index 5b4b7223..da840298 100644 --- a/source/game.c +++ b/source/game.c @@ -661,8 +661,6 @@ void game_init() jokers_available_to_shop_init(); - load_game(); - hands = max_hands; discards = max_discards; g_game_vars.timer = TM_ZERO; diff --git a/source/game/options_menu.c b/source/game/options_menu.c index 945395ce..4fedf162 100644 --- a/source/game/options_menu.c +++ b/source/game/options_menu.c @@ -473,7 +473,7 @@ static void back_on_pressed(void) { if (back_btn_is_save_state) { - save_game(); + save_options(); change_back_save_text(true); back_btn_is_save_state = false; } diff --git a/source/main.c b/source/main.c index 07d56985..1ab1b977 100644 --- a/source/main.c +++ b/source/main.c @@ -6,6 +6,7 @@ #include "gbalatro_sys8.h" #include "graphic_utils.h" #include "joker.h" +#include "save.h" #include "sprite.h" #include @@ -85,6 +86,7 @@ void init() // Initialize subsystems mmInitDefault((mm_addr)soundbank_bin, GBAL_MM_NUM_CHANNELS); + load_options(); affine_background_init(); sprite_init(); card_init(); diff --git a/source/save.c b/source/save.c index af722b38..d637867b 100644 --- a/source/save.c +++ b/source/save.c @@ -4,6 +4,7 @@ #include "save.h" #include "audio_utils.h" +#include "bitset.h" #include "game.h" #include "joker.h" #include "list.h" @@ -19,25 +20,31 @@ // - Memory is filled with 1s by default // (at least in mgba, not sure about real HW) -#define CHECK_BASE 0x0000 -#define GAME_BASE 0x0010 -#define LISTS_BASE 0x0060 +#define HEADER_ADDRESS 0x0 +#define OPTIONS_ADDRESS 0x10 +#define GAME_ADDRESS 0x30 + +#define SAVE_SECTION_FLAG_NONE 0 +#define SAVE_SECTION_FLAG_OPTIONS (1 << 0) +#define SAVE_SECTION_FLAG_GAME (1 << 1) #define CHECK_MAGIC 0x4C414247 // Spells GBAL, used to determine if the save data is junk #define CHECK_HASH_SIZE 7 #define GIT_HASH_START 17 // starts after "GBALATRO-VERSION:" in the gbalatro_version var +#define SAVE_LABEL_SIZE 16 + // clang-format off /** - * @brief SaveHeader for validation checks + * @brief SaveHeader for validation checks to be packed and written to SRAM for validation. + * Defined in this discussion as follows: https://github.com/GBALATRO/balatro-gba/discussions/450 * - * Structure holding save data header info to be packed and written to SRAM for validation. - * Defined in this discussion as follows: https://github.com/GBALATRO/balatro-gba/discussions/450 * word | Byte 0 | Byte 1 | Byte 2 | Byte 3 | name | purpose * -----|--------|--------|--------|--------|--------------|------------------------------------------------------------------ * 0 | 0x47 | 0x42 | 0x41 | 0x4C | MAGIC | Identify if proceeding data is valid and not junk, spells "GBAL" * 1 | Dirty | H[0] | H[1] | H[2] | GITHASH_LOW | Dirty flag, followed by the first 3 bytes of shortened git hash H * 2 | H[3] | H[4] | H[5] | H[6] | GITHASH_HIGH | Last 4 bytes of shortened git hash H, with a dirty flag + * 3 | SEC[0] | SEC[1] | SEC[2] | SEC[3] | VALID_SCTNS | Identifies whether each section of the save data is valid or not */ // clang-format on typedef struct SaveHeader @@ -45,8 +52,135 @@ typedef struct SaveHeader u32 magic; bool dirty; char githash[CHECK_HASH_SIZE]; + u32 valid_sections; } SaveHeader; +// clang-format off +/** + * @brief SaveOptions will only contain options data set in the Options Menu + * + * word | Byte 0 | Byte 1 | Byte 2 | Byte 3 | name | purpose + * -----|--------|--------|--------|--------|--------------|------------------------------------------------------------------ + * 0 | '-' | ' ' | 'O' | 'P' | TAG | Pretty tag to clearly visualize the Options section in a hex viewer + * 1 | 'T' | 'I' | 'O' | 'N' | - | Spells "- OPTIONS DATA -" + * 2 | 'S' | ' ' | 'D' | 'A' | - | - + * 3 | 'T' | 'A' | ' ' | '-' | - | - + * 4 | SPEED | CNTRST | MUSIC | SOUND | OPTN_VALUES | All 4 option values, packed in a single word. + * 5 | UNDEF | UNDEF | UNDEF | UNDEF | PADDING | Padding, so that the next section starts at the beginning of the + * 6 | UNDEF | UNDEF | UNDEF | UNDEF | - | next 4-word row in a hex viewer + * 7 | UNDEF | UNDEF | UNDEF | UNDEF | - | - + */ +// clang-format on +typedef struct SaveOptions +{ + char tag_options[SAVE_LABEL_SIZE]; + u8 game_speed; + bool high_contrast; + u8 music_volume; + u8 sound_volume; + u32 padding[3]; +} SaveOptions; + +/** + * @brief JokerObjectSaveData will hold the minimal amount of data necessary to reconstruct a Joker. + * The `id` is a u8 in the base Joker struct, but I made it a u32 here to keep + * a better aligment when looking at the save file in a hex viewer. + */ +typedef struct JokerObjectSaveData +{ + u32 id; + u32 persistent_state; +} JokerObjectSaveData; + +// clang-format off +/** + * @brief SaveGame will contain the data about the current run to be saved to SRAM. + * GameVariables was used for this purpose at first, but some data needed to be shared but + * not saved, so it couldn't be dumped "as is" anymore and this struct had to be created. + * + * word | Byte 0 | Byte 1 | Byte 2 | Byte 3 | name | purpose + * -----|--------|--------|--------|--------|--------------|------------------------------------------------------------------ + * 0 | '-' | 'I' | 'N' | 'T' | TAG | Spells "-INTERNAL DATA -" + * 1 | 'E' | 'R' | 'N' | 'A' | - | - + * 2 | 'L' | ' ' | 'D' | 'A' | - | - + * 3 | 'T' | 'A' | ' ' | '-' | - | - + * 4 | T[0] | T[1] | T[2] | T[3] | GLOB TIMER | The global timer used for animations thoughout the game + * 5 | SED[0] | SED[1] | SED[2] | SED[3] | RNG SEED | The seed used for RNG, either randomly shuffled or chosen by the player at game start + * 6 | STP[0] | STP[1] | STP[2] | STP[3] | RNG STEP | The current position in the RNG sequence for the given seed, since the start of the run + * 7 | RND[0] | RND[1] | RND[2] | RND[3] | ROUND | What Round we are about to start + * 8 | ANT[0] | ANT[1] | ANT[2] | ANT[3] | ANTE | What Ante we are on + * 9 | MNY[0] | MNY[1] | MNY[2] | MNY[3] | MONEY | How much money we currently have left + * 10 | UNDEF | UNDEF | UNDEF | UNDEF | PADDING | Some padding + * 11 | UNDEF | UNDEF | UNDEF | UNDEF | - | - + * 12 | '-' | ' ' | 'O' | 'W' | TAG | Spells "- OWNED JOKERS -" + * 13 | 'N' | 'E' | 'D' | ' ' | - | - + * 14 | 'J' | 'O' | 'K' | 'E' | - | - + * 15 | 'R' | 'S' | ' ' | '-' | - | - + * 16 | ID[0] | ID[1] | ID[2] | ID[3] | JOKER DATA 0 | Minimal necessary data to reconstruct a JokerObject + * 17 | STT[0] | STT[1] | STT[2] | STT[3] | - | Contains the Joker's `id` and `persistent_state` + * ... | ... | ... | ... | ... | ... | ... + * ... | ... | ... | ... | ... | ... | ... + * ?? | '_' | 'E' | 'N' | 'D' | END_TAG | Spells "_END", marks the end of the savefile + */ +// clang-format on +typedef struct SaveGame +{ + char tag_internal[SAVE_LABEL_SIZE]; + s32 timer; + u32 rng_seed; + u32 rng_step; + int round; + int ante; + int money; + u32 padding[2]; + + char tag_jokers[SAVE_LABEL_SIZE]; + JokerObjectSaveData jokers_data[MAX_JOKERS_HELD_SIZE]; + + char tag_end[4]; +} SaveGame; + +/** + * @brief Default value for the SaveHeader struct. + */ +static const SaveHeader SaveHeader_default = { + .magic = CHECK_MAGIC, + .dirty = false, + .githash = "fffffff", + .valid_sections = SAVE_SECTION_FLAG_NONE +}; + +/** + * @brief Default value for the SaveOptions struct, with tags already set. + */ +static const SaveOptions SaveOptions_default = { + .tag_options = "- OPTIONS DATA -", + .game_speed = 0, + .high_contrast = false, + .music_volume = 0, + .sound_volume = 0, + .padding = {UNDEFINED, UNDEFINED, UNDEFINED}, +}; + +/** + * @brief Default value for the SaveGame struct, with tags already set. + */ +static const SaveGame SaveGame_default = { + .tag_internal = "-INTERNAL DATA -", + .timer = 0, + .rng_seed = 0, + .rng_step = 0, + .round = 0, + .ante = 0, + .money = 0, + .padding = {UNDEFINED, UNDEFINED}, + + .tag_jokers = "- OWNED JOKERS -", + .jokers_data = {}, + + .tag_end = "_END" +}; + /** * @brief Write raw binary data to SRAM * @@ -108,59 +242,145 @@ static inline bool check_hash(const char* prefix) * * @returns true if version is dirty, false otherwise. */ -static inline bool is_version_dirty() +static inline bool is_version_dirty(void) { return strlen(gbalatro_version) > GIT_HASH_START + CHECK_HASH_SIZE; } /** - * @brief Writes a magic number and ROM version info to SRAM to signal that the - * save data exists and allow the game to determine if it is compatible. + * @brief Reads whether the save data exists and is valid. + * + * @param header pointer to the SaveHeader struct to fill + * @returns true if the save data is valid, false if not */ -static inline void set_save_header() +static inline bool get_save_header(SaveHeader* header) { - SaveHeader check = {}; - check.magic = CHECK_MAGIC; - check.dirty = is_version_dirty(); - memcpy(&(check.githash), gbalatro_version + GIT_HASH_START, CHECK_HASH_SIZE); - - write_sram(CHECK_BASE, (const u8*)&check, sizeof(check)); + read_sram(HEADER_ADDRESS, (u8*)header, sizeof(*header)); + return (header->magic == CHECK_MAGIC) && header->dirty == is_version_dirty() && + check_hash(header->githash); } /** - * @brief Reads whether the save data exists and is valid. + * @brief Writes a magic number and ROM version info to SRAM to signal that the + * save data exists and allow the game to determine if it is compatible. * - * @sa set_save_valid + * This will read the SaveHeader first and check if the data is valid. If yes, the + * `valid_sections` will be updated, if not, it will be overwritten and start from + * `SAVE_SECTION_FLAG_NONE`. + * + * @param section_flag The section flag to be set to 1, corresponds to the + * section we're writing to SRAM. */ -static inline bool check_save_header() +static inline void set_save_header(u32 section_flag) { - SaveHeader check; - read_sram(CHECK_BASE, (u8*)&check, sizeof(check)); + SaveHeader header; - bool is_valid = (check.magic == CHECK_MAGIC) && check.dirty == is_version_dirty() && - check_hash(check.githash); + // Check for valid data. If it's junk, set all sections as invalid, else keep the flags. + // Then add the requested flag. + if (!get_save_header(&header)) + { + memcpy(&header, &SaveHeader_default, sizeof(SaveHeader_default)); + } - return is_valid; + header.valid_sections |= section_flag; + + header.dirty = is_version_dirty(); + memcpy(&(header.githash), gbalatro_version + GIT_HASH_START, CHECK_HASH_SIZE); + + write_sram(HEADER_ADDRESS, (const u8*)&header, sizeof(header)); +} + +void save_options(void) +{ + SaveOptions options = SaveOptions_default; + + options.game_speed = g_game_vars.game_speed; + options.high_contrast = g_game_vars.high_contrast; + options.music_volume = g_game_vars.music_volume; + options.sound_volume = g_game_vars.sound_volume; + + write_sram(OPTIONS_ADDRESS, (const u8*)&options, sizeof(options)); + set_save_header(SAVE_SECTION_FLAG_OPTIONS); +} + +void load_options(void) +{ + SaveHeader header; + if (!get_save_header(&header) || !(header.valid_sections & SAVE_SECTION_FLAG_OPTIONS)) + return; + + SaveOptions options = SaveOptions_default; + + read_sram(OPTIONS_ADDRESS, (u8*)&options, sizeof(options)); + + g_game_vars.game_speed = options.game_speed; + g_game_vars.high_contrast = options.high_contrast; + g_game_vars.music_volume = options.music_volume; + g_game_vars.sound_volume = options.sound_volume; + + mmSetModuleVolume(MM_MODULE_FULL_VOLUME * g_game_vars.music_volume / VOLUME_OPTION_MAX); } void save_game(void) { - set_save_header(); - write_sram(GAME_BASE, (const u8*)&g_game_vars, sizeof(g_game_vars)); + SaveGame game = SaveGame_default; + + // Fixed data + + game.timer = g_game_vars.timer; + game.rng_seed = g_game_vars.rng_seed; + game.rng_step = g_game_vars.rng_step; + game.round = g_game_vars.round; + game.ante = g_game_vars.ante; + game.money = g_game_vars.money; + + // Lists + + List* jokers_list = get_jokers_list(); + int nb_jokers = list_get_len(jokers_list); + + int i = 0; + for (; i < nb_jokers; i++) + { + JokerObject* joker_object = list_get_at_idx(jokers_list, (u32)i); + JokerObjectSaveData data = { + (u32)joker_object->joker->id, + joker_object->joker->persistent_state + }; + game.jokers_data[i] = data; + } + for (; i < MAX_JOKERS_HELD_SIZE; i++) + { + JokerObjectSaveData data = {UNDEFINED, UNDEFINED}; + game.jokers_data[i] = data; + } + + write_sram(GAME_ADDRESS, (const u8*)&game, sizeof(game)); + set_save_header(SAVE_SECTION_FLAG_GAME); } void load_game(void) { - if (!check_save_header()) + SaveHeader header; + if (!get_save_header(&header) || !(header.valid_sections & SAVE_SECTION_FLAG_GAME)) return; - read_sram(GAME_BASE, (u8*)&g_game_vars, sizeof(g_game_vars)); + SaveGame game = SaveGame_default; + + read_sram(GAME_ADDRESS, (u8*)&game, sizeof(game)); + + g_game_vars.timer = game.timer; + g_game_vars.rng_seed = game.rng_seed; + g_game_vars.rng_step = game.rng_step; + g_game_vars.round = game.round; + g_game_vars.ante = game.ante; + g_game_vars.money = game.money; + + // TODO: load Jokers from stored minimal data // return to where we were in the random sequence so that the run stays reproducible for (u32 i = 0; i < g_game_vars.rng_step; i++) { (void)rand(); } - - mmSetModuleVolume(MM_MODULE_FULL_VOLUME * g_game_vars.music_volume / VOLUME_OPTION_MAX); }