GrogVM

A SCUMM v5 script engine for Elixir. It runs the bytecode of the LucasArts adventure games of 1990–1992 (The Secret of Monkey Island, Monkey Island 2, Indiana Jones and the Fate of Atlantis) by compiling each script to an Elixir module and running it against a host: a module your game writes that supplies the world the scripts talk to.

GrogVM is the engine under MonkeyXland, which plays The Secret of Monkey Island in a terminal. GrogVM itself knows nothing about Monkey Island, rooms, actors or pixels. It knows scripts: how to decode them, how to run them, and which of their instructions to hand to you.

No game data is included.

What is yours and what is GrogVM's

A SCUMM script is a stream of instructions. Some of them are the script's own business: variables, arithmetic, jumps, comparisons, waiting out a delay, starting and stopping other scripts, freezing them, opening and closing cutscenes. GrogVM runs all of those itself.

Everything else is a request to the game world: put an actor somewhere, print a line, load a room, play a sound, ask what state an object is in. GrogVM decodes the instruction, reads its variables, and hands you a plain tuple:

{:put_actor, 1, 120, 130}
{:actor_ops, 1, [{:costume, [3]}, {:walk_speed, [8, 2]}]}
{:print, 1, [{:color, [10]}, {:text, ["Hello, I'm Guybrush Threepwood."]}]}
{:if_state, 42, 1}

You decide what each one means. Variables have already been replaced by their values, messages have been decoded, and sub-operations such as actor_ops' list are tuples of a name and its arguments.

Installation

def deps do
  [{:grog_vm, "~> 0.1"}]
end

Elixir 1.18 or later.

Running a game

Three steps: compile the scripts, write a host, drive the frame loop.

1. Compile scripts

Scripts come out of the game's resource files as binaries. GrogVM.Compiler.load/2 turns one into a module, given the offsets code may start at ([0] for a global or local script; an object's verb table for an object script):

{:ok, module} = GrogVM.Compiler.load(script_bytes, [0])

GrogVM.Compiler.load_all/1 compiles a list of {bytes, entries} pairs in parallel and returns the modules in the same order. A game with hundreds of scripts loads them all up front this way.

Modules are named by a digest of the bytecode, the entries and the compiler, so the same script always compiles to the same module. Compiled modules are written to a cache directory and loaded from there on later runs: the application setting :grog_vm, :cache_dir, by default grog_vm under the user cache directory.

2. Write a host

A host implements GrogVM.Host, two callbacks:

defmodule MyGame.Host do
  @behaviour GrogVM.Host

  @impl true
  def code(ctx, {:script, number}), do: ...
  def code(ctx, {:object, object, verb}), do: ...

  @impl true
  def op(ctx, {:put_actor, actor, x, y}), do: ...
  def op(ctx, {:print, actor, parts}), do: ...
  def op(ctx, {:if_state, object, state}), do: ...
end

code/2 answers "where is this script?". Given {:script, number} or {:object, object, verb}, it returns {:ok, %{code: module, offset: offset, kind: kind}} with the compiled module, the offset to start at, and the script's kind (:global, :local, :room, :object or :inventory), or :none when there is no such code. Object verbs fall back to the default verb 0xFF.

op/2 runs one world operation and returns one of:

return meaning
{:ok, ctx} done, the script continues
{:ok, value, ctx} done, with a result: an integer for instructions that store one, a boolean for tests like if_state
{:pause, ctx} done, the script waits for the next frame
{:retry, ctx} not done yet; the script runs this instruction again next frame

{:retry, ctx} is how wait instructions work: the host says "the actor is still walking", the script asks again next frame.

The ctx is a GrogVM.Ctx. Its host field holds whatever state your game wants to carry: the world, the screen, the sound state. Operations read and write it and return it. A host may also call back into GrogVM during an operation, starting and stopping scripts, including the one that is running.

3. Drive the frame loop

The game owns the main loop. SCUMM time is measured in jiffies (1/60 s). Each frame the game counts the elapsed jiffies off waiting scripts and runs every script that is due:

ctx = GrogVM.new(MyGame.Host, MyGame.World.new())
ctx = GrogVM.start_script(ctx, 1, [])

loop = fn ctx, loop ->
  jiffies = elapsed_since_last_frame()
  ctx = GrogVM.run_frame(ctx, jiffies)
  ctx = handle_player_input(ctx)
  loop.(ctx, loop)
end

Script 1 is conventionally the boot script. Between frames the game starts whatever scripts the player's actions call for: GrogVM.start_script/4 for a numbered script, GrogVM.start_object/5 for an object's verb, GrogVM.start_code/5 for code the game found itself, such as a room's entry script. GrogVM.abort_cutscene/1 skips the current cutscene when the player presses Escape.

A started script runs at once, inside whatever started it, until it first pauses or ends. Then its starter goes on.

The context is a plain value. A game saves by serialising it, may hold more than one, and can run frames in tests with nothing started.

Looking inside

What the machine does itself

So you know what not to implement in a host:

Every other instruction name in GrogVM.Decode.Table arrives at your host's op/2.

A script that loops without ever pausing is stopped with an error after a large number of jumps, so a bad script cannot hang the game.

Configuration

GrogVM.new/3 takes options passed on to GrogVM.Machine.new/1:

Reading more

License

MIT.