Customizing an interpreter for a Glulx game release
Thursday, June 18, 2015
Tagged: glulx, hadean lands, c, inform, interactive fiction, if
Another technical question from Twitter: the integration of Hadean Lands with its iOS app. How did I set up iOS UI features like the dynamic map and the recipe index?
(Warning: if you don't think C code is interesting, this post is not for you! Sorry.)
The iOS version of HL uses my standard iOS IF interface, extended. I've added two tabs to it. The map tab shows your current location, and you can tap to travel to any room you've visited before. The recipe tab shows an index of recipes and other information you've learned. These work just like the "GO TO..." and "RECALL..." commands, so they don't make the game easier to solve, but they're convenient shortcuts.
I'm not going to post the iOS UI code I used. If you know iOS programming, it's very basic -- textbook UITableView and UIImageView stuff. Instead, I'll talk about the general problem: transferring information between the Glulx VM and your native (C) interpreter.
I should put "general problem" in quotes. There are several Glulx interpreters, after all. But let's assume that you're building a native app for your Glulx game, incorporating the glulxe interpreter engine (in C), and you want to customize it with game-specific features. You've implemented the UI part; now you just need to extract game state information. Say, the player's location to show on the map.
There are a couple of approaches that would work. For example, we could define a completely general system for transmitting game information to an outside observer, and add that to the Glk spec. Sound like a good idea? Well, maybe, but it's both a hard problem and a vague problem -- what's "information"? We'd probably need some kind of structured interchange format (XML? JSON?). Then we'd have to encode and decode that. Plenty of headaches. No thanks, not right now.
Or we could define a new Glk output capability just for this game. No spec, just define a function ("
glk_set_map_location
") and have the game call it each turn. I thought about this, but I decided it would require modifying too many different modules. Glk function dispatching is kind of ugly.
Instead, I decided to write C code to peer directly into Glulx VM memory! It's stored as a simple byte array, after all. Reading the game state out is just a matter of understanding the memory layouts of objects, variables, and arrays.
Okay, that's not easy, but it's doable in a small amount of code. To make this work:
-
You'll need to know some Inform 6. Sorry. Inform 7 is a wonderful high-level programming system, but it compiles into low-level objects, variables, and arrays.
-
You'll need to extract information from the
gameinfo.dbg
file that's built along with your game. (This lives in theBuild
subdirectory of yourGame.inform
project.) This is where you find the memory addresses of those objects, variables, and arrays.
-
You'll need some boilerplate C code to examine objects, variables, and arrays. I'll attach it to this post; you can copy-and-paste.
How do you use this? Say you're interested in the player's location. (For the map, right?) You know from the Standard Rules that
The location variable translates into I6 as "real_location".
Browse through
gameinfo.dbg
with a text editor (or an XML editor) and you'll see a stanza that starts:
<global-variable>
<identifier>real_location</identifier>
<address> 249528</address>
[...more info...]
</global-variable>
This tells you the absolute memory address of the I6 variable
real_location
. Call gameparse_get_global()
with that address -- the function is shown below -- and you'll get the player's location, expressed as the memory address of the room object.
How do you know what room that is? Somewhere in
gameinfo.dbg
is another stanza:
<object>
<identifier>I88_kitchen</identifier>
<value> 273861</value>
[...more info...]
</object>
This indicates that an object named "Kitchen" has memory address 273861. (It happens to be the 88th item that the compiler defined.)
Obviously this is not very convenient. All of these addresses are liable to change every time you compile your Inform game. So you wind up writing a script to parse your
gameinfo.dbg
XML, locate the real_location
address, locate an object called I#_kitchen
(for some integer, might not be 88), and so on. I like to write them out to a C header file looking like this:
#define GLOBAL_REAL_LOCATION (249528)
#define OBJ_KITCHEN (273861)
Then you can include this header in your game project and write code like
if (gameparse_get_global(GLOBAL_REAL_LOCATION) == OBJ_KITCHEN) {...}
But you'll have to write this XML-parsing script yourself, I'm afraid. I don't have one for you. You could start with profile-analyze.py.
Note that you can't do this for I7 global variables (declarations like "Foo is a thing that varies.") Those get tucked into an I6 array and the
gameinfo.dbg
file has no information about them. If you need to observe a global variable, you'll have to declare it in I6 with an I7 translation. See I7 manual 27.22.
The
gameparse_obj_child
, gameparse_obj_sibling
, gameparse_obj_parent
functions let you traverse the I6 object tree. (Although this doesn't give you I7 relations like component-ness.) I used this for HL's recipe index. The game's internal knowledge objects are moved into special containers, for easy scopability, so I can trawl those containers to set up the recipe tab.
Looking at object attribute and properties is easy. The functions
gameparse_obj_attribute
and gameparse_obj_property
do that. Knowing what I6 attribute or property represents a given Inform property is harder. The best plan is to to look at the generated I6 code (the Build/auto.inf
in your Inform project) and see what's going on under the covers. For examine, the boolean property open/closed
is implemented by the following I6 lines:
! meaning of "open"
if (t_0) return (GetEitherOrProperty(t_0, open));
This means you're looking for an I6 attribute in the
gameinfo.dbg
file:
<attribute>
<identifier>open</identifier>
<value>13</value>
</attribute>
Or say you've written the I7 line:
A thing has a number called the weight.
You find that this generates I6 code like:
! [1: let n be the weight of the noun]
tmp_0 = GProperty(OBJECT_TY, noun, p15_weight);
And so you're looking for an XML stanza like:
<property>
<identifier>p15_weight</identifier>
<value>268</value>
</property>
At this point you start to wonder if the general information API wouldn't be better after all. Maybe it is. Why didn't I go that way? (I realize this is more of a peek into Zarfbrain than you probably care about.) I find that this stuff is the easy part. Drawing a map was hard. Extracting recipes from game output and importing them into an iOS app was work, if not really hard. Extracting state from VM memory was a solved problem; I just had to do it for a lot of objects.
Also, it's fast. I only inspect game state when flipping to the relevant tab, and the inspection code is C. If I'd rigged the game to output state to an API, it would have to be every turn, which would slow down normal gameplay. Or I guess I could have added a secret input event for tab-flipping, which would mean blocking the UI on game code, also slow... Anyhow.
Some other concerns:
-
In my iOS interpreter, the VM runs in a background thread. The UI runs in the main thread, as is usual for iOS apps. How did I synchronize the VM inspection? I didn't! Totally whiffed the thread-safety issue. It's generally not a problem; the player will flip tabs while the VM is blocked awaiting input. But if you have some fancy timed-input code that moves the player around, and the player flips tabs at just the moment when the timer fires, you could get a bum value out of
real_location
.
-
What about Z-code games? You can take the same approach, but you need different state-inspection code, because the Z-machine's memory layout is different. (For a start, it's all 16-bit words, not 32-bit.) I did some of this for the iOS releases of Dreamhold, Shade, and Heliopause. But I didn't wrap up the C code into nice functions like these. So -- exercise for the reader, sorry.
-
Can you do this in Quixe? (A Javascript interpreter instead of a C interpreter.) Yes, and the plan is just about identical. Quixe maintains a private
memmap
array, which is a Javascript array of byte values, so you just have to translate the code below into Javascript and you'll get the same results. The only trick is thatmemmap
is in a private scope. You could add the functions below to the Quixe global object, or rely onReadWord/ReadByte
, or just add a one-liner to exportmemmap
:
get_memmap: function() { return memmap; },
Finally, we have the question of input. When the player taps a room on the HL map, the game must accept it as input.
Again, there are a few ways to handle this. I decided to use a custom Glk event. The Glk spec says that negative event ids (0x80000000 to 0xFFFFFFFF) are reserved for implementation-specific events, and that's what this is. The iOSGlk library has a
forceCustomEvent
method. The map UI invokes this, passing a negative constant as the event type and the room object address as an extra argument. Conveniently we've already extracted the addresses of all the rooms from gameinfo.dbg
.
(Other Glk libraries might not have this sort of API, but it will be easy to add. All events funnel into the Glk library in the same way.)
The only remaining chore is for the game to react to this custom event. Unfortunately, Inform's core parser loop is built to accept only text. This will have to be improved someday! (Not just for custom map hacks, but for hyperlink input, mouse input, and so on.)
But, again, I took the cheap way out. I used the
HandleGlkEvent
hook to translate the custom event into the input line "MAP-VERB 273861", where the number is the decimal room address.
This is not ideal because a player could type that line by hand! Well, whatever. Players can do what they want. Mind you, I sanity-check the argument very carefully to make sure an invalid address can't crash the game or leave the player stuck in a teakettle.
The relevant I7 code (and I6 inclusions) appear at the end of this post. I just noticed, though: it won't compile with the current Inform 6 compiler (6.33)! This is because I rely on the constants
#lowest_object_number, #highest_object_number, #highest_class_number
. These were missing from the Glulx compiler until, well, until I was writing this map code and needed them. You can build the latest I6 compiler from source and shove that into your I7 distribution. Then this'll work.
So the conclusion is, this is all a big pain in the butt, isn't it. Yep.
(No comments out of you, Dave. It really was the least-effort solution for my particular problem.)
C code for examining Glulx VM state:
#include "glulxe.h"
/* The glulxe.h header defines the glui32 type and the Mem4() macro.
Also the memmap global variable (array of bytes). */
/* These functions do a little bit of safety-checking, but you really
should be careful to only call them with valid object addresses. */
/* Feel free to add warnings or errors to the "error" cases. I like to
throw exceptions, myself. */
/* All of this code assumes that NUM_ATTR_BYTES is 7, the default
value for Glulx games. If you increase NUM_ATTR_BYTES, you'll
have to adjust the object structure offsets. */
int gameparse_mem_active(void)
{
return (memmap != NULL);
}
/* Fetch a global variable. */
glui32 gameparse_get_global(glui32 addr)
{
if (!memmap)
return 0; // error: called get_global with no memory map
return Mem4(addr);
}
/* Get the object which contains a given object, or 0 if it
is off-stage. (The I6 parent() function.) */
glui32 gameparse_obj_parent(glui32 obj)
{
if (!memmap)
return 0; // error: called obj_parent with no memory map
if (memmap[obj] != 0x70)
return 0; // error: called obj_parent on a non-object
return Mem4(obj+5*4);
}
/* Get the first object contained by a given object, or 0 if it
has no contents. (The I6 child() function.) */
glui32 gameparse_obj_child(glui32 obj)
{
if (!memmap)
return 0; // error: called obj_child with no memory map
if (memmap[obj] != 0x70)
return 0; // error: called obj_child on a non-object
return Mem4(obj+7*4);
}
/* Get the next object contained after a given object, or 0 if there
are no more. (The I6 sibling() function.) */
glui32 gameparse_obj_sibling(glui32 obj)
{
if (!memmap)
return 0; // error: called obj_sibling with no memory map
if (memmap[obj] != 0x70)
return 0; // error: called obj_sibling on a non-object
return Mem4(obj+6*4);
}
/* Look up an attribute flag on an object. */
int gameparse_obj_attribute(glui32 obj, int attr)
{
if (!memmap)
return 0; // error: called obj_attribute with no memory map
if (memmap[obj] != 0x70)
return 0; // error: called obj_attribute on a non-object
unsigned char byte = memmap[obj+1+(attr>>3)];
if (byte & (1 << (attr & 7)))
return 1;
else
return 0;
}
/* Look up a property value on an object. */
/* Returns the first word of the property, if multi-word. (In most I7 games,
the only multi-word property is "name". So you can't use this function
to scan through the name list of an object.)
If the property is not provided for this object, returns 0. */
glui32 gameparse_obj_property(glui32 obj, int prop)
{
if (!memmap)
return 0; // error: called obj_property with no memory map
if (memmap[obj] != 0x70)
return 0; // error: called obj_property on a non-object
glui32 proptab = Mem4(obj+16);
glui32 propcount = Mem4(proptab+0);
for (int ix=0; ix<propcount; ix++) {
glui32 propent = proptab+4+ix*10;
int pid = Mem2(propent+0);
if (pid == prop) {
glui32 paddr = Mem4(propent+4);
return Mem4(paddr);
}
}
/* Property not provided. */
return 0;
}
Inform 7 code for accepting custom input events. (This assumes a "select one room on a map" action, but it can be adapted to other uses.)
Include (-
[ HandleGlkEvent ev ischar args val;
if (ischar == 0 && ev-->0 == CUSTOM_EVENT_ID) {
val = ev-->2; ! the object address of the tapped room
glk_cancel_line_event(gg_mainwin, gg_event);
! Write a synthetic "MAP-VERB ###" command into the buffer.
VM_PrintToBuffer(buffer, INPUT_BUFFER_LEN-WORDSIZE, PrintVisitNum, val);
return 2;
}
return 0;
];
[ PrintVisitNum val;
print "MAP-VERB ", val;
];
-) before "Stubs" in "Glulx.i6t".
To decide what object is paranoid-object-check (N - number): (- ParanoidObjCheck({N}) -).
Include (-
! Return val if val is a valid object, otherwise 0.
! This code requires the bleeding-edge (6.34) Inform 6 compiler.
[ ParanoidObjCheck val;
if (val < Class + ((#lowest_object_number + #highest_class_number) * GOBJ_TOTAL_LENGTH))
return 0;
if (val > Class + ((#highest_object_number) * GOBJ_TOTAL_LENGTH))
return 0;
if ((val - Class) % GOBJ_TOTAL_LENGTH ~= 0)
return 0;
if (val->0 ~= $70)
return 0;
return val;
];
-).
Numeric-visiting is an action applying to one number.
Understand "map-verb [number]" as numeric-visiting.
Carry out numeric-visiting:
let N be the number understood;
let O be paranoid-object-check N;
if O is nothing:
instead say "That address ([N]) is not an object!";
if O is not a room:
instead say "That address ([N]) is not a room!";
[Check locked doors and so on here...]
now the player is in O;
say "You move to [O]."