Interpreter-Managed Saves in Glulx

Abstract

I describe how to build a Glulx interpreter that returns the player to their last game state at launch. (This is how mobile apps typically behave.) This requires extensions to the Glk library.

Introduction

The Z-machine's save-restore model was entirely under the player's control. The terp always launched at the game's beginning; the player would type SAVE or RESTORE at a prompt. The verb-handler routines for these commands contained special opcodes to save or restore the game state. When I designed Glulx, I copied this model.

(UNDO works similarly, except that there is no explicit SAVEUNDO command. The game silently runs a SAVEUNDO routine after every command input.)

Glulx has the additional complication of I/O state (the number of windows, which windows are accepting input, open files, etc). This information is represented in the Glk library, but the Glulx VM does not try to save or restore it from the save file. Instead, after a successful RESTORE/UNDO, the game must query the Glk library and adjust itself to the pre-existing I/O state. (This is the GGRecoverObjects() call in the Inform library. It is also called at startup time; the game might be starting up after a RESTART command, with pre-existing I/O state.)

This model suffices for desktop interpreters. However, in the mobile world, we want to silently save the user's state whenever the app is shut down or switched away from. When the app is relaunched, it should reappear in the state that the user last left it. We will call this "terp-managed save", in contrast to "player-managed save".

Desktop apps are increasingly inclined to use this model as well. There's no reason that a desktop IF terp shouldn't track the current state of each game that you play, whether or not you explicitly SAVE.

(A historical note: Frotz pioneered the notion of "hotkey UNDO" -- a UI menu command to perform UNDO, whether or not the game supported it. This requires the same technical implementation as terp-managed save/restore for the Z-machine. I don't know whether Frotz tackled the full auto-save/auto-launch cycle before mobile development made it mandatory.)

How to implement terp-managed save?

What Doesn't Work

The "obvious" solution is to launch the game and then create an artificial line-input event, forcing the string "RESTORE" into the input buffer. This is inadequate for all sorts of reasons:

The same issues apply when trying to save the game at terp-shutdown time. Inserting "SAVE" into the input buffer is not a reliable strategy.

The Plan That Works

We simplify the problem by assuming that terp-managed save always happens while the game is awaiting input. (An IF game is always awaiting input, from the player's point of view. Computation runs for a tiny interval every time the player hits Enter. If the terp shuts down during this interval, the player's last command won't be saved, but this should be a rare case.)

Whenever the game blocks to await input, the terp silently saves. (This occurs early in the glk_select() call.) The save file is stored in a special location, not to be mixed in with player-managed save files. We use the standard Glulx save format, but add additional information that represents the I/O state.

When the terp launches, it checks the special location. If a save file is present there, it restores the game state and the I/O state. The I/O state has to be restored because the game is not about to call GGRecoverObjects(). It will re-enter the glk_select() call and continue waiting for player input. Therefore, the I/O state must be consistent with what the game remembers when it was saved. (Same number of windows, same text visible in the windows, same files open, etc.)

Summary of the Differences

Player-managed save:

Terp-managed save:

Note, by the way, that these two types of save file are not exchangeable. You cannot take a SAVE-created save file and install it as the terp-managed auto-save. It lacks the necessary I/O data, and the stack hackery is different.

The Bad News: Glk Storage

This plan requires a few extra lines in the Glulx interpreter, but significant extra code in the Glk library. Glk must be able to write its entire state to a file, and then read it back it. There are many Glk interpreters with different internal code, so this work must be implemented several times in different ways.

The good news is that this state does not have to be portable between interpreters. (It will always be saved and restored by the same interpreter, on the same device, or at worst a very similar device.) Therefore, we will not specify a format for it. Each Glk library can implement its own format. (Perhaps this is bad news...) In iOSGlk, I used Cocoa's native NSCoder interface. Other libraries can use different tools.

(If this Glk storage format were well-specified, we could share the auto-save state between interpreters. However, as noted earlier, we do not aspire to this. We can already transfer player-managed save files between interpreters, which is sufficient for the present.)

Update, March 2015: It occurred to me much later that if we were to specify to a storage format, it should be the GlkOte JSON data format. Obviously. This would require some extension -- we would need memory stream information, for example. But it's possible.

More Bad News: Re-running the Interpreter

Another mobile convention (at least on iOS) is that the app never shuts itself down. It's always the user's choice to exit the app, by returning to the home screen (and then the app may be backgrounded, rather than shut down immediately). When an app shuts down spontaneously, the user perceives it as a crash.

So what should we do when the player types QUIT in an iOS IF terp? The least awkward response is to display a RESTART GAME button.

Therefore, where a desktop Glk library invokes glk_main() once, the iOSGlk library invokes it in a loop. After each invocation it wipes the autosave file, and then blocks and displays the RESTART GAME button. Glulxe is engineered so that glk_main() can be called repeatedly without leaking memory.

However, if glk_main() exits due to an interpreter error, it is not safe to restart it. We detect this case by looking at Glulxe's vm_exited_cleanly global variable. If it is false, we instead display an EXIT APP button. (In this case, the terp effectively has crashed. Exiting with a user notification is the best we can manage.)

Warning: a call to glk_exit() is currently treated as an interpreter error rather than a clean exit. This is wrong, but it's also hard to fix. The @quit opcode and the quit I6 statement are clean, and most Inform games use these options, so we're usually okay.

Implementation

The pattern for launching the interpreter can be seen in iosstart.m, included in the Glulxe source. Four functions must be supplied. These are their responsibilities:

The Startup Function

In the Glk library startup code (before glk_main()), we set three Glulxe extension hooks. We do this by calling three functions, supplying a hook function address to each:

(In desktop interpreters, we might open the game file here and call locate_gamefile(). However, in the mobile world, we have to move that code to the start hook function.)

The Start Hook Function

Open the game file. Check whether it's a Blorb file or a raw Glulx file; call locate_gamefile().

If an error occurs, indicate this by setting the Glulxe global init_err and returning without a call to locate_gamefile(). (The locate_gamefile() function may itself detect an error and set init_err.)

(Again, this is only necessary in the mobile world, where glk_main() is invoked in a loop. If re-running glk_main() is not supported, we can open the game file in the startup function; the game file start hook can then be omitted.)

The Autorestore Hook Function

If the autosave file exists, load it in.

In iOSGlk, we keep the autosave data in two files, both in the app's documents directory. This is easier than trying to merge the Glk library data into the Quetzal format. autosave.glksave is a standard Glulx save file (except that the stack data is slightly different, as noted above). autosave.plist is the Glk library data, as generated by NSKeyedArchive (the NSCoder interface).

(One possible wrinkle: the app's window size might not be the same as when the game was saved. For example, a mobile device might launch the game in portrait orientation after saving in landscape orientation. In this case we must immediately end glk_select() with a screen rearrangement event. Typically this will work by setting the same "size changed" flag that a real reorientation event would set, and letting the library's event loop notice as usual.)

The Select Hook Function

Perform a terp-managed save.

It is safest to write the two autosave files to temporary locations and then rename them to the final filenames. That way, if an error occurs, existing autosave data is not destroyed.

Note that this hook function is only called when glk_select() is started. The glk_fileref_create_by_prompt() function also blocks and waits for player input, but the select hook is not called for that. Autosaving there would be confusing.

What Must Glk Store?

The short answer is "everything", but it may be useful to go into detail.

We assume that Glk objects (windows, streams, etc) have unique identifiers which can be stored in a file and then read back in. (iOSGlk uses NSNumber tags, both for this purpose and internally as hash keys.) (These identifiers are distinct from the the glui32 ids seen by the Glulx VM. Those are generated separately, in the gi_dispa layer. We will have to store both.)

The following lists are not guaranteed to be complete. I've looked over my iOSGlk code, plus some other Glk libraries, but I'm sure I've missed fields that are not supported by all libraries.

Library top-level data

Window data

Stream data

A footnote on file streams: it is unfortunately necessary that if an open file stream is autosaved, the autorestore must reopen it. (The game will assume that the stream is still open after autorestore.) If the file has been deleted in the meantime, the autorestore procedure must recreate it! This is doable on iOS (where the interpreter has full control of the app's files), but on a desktop OS, the user may have deleted or moved the directory where the file should be. In this case, the interpreter might create and open a dummy file, or perhaps query the user where to put the file.

Fileref data

Schannel data

(I've never implemented this)

Extra Glulxe state

The Glulx VM has some data which is marked as "not part of the saved-game state". Player-managed saves ignore this data, but terp-managed saves must deal with it.


Last updated February 25, 2016.

Glk home page

Zarfhome (map) (down)