Let’s jump right into the main focus of this week’s development: savefiles.
The original game uses a FILE setup to read and write savefiles, which we will need to refactor as with everything else we’ve done so far.
I will outline the process of how a save is written/read:
When we click “save” in the options menu, a event for cmdOptionsSaveGame
is sent, which eventually leads to a call for saveGameState
. Here is the GDB Backtrace:
#0 Saga2::saveGameState(short, char*)
(saveNo=32767, saveName=0x7fffffffa170 "\263\212\265A")
at engines/saga2/loadsave.cpp:131
#1 0x0000555555f34a1a in Saga2::cmdFileSave(Saga2::gEvent&) (ev=...)
at engines/saga2/uidialog.cpp:1662
#2 0x0000555555e130c5 in Saga2::gPanel::notify(Saga2::gEventType, int)
(this=0x60e00002fb40, type=Saga2::gEventNewValue, value=1)
at engines/saga2/panel.cpp:169
#3 0x0000555555f464e0 in Saga2::gCompButton::pointerRelease(Saga2::gPanelMessage&)
(this=0x60e00002fb40) at engines/saga2/button.cpp:553
#4 0x0000555555e1baeb in Saga2::gToolBase::handleMouse(Common::Event&, unsigned int)
(this=0x555557592e40 <Saga2::G_BASE>, event=..., time=71827)
at engines/saga2/panel.cpp:985
#5 0x0000555555d843e8 in Saga2::processEventLoop(bool) (updateScreen=true)
at engines/saga2/main.cpp:304
#6 0x0000555555d840b8 in Saga2::EventLoop(bool&, bool)
(running=@0x7fffffffa5e0: false) at engines/saga2/main.cpp:270
#7 0x0000555555f2ea42 in Saga2::FileDialog(short) (fileProcess=0)
at engines/saga2/uidialog.cpp:760
#8 0x0000555555f351ca in Saga2::cmdOptionsSaveGame(Saga2::gEvent&) (ev=...)
at engines/saga2/uidialog.cpp:1719
#9 0x0000555555e130c5 in Saga2::gPanel::notify(Saga2::gEventType, int)
(this=0x60e00002e720, type=Saga2::gEventNewValue, value=1)
at engines/saga2/panel.cpp:169
#10 0x0000555555f464e0 in Saga2::gCompButton::pointerRelease(Saga2::gPanelMessage&)
(this=0x60e00002e720) at engines/saga2/button.cpp:553
#11 0x0000555555e1baeb in Saga2::gToolBase::handleMouse(Common::Event&, unsigned int)
(this=0x555557592e40 <Saga2::G_BASE>, event=..., time=58752)
at engines/saga2/panel.cpp:985
#12 0x0000555555d843e8 in Saga2::processEventLoop(bool) (updateScreen=true)
at engines/saga2/main.cpp:304
I will showcase its contents:
void saveGameState( int16 saveNo, char *saveName )
{
pauseTimer();
try
{
SaveFileConstructor saveGame( saveNo, saveName );
saveGlobals( saveGame );
saveTimer( saveGame );
...
savePaletteState( saveGame );
saveContainerNodes( saveGame );
}
catch ( SaveFileWriteError writeError )
{
// For now this simply rethrows the exception. Eventually,
// this must notify the user that the game was not saved and
// recover gracefully.
//throw writeError;
throw SystemError(cpSavFileWrite,"Writing saved game");
}
resumeTimer();
}
I’ve summarized it a bit to keep it simple, but the gist of it is that we create this SaveFileConstructor
object and pass it through all of these methods, saving parts of the game into saveGame
.
This is how one of those methods look like:
//-----------------------------------------------------------------------
// Store miscellaneous globals in a save file
void saveGlobals( SaveFileConstructor &saveGame )
{
GlobalsArchive archive;
archive.objectIndex = objectIndex;
archive.actorIndex = actorIndex;
archive.brotherBandingEnabled = brotherBandingEnabled;
archive.centerActorIndicatorEnabled = centerActorIndicatorEnabled;
archive.interruptableMotionsPaused = interruptableMotionsPaused;
archive.objectStatesPaused = objectStatesPaused;
archive.actorStatesPaused = actorStatesPaused;
archive.actorTasksPaused = actorTasksPaused;
archive.combatBehaviorEnabled = combatBehaviorEnabled;
archive.backgroundSimulationPaused = backgroundSimulationPaused;
saveGame.writeChunk(
MakeID( 'G', 'L', 'O', 'B' ),
&archive,
sizeof( archive ) );
}
We may as well take a look into this writeChunk
method to see how exactly one writes into the file.
//----------------------------------------------------------------------
// Create a new chunk and write the chunk data.
bool SaveFileConstructor::writeChunk( ChunkID id, void *buf, int32 size )
{
// Determine if file position is at end of previous chunk
if ( posInChunk < chunkSize ) return FALSE;
ASSERT( posInChunk == chunkSize );
SaveFileChunkInfo chunkHeader;
// Initialize the chunk header
chunkHeader.id = id;
chunkHeader.size = size;
// Write the chunk header
if ( fwrite( &chunkHeader, sizeof( chunkHeader ), 1, fileHandle ) != 1 )
throw SaveFileWriteError( "Error writing save game chunk header" );
// Write the chunk data
if ( size > 0 && fwrite( buf, size, 1, fileHandle ) != 1 )
throw SaveFileWriteError( "Error writing save game data" );
// Initialize the chunk varibles to indicate the file position is
// at the end of the chunk
chunkSize = posInChunk = size;
// Return success
return TRUE;
}
The important takeaway here is that we first write the “chunk” header, which consists of the id (the 4 bytes that serve as the identifier, in this case “GLOB”) and the size. After that comes the actual contents that we want to save, that we store in a GlobalsArchive
here.
So then, how do we refactor this for portability? The answer is to use streams. More specifically, for writing into savefiles, we use Common::OutSaveFile
and for reading we use Common::InSaveFile
. The latter is simply a typedef
for Common::SeekableReadStream
.
You remember how we declared saveGame
inside of saveGameState
? We shall do the same thing here. We declare a Common::OutSaveFile *out
pointer that we initialize using OSystem’s SaveFileManager
.1 After that, we send it as an argument to all of these methods. Obviously, that requires us to rewrite all of these individual methods.
Common::Error saveGameState(int16 saveNo, char *saveName) {
pauseTimer();
debugC(1, kDebugSaveload, "Saving game");
Common::OutSaveFile *out = g_vm->getSaveFileManager()->openForSaving(getSaveFileName(saveNo), false);
if (!out)
return Common::kCreatingFileFailed;
SaveFileHeader header;
header.gameID = gameID;
header.saveName = saveName;
header.write(out);
saveGlobals(out);
saveTimer(out);
...
savePaletteState(out);
saveContainerNodes(out);
out->finalize();
delete out;
resumeTimer();
return Common::kNoError;
}
And then finally, once we’re done writing all of the data, we call out->finalize()
and delete the object.
The code for saveGlobals
becomes as follows, by the way:
void saveGlobals(Common::OutSaveFile *out) {
out->write("GLOB", 4);
out->writeUint32LE(sizeof(GlobalsArchive));
out->writeUint32LE(objectIndex);
out->writeUint32LE(actorIndex);
out->writeByte(brotherBandingEnabled);
out->writeByte(centerActorIndicatorEnabled);
out->writeByte(interruptableMotionsPaused);
out->writeByte(objectStatesPaused);
out->writeByte(actorStatesPaused);
out->writeByte(actorTasksPaused);
out->writeByte(combatBehaviorEnabled);
out->writeByte(backgroundSimulationPaused);
}
I’ve removed some debug message calls that aren’t of interest to us. As you can see, we write the chunk header first, and then write all of the individual members of GlobalsArchive
according to their order within the struct. An important thing to note here is the sizeof(GlobalsArchive)
. Due to padding shenanigans, the size of these structs may not be the same for all platforms. This obviously creates portability issues when you try to load saves from other machines. That is why we need to change each and every of one of these suspicious sizeof
operations to a constant such as kGlobalsArchiveSize
.
As for loading, the process is very similar:
void loadSavedGameState(int16 saveNo) {
uint32 loadFlags = 0;
pauseTimer();
Common::InSaveFile *in = g_vm->getSaveFileManager()->openForLoading(getSaveFileName(saveNo));
ChunkID id;
int32 chunkSize;
bool notEOF;
notEOF = firstChunk(in, id, chunkSize);
while (notEOF) {
switch (id) {
case MKTAG('G', 'L', 'O', 'B'):
loadGlobals(in);
loadFlags |= loadGlobalsFlag;
break;
...
}
notEOF = nextChunk(in, id, chunkSize);
}
...
}
I’ve omitted some code for simplicity. Here we construct a Common::InSaveFile
similar to before. We load in each chunk’s header with firstChunk
or nextChunk
2, and according to the ChunkID we load in the appropriate chunk.loadGlobals
is like this, by the way:
void loadGlobals(Common::InSaveFile *in) {
objectIndex = in->readUint32LE();
actorIndex = in->readUint32LE();
brotherBandingEnabled = in->readByte();
centerActorIndicatorEnabled = in->readByte();
interruptableMotionsPaused = in->readByte();
objectStatesPaused = in->readByte();
actorStatesPaused = in->readByte();
actorTasksPaused = in->readByte();
combatBehaviorEnabled = in->readByte();
backgroundSimulationPaused = in->readByte();
}
So, all in all, it’s not very hard work. As long as one pays attention to the sizes3, there is not much to think about. I personally cut down some time by streamlining with Vim macros, and I’m sure someone could write a script to automate much of this, but I didn’t go that far.
Now, I’m showing all of the code commented out, but we proceeded in steps. We started with an #if 0
block surrounding all of the save methods and took things out of it one by one. It took a while, but because of that I learned a new skill: Vim macros.
An interesting thing to note here, is that because the code is mostly the same, it’s very easy to notice when it’s different. In the majority of the code we use methods such as void *archive(...)
or constructX(void**)
, but in spellio.cpp
the save/loading methods are named quite different:
// ------------------------------------------------------------------
// serialize active spells
void saveSpellState( SaveFileConstructor &saveGame )
{
activeSpells.save(saveGame);
}
// ------------------------------------------------------------------
// read serialized active spells
void loadSpellState( SaveFileReader &saveGame )
{
activeSpells.load(saveGame);
}
This leads me to ponder that the spell system was written much later (or earlier) in development.
Now, this is all very fun, but even after doing all of that the work is not done. We still need to: Get rid of the original code; Replace sizeof by portable alternatives; Fix any crashes or bugs detected during playtest. As the saying goes, work never ends!
[1] getSaveFileName(int16)
is a method that returns the filename according to the slot. For instance getSaveFileName(0) returns “000.SAV” as a Common::String.
[2] The original had methods for seeking to the next chunk when the chunk is thought to not be completed. I removed those operations because they did not seem necessary. I believe this shouldn’t be a problem for saves created locally, but I wonder if it will have an effect when loading original saves. I’m writing this here so that I don’t forget it one week later when I can’t get loading to work for mysterious reasons.
[3] Extra points if the size is knowable. There were some instances of enum SpellID
being saved into the archive. This is a problem because enums could have all kinds of size depending on the compiler. Will this have to be reverse engineered !?
Leave a Reply