zmachine

package module
v0.2.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jul 30, 2026 License: MIT Imports: 13 Imported by: 0

README

zmachine

Go Reference

A small, embeddable Go package that implements a headless Z-machine Version 3 execution engine.

It exists for one job: letting a Go server advance a browser-based interactive fiction session one command at a time. It is not an interpreter you run — there is no terminal, no prompt, no main loop. It is a library a request handler calls.

The whole package is one invariant:

Given a validated immutable V3 story, an optional saved state, and at most one line of input, execute until the next input boundary, clean termination, cancellation, limit, or fault; then return captured output and resumable state without process-level side effects.

Install

go get github.com/maloquacious/zmachine
Requirements

Go 1.26 or later.

There are no other dependencies. Nothing outside the standard library and Quetzal is imported by the engine.

Stability

This package is at v0.x. Under semantic versioning that means the exported API may change in a minor release, and this project's own rule is to bump the minor for new or changed exported behaviour and the patch for everything else. Pin a version and read CHANGELOG.md before upgrading.

What v1.0.0 will mean, when it arrives, is that the exported API stops moving without a major version. It does not exist yet.

Stored saved state is a separate promise and a stronger one: a Result.State stays restorable for as long as the story file does not change, whatever version of this package wrote it. See Saved-state compatibility.

Documentation

Where What
docs/tutorial.md A first session, end to end: load Zork I, play three turns, and rebuild the machine between them. Start here.
docs/how-to/ Guides for a host's real goals: storing session state, handling a cancelled request, serving many players at once.
pkg.go.dev Generated API documentation. Authoritative for signatures.
docs/reference.md The host-facing contract in one place: lifecycle calls, every option, every Result field, the error taxonomy, concurrency and limits.
CHANGELOG.md What changed in each release.
SECURITY.md How to report a vulnerability privately, and what counts as one.
specification.md Product and architecture specification.
docs/prng-history.md How the Frotz generator's golden digest was derived.

This README is the front door: what the package is, why it is shaped this way, and enough to get a first turn running. A reader who would rather run something than read about it should go to the tutorial instead and come back.

The request-oriented model

A conventional interpreter owns the session: it loads a story, loops, blocks on the keyboard, and writes to a screen. None of that survives contact with a web server, where a turn is a request, requests are not ordered, and nothing may block a worker.

So this package inverts it. There are two types:

  • Story is a validated, immutable story file. Loading and validating one is the expensive step, and it is done once per process. A Story is safe to share across goroutines and across every player of that game.
  • Machine is one execution instance. It owns all the mutable state — its own copy of dynamic memory, the evaluation stack, the call chain, the program counter, the random generator. Creating one is cheap, it is not safe for concurrent use, and it is not meant to be kept.

A turn is: create a Machine, restore the state from last time, run one command, take the output and the new state, throw the Machine away.

load Story once  ─────────────────────────────────────────────┐
                                                              │
  request ──▶ New(story) ──▶ Restore(saved) ──▶ Run(ctx, cmd) ─┤──▶ Result
                                                              │      ├─ Output
                                     Machine discarded ◀──────┘      ├─ StatusLine
                                                                     └─ State ──▶ storage

Doing this on every single turn is observably identical to keeping one Machine alive for the whole game. That equivalence is the central promise of the package, and it has a test that plays Zork I both ways and compares every turn.

The state is a Quetzal saved game, produced by github.com/maloquacious/quetzal. It is opaque bytes to the host: store them in a column, a blob, a cache — whatever the application already does with a session.

Each state is complete in itself rather than a link in a chain, so keeping only the most recent is enough, and a few hundred bytes is typical. A state stays restorable as long as the story file does not change — the engine version is not part of the contract, so stored state needs no migration when this package is upgraded, and no engine version recorded beside it. What is part of the contract is the story file itself, which makes a hash of it the right key for a session. See Saved-state compatibility.

Usage

A web handler, which is the shape this package was designed around:

func play(
	ctx context.Context,
	story *zmachine.Story,
	saved []byte,
	command string,
) (zmachine.Result, error) {
	machine, err := zmachine.New(story)
	if err != nil {
		return zmachine.Result{}, err
	}

	if len(saved) != 0 {
		if err := machine.Restore(saved); err != nil {
			return zmachine.Result{}, err
		}
	}

	return machine.Run(ctx, command)
}

Starting a new game is the one case that differs, because a story usually prints its banner and opening room before it asks for anything. There is no saved state and no command yet, so Start is used instead of Run:

story, err := zmachine.LoadStory(storyFileBytes)
if err != nil {
	return err
}

machine, err := zmachine.New(story)
if err != nil {
	return err
}

result, err := machine.Start(ctx)
if err != nil {
	return err
}

fmt.Print(result.Output) // the banner and the first room
save(result.State)       // resume here when the player types

Result carries the text the story printed, the upper window, the Version 3 status line, the resumable state and the reason execution stopped. Every field is described in docs/reference.md.

Options are passed to New:

machine, err := zmachine.New(story,
	zmachine.WithLogger(logger),              // diagnostics; never story output
	zmachine.WithRandomSeed(42),              // reproducible execution
	zmachine.WithInstructionLimit(1_000_000), // bound one call
	zmachine.WithTracer(tracer),              // one event per instruction
)

A Machine with no options discards its diagnostics — it never falls back to slog.Default — seeds itself unpredictably, and bounds each call at ten million instructions. See Options for each one's default and what makes New reject it.

There is one further option, WithFrotzRandomSeed, which makes the machine draw random numbers exactly as Frotz does. It exists so that this engine can be compared against dfrotz turn for turn on stories that use randomness, and it is not meant for running a real session: the Z-machine standard fixes only that a seeded generator be reproducible, not which numbers it yields, so two correct interpreters disagree from the first draw. Ordinary use wants WithRandomSeed.

Safety in a server

Story files and saved states are both treated as hostile binary input, because in a server they are: one is uploaded, the other comes back from storage or from a request body.

  • Nothing panics on bad input. Every address, length, table offset, packed address and allocation size derived from a story or a save is checked before use. Malformed input is an error with context, not a crash. Panics are reserved for genuine internal invariant violations.
  • Errors are classifiable. Every error arising from a story, a save or execution wraps one of seven sentinels, so a host can tell a bad request from a bug, and typed errors carry the program counter, opcode and address. See Errors — including the two cases that deliberately do not wrap a sentinel, one of which is context cancellation.
  • Execution is bounded. Every call to Start or Run has an instruction limit, and honours context.Context cancellation. A story that loops forever cannot hold a worker.
  • Allocation is bounded. A saved state cannot ask a Machine for a deeper call chain or a larger stack than that Machine would ever have built itself.
  • Players are isolated. Machines built from the same Story share only immutable memory. Nothing mutable lives in a package global.
  • No process-level side effects. No os.Exit, no signal handlers, no filesystem, no environment variables, no working-directory dependence. The engine knows nothing about HTTP, JSON, WebSockets, databases, sessions or terminals.

Should any of that turn out to be false, SECURITY.md says how to report it privately, which versions get fixes, and where the line falls between a story misbehaving inside the VM and a story escaping it.

Scope

Version 3 only. LoadStory rejects every other version. This is deliberate: V3 is a complete, self-consistent target, and generalising for later versions would cost clarity in the parts that matter here.

Some things belong to the host by design, not by omission: in-story SAVE and RESTORE, word wrapping, output streams 2 and 4, and everything about transport, users, storage, transactions, retries and idempotency. What each of those does instead is in Not implemented.

Testing

go test ./...
go test -race ./...
go vet ./...
go test -run '^$' -fuzz FuzzRestore -fuzztime 30s .

Unit tests cover each layer against small, hand-built machine states rather than whole stories, so a rule is proved by the smallest thing that can express it. The Example functions in example_test.go are the samples a host copies — load and start, run a turn, restore between turns, classify an error — and they are compiled and run like any other test, so a changed signature breaks a build rather than a reader. Integration tests play Zork I across dozens of create/restore/run/destroy cycles and assert that it matches continuous execution turn for turn. Fuzz targets cover every parser exposed to arbitrary bytes: the story header, the instruction decoder, the Z-string decoder, the object tables and the state adapter.

Differential tests against Frotz

A separate set of tests compares this engine against Frotz, which is a different and harder claim than agreeing with our own reading of the standard: a misreading held consistently throughout this package would satisfy every other test and fail these. They run in three layers — the transcript, the status line, and the state of play, where the whole object tree and every attribute are compared against a save Frotz wrote.

They run as part of an ordinary go test ./..., from fixtures committed under testdata/frotz. dfrotz is a tool used to make those fixtures, never a dependency of the engine or of its tests.

Two questions a committed file cannot answer do need dfrotz installed, and are skipped without it: whether the fixtures still match the Frotz people have, and whether Frotz can take up a save this engine wrote. A skip reads the same as a pass, so a run that means to check either should set ZMACHINE_REQUIRE_DFROTZ, which turns a missing dfrotz into a failure.

brew install frotz        # or your platform's package
ZMACHINE_REQUIRE_DFROTZ=1 go test ./...

Worth doing before committing. See testdata/frotz/README.md for how the fixtures are made and regenerated, and docs/prng-history.md for how the random number generator is pinned to Frotz's.

Story fixtures and licences

testdata/stories/ holds Zork I, II and III as test inputs. They are test fixtures, not dependencies — nothing in the package needs them, and the tests skip themselves when the files are absent.

Each is accompanied by its licence (LICENSE.zork1.txt and so on); they are distributed by Microsoft under the MIT License. Respect those licences: they cover the story files, not this package.

Zork I is the first compatibility target, not the definition of the VM. No Zork-specific behaviour is hard-coded anywhere in the engine.

Licence

MIT. See LICENSE.

Documentation

Overview

Package zmachine implements a headless Z-machine Version 3 execution engine.

It is built for a host that advances a story one command at a time: given a validated story, an optional saved state and at most one line of input, it executes until the next input boundary, clean termination, cancellation, a limit or a fault, then returns the captured output and a resumable state. It never blocks for input, never prints, and touches nothing outside the process - no filesystem, no terminal, no environment, no process-global state.

Types

A Story holds validated, immutable story data and is safe for concurrent use. Each Machine built from a Story owns its own mutable execution state, so many independent sessions may share one loaded story. A Machine is not safe for concurrent use, and is not meant to be kept: creating one is cheap.

The request lifecycle

Loading a story is the expensive step and is done once. Everything after that is per request:

machine, err := zmachine.New(story)
if err != nil {
	return err
}
if len(saved) != 0 {
	if err := machine.Restore(saved); err != nil {
		return err
	}
}
result, err := machine.Run(ctx, command)
if err != nil {
	return err
}
store(result.State)
send(result.Output)

A story that has not begun is started with Start rather than Run, since a new game usually prints its banner before asking for anything. Either call returns a Result whose State resumes execution exactly where it stopped, so the Machine may be dropped as soon as the Result is in hand. Doing that on every turn is observably the same as keeping one Machine alive throughout.

Untrusted input

Stories and saved states are both treated as hostile binary input. Every address, length and count derived from either is checked before it is used to index, allocate or slice, and malformed input is reported as an error - never a panic. Errors wrap a sentinel (ErrInvalidStory, ErrInvalidState, ErrExecutionFault and the rest) so a host can classify a failure with errors.Is, and carry the program counter, opcode and address in a typed error so it can be diagnosed.

Version 3 only

The Z-machine semantics implemented here follow the Z-Machine Standards Document 1.1; section references in comments refer to that document. Only Version 3 is implemented, and LoadStory rejects every other version.

Example

Example loads a story, creates a machine and runs it up to the first input boundary. This is how a session begins: Start supplies no input, because a story prints its banner and opening room before it asks for anything.

package main

import (
	"context"
	"encoding/binary"
	"fmt"
	"strings"

	"github.com/maloquacious/zmachine"
)

func main() {
	story, err := zmachine.LoadStory(exampleStory())
	if err != nil {
		fmt.Println("load:", err)
		return
	}

	// The seed only makes a run reproducible. A host serving real players
	// leaves it out and lets New seed itself unpredictably.
	machine, err := zmachine.New(story, zmachine.WithRandomSeed(1))
	if err != nil {
		fmt.Println("new:", err)
		return
	}

	result, err := machine.Start(context.Background())
	if err != nil {
		fmt.Println("start:", err)
		return
	}

	fmt.Print(result.Output)
	fmt.Println("waiting for input:", result.Status == zmachine.WaitingForInput)
	fmt.Println("resumable:", len(result.State) > 0)

}

// The story the examples run.
//
// It is the smallest thing that can demonstrate a session: it prints a room,
// asks for a line, prints a reply, asks for a second line, prints a second
// reply and quits. That is enough to show an input boundary, a resumable
// state and a clean termination, which is all the examples assert.
//
// Its layout, which is the ordinary Version 3 memory map of S 1.1:
//
//	0x0000 header (S 11.1)
//	0x0040 global variables table, 240 words (S 6.2)
//	0x0220 object table: property defaults only, no objects (S 12.1)
//	0x0260 text buffer, 60 bytes (S 15, read)
//	0x02a0 parse buffer, room for 8 words (S 15, read)
//	0x0300 abbreviations table, 96 words, unused (S 3.3)
//	0x03c0 base of static memory
//	0x03c8 dictionary (S 13.1)
//	0x0400 base of high memory; initial program counter
//	0x0800 end of file
const (
	exampleGlobals       = 0x0040
	exampleObjectTable   = 0x0220
	exampleTextBuffer    = 0x0260
	exampleParseBuffer   = 0x02a0
	exampleAbbreviations = 0x0300
	exampleStaticBase    = 0x03c0
	exampleDictionary    = 0x03c8
	exampleInitialPC     = 0x0400
	exampleStorySize     = 0x0800
)

// exampleStory assembles the story image. It returns a fresh copy each time,
// so an example may corrupt it to produce an error without disturbing another.
func exampleStory() []byte {
	image := make([]byte, exampleStorySize)

	putByte := func(addr int, v uint8) { image[addr] = v }
	putWord := func(addr int, v uint16) { binary.BigEndian.PutUint16(image[addr:], v) }

	putByte(0x00, 3)
	putWord(0x02, 1)
	putWord(0x04, exampleInitialPC)
	putWord(0x06, exampleInitialPC)
	putWord(0x08, exampleDictionary)
	putWord(0x0a, exampleObjectTable)
	putWord(0x0c, exampleGlobals)
	putWord(0x0e, exampleStaticBase)
	copy(image[0x12:0x18], "000000")
	putWord(0x18, exampleAbbreviations)
	putWord(0x1a, exampleStorySize/2)

	putByte(exampleTextBuffer, 60)
	putByte(exampleParseBuffer, 8)

	putByte(exampleDictionary, 3)
	copy(image[exampleDictionary+1:], ".,\"")
	putByte(exampleDictionary+4, 7)
	putWord(exampleDictionary+5, 2)

	code := concat(
		printString("West of House"),
		newLine(),
		sread(),
		printString("Opening the small mailbox reveals a leaflet."),
		newLine(),
		sread(),
		printString("Taken."),
		newLine(),
		quit(),
	)
	copy(image[exampleInitialPC:], code)

	var sum uint16
	for _, b := range image[0x40:] {
		sum += uint16(b)
	}
	putWord(0x1c, sum)

	return image
}

// printString is the 0OP instruction print with its text inline (S 15, print).
func printString(s string) []byte {
	return append([]byte{0xb2}, encodeZString(s)...)
}

// newLine is the 0OP instruction new_line (S 15, new_line).
func newLine() []byte { return []byte{0xbb} }

// quit is the 0OP instruction quit (S 15, quit).
func quit() []byte { return []byte{0xba} }

// sread is the VAR instruction read, taking the text and parse buffers as
// large constants (S 15, read). It is the only instruction that suspends.
//
// The type byte gives four two-bit operand types, most significant first:
// two large constants ($$00) and two omitted ($$11).
func sread() []byte {
	return []byte{
		0xe4, 0x0f,
		exampleTextBuffer >> 8, exampleTextBuffer & 0xff,
		exampleParseBuffer >> 8, exampleParseBuffer & 0xff,
	}
}

// encodeZString encodes text as a Version 3 Z-string (S 3.2): three
// five-bit Z-characters to a word, the top bit of the last word set.
//
// It handles the subset the example story needs. A character not in it is a
// mistake in this file rather than anything the engine could be given, so it
// panics rather than encoding something else.
func encodeZString(s string) []byte {

	const alphabetA2 = "\x00\r0123456789.,!?_#'\"/\\-:()"

	var chars []uint8
	for _, r := range s {
		switch {
		case r == ' ':

			chars = append(chars, 0)
		case r >= 'a' && r <= 'z':
			chars = append(chars, uint8(r-'a')+6)
		case r >= 'A' && r <= 'Z':

			chars = append(chars, 4, uint8(r-'A')+6)
		default:
			i := strings.IndexRune(alphabetA2, r)
			if i < 1 {
				panic(fmt.Sprintf("example story: %q cannot be encoded", r))
			}
			chars = append(chars, 5, uint8(i)+6)
		}
	}

	for len(chars) == 0 || len(chars)%3 != 0 {
		chars = append(chars, 5)
	}

	out := make([]byte, 0, len(chars)/3*2)
	for i := 0; i < len(chars); i += 3 {
		word := uint16(chars[i])<<10 | uint16(chars[i+1])<<5 | uint16(chars[i+2])
		if i+3 == len(chars) {
			word |= 0x8000
		}
		out = append(out, uint8(word>>8), uint8(word))
	}
	return out
}

// concat joins encoded instructions.
func concat(parts ...[]byte) []byte {
	var out []byte
	for _, part := range parts {
		out = append(out, part...)
	}
	return out
}
Output:
West of House
waiting for input: true
resumable: true
Example (ErrorClassification)

Example_errorClassification shows a host sorting engine errors into the answers it owes a caller. Every error arising from a story, a saved state or execution wraps one of the package's sentinels, so the classification never depends on message text.

The case worth noticing is cancellation, which wraps no engine sentinel at all: it is reported as the context's own error so that errors.Is finds context.Canceled, as a caller expects. Test for it first, or a later clause will not reach it.

package main

import (
	"context"
	"encoding/binary"
	"errors"
	"fmt"
	"strings"

	"github.com/maloquacious/zmachine"
)

func main() {
	describe := func(err error) string {
		switch {
		case err == nil:
			return "ok"
		case errors.Is(err, context.Canceled), errors.Is(err, context.DeadlineExceeded):
			return "cancelled: the caller went away; charge nobody for the turn"
		case errors.Is(err, zmachine.ErrInvalidStory):
			return "bad story: reject the upload"
		case errors.Is(err, zmachine.ErrInvalidState):
			return "bad state: the session cannot be resumed"
		case errors.Is(err, zmachine.ErrExecutionLimit):
			return "too long: the story outran its instruction limit"
		case errors.Is(err, zmachine.ErrExecutionFault),
			errors.Is(err, zmachine.ErrInvalidOpcode),
			errors.Is(err, zmachine.ErrMemoryAccess),
			errors.Is(err, zmachine.ErrInvalidText):
			return "the story faulted: end the session"
		default:
			// A mistake at the call site - a nil context, a bad option - lands
			// here, and so would a bug in the engine.
			return "unclassified"
		}
	}

	// A story that is not a Version 3 story.
	notV3 := exampleStory()
	notV3[0] = 5
	_, err := zmachine.LoadStory(notV3)
	fmt.Println(describe(err))

	// The typed errors carry the detail a log needs. StoryError names the
	// header field at fault and the value that was wrong.
	var storyErr *zmachine.StoryError
	if errors.As(err, &storyErr) {
		fmt.Printf("  field %q, value %d\n", storyErr.Field, storyErr.Value)
	}

	story, err := zmachine.LoadStory(exampleStory())
	if err != nil {
		fmt.Println("load:", err)
		return
	}
	machine, err := zmachine.New(story, zmachine.WithRandomSeed(1))
	if err != nil {
		fmt.Println("new:", err)
		return
	}

	// Saved state that is not a saved game. Restore leaves the machine exactly
	// as it was, so it is still usable below.
	fmt.Println(describe(machine.Restore([]byte("not a saved game"))))

	// A cancelled request. Execution checks the context often enough that the
	// call returns promptly however long the turn would have taken.
	ctx, cancel := context.WithCancel(context.Background())
	cancel()
	_, err = machine.Start(ctx)
	fmt.Println(describe(err))
	fmt.Println("wraps an engine sentinel:", errors.Is(err, zmachine.ErrExecutionFault))

}

// The story the examples run.
//
// It is the smallest thing that can demonstrate a session: it prints a room,
// asks for a line, prints a reply, asks for a second line, prints a second
// reply and quits. That is enough to show an input boundary, a resumable
// state and a clean termination, which is all the examples assert.
//
// Its layout, which is the ordinary Version 3 memory map of S 1.1:
//
//	0x0000 header (S 11.1)
//	0x0040 global variables table, 240 words (S 6.2)
//	0x0220 object table: property defaults only, no objects (S 12.1)
//	0x0260 text buffer, 60 bytes (S 15, read)
//	0x02a0 parse buffer, room for 8 words (S 15, read)
//	0x0300 abbreviations table, 96 words, unused (S 3.3)
//	0x03c0 base of static memory
//	0x03c8 dictionary (S 13.1)
//	0x0400 base of high memory; initial program counter
//	0x0800 end of file
const (
	exampleGlobals       = 0x0040
	exampleObjectTable   = 0x0220
	exampleTextBuffer    = 0x0260
	exampleParseBuffer   = 0x02a0
	exampleAbbreviations = 0x0300
	exampleStaticBase    = 0x03c0
	exampleDictionary    = 0x03c8
	exampleInitialPC     = 0x0400
	exampleStorySize     = 0x0800
)

// exampleStory assembles the story image. It returns a fresh copy each time,
// so an example may corrupt it to produce an error without disturbing another.
func exampleStory() []byte {
	image := make([]byte, exampleStorySize)

	putByte := func(addr int, v uint8) { image[addr] = v }
	putWord := func(addr int, v uint16) { binary.BigEndian.PutUint16(image[addr:], v) }

	putByte(0x00, 3)
	putWord(0x02, 1)
	putWord(0x04, exampleInitialPC)
	putWord(0x06, exampleInitialPC)
	putWord(0x08, exampleDictionary)
	putWord(0x0a, exampleObjectTable)
	putWord(0x0c, exampleGlobals)
	putWord(0x0e, exampleStaticBase)
	copy(image[0x12:0x18], "000000")
	putWord(0x18, exampleAbbreviations)
	putWord(0x1a, exampleStorySize/2)

	putByte(exampleTextBuffer, 60)
	putByte(exampleParseBuffer, 8)

	putByte(exampleDictionary, 3)
	copy(image[exampleDictionary+1:], ".,\"")
	putByte(exampleDictionary+4, 7)
	putWord(exampleDictionary+5, 2)

	code := concat(
		printString("West of House"),
		newLine(),
		sread(),
		printString("Opening the small mailbox reveals a leaflet."),
		newLine(),
		sread(),
		printString("Taken."),
		newLine(),
		quit(),
	)
	copy(image[exampleInitialPC:], code)

	var sum uint16
	for _, b := range image[0x40:] {
		sum += uint16(b)
	}
	putWord(0x1c, sum)

	return image
}

// printString is the 0OP instruction print with its text inline (S 15, print).
func printString(s string) []byte {
	return append([]byte{0xb2}, encodeZString(s)...)
}

// newLine is the 0OP instruction new_line (S 15, new_line).
func newLine() []byte { return []byte{0xbb} }

// quit is the 0OP instruction quit (S 15, quit).
func quit() []byte { return []byte{0xba} }

// sread is the VAR instruction read, taking the text and parse buffers as
// large constants (S 15, read). It is the only instruction that suspends.
//
// The type byte gives four two-bit operand types, most significant first:
// two large constants ($$00) and two omitted ($$11).
func sread() []byte {
	return []byte{
		0xe4, 0x0f,
		exampleTextBuffer >> 8, exampleTextBuffer & 0xff,
		exampleParseBuffer >> 8, exampleParseBuffer & 0xff,
	}
}

// encodeZString encodes text as a Version 3 Z-string (S 3.2): three
// five-bit Z-characters to a word, the top bit of the last word set.
//
// It handles the subset the example story needs. A character not in it is a
// mistake in this file rather than anything the engine could be given, so it
// panics rather than encoding something else.
func encodeZString(s string) []byte {

	const alphabetA2 = "\x00\r0123456789.,!?_#'\"/\\-:()"

	var chars []uint8
	for _, r := range s {
		switch {
		case r == ' ':

			chars = append(chars, 0)
		case r >= 'a' && r <= 'z':
			chars = append(chars, uint8(r-'a')+6)
		case r >= 'A' && r <= 'Z':

			chars = append(chars, 4, uint8(r-'A')+6)
		default:
			i := strings.IndexRune(alphabetA2, r)
			if i < 1 {
				panic(fmt.Sprintf("example story: %q cannot be encoded", r))
			}
			chars = append(chars, 5, uint8(i)+6)
		}
	}

	for len(chars) == 0 || len(chars)%3 != 0 {
		chars = append(chars, 5)
	}

	out := make([]byte, 0, len(chars)/3*2)
	for i := 0; i < len(chars); i += 3 {
		word := uint16(chars[i])<<10 | uint16(chars[i+1])<<5 | uint16(chars[i+2])
		if i+3 == len(chars) {
			word |= 0x8000
		}
		out = append(out, uint8(word>>8), uint8(word))
	}
	return out
}

// concat joins encoded instructions.
func concat(parts ...[]byte) []byte {
	var out []byte
	for _, part := range parts {
		out = append(out, part...)
	}
	return out
}
Output:
bad story: reject the upload
  field "version number", value 5
bad state: the session cannot be resumed
cancelled: the caller went away; charge nobody for the turn
wraps an engine sentinel: false

Index

Examples

Constants

This section is empty.

Variables

View Source
var (
	// ErrInvalidStory reports a story file that is not a usable Version 3 story.
	ErrInvalidStory = errors.New("invalid Z3 story")

	// ErrInvalidState reports a saved state that cannot be restored.
	ErrInvalidState = errors.New("invalid saved state")

	// ErrInvalidOpcode reports an instruction that is not defined in Version 3.
	ErrInvalidOpcode = errors.New("invalid opcode")

	// ErrMemoryAccess reports a read or write that the Version 3 memory model
	// does not permit.
	ErrMemoryAccess = errors.New("invalid memory access")

	// ErrExecutionLimit reports that execution stopped because a host-imposed
	// limit was reached.
	ErrExecutionLimit = errors.New("execution limit exceeded")

	// ErrInvalidText reports encoded text that does not obey the Version 3
	// rules for Z-strings. It is distinct from ErrInvalidStory because strings
	// may be built in dynamic memory while the story runs, so a malformed
	// string is not necessarily a defect in the story file.
	ErrInvalidText = errors.New("invalid Z-string")

	// ErrExecutionFault reports a story that ran into a condition the
	// Z-machine does not define a result for: dividing by zero (S 2.3.1),
	// underflowing the evaluation stack (S 6.3.1), returning from the initial
	// execution environment (S 5.5), and so on. The story is at fault, not the
	// engine, so these are ordinary errors and never panics.
	ErrExecutionFault = errors.New("execution fault")
)

Sentinel errors classifying the major failure modes of the engine.

Every error arising from a story, a saved state or execution wraps exactly one of these, so callers can classify failures with errors.Is without depending on message text.

Two kinds of error deliberately do not. A cancelled context is reported as the context's own error, so that errors.Is finds context.Canceled or context.DeadlineExceeded as a caller expects. A nil context, or an option given a nil logger, a nil tracer or a zero instruction limit, reports a mistake at the call site rather than anything derived from untrusted input, and is a plain error naming the call.

Functions

func Version

func Version() string

Version returns the semantic version of this package.

It is here for a host that reports which engine it is running to its operators - the web server this package is embedded in prints it beside its own build - and that is the whole of what it is for.

It reports nothing about conformance, deliberately. This engine implements Version 3 of the Z-machine and is written against Standard 1.1 in references/z-spec11, but the interoperability testing that would justify claiming that standard is not far enough along, and the engine correspondingly leaves the header's standard revision number ($32-$33, S 11.1) unset rather than filling it in. Which build a host is running and which standard an engine meets are two different statements, and only the first is made here.

Types

type DecodeError

type DecodeError struct {
	// Addr is the byte address of the instruction, which is the program counter
	// it was decoded from.
	Addr uint32
	// Opcode is the first byte of the instruction. It is zero when the failure
	// was reading that byte itself.
	Opcode uint8
	// Detail explains what is wrong with the instruction.
	Detail string
	// Err is the error this one is classified as.
	Err error
}

DecodeError describes an instruction that could not be decoded. It wraps the sentinel that classifies the failure: ErrInvalidOpcode for an instruction Version 3 does not define, and the refused memory access - and so ErrMemoryAccess - for one that runs off the end of the story.

func (*DecodeError) Error

func (e *DecodeError) Error() string

Error implements error.

func (*DecodeError) Unwrap

func (e *DecodeError) Unwrap() error

Unwrap returns the error classifying this one.

type ExecutionError

type ExecutionError struct {
	// PC is the byte address of the instruction, which is the program counter
	// it was decoded from.
	PC uint32
	// Op identifies the instruction in the form used by S 14, for example
	// "2OP:20 add".
	//
	// It is a string rather than an instruction identity because that is all a
	// host can use it for: the field is diagnostic, and it holds the same form
	// as TraceInstruction.Opcode so that a fault and a trace name an
	// instruction the same way.
	Op string
	// Detail explains what went wrong.
	Detail string
	// Err is the error this one is classified as.
	Err error
}

ExecutionError describes an instruction that could not be carried out. It wraps the error classifying the failure, which is ErrExecutionFault for a condition the Z-machine leaves undefined, and the underlying error - and so ErrMemoryAccess or ErrInvalidText - for a refused memory access or a malformed string reached while the instruction ran.

func (*ExecutionError) Error

func (e *ExecutionError) Error() string

Error implements error.

func (*ExecutionError) Unwrap

func (e *ExecutionError) Unwrap() error

Unwrap returns the error classifying this one.

type Machine

type Machine struct {
	// contains filtered or unexported fields
}

Machine is one execution instance of the Z-machine.

A Machine owns every piece of mutable state the Version 3 "state of play" consists of (S 6.1): its own copy of dynamic memory, the evaluation stack, the chain of routine call frames, and the program counter. Machines built from the same Story are therefore completely isolated from one another.

A Machine is not safe for concurrent use. The Story it was built from is.

func New

func New(story *Story, opts ...Option) (*Machine, error)

New creates a Machine that will execute story from its initial program counter (S 5.5).

Creating a Machine is cheap: only dynamic memory is copied, while static and high memory are shared with the Story. Any number of Machines may be built from one Story and used independently.

func (*Machine) Halted

func (m *Machine) Halted() bool

Halted reports whether the story has terminated. A halted machine cannot be run again.

func (*Machine) Restore

func (m *Machine) Restore(data []byte) error

Restore replaces the machine's state with one previously returned in Result.State (spec S 9).

The machine must have been created from the same story the state was saved from: a state belonging to another story is refused with an error wrapping ErrInvalidState rather than being decoded against the wrong memory. Saved state is untrusted input (spec S 26), so every address, count and length in it is checked before anything is allocated or written; malformed state returns an error and never panics.

A successful restore leaves the machine at an input boundary, so that the next call is Run, which supplies a line. On failure the machine is left exactly as it was, so a host may report the error and retry with different state.

Saves written by this engine and saves written by another interpreter suspend in different places, and Restore accepts both: a save this engine did not write has its program counter moved to the input boundary this engine resumes from.

Example

ExampleMachine_Restore shows the turn a request handler performs: create a Machine, restore the state from last time, run one command, keep the output and the new state, and throw the Machine away.

Doing this on every turn is observably identical to keeping one Machine alive for the whole game, which is the central promise of the package. Note that the Story is loaded once and shared; only the Machine is per-turn.

package main

import (
	"context"
	"encoding/binary"
	"fmt"
	"strings"

	"github.com/maloquacious/zmachine"
)

func main() {
	story, err := zmachine.LoadStory(exampleStory())
	if err != nil {
		fmt.Println("load:", err)
		return
	}

	// playOneTurn is the whole of what a handler does. It holds no state of
	// its own: everything the next turn needs is in the bytes it returns.
	playOneTurn := func(saved []byte, command string) (zmachine.Result, error) {
		machine, err := zmachine.New(story, zmachine.WithRandomSeed(1))
		if err != nil {
			return zmachine.Result{}, err
		}
		if err := machine.Restore(saved); err != nil {
			return zmachine.Result{}, err
		}
		return machine.Run(context.Background(), command)
	}

	// The opening turn is the one that differs: there is no saved state yet,
	// so it uses Start rather than Restore and Run.
	opening, err := func() (zmachine.Result, error) {
		machine, err := zmachine.New(story, zmachine.WithRandomSeed(1))
		if err != nil {
			return zmachine.Result{}, err
		}
		return machine.Start(context.Background())
	}()
	if err != nil {
		fmt.Println("start:", err)
		return
	}

	fmt.Print(opening.Output)

	saved := opening.State
	for _, command := range []string{"open mailbox", "take leaflet"} {
		result, err := playOneTurn(saved, command)
		if err != nil {
			fmt.Println(command, "-", err)
			return
		}
		fmt.Print(result.Output)
		saved = result.State
	}

}

// The story the examples run.
//
// It is the smallest thing that can demonstrate a session: it prints a room,
// asks for a line, prints a reply, asks for a second line, prints a second
// reply and quits. That is enough to show an input boundary, a resumable
// state and a clean termination, which is all the examples assert.
//
// Its layout, which is the ordinary Version 3 memory map of S 1.1:
//
//	0x0000 header (S 11.1)
//	0x0040 global variables table, 240 words (S 6.2)
//	0x0220 object table: property defaults only, no objects (S 12.1)
//	0x0260 text buffer, 60 bytes (S 15, read)
//	0x02a0 parse buffer, room for 8 words (S 15, read)
//	0x0300 abbreviations table, 96 words, unused (S 3.3)
//	0x03c0 base of static memory
//	0x03c8 dictionary (S 13.1)
//	0x0400 base of high memory; initial program counter
//	0x0800 end of file
const (
	exampleGlobals       = 0x0040
	exampleObjectTable   = 0x0220
	exampleTextBuffer    = 0x0260
	exampleParseBuffer   = 0x02a0
	exampleAbbreviations = 0x0300
	exampleStaticBase    = 0x03c0
	exampleDictionary    = 0x03c8
	exampleInitialPC     = 0x0400
	exampleStorySize     = 0x0800
)

// exampleStory assembles the story image. It returns a fresh copy each time,
// so an example may corrupt it to produce an error without disturbing another.
func exampleStory() []byte {
	image := make([]byte, exampleStorySize)

	putByte := func(addr int, v uint8) { image[addr] = v }
	putWord := func(addr int, v uint16) { binary.BigEndian.PutUint16(image[addr:], v) }

	putByte(0x00, 3)
	putWord(0x02, 1)
	putWord(0x04, exampleInitialPC)
	putWord(0x06, exampleInitialPC)
	putWord(0x08, exampleDictionary)
	putWord(0x0a, exampleObjectTable)
	putWord(0x0c, exampleGlobals)
	putWord(0x0e, exampleStaticBase)
	copy(image[0x12:0x18], "000000")
	putWord(0x18, exampleAbbreviations)
	putWord(0x1a, exampleStorySize/2)

	putByte(exampleTextBuffer, 60)
	putByte(exampleParseBuffer, 8)

	putByte(exampleDictionary, 3)
	copy(image[exampleDictionary+1:], ".,\"")
	putByte(exampleDictionary+4, 7)
	putWord(exampleDictionary+5, 2)

	code := concat(
		printString("West of House"),
		newLine(),
		sread(),
		printString("Opening the small mailbox reveals a leaflet."),
		newLine(),
		sread(),
		printString("Taken."),
		newLine(),
		quit(),
	)
	copy(image[exampleInitialPC:], code)

	var sum uint16
	for _, b := range image[0x40:] {
		sum += uint16(b)
	}
	putWord(0x1c, sum)

	return image
}

// printString is the 0OP instruction print with its text inline (S 15, print).
func printString(s string) []byte {
	return append([]byte{0xb2}, encodeZString(s)...)
}

// newLine is the 0OP instruction new_line (S 15, new_line).
func newLine() []byte { return []byte{0xbb} }

// quit is the 0OP instruction quit (S 15, quit).
func quit() []byte { return []byte{0xba} }

// sread is the VAR instruction read, taking the text and parse buffers as
// large constants (S 15, read). It is the only instruction that suspends.
//
// The type byte gives four two-bit operand types, most significant first:
// two large constants ($$00) and two omitted ($$11).
func sread() []byte {
	return []byte{
		0xe4, 0x0f,
		exampleTextBuffer >> 8, exampleTextBuffer & 0xff,
		exampleParseBuffer >> 8, exampleParseBuffer & 0xff,
	}
}

// encodeZString encodes text as a Version 3 Z-string (S 3.2): three
// five-bit Z-characters to a word, the top bit of the last word set.
//
// It handles the subset the example story needs. A character not in it is a
// mistake in this file rather than anything the engine could be given, so it
// panics rather than encoding something else.
func encodeZString(s string) []byte {

	const alphabetA2 = "\x00\r0123456789.,!?_#'\"/\\-:()"

	var chars []uint8
	for _, r := range s {
		switch {
		case r == ' ':

			chars = append(chars, 0)
		case r >= 'a' && r <= 'z':
			chars = append(chars, uint8(r-'a')+6)
		case r >= 'A' && r <= 'Z':

			chars = append(chars, 4, uint8(r-'A')+6)
		default:
			i := strings.IndexRune(alphabetA2, r)
			if i < 1 {
				panic(fmt.Sprintf("example story: %q cannot be encoded", r))
			}
			chars = append(chars, 5, uint8(i)+6)
		}
	}

	for len(chars) == 0 || len(chars)%3 != 0 {
		chars = append(chars, 5)
	}

	out := make([]byte, 0, len(chars)/3*2)
	for i := 0; i < len(chars); i += 3 {
		word := uint16(chars[i])<<10 | uint16(chars[i+1])<<5 | uint16(chars[i+2])
		if i+3 == len(chars) {
			word |= 0x8000
		}
		out = append(out, uint8(word>>8), uint8(word))
	}
	return out
}

// concat joins encoded instructions.
func concat(parts ...[]byte) []byte {
	var out []byte
	for _, part := range parts {
		out = append(out, part...)
	}
	return out
}
Output:
West of House
Opening the small mailbox reveals a leaflet.
Taken.

func (*Machine) Run

func (m *Machine) Run(ctx context.Context, input string) (Result, error)

Run supplies one line of player input and executes until the next input boundary or termination (spec S 11).

The input is given to the story exactly as an interactive interpreter would give it: the engine performs the transformations Version 3 requires and leaves the story's own parser to interpret the command. Once the line has been consumed, the next request for input returns WaitingForInput.

Example

ExampleMachine_Run supplies one line of player input and executes to the next input boundary. Each call returns the text the story printed during that call alone, and a state the next call can resume from.

package main

import (
	"context"
	"encoding/binary"
	"fmt"
	"strings"

	"github.com/maloquacious/zmachine"
)

func main() {
	story, err := zmachine.LoadStory(exampleStory())
	if err != nil {
		fmt.Println("load:", err)
		return
	}

	machine, err := zmachine.New(story, zmachine.WithRandomSeed(1))
	if err != nil {
		fmt.Println("new:", err)
		return
	}

	if _, err := machine.Start(context.Background()); err != nil {
		fmt.Println("start:", err)
		return
	}

	result, err := machine.Run(context.Background(), "open mailbox")
	if err != nil {
		fmt.Println("run:", err)
		return
	}

	fmt.Print(result.Output)

	// A story that ends itself with quit reports Halted and carries no state,
	// because there is nothing left to resume.
	last, err := machine.Run(context.Background(), "take leaflet")
	if err != nil {
		fmt.Println("run:", err)
		return
	}

	fmt.Print(last.Output)
	fmt.Println("halted:", last.Status == zmachine.Halted)
	fmt.Println("resumable:", len(last.State) > 0)

}

// The story the examples run.
//
// It is the smallest thing that can demonstrate a session: it prints a room,
// asks for a line, prints a reply, asks for a second line, prints a second
// reply and quits. That is enough to show an input boundary, a resumable
// state and a clean termination, which is all the examples assert.
//
// Its layout, which is the ordinary Version 3 memory map of S 1.1:
//
//	0x0000 header (S 11.1)
//	0x0040 global variables table, 240 words (S 6.2)
//	0x0220 object table: property defaults only, no objects (S 12.1)
//	0x0260 text buffer, 60 bytes (S 15, read)
//	0x02a0 parse buffer, room for 8 words (S 15, read)
//	0x0300 abbreviations table, 96 words, unused (S 3.3)
//	0x03c0 base of static memory
//	0x03c8 dictionary (S 13.1)
//	0x0400 base of high memory; initial program counter
//	0x0800 end of file
const (
	exampleGlobals       = 0x0040
	exampleObjectTable   = 0x0220
	exampleTextBuffer    = 0x0260
	exampleParseBuffer   = 0x02a0
	exampleAbbreviations = 0x0300
	exampleStaticBase    = 0x03c0
	exampleDictionary    = 0x03c8
	exampleInitialPC     = 0x0400
	exampleStorySize     = 0x0800
)

// exampleStory assembles the story image. It returns a fresh copy each time,
// so an example may corrupt it to produce an error without disturbing another.
func exampleStory() []byte {
	image := make([]byte, exampleStorySize)

	putByte := func(addr int, v uint8) { image[addr] = v }
	putWord := func(addr int, v uint16) { binary.BigEndian.PutUint16(image[addr:], v) }

	putByte(0x00, 3)
	putWord(0x02, 1)
	putWord(0x04, exampleInitialPC)
	putWord(0x06, exampleInitialPC)
	putWord(0x08, exampleDictionary)
	putWord(0x0a, exampleObjectTable)
	putWord(0x0c, exampleGlobals)
	putWord(0x0e, exampleStaticBase)
	copy(image[0x12:0x18], "000000")
	putWord(0x18, exampleAbbreviations)
	putWord(0x1a, exampleStorySize/2)

	putByte(exampleTextBuffer, 60)
	putByte(exampleParseBuffer, 8)

	putByte(exampleDictionary, 3)
	copy(image[exampleDictionary+1:], ".,\"")
	putByte(exampleDictionary+4, 7)
	putWord(exampleDictionary+5, 2)

	code := concat(
		printString("West of House"),
		newLine(),
		sread(),
		printString("Opening the small mailbox reveals a leaflet."),
		newLine(),
		sread(),
		printString("Taken."),
		newLine(),
		quit(),
	)
	copy(image[exampleInitialPC:], code)

	var sum uint16
	for _, b := range image[0x40:] {
		sum += uint16(b)
	}
	putWord(0x1c, sum)

	return image
}

// printString is the 0OP instruction print with its text inline (S 15, print).
func printString(s string) []byte {
	return append([]byte{0xb2}, encodeZString(s)...)
}

// newLine is the 0OP instruction new_line (S 15, new_line).
func newLine() []byte { return []byte{0xbb} }

// quit is the 0OP instruction quit (S 15, quit).
func quit() []byte { return []byte{0xba} }

// sread is the VAR instruction read, taking the text and parse buffers as
// large constants (S 15, read). It is the only instruction that suspends.
//
// The type byte gives four two-bit operand types, most significant first:
// two large constants ($$00) and two omitted ($$11).
func sread() []byte {
	return []byte{
		0xe4, 0x0f,
		exampleTextBuffer >> 8, exampleTextBuffer & 0xff,
		exampleParseBuffer >> 8, exampleParseBuffer & 0xff,
	}
}

// encodeZString encodes text as a Version 3 Z-string (S 3.2): three
// five-bit Z-characters to a word, the top bit of the last word set.
//
// It handles the subset the example story needs. A character not in it is a
// mistake in this file rather than anything the engine could be given, so it
// panics rather than encoding something else.
func encodeZString(s string) []byte {

	const alphabetA2 = "\x00\r0123456789.,!?_#'\"/\\-:()"

	var chars []uint8
	for _, r := range s {
		switch {
		case r == ' ':

			chars = append(chars, 0)
		case r >= 'a' && r <= 'z':
			chars = append(chars, uint8(r-'a')+6)
		case r >= 'A' && r <= 'Z':

			chars = append(chars, 4, uint8(r-'A')+6)
		default:
			i := strings.IndexRune(alphabetA2, r)
			if i < 1 {
				panic(fmt.Sprintf("example story: %q cannot be encoded", r))
			}
			chars = append(chars, 5, uint8(i)+6)
		}
	}

	for len(chars) == 0 || len(chars)%3 != 0 {
		chars = append(chars, 5)
	}

	out := make([]byte, 0, len(chars)/3*2)
	for i := 0; i < len(chars); i += 3 {
		word := uint16(chars[i])<<10 | uint16(chars[i+1])<<5 | uint16(chars[i+2])
		if i+3 == len(chars) {
			word |= 0x8000
		}
		out = append(out, uint8(word>>8), uint8(word))
	}
	return out
}

// concat joins encoded instructions.
func concat(parts ...[]byte) []byte {
	var out []byte
	for _, part := range parts {
		out = append(out, part...)
	}
	return out
}
Output:
Opening the small mailbox reveals a leaflet.
Taken.
halted: true
resumable: false

func (*Machine) Start

func (m *Machine) Start(ctx context.Context) (Result, error)

Start begins execution at the story's initial program counter (S 5.5) and runs until the first input boundary or termination (spec S 10).

It supplies no player input: a new story usually prints its banner and opening text before asking for the first command.

type MemoryError

type MemoryError struct {
	// Op is the kind of access attempted.
	Op MemoryOp
	// Width is the number of bytes the access would have touched.
	Width int
	// Addr is the byte address of the first byte of the access.
	Addr uint32
	// Region is the region containing Addr, or RegionUnknown when Addr lies
	// outside the story image.
	Region Region
	// Detail explains why the access was refused.
	Detail string
	// Err is the sentinel this error is classified as.
	Err error
}

MemoryError describes a refused memory access. It always wraps ErrMemoryAccess unless a caller constructs it otherwise.

func (*MemoryError) Error

func (e *MemoryError) Error() string

Error implements error.

func (*MemoryError) Unwrap

func (e *MemoryError) Unwrap() error

Unwrap returns the sentinel classifying this error.

type MemoryOp

type MemoryOp uint8

MemoryOp distinguishes the kinds of memory access an error can describe.

const (
	// MemoryRead is a load from story memory.
	MemoryRead MemoryOp = iota
	// MemoryWrite is a store into story memory.
	MemoryWrite
)

func (MemoryOp) String

func (o MemoryOp) String() string

String returns the operation name used in error messages.

type Option

type Option func(*config) error

Option configures a Machine. Options are applied in order by New, and an option that cannot be satisfied makes New fail rather than silently leaving the machine misconfigured.

func WithFrotzRandomSeed

func WithFrotzRandomSeed(seed uint64) Option

WithFrotzRandomSeed makes the machine draw random numbers exactly as Frotz does, seeded as "dfrotz -s seed" would seed it.

It exists so that this engine's behaviour can be compared against another interpreter's, turn for turn, on stories that consult the random number generator (spec S 33). S 2.4 fixes only that a seeded generator be reproducible, not which numbers it yields, so two conforming interpreters disagree from the first draw and no text comparison past that point means anything. Matching Frotz's generator is the only way to make the comparison possible.

It is not a better generator than the default and carries Frotz's own quirks. In particular Frotz's seed_random treats a seed below 1000 as a request to count from 0 to seed-1 forever rather than to generate at all, so a comparison against dfrotz should use a seed of at least 1000, and so should this. A seed of 0 asks for entropy, exactly as it does in Frotz.

Ordinary use of this package wants WithRandomSeed instead.

func WithInstructionLimit

func WithInstructionLimit(limit uint64) Option

WithInstructionLimit bounds the number of instructions one call to Start or Run may execute (spec S 25).

Reaching the limit stops execution with an error wrapping ErrExecutionLimit. The limit applies to each call separately, so a story that legitimately runs for a long time is not penalised for having done so on an earlier turn. The limit must be positive: a machine with no limit at all cannot be made safe for a server, because a story may loop forever without executing an illegal instruction.

func WithLogger

func WithLogger(logger *slog.Logger) Option

WithLogger sets the logger the machine writes diagnostics to.

The logger is used for interpreter diagnostics only. Story output is never written to it, and logging never changes execution semantics. A Machine created without this option discards its diagnostics; it never falls back to slog.Default.

func WithRandomSeed

func WithRandomSeed(seed uint64) Option

WithRandomSeed seeds the machine's random number generator, making execution reproducible (spec S 21).

The generator starts in the "predictable" state of S 2.4.2: two machines given the same story and the same seed produce the same sequence of random numbers. Without this option the generator is seeded unpredictably, which is the "random" state S 2.4 requires at the start of a game.

The story can still change the state itself: random with a negative range reseeds the generator, and random with a range of zero reseeds it unpredictably (S 15, random).

func WithTracer

func WithTracer(tracer Tracer) Option

WithTracer installs a Tracer, which receives one event per executed instruction (spec S 30).

Tracing is off unless this option is given, and a Tracer cannot change what the story does: it is handed copies of the machine's values and its return is ignored.

type Region

type Region uint8

Region names one of the three regions of the Z-machine memory map (Z-machine Standards Document 1.1, section 1.1).

const (
	// RegionUnknown means the address does not lie inside the story image.
	RegionUnknown Region = iota
	// RegionDynamic is readable and writable memory, below the base of static memory.
	RegionDynamic
	// RegionStatic is readable but not writable memory.
	RegionStatic
	// RegionHigh holds routines and strings. Its bottom may overlap the top of
	// static memory, so an address reported as high memory may also be readable
	// as static memory.
	RegionHigh
)

func (Region) String

func (r Region) String() string

String returns the region name used in error messages.

type Result

type Result struct {
	// Output is the text the story printed to the screen during this call,
	// with the story's whitespace preserved exactly. It never contains the
	// status line and never contains interpreter diagnostics.
	Output string

	// UpperWindow is the text the story printed while the upper window was
	// selected (S 8.6.1). It is reported separately because the upper window
	// overlays fixed screen positions rather than joining the narrative, so
	// merging it into Output would corrupt both.
	UpperWindow string

	// StatusLine is the status line as of the moment execution stopped.
	StatusLine StatusLine

	// State is the resumable machine state, in the Quetzal saved-game format
	// (spec S 22). Passing it to Restore on a Machine built from the same Story
	// returns execution to the point this call stopped at.
	//
	// It is present whenever Status is WaitingForInput, which is the input
	// boundary spec S 23 requires a snapshot at, and nil when Status is Halted,
	// because a story that ended itself with quit has nothing to resume.
	State []byte

	// Status reports why execution stopped.
	Status Status
}

Result is what one call to Start or Run produced.

type Status

type Status uint8

Status reports why execution stopped.

const (
	// WaitingForInput means execution reached a line-input instruction for
	// which the host has supplied no input, and can be resumed by supplying
	// one.
	WaitingForInput Status = iota
	// Halted means the story terminated itself with quit (S 15, quit).
	Halted
)

func (Status) String

func (s Status) String() string

String returns the status name.

type StatusLine

type StatusLine struct {
	// Available reports whether the status line has been updated at least
	// once. The remaining fields are meaningless until it is true, because the
	// status line is not displayed when the game begins (S 8.2.4).
	Available bool

	// Object is the object number held in the first global variable, whose
	// short name belongs on the left of the line (S 8.2.2).
	Object uint16
	// Name is the short name of that object. It is empty until the object
	// model is available.
	Name string

	// TimeGame reports which form the right of the line takes: false for a
	// "score game" and true for a "time game" (S 8.2.1). It is fixed by bit 1
	// of Flags 1 in the story header.
	TimeGame bool

	// Score and Turns are the second and third globals in a score game
	// (S 8.2.3.1). They are signed: the score may be negative.
	Score int16
	Turns int16

	// Hours and Minutes are the second and third globals in a time game
	// (S 8.2.3.2).
	Hours   uint8
	Minutes uint8
}

StatusLine is the Version 3 status line (S 8.2), reported to the host separately from story output because it is drawn by the interpreter rather than printed by the story.

It is updated in exactly two circumstances: when the story executes show_status, and just before the line-input instruction reads (S 8.2.4).

type Story

type Story struct {
	// contains filtered or unexported fields
}

Story is a validated, immutable Version 3 story file.

A Story is safe for concurrent use by any number of Machines. It never changes after LoadStory returns.

func LoadStory

func LoadStory(data []byte) (*Story, error)

LoadStory validates data as a Version 3 story file and returns an immutable Story. The returned Story owns a private copy of the story image, so the caller may reuse or modify data afterwards.

Every header address and table extent is checked before use. Malformed input is reported as an error wrapping ErrInvalidStory; it never panics.

func (*Story) Checksum

func (s *Story) Checksum() uint16

Checksum reports the checksum recorded in the header. It is zero in early Version 3 stories that carry no checksum.

func (*Story) Release

func (s *Story) Release() uint16

Release reports the release number recorded in the header.

func (*Story) Serial

func (s *Story) Serial() string

Serial reports the six-character serial code recorded in the header, conventionally the compilation date as YYMMDD.

func (*Story) Size

func (s *Story) Size() int

Size reports the length in bytes of the story image, which is the file length declared in the header when the story declares one.

func (*Story) Version

func (s *Story) Version() uint8

Version reports the Z-machine version of the story. It is always 3.

type StoryError

type StoryError struct {
	// Field names the header field or table at fault, for example
	// "base of static memory". It is empty when no single field is responsible.
	Field string
	// Value is the offending value. It is only meaningful when Field is set.
	Value uint32
	// Detail explains what is wrong with the value.
	Detail string
	// Err is the sentinel this error is classified as.
	Err error
}

StoryError describes why a story file was rejected. It always wraps ErrInvalidStory unless a caller constructs it otherwise.

func (*StoryError) Error

func (e *StoryError) Error() string

Error implements error.

func (*StoryError) Unwrap

func (e *StoryError) Unwrap() error

Unwrap returns the sentinel classifying this error.

type TextError

type TextError struct {
	// Addr is the byte address the string was read from, or zero when the text
	// did not come from story memory.
	Addr uint32
	// Detail explains what is wrong with the text.
	Detail string
	// Err is the sentinel this error is classified as.
	Err error
}

TextError describes encoded text that could not be decoded. It always wraps ErrInvalidText unless a caller constructs it otherwise.

func (*TextError) Error

func (e *TextError) Error() string

Error implements error.

func (*TextError) Unwrap

func (e *TextError) Unwrap() error

Unwrap returns the sentinel classifying this error.

type TraceInstruction

type TraceInstruction struct {
	// PC is the byte address the instruction was decoded from.
	PC uint32
	// Next is the program counter after the instruction ran. It differs from
	// the address after the instruction when a branch, jump, call or return
	// moved it.
	Next uint32
	// Opcode identifies the instruction in the form used by S 14, for example
	// "2OP:20 add".
	Opcode string
	// Operands holds the operand values in the order they were evaluated
	// (S 4.5.2). It is a copy: the tracer may retain it.
	Operands []uint16
	// CallDepth is the number of routines on the call chain before the
	// instruction ran. It is zero in the initial execution environment of
	// S 5.5.
	CallDepth int

	// Stored reports whether the instruction wrote a result, and StoreVariable
	// and StoreValue say where and what (S 4.6).
	Stored        bool
	StoreVariable uint8
	StoreValue    uint16

	// Branched reports whether a branch instruction took its branch (S 4.7).
	// It is false for instructions that do not branch.
	Branched bool

	// Called reports that the instruction entered a routine, and Returned that
	// it left one. ReturnValue is meaningful only when Returned is set
	// (S 6.4, S 6.5).
	Called      bool
	Returned    bool
	ReturnValue uint16
}

TraceInstruction describes one executed instruction.

type Tracer

type Tracer interface {
	Instruction(TraceInstruction)
}

Tracer receives one event for each instruction the machine executes.

Instruction is called after the instruction has taken effect, so that the event can report what it did as well as what it was. An instruction that failed produces no event; the error describes it instead.

Directories

Path Synopsis
internal
prng
Package prng provides the random number generators a Z-machine may own.
Package prng provides the random number generators a Z-machine may own.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL