Packages

SCUMM v5 scripts compiled to Elixir and run against a host the game supplies.

Current section

Files

Jump to
grog_vm README.md
Raw

README.md

# 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](https://github.com/jaman/monkey_xland), 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:

```elixir
{: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

```elixir
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):

```elixir
{: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:

```elixir
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:

```elixir
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

* `GrogVM.var/2` and `GrogVM.put_var/3` read and write global variables. Games keep their
  configuration in them (machine speed, sound card, the key that skips a cutscene), set
  before the boot script runs.
* `GrogVM.scripts/1` lists every script running or waiting, as `GrogVM.Slot` structs:
  number, kind, where it is in its code, whether it is paused or frozen, its 25 locals.
* `GrogVM.running?/2`, `GrogVM.current/1` and `GrogVM.sentences_frozen?/1` answer the
  questions a game's input handling asks.
* `GrogVM.Decode.program/2` decodes a script into `GrogVM.Instruction` structs by offset
  without compiling it, for reading or disassembling one.

## What the machine does itself

So you know what *not* to implement in a host:

* **Variables.** Global word variables, bit variables, each script's locals, and indexed
  references (`GrogVM.Var`).
* **Arithmetic and tests.** `move`, `add`, `subtract`, `multiply`, `divide`, `and`, `or`,
  `increment`, `decrement`, `set_var_range`, the comparisons, `expression` stacks and
  `get_random_nr`.
* **Control.** `jump_relative`, `break_here`, `delay`, `delay_variable`, `stop_object_code`.
* **Scripts.** Up to 40 slots. `start_script`, `chain_script`, `start_object`,
  `stop_script`, `stop_object_script`, `is_script_running`, recursive and
  freeze-resistant starts, `freeze_scripts`.
* **Cutscenes.** `cutscene`, `end_cutscene`, `begin_override`, the cutscene stack, the
  cutscene start and end scripts (variables 35 and 36 by default), and skipping.

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`:

* `config:` a map of the variable numbers the machine itself uses: `override`,
  `cutscene_start_script`, `cutscene_end_script`. Defaults are SCUMM v5's 5, 35 and 36.
* `seed:` the random number seed.

## Reading more

* [MonkeyXland](https://github.com/jaman/monkey_xland) is a complete host: one module per subject (actors,
  objects, rooms, interface, audio), each handling the operations named for it.
* The [ScummVM](https://www.scummvm.org/) source and the
  [SCUMM technical reference](https://wiki.scummvm.org/index.php/SCUMM/Technical_Reference)
  document the opcodes and the engine behaviour GrogVM reproduces.

## License

MIT.