Glulx: The Local Variable Mess

The draft of the next Glulx spec says, on the subject of local variables:

NOTE: 8-bit and 16-bit locals have never been in common use, and this spec has not been unambiguous in describing their handling. (By which I mean, what I implemented in the reference interpreter didn't match the spec.) Therefore, 8-bit and 16-bit locals are deprecated. Use of the copyb and copys opcodes with a local-variable operand is also deprecated.

When I wrote "never been in common use" I was being somewhat parochial. Inform 6 and 7 only generate 32-bit local variables, so no Inform games are affected by this deprecation. However, the Spanish IF system Superglus has used short locals, and some Superglus games are out there relying on this feature.

It is therefore worth documenting what the spec said, what Glulxe (the reference interpreter) implemented, and what other interpreters can handle. This is for everybody's benefit, including mine -- whenever the subject comes up, I have to re-trawl the source code to remind myself what's going on.

What the spec says

The relevant parts of the Glulx spec (version 3.1.1):

The "locals" are a list of values which the function uses as local variables. These also include function arguments. (The first N locals can be used as the arguments to an N-argument function.) Locals can be 8, 16, or 32-bit values. They are not necessarily contiguous; padding is inserted wherever necessary to bring a value to its natural alignment (16-bit values at even addresses, 32-bit values at multiples of four).

The program is not prevented from accessing [local variable] locations whose size and position don't match the formatting, or locations that overlap, or even locations in the padding between locals. However, if a program does this, the results are undefined, because the byte-ordering of locals is up to the terp.

The "call frame local" modes access a field on the stack, starting at byte ((FramePtr+LocalsPos) + address). [...] this must be aligned with (and the same size as) one of the fields described in the function's locals format.

Since copys and copyb can access chunks smaller than the usual four bytes, they require some comment. When reading from main memory or the call-frame locals, they access two or one bytes, instead of four. However, when popping or pushing values on the stack, these opcodes pull or push a full 32-bit value.

The confusion is already implicit in the fourth quoted paragraph. That is written as if local variables were always four bytes, and all opcodes other than copys and copyb accessed four-byte locals only. This contradicts the third paragaph.

As I said, Inform 6 and 7 only use four-byte locals. They also never generate the copyb and copys opcodes when compiling regular code. (copyb is used in one place, when printing the game version banner, but not to access local variables -- only main memory.) This is why the confusion lingered for years; the games I was familiar with never tripped over it.

What the spec means

I'll use this example call frame to illustrate henceforth:

  Addr  Variable
  ----  --------
  L+0   LocX (4 bytes)
  L+4   LocY (2 bytes)
  L+6   LocZ (1 byte)
  L+7   LocW (1 byte)
  L+8   LocQ (4 bytes)

This locals segment is twelve bytes, starting at stack address L (FramePtr+LocalsPos). It contains a 32-bit variable (LocX), a 16-bit variable (LocY), two 8-bit variables (LocZ and LocW), and then another 32-bit (LocQ). There is no padding.

Remember, the Glulx stack (which includes the locals segment) doesn't have a specified endianness. It doesn't even have to be stored as a bunch of bytes. So it's important that a valid Glulx program only access these local variables as whole units. If you try to read the "first byte" of LocX as an 8-bit variable, who knows what you're going to get.

The intent of the spec is that most opcodes should obey the local-variable structure when accessing the locals segment. For example, if you write the I6 line

  LocX = LocY + LocZ;

...the compiler could compile this as:

  @add loc:4 loc:6 loc:0;

Note: that isn't legitimate I6 assembly syntax. I'm using "loc:4" to indicate the operand mode "call frame local with address 4", e.g., LocY. The point is, the interpreter is supposed to read a two-byte value starting at address L+4, add a one-byte value starting at address L+6, and store the sum in the four-byte field starting at address L+0.

The spec calls out copyb and copys as special, implying that an opcode

  @copyb loc:6 loc:7

...should copy one byte at L+6 to L+7. This isn't very informative, however, because the rule above implies that the regular (four-byte) copy opcode should do exactly the same thing! That is,

  @copy loc:6 loc:7

...should also copy one byte at L+6 to L+7.

On the other hand, if you do

  @copyb loc:0 loc:7

...then you're breaking the locals structure; you're trying to read the first byte of LocX. As I said, local variables have no defined endianness, so the outcome is not guaranteed. (But the Inform compiler has no safeguards against doing this.)

To demonstrate this problem in compilable Inform 6 code:

  [ testfunc loca locb;
    loca = $01020304;
    @copyb loca locb;
    print locb;

This routine will print "4" on an Intel Mac, but "16777216" (0x01000000) in Glulxe on a PPC Mac.

The value of copys and copyb is demonstrated if we copy to main memory (or the stack) instead. Both of the following are valid and useful:

  @copyb loc:6 mem:1000

This copies one byte from L+6 to main memory address 1000. In contrast,

  @copy loc:6 mem:1000

...reads one byte from L+6, and stores it in the four-byte field starting at main memory 1000. (Glulx main memory is always big-endian, so this means the byte is copied to address 1003, while addresses 1000-1002 are filled with zeroes.)

As it happens, you can achieve the same results, without the copyb/copys special rule, by using the astoreb and astores opcodes. (This is in fact what the Inform compiler uses for I6 array code.) The whole mess would have been avoided if I'd noticed this and left copyb/copys out of the original spec.

What Glulxe does

We love specifications, but we write what works. Glulxe was the initial Glulx interpreter. I still consider it "the reference interpreter" (meaning that when the implementation clashes with the spec, I can't just wish the problem away).

Glulxe has an outright bug, according to the spec: it always reads or writes four-byte locals, except when executing the copyb opcode (which always reads or writes one-byte locals) or the copys opcode (always two-byte locals). It doesn't pay attention to the locals structure at all.

This means that the line

  @copy loc:6 loc:7

...will not copy one byte. It will copy a four-byte field (L+6 to L+9) to a overlapping four-byte field (L+7 to L+10). This will do the right thing as far as LocZ and LocW are concerned, but it will corrupt LocQ. If there were no LocQ, it would corrupt the bottom value of the value stack.

(This case, at root, is why I am deprecating short locals. Code that relies on them has never worked right in Glulxe. I could fix Glulxe, but I don't want to break games that might rely on its buggy behavior!)

The copyb/copys opcodes are not totally useless. They can safely be used with main memory and the stack. (As I said, most Inform games rely on this behavior in one place.) Glulxe also handles them correctly with short locals, if the local size matches the argument size:

  @copyb loc:6 loc:7
  @copyb loc:6 mem:1000

These two lines will do the right thing, because a one-byte opcode is being applied to one-byte local variables. However,

  @copyb loc:0 loc:7 unsafe, as noted above. On a big-endian machine, it will copy the high byte of LocX to LocW. On a little-endian machine, it will copy the low byte of LocX to LocW.

(This case is why I am deprecating the use of copyb/copys with all locals. Even if I had not shipped a buggy Glulxe interpreter, it would be inherently unsafe.)

What Git does

Git is a fast C interpreter written by Iain Merrick.

Git doesn't support short locals at all. If a function header defines 8-bit or 16-bit local variables, calling that function will cause Git to exit with an error: "Local variable wasn't 4 bytes wide".

This simplifies the operand code greatly, which is good, because Git's operand code is some kind of Faustian preprocessing nightmare which I wouldn't try to understand for a big clock.

What Quixe does

Quixe is a Javascript (web-app) interpreter which does JIT-compilation to speed up VM code execution. It is not yet released (as I write this), but the opcode-handling has been stable for some time now, so I can report what it does.

Quixe does not represent the stack (and locals) as an array of bytes. For efficiency, it stores the locals segment as a sparse array of (native) Javascript number. Each local is a single number, indexed at the address which it "should" have. The intermediate array positions are undefined. So, for our example stack frame, Quixe would have the array

  [ LocX, undef, undef, undef, LocY, undef, LocZ, LocW, LocQ ]

(The trailing undefined values are omitted.)

Because locals are not encoded as bytes, Quixe avoids the endian problems that Glulxe suffers.

  @copy loc:6 loc:7

...correctly copies LocZ to LocW.

  @copyb loc:0 loc:7

...reliably copies the low byte of LocX to LocW. The short I6 program quoted earlier will print "4", same as Intel Glulxe.

However, one bug remains. Quixe will still ignore the size limitations of the locals structure. If you do

  @copy loc:0 loc:7

...then Quixe will copy LocX to LocW directly. This could leave LocW holding a value greater than 255, despite nominally being an 8-bit variable. In fact, any operation which stores into a short local can wind up storing a value too large for that local.

(The copyb and copys opcodes truncate properly -- albeit for the opcode size, not the variable size. This is why the "@copy loc:6 loc:7" case works correctly.)

Last updated May 25, 2010.

Glulx home page

Zarfhome (map) (down)