quetzal

package module
v0.2.4 Latest Latest
Warning

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

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

README

quetzal

Go Reference

A Go package for reading, validating, and writing Quetzal saved-game files for the Z-machine.

Quetzal is the common save-file format shared by Z-machine interpreters. This package implements that format and nothing else: it does not execute Z-machine instructions, emulate a terminal, or implement the object, dictionary, or text systems. It is meant for interpreters, servers that persist sessions, save-file inspection tools, and interoperability testing.

The normative reference is Martin Frost's Z-machine Common Save-File Format Standard (Quetzal), version 1.4.

Status

Feature complete for v1.0, and the API is not expected to change further. specification.md is accepted rather than draft: every deliberate departure from Quetzal 1.4 is enumerated in its §2.1, every accepted limitation in its §30, and spec-deltas.md carries the reasoning and the interoperability evidence behind each one.

Implemented:

  • The IFF container: FORM/IFZS parsing, chunk parsing, padding, retention of unknown chunks, and configurable resource limits.
  • Story identification: the IFhd chunk, extraction of the Z-machine header fields Quetzal depends on, and story matching.
  • Dynamic memory: the CMem and UMem chunks, compression and decompression of the CMem difference stream, and reconstruction of a save's dynamic memory against its story.
  • Stack frames: the Stks chunk, frame decoding and encoding, the dummy frame that versions other than 6 require, and frame validation.
  • Reading and writing whole saves: Read, Write, and Validate, the copy restrictions on interpreter-dependent IntD data, and semantic round trips.
  • The text chunks a save may carry: Annotations, Author, and Copyright.

Interoperability is done. Saves written by Frotz 2.55, Bocfel 2.5, and jzip 2.1 all load here, and all three restore files this package writes. Saving one game position in all three produces dynamic memory that agrees to the byte outside the Z-machine header, where each interpreter records its own capabilities. ckifzs, the conformance checker distributed with the standard, reports every file this package writes as conforming. The test suite reads the committed fixtures and needs no interpreter installed.

Version coverage. The package implements Z-machine versions 1 through 8. Its committed fixtures are all version 3, because those are the only stories whose redistribution permits shipping them, but versions 5 and 6 are exercised against stories a maintainer can fetch — including a version 6 save, which is the only kind that carries no dummy frame. Versions 1 and 2 remain untested by any real file: what they need is a story with no checksum in its header, and no copy of one could be found. The code computes such a checksum as the format requires; nothing has confirmed it against a story that needs it. See D43 and D27 in spec-deltas.md.

Stored files

Files this package writes stay readable by later releases. The format on disk is Quetzal 1.4's, not this package's: nothing the writer emits records a package version, so no API change can reach bytes already saved. The usual caveat that a package below v1.0 may still change is a caveat about the Go API, and no stored file depends on the Go API. Version() reports the package version and the Quetzal version separately because the two do not move together.

Only a standards or security justification would make a later release reject a file an earlier one accepted — a reader defect being fixed, or a default resource limit lowered against hostile input, which a caller can raise again with WithLimits. Either would be called out in CHANGELOG.md.

Two things to know before designing storage around these bytes:

  • A save is not self-contained. It names its story by release number, serial number, and checksum, and compressed memory is a difference against that story's dynamic memory, so reading one back requires the same story file. Keep the stories along with the saves.
  • Rewriting a save does not reproduce its bytes. A round trip preserves the state, not the encoding, so a hash of the file is not an identifier of the position it holds.

specification.md §26.1 states this as policy, including what would have to be announced if it ever changed.

Install

go get github.com/maloquacious/quetzal

Requires Go 1.26 or later. The package has no third-party dependencies.

Documentation

New to Quetzal, or to this package? Start with the tutorial — it takes one real Frotz save of Zork I, takes it apart until you can say where the player was standing and what the game was in the middle of doing, and writes it back out. Fifteen minutes, and it uses fixtures committed here, so there is nothing to go and find first.

Everything else assumes you have done that or already know the format:

Tutorial A lesson. Read your first save, start to finish.
Usage, below How-to guides for specific goals.
pkg.go.dev The API reference. Also go doc github.com/maloquacious/quetzal.
specification.md What this package promises, and its accepted limitations.
spec-deltas.md Every deliberate departure from Quetzal 1.4, argued out.
CHANGELOG.md What changed, by release.

Usage

Inspect a save

Decode parses a save's structure. It needs no story file, so it is the right entry point for tools that only want to look at what a save contains.

f, err := os.Open("save.sav")
if err != nil {
	log.Fatal(err)
}
defer f.Close()

save, err := quetzal.Decode(f)
if err != nil {
	log.Fatal(err)
}

header, err := save.Header()
if err != nil {
	log.Fatal(err)
}

fmt.Printf("story: %s\n", header.Identity())
fmt.Printf("PC:    %#x\n", header.PC)

for _, chunk := range save.Chunks {
	fmt.Printf("chunk %s, %d bytes\n", chunk.ID, len(chunk.Data))
}
Check that a save belongs to a story

A save records the release number, serial number, and checksum of the story it came from. Verify compares them and reports how they differ; Matches answers the same question as a bool.

Stories written before the Z-machine header carried a checksum hold zero in that field, and the format requires an interpreter to compute the value from the story image instead. ParseStory does so, and sets Story.ChecksumComputed to say that it did.

image, err := os.ReadFile("zork1.z3")
if err != nil {
	log.Fatal(err)
}

story, err := quetzal.ParseStory(image)
if err != nil {
	log.Fatal(err)
}

if err := header.Verify(story); err != nil {
	log.Fatal(err) // wraps quetzal.ErrStoryMismatch
}
Rebuild the saved dynamic memory

Most saves store dynamic memory as a difference against the story it came from, so rebuilding it needs the story. Memory checks that the save and the story agree before it does so, since a difference applied to the wrong story would produce plausible nonsense rather than an error.

mem, err := save.Memory(story)
if err != nil {
	log.Fatal(err)
}

fmt.Printf("%d bytes of dynamic memory, saved as %s\n", len(mem.Data), mem.Encoding)
Walk the call stack

Frames run from the oldest to the newest, so the last one is the call that was executing when the game was saved. Evaluation stacks use the same order: Evaluation[0] is the least recent word and the last element is the top of the stack.

frames, err := save.Frames()
if err != nil {
	log.Fatal(err)
}

for i, frame := range frames {
	if frame.IsDummy() {
		fmt.Printf("frame %d: top-level, %d words on the stack\n", i, len(frame.Evaluation))
		continue
	}
	fmt.Printf("frame %d: returns to %#x, %d local(s), %d word(s) on the stack\n",
		i, frame.ReturnPC, len(frame.Locals), len(frame.Evaluation))
}
Read the text a save carries

A save may carry an annotation, the name of whoever wrote the file, and a copyright notice. Interpreters use them for whatever they see fit — Bocfel records its own name and version — and nothing about restoring a game depends on them, so a save with none of these is in no way deficient.

for _, note := range save.Annotations() {
	fmt.Printf("annotation: %s\n", note)
}
if author, ok := save.Author(); ok {
	fmt.Printf("saved by:   %s\n", author)
}

The same three methods are available on a decoded File, so a tool inspecting a save need not have the story it belongs to. The text comes back exactly as it was stored: the format says it holds printable ASCII, but a file that breaks that rule still carries what its writer meant, and treating the bytes as untrusted is left to whoever displays them.

Change what a save records about itself

There is no setter. Annotations and the rest live in Save.Chunks alongside everything else the file carried, and editing that slice is how they change — which keeps one rule visible: a chunk this package does not understand is carried along untouched, so rewriting a save does not quietly discard what another interpreter put in it.

// Drop any annotation already present and record our own instead.
var chunks []quetzal.Chunk
for _, c := range save.Chunks {
	if c.ID != quetzal.IDANNO {
		chunks = append(chunks, c)
	}
}
save.Chunks = append(chunks, quetzal.Chunk{
	ID:   quetzal.IDANNO,
	Data: []byte("score 25, 140 moves"),
})

if err := quetzal.Write(out, story, save); err != nil {
	log.Fatal(err)
}

Two things the writer will not let you get away with. An IFhd, CMem, UMem, or Stks chunk in Save.Chunks is rejected rather than written, since it would contradict the fields that already describe those; and an IntD chunk whose flags forbid copying is left out when a save is read, so it cannot be carried into a file it does not belong to.

Read and write whole saves

The examples above take a save apart a piece at a time. Read does all of it at once and checks the result, which is what an interpreter restoring a game wants. Write is its inverse.

save, err := quetzal.Read(f, story)
if err != nil {
	log.Fatal(err)
}

// Resume the game from save.Memory.Data, save.Header.PC, and save.Frames,
// then save it again later.
out, err := os.Create("save.qzl")
if err != nil {
	log.Fatal(err)
}
defer out.Close()

if err := quetzal.Write(out, story, save); err != nil {
	log.Fatal(err)
}

Reading and writing round trip semantically, not byte for byte: the story identity, dynamic memory, program counter, and stack frames all survive, but the bytes need not, since the format leaves the choice of encoding open. Pass quetzal.WithEncoding(quetzal.MemoryUncompressed) to write dynamic memory in full rather than as a difference.

Save.Validate(story) runs the same checks without producing a file, for a caller assembling a save of its own.

Reading is strict by default. The one exception a caller can ask for is quetzal.IgnoreChunkOrder(), which accepts a save whose IFhd chunk does not come first — the format requires that order, but not every interpreter enforces it, and a file the rest of the world reads should not be unreadable here.

Errors wrap the sentinels ErrInvalidFormat, ErrStoryMismatch, ErrTruncated, and ErrLimitExceeded, so test them with errors.Is. Container problems are reported as a *ChunkError naming the chunk and its offset, and stack problems as a *FrameError naming the frame.

Compare two saves

Compare reports how two saves differ, which is what a test wants when the question is whether this package and some other interpreter agree about a position. It needs no story of its own, returns no error, and reports nothing at all when the two agree.

ours := readSave("ours.qzl", story)
theirs := readSave("dfrotz.qzl", story)

for _, d := range quetzal.Compare(ours, theirs) {
	fmt.Println(d)
}

Most of what that prints between two different interpreters is uninteresting, and one part of it is actively misleading. The whole Z-machine header lives inside dynamic memory, so a save carries the screen size, font size, interpreter number, default colours, and claimed standard revision of whoever wrote it — fields the interpreter fills in for itself and that say nothing about the saved game. Options disregard those, and the chunks each interpreter writes for itself:

diffs := quetzal.Compare(ours, theirs,
	quetzal.IgnoreInterpreterHeader(),
	quetzal.IgnoreMemoryEncoding(),
	quetzal.IgnoreChunks(quetzal.IDANNO, quetzal.IDIntD),
)
if len(diffs) != 0 {
	log.Fatalf("the two interpreters disagree: %v", diffs)
}

IgnoreMemoryRange(start, end) handles anything game-specific left over. Every option is named for what it disregards, and none of them can turn agreement into a difference, so a comparison with no options is exact.

CompareFiles asks the other question — whether two files say it the same way — and compares containers chunk by chunk, in order, without interpreting anything.

Comparison is a testing and debugging facility rather than part of the format, so specification.md deliberately says nothing about it. Its doc comments and issue 1 are where it is specified; see specification.md §5.7 for why.

Design

  • Standard library only. No third-party runtime dependencies.
  • Streams, not paths. The core API works on io.Reader and io.Writer.
  • No hidden story lookup. Compressed memory cannot be rebuilt without the original story, and this package never goes looking for one. Callers pass story data in explicitly.
  • Untrusted input. Every length in a save file comes from the file itself. Reads are bounds-checked, arithmetic is overflow-safe, allocations are limited, and malformed input returns an error instead of panicking.
  • Information is preserved. Unknown chunks are kept, in order, rather than treated as errors — up to a configurable total, since a chunk nothing understands is the one part of a file whose size nothing but the file itself constrains.

Testing

go test ./...

The test suite runs without any external interpreter installed. Fuzz targets are included:

go test -run XXX -fuzz FuzzDecode -fuzztime 30s ./...

Every Go snippet above is compiled. They live in example_test.go as example functions, which the compiler checks and the test runner does not execute, and a test asserts that the README and that file still agree — so a snippet cannot quietly rot into something that no longer builds.

The tutorial is held to more than that, because a reader who is still learning cannot tell a stale lesson from their own mistake. tutorial_test.go runs the program the tutorial builds against the same committed fixtures the reader is told to copy, and asserts every number it prints — the program counter, the frame count, both file sizes, and the single difference Compare reports at the end.

The same suite checks that the two design documents stay in step: that every entry in spec-deltas.md records what was done with it, and that every departure from the standard reaches specification.md §2.1.

License

This package is released under the MIT License. See LICENSE.

Third-party material

Some files in this repository are not part of the package and are not covered by the package's license.

The story files under testdata/stories/ are third-party works. They are included only as test fixtures, so that the package can be exercised against real Version 3 Z-machine story files. Their presence here does not make them part of this package, does not place them under this package's license, and grants you no rights to them. Each story file carries its own license file alongside it, and that license is what governs your use of it. See testdata/README.md for the source and license of each file.

The Quetzal specification itself is the work of Martin Frost and is not redistributed here. See references/README.txt.

Documentation

Overview

Package quetzal reads, validates, manipulates, and writes Quetzal saved-game files for the Z-machine.

Quetzal is a saved-state file format, not a Z-machine. This package does not execute Z-machine instructions, implement the object, dictionary, or text systems, or emulate a terminal. It models Quetzal as a file format so that interpreters, servers, and inspection tools can share one implementation.

The implementation follows The Quetzal Z-Machine Saved Game Standard, version 1.4, by Martin Frost. A Quetzal file is an IFF FORM whose form type is IFZS and whose required contents are an IFhd chunk, one of CMem or UMem, and a Stks chunk. All multi-byte integers are big-endian.

Layers

The package separates the IFF container from the Quetzal payloads:

Decode  parses the container into raw chunks and needs no story file.
Read    additionally reconstructs saved state and requires the story.

The separation matters because inspecting a save's structure, header, or annotations does not inherently require the original story image, while reconstructing compressed dynamic memory does.

Writing runs the same way in reverse. Save.Encode turns saved state into a container, File.WriteTo writes a container out, and Write does both:

quetzal.Read(r, story)  ==  quetzal.Decode(r) then File.Save(story)
quetzal.Write(w, story, save)  ==  Save.Encode(story) then File.WriteTo(w)

Reading and writing round trip semantically rather than byte for byte: a save that is read and written again holds the same story identity, dynamic memory, program counter, and call stack, but need not be the same sequence of bytes, since the format leaves the choice of encoding open.

Naming

Four prefixes recur, and each means something:

Parse    turns a payload into one value of fixed layout: ParseHeader,
         ParseStory, ParseInterpreterData.
Decode   turns a payload into however many values it describes:
         Decode, DecodeCMem, DecodeStks.
Validate reports whether a value could be written, without writing it.
         Header, Frame, Memory, and Save each have one; ValidateFrames
         checks a whole call stack against its story.
Compare  reports how two values of one kind differ, judging neither:
         Compare over saves, CompareFiles over containers.

Limits bounds the calls where, and only where, the input decides how much there is to allocate: Decode, which reads a chunk count out of the FORM, and DecodeStks, which reads frame and word counts out of its payload. DecodeCMem needs none, because its result is exactly as long as the original memory the caller supplied, however long the difference stream turns out to be. Decode takes its limits through WithLimits, since it accepts other options too; DecodeStks takes a Limits directly, since none of the other options mean anything to a bare payload.

Encode is the inverse of the layer it is called on rather than of any one of these: Header.Encode returns a payload, Memory.Encode returns a Chunk, and Save.Encode returns a whole File.

Options

ReadOption, WriteOption, and CompareOption configure a call. All three are functions over an unexported type, so this package defines every option there is and a caller cannot write its own.

For ReadOption and WriteOption that closure is the point: an option there names one rule being relaxed or one choice being made, and the set of rules is the format's rather than open-ended. IgnoreChunkOrder exists because Quetzal states an ordering rule that not every interpreter enforces. There is no option to accept a save for the wrong story, because no such leniency is defensible.

CompareOption is closed for a weaker reason, and the difference is worth stating. What a caller is willing to disregard when it compares two saves is its own testing policy, not a rule of the format, so the reasoning above does not reach it: IgnoreMemoryRange takes arbitrary bounds precisely because nothing in Quetzal says which bytes of dynamic memory a caller ought to care about. These options are a closed set only because a caller that needs another one is better served by asking for it than by writing it — an option that ships here is documented, tested, and named for what it disregards, and the next caller finds it. See Compare.

Comparison

Compare reports how two saves differ, and CompareFiles does the same for two containers. Neither is part of the Quetzal format: they exist to make a difference between this package and another interpreter, or between two runs of one interpreter, something a test can print rather than something a person has to find in a byte dump.

They are therefore outside the scope of specification.md, which describes the format and this package's reading and writing of it. That exclusion is stated in its §5.7, along with the requirements a facility of this kind must still meet. The design is recorded in https://github.com/maloquacious/quetzal/issues/1, and these doc comments are authoritative for the behavior.

Story data

Compressed memory (CMem) is an XOR difference against the story's original dynamic memory, so it cannot be reconstructed without that memory. This package never searches the filesystem for a matching story file and never performs filesystem or network access as a side effect of parsing. Callers supply story data explicitly.

Stored files

A file this package writes stays readable by later releases. The format on disk is Quetzal 1.4's rather than this package's, and nothing the writer emits records a package version, so no change to this API can reach bytes already saved. That holds before v1.0 as well as after: the caveat that a package below v1.0 may still change is a caveat about this API, and no stored file depends on it. Version reports the package version and the Quetzal version separately because the two do not move together.

A caller persisting saves should not assume two things. A save is not self-contained: it names its story by release number, serial number, and checksum, and compressed memory is a difference against that story's dynamic memory, so reading one back requires the same story. And rewriting a save does not reproduce its bytes, because a round trip preserves the state rather than the encoding, so a hash of the file does not identify the position it holds.

The policy, including what would have to justify a later release rejecting a file an earlier one accepted, is https://github.com/maloquacious/quetzal/blob/main/specification.md#261-files-already-written.

Untrusted input

Saved games are binary input from untrusted sources. Every length field in a Quetzal file is attacker-controlled, so this package bounds-checks reads, uses overflow-safe arithmetic, validates lengths before allocating, and enforces configurable resource limits. Malformed input returns an error rather than panicking.

Example (ChangeWhatASaveRecordsAboutItself)
package main

import (
	"log"
	"os"

	"github.com/maloquacious/quetzal"
)

func main() {
	save, story := readSaveAndStory()

	out, err := os.Create("save.qzl")
	if err != nil {
		log.Fatal(err)
	}
	defer out.Close()

	// Drop any annotation already present and record our own instead.
	var chunks []quetzal.Chunk
	for _, c := range save.Chunks {
		if c.ID != quetzal.IDANNO {
			chunks = append(chunks, c)
		}
	}
	save.Chunks = append(chunks, quetzal.Chunk{
		ID:   quetzal.IDANNO,
		Data: []byte("score 25, 140 moves"),
	})

	if err := quetzal.Write(out, story, save); err != nil {
		log.Fatal(err)
	}
}

func openSaveAndStory() (*os.File, quetzal.Story) {
	f, err := os.Open("save.sav")
	if err != nil {
		log.Fatal(err)
	}

	image, err := os.ReadFile("zork1.z3")
	if err != nil {
		log.Fatal(err)
	}

	story, err := quetzal.ParseStory(image)
	if err != nil {
		log.Fatal(err)
	}

	return f, story
}

func readSaveAndStory() (*quetzal.Save, quetzal.Story) {
	f, story := openSaveAndStory()
	defer f.Close()

	save, err := quetzal.Read(f, story)
	if err != nil {
		log.Fatal(err)
	}

	return save, story
}
Example (CheckThatASaveBelongsToAStory)
package main

import (
	"log"
	"os"

	"github.com/maloquacious/quetzal"
)

func main() {
	f, err := os.Open("save.sav")
	if err != nil {
		log.Fatal(err)
	}
	defer f.Close()

	save, err := quetzal.Decode(f)
	if err != nil {
		log.Fatal(err)
	}

	header, err := save.Header()
	if err != nil {
		log.Fatal(err)
	}

	image, err := os.ReadFile("zork1.z3")
	if err != nil {
		log.Fatal(err)
	}

	story, err := quetzal.ParseStory(image)
	if err != nil {
		log.Fatal(err)
	}

	if err := header.Verify(story); err != nil {
		log.Fatal(err) // wraps quetzal.ErrStoryMismatch
	}
}
Example (CompareTwoSaves)
package main

import (
	"fmt"
	"log"
	"os"

	"github.com/maloquacious/quetzal"
)

func main() {
	story := parsedStory()

	ours := readSave("ours.qzl", story)
	theirs := readSave("dfrotz.qzl", story)

	for _, d := range quetzal.Compare(ours, theirs) {
		fmt.Println(d)
	}

	diffs := quetzal.Compare(ours, theirs,
		quetzal.IgnoreInterpreterHeader(),
		quetzal.IgnoreMemoryEncoding(),
		quetzal.IgnoreChunks(quetzal.IDANNO, quetzal.IDIntD),
	)
	if len(diffs) != 0 {
		log.Fatalf("the two interpreters disagree: %v", diffs)
	}
}

func openSaveAndStory() (*os.File, quetzal.Story) {
	f, err := os.Open("save.sav")
	if err != nil {
		log.Fatal(err)
	}

	image, err := os.ReadFile("zork1.z3")
	if err != nil {
		log.Fatal(err)
	}

	story, err := quetzal.ParseStory(image)
	if err != nil {
		log.Fatal(err)
	}

	return f, story
}

func parsedStory() quetzal.Story {
	_, story := openSaveAndStory()
	return story
}

func readSave(path string, story quetzal.Story) *quetzal.Save {
	f, err := os.Open(path)
	if err != nil {
		log.Fatal(err)
	}
	defer f.Close()

	save, err := quetzal.Read(f, story)
	if err != nil {
		log.Fatal(err)
	}

	return save
}
Example (InspectASave)
package main

import (
	"fmt"
	"log"
	"os"

	"github.com/maloquacious/quetzal"
)

func main() {
	f, err := os.Open("save.sav")
	if err != nil {
		log.Fatal(err)
	}
	defer f.Close()

	save, err := quetzal.Decode(f)
	if err != nil {
		log.Fatal(err)
	}

	header, err := save.Header()
	if err != nil {
		log.Fatal(err)
	}

	fmt.Printf("story: %s\n", header.Identity())
	fmt.Printf("PC:    %#x\n", header.PC)

	for _, chunk := range save.Chunks {
		fmt.Printf("chunk %s, %d bytes\n", chunk.ID, len(chunk.Data))
	}
}
Example (ReadAndWriteWholeSaves)
package main

import (
	"log"
	"os"

	"github.com/maloquacious/quetzal"
)

func main() {
	f, story := openSaveAndStory()
	defer f.Close()

	save, err := quetzal.Read(f, story)
	if err != nil {
		log.Fatal(err)
	}

	// Resume the game from save.Memory.Data, save.Header.PC, and save.Frames,
	// then save it again later.
	out, err := os.Create("save.qzl")
	if err != nil {
		log.Fatal(err)
	}
	defer out.Close()

	if err := quetzal.Write(out, story, save); err != nil {
		log.Fatal(err)
	}
}

func openSaveAndStory() (*os.File, quetzal.Story) {
	f, err := os.Open("save.sav")
	if err != nil {
		log.Fatal(err)
	}

	image, err := os.ReadFile("zork1.z3")
	if err != nil {
		log.Fatal(err)
	}

	story, err := quetzal.ParseStory(image)
	if err != nil {
		log.Fatal(err)
	}

	return f, story
}
Example (ReadTheTextASaveCarries)
package main

import (
	"fmt"
	"log"
	"os"

	"github.com/maloquacious/quetzal"
)

func main() {
	save, _ := decodedSaveAndStory()

	for _, note := range save.Annotations() {
		fmt.Printf("annotation: %s\n", note)
	}
	if author, ok := save.Author(); ok {
		fmt.Printf("saved by:   %s\n", author)
	}
}

func openSaveAndStory() (*os.File, quetzal.Story) {
	f, err := os.Open("save.sav")
	if err != nil {
		log.Fatal(err)
	}

	image, err := os.ReadFile("zork1.z3")
	if err != nil {
		log.Fatal(err)
	}

	story, err := quetzal.ParseStory(image)
	if err != nil {
		log.Fatal(err)
	}

	return f, story
}

func decodedSaveAndStory() (*quetzal.File, quetzal.Story) {
	f, story := openSaveAndStory()
	defer f.Close()

	save, err := quetzal.Decode(f)
	if err != nil {
		log.Fatal(err)
	}

	return save, story
}
Example (RebuildTheSavedDynamicMemory)
package main

import (
	"fmt"
	"log"
	"os"

	"github.com/maloquacious/quetzal"
)

func main() {
	save, story := decodedSaveAndStory()

	mem, err := save.Memory(story)
	if err != nil {
		log.Fatal(err)
	}

	fmt.Printf("%d bytes of dynamic memory, saved as %s\n", len(mem.Data), mem.Encoding)
}

func openSaveAndStory() (*os.File, quetzal.Story) {
	f, err := os.Open("save.sav")
	if err != nil {
		log.Fatal(err)
	}

	image, err := os.ReadFile("zork1.z3")
	if err != nil {
		log.Fatal(err)
	}

	story, err := quetzal.ParseStory(image)
	if err != nil {
		log.Fatal(err)
	}

	return f, story
}

func decodedSaveAndStory() (*quetzal.File, quetzal.Story) {
	f, story := openSaveAndStory()
	defer f.Close()

	save, err := quetzal.Decode(f)
	if err != nil {
		log.Fatal(err)
	}

	return save, story
}
Example (Tutorial)

Example_tutorial is the program tutorial.md builds, exactly as the reader assembles it, so the compiler checks what they will type. It opens files that do not exist here and so carries no Output comment: go test compiles it without running it. TestTutorialProgram is what runs the same steps for real.

package main

import (
	"fmt"
	"log"
	"os"

	"github.com/maloquacious/quetzal"
)

func main() {
	image, err := os.ReadFile("zork1.z3")
	if err != nil {
		log.Fatal(err)
	}

	story, err := quetzal.ParseStory(image)
	if err != nil {
		log.Fatal(err)
	}

	fmt.Printf("story:  release %d, serial %s, version %d\n",
		story.Release, story.Serial, story.Version)
	fmt.Printf("        %d bytes of dynamic memory\n", len(story.DynamicMemory))

	f, err := os.Open("kitchen.qzl")
	if err != nil {
		log.Fatal(err)
	}
	defer f.Close()

	save, err := quetzal.Read(f, story)
	if err != nil {
		log.Fatal(err)
	}

	fmt.Printf("save:   %s\n", save.Header.Identity())
	fmt.Printf("        PC %#x\n", save.Header.PC)
	fmt.Printf("memory: %d bytes, stored as %s\n",
		len(save.Memory.Data), save.Memory.Encoding)

	fmt.Printf("stack:  %d frames\n", len(save.Frames))
	for i, frame := range save.Frames {
		if frame.IsDummy() {
			fmt.Printf("  frame %d: dummy, %d word(s) on the evaluation stack\n",
				i, len(frame.Evaluation))
			continue
		}
		fmt.Printf("  frame %d: returns to %#x, %d local(s), %d word(s) on the evaluation stack\n",
			i, frame.ReturnPC, len(frame.Locals), len(frame.Evaluation))
	}

	out, err := os.Create("mine.qzl")
	if err != nil {
		log.Fatal(err)
	}
	if err := quetzal.Write(out, story, save); err != nil {
		log.Fatal(err)
	}
	out.Close()

	plain, err := os.Create("plain.qzl")
	if err != nil {
		log.Fatal(err)
	}
	if err := quetzal.Write(plain, story, save,
		quetzal.WithEncoding(quetzal.MemoryUncompressed)); err != nil {
		log.Fatal(err)
	}
	plain.Close()

	g, err := os.Open("plain.qzl")
	if err != nil {
		log.Fatal(err)
	}
	defer g.Close()

	reread, err := quetzal.Read(g, story)
	if err != nil {
		log.Fatal(err)
	}

	for _, d := range quetzal.Compare(save, reread) {
		fmt.Printf("difference: %s\n", d)
	}
}
Example (WalkTheCallStack)
package main

import (
	"fmt"
	"log"
	"os"

	"github.com/maloquacious/quetzal"
)

func main() {
	save, _ := decodedSaveAndStory()

	frames, err := save.Frames()
	if err != nil {
		log.Fatal(err)
	}

	for i, frame := range frames {
		if frame.IsDummy() {
			fmt.Printf("frame %d: top-level, %d words on the stack\n", i, len(frame.Evaluation))
			continue
		}
		fmt.Printf("frame %d: returns to %#x, %d local(s), %d word(s) on the stack\n",
			i, frame.ReturnPC, len(frame.Locals), len(frame.Evaluation))
	}
}

func openSaveAndStory() (*os.File, quetzal.Story) {
	f, err := os.Open("save.sav")
	if err != nil {
		log.Fatal(err)
	}

	image, err := os.ReadFile("zork1.z3")
	if err != nil {
		log.Fatal(err)
	}

	story, err := quetzal.ParseStory(image)
	if err != nil {
		log.Fatal(err)
	}

	return f, story
}

func decodedSaveAndStory() (*quetzal.File, quetzal.Story) {
	f, story := openSaveAndStory()
	defer f.Close()

	save, err := quetzal.Decode(f)
	if err != nil {
		log.Fatal(err)
	}

	return save, story
}

Index

Examples

Constants

View Source
const (
	// MaxLocals is the number of local variables a Z-machine routine may
	// have, and therefore the largest count a frame's flags byte can hold.
	MaxLocals = 15

	// MaxEvaluationWords is the largest evaluation stack a single frame can
	// record, since the count is stored in one word.
	MaxEvaluationWords = 0xffff
)

The limits a Stks frame's own layout imposes. Both are ceilings on what the format can express, not on what a Z-machine may do, and both are checked by Frame.Validate rather than on reading — a decoded frame cannot exceed either, since each count is read from a field too small to hold a larger number.

View Source
const (
	MinVersion = 1
	MaxVersion = 8
)

The range of Z-machine versions this package supports, as represented by Quetzal 1.4.

Quetzal is largely version-independent, so the code paths that differ by version are few: whether a save carries the dummy frame (version 6 does not), and how the story's declared length is scaled when a checksum has to be computed. Every version in this range is implemented. Not every one has been exercised against a real story file — see the version-coverage note in the README, which says which and why.

View Source
const MaxPC = 0xffffff

MaxPC is the largest program counter a Quetzal file can represent, since program counters are stored as three bytes. It bounds Header.PC and Frame.ReturnPC alike, and writing either beyond it is an error rather than a truncation.

Variables

View Source
var (
	IDFORM = ID{'F', 'O', 'R', 'M'} // outer IFF chunk
	IDIFZS = ID{'I', 'F', 'Z', 'S'} // Quetzal FORM type

	IDIFhd = ID{'I', 'F', 'h', 'd'} // story identification
	IDCMem = ID{'C', 'M', 'e', 'm'} // compressed dynamic memory
	IDUMem = ID{'U', 'M', 'e', 'm'} // uncompressed dynamic memory
	IDStks = ID{'S', 't', 'k', 's'} // stack frames
	IDIntD = ID{'I', 'n', 't', 'D'} // interpreter-dependent data

	IDANNO = ID{'A', 'N', 'N', 'O'} // annotation text
	IDAUTH = ID{'A', 'U', 'T', 'H'} // author text
	IDCopy = ID{'(', 'c', ')', ' '} // copyright text
)

Chunk IDs used by Quetzal. The container layer treats every chunk alike; these exist so callers and later layers can select chunks without building identifiers by hand.

View Source
var (
	// ErrInvalidFormat reports data that does not conform to Quetzal or to
	// the IFF container rules Quetzal relies on.
	ErrInvalidFormat = errors.New("quetzal: invalid format")

	// ErrStoryMismatch reports that a save does not belong to the story
	// image supplied by the caller.
	ErrStoryMismatch = errors.New("quetzal: story mismatch")

	// ErrTruncated reports input that ended before a structure was complete.
	ErrTruncated = errors.New("quetzal: truncated data")

	// ErrLimitExceeded reports input whose declared sizes exceed the
	// configured Limits.
	ErrLimitExceeded = errors.New("quetzal: resource limit exceeded")
)

Sentinel errors report conditions callers are likely to branch on. Detailed errors returned by this package wrap one of these, so use errors.Is rather than comparing values directly.

Functions

func DecodeCMem

func DecodeCMem(payload, original []byte) ([]byte, error)

DecodeCMem expands the payload of a CMem chunk into dynamic memory, given the original dynamic memory of the story the save was made from.

The payload is a difference stream: a non-zero byte is exclusive-ored with the byte at the current position, and a zero byte followed by a length byte n leaves the next n+1 bytes unchanged. A stream that ends early leaves the rest of dynamic memory unchanged, which is how writers drop a redundant run at the end.

The result is a new buffer the length of original. Neither argument is retained or modified.

func EncodeCMem

func EncodeCMem(current, original []byte) ([]byte, error)

EncodeCMem compresses dynamic memory against the original dynamic memory of the story it came from, producing the payload of a CMem chunk.

Any run of unchanged bytes at the end is omitted, since a reader treats a stream that ends early as unchanged to the end. The result is not necessarily the shortest possible encoding, which the standard does not require, but it is the shortest this scheme allows for a single pass.

Neither argument is retained or modified.

func EncodeStks

func EncodeStks(frames []Frame) ([]byte, error)

EncodeStks encodes frames into the payload of a Stks chunk, in the order given, which must run from the oldest frame to the newest.

A frame that discards its result has its result variable written as zero, since the byte carries no meaning in that case. The frames themselves are neither retained nor modified.

func StoryChecksum

func StoryChecksum(data []byte) (checksum uint16, ok bool)

StoryChecksum computes a story image's checksum the way the Z-machine defines it: the sum of every byte from the end of the 64-byte header to the end of the story, modulo 0x10000.

Interpreters normally read this value from offset $1C rather than computing it, and Quetzal records it in IFhd so that a save can be matched to the story it belongs to. Games written before the field came into use carry zero there; standard 5.5 requires the value to be calculated instead, which is what this function is for. ParseStory calls it for exactly those stories.

The length of the story comes from the header, not from the size of the image, because a story file may carry padding beyond its declared end. ok is false when the header declares no usable length — true of some of the same early games, which leave that field unused as well — and in that case no checksum can be computed from the image at all.

The image is read but neither retained nor modified.

func ValidateFrames

func ValidateFrames(frames []Frame, story Story) error

ValidateFrames checks a call stack against the story it belongs to.

Every frame must be representable, and a save for any Z-machine version other than 6 must begin with the dummy frame that holds top-level evaluation-stack state.

func Version

func Version() (pkg, spec string)

Version returns the semantic version of this package and the version of the Quetzal specification it implements.

func Write

func Write(w io.Writer, story Story, save *Save, opts ...WriteOption) error

Write writes a save as a Quetzal file.

The story is required: it supplies the original dynamic memory that compressed memory is a difference against, and the Z-machine version that decides what the call stack must contain. Write refuses to write a save that does not belong to the story given, since the identity it would record would then be a lie about its own contents.

Nothing is written until the whole save has been checked and encoded, so a rejected save leaves the writer untouched. The writer need not be seekable.

Neither the save nor the story is retained or modified.

Types

type Chunk

type Chunk struct {
	ID   ID
	Data []byte
}

Chunk is one IFF chunk: a four-byte identifier and its exact payload.

Data never includes the pad byte that follows an odd-length chunk. That byte is structural, belongs to the container rather than the chunk, and is regenerated on write.

type ChunkError

type ChunkError struct {
	ID     ID
	Offset int64

	// Err is the underlying problem, and wraps one of the package sentinels.
	Err error
}

ChunkError identifies the chunk and file offset at which an error occurred. Offset is the position of the chunk's ID within the input stream.

func (*ChunkError) Error

func (e *ChunkError) Error() string

Error implements the error interface.

func (*ChunkError) Unwrap

func (e *ChunkError) Unwrap() error

Unwrap returns the underlying error so that errors.Is and errors.As reach the sentinel it wraps.

type CompareOption

type CompareOption func(*compareConfig)

CompareOption configures a comparison.

Every option is named for what it disregards, and disregarding is all an option here can do: none of them can turn agreement into a difference. A comparison run with every option is therefore the most forgiving one available, and one run with none is exact.

func IgnoreChunks

func IgnoreChunks(ids ...ID) CompareOption

IgnoreChunks disregards every chunk with one of the given identifiers.

ANNO and IntD are the usual arguments. Each interpreter writes its own annotations and its own interpreter data, and neither is state the game depends on: the format is explicit that an interpreter must not rely on the text chunks being present (7.6), and IntD holds what one interpreter needs and others need not understand.

CompareFiles drops these chunks before comparing anything, so that ignoring a chunk one file carries and the other does not lines up the chunks that follow it rather than reporting every one of them as displaced.

func IgnoreInterpreterHeader

func IgnoreInterpreterHeader() CompareOption

IgnoreInterpreterHeader disregards the fields of the Z-machine header that the interpreter writes rather than the game.

This is the option a cross-interpreter comparison almost certainly wants, and the reason is a detail of where the header lives. Dynamic memory runs from address zero, so the whole 64-byte header sits inside it and is saved with it — including every field the interpreter filled in for itself. Two interpreters will differ on the interpreter number and version, the screen size in lines and columns and again in units, the font size, the default colours, the width of text sent to output stream 3, and the standard revision they claim. None of that describes the saved position. Without this option those fields are the first dozen differences reported between any two interpreters, every time, and they bury the one difference that was worth looking for.

The ranges disregarded are $01, $10 to $11, $1E to $27, $2C to $2D, and $30 to $33. That set is the union across Versions, not the set that applies to the Version of the save being compared: Standard 11.1 introduces $1E to $21 at Version 4, $22 to $27 and $2C to $2D at Version 5, and $30 to $31 at Version 6. On a save below Version 4 those ten bytes are ordinary dynamic memory rather than fields the interpreter owns, and this option disregards them anyway. In practice they are zero on both sides of such a comparison, since neither the game nor the interpreter writes them, so nothing observable is hidden; the option stays declarative rather than making its effect depend on the values it is comparing.

Two of those ranges are wider than they strictly need to be. Flags 1 and Flags 2 each mix bits the game sets with bits the interpreter sets, and disregarding an address is byte-granular, so a game-written flag in the same byte as an interpreter-written one goes uncompared with it. Reporting those bits would mean reporting the interpreter's bits alongside them, which is the noise this exists to remove; a caller that needs one of them can compare Memory.Data itself.

func IgnoreMemoryEncoding

func IgnoreMemoryEncoding() CompareOption

IgnoreMemoryEncoding disregards whether dynamic memory was stored compressed or uncompressed.

The encoding is a writer's choice and not part of the state a save records: the same memory stored either way restores the same game, which is what §18.1 means by round-tripping semantically rather than byte for byte. Two interpreters comparing notes about a saved position almost never mean to compare this, and one of them writing UMem while the other writes CMem is the most likely single difference between any two conforming writers.

func IgnoreMemoryRange

func IgnoreMemoryRange(start, end int) CompareOption

IgnoreMemoryRange disregards dynamic memory from start up to but not including end.

A range that is empty or inverted disregards nothing, and one reaching outside dynamic memory disregards only the part that lies inside, so no combination of bounds is an error. Ranges accumulate: passing the option twice disregards both.

An ignored address also ends whatever run of differing bytes precedes it, so disregarding a range in the middle of a difference splits the DiffMemoryBytes that would otherwise have covered it into two.

type Difference

type Difference struct {
	// Kind is what differs.
	Kind DifferenceKind

	// Frame is the index of the stack frame the difference belongs to,
	// counting from zero at the oldest frame, or noIndex for a difference
	// outside the call stack.
	Frame int

	// Offset locates the difference within what Kind names: the byte address
	// in dynamic memory, the index of a local variable or evaluation-stack
	// word, or the position of a chunk. It is negative where the kind names a
	// whole value rather than a position within one.
	Offset int

	// ID names the chunk a chunk difference belongs to. It is the zero ID
	// where the difference is not about a chunk, and also on the
	// DiffChunkCount that CompareFiles reports for the total number of
	// chunks — four zero bytes are not a valid chunk identifier, so the zero
	// value is unambiguous.
	ID ID

	// A and B are the differing values, from the first and second argument
	// respectively. See the type table above.
	A, B any
}

Difference is one way in which two saves, or two containers, differ.

A and B hold the differing values from the first and second argument to Compare, in that order. Both are always set: a value present on one side and absent on the other is reported as a difference in a count — a chunk identifier that appears twice on one side and not at all on the other is DiffChunkCount with A of 2 and B of 0 — so neither field is ever nil to mean absence, and a caller need not distinguish absent from zero.

The type they hold follows from Kind:

DiffRelease, DiffChecksum                        uint16
DiffSerial                                       Serial
DiffProgramCounter, DiffReturnPC                 uint32
DiffHeaderExtra, DiffMemoryBytes, DiffChunkData  []byte
DiffMemoryEncoding                               MemoryEncoding
DiffDiscardResult                                bool
DiffResultVariable, DiffArguments                byte
DiffLocalValue, DiffEvaluationValue              uint16
DiffChunkID                                      ID
every count and size                             int

Any []byte is a copy. Nothing a Difference holds aliases the saves it came from, so a caller may keep the result and mutate the inputs.

func Compare

func Compare(a, b *Save, opts ...CompareOption) []Difference

Compare reports every way in which two saves differ, in a fixed order: story identification, dynamic memory, the call stack, then the remaining chunks. An empty result means the two agree under the options given.

The saves need not belong to the same story. Comparing saves of two different stories is how a caller finds out that is what it has, and the release, serial, and checksum differences say so; no story image is needed to answer the question, and none is asked for.

Differences are reported at the finest granularity that stays readable. Frames are compared oldest first, which is the order they are stored in and the order in which two stacks of different depths share a prefix, so a difference deep in a stack is reported against the frame it belongs to rather than displacing every frame after it. Runs of differing memory bytes are coalesced, so memory that differs everywhere reports one difference rather than thousands.

One byte is deliberately not compared. A frame that discards its result gives no meaning to its result variable, and this package zeroes that byte on write while preserving whatever it read (D16), so two saves of one position can disagree there without describing different states. The variable is compared only when neither frame discards its result; when they disagree about discarding, DiffDiscardResult reports that instead.

A nil save is compared as an empty one, which reports a difference against every field the other holds rather than panicking. Neither save is modified, and nothing in the result aliases either of them.

func CompareFiles

func CompareFiles(a, b *File, opts ...CompareOption) []Difference

CompareFiles reports every way in which two containers differ, comparing chunks by position: how many there are, then the identifier and payload of each.

Where Compare asks whether two saves record the same state, this asks whether two files say it the same way. Chunk order is a difference here and not there, because order is what a container has and a save does not: Quetzal requires IFhd before the memory and stack chunks, and a File is the layer that can still see whether a writer obeyed. Nothing is interpreted, so no story is needed and a container that is not a valid save compares as readily as one that is.

Only IgnoreChunks has any effect. The memory options describe dynamic memory, and a container holds an encoded payload rather than dynamic memory — a CMem payload compared against a UMem payload differs along its whole length, and no range of addresses within either one means what the option's argument would suggest. Compare is the layer where those options belong.

A nil file is compared as an empty one. Neither file is modified, and nothing in the result aliases either of them.

func (Difference) String

func (d Difference) String() string

String describes the difference in one line, in the form "what: A vs B".

It is meant to be printed by a failing test, so it favors being readable over being parsed: values appear in whichever base the format stores them in, and a long run of differing memory is abbreviated. A caller that wants the values themselves takes them from A and B.

type DifferenceKind

type DifferenceKind uint8

DifferenceKind names what differs, and with it the type held in Difference.A and Difference.B. The table in Difference gives that type for each kind.

const (
	// Differences in the IFhd chunk.
	DiffRelease        DifferenceKind = iota + 1 // release number
	DiffSerial                                   // serial number
	DiffChecksum                                 // story checksum
	DiffProgramCounter                           // saved program counter
	DiffHeaderExtra                              // bytes beyond the 13 Quetzal defines

	// Differences in dynamic memory.
	DiffMemoryEncoding // stored as CMem on one side and UMem on the other
	DiffMemorySize     // the two hold different amounts of dynamic memory
	DiffMemoryBytes    // a run of bytes that differ

	// Differences in the call stack.
	DiffFrameCount      // the stacks are of different depths
	DiffReturnPC        // a frame returns to a different address
	DiffDiscardResult   // a frame's p bit differs
	DiffResultVariable  // a frame stores its result in a different variable
	DiffArguments       // a frame's argument-supplied mask differs
	DiffLocalCount      // a frame has a different number of locals
	DiffLocalValue      // a frame holds a different value in one local
	DiffEvaluationDepth // a frame has a different amount of evaluation stack
	DiffEvaluationValue // a frame holds a different value in one stack word

	// Differences in the remaining chunks.
	DiffChunkCount // a different number of chunks, of one identifier or in total
	DiffChunkID    // a different chunk identifier at the same position
	DiffChunkData  // a chunk with a different payload
)

The kinds of difference Compare and CompareFiles report. They are grouped as a save is: story identification, dynamic memory, the call stack, and the remaining chunks.

func (DifferenceKind) String

func (k DifferenceKind) String() string

String names the kind of difference. The name is a noun phrase, so that it reads as the subject of a sentence a caller is building for itself; Difference itself has a String that produces the whole sentence.

type File

type File struct {
	// Chunks holds the FORM's contents in file order.
	Chunks []Chunk
	// contains filtered or unexported fields
}

File is the raw IFF container of a Quetzal save: every chunk, in the order it appeared, with payloads exactly as stored.

A File is deliberately uninterpreted. It reports what the container holds, including chunks this package assigns no meaning to, and makes no claim that the save is complete or restorable. Use Read to reconstruct saved state.

func Decode

func Decode(r io.Reader, opts ...ReadOption) (*File, error)

Decode parses the IFF container of a Quetzal save without reconstructing saved state, and therefore without needing the story image.

Decode verifies that the input is a FORM of type IFZS, that every chunk lies within the FORM, and that odd-length chunks carry their pad byte. It does not check that the chunks required by Quetzal are present, nor interpret any payload; that is Read's work. Chunks this package assigns no meaning to are retained rather than treated as errors, up to Limits.MaxUnknownBytes in total.

Decode stops at the end of the FORM. Any bytes following it are neither consumed nor examined, since a simple IFF file is a single FORM chunk.

The returned File owns its payloads; they are not aliases of any buffer supplied by the caller.

func (*File) All

func (f *File) All(id ID) []Chunk

All returns every chunk with the given identifier, in file order. Repeated chunks are legal in IFF; ANNO in particular may appear more than once.

For chunks Quetzal allows only one of, this is also how a caller sees that a file broke the rule. Such a duplicate is not an error and does not reach a Save, so a caller wanting to report one — a save-file inspector, or a server logging what it was handed — asks here, before or instead of calling Save.

func (*File) Annotations

func (f *File) Annotations() []string

Annotations returns the text of every ANNO chunk, in file order.

Multiple annotations are legal and mean exactly that: several separate remarks, not one split across chunks. An interpreter might record the score and turn count, or its own name and version; the content is entirely up to whoever wrote the file.

func (*File) Author

func (f *File) Author() (string, bool)

Author returns the text of the AUTH chunk, which names whoever created the file — on a multi-user system, often just a login name.

func (*File) Copyright

func (f *File) Copyright() (string, bool)

Copyright returns the text of the "(c) " chunk: a copyright date and holder, without the symbol itself, which a caller displaying the text supplies. The format notes this is unlikely to be useful on a save file.

func (*File) First

func (f *File) First(id ID) (Chunk, bool)

First returns the first chunk with the given identifier.

Where Quetzal expects a single instance of a chunk, the first is authoritative and later instances are ignored. Ignored here means only that nothing is decoded from them: a File keeps every chunk it read, so All reports the duplicates this returns one of.

func (*File) Frames

func (f *File) Frames() ([]Frame, error)

Frames decodes the save's call stack from its Stks chunk, oldest frame first. The limits the file was decoded under bound what it may allocate.

func (*File) Header

func (f *File) Header() (Header, error)

Header returns the save's story identification, decoded from the first IFhd chunk in the file. Later IFhd chunks, if any, are ignored.

func (*File) Memory

func (f *File) Memory(story Story) (Memory, error)

Memory reconstructs the save's dynamic memory from its CMem or UMem chunk.

Because compressed memory is a difference against the story it came from, decoding it against the wrong story would yield plausible nonsense rather than an error. Memory therefore verifies the save's IFhd against the story first, and reports ErrStoryMismatch if they disagree.

A file holding both a CMem and a UMem chunk is rejected. The two are competing statements of the same state, and Quetzal gives no rule for choosing between them.

func (*File) Save

func (f *File) Save(story Story) (*Save, error)

Save reconstructs the saved state held in an already-decoded file, which is what Read does after decoding.

The file must hold the chunks Quetzal requires, its IFhd must come before its memory and stack chunks, and the resulting save must be valid for the given story.

func (*File) WriteTo

func (f *File) WriteTo(w io.Writer) (int64, error)

WriteTo writes the file as a FORM of type IFZS, computing every length and supplying the pad byte that follows an odd-length chunk. It implements io.WriterTo and returns the number of bytes written.

The chunks are written in the order the File holds them. WriteTo makes no claim that they are the chunks Quetzal requires, or that they are in the order it requires; it writes the container it is given. Save.Encode is what puts a save's chunks in a conforming order.

type Frame

type Frame struct {
	// ReturnPC is the byte address in the story file that the call returns
	// to. Like every Quetzal program counter it is stored in three bytes,
	// so it is never greater than MaxPC.
	ReturnPC uint32

	// DiscardResult reports the p bit: the call was made by one of the
	// CALL_xN instructions and throws its result away. ResultVariable then
	// carries no meaning, and is written as zero.
	DiscardResult bool

	// ResultVariable is the number of the variable the call's result is
	// stored in.
	ResultVariable byte

	// Arguments is the argument-supplied mask, 0gfedcba: bit 0 is set if
	// the first argument was supplied, bit 1 if the second was, and so on
	// through the seven arguments a routine can take.
	//
	// All seven bits are preserved. The eighth is undefined, is masked
	// away when reading, and cannot be written: Validate rejects it, since
	// a caller that sets it is asking for something the format cannot
	// express rather than presenting a file that has to be dealt with.
	Arguments uint8

	// Locals holds the routine's local variables in order, so that
	// Locals[0] is local 1. A routine has at most MaxLocals of them.
	Locals []uint16

	// Evaluation is the part of the evaluation stack this call used, in
	// file order: Evaluation[0] is the least recent word and
	// Evaluation[len(Evaluation)-1] is the top of the stack.
	Evaluation []uint16
}

Frame is one Z-machine call frame as Quetzal records it.

Frames are always handled oldest first, the order the file stores them in, so the last frame of a save is the call that was executing when the game was saved. The first is the dummy frame on every version but 6; see IsDummy.

func DecodeStks

func DecodeStks(payload []byte, limits Limits) ([]Frame, error)

DecodeStks decodes the payload of a Stks chunk into frames, oldest first.

The dummy frame that versions other than 6 require is returned like any other frame rather than being recognized or removed; see Frame.IsDummy and ValidateFrames.

Bits the frame header leaves undefined — the top three of the flags byte and the top bit of the arguments byte — are ignored rather than treated as errors, so a frame is never rejected for a bit that carries no meaning.

Zero-valued fields of limits take their defaults. The payload is neither retained nor modified.

func (Frame) IsDummy

func (f Frame) IsDummy() bool

IsDummy reports whether this is the dummy frame that Quetzal requires as the first frame of a save for any Z-machine version other than 6.

Execution in those versions begins at an address rather than at a routine, so words can be pushed on the evaluation stack while nothing is on the call stack. The dummy frame is where they live: every field is zero except the evaluation stack, which may itself be empty.

func (Frame) Validate

func (f Frame) Validate() error

Validate reports whether the frame can be represented in Quetzal: the return program counter must fit in three bytes, the locals in the four bits that count them, the evaluation stack in the word that counts it, and the arguments mask in the seven bits the format defines.

Every one of these is a check on a frame a caller built. A frame that came from DecodeStks cannot fail any of them, since each field is read from a place too small to hold a value out of range.

type FrameError

type FrameError struct {
	Index int

	// Err is the underlying problem, and wraps one of the package sentinels.
	Err error
}

FrameError identifies which frame of a Stks chunk an error belongs to. Index counts from zero at the oldest frame, the order frames are stored in.

func (*FrameError) Error

func (e *FrameError) Error() string

Error implements the error interface.

func (*FrameError) Unwrap

func (e *FrameError) Unwrap() error

Unwrap returns the underlying error so that errors.Is and errors.As reach the sentinel it wraps.

type Header struct {
	// Release, Serial, and Checksum identify the story, and are the values
	// the saving interpreter read from offsets $2, $12, and $1C of its
	// header. Identity groups the three.
	//
	// Checksum is not necessarily what stands at $1C of the story image a
	// caller holds. A story written before that field came into use carries
	// zero there and the format requires the value to be computed from the
	// image instead, so a save of such a story records a checksum its story
	// file does not contain. Compare with Matches or Verify rather than
	// against the image, and see Story.ChecksumComputed.
	Release  uint16
	Serial   Serial
	Checksum uint16

	// PC is the saved program counter, held in three bytes on disk and so
	// never greater than MaxPC.
	//
	// What it points at depends on the Z-machine version. On versions 3 and
	// below it addresses the branch data of the SAVE instruction; on
	// versions 4 and above it addresses the byte describing where SAVE
	// stores its result.
	PC uint32

	// Extra holds any bytes beyond the 13 this version of Quetzal defines.
	// The standard anticipates a larger IFhd in a future revision and
	// guarantees that the first 13 bytes will keep their meaning, so such
	// bytes are preserved rather than rejected or discarded.
	Extra []byte
}

Header is the content of a save's IFhd chunk: the identity of the story the save belongs to, and the program counter at which it was saved.

func ParseHeader

func ParseHeader(data []byte) (Header, error)

ParseHeader decodes the payload of an IFhd chunk.

A payload longer than 13 bytes is accepted and its remainder kept in Header.Extra, because the standard reserves the right to extend IFhd while preserving the meaning of these first 13 bytes.

func (Header) Encode

func (h Header) Encode() ([]byte, error)

Encode returns the payload of an IFhd chunk describing the header. Any bytes in Extra are appended, so a header read from a longer IFhd re-encodes to the same payload.

func (Header) Identity

func (h Header) Identity() Identity

Identity returns the story identity recorded in the save.

func (Header) Matches

func (h Header) Matches(story Story) bool

Matches reports whether the save belongs to the given story, comparing release number, serial number, and checksum.

A story predating checksums carries zero at offset $1C. Quetzal expects a checksum computed from the story image to be saved in that case, so a save written that way will not match a Story parsed straight from such an image.

func (Header) Validate

func (h Header) Validate() error

Validate reports whether the header can be represented in Quetzal, which at this layer means only that the program counter fits in the three bytes the format gives it.

It says nothing about whether the header describes any particular story. That is Verify's question, and it needs one.

func (Header) Verify

func (h Header) Verify(story Story) error

Verify reports whether the save belongs to the given story, describing the difference when it does not. The returned error matches ErrStoryMismatch and can be inspected with errors.As as a *StoryMismatchError.

type ID

type ID [4]byte

ID is an IFF chunk identifier: four ASCII characters in the range 0x20 to 0x7E, compared as a simple four-byte equality test and therefore case-sensitive.

func (ID) String

func (id ID) String() string

String renders the identifier as its four characters. Identifiers holding bytes outside the printable ASCII range are quoted instead, so that error messages about malformed input stay readable.

type Identity

type Identity struct {
	Release  uint16
	Serial   Serial
	Checksum uint16
}

Identity is the triple that identifies the story a save belongs to: the values at offsets $2, $12, and $1C of the Z-machine header. Interpreters compare these and refuse to restore a save whose identity differs.

func (Identity) String

func (i Identity) String() string

String describes the identity in a form suitable for error messages.

type InterpreterData

type InterpreterData struct {
	// OperatingSystem names the system the data belongs to. Four spaces
	// mean the data is useful to every port of one interpreter.
	OperatingSystem ID

	// Flags is the flags byte, 000000sc. Prefer PositionSpecific,
	// MachineSpecific, and Copyable to testing its bits.
	Flags byte

	// ContentsID says what the data is, within the scope of the operating
	// system and interpreter that defined it.
	ContentsID byte

	// Interpreter names the interpreter the data belongs to. Four spaces
	// mean the data is useful to every interpreter on the named system.
	Interpreter ID

	// Data is the interpreter's own payload, exactly as stored.
	Data []byte
}

InterpreterData is the fixed header of an IntD chunk, the place Quetzal reserves for information one interpreter needs and others need not understand.

Data is opaque. This package assigns no meaning to it, because its meaning belongs to whoever defined it: the interpreter named by Interpreter, running on the system named by OperatingSystem. The reserved word the format places before the interpreter identifier is not represented, since it carries no information.

func ParseInterpreterData

func ParseInterpreterData(payload []byte) (InterpreterData, error)

ParseInterpreterData decodes the fixed header of an IntD payload, leaving the remainder opaque.

The returned value owns its data; the payload is neither retained nor modified.

func (InterpreterData) Copyable

func (d InterpreterData) Copyable() bool

Copyable reports whether this chunk may be carried from one save into another.

The format forbids copying position-specific contents outright, and forbids copying machine-specific contents onto a different system. This package has no notion of what system it is running on and so cannot tell a different one from the original, which makes the machine-specific case indistinguishable from the forbidden one. Copyable therefore answers no to both. A caller that does know its own system can recover such a chunk from the decoded File and carry it forward deliberately.

func (InterpreterData) MachineSpecific

func (d InterpreterData) MachineSpecific() bool

MachineSpecific reports the s flag: the contents, such as a filename or a file reference, are meaningful only on the machine or network the save was made on.

func (InterpreterData) PositionSpecific

func (d InterpreterData) PositionSpecific() bool

PositionSpecific reports the c flag: the contents describe this saved position and no other, so they must not be carried into another save.

type Limits

type Limits struct {
	// MaxFormBytes bounds the declared length of the outer FORM chunk, and
	// with it the total size of the decoded file.
	MaxFormBytes uint64

	// MaxChunkBytes bounds the declared length of any single chunk.
	MaxChunkBytes uint64

	// MaxUnknownBytes bounds the combined payload of the chunks this package
	// assigns no meaning to, which it retains whole rather than discarding.
	// Those are the only chunks whose size nothing but the file itself
	// constrains: every chunk this package understands is bounded by what it
	// can validly contain. Reaching this limit is reported against the chunk
	// that crossed it, before its payload is allocated.
	//
	// Real saves come nowhere near the default. Bocfel's scrollback chunk,
	// the largest unknown chunk seen in practice, is under 3 KB.
	MaxUnknownBytes uint64

	// MaxFrames bounds the number of stack frames read from Stks.
	MaxFrames int

	// MaxStackWords bounds the total number of evaluation-stack and local
	// variable words read from Stks.
	MaxStackWords int
}

Limits bounds the resources a decode may consume. Because every length in a Quetzal file is supplied by the file itself, these limits are what stop a malformed or hostile input from forcing an unreasonable allocation.

A zero field means "use the default for that field", so a caller may set only the limits it cares about. Callers processing trusted historical saves normally need no configuration at all.

func DefaultLimits

func DefaultLimits() Limits

DefaultLimits returns the limits used when a caller supplies none.

type Memory

type Memory struct {
	Encoding MemoryEncoding
	Data     []byte
}

Memory is a save's dynamic memory.

Data is the dynamic memory itself, however the save happened to store it: a CMem payload is expanded against the story before it reaches this type. Its length always equals the length of the story's dynamic memory.

Encoding records how the save held that memory. Reading it describes the file that was read; setting it chooses how the memory will be written.

func (Memory) Encode

func (m Memory) Encode(story Story) (Chunk, error)

Encode returns the chunk that stores this dynamic memory, in the encoding the Memory names. The story supplies the original dynamic memory that MemoryCompressed is a difference against.

The returned chunk owns its payload and neither aliases nor modifies the memory or the story.

func (Memory) Validate

func (m Memory) Validate(story Story) error

Validate reports whether the memory can be written for the given story: the encoding must be one Quetzal defines, and the data must be exactly as long as the story's dynamic memory.

The length is not a formality. Dynamic memory runs from address zero to the base of static memory, which the story header fixes, so memory of any other length does not describe this story — and a compressed difference against it would decode without complaint into something that never existed.

type MemoryEncoding

type MemoryEncoding uint8

MemoryEncoding identifies the way a save stores dynamic memory. Quetzal defines two, and a reader must understand both.

const (
	// MemoryCompressed is the CMem encoding: dynamic memory exclusive-ored
	// against the story's original dynamic memory, with runs of unchanged
	// bytes collapsed. Reconstructing it requires the original story.
	MemoryCompressed MemoryEncoding = iota + 1

	// MemoryUncompressed is the UMem encoding: dynamic memory dumped
	// unchanged. It is larger but needs no story to read.
	MemoryUncompressed
)

func (MemoryEncoding) String

func (e MemoryEncoding) String() string

String names the encoding by the chunk that carries it.

type ReadOption

type ReadOption func(*readConfig)

ReadOption configures a decode.

func IgnoreChunkOrder

func IgnoreChunkOrder() ReadOption

IgnoreChunkOrder accepts a save whose IFhd chunk does not come before its memory and stack chunks.

The format requires that order, so that an interpreter learns it has the wrong story before decoding anything against it, and this package enforces it by default. Frotz does not: it restores a save whose IFhd comes last without complaint. A file written by some interpreter that gets the order wrong would therefore work elsewhere and fail here, and this option is the way to accept it anyway.

Nothing else is relaxed. The identity check still happens before memory is rebuilt — it is the ordering of the chunks in the file that is overlooked, not the verification they exist for.

func WithLimits

func WithLimits(l Limits) ReadOption

WithLimits sets the resource limits for a decode. Zero-valued fields keep their defaults, so a caller may override only the limits it cares about.

type Save

type Save struct {
	// Header is the story identification and saved program counter from the
	// IFhd chunk.
	Header Header

	// Memory is the dynamic memory the save recorded, already expanded, and
	// the encoding it was stored in.
	Memory Memory

	// Frames is the call stack, oldest frame first. On every Z-machine
	// version except 6 the first frame is the dummy frame that holds
	// top-level evaluation-stack state.
	Frames []Frame

	// Chunks holds the file's remaining chunks in their original relative
	// order: annotations, author and copyright text, interpreter data, and
	// chunks this package assigns no meaning to. Writing a Save writes them
	// after the three chunks the fields above describe.
	//
	// It never holds an IFhd, CMem, UMem, or Stks chunk. Those are
	// represented by the fields above, and a second copy of one would
	// contradict them.
	Chunks []Chunk
}

Save is a Quetzal saved game with its state reconstructed: the story it belongs to, the dynamic memory it recorded, and the call stack it was suspended on.

A Save is what an interpreter needs in order to restore. It is the interpreted form of a File, and unlike a File it makes the claim that the save is complete and consistent with the story it names.

func Read

func Read(r io.Reader, story Story, opts ...ReadOption) (*Save, error)

Read reads a Quetzal save and reconstructs the state it holds.

The story is required. Dynamic memory is usually stored as a difference against the story it came from and cannot be rebuilt without it, and the story's version decides what the call stack must contain. Read verifies that the save belongs to the story before rebuilding anything, and reports ErrStoryMismatch if it does not.

Use Decode instead to examine a save's structure without a story.

The returned Save owns its data. Neither the reader's bytes nor the story are retained or modified.

func (*Save) Annotations

func (s *Save) Annotations() []string

Annotations returns the text of every ANNO chunk, in file order.

Multiple annotations are legal and mean exactly that: several separate remarks, not one split across chunks. An interpreter might record the score and turn count, or its own name and version; the content is entirely up to whoever wrote the file.

func (*Save) Author

func (s *Save) Author() (string, bool)

Author returns the text of the AUTH chunk, which names whoever created the file — on a multi-user system, often just a login name.

func (*Save) Copyright

func (s *Save) Copyright() (string, bool)

Copyright returns the text of the "(c) " chunk: a copyright date and holder, without the symbol itself, which a caller displaying the text supplies. The format notes this is unlikely to be useful on a save file.

func (*Save) Encode

func (s *Save) Encode(story Story, opts ...WriteOption) (*File, error)

Encode builds the IFF container that represents the save, in the order Quetzal requires: IFhd, then dynamic memory, then Stks, then whatever additional chunks the save carries, in the order it carries them.

The story supplies the original dynamic memory that compressed memory is a difference against, and the version that decides what the call stack must contain.

The returned File owns its payloads. Neither the save nor the story is retained or modified.

func (*Save) Validate

func (s *Save) Validate(story Story) error

Validate reports whether the save is complete, representable in Quetzal, and consistent with the given story.

It checks story identity, the size and encoding of dynamic memory, the representability of every program counter, the local and evaluation-stack counts and argument mask of every frame, the dummy frame that versions other than 6 require, and the additional chunks.

Validation is available on its own so that a caller can find out whether a save it has assembled is sound without producing a file. Write performs the same checks.

type Serial

type Serial [6]byte

Serial is a story's six-byte serial number.

It is held as raw bytes. Although Infocom serial numbers are conventionally a date in YYMMDD form, that is not guaranteed, so this package never parses one or assumes it is a date or an integer.

func (Serial) String

func (s Serial) String() string

String renders the serial as its six characters, quoting it instead if it holds bytes outside the printable ASCII range.

type Story

type Story struct {
	// Version is the Z-machine version the story runs on.
	Version uint8

	// Release, Serial, and Checksum are the values at offsets $2, $12, and
	// $1C of the story header, which together identify the story.
	//
	// Checksum is not always the value stored at $1C. Games written before
	// the field was used carry zero there, and the format requires the
	// checksum to be computed from the story image in that case, so that a
	// save records an identity other interpreters agree with. See
	// ChecksumComputed and StoryChecksum.
	Release  uint16
	Serial   Serial
	Checksum uint16

	// ChecksumComputed reports that Checksum was computed from the story
	// image because the header carried none, rather than read from $1C.
	//
	// It is worth logging. A story that needs a computed checksum is old
	// enough that little else about it has been tested, and a save that
	// fails to match one is the first place to look.
	ChecksumComputed bool

	// DynamicMemory is the story's original dynamic memory: bytes 0 through
	// the base of static memory, exclusive. Its length is the length any
	// restored dynamic memory must have.
	DynamicMemory []byte
}

Story carries the information about an original story image that Quetzal operations require. It is not a Z-machine and holds no execution state.

A Story is needed because compressed memory (CMem) is stored as an XOR difference against the story's original dynamic memory and cannot be reconstructed without it. This package never looks for a story file itself; callers supply one.

func ParseStory

func ParseStory(data []byte) (Story, error)

ParseStory extracts the information Quetzal needs from a complete Z-machine story image, reading the header fields that identify the story and taking the extent of dynamic memory from the base of static memory.

The returned Story owns its dynamic memory. The caller's buffer is read but neither retained nor modified, so it remains safe to reuse or mutate.

func (Story) Identity

func (s Story) Identity() Identity

Identity returns the triple that identifies this story.

type StoryMismatchError

type StoryMismatchError struct {
	// Save is the identity recorded in the save's IFhd chunk.
	Save Identity

	// Story is the identity of the story image supplied by the caller.
	Story Identity
}

StoryMismatchError reports that a save does not belong to the story it was checked against, and carries both identities so a caller can say how they differ.

func (*StoryMismatchError) Error

func (e *StoryMismatchError) Error() string

Error implements the error interface.

func (*StoryMismatchError) Unwrap

func (e *StoryMismatchError) Unwrap() error

Unwrap reports this error as an ErrStoryMismatch for errors.Is.

type WriteOption

type WriteOption func(*writeConfig)

WriteOption configures a write.

func WithEncoding

func WithEncoding(e MemoryEncoding) WriteOption

WithEncoding chooses how dynamic memory is stored, overriding the encoding the Save carries. It is how a caller converts between the two encodings without disturbing the save itself.

Compressed memory is much smaller and is what interpreters normally write. Uncompressed memory can be read back without the story.

Jump to

Keyboard shortcuts

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