@frotzinc/zmachine 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,21 @@
1
+ # Changelog
2
+
3
+ All notable changes to `@frotzinc/zmachine`. Versions follow [Semantic Versioning](https://semver.org/). Part of [Frotz, Inc.](https://frotzinc.com).
4
+
5
+ ## [0.1.0] - 2026-11-07
6
+
7
+ The first release: the interpreter behind frotzplay. (On npm once published.)
8
+
9
+ ### Added
10
+ - **Saves (Phase 2, [docs/12](../../docs/12-frotzplay-phase-2-state.md)):** `@save` and `@restore` stop for the host (`save` with a Quetzal 1.4 file, `restore`), finished with `resume({ saved })` / `resume({ restore })`. Saves are byte-compatible with ZVM's; a bad file is a `bad-save` notice in words and a failed restore. `readQuetzal`/`writeQuetzal` exported. `play()` takes `files`.
11
+ - **Snapshots:** `machine.snapshot()` and `Machine.fromSnapshot(story, snapshot, options)`, the whole machine as plain data.
12
+ - **Conformance:** Czech 0.8 (v3/4/5/8) and Praxix, compiled at test time, pass and match ZVM.
13
+ - `header.standard` (default 1.1).
14
+
15
+ ### Fixed
16
+ - `@print_char 0` prints nothing in any stream (§3.8.2.1); it was written into stream 3.
17
+ - The interpreter (frotzplay Phase 1, [docs/11](../../docs/11-frotzplay-phase-1-interpreter.md)): every opcode of versions 3, 4, 5 and 8, run from `@frotzinc/zcode`'s decoded instructions; the §8 screen model as data; line, character and timed input that suspends the machine; ZVM-compatible seeded random; undo; hooks on writes, calls, returns, branches, prints and input; faults instead of exceptions.
18
+ - `play(story, commands, options)` and the `zmachine-run` command: normalized transcripts, turn by turn.
19
+ - Differential tests against ZVM: every sample, every version built (v3, v4, v5, v8), played through with its walkthrough under two seeds, gives the same transcript; the same header.
20
+ - Opcode tests with a small test assembler: every rule-6 item named and cited; every opcode of every version run (a coverage test).
21
+ - Command scripts: `[timeout]` (a timed read's interval ran out) and `[key x]` (a key for `@read_char`); timed input tested with a compiled Inform 6 fixture.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Christopher Schweda
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,33 @@
1
- # Temporary Holding Version
1
+ # @frotzinc/zmachine
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ A Z-machine interpreter for versions 3, 4, 5 and 8, in TypeScript. It runs decoded instructions one at a time (no `eval`, no JIT, so it runs under a strict Content-Security-Policy), keeps all its state as plain data, and stops whenever it needs input, so the host decides how input arrives. No DOM: it runs in a browser, a Web Worker, or Node. The interpreter behind [frotzplay](../../apps/play).
4
+
5
+ > **Status: in development** (frotzplay Phase 1). Not yet on npm.
6
+
7
+ ```ts
8
+ import { Machine } from '@frotzinc/zmachine'
9
+
10
+ const m = Machine.create(storyBytes, { seed: 1 })
11
+ if (!(m instanceof Machine)) throw new Error(m.fault.message)
12
+
13
+ let stop = m.run(100_000)
14
+ // stop.kind: 'input-line' | 'input-char' | 'budget' (call run again) | 'quit' | 'fault'
15
+ console.log(m.flushText())
16
+ m.resume({ line: 'look' })
17
+ stop = m.run(100_000)
18
+ ```
19
+
20
+ Or from a terminal: `zmachine-run story.z5 script.txt [--seed N]` prints each turn's text. A script is one command per line (`!` starts a comment); `[timeout]` means a timed read's interval ran out, and `[key x]` presses a key for `@read_char` (`[key]` is Return), so a recorded session with its timer events replays exactly.
21
+
22
+ ## Conformance
23
+
24
+ The Z-Machine Standards Document 1.1, versions 3, 4, 5 and 8, checked against [ZVM](https://github.com/curiousdannii/ifvms.js) (Parchment's engine) on every sample story: the same story, commands and seed give the same transcript. Deliberate choices:
25
+
26
+ - **Random numbers** follow ZVM, not the Standard's suggested "predictable" mode for small seeds (§2.4.1, a recommendation ZVM doesn't follow either): seeded play draws from a mulberry32 stream where ZVM would use `Math.random`, and `@random -n` switches to ZVM's own xorshift.
27
+ - **Version 6**, sound beyond beeps, and font 3 are not supported.
28
+
29
+ ## Standing on
30
+
31
+ The [Z-Machine Standards Document](https://inform-fiction.org/zmachine/standards/) (Graham Nelson, Andrew Hunter and contributors); [ZVM / ifvms.js](https://github.com/curiousdannii/ifvms.js) by Dannii Willis, the test oracle, whose xorshift `@random` is copied here with attribution (MIT).
32
+
33
+ MIT. From [Frotz, Inc.](https://frotzinc.com).
package/bin/run.mjs ADDED
@@ -0,0 +1,35 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Play a story from a command script and print each turn's normalized text:
4
+ * zmachine-run story.z5 script.txt [--seed N] [--zvm]
5
+ * The script is one command per line (or several separated by '.'); '!' lines are comments.
6
+ * --zvm writes the header exactly as ZVM does (for comparing with it).
7
+ */
8
+ import { readFileSync } from 'node:fs'
9
+ import { play } from '../dist/index.js'
10
+
11
+ const args = process.argv.slice(2)
12
+ const flag = name => {
13
+ const i = args.indexOf(name)
14
+ return i >= 0 ? args.splice(i, 2)[1] : undefined
15
+ }
16
+ const seed = Number(flag('--seed') ?? 1)
17
+ const zvm = args.includes('--zvm')
18
+ const [storyPath, scriptPath] = args.filter(a => !a.startsWith('--'))
19
+ if (!storyPath) {
20
+ console.error('usage: zmachine-run story.z5 [script.txt] [--seed N] [--zvm]')
21
+ process.exit(2)
22
+ }
23
+ const commands = scriptPath
24
+ ? readFileSync(scriptPath, 'utf8').split('\n').map(l => l.trim()).filter(l => l && !l.startsWith('!')).flatMap(l => l.split('.')).map(c => c.trim().replace(/^>\s*/, '')).filter(Boolean)
25
+ : []
26
+ const result = play(new Uint8Array(readFileSync(storyPath)), commands, { seed, header: zvm ? { profile: 'zvm' } : {} })
27
+ for (const t of result.turns) {
28
+ if (t.command) console.log(`> ${t.command}`)
29
+ console.log(t.output)
30
+ console.log()
31
+ }
32
+ if (result.stoppedBy && typeof result.stoppedBy === 'object') {
33
+ console.error(`stopped: ${result.stoppedBy.code}: ${result.stoppedBy.message}`)
34
+ process.exit(1)
35
+ }
@@ -0,0 +1,9 @@
1
+ import type { Fault, FaultCode } from './types.js';
2
+ /** Thrown inside the package only; run() turns it into a `fault` stop. Never escapes. */
3
+ export declare class MachineFault extends Error {
4
+ readonly code: FaultCode;
5
+ constructor(code: FaultCode, message: string);
6
+ toFault(pc: number): Fault;
7
+ }
8
+ export declare const fail: (code: FaultCode, message: string) => never;
9
+ export declare const hex: (n: number, width?: number) => string;
package/dist/fault.js ADDED
@@ -0,0 +1,16 @@
1
+ /** Thrown inside the package only; run() turns it into a `fault` stop. Never escapes. */
2
+ export class MachineFault extends Error {
3
+ code;
4
+ constructor(code, message) {
5
+ super(message);
6
+ this.code = code;
7
+ }
8
+ toFault(pc) {
9
+ return { code: this.code, message: this.message, pc };
10
+ }
11
+ }
12
+ export const fail = (code, message) => {
13
+ throw new MachineFault(code, message);
14
+ };
15
+ export const hex = (n, width = 4) => '0x' + n.toString(16).padStart(width, '0');
16
+ //# sourceMappingURL=fault.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"fault.js","sourceRoot":"","sources":["../src/fault.ts"],"names":[],"mappings":"AAEA,yFAAyF;AACzF,MAAM,OAAO,YAAa,SAAQ,KAAK;IAE1B;IADX,YACW,IAAe,EACxB,OAAe;QAEf,KAAK,CAAC,OAAO,CAAC,CAAA;QAHL,SAAI,GAAJ,IAAI,CAAW;IAI1B,CAAC;IAED,OAAO,CAAC,EAAU;QAChB,OAAO,EAAE,IAAI,EAAE,IAAI,CAAC,IAAI,EAAE,OAAO,EAAE,IAAI,CAAC,OAAO,EAAE,EAAE,EAAE,CAAA;IACvD,CAAC;CACF;AAED,MAAM,CAAC,MAAM,IAAI,GAAG,CAAC,IAAe,EAAE,OAAe,EAAS,EAAE;IAC9D,MAAM,IAAI,YAAY,CAAC,IAAI,EAAE,OAAO,CAAC,CAAA;AACvC,CAAC,CAAA;AAED,MAAM,CAAC,MAAM,GAAG,GAAG,CAAC,CAAS,EAAE,KAAK,GAAG,CAAC,EAAE,EAAE,CAAC,IAAI,GAAG,CAAC,CAAC,QAAQ,CAAC,EAAE,CAAC,CAAC,QAAQ,CAAC,KAAK,EAAE,GAAG,CAAC,CAAA"}
@@ -0,0 +1,11 @@
1
+ /**
2
+ * @frotzinc/zmachine: a Z-machine interpreter for versions 3, 4, 5 and 8, in TypeScript. It runs
3
+ * decoded instructions one at a time (no eval, no JIT), keeps all its state as plain data, and
4
+ * stops for the host to supply input. No DOM; runs in a browser, a worker, or Node.
5
+ */
6
+ export { Machine } from './machine.js';
7
+ export { play, type PlayOptions, type PlayResult, type Turn } from './play.js';
8
+ export { normalizeTurn } from './normalize.js';
9
+ export { readQuetzal, writeQuetzal, type QuetzalState } from './quetzal.js';
10
+ export { mulberry32, mulberry32Step, random } from './random.js';
11
+ export type * from './types.js';
package/dist/index.js ADDED
@@ -0,0 +1,11 @@
1
+ /**
2
+ * @frotzinc/zmachine: a Z-machine interpreter for versions 3, 4, 5 and 8, in TypeScript. It runs
3
+ * decoded instructions one at a time (no eval, no JIT), keeps all its state as plain data, and
4
+ * stops for the host to supply input. No DOM; runs in a browser, a worker, or Node.
5
+ */
6
+ export { Machine } from './machine.js';
7
+ export { play } from './play.js';
8
+ export { normalizeTurn } from './normalize.js';
9
+ export { readQuetzal, writeQuetzal } from './quetzal.js';
10
+ export { mulberry32, mulberry32Step, random } from './random.js';
11
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AACH,OAAO,EAAE,OAAO,EAAE,MAAM,cAAc,CAAA;AACtC,OAAO,EAAE,IAAI,EAAgD,MAAM,WAAW,CAAA;AAC9E,OAAO,EAAE,aAAa,EAAE,MAAM,gBAAgB,CAAA;AAC9C,OAAO,EAAE,WAAW,EAAE,YAAY,EAAqB,MAAM,cAAc,CAAA;AAC3E,OAAO,EAAE,UAAU,EAAE,cAAc,EAAE,MAAM,EAAE,MAAM,aAAa,CAAA"}
@@ -0,0 +1,109 @@
1
+ /**
2
+ * The Z-machine (Standard 1.1, versions 3, 4, 5 and 8): "apply this decoded Instruction to this
3
+ * state." zcode decodes; this executes, one instruction at a time, and stops for input, quit, a
4
+ * spent budget, or a fault. No DOM, no eval, no timers: the host drives it (docs/11).
5
+ *
6
+ * Section references (§) are to the Z-Machine Standards Document 1.1.
7
+ */
8
+ import { type Version } from '@frotzinc/zcode';
9
+ import type { Fault, InputValue, MachineOptions, MachineSnapshot, MachineState, StopReason } from './types.js';
10
+ export declare class Machine {
11
+ readonly state: MachineState;
12
+ readonly version: Version;
13
+ private readonly story;
14
+ private readonly mem;
15
+ private readonly obj;
16
+ private readonly screen;
17
+ private readonly text;
18
+ private readonly hooks;
19
+ private readonly options;
20
+ private readonly cache;
21
+ private readonly globals;
22
+ private readonly dictionary;
23
+ /** Set by an instruction that ends run(): a read, quit. */
24
+ private stop;
25
+ /** The instruction being executed (for faults). */
26
+ private current;
27
+ private constructor();
28
+ /** A machine for `story`, or the reason it can't run one. Never throws. */
29
+ static create(story: Uint8Array, options: MachineOptions): Machine | {
30
+ fault: Fault;
31
+ };
32
+ private textContext;
33
+ /** What the interpreter writes into the header, at start, restart and restore. */
34
+ private writeHeader;
35
+ /** Run until input is needed, the game quits, `budget` instructions have run, or a fault. */
36
+ run(budget: number): StopReason;
37
+ /** One instruction. Null when the machine can go on. */
38
+ step(): StopReason | null;
39
+ private toFault;
40
+ private decode;
41
+ private waiting;
42
+ /** The most a text buffer takes, and any text already in it (v5+, §15 read). */
43
+ private lineLimits;
44
+ /** Give the machine what it's waiting for: a line, a key, or the news that a timed read's interval ran out. */
45
+ resume(input: InputValue): Fault | null;
46
+ /** The read an interrupt routine is running for. */
47
+ private interrupted;
48
+ private get frame();
49
+ private push;
50
+ private pop;
51
+ private peek;
52
+ private readVar;
53
+ private writeVar;
54
+ /** Indirect variable references (§6.3.4): variable 0 is the top of the stack, read or written in place. */
55
+ private readVarInPlace;
56
+ private writeVarInPlace;
57
+ private unpackRoutine;
58
+ private unpackString;
59
+ private callRoutine;
60
+ private ret;
61
+ private branch;
62
+ private store;
63
+ /** ZSCII codes to the active output streams. */
64
+ private print;
65
+ private printString;
66
+ /** @print_unicode: the character itself on the screen; ZSCII (or '?') into stream 3. */
67
+ private printUnicode;
68
+ private printNum;
69
+ private printObject;
70
+ /** Lower-window text since the last call: what the Node runner prints for a turn. */
71
+ flushText(): string;
72
+ /** v3: the status line, from globals 0–2 (§8.2.2). */
73
+ private showStatus;
74
+ private read;
75
+ private finishLine;
76
+ private finishChar;
77
+ /** An interrupt routine for a timed read returned `v` (§10.2.4). */
78
+ private afterInterrupt;
79
+ /** The words of a text buffer, looked up in a dictionary, into a parse buffer (§13.6). */
80
+ private tokenise;
81
+ private readonly dictionaries;
82
+ private dictionaryAt;
83
+ private readDictionary;
84
+ private storyId;
85
+ /** The machine as a Quetzal file, with `at` as the save instruction's store or branch byte. */
86
+ private quetzal;
87
+ /**
88
+ * Finish a save or restore whose result is `value` (§15 save/restore): v4+ store it at the store
89
+ * byte `at`; v3 branch on it from the branch byte(s) at `at`.
90
+ */
91
+ private completeSave;
92
+ /** Restore a Quetzal file; the reason in words when it can't be. */
93
+ private restoreQuetzal;
94
+ /** The whole machine as plain data. */
95
+ snapshot(): MachineSnapshot;
96
+ /** A machine in exactly the state of `snapshot`, or why it can't be (another story's). */
97
+ static fromSnapshot(story: Uint8Array, snapshot: MachineSnapshot, options: MachineOptions): Machine | {
98
+ fault: Fault;
99
+ };
100
+ private saveUndo;
101
+ private restoreUndo;
102
+ private restart;
103
+ private verify;
104
+ private copyTable;
105
+ private scanTable;
106
+ private printTable;
107
+ private execute;
108
+ private outputStream;
109
+ }