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 ¶
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) 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) 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 ¶
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 ¶
Halted reports whether the story has terminated. A halted machine cannot be run again.
func (*Machine) Restore ¶
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 ¶
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
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) 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.
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 )
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 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 ¶
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 ¶
Checksum reports the checksum recorded in the header. It is zero in early Version 3 stories that carry no checksum.
func (*Story) Serial ¶
Serial reports the six-character serial code recorded in the header, conventionally the compilation date as YYMMDD.
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) 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.
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.