mirror of
https://github.com/cellos51/balatro-gba.git
synced 2026-09-09 01:16:05 -05:00
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 <mathis.martin31@gmail.com> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -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 <gbalatro.sav>` to view the contents of the save
|
||||
*/
|
||||
#ifndef SAVE_H
|
||||
#define SAVE_H
|
||||
@@ -11,15 +32,27 @@
|
||||
#include <tonc.h>
|
||||
|
||||
/**
|
||||
* @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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
#include "gbalatro_sys8.h"
|
||||
#include "graphic_utils.h"
|
||||
#include "joker.h"
|
||||
#include "save.h"
|
||||
#include "sprite.h"
|
||||
|
||||
#include <maxmod.h>
|
||||
@@ -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();
|
||||
|
||||
280
source/save.c
280
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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user