Rewrite gamedata format

This commit is contained in:
Eidolon 2024-02-17 23:58:03 -06:00
parent e0fe5543fa
commit b0348526cd
6 changed files with 1250 additions and 1094 deletions

View file

@ -19,6 +19,7 @@ add_executable(SRB2SDL2 MACOSX_BUNDLE WIN32
g_build_ticcmd.cpp
g_demo.c
g_game.c
g_gamedata.cpp
g_input.c
g_party.cpp
am_map.c

File diff suppressed because it is too large Load diff

688
src/g_gamedata.cpp Normal file
View file

@ -0,0 +1,688 @@
// SONIC ROBO BLAST 2
//-----------------------------------------------------------------------------
// Copyright (C) 2024 by Ronald "Eidolon" Kinard
//
// This program is free software distributed under the
// terms of the GNU General Public License, version 2.
// See the 'LICENSE' file for more details.
//-----------------------------------------------------------------------------
#include "g_gamedata.h"
#include <algorithm>
#include <cstdint>
#include <cstddef>
#include <filesystem>
#include <fmt/format.h>
#include "io/streams.hpp"
#include "d_main.h"
#include "m_argv.h"
#include "m_cond.h"
#include "g_game.h"
#include "r_skins.h"
#include "z_zone.h"
namespace fs = std::filesystem;
using json = nlohmann::json;
void srb2::save_ng_gamedata()
{
if (gamedata == NULL || !gamedata->loaded)
return; // If never loaded (-nodata), don't save
gamedata->deferredsave = false;
if (usedCheats)
{
#ifdef DEVELOP
CONS_Alert(CONS_WARNING, M_GetText("Cheats used - Gamedata will not be saved.\n"));
#endif
return;
}
GamedataJson ng{};
ng.playtime.total = gamedata->totalplaytime;
ng.rings.total = gamedata->totalrings;
ng.playtime.tumble = gamedata->totaltumbletime;
ng.rounds.race = gamedata->roundsplayed[GDGT_RACE];
ng.rounds.battle = gamedata->roundsplayed[GDGT_BATTLE];
ng.rounds.prisons = gamedata->roundsplayed[GDGT_PRISONS];
ng.rounds.special = gamedata->roundsplayed[GDGT_SPECIAL];
ng.rounds.custom = gamedata->roundsplayed[GDGT_CUSTOM];
ng.challengekeys.pendingkeyrounds = gamedata->pendingkeyrounds;
ng.challengekeys.pendingkeyroundoffset = gamedata->pendingkeyroundoffset;
ng.challengekeys.keyspending = gamedata->keyspending;
ng.challengekeys.chaokeys = gamedata->chaokeys;
ng.goner.everloadedaddon = gamedata->everloadedaddon;
ng.goner.everfinishcredits = gamedata->everfinishedcredits;
ng.goner.eversavedreplay = gamedata->eversavedreplay;
ng.goner.everseenspecial = gamedata->everseenspecial;
ng.goner.chaokeytutorial = gamedata->chaokeytutorial;
ng.goner.majorkeyskipattempted = gamedata->majorkeyskipattempted;
ng.goner.finishedtutorialchallenge = gamedata->finishedtutorialchallenge;
ng.goner.enteredtutorialchallenge = gamedata->enteredtutorialchallenge;
ng.goner.level = gamedata->gonerlevel;
ng.prisons.thisprisoneggpickup = gamedata->thisprisoneggpickup;
ng.prisons.prisoneggstothispickup = gamedata->prisoneggstothispickup;
ng.tafolderhash = quickncasehash(timeattackfolder, 64);
ng.emblems.resize(MAXEMBLEMS, false);
for (int i = 0; i < MAXEMBLEMS; i++)
{
ng.emblems[i] = gamedata->collected[i];
}
ng.unlockables.resize(MAXUNLOCKABLES, false);
for (int i = 0; i < MAXUNLOCKABLES; i++)
{
ng.unlockables[i] = gamedata->unlocked[i];
}
ng.unlockpending.resize(MAXUNLOCKABLES, false);
for (int i = 0; i < MAXUNLOCKABLES; i++)
{
ng.unlockpending[i] = gamedata->unlockpending[i];
}
ng.conditionsets.resize(MAXCONDITIONSETS, false);
for (int i = 0; i < MAXCONDITIONSETS; i++)
{
ng.conditionsets[i] = gamedata->achieved[i];
}
if (gamedata->challengegrid)
{
ng.challengegrid.width = gamedata->challengegridwidth;
ng.challengegrid.grid.resize(gamedata->challengegridwidth * CHALLENGEGRIDHEIGHT, 0);
for (int i = 0; i < gamedata->challengegridwidth * CHALLENGEGRIDHEIGHT; i++)
{
ng.challengegrid.grid[i] = gamedata->challengegrid[i];
}
}
ng.timesBeaten = gamedata->timesBeaten;
for (int i = 0; i < numskins; i++)
{
srb2::GamedataSkinJson skin;
std::string name = std::string(skins[i].name);
skin.records.wins = skins[i].records.wins;
ng.skins[name] = skin;
}
for (auto unloadedskin = unloadedskins; unloadedskin; unloadedskin = unloadedskin->next)
{
srb2::GamedataSkinJson skin;
std::string name = std::string(unloadedskin->name);
skin.records.wins = unloadedskin->records.wins;
ng.skins[name] = skin;
}
for (int i = 0; i < nummapheaders; i++)
{
srb2::GamedataMapJson map;
std::string lumpname = std::string(mapheaderinfo[i]->lumpname);
map.visited.visited = mapheaderinfo[i]->records.mapvisited & MV_VISITED;
map.visited.beaten = mapheaderinfo[i]->records.mapvisited & MV_BEATEN;
map.visited.encore = mapheaderinfo[i]->records.mapvisited & MV_ENCORE;
map.visited.spbattack = mapheaderinfo[i]->records.mapvisited & MV_SPBATTACK;
map.visited.mysticmelody = mapheaderinfo[i]->records.mapvisited & MV_MYSTICMELODY;
map.stats.besttime = mapheaderinfo[i]->records.time;
map.stats.bestlap = mapheaderinfo[i]->records.lap;
ng.maps[lumpname] = map;
}
for (auto unloadedmap = unloadedmapheaders; unloadedmap; unloadedmap = unloadedmap->next)
{
srb2::GamedataMapJson map;
std::string lumpname = std::string(unloadedmap->lumpname);
map.visited.visited = unloadedmap->records.mapvisited & MV_VISITED;
map.visited.beaten = unloadedmap->records.mapvisited & MV_BEATEN;
map.visited.encore = unloadedmap->records.mapvisited & MV_ENCORE;
map.visited.spbattack = unloadedmap->records.mapvisited & MV_SPBATTACK;
map.visited.mysticmelody = unloadedmap->records.mapvisited & MV_MYSTICMELODY;
map.stats.besttime = unloadedmap->records.time;
map.stats.bestlap = unloadedmap->records.lap;
ng.maps[lumpname] = map;
}
for (int i = 0; i < gamedata->numspraycans; i++)
{
srb2::GamedataSprayCanJson spraycan;
candata_t* can = &gamedata->spraycans[i];
spraycan.color = can->col;
if (can->map >= nummapheaders)
{
continue;
}
mapheader_t* mapheader = mapheaderinfo[can->map];
if (!mapheader)
{
continue;
}
spraycan.map = std::string(mapheader->lumpname);
ng.spraycans.push_back(spraycan);
}
for (auto cup = kartcupheaders; cup; cup = cup->next)
{
if (cup->windata[0].best_placement == 0)
{
continue;
}
srb2::GamedataCupJson cupdata;
cupdata.name = std::string(cup->name);
for (int i = 0; i < 4; i++)
{
cupdata.records[i].bestgrade = cup->windata[i].best_grade;
cupdata.records[i].bestplacement = cup->windata[i].best_placement;
cupdata.records[i].bestskin = std::string(skins[cup->windata[i].best_skin.id].name);
cupdata.records[i].emerald = cup->windata[i].got_emerald;
}
ng.cups[cupdata.name] = cupdata;
}
for (auto unloadedcup = unloadedcupheaders; unloadedcup; unloadedcup = unloadedcup->next)
{
if (unloadedcup->windata[0].best_placement == 0)
{
continue;
}
srb2::GamedataCupJson cupdata;
cupdata.name = std::string(unloadedcup->name);
for (int i = 0; i < 4; i++)
{
cupdata.records[i].bestgrade = unloadedcup->windata[i].best_grade;
cupdata.records[i].bestplacement = unloadedcup->windata[i].best_placement;
cupdata.records[i].bestskin = std::string(skins[unloadedcup->windata[i].best_skin.id].name);
cupdata.records[i].emerald = unloadedcup->windata[i].got_emerald;
}
ng.cups[cupdata.name] = cupdata;
}
std::string gamedataname_s{gamedatafilename};
fs::path savepath{fmt::format("{}/{}", srb2home, gamedataname_s)};
fs::path tmpsavepath{fmt::format("{}/{}.tmp", srb2home, gamedataname_s)};
json ngdata_json = ng;
try
{
std::string tmpsavepathstring = tmpsavepath.string();
srb2::io::FileStream file {tmpsavepathstring, srb2::io::FileStreamMode::kWrite};
srb2::io::BufferedOutputStream<srb2::io::FileStream> bos {std::move(file)};
// The header is necessary to validate during loading.
srb2::io::write(static_cast<uint32_t>(0xBA5ED321), bos); // major
srb2::io::write(static_cast<uint8_t>(0), bos); // minor/flags
srb2::io::write(static_cast<uint8_t>(gamedata->evercrashed), bos); // dirty (crash recovery)
std::vector<uint8_t> ubjson = json::to_ubjson(ng);
srb2::io::write_exact(bos, tcb::as_bytes(tcb::make_span(ubjson)));
bos.flush();
file = bos.stream();
file.close();
}
catch (const srb2::io::FileStreamException& ex)
{
CONS_Alert(CONS_ERROR, "NG Gamedata save failed: %s\n", ex.what());
}
catch (...)
{
CONS_Alert(CONS_ERROR, "NG Gamedata save failed\n");
}
try
{
// Now that the save is written successfully, move it over the old save
fs::rename(tmpsavepath, savepath);
}
catch (const fs::filesystem_error& ex)
{
CONS_Alert(CONS_ERROR, "NG Gamedata save succeeded but did not replace old save successfully: %s\n", ex.what());
}
}
// G_SaveGameData
// Saves the main data file, which stores information such as emblems found, etc.
void G_SaveGameData(void)
{
try
{
srb2::save_ng_gamedata();
}
catch (...)
{
CONS_Alert(CONS_ERROR, "Gamedata save failed\n");
return;
}
// Also save profiles here.
PR_SaveProfiles();
#ifdef DEVELOP
CONS_Alert(CONS_NOTICE, M_GetText("Gamedata saved.\n"));
#endif
}
static const char *G_GameDataFolder(void)
{
if (strcmp(srb2home,"."))
return srb2home;
else
return "the Ring Racers folder";
}
void srb2::load_ng_gamedata()
{
// Stop saving, until we successfully load it again.
gamedata->loaded = false;
// Clear things so previously read gamedata doesn't transfer
// to new gamedata
// see also M_EraseDataResponse
G_ClearRecords(); // records
M_ClearStats(); // statistics
M_ClearSecrets(); // emblems, unlocks, maps visited, etc
if (M_CheckParm("-nodata"))
{
// Don't load at all.
// The following used to be in M_ClearSecrets, but that was silly.
M_UpdateUnlockablesAndExtraEmblems(false, true);
M_FinaliseGameData();
gamedata->loaded = true;
return;
}
if (M_CheckParm("-resetdata"))
{
// Don't load, but do save. (essentially, reset)
M_FinaliseGameData();
gamedata->loaded = true;
return;
}
std::string datapath {fmt::format("{}/{}", srb2home, gamedatafilename)};
srb2::io::BufferedInputStream<srb2::io::FileStream> bis;
try
{
srb2::io::FileStream file {datapath, srb2::io::FileStreamMode::kRead };
bis = srb2::io::BufferedInputStream(std::move(file));
}
catch (const srb2::io::FileStreamException& ex)
{
M_FinaliseGameData();
gamedata->loaded = true;
return;
}
uint32_t majorversion;
uint8_t minorversion;
uint8_t dirty;
try
{
majorversion = srb2::io::read_uint32(bis);
minorversion = srb2::io::read_uint8(bis);
dirty = srb2::io::read_uint8(bis);
}
catch (...)
{
CONS_Alert(CONS_ERROR, "Failed to read ng gamedata header\n");
return;
}
if (majorversion != 0xBA5ED321)
{
const char* gdfolder = G_GameDataFolder();
I_Error("Game data is not for Ring Racers v2.0.\nDelete %s (maybe in %s) and try again.", gamedatafilename, gdfolder);
return;
}
std::vector<std::byte> remainder = srb2::io::read_to_vec(bis);
GamedataJson js;
try
{
// safety: std::byte repr is always uint8_t 1-byte aligned
tcb::span<uint8_t> remainder_as_u8 = tcb::span((uint8_t*)remainder.data(), remainder.size());
json parsed = json::from_ubjson(remainder_as_u8);
js = parsed.template get<GamedataJson>();
}
catch (...)
{
const char* gdfolder = G_GameDataFolder();
I_Error("Game data is corrupt.\nDelete %s (maybe in %s) and try again.", gamedatafilename, gdfolder);
return;
}
// Quick & dirty hash for what mod this save file is for.
if (js.tafolderhash != quickncasehash(timeattackfolder, 64))
{
const char* gdfolder = G_GameDataFolder();
I_Error("Game data is corrupt.\nDelete %s (maybe in %s) and try again.", gamedatafilename, gdfolder);
return;
}
// Now we extract the json struct's data and put it into the C-side gamedata.
gamedata->evercrashed = dirty;
gamedata->totalplaytime = js.playtime.total;
gamedata->totalrings = js.rings.total;
gamedata->totaltumbletime = js.playtime.tumble;
gamedata->roundsplayed[GDGT_RACE] = js.rounds.race;
gamedata->roundsplayed[GDGT_BATTLE] = js.rounds.battle;
gamedata->roundsplayed[GDGT_PRISONS] = js.rounds.prisons;
gamedata->roundsplayed[GDGT_SPECIAL] = js.rounds.special;
gamedata->roundsplayed[GDGT_CUSTOM] = js.rounds.custom;
gamedata->pendingkeyrounds = js.challengekeys.pendingkeyrounds;
gamedata->pendingkeyroundoffset = js.challengekeys.pendingkeyroundoffset;
gamedata->keyspending = js.challengekeys.keyspending;
gamedata->chaokeys = js.challengekeys.chaokeys;
gamedata->everloadedaddon = js.goner.everloadedaddon;
gamedata->everfinishedcredits = js.goner.everfinishcredits;
gamedata->eversavedreplay = js.goner.eversavedreplay;
gamedata->everseenspecial = js.goner.everseenspecial;
gamedata->chaokeytutorial = js.goner.chaokeytutorial;
gamedata->majorkeyskipattempted = js.goner.majorkeyskipattempted;
gamedata->finishedtutorialchallenge = js.goner.finishedtutorialchallenge;
gamedata->enteredtutorialchallenge = js.goner.enteredtutorialchallenge;
gamedata->gonerlevel = js.goner.level;
gamedata->thisprisoneggpickup = js.prisons.thisprisoneggpickup;
gamedata->prisoneggstothispickup = js.prisons.prisoneggstothispickup;
size_t emblems_size = js.emblems.size();
for (size_t i = 0; i < std::min((size_t)MAXEMBLEMS, emblems_size); i++)
{
gamedata->collected[i] = js.emblems[i];
}
size_t unlocks_size = js.unlockables.size();
for (size_t i = 0; i < std::min((size_t)MAXUNLOCKABLES, unlocks_size); i++)
{
gamedata->unlocked[i] = js.unlockables[i];
}
size_t pending_unlocks_size = js.unlockpending.size();
for (size_t i = 0; i < std::min((size_t)MAXUNLOCKABLES, pending_unlocks_size); i++)
{
gamedata->unlockpending[i] = js.unlockpending[i];
}
size_t conditions_size = js.conditionsets.size();
for (size_t i = 0; i < std::min((size_t)MAXCONDITIONSETS, conditions_size); i++)
{
gamedata->achieved[i] = js.conditionsets[i];
}
if (M_CheckParm("-resetchallengegrid"))
{
gamedata->challengegridwidth = 0;
if (gamedata->challengegrid)
{
Z_Free(gamedata->challengegrid);
gamedata->challengegrid = nullptr;
}
}
else
{
gamedata->challengegridwidth = std::min(js.challengegrid.width, (uint32_t)0);
if (gamedata->challengegrid)
{
Z_Free(gamedata->challengegrid);
gamedata->challengegrid = nullptr;
}
if (gamedata->challengegridwidth)
{
gamedata->challengegrid = static_cast<uint16_t*>(Z_Malloc(
(gamedata->challengegridwidth * CHALLENGEGRIDHEIGHT * sizeof(UINT16)),
PU_STATIC, NULL));
for (size_t i = 0; i < std::min((size_t)(gamedata->challengegridwidth * CHALLENGEGRIDHEIGHT), js.challengegrid.grid.size()); i++)
{
int16_t gridvalue = js.challengegrid.grid[i];
if (gridvalue < 0)
{
gamedata->challengegrid[i] = MAXUNLOCKABLES;
}
else
{
gamedata->challengegrid[i] = static_cast<uint8_t>(gridvalue);
}
}
M_SanitiseChallengeGrid();
}
else
{
gamedata->challengegrid = NULL;
}
}
gamedata->timesBeaten = js.timesBeaten;
// Main records
for (auto& skinpair : js.skins)
{
INT32 skin = R_SkinAvailable(skinpair.first.c_str());
skinrecord_t dummyrecord {};
dummyrecord.wins = skinpair.second.records.wins;
if (skin != -1)
{
skins[skin].records = dummyrecord;
}
else if (dummyrecord.wins)
{
// Invalid, but we don't want to lose all the juicy statistics.
// Instead, update a FILO linked list of "unloaded skins".
unloaded_skin_t *unloadedskin =
static_cast<unloaded_skin_t*>(Z_Malloc(
sizeof(unloaded_skin_t),
PU_STATIC, NULL
));
// Establish properties, for later retrieval on file add.
strlcpy(unloadedskin->name, skinpair.first.c_str(), sizeof(unloadedskin->name));
unloadedskin->namehash = quickncasehash(unloadedskin->name, SKINNAMESIZE);
// Insert at the head, just because it's convenient.
unloadedskin->next = unloadedskins;
unloadedskins = unloadedskin;
// Finally, copy into.
unloadedskin->records = dummyrecord;
}
}
for (auto& mappair : js.maps)
{
UINT16 mapnum = G_MapNumber(mappair.first.c_str());
recorddata_t dummyrecord {};
dummyrecord.mapvisited |= mappair.second.visited.visited ? MV_VISITED : 0;
dummyrecord.mapvisited |= mappair.second.visited.beaten ? MV_BEATEN : 0;
dummyrecord.mapvisited |= mappair.second.visited.encore ? MV_ENCORE : 0;
dummyrecord.mapvisited |= mappair.second.visited.spbattack ? MV_SPBATTACK : 0;
dummyrecord.mapvisited |= mappair.second.visited.mysticmelody ? MV_SPBATTACK : 0;
dummyrecord.lap = mappair.second.stats.bestlap;
dummyrecord.time = mappair.second.stats.besttime;
if (mapnum < nummapheaders && mapheaderinfo[mapnum])
{
// Valid mapheader, time to populate with record data.
mapheaderinfo[mapnum]->records = dummyrecord;
}
else if (dummyrecord.mapvisited & MV_BEATEN || dummyrecord.time != 0 || dummyrecord.lap != 0)
{
// Invalid, but we don't want to lose all the juicy statistics.
// Instead, update a FILO linked list of "unloaded mapheaders".
unloaded_mapheader_t *unloadedmap =
static_cast<unloaded_mapheader_t*>(Z_Malloc(
sizeof(unloaded_mapheader_t),
PU_STATIC, NULL
));
// Establish properties, for later retrieval on file add.
unloadedmap->lumpname = Z_StrDup(mappair.first.c_str());
unloadedmap->lumpnamehash = quickncasehash(unloadedmap->lumpname, MAXMAPLUMPNAME);
// Insert at the head, just because it's convenient.
unloadedmap->next = unloadedmapheaders;
unloadedmapheaders = unloadedmap;
// Finally, copy into.
unloadedmap->records = dummyrecord;
}
}
gamedata->gotspraycans = 0;
gamedata->numspraycans = js.spraycans.size();
if (gamedata->spraycans)
{
Z_Free(gamedata->spraycans);
}
if (gamedata->numspraycans)
{
gamedata->spraycans = static_cast<candata_t*>(Z_Malloc(
(gamedata->numspraycans * sizeof(candata_t)),
PU_STATIC, NULL));
for (size_t i = 0; i < js.spraycans.size(); i++)
{
auto& can = js.spraycans[i];
gamedata->spraycans[i].col = can.color;
gamedata->spraycans[i].map = NEXTMAP_INVALID;
if (can.map.empty())
{
continue;
}
UINT16 mapnum = G_MapNumber(can.map.c_str());
if (mapnum < 0)
{
continue;
}
gamedata->spraycans[i].map = mapnum;
if (gamedata->gotspraycans != i)
{
//CONS_Printf("LOAD - Swapping gotten can %u, color %s with prior ungotten can %u\n", i, skincolors[col].name, gamedata->gotspraycans);
// All grabbed cans should be at the head of the list.
// Let's swap with the can the disjoint occoured at.
// This will prevent a gap from occouring on reload.
candata_t copycan = gamedata->spraycans[gamedata->gotspraycans];
gamedata->spraycans[gamedata->gotspraycans] = gamedata->spraycans[i];
gamedata->spraycans[i] = copycan;
mapheaderinfo[copycan.map]->cache_spraycan = i;
}
mapheaderinfo[mapnum]->cache_spraycan = gamedata->gotspraycans;
gamedata->gotspraycans++;
}
}
else
{
gamedata->spraycans = nullptr;
}
for (auto& cuppair : js.cups)
{
cupwindata_t dummywindata[4] {{}};
cupheader_t* cup = nullptr;
// Find the loaded cup
for (cup = kartcupheaders; cup; cup = cup->next)
{
std::string cupname = std::string(cup->name);
if (cupname == cuppair.first)
{
continue;
}
}
// Digest its data...
for (size_t j = 0; j < (size_t)KARTGP_MAX; j++)
{
dummywindata[j].best_placement = cuppair.second.records[j].bestplacement;
dummywindata[j].best_grade = static_cast<gp_rank_e>(cuppair.second.records[j].bestgrade);
dummywindata[j].got_emerald = cuppair.second.records[j].emerald;
dummywindata[j].best_skin.id = MAXSKINS;
dummywindata[j].best_skin.unloaded = nullptr;
bool skinfound = false;
for (int skin = 0; skin < numskins; skin++)
{
std::string skinname = std::string(skins[skin].name);
if (skinname == cuppair.second.records[j].bestskin)
{
skinreference_t ref {};
ref.id = skin;
ref.unloaded = nullptr;
dummywindata[j].best_skin = ref;
skinfound = true;
break;
}
}
if (skinfound)
{
continue;
}
for (auto unloadedskin = unloadedskins; unloadedskin; unloadedskin = unloadedskin->next)
{
std::string skinname = std::string(unloadedskin->name);
if (skinname == cuppair.second.records[j].bestskin)
{
skinreference_t ref {};
ref.id = MAXSKINS;
ref.unloaded = unloadedskin;
dummywindata[j].best_skin = ref;
skinfound = true;
break;
}
}
}
if (cup)
{
// We found a cup, so assign the windata.
memcpy(cup->windata, dummywindata, sizeof(cup->windata));
}
else if (dummywindata[0].best_placement != 0)
{
// Invalid, but we don't want to lose all the juicy statistics.
// Instead, update a FILO linked list of "unloaded cupheaders".
unloaded_cupheader_t *unloadedcup =
static_cast<unloaded_cupheader_t*>(Z_Malloc(
sizeof(unloaded_cupheader_t),
PU_STATIC, NULL
));
// Establish properties, for later retrieval on file add.
strlcpy(unloadedcup->name, cuppair.first.c_str(), sizeof(unloadedcup->name));
unloadedcup->namehash = quickncasehash(unloadedcup->name, MAXCUPNAME);
// Insert at the head, just because it's convenient.
unloadedcup->next = unloadedcupheaders;
unloadedcupheaders = unloadedcup;
// Finally, copy into.
memcpy(unloadedcup->windata, dummywindata, sizeof(cup->windata));
}
}
M_FinaliseGameData();
}
// G_LoadGameData
// Loads the main data file, which stores information such as emblems found, etc.
void G_LoadGameData(void)
{
try
{
srb2::load_ng_gamedata();
}
catch (...)
{
CONS_Alert(CONS_ERROR, "NG Gamedata loading failed\n");
}
}

229
src/g_gamedata.h Normal file
View file

@ -0,0 +1,229 @@
// SONIC ROBO BLAST 2
//-----------------------------------------------------------------------------
// Copyright (C) 2024 by Ronald "Eidolon" Kinard
//
// This program is free software distributed under the
// terms of the GNU General Public License, version 2.
// See the 'LICENSE' file for more details.
//-----------------------------------------------------------------------------
#ifndef SRB2_G_GAMEDATA_H
#define SRB2_G_GAMEDATA_H
#ifdef __cplusplus
#include <array>
#include <cstdint>
#include <string>
#include <unordered_map>
#include <vector>
#include <nlohmann/json.hpp>
namespace srb2
{
struct GamedataPlaytimeJson final
{
uint32_t total;
uint32_t tumble;
NLOHMANN_DEFINE_TYPE_INTRUSIVE_WITH_DEFAULT(GamedataPlaytimeJson, total, tumble)
};
struct GamedataRingsJson final
{
uint32_t total;
NLOHMANN_DEFINE_TYPE_INTRUSIVE_WITH_DEFAULT(GamedataRingsJson, total)
};
struct GamedataRoundsJson final
{
uint32_t race;
uint32_t battle;
uint32_t prisons;
uint32_t special;
uint32_t custom;
NLOHMANN_DEFINE_TYPE_INTRUSIVE_WITH_DEFAULT(GamedataRoundsJson, race, battle, prisons, special, custom)
};
struct GamedataChallengeKeysJson final
{
uint32_t pendingkeyrounds;
uint8_t pendingkeyroundoffset;
uint16_t keyspending;
uint16_t chaokeys;
NLOHMANN_DEFINE_TYPE_INTRUSIVE_WITH_DEFAULT(GamedataChallengeKeysJson, pendingkeyrounds, pendingkeyroundoffset, keyspending, chaokeys)
};
struct GamedataGonerJson final
{
uint32_t level;
bool everloadedaddon;
bool everfinishcredits;
bool eversavedreplay;
bool everseenspecial;
bool chaokeytutorial;
bool majorkeyskipattempted;
bool finishedtutorialchallenge;
bool enteredtutorialchallenge;
NLOHMANN_DEFINE_TYPE_INTRUSIVE_WITH_DEFAULT(
GamedataGonerJson,
level,
everloadedaddon,
everfinishcredits,
eversavedreplay,
everseenspecial,
chaokeytutorial,
majorkeyskipattempted,
finishedtutorialchallenge,
enteredtutorialchallenge
)
};
struct GamedataPrisonEggPickupsJson final
{
uint16_t thisprisoneggpickup;
uint16_t prisoneggstothispickup;
NLOHMANN_DEFINE_TYPE_INTRUSIVE_WITH_DEFAULT(GamedataPrisonEggPickupsJson, thisprisoneggpickup, prisoneggstothispickup)
};
struct GamedataChallengeGridJson final
{
uint32_t width;
std::vector<int16_t> grid;
NLOHMANN_DEFINE_TYPE_INTRUSIVE_WITH_DEFAULT(GamedataChallengeGridJson, width, grid)
};
struct GamedataSkinRecordsJson final
{
uint32_t wins;
NLOHMANN_DEFINE_TYPE_INTRUSIVE_WITH_DEFAULT(GamedataSkinRecordsJson, wins)
};
struct GamedataSkinJson final
{
GamedataSkinRecordsJson records;
NLOHMANN_DEFINE_TYPE_INTRUSIVE_WITH_DEFAULT(GamedataSkinJson, records)
};
struct GamedataMapVisitedJson final
{
bool visited;
bool beaten;
bool encore;
bool spbattack;
bool mysticmelody;
NLOHMANN_DEFINE_TYPE_INTRUSIVE_WITH_DEFAULT(GamedataMapVisitedJson, visited, beaten, encore, spbattack, mysticmelody)
};
struct GamedataMapStatsJson final
{
uint32_t besttime;
uint32_t bestlap;
NLOHMANN_DEFINE_TYPE_INTRUSIVE_WITH_DEFAULT(GamedataMapStatsJson, besttime, bestlap)
};
struct GamedataMapJson final
{
GamedataMapVisitedJson visited;
GamedataMapStatsJson stats;
NLOHMANN_DEFINE_TYPE_INTRUSIVE_WITH_DEFAULT(GamedataMapJson, visited, stats)
};
struct GamedataSprayCanJson final
{
std::string map;
uint16_t color;
NLOHMANN_DEFINE_TYPE_INTRUSIVE_WITH_DEFAULT(GamedataSprayCanJson, map, color)
};
struct GamedataCupRecordsJson final
{
uint8_t bestplacement;
uint8_t bestgrade;
uint8_t emerald;
std::string bestskin;
NLOHMANN_DEFINE_TYPE_INTRUSIVE_WITH_DEFAULT(GamedataCupRecordsJson, bestplacement, bestgrade, emerald, bestskin)
};
struct GamedataCupJson final
{
std::string name;
std::array<GamedataCupRecordsJson, 4> records;
NLOHMANN_DEFINE_TYPE_INTRUSIVE_WITH_DEFAULT(GamedataCupJson, name, records)
};
struct GamedataJson final
{
GamedataPlaytimeJson playtime;
GamedataRingsJson rings;
GamedataRoundsJson rounds;
GamedataChallengeKeysJson challengekeys;
GamedataGonerJson goner;
GamedataPrisonEggPickupsJson prisons;
uint32_t tafolderhash;
std::vector<bool> emblems;
std::vector<bool> unlockables;
std::vector<bool> unlockpending;
std::vector<bool> conditionsets;
GamedataChallengeGridJson challengegrid;
uint32_t timesBeaten;
std::unordered_map<std::string, GamedataSkinJson> skins;
std::unordered_map<std::string, GamedataMapJson> maps;
std::vector<GamedataSprayCanJson> spraycans;
std::unordered_map<std::string, GamedataCupJson> cups;
NLOHMANN_DEFINE_TYPE_INTRUSIVE_WITH_DEFAULT(
GamedataJson,
playtime,
rings,
rounds,
challengekeys,
goner,
prisons,
tafolderhash,
emblems,
unlockables,
unlockpending,
conditionsets,
challengegrid,
timesBeaten,
skins,
maps,
spraycans,
cups
)
};
void save_ng_gamedata(void);
void load_ng_gamedata(void);
}
extern "C"
{
#endif // __cplusplus
void G_SaveGameData(void);
void G_LoadGameData(void);
#ifdef __cplusplus
} // extern "C"
#endif // __cplusplus
#endif // SRB2_G_GAMEDATA_H

View file

@ -9,5 +9,161 @@
#include "streams.hpp"
#include <cstdio>
#include <cerrno>
#include <stdexcept>
template class srb2::io::ZlibInputStream<srb2::io::SpanStream>;
template class srb2::io::ZlibInputStream<srb2::io::VecStream>;
template class srb2::io::BufferedOutputStream<srb2::io::FileStream>;
template class srb2::io::BufferedInputStream<srb2::io::FileStream>;
using namespace srb2::io;
static FileStreamException make_exception_from_errno(int err)
{
char* errnostr = strerror(err);
return FileStreamException(std::string(errnostr));
}
FileStreamException::FileStreamException(const char* msg) : msg_(msg) {}
FileStreamException::FileStreamException(const std::string& msg) : msg_(msg) {}
FileStreamException::FileStreamException(const FileStreamException&) = default;
FileStreamException::FileStreamException(FileStreamException&& r) noexcept = default;
FileStreamException::~FileStreamException() = default;
FileStreamException& FileStreamException::operator=(const FileStreamException&) = default;
FileStreamException& FileStreamException::operator=(FileStreamException&&) noexcept = default;
const char* FileStreamException::what() const noexcept
{
return msg_.c_str();
}
FileStream::FileStream() noexcept = default;
FileStream::FileStream(FileStream&& r) noexcept
{
*this = std::move(r);
};
FileStream::~FileStream()
{
if (file_)
{
// We don't care about the result. Exceptions can't be thrown in destructors.
std::fclose((std::FILE*)(this->file_));
}
file_ = nullptr;
}
FileStream::FileStream(const std::string& path, FileStreamMode mode) : file_(nullptr), mode_(mode)
{
const char* fopenmode = "r";
switch (mode_)
{
case FileStreamMode::kRead:
fopenmode = "rb";
break;
case FileStreamMode::kWrite:
fopenmode = "wb";
break;
case FileStreamMode::kAppend:
fopenmode = "ab";
break;
default:
throw std::invalid_argument("file stream mode unsupported");
}
std::FILE* file = std::fopen(path.c_str(), fopenmode);
if (file == nullptr)
{
int err = errno;
throw make_exception_from_errno(err);
}
// We want raw, unbuffered IO for the stream. Buffering can be layered on top.
if (std::setvbuf(file, nullptr, _IONBF, 0) != 0)
{
int err = errno;
throw make_exception_from_errno(err);
}
this->file_ = (void*) file;
}
FileStream& FileStream::operator=(FileStream&& r) noexcept
{
file_ = r.file_;
r.file_ = nullptr;
mode_ = r.mode_;
return *this;
};
StreamSize FileStream::read(tcb::span<std::byte> buffer)
{
if (this->file_ == nullptr)
{
throw std::domain_error("FileStream is empty");
}
if (this->mode_ != FileStreamMode::kRead)
{
throw std::domain_error("FileStream is not in read mode");
}
void* cbuf = (void*)(buffer.data());
std::size_t cbufsize = buffer.size_bytes();
std::size_t bytesread = fread(cbuf, 1, cbufsize, (std::FILE*)(this->file_));
if (std::ferror((std::FILE*)(this->file_)) != 0)
{
int err = errno;
throw make_exception_from_errno(err);
}
return bytesread;
}
StreamSize FileStream::write(tcb::span<const std::byte> buffer)
{
if (this->file_ == nullptr)
{
throw std::domain_error("FileStream is empty");
}
if (this->mode_ == FileStreamMode::kRead)
{
throw std::domain_error("FileStream is not in writable mode");
}
void* cbuf = (void*)(buffer.data());
std::size_t cbufsize = buffer.size_bytes();
std::size_t byteswritten = std::fwrite(cbuf, 1, cbufsize, (std::FILE*)(this->file_));
if (std::ferror((std::FILE*)(this->file_)) != 0)
{
int err = errno;
throw make_exception_from_errno(err);
}
return byteswritten;
}
void FileStream::close()
{
if (!file_)
{
return;
}
if (std::fclose((std::FILE*)(this->file_)) != 0)
{
int err = errno;
throw make_exception_from_errno(err);
}
if (std::ferror((std::FILE*)(this->file_)) != 0)
{
int err = errno;
throw make_exception_from_errno(err);
}
file_ = nullptr;
}

View file

@ -47,6 +47,9 @@ struct IsSeekableStream
: public std::is_same<decltype(std::declval<T&>().seek(std::declval<SeekFrom>(), std::declval<StreamOffset>())),
StreamSize> {};
template <typename T>
struct IsFlushableStream : public std::is_same<decltype(std::declval<T&>().flush()), void> {};
template <typename T>
struct IsStream : public std::disjunction<IsInputStream<T>, IsOutputStream<T>> {};
@ -60,6 +63,8 @@ inline constexpr const bool IsOutputStreamV = IsOutputStream<T>::value;
template <typename T>
inline constexpr const bool IsSeekableStreamV = IsSeekableStream<T>::value;
template <typename T>
inline constexpr const bool IsFlushableStreamV = IsFlushableStream<T>::value;
template <typename T>
inline constexpr const bool IsStreamV = IsStream<T>::value;
template <typename T>
inline constexpr const bool IsInputOutputStreamV = IsInputOutputStream<T>::value;
@ -570,6 +575,54 @@ inline void read_exact(VecStream& stream, tcb::span<std::byte> buffer)
std::copy(copy_begin, copy_end, buffer.begin());
}
enum class FileStreamMode
{
kRead,
kWrite,
kAppend,
};
class FileStreamException final : public std::exception
{
std::string msg_;
public:
explicit FileStreamException(const char* msg);
explicit FileStreamException(const std::string& msg);
FileStreamException(const FileStreamException&);
FileStreamException(FileStreamException&&) noexcept;
~FileStreamException();
FileStreamException& operator=(const FileStreamException&);
FileStreamException& operator=(FileStreamException&&) noexcept;
virtual const char* what() const noexcept override;
};
class FileStream final
{
void* file_ = nullptr; // Type is omitted to avoid include cstdio
FileStreamMode mode_;
public:
FileStream() noexcept;
FileStream(const FileStream&) = delete;
FileStream(FileStream&&) noexcept;
FileStream(const std::string& path, FileStreamMode mode = FileStreamMode::kRead);
~FileStream();
FileStream& operator=(const FileStream&) = delete;
FileStream& operator=(FileStream&&) noexcept;
StreamSize read(tcb::span<std::byte> buffer);
StreamSize write(tcb::span<const std::byte> buffer);
// not bothering with seeking for now -- apparently 64-bit file positions is not available in ansi c
// StreamSize seek(SeekFrom seek_from, StreamOffset offset);
void close();
};
class ZlibException : public std::exception {
int err_ {0};
std::string msg_;
@ -759,6 +812,124 @@ private:
}
};
extern template class ZlibInputStream<SpanStream>;
extern template class ZlibInputStream<VecStream>;
template <typename O,
typename std::enable_if_t<IsOutputStreamV<O> && std::is_move_constructible_v<O> &&
std::is_move_assignable_v<O>>* = nullptr>
class BufferedOutputStream final
{
O inner_;
std::vector<std::byte> buf_;
tcb::span<const std::byte>::size_type cap_;
public:
explicit BufferedOutputStream(O&& o) : inner_(std::forward<O>(o)), buf_(), cap_(8192) {}
BufferedOutputStream(O&& o, tcb::span<const std::byte>::size_type capacity) : inner_(std::forward<O>(o)), buf_(), cap_(capacity) {}
BufferedOutputStream(const BufferedOutputStream&) = delete;
BufferedOutputStream(BufferedOutputStream&&) = default;
~BufferedOutputStream() = default;
BufferedOutputStream& operator=(const BufferedOutputStream&) = delete;
BufferedOutputStream& operator=(BufferedOutputStream&&) = default;
StreamSize write(tcb::span<const std::byte> buffer)
{
StreamSize totalwritten = 0;
while (buffer.size() > 0)
{
std::size_t tocopy = std::min(std::min(cap_, cap_ - buf_.size()), buffer.size());
tcb::span<const std::byte> copy_slice = buffer.subspan(0, tocopy);
buf_.reserve(cap_);
buf_.insert(buf_.end(), copy_slice.begin(), copy_slice.end());
flush();
totalwritten += copy_slice.size();
buffer = buffer.subspan(tocopy);
}
return totalwritten;
}
void flush()
{
tcb::span<const std::byte> writebuf = tcb::make_span(buf_);
write_exact(inner_, writebuf);
buf_.resize(0);
}
O&& stream() noexcept
{
return std::move(inner_);
}
};
extern template class BufferedOutputStream<FileStream>;
template <typename I,
typename std::enable_if_t<IsInputStreamV<I> && std::is_move_constructible_v<I> &&
std::is_move_assignable_v<I>>* = nullptr>
class BufferedInputStream final
{
I inner_;
std::vector<std::byte> buf_;
tcb::span<std::byte>::size_type cap_;
public:
template <typename std::enable_if_t<std::is_default_constructible_v<I>>* = nullptr>
BufferedInputStream() : inner_(), buf_(), cap_(8192) {}
explicit BufferedInputStream(I&& i) : inner_(std::forward<I>(i)), buf_(), cap_(8192) {}
BufferedInputStream(I&& i, tcb::span<std::byte>::size_type capacity) : inner_(std::forward<I>(i)), buf_(), cap_(capacity) {}
BufferedInputStream(const BufferedInputStream&) = delete;
BufferedInputStream(BufferedInputStream&&) = default;
~BufferedInputStream() = default;
BufferedInputStream& operator=(const BufferedInputStream&) = delete;
BufferedInputStream& operator=(BufferedInputStream&&) = default;
StreamSize read(tcb::span<std::byte> buffer)
{
StreamSize totalread = 0;
buf_.reserve(cap_);
while (buffer.size() > 0)
{
std::size_t toread = cap_ - buf_.size();
std::size_t prereadsize = buf_.size();
buf_.resize(prereadsize + toread);
tcb::span<std::byte> readspan{buf_.data() + prereadsize, buf_.data() + prereadsize + toread};
StreamSize bytesread = inner_.read(readspan);
buf_.resize(prereadsize + bytesread);
StreamSize tocopyfrombuf = std::min(buffer.size(), buf_.size());
std::copy(buf_.begin(), std::next(buf_.begin(), tocopyfrombuf), buffer.begin());
buffer = buffer.subspan(tocopyfrombuf);
totalread += tocopyfrombuf;
// Move the remaining buffer backwards
std::size_t bufremaining = buf_.size() - tocopyfrombuf;
std::move(std::next(buf_.begin(), tocopyfrombuf), buf_.end(), buf_.begin());
buf_.resize(bufremaining);
// If we read 0 bytes from the stream, assume the inner stream won't return more for a while.
// The caller can read in a loop if it must (i.e. read_exact)
if (bytesread == 0)
{
break;
}
}
return totalread;
}
I&& stream() noexcept
{
return std::move(inner_);
}
};
extern template class BufferedInputStream<FileStream>;
// Utility functions
template <typename I, typename O>