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
}
Output:
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
}
}
Output:
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
}
Output:
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))
}
}
Output:
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
}
Output:
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
}
Output:
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
}
Output:
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)
}
}
Output:
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
}
Output:
Index ¶
- Constants
- Variables
- func DecodeCMem(payload, original []byte) ([]byte, error)
- func EncodeCMem(current, original []byte) ([]byte, error)
- func EncodeStks(frames []Frame) ([]byte, error)
- func StoryChecksum(data []byte) (checksum uint16, ok bool)
- func ValidateFrames(frames []Frame, story Story) error
- func Version() (pkg, spec string)
- func Write(w io.Writer, story Story, save *Save, opts ...WriteOption) error
- type Chunk
- type ChunkError
- type CompareOption
- type Difference
- type DifferenceKind
- type File
- func (f *File) All(id ID) []Chunk
- func (f *File) Annotations() []string
- func (f *File) Author() (string, bool)
- func (f *File) Copyright() (string, bool)
- func (f *File) First(id ID) (Chunk, bool)
- func (f *File) Frames() ([]Frame, error)
- func (f *File) Header() (Header, error)
- func (f *File) Memory(story Story) (Memory, error)
- func (f *File) Save(story Story) (*Save, error)
- func (f *File) WriteTo(w io.Writer) (int64, error)
- type Frame
- type FrameError
- type Header
- type ID
- type Identity
- type InterpreterData
- type Limits
- type Memory
- type MemoryEncoding
- type ReadOption
- type Save
- type Serial
- type Story
- type StoryMismatchError
- type WriteOption
Examples ¶
Constants ¶
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.
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.
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 ¶
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.
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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) Matches ¶
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 ¶
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.
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.
type Identity ¶
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.
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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.
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 ¶
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.
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.