Bedquilt
The Bedquilt project is an effort to build a set of tools for developing interactive fiction using general-purpose programming languages — particularly but not exclusively Rust — and producing portable game files that are playable on any interpreter that supports the Glk/Glulx/Blorb tech stack. Eventually, Bedquilt will become a full-fledged text adventure engine competing with the likes of Inform and TADS. It isn't there yet, but a major foundational piece is complete: Wasm2Glulx, which translates WebAssembly into Glulx. Wasm2Glulx makes it possible develop for Glulx using any high-level-language compiler that has a WebAssembly backend. Wasm2Glulx has already been used to produce one complete game: a new port of Adventure.
Bedquilt is developed on GitHub at https://github.com/dfoxfranke/bedquilt.
Wasm2Glulx
Wasm2Glulx translates WebAssembly into Glulx. It is mainly a command line tool but also has a Rust library interface. Its raison d'être is to make it possible to use general-purpose programming languages to develop interactive fiction, while producing portable game files that run seamlessly on existing Glulx interpreters.
Wasm2Glulx is plumbing; it is not intended to provide a direct, friendly interface for game developers. It deals only in WebAssembly and in Glulx, and is agnostic to whatever high-level language its input may have been compiled from. As such, the audience for this manual is people developing game engines which target Glulx via WebAssembly, and not so much the users of those engines. The reader is assumed to be familiar with Glulx and with at least the high-level structure of a WebAssembly module.
Installation & Command Line Interface
Installation
Wasm2Glulx does not currently distribute binary packages. The supported way to
install it is via the Rust package manager, cargo
. If you haven't already done
so, first install Rust. Then, to build
and install Wasm2Glulx, simply run
cargo install wasm2glulx
Command Line Interface
A typical invocation of Wasm2Glulx is simply
wasm2glulx mygame.wasm
where mygame.wasm
is a WASM module; this will output a Glulx story file as
mygame.ulx
. If no file is provided as an argument, it will default to reading
from stdin and writing to stdout. Additionally, the following command-line options
are supported:
-
-o, --output <FILE>
Name of output file, or "-" for stdout.
The default is stdout if the input comes from stdin. Otherwise, the default is to strip any .wasm suffix from the input file name, add a .ulx suffix, and output it to the current directory.
-
--glk-area-size <SIZE>
Size (in bytes) of the Glk area. See section Bindings to Glk on the role of this. The default is 4096 (4KiB).
-
--stack-size <SIZE>
Size (in bytes) of the program stack. This goes into the
stacksize
field of the Glulx header. The default is an extremely generous 1048576 (1MiB), chosen because this matches what Rust allocates by default on other platforms. Users of modern systems will never miss 1 MiB of memory, but consider reducing this if you want to keep your games friendly to retrocomputing hobbyists. -
--table-growth-limit <N>
Growth limit (in entries) for WASM tables.
If the input module specifies a smaller maximum, the smaller value will be used. Most programs don't use growable tables and will specify a maximum size the same as the initial one, so this option is usually ignored.
-
--text
Output human-readable assembly rather than a story file.
The format of the assembly is not fully defined and is subject to change in future versions; there is no tool which will re-parse what whis outputs. Unless overridden by
-o
, the output file will have a suffix of.glulxasm
. -
-h, --help
Print a summary of command line options, similar to this manual section.
-
-V, --version
Print version information.
Your Program's Entrypoint
There are two ways for a WebAssembly module to define an entrypoint for
Wasm2Glulx. The module can either define a start
function,
or it can export a function named glulx_main
. In either case, the function
must take no parameters and return no result. If the module defines a start
function and a glulx_main
function, and the two are distinct from each
other, then the start function will be called first and glulx_main
will be
called after the start function returns.
No matter how you define your entrypoint, Wasm2Glulx will always generate some
initialization code that runs prior to the entrypoint being called. This code
takes care of initializing memory from any active data
segments
that your module defines, and initializing tables from active element
segments.
It will also execute a setiosys 2 0
instruction to set Glk as the output
system.
Bindings to Glk
Wasm2Glulx provides a complete set of bindings to the Glk 0.7.5
API. To access a Glk
function, your module should declare a function import with a module name of
"glk"
and a function name which matches the one defined in the Glk spec, but
with the glk_
prefix removed.
All parameters to Glk functions are of type i32
, and the result type is either
i32
or empty. Wasm2Glulx will typecheck your imports and give a compile-time
error if they are incorrectly declared. Wherever a Glk function takes a pointer
argument, the argument passed to the corresponding WASM function should be an
index into the module's memory. An index of zero is interpreted as a null
pointer.
Pointer arguments must not alias each other; treat all pointers as though they
were declared restrict
in Glk's C header. This is not something you have to
actually worry about, because the only Glk function which could possibly take
aliased pointers without UB according to C's aliasing rules is
glk_image_get_info
, and since those are pointers to output parameters it would
be nonsense for them to alias.
WASM Glk functions take their arguments in the same order as described in the Glk spec. This is despite WASM and Glulx having opposite calling conventions (in Glulx, the first function argument is on top of the stack; in WASM, the last argument is on top). The necessary swapping happens behind the scenes.
When an argument to a Glk function is a null-terminated string, Glulx expects
the string to be prefixed with 0xE0
(for Latin-1 strings) or 0xE2000000
(for
Unicode strings). Wasm2Glulx does not require this prefix to be included.
The generated bindings will automatically patch the prefix into memory and then
replace the original memory before returning. This works cleanly because none of
these functions take any other pointer arguments, so there's no need to worry
that the prefix patch will overwrite another argument.
There is no binding for glk_set_interrupt_handler
. However, if your module
exports a function named glulx_interrupt_handler
, it will be configured as
your interrupt handler at startup. (This was cleaner than trying to pass
function pointers in and out of WASM. Although Wasm2Glulx fully supports
funcref
, LLVM's support for it seems to be very buggy and my first experiment
with it segfaulted Clang.)
There are no bindings for Glulx's setiosys
and getiosys
instructions. Glk is
automatically set as the IO system at startup, and it cannot be changed. Your
own code is still responsible for the rest of Glk initialization, such as
creating a root window.
The Glk area
Certain Glk functions pass it ownership of memory buffers that you provide to it. These include:
glk_request_line_event
glk_request_line_event_uni
glk_stream_open_memory
glk_stream_open_memory_uni
Working with these functions is a bit more complicated. Wasm2Glulx creates a
special region of your program image, called the Glk area, which lives outside
the address space of your module's memory. The size of this region is fixed at
compile time but controllable by the --glk-area-size
command line argument.
When you call one of the above four functions, the buf
argument is an index
into the Glk area, rather than an index into memory. Unlike pointers to main
memory, 0
is an ordinary and valid Glk area offset and will not be interpreted
as a null pointer.
This extra bit of ceremony is necessary for two reasons. The first reason arises from the fact that WebAssembly is a little-endian architecture, but Glulx is big-endian and Glk expects to see big-endian data. When passing borrowed pointers to Glk, this difference is kept transparent: the generated bindings automatically swap memory into big-endian before calling Glk, and swap it back before returning. But for owned buffers, this doesn't work, because the Glk API makes it too complex for Wasm2Glulx to infer when buffer ownership has been returned to the caller and the buffer should be swapped back to little-endian. Having a separate Glk area solves this problem: the Glk area is always big-endian, memory is always little-endian, and swapping happens whenever you transfer data from one to the other. The second reason pertains to future-proofing. Currently, conversion between Glulx memory addresses and indexes into WASM memory is just a matter of adding or subtracting a fixed offset determined at compile time. However, this may change, because supporting future WASM features may make it necessary for WASM memory to move around in Glulx's address space. If some of that memory were potentially owned by Glk, then this movement would wreak havoc. Keeping the Glk area separate solves this too, by ensuring that it can always remain at a fixed address even when main memory moves around.
The following intrinsics are provided for moving data in and out of the Glk area:
(import "glulx" "glkarea_get_byte" (func (param $glkaddr i32) (result i32)))
(import "glulx" "glkarea_get_word" (func (param $glkaddr i32) (result i32)))
(import "glulx" "glkarea_put_byte" (func (param $glkaddr i32) (param $byte i32)))
(import "glulx" "glkarea_put_word" (func (param $glkaddr i32) (param $word i32)))
(import "glulx" "glkarea_get_bytes"
(func (param $addr) (param $glkaddr i32) (param $n i32)))
(import "glulx" "glkarea_get_words"
(func (param $addr) (param $glkaddr i32) (param $n i32)))
(import "glulx" "glkarea_put_bytes"
(func (param $glkaddr) (param $addr i32) (param $n i32)))
(import "glulx" "glkarea_put_words"
(func (param $glkaddr) (param $addr i32) (param $n i32)))
The first four functions read or write an individual byte or word to or from the
Glk area at offset $glkaddr
, while the second four move $n
bytes or words
between the Glk area at offset $glkaddr
and main memory at offset $addr
.
Note that the destination argument always comes first. The word functions will
perform endianness swaps as required, while the byte functions will not swap
anything.
Bindings to Glulx Intrinsics
The functions specified in this section can be imported, using a module name of
glulx
, to access various special Glulx instructions which don't have already
have a WASM equivalent.
Math functions
All of these functions are equivalent to the similarly-named functions in C's
<math.h>
. Math functions which already have WASM instructions (such as trunc
and sqrt
) do not have bindings, nor do functions which have neither WASM
instructions nor any special Glulx instructions which accelerate them (such as
expm1
).
Single-precision:
(import "glulx" "fmodf" (func (param $x f32) (param $y f32) (result f32)))
(import "glulx" "floorf" (func (param $x f32) (result f32)))
(import "glulx" "ceilf" (func (param $x f32) (result f32)))
(import "glulx" "expf" (func (param $x f32) (result f32)))
(import "glulx" "logf" (func (param $x f32) (result f32)))
(import "glulx" "powf" (func (param $x f32) (param $y f32) (result f32)))
(import "glulx" "sinf" (func (param $x f32) (result f32)))
(import "glulx" "cosf" (func (param $x f32) (result f32)))
(import "glulx" "tanf" (func (param $x f32) (result f32)))
(import "glulx" "asinf" (func (param $x f32) (result f32)))
(import "glulx" "acosf" (func (param $x f32) (result f32)))
(import "glulx" "atanf" (func (param $x f32) (result f32)))
(import "glulx" "atan2f" (func (param $y f32) (param $x f32) (result f32)))
Double-precision:
(import "glulx" "fmod" (func (param $x f64) (param $y f64) (result f64)))
(import "glulx" "floor" (func (param $x f64) (result f64)))
(import "glulx" "ceil" (func (param $x f64) (result f64)))
(import "glulx" "exp" (func (param $x f64) (result f64)))
(import "glulx" "log" (func (param $x f64) (result f64)))
(import "glulx" "pow" (func (param $x f64) (param $y f64) (result f64)))
(import "glulx" "sin" (func (param $x f64) (result f64)))
(import "glulx" "cos" (func (param $x f64) (result f64)))
(import "glulx" "tan" (func (param $x f64) (result f64)))
(import "glulx" "asin" (func (param $x f64) (result f64)))
(import "glulx" "acos" (func (param $x f64) (result f64)))
(import "glulx" "atan" (func (param $x f64) (result f64)))
(import "glulx" "atan2" (func (param $y f64) (param $x f64) (result f64)))
Game state functions
Each of these functions performs the same task as its equivalently-named Glulx
instruction. There is no binding for quit
because it is redundant with
glk_exit
.
(import "glulx" "restart" (func))
(import "glulx" "save" (func (param $strid i32) (result i32)))
(import "glulx" "restore" (func (param $strid i32) (result i32)))
(import "glulx" "saveundo" (func (result i32)))
(import "glulx" "restoreundo" (func (result i32)))
(import "glulx" "hasundo" (func (result i32)))
(import "glulx" "discardundo" (func))
(import "glulx" "protect" (fun (param $addr i32) (param $len i32)))
The $addr
argument to protect
is a memory index. Protecting other parts of a
WASM instance's state, such tables and globals, is not supported.
Miscellaneous functions
Each of these functions performs the same task as its equivalently-named Glulx instruction.
(import "glulx" "gestalt"
(func (param $selector i32) (param $extra i32) (result i32)))
(import "glulx" "random" (func (param $range i32) (result i32)))
(import "glulx" "setrandom" (func (param $seed i32)))
Bindings intentionally omitted
The search instructions linearsearch
, binarysearch
and linkedsearch
do not
have bindings because they rely on assumptions that are incompatible with
Wasm2Glulx's internal ABI.
The heap instructions malloc
and mfree
are not bound because they are
reserved for future internal use by Wasm2Glulx runtime. You can still allocate
memory using WASM's memory.grow
instruction, and bring your own heap
implementation.
There are no bindings for getstringtbl
or setstringtbl
, and string-decoding
tables are unsupported in general.
There are currently no bindings for throw
and catch
. The functionality
provided by these instructions will be supported in the future by way of the
exnref
WASM feature extension.
High-Level Language Examples
C
Here is an example of a simple freestanding C program which can be compiled into Glulx using clang and Wasm2Glulx, which will print "Hello, sailor!" and then exit.
#define NULL ((void*)0)
typedef unsigned int glui32;
typedef struct glk_window_struct *winid_t;
typedef struct glk_stream_struct *strid_t;
extern winid_t glk_window_open(winid_t split, glui32 method, glui32 size,
glui32 wintype, glui32 rock)
__attribute__((import_module("glk"), import_name("window_open")));
extern void glk_stream_set_current(strid_t str)
__attribute__((import_module("glk"), import_name("stream_set_current")));
extern void glk_put_string(const char *s)
__attribute__((import_module("glk"), import_name("put_string")));
extern strid_t glk_window_get_stream(winid_t win)
__attribute__((import_module("glk"), import_name("window_get_stream")));
#define wintype_TextBuffer 3
void glulx_main() {
winid_t root_window = glk_window_open(NULL, 0, 0, wintype_TextBuffer, 0);
glk_stream_set_current(glk_window_get_stream(root_window));
glk_put_string("Hello, sailor!\n");
}
This can be compiled by running
clang --target=wasm32-unknown-unknown -ffreestanding -nostdinc -nostdlib \
-Wl,--no-entry -Wl,--import-undefined -Wl,--export,glulx_main \
-o hello_sailor.wasm hello_sailor.c
wasm2glulx hello_sailor.wasm
and the resulting hello_sailor.ulx
will run in any Glulx interpreter.
Rust
A similar program in Rust:
#![no_std]
#![no_main]
use core::ffi::{c_char, c_void, CStr};
use core::panic::PanicInfo;
#[derive(Copy, Clone)]
#[repr(transparent)]
struct Strid(*const c_void);
#[derive(Copy, Clone)]
#[repr(transparent)]
struct Winid(*const c_void);
const WINTYPE_TEXT_BUFFER: u32 = 3;
#[link(wasm_import_module = "glk")]
extern "C" {
#[link_name = "exit"]
fn glk_exit() -> !;
#[link_name = "window_open"]
fn glk_window_open(
split: Winid,
method: u32,
size: u32,
wintype: u32,
rock: u32
) -> Winid;
#[link_name = "stream_set_current"]
fn glk_stream_set_current(stream: Strid);
#[link_name = "put_string"]
fn glk_put_string(s: *const c_char);
#[link_name = "window_get_stream"]
fn glk_window_get_stream(window: Winid) -> Strid;
}
#[panic_handler]
fn panic(_: &PanicInfo) -> ! {
unsafe {
glk_exit();
}
}
#[no_mangle]
extern "C" fn glulx_main() {
unsafe {
let root_window = glk_window_open(
Winid(core::ptr::null()),
0,
0,
WINTYPE_TEXT_BUFFER,
0);
glk_stream_set_current(glk_window_get_stream(root_window));
glk_put_string(
CStr::from_bytes_with_nul(b"Hello, sailor!\n\0")
.unwrap()
.as_ptr(),
);
}
}
This can be compiled by running
rustc --target=wasm32-unknown-unknown -o hello_sailor.wasm hello_sailor.rs
wasm2glulx hello_sailor.wasm
Supported WASM Feature Extensions
Wasm2Glulx supports 100% of the WebAssembly 1.0 Core Specification, and most of the current 2.0 draft: as of the 2024-09-21 draft, everything except the SIMD instructions. In addition, a number of feature extensions that are not (or not yet) part of the core spec are either supported or planned to be supported. Here is the support status of every feature extension defined by the WebAssembly working group:
The following features are fully supported:
- Bulk Memory Operations
- Multi-value
- Reference Types
- Non-trapping float-to-int Conversions
- Sign-extension Operators
The following features are not yet supported, but planned:
- Fixed-width SIMD
- Glulx does not natively support SIMD. It's straightforward to emulate, albeit very tedious because there are a huge number of instructions to implement. Adding this will be a low priority unless a compelling use case comes up, which is doubtful.
- Relaxed SIMD
- Tail Call
- Coming soon.
- Typed Function References
- Awaiting upstream support from Walrus (the library that Wasm2Glulx uses for parsing WebAssembly).
- Exception Handling with exnref
- Awaiting upstream support.
- Threads
- Yes, Wasm2Glulx will be able to support this even though Glulx is strictly single-threaded. The Threads proposal really just defines atomics and synchronization primitives, and doesn't define any way to spawn a thread, leaving that up to the embedder. So, the atomics can be implemented as ordinary instructions and the synchronization primitives can be no-ops.
- Custom Page Sizes
- Too early a draft right now, but should be easy to support once fleshed out.
The following features are not planned:
- Branch Hinting
- Wasm2Glulx accepts input which includes branch hints, but it ignores them.
- Multiple Memories
- This is planned for in the sense that Wasm2Glulx is architected in a way that will make it possible to add in the future if necessary. But, doing so would significantly complicate things and add some runtime overhead, so it will be avoided unless a compelling use case comes up.
- Garbage Collection
- This would be a massive amount of work to implement, on top Multiple Memories support which would be a prerequisite. Glulx doesn't have a garbage collector, and any polyfill that Wasm2Glulx could provide would not be any more (and probably less) efficient than the polyfills that GCed languages targeting WebAssembly already usually provide.
- Memory64
- Impossible, since Glulx is a 32-bit machine.
- Instrumentation and Tracing Technology
- This proposal doesn't seem to be fleshed out and seems to be dead.
- Stack Switching
- Infeasible without first extending Glulx.
- Legacy Exception Handling
- This feature is deprecated. Its replacement, Exception Handling with exnref, is planned.
The following features are N/A. They relate to aspects of WebAssembly that aren't applicable to Wasm2Glulx, such as its JavaScript embedding.
- JS BigInt to Wasm i64 Integration
- Custom Text Format Annotations
- Extended Constant Expressions
- Constant expressions are functions of imported constant globals. Since Wasm2Glulx doesn't make any constant globals available for import, it's impossible to write a well-typed extended constant expression.
- Import/Export of Mutable Globals
- Exported mutable globals are accepted but ignored.
- JS String Builtins
- ESM Integration
- JS Promise Integration
- Type Reflection for JS API
- Web Content Security Policy
Known Bugs
Running Wasm2Glulx's test suite gave Glulx interpreters some exercise they've never gotten before, and this turned up a handful of bugs in them. There are two such bugs for which Wasm2Glulx does not currently implement any workaround.
-
The Glulx specification did not state what the result of dividing
-0x80000000/-1
should be, while WebAssembly specifies that this should trap. Glulx interpreters had divergent behavior: Glulxe and Quixe return-0x80000000
, while Git had undefined behavior; on most compilers and architectures, it would crash. It was decided that Glulx should consider this case to be an error, thus matching WASM semantics. Compiling a WASM program which computes-0x80000000/-1
and running it on an interpreter which has not been patched to implement this spec change will yield a result which does not comply with the WASM specification. This is only a problem for 32-bit integers; 64-bit integer division is implemented in Wasm2Glulx's runtime library and does not use Glulx'sdiv
instruction. -
Glulxe and Quixe do not correctly propagate NaN payloads, and return non-canonical payloads for non-propagated double precision NaNs. C and Rust programmers will never care about this, but it's a big problem for dynamic language runtimes which implement NaN boxing. In particular, AssemblyScript is probably going to break. If you are interested in developing for Glulx in AssemblyScript, please file a ticket about it and I can add a command-line flag to enable generating workaround code.
Architectural FAQ
Why WebAssembly? Why not write an LLVM backend instead?
The answer comes down to stability and maintainability. LLVM-IR evolves rapidly, as do LLVM's internal APIs. If you maintain a frontend which targets LLVM, that's great: you're getting a constant stream of new features and new optimizations. Highly-active compiler projects like Rust love this; every sesquimonthly Rust release depends on the latest bleeding edge LLVM. On the other hand, maintaining a backend and keeping up with bitrot becomes a never-ending red queen's race. Glulx-LLVM was last updated in November 2021 as of September 2024. It only ever worked as a forked LLVM with a forked clang, and now that fork is three years out of date. C is pretty static, so maybe working with a three-year-old compiler is acceptable, but for Rust it certainly wouldn't be.
WebAssembly is different. Being a web standard, it has to support many interoperable implementations. While the W3C has become remarkably efficient compared to other standards bodies, changes to WebAssembly are still a much slower and deliberate process than changes to LLVM internals, and when the standard moves, it leaves fixed and durable milestones along its path. WebAssembly 1.0 was finalized in 2019, and although it has been extended considerably since then, the 1.0 standard is still what LLVM and most LLVM-based compilers target by default unless you supply flags to enable newer features. Support for targeting 1.0 is not likely to go away any time soon, just as support for old CPUs is seldom dropped without good reason. Wasm2Glulx already supports 1.0 and much more. Consequently, the version of Wasm2Glulx that exists today will probably work just fine with compilers released a decade from now.
How efficient is Wasm2Glulx's output? Does it have an optimizer?
It does have a bit of an optimizer, yes. The optimizer doesn't have to be complicated, because it's assumed that Wasm2Glulx's input already came from an optimizing compiler. The primary concern is to optimize cases where sequences of multiple WASM instructions can be replaced by a single Glulx instruction. This happens most often with respect to pushing and popping local variables. WASM is strictly load/store, so adding two local variables and assigning the result to a third will look something like
local.get 0
local.get 1
i32.add
local.get 2
A naïve translation into Glulx might look like
copy $0 push
copy $1 push
add pop pop push
copy pop $2
But since Glulx has a richer set of address operands, we'd rather just say
add $0 $1 $2
and Wasm2Glulx does optimize this.
As to overall efficiency, there is one pain point that simple optimization
techniques can't address, which relates to the mismatch of endianness between
WebAssembly (little endian) and Glulx (big endian). This requires every memory
load/store operation to be accompanied by several instructions for byteswapping.
(Thankfully, this isn't required for local variable operations, because just
like in Glulx, WASM locals don't have any particular endianness.) This is a
pretty big performance hit, probably 2x overall to a typical game. In the near
future, this will be addressed using Glulx's accelfunc
facility. Accelfunc has
only ever been used previously to accelerate Inform's veneer functions, but it
is actually very flexible and extensible. New accelfuncs will be defined for the
load/store functions in Wasm2Glulx's runtime library, and support for these will
be upstreamed into interpreters.
glulx-asm
glulx-asm
is a Glulx assembler used by and co-developed with Wasm2Glulx.
Currently, it is the only the backend of an assembler: it can produce Glulx
story files or human-readable output from an AST, but it has no parser for
creating an AST from human-readable text. It may grow such functionality in the
future.
glulx-asm
is not documented here, but at https://docs.rs/glulx-asm.