@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 +21 -0
- package/LICENSE +21 -0
- package/README.md +32 -2
- package/bin/run.mjs +35 -0
- package/dist/fault.d.ts +9 -0
- package/dist/fault.js +16 -0
- package/dist/fault.js.map +1 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +11 -0
- package/dist/index.js.map +1 -0
- package/dist/machine.d.ts +109 -0
- package/dist/machine.js +1114 -0
- package/dist/machine.js.map +1 -0
- package/dist/memory.d.ts +16 -0
- package/dist/memory.js +47 -0
- package/dist/memory.js.map +1 -0
- package/dist/normalize.d.ts +7 -0
- package/dist/normalize.js +15 -0
- package/dist/normalize.js.map +1 -0
- package/dist/objects.d.ts +45 -0
- package/dist/objects.js +181 -0
- package/dist/objects.js.map +1 -0
- package/dist/play.d.ts +32 -0
- package/dist/play.js +81 -0
- package/dist/play.js.map +1 -0
- package/dist/quetzal.d.ts +23 -0
- package/dist/quetzal.js +151 -0
- package/dist/quetzal.js.map +1 -0
- package/dist/random.d.ts +24 -0
- package/dist/random.js +42 -0
- package/dist/random.js.map +1 -0
- package/dist/screen.d.ts +32 -0
- package/dist/screen.js +126 -0
- package/dist/screen.js.map +1 -0
- package/dist/text.d.ts +25 -0
- package/dist/text.js +81 -0
- package/dist/text.js.map +1 -0
- package/dist/types.d.ts +246 -0
- package/dist/types.js +6 -0
- package/dist/types.js.map +1 -0
- package/package.json +56 -3
package/dist/machine.js
ADDED
|
@@ -0,0 +1,1114 @@
|
|
|
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 { Bytes, Malformed, OutOfBounds, decodeInstruction, defaultExtra, readAlphabetTable, readUnicodeTable } from '@frotzinc/zcode';
|
|
9
|
+
import { MachineFault, fail, hex } from './fault.js';
|
|
10
|
+
import { Memory, signed, u16 } from './memory.js';
|
|
11
|
+
import { Objects } from './objects.js';
|
|
12
|
+
import { initialRandom, random } from './random.js';
|
|
13
|
+
import { Screen, initialScreen } from './screen.js';
|
|
14
|
+
import { encodeWord, inputToZscii, unicodeToZscii, zsciiAt, zsciiText } from './text.js';
|
|
15
|
+
import { readQuetzal, writeQuetzal } from './quetzal.js';
|
|
16
|
+
const MAX_FRAMES = 1024;
|
|
17
|
+
const MAX_EVAL_STACK = 0xffff;
|
|
18
|
+
const H = {
|
|
19
|
+
version: 0x00,
|
|
20
|
+
flags1: 0x01,
|
|
21
|
+
initialPc: 0x06,
|
|
22
|
+
dictionary: 0x08,
|
|
23
|
+
objects: 0x0a,
|
|
24
|
+
globals: 0x0c,
|
|
25
|
+
staticBase: 0x0e,
|
|
26
|
+
flags2: 0x10,
|
|
27
|
+
abbreviations: 0x18,
|
|
28
|
+
fileLength: 0x1a,
|
|
29
|
+
checksum: 0x1c,
|
|
30
|
+
interpreterNumber: 0x1e,
|
|
31
|
+
interpreterVersion: 0x1f,
|
|
32
|
+
screenHeight: 0x20,
|
|
33
|
+
screenWidth: 0x21,
|
|
34
|
+
screenWidthUnits: 0x22,
|
|
35
|
+
screenHeightUnits: 0x24,
|
|
36
|
+
fontWidth: 0x26,
|
|
37
|
+
fontHeight: 0x27,
|
|
38
|
+
terminators: 0x2e,
|
|
39
|
+
standard: 0x32,
|
|
40
|
+
alphabets: 0x34,
|
|
41
|
+
extension: 0x36,
|
|
42
|
+
};
|
|
43
|
+
export class Machine {
|
|
44
|
+
state;
|
|
45
|
+
version;
|
|
46
|
+
story;
|
|
47
|
+
mem;
|
|
48
|
+
obj;
|
|
49
|
+
screen;
|
|
50
|
+
text;
|
|
51
|
+
hooks;
|
|
52
|
+
options;
|
|
53
|
+
cache = new Map();
|
|
54
|
+
globals;
|
|
55
|
+
dictionary;
|
|
56
|
+
/** Set by an instruction that ends run(): a read, quit. */
|
|
57
|
+
stop = null;
|
|
58
|
+
/** The instruction being executed (for faults). */
|
|
59
|
+
current = 0;
|
|
60
|
+
constructor(story, options) {
|
|
61
|
+
// Our own copy of the original: restart and @verify read it, and a host may reuse its buffer.
|
|
62
|
+
this.story = story.slice();
|
|
63
|
+
this.options = options;
|
|
64
|
+
this.hooks = options.hooks;
|
|
65
|
+
this.version = story[0];
|
|
66
|
+
const memory = story.slice();
|
|
67
|
+
const staticBase = (memory[H.staticBase] << 8) | memory[H.staticBase + 1];
|
|
68
|
+
this.mem = new Memory(memory, staticBase, this.hooks);
|
|
69
|
+
this.globals = this.mem.word(H.globals);
|
|
70
|
+
this.dictionary = this.mem.word(H.dictionary);
|
|
71
|
+
this.obj = new Objects(this.mem, this.version, this.mem.word(H.objects));
|
|
72
|
+
this.text = this.textContext(memory);
|
|
73
|
+
const size = options.header?.screen ?? { width: 80, height: 25 };
|
|
74
|
+
this.state = {
|
|
75
|
+
memory,
|
|
76
|
+
pc: 0,
|
|
77
|
+
frames: [],
|
|
78
|
+
random: initialRandom(options.seed),
|
|
79
|
+
screen: initialScreen(size.width, size.height),
|
|
80
|
+
input: { pending: null },
|
|
81
|
+
streams: { screen: true, transcript: false, commands: false, memory: [] },
|
|
82
|
+
counters: { instructions: 0, turns: 0 },
|
|
83
|
+
undo: [],
|
|
84
|
+
};
|
|
85
|
+
this.screen = new Screen(this.state.screen);
|
|
86
|
+
this.restart(true);
|
|
87
|
+
}
|
|
88
|
+
/** A machine for `story`, or the reason it can't run one. Never throws. */
|
|
89
|
+
static create(story, options) {
|
|
90
|
+
const bad = (message) => ({ fault: { code: 'bad-story', message, pc: 0 } });
|
|
91
|
+
if (story.length < 64)
|
|
92
|
+
return bad(`The file is ${story.length} bytes, too short to be a story (a header alone is 64).`);
|
|
93
|
+
const v = story[0];
|
|
94
|
+
if (v === 6)
|
|
95
|
+
return bad('This is a version 6 story (with graphics); frotzplay plays versions 3, 4, 5 and 8.');
|
|
96
|
+
if (![3, 4, 5, 8].includes(v))
|
|
97
|
+
return bad(`This isn't a Z-machine story frotzplay can play: its version byte is ${v} (versions 3, 4, 5 and 8 are supported).`);
|
|
98
|
+
const staticBase = (story[H.staticBase] << 8) | story[H.staticBase + 1];
|
|
99
|
+
if (staticBase < 64 || staticBase > story.length)
|
|
100
|
+
return bad(`The story's header is damaged: static memory starts at ${hex(staticBase)}, outside the file.`);
|
|
101
|
+
try {
|
|
102
|
+
return new Machine(story, options);
|
|
103
|
+
}
|
|
104
|
+
catch (e) {
|
|
105
|
+
return bad(e instanceof Error ? `The story couldn't start: ${e.message}` : 'The story couldn’t start.');
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
textContext(memory) {
|
|
109
|
+
const bytes = new Bytes(memory);
|
|
110
|
+
const ctx = { bytes, version: this.version, abbreviations: [], alphabets: null, extra: defaultExtra() };
|
|
111
|
+
const abbr = this.mem.word(H.abbreviations);
|
|
112
|
+
if (abbr)
|
|
113
|
+
for (let i = 0; i < 96; i++)
|
|
114
|
+
ctx.abbreviations.push(this.mem.word(abbr + i * 2) * 2);
|
|
115
|
+
if (this.version >= 5) {
|
|
116
|
+
const alpha = this.mem.word(H.alphabets);
|
|
117
|
+
if (alpha)
|
|
118
|
+
ctx.alphabets = readAlphabetTable(bytes, alpha);
|
|
119
|
+
const ext = this.mem.word(H.extension);
|
|
120
|
+
if (ext && this.mem.word(ext) >= 3) {
|
|
121
|
+
const unicode = this.mem.word(ext + 6);
|
|
122
|
+
if (unicode)
|
|
123
|
+
ctx.extra = readUnicodeTable(bytes, unicode);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return ctx;
|
|
127
|
+
}
|
|
128
|
+
// ── the header (§11) ──────────────────────────────────────────────────────
|
|
129
|
+
/** What the interpreter writes into the header, at start, restart and restore. */
|
|
130
|
+
writeHeader() {
|
|
131
|
+
const m = this.state.memory;
|
|
132
|
+
const h = this.options.header ?? {};
|
|
133
|
+
const zvm = h.profile === 'zvm';
|
|
134
|
+
const v = this.version;
|
|
135
|
+
const { width, height } = this.state.screen;
|
|
136
|
+
const set16 = (a, x) => {
|
|
137
|
+
m[a] = (x >> 8) & 0xff;
|
|
138
|
+
m[a + 1] = x & 0xff;
|
|
139
|
+
};
|
|
140
|
+
if (v === 3) {
|
|
141
|
+
// Bit 4: status line not available (clear); bit 5: screen-splitting available; bit 6: the
|
|
142
|
+
// default font is variable-pitch.
|
|
143
|
+
m[H.flags1] = (m[H.flags1] & 0x8f) | 0x20 | 0x40;
|
|
144
|
+
if (zvm)
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
else {
|
|
148
|
+
// Bit 0 colours, 2 bold, 3 italic, 4 fixed-pitch, 7 timed input (ZVM claims none).
|
|
149
|
+
m[H.flags1] = (h.colour ? 0x01 : 0) | 0x1c | (zvm ? 0 : 0x80);
|
|
150
|
+
// Flags 2 (§11.1.7): no pictures (3), mouse (5) or sound (7).
|
|
151
|
+
m[H.flags2 + 1] = m[H.flags2 + 1] & 0x57;
|
|
152
|
+
}
|
|
153
|
+
if (v >= 4) {
|
|
154
|
+
m[H.screenHeight] = Math.min(height, 254);
|
|
155
|
+
m[H.screenWidth] = Math.min(width, 255);
|
|
156
|
+
}
|
|
157
|
+
if (v >= 5) {
|
|
158
|
+
set16(H.screenWidthUnits, width);
|
|
159
|
+
set16(H.screenHeightUnits, height);
|
|
160
|
+
m[H.fontWidth] = 1;
|
|
161
|
+
m[H.fontHeight] = 1;
|
|
162
|
+
}
|
|
163
|
+
if (!zvm) {
|
|
164
|
+
m[H.interpreterNumber] = h.interpreterNumber ?? 6;
|
|
165
|
+
m[H.interpreterVersion] = h.interpreterVersion ?? 'F'.charCodeAt(0);
|
|
166
|
+
}
|
|
167
|
+
if (v >= 4 && !zvm) {
|
|
168
|
+
// Default colours (§8.3.2): black (2) on white (9) is the Standard's suggestion; the player maps them.
|
|
169
|
+
m[0x2c] = m[0x2c] || 2;
|
|
170
|
+
m[0x2d] = m[0x2d] || 9;
|
|
171
|
+
}
|
|
172
|
+
// The Standard this follows (§11.1.5): 1.1, which is what this implements. (ZVM claims 1.2, with
|
|
173
|
+
// its @gestalt; the 'zvm' profile still says 1.1, since a story may act on the claim, as Praxix
|
|
174
|
+
// does. The header test leaves these two bytes out.)
|
|
175
|
+
const [major, minor] = h.standard ?? [1, 1];
|
|
176
|
+
set16(H.standard, (major << 8) | minor);
|
|
177
|
+
// Flags 3 (§11.1.7.4): none of its features.
|
|
178
|
+
const ext = (m[H.extension] << 8) | m[H.extension + 1];
|
|
179
|
+
if (ext && ext + 9 < this.mem.staticBase && ((m[ext] << 8) | m[ext + 1]) >= 4)
|
|
180
|
+
set16(ext + 8, 0);
|
|
181
|
+
}
|
|
182
|
+
// ── running ───────────────────────────────────────────────────────────────
|
|
183
|
+
/** Run until input is needed, the game quits, `budget` instructions have run, or a fault. */
|
|
184
|
+
run(budget) {
|
|
185
|
+
if (this.state.input.pending)
|
|
186
|
+
return this.waiting(this.state.input.pending);
|
|
187
|
+
for (let n = 0; n < budget; n++) {
|
|
188
|
+
const stop = this.step();
|
|
189
|
+
if (stop)
|
|
190
|
+
return stop;
|
|
191
|
+
}
|
|
192
|
+
return { kind: 'budget' };
|
|
193
|
+
}
|
|
194
|
+
/** One instruction. Null when the machine can go on. */
|
|
195
|
+
step() {
|
|
196
|
+
if (this.state.input.pending)
|
|
197
|
+
return this.waiting(this.state.input.pending);
|
|
198
|
+
this.current = this.state.pc;
|
|
199
|
+
try {
|
|
200
|
+
const ins = this.decode(this.state.pc);
|
|
201
|
+
this.state.pc = ins.address + ins.length;
|
|
202
|
+
this.state.counters.instructions++;
|
|
203
|
+
this.execute(ins);
|
|
204
|
+
}
|
|
205
|
+
catch (e) {
|
|
206
|
+
return { kind: 'fault', fault: this.toFault(e) };
|
|
207
|
+
}
|
|
208
|
+
const stop = this.stop;
|
|
209
|
+
this.stop = null;
|
|
210
|
+
return stop;
|
|
211
|
+
}
|
|
212
|
+
toFault(e) {
|
|
213
|
+
if (e instanceof MachineFault)
|
|
214
|
+
return e.toFault(this.current);
|
|
215
|
+
if (e instanceof Malformed)
|
|
216
|
+
return { code: 'illegal-opcode', message: `An instruction the story's version doesn't have, at ${hex(this.current)}: ${e.message}`, pc: this.current };
|
|
217
|
+
if (e instanceof OutOfBounds)
|
|
218
|
+
return { code: 'bad-address', message: `The story read outside itself at ${hex(this.current)}: ${e.message}`, pc: this.current };
|
|
219
|
+
return { code: 'bad-story', message: `Something went wrong at ${hex(this.current)}: ${e instanceof Error ? e.message : String(e)}`, pc: this.current };
|
|
220
|
+
}
|
|
221
|
+
decode(pc) {
|
|
222
|
+
const cached = this.cache.get(pc);
|
|
223
|
+
if (cached)
|
|
224
|
+
return cached;
|
|
225
|
+
const ins = decodeInstruction(this.text.bytes, pc, this.version, this.text);
|
|
226
|
+
// Code in static or high memory can't change, so its decoding can be kept (§1.1).
|
|
227
|
+
if (pc >= this.mem.staticBase)
|
|
228
|
+
this.cache.set(pc, ins);
|
|
229
|
+
return ins;
|
|
230
|
+
}
|
|
231
|
+
waiting(p) {
|
|
232
|
+
if (p.kind === 'save')
|
|
233
|
+
return { kind: 'save', data: this.quetzal(p.at) };
|
|
234
|
+
if (p.kind === 'restore')
|
|
235
|
+
return { kind: 'restore' };
|
|
236
|
+
if (p.kind === 'char')
|
|
237
|
+
return p.timeout ? { kind: 'input-char', timeout: p.timeout } : { kind: 'input-char' };
|
|
238
|
+
const { max, preloaded } = this.lineLimits(p.textBuffer);
|
|
239
|
+
return p.timeout ? { kind: 'input-line', maxLength: max, timeout: p.timeout, preloaded } : { kind: 'input-line', maxLength: max, preloaded };
|
|
240
|
+
}
|
|
241
|
+
/** The most a text buffer takes, and any text already in it (v5+, §15 read). */
|
|
242
|
+
lineLimits(buffer) {
|
|
243
|
+
const size = this.mem.byte(buffer);
|
|
244
|
+
if (this.version <= 4)
|
|
245
|
+
return { max: Math.max(0, size - 1), preloaded: '' };
|
|
246
|
+
const n = Math.min(this.mem.byte(buffer + 1), size);
|
|
247
|
+
let preloaded = '';
|
|
248
|
+
for (let i = 0; i < n; i++)
|
|
249
|
+
preloaded += zsciiText(this.mem.byte(buffer + 2 + i), this.text);
|
|
250
|
+
return { max: size, preloaded };
|
|
251
|
+
}
|
|
252
|
+
/** Give the machine what it's waiting for: a line, a key, or the news that a timed read's interval ran out. */
|
|
253
|
+
resume(input) {
|
|
254
|
+
const p = this.state.input.pending;
|
|
255
|
+
if (!p)
|
|
256
|
+
return { code: 'not-waiting', message: 'The story isn’t waiting for input.', pc: this.state.pc };
|
|
257
|
+
try {
|
|
258
|
+
if (p.kind === 'save' || p.kind === 'restore') {
|
|
259
|
+
if (!('saved' in input) && !('restore' in input))
|
|
260
|
+
return { code: 'not-waiting', message: `The story is waiting for a ${p.kind}, not typed input.`, pc: this.state.pc };
|
|
261
|
+
this.state.input.pending = null;
|
|
262
|
+
if ('saved' in input) {
|
|
263
|
+
this.completeSave(p.at, input.saved ? 1 : 0);
|
|
264
|
+
return null;
|
|
265
|
+
}
|
|
266
|
+
if (!input.restore) {
|
|
267
|
+
this.completeSave(p.at, 0);
|
|
268
|
+
return null;
|
|
269
|
+
}
|
|
270
|
+
const why = this.restoreQuetzal(input.restore);
|
|
271
|
+
if (why) {
|
|
272
|
+
this.completeSave(p.at, 0);
|
|
273
|
+
return { code: 'bad-save', message: why, pc: this.state.pc };
|
|
274
|
+
}
|
|
275
|
+
return null;
|
|
276
|
+
}
|
|
277
|
+
if (!('line' in input) && !('char' in input) && !('timeout' in input))
|
|
278
|
+
return { code: 'not-waiting', message: 'The story is waiting for typed input, not a save file.', pc: this.state.pc };
|
|
279
|
+
if ('timeout' in input) {
|
|
280
|
+
if (!p.timeout)
|
|
281
|
+
return { code: 'not-waiting', message: 'This read has no time limit.', pc: this.state.pc };
|
|
282
|
+
// Run the interrupt routine (§10.2.4): it returns true to end the read.
|
|
283
|
+
this.state.input.pending = null;
|
|
284
|
+
this.callRoutine(p.timeout.routine, [], null, true);
|
|
285
|
+
this.interrupted = p;
|
|
286
|
+
return null;
|
|
287
|
+
}
|
|
288
|
+
this.state.input.pending = null;
|
|
289
|
+
if ('line' in input)
|
|
290
|
+
this.finishLine(p, input.line, 13);
|
|
291
|
+
else
|
|
292
|
+
this.finishChar(p, input.char);
|
|
293
|
+
}
|
|
294
|
+
catch (e) {
|
|
295
|
+
return this.toFault(e);
|
|
296
|
+
}
|
|
297
|
+
return null;
|
|
298
|
+
}
|
|
299
|
+
/** The read an interrupt routine is running for. */
|
|
300
|
+
interrupted = null;
|
|
301
|
+
// ── variables (§6) ────────────────────────────────────────────────────────
|
|
302
|
+
get frame() {
|
|
303
|
+
return this.state.frames[this.state.frames.length - 1];
|
|
304
|
+
}
|
|
305
|
+
push(v) {
|
|
306
|
+
const s = this.frame.evalStack;
|
|
307
|
+
if (s.length >= MAX_EVAL_STACK)
|
|
308
|
+
fail('stack-overflow', 'the evaluation stack overflowed');
|
|
309
|
+
s.push(v & 0xffff);
|
|
310
|
+
}
|
|
311
|
+
pop() {
|
|
312
|
+
const v = this.frame.evalStack.pop();
|
|
313
|
+
if (v === undefined)
|
|
314
|
+
fail('stack-underflow', 'the story took a value from an empty stack');
|
|
315
|
+
return v;
|
|
316
|
+
}
|
|
317
|
+
peek() {
|
|
318
|
+
const s = this.frame.evalStack;
|
|
319
|
+
if (!s.length)
|
|
320
|
+
fail('stack-underflow', 'the story read the top of an empty stack');
|
|
321
|
+
return s[s.length - 1];
|
|
322
|
+
}
|
|
323
|
+
readVar(n) {
|
|
324
|
+
if (n === 0)
|
|
325
|
+
return this.pop();
|
|
326
|
+
if (n < 16) {
|
|
327
|
+
const l = this.frame.locals;
|
|
328
|
+
if (n > l.length)
|
|
329
|
+
fail('bad-variable', `local ${n} doesn't exist (the routine has ${l.length})`);
|
|
330
|
+
return l[n - 1];
|
|
331
|
+
}
|
|
332
|
+
return this.mem.word(this.globals + (n - 16) * 2);
|
|
333
|
+
}
|
|
334
|
+
writeVar(n, v) {
|
|
335
|
+
if (n === 0)
|
|
336
|
+
return this.push(v);
|
|
337
|
+
if (n < 16) {
|
|
338
|
+
const l = this.frame.locals;
|
|
339
|
+
if (n > l.length)
|
|
340
|
+
fail('bad-variable', `local ${n} doesn't exist (the routine has ${l.length})`);
|
|
341
|
+
l[n - 1] = v & 0xffff;
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
this.mem.setWord(this.globals + (n - 16) * 2, v);
|
|
345
|
+
}
|
|
346
|
+
/** Indirect variable references (§6.3.4): variable 0 is the top of the stack, read or written in place. */
|
|
347
|
+
readVarInPlace(n) {
|
|
348
|
+
return n === 0 ? this.peek() : this.readVar(n);
|
|
349
|
+
}
|
|
350
|
+
writeVarInPlace(n, v) {
|
|
351
|
+
if (n === 0) {
|
|
352
|
+
this.peek();
|
|
353
|
+
const s = this.frame.evalStack;
|
|
354
|
+
s[s.length - 1] = v & 0xffff;
|
|
355
|
+
}
|
|
356
|
+
else
|
|
357
|
+
this.writeVar(n, v);
|
|
358
|
+
}
|
|
359
|
+
// ── routines (§5, §6.4) ───────────────────────────────────────────────────
|
|
360
|
+
unpackRoutine(p) {
|
|
361
|
+
return p * (this.version <= 3 ? 2 : this.version <= 5 ? 4 : 8);
|
|
362
|
+
}
|
|
363
|
+
unpackString(p) {
|
|
364
|
+
return this.unpackRoutine(p);
|
|
365
|
+
}
|
|
366
|
+
callRoutine(packed, args, store, interrupt = false) {
|
|
367
|
+
if (packed === 0) {
|
|
368
|
+
// Calling routine 0 does nothing and returns false (§6.4.3).
|
|
369
|
+
if (store !== null)
|
|
370
|
+
this.writeVar(store, 0);
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
if (this.state.frames.length >= MAX_FRAMES)
|
|
374
|
+
fail('stack-overflow', `routines nested more than ${MAX_FRAMES} deep`);
|
|
375
|
+
const address = this.unpackRoutine(packed);
|
|
376
|
+
const count = this.mem.byte(address);
|
|
377
|
+
if (count > 15)
|
|
378
|
+
fail('bad-address', `there's no routine at ${hex(address)} (it would have ${count} locals)`);
|
|
379
|
+
const locals = [];
|
|
380
|
+
let pc = address + 1;
|
|
381
|
+
for (let i = 0; i < count; i++) {
|
|
382
|
+
// v1–4: initial values follow the count; v5+: locals start at 0 (§5.2).
|
|
383
|
+
if (this.version <= 4) {
|
|
384
|
+
locals.push(this.mem.word(pc));
|
|
385
|
+
pc += 2;
|
|
386
|
+
}
|
|
387
|
+
else
|
|
388
|
+
locals.push(0);
|
|
389
|
+
}
|
|
390
|
+
for (let i = 0; i < Math.min(args.length, count); i++)
|
|
391
|
+
locals[i] = args[i] & 0xffff;
|
|
392
|
+
const frame = { returnPC: this.state.pc, locals, evalStack: [], storeVariable: store, argCount: args.length };
|
|
393
|
+
if (interrupt)
|
|
394
|
+
frame.interrupt = true;
|
|
395
|
+
this.state.frames.push(frame);
|
|
396
|
+
this.state.pc = pc;
|
|
397
|
+
this.hooks?.call?.(address, args, frame);
|
|
398
|
+
}
|
|
399
|
+
ret(v) {
|
|
400
|
+
const f = this.state.frames.pop();
|
|
401
|
+
if (!this.state.frames.length)
|
|
402
|
+
fail('stack-underflow', 'the main routine returned');
|
|
403
|
+
this.hooks?.return?.(v, f);
|
|
404
|
+
this.state.pc = f.returnPC;
|
|
405
|
+
if (f.interrupt)
|
|
406
|
+
return this.afterInterrupt(v);
|
|
407
|
+
if (f.storeVariable !== null)
|
|
408
|
+
this.writeVar(f.storeVariable, v);
|
|
409
|
+
}
|
|
410
|
+
branch(ins, condition) {
|
|
411
|
+
const b = ins.branch;
|
|
412
|
+
const taken = condition === b.onTrue;
|
|
413
|
+
this.hooks?.branch?.(ins.address, taken);
|
|
414
|
+
if (!taken)
|
|
415
|
+
return;
|
|
416
|
+
if (b.returns !== undefined)
|
|
417
|
+
this.ret(b.returns ? 1 : 0);
|
|
418
|
+
else
|
|
419
|
+
this.state.pc = b.target;
|
|
420
|
+
}
|
|
421
|
+
store(ins, v) {
|
|
422
|
+
if (ins.store !== undefined)
|
|
423
|
+
this.writeVar(ins.store, v);
|
|
424
|
+
}
|
|
425
|
+
// ── output (§7) ───────────────────────────────────────────────────────────
|
|
426
|
+
/** ZSCII codes to the active output streams. */
|
|
427
|
+
print(codes) {
|
|
428
|
+
const streams = this.state.streams;
|
|
429
|
+
const s3 = streams.memory.at(-1);
|
|
430
|
+
if (s3) {
|
|
431
|
+
// Stream 3 takes everything, and nothing else gets it (§7.1.2.2). ZSCII 0 prints nothing
|
|
432
|
+
// anywhere (§3.8.2.1).
|
|
433
|
+
for (const c of codes) {
|
|
434
|
+
if (c === 0)
|
|
435
|
+
continue;
|
|
436
|
+
this.mem.setByte(s3.table + 2 + s3.count, c === 10 ? 13 : c);
|
|
437
|
+
s3.count++;
|
|
438
|
+
}
|
|
439
|
+
return;
|
|
440
|
+
}
|
|
441
|
+
if (!streams.screen)
|
|
442
|
+
return;
|
|
443
|
+
let text = '';
|
|
444
|
+
for (const c of codes)
|
|
445
|
+
if (c !== 0)
|
|
446
|
+
text += zsciiText(c, this.text);
|
|
447
|
+
this.screen.print(text);
|
|
448
|
+
this.hooks?.print?.(text, this.state.screen.window);
|
|
449
|
+
}
|
|
450
|
+
printString(s) {
|
|
451
|
+
this.print([...s].map(ch => unicodeToZscii(ch, this.text)));
|
|
452
|
+
}
|
|
453
|
+
/** @print_unicode: the character itself on the screen; ZSCII (or '?') into stream 3. */
|
|
454
|
+
printUnicode(cp) {
|
|
455
|
+
const ch = cp >= 32 && cp <= 0x10ffff && !(cp >= 0xd800 && cp <= 0xdfff) ? String.fromCodePoint(cp) : '?';
|
|
456
|
+
const streams = this.state.streams;
|
|
457
|
+
if (streams.memory.length || !streams.screen)
|
|
458
|
+
return this.printString(ch);
|
|
459
|
+
this.screen.print(ch);
|
|
460
|
+
this.hooks?.print?.(ch, this.state.screen.window);
|
|
461
|
+
}
|
|
462
|
+
printNum(v) {
|
|
463
|
+
this.printString(String(signed(v)));
|
|
464
|
+
}
|
|
465
|
+
printObject(o) {
|
|
466
|
+
if (o === 0)
|
|
467
|
+
return;
|
|
468
|
+
const { address, words } = this.obj.shortName(o);
|
|
469
|
+
if (words)
|
|
470
|
+
this.print(zsciiAt(this.text, address));
|
|
471
|
+
}
|
|
472
|
+
/** Lower-window text since the last call: what the Node runner prints for a turn. */
|
|
473
|
+
flushText() {
|
|
474
|
+
return this.screen.take();
|
|
475
|
+
}
|
|
476
|
+
// ── input (§10, §13) ──────────────────────────────────────────────────────
|
|
477
|
+
/** v3: the status line, from globals 0–2 (§8.2.2). */
|
|
478
|
+
showStatus() {
|
|
479
|
+
if (this.version !== 3)
|
|
480
|
+
return;
|
|
481
|
+
const g = (i) => this.mem.word(this.globals + i * 2);
|
|
482
|
+
let location = '';
|
|
483
|
+
try {
|
|
484
|
+
const o = g(0);
|
|
485
|
+
if (o)
|
|
486
|
+
location = zsciiAt(this.text, this.obj.shortName(o).address).map(c => zsciiText(c, this.text)).join('');
|
|
487
|
+
}
|
|
488
|
+
catch {
|
|
489
|
+
location = '';
|
|
490
|
+
}
|
|
491
|
+
const time = (this.mem.byte(H.flags1) & 0x02) !== 0;
|
|
492
|
+
this.state.screen.statusLine = time ? { location, hours: g(1), minutes: g(2) } : { location, score: signed(g(1)), moves: g(2) };
|
|
493
|
+
}
|
|
494
|
+
read(ins, ops) {
|
|
495
|
+
const [textBuffer = 0, parseBuffer = 0, time = 0, routine = 0] = ops;
|
|
496
|
+
this.showStatus();
|
|
497
|
+
const p = { kind: 'line', store: ins.store ?? null, textBuffer, parseBuffer };
|
|
498
|
+
if (this.version >= 4 && time && routine)
|
|
499
|
+
p.timeout = { tenths: time, routine };
|
|
500
|
+
this.state.input.pending = p;
|
|
501
|
+
this.stop = this.waiting(p);
|
|
502
|
+
}
|
|
503
|
+
finishLine(p, line, terminator) {
|
|
504
|
+
const { max } = this.lineLimits(p.textBuffer);
|
|
505
|
+
const codes = inputToZscii(line, this.text).filter(c => c !== 13).slice(0, max);
|
|
506
|
+
if (this.version <= 4) {
|
|
507
|
+
codes.forEach((c, i) => this.mem.setByte(p.textBuffer + 1 + i, c));
|
|
508
|
+
this.mem.setByte(p.textBuffer + 1 + codes.length, 0);
|
|
509
|
+
}
|
|
510
|
+
else {
|
|
511
|
+
this.mem.setByte(p.textBuffer + 1, codes.length);
|
|
512
|
+
codes.forEach((c, i) => this.mem.setByte(p.textBuffer + 2 + i, c));
|
|
513
|
+
}
|
|
514
|
+
this.hooks?.input?.('line', line);
|
|
515
|
+
this.state.counters.turns++;
|
|
516
|
+
if (p.parseBuffer)
|
|
517
|
+
this.tokenise(p.textBuffer, p.parseBuffer, this.dictionary, false);
|
|
518
|
+
if (p.store !== null)
|
|
519
|
+
this.writeVar(p.store, terminator);
|
|
520
|
+
}
|
|
521
|
+
finishChar(p, code) {
|
|
522
|
+
this.hooks?.input?.('char', code);
|
|
523
|
+
if (p.store !== null)
|
|
524
|
+
this.writeVar(p.store, code);
|
|
525
|
+
}
|
|
526
|
+
/** An interrupt routine for a timed read returned `v` (§10.2.4). */
|
|
527
|
+
afterInterrupt(v) {
|
|
528
|
+
const p = this.interrupted;
|
|
529
|
+
this.interrupted = null;
|
|
530
|
+
if (!p)
|
|
531
|
+
return;
|
|
532
|
+
if (v) {
|
|
533
|
+
// The read ends: an empty line with terminator 0, or character 0.
|
|
534
|
+
if (p.kind === 'line')
|
|
535
|
+
this.finishLine(p, '', 0);
|
|
536
|
+
else
|
|
537
|
+
this.finishChar(p, 0);
|
|
538
|
+
}
|
|
539
|
+
else {
|
|
540
|
+
this.state.input.pending = p;
|
|
541
|
+
this.stop = this.waiting(p);
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
/** The words of a text buffer, looked up in a dictionary, into a parse buffer (§13.6). */
|
|
545
|
+
tokenise(textBuffer, parseBuffer, dictionary, keepUnknown) {
|
|
546
|
+
const start = this.version <= 4 ? 1 : 2;
|
|
547
|
+
const codes = [];
|
|
548
|
+
if (this.version <= 4) {
|
|
549
|
+
for (let i = start;; i++) {
|
|
550
|
+
const c = this.mem.byte(textBuffer + i);
|
|
551
|
+
if (c === 0)
|
|
552
|
+
break;
|
|
553
|
+
codes.push(c);
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
else {
|
|
557
|
+
const n = this.mem.byte(textBuffer + 1);
|
|
558
|
+
for (let i = 0; i < n; i++)
|
|
559
|
+
codes.push(this.mem.byte(textBuffer + 2 + i));
|
|
560
|
+
}
|
|
561
|
+
const dict = this.dictionaryAt(dictionary);
|
|
562
|
+
const words = [];
|
|
563
|
+
let cur = null;
|
|
564
|
+
codes.forEach((c, i) => {
|
|
565
|
+
if (c === 32)
|
|
566
|
+
cur = null;
|
|
567
|
+
else if (dict.separators.includes(c)) {
|
|
568
|
+
cur = null;
|
|
569
|
+
words.push({ at: i, codes: [c] });
|
|
570
|
+
}
|
|
571
|
+
else {
|
|
572
|
+
if (!cur) {
|
|
573
|
+
cur = { at: i, codes: [] };
|
|
574
|
+
words.push(cur);
|
|
575
|
+
}
|
|
576
|
+
cur.codes.push(c);
|
|
577
|
+
}
|
|
578
|
+
});
|
|
579
|
+
const max = this.mem.byte(parseBuffer);
|
|
580
|
+
const found = Math.min(words.length, max);
|
|
581
|
+
this.mem.setByte(parseBuffer + 1, found);
|
|
582
|
+
for (let w = 0; w < found; w++) {
|
|
583
|
+
const word = words[w];
|
|
584
|
+
const entry = dict.lookup(encodeWord(word.codes, this.text));
|
|
585
|
+
const block = parseBuffer + 2 + w * 4;
|
|
586
|
+
if (entry === 0 && keepUnknown)
|
|
587
|
+
continue;
|
|
588
|
+
this.mem.setWord(block, entry);
|
|
589
|
+
this.mem.setByte(block + 2, word.codes.length);
|
|
590
|
+
this.mem.setByte(block + 3, word.at + start);
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
dictionaries = new Map();
|
|
594
|
+
dictionaryAt(address) {
|
|
595
|
+
// A dictionary in static memory can't change, so it's read once; one in dynamic memory each time.
|
|
596
|
+
if (address < this.mem.staticBase)
|
|
597
|
+
return this.readDictionary(address);
|
|
598
|
+
let d = this.dictionaries.get(address);
|
|
599
|
+
if (!d)
|
|
600
|
+
this.dictionaries.set(address, (d = this.readDictionary(address)));
|
|
601
|
+
return d;
|
|
602
|
+
}
|
|
603
|
+
readDictionary(address) {
|
|
604
|
+
const n = this.mem.byte(address);
|
|
605
|
+
const separators = [];
|
|
606
|
+
for (let i = 0; i < n; i++)
|
|
607
|
+
separators.push(this.mem.byte(address + 1 + i));
|
|
608
|
+
const entryLength = this.mem.byte(address + 1 + n);
|
|
609
|
+
const count = signed(this.mem.word(address + 2 + n));
|
|
610
|
+
const first = address + 4 + n;
|
|
611
|
+
const keyLength = this.version <= 3 ? 4 : 6;
|
|
612
|
+
const index = new Map();
|
|
613
|
+
// A negative count means unsorted (§13.4); either way, a map finds the entry.
|
|
614
|
+
for (let i = 0; i < Math.abs(count); i++) {
|
|
615
|
+
const at = first + i * entryLength;
|
|
616
|
+
let key = '';
|
|
617
|
+
for (let b = 0; b < keyLength; b++)
|
|
618
|
+
key += String.fromCharCode(this.mem.byte(at + b));
|
|
619
|
+
if (!index.has(key))
|
|
620
|
+
index.set(key, at);
|
|
621
|
+
}
|
|
622
|
+
return {
|
|
623
|
+
separators,
|
|
624
|
+
lookup: (encoded) => index.get(String.fromCharCode(...encoded)) ?? 0,
|
|
625
|
+
};
|
|
626
|
+
}
|
|
627
|
+
// ── save files (Quetzal 1.4) ──────────────────────────────────────────────
|
|
628
|
+
storyId() {
|
|
629
|
+
const m = this.story;
|
|
630
|
+
return { release: (m[0x02] << 8) | m[0x03], serial: m.subarray(0x12, 0x18), checksum: (m[0x1c] << 8) | m[0x1d] };
|
|
631
|
+
}
|
|
632
|
+
/** The machine as a Quetzal file, with `at` as the save instruction's store or branch byte. */
|
|
633
|
+
quetzal(at) {
|
|
634
|
+
const id = this.storyId();
|
|
635
|
+
return writeQuetzal({ release: id.release, serial: id.serial, checksum: id.checksum, pc: at, dynamic: this.state.memory.subarray(0, this.mem.staticBase), frames: this.state.frames }, this.story.subarray(0, this.mem.staticBase));
|
|
636
|
+
}
|
|
637
|
+
/**
|
|
638
|
+
* Finish a save or restore whose result is `value` (§15 save/restore): v4+ store it at the store
|
|
639
|
+
* byte `at`; v3 branch on it from the branch byte(s) at `at`.
|
|
640
|
+
*/
|
|
641
|
+
completeSave(at, value) {
|
|
642
|
+
if (this.version >= 4) {
|
|
643
|
+
this.state.pc = at + 1;
|
|
644
|
+
return this.writeVar(this.mem.byte(at), value);
|
|
645
|
+
}
|
|
646
|
+
const b = this.mem.byte(at);
|
|
647
|
+
let offset;
|
|
648
|
+
let after;
|
|
649
|
+
if (b & 0x40) {
|
|
650
|
+
offset = b & 0x3f;
|
|
651
|
+
after = at + 1;
|
|
652
|
+
}
|
|
653
|
+
else {
|
|
654
|
+
offset = (((b & 0x3f) << 8) | this.mem.byte(at + 1)) << 18 >> 18;
|
|
655
|
+
after = at + 2;
|
|
656
|
+
}
|
|
657
|
+
this.state.pc = after;
|
|
658
|
+
if ((value !== 0) !== ((b & 0x80) !== 0))
|
|
659
|
+
return;
|
|
660
|
+
if (offset === 0 || offset === 1)
|
|
661
|
+
return this.ret(offset);
|
|
662
|
+
this.state.pc = after + offset - 2;
|
|
663
|
+
}
|
|
664
|
+
/** Restore a Quetzal file; the reason in words when it can't be. */
|
|
665
|
+
restoreQuetzal(bytes) {
|
|
666
|
+
let q;
|
|
667
|
+
try {
|
|
668
|
+
q = readQuetzal(bytes, this.story.subarray(0, this.mem.staticBase));
|
|
669
|
+
}
|
|
670
|
+
catch (e) {
|
|
671
|
+
return `That save file can\u2019t be restored: ${e instanceof Error ? e.message : String(e)}.`;
|
|
672
|
+
}
|
|
673
|
+
const id = this.storyId();
|
|
674
|
+
if (q.release !== id.release || q.checksum !== id.checksum || q.serial.some((b, i) => b !== id.serial[i])) {
|
|
675
|
+
return 'That save file is from a different story (or a different release of this one).';
|
|
676
|
+
}
|
|
677
|
+
// Flags 2 survives a restore (§11.1.7.2… ZVM keeps the whole low byte).
|
|
678
|
+
const flags2 = this.state.memory[0x11];
|
|
679
|
+
this.state.memory.set(q.dynamic, 0);
|
|
680
|
+
this.state.memory[0x11] = flags2;
|
|
681
|
+
this.state.frames = q.frames;
|
|
682
|
+
this.writeHeader();
|
|
683
|
+
this.state.random.mode = 'session';
|
|
684
|
+
this.state.random.xorshift = 0;
|
|
685
|
+
this.state.streams.memory = [];
|
|
686
|
+
this.dictionaries.clear();
|
|
687
|
+
// v3: the upper window collapses on a restore (§8.6.1.3).
|
|
688
|
+
if (this.version === 3)
|
|
689
|
+
this.screen.split(0, 3);
|
|
690
|
+
this.completeSave(q.pc, 2);
|
|
691
|
+
return null;
|
|
692
|
+
}
|
|
693
|
+
// ── snapshots (autosave, workers) ─────────────────────────────────────────
|
|
694
|
+
/** The whole machine as plain data. */
|
|
695
|
+
snapshot() {
|
|
696
|
+
const id = this.storyId();
|
|
697
|
+
const s = this.state;
|
|
698
|
+
return structuredCopy({
|
|
699
|
+
version: 1,
|
|
700
|
+
story: { release: id.release, serial: String.fromCharCode(...id.serial), checksum: id.checksum },
|
|
701
|
+
dynamic: s.memory.slice(0, this.mem.staticBase),
|
|
702
|
+
pc: s.pc,
|
|
703
|
+
frames: s.frames,
|
|
704
|
+
random: s.random,
|
|
705
|
+
screen: s.screen,
|
|
706
|
+
input: s.input,
|
|
707
|
+
streams: s.streams,
|
|
708
|
+
counters: s.counters,
|
|
709
|
+
undo: s.undo,
|
|
710
|
+
interrupted: this.interrupted,
|
|
711
|
+
});
|
|
712
|
+
}
|
|
713
|
+
/** A machine in exactly the state of `snapshot`, or why it can't be (another story's). */
|
|
714
|
+
static fromSnapshot(story, snapshot, options) {
|
|
715
|
+
const m = Machine.create(story, options);
|
|
716
|
+
if (!(m instanceof Machine))
|
|
717
|
+
return m;
|
|
718
|
+
const id = m.storyId();
|
|
719
|
+
const sid = snapshot.story;
|
|
720
|
+
if (snapshot.version !== 1 || sid.release !== id.release || sid.checksum !== id.checksum || sid.serial !== String.fromCharCode(...id.serial)) {
|
|
721
|
+
return { fault: { code: 'bad-save', message: 'That saved state is from a different story (or a different release of this one).', pc: 0 } };
|
|
722
|
+
}
|
|
723
|
+
const c = structuredCopy(snapshot);
|
|
724
|
+
m.state.memory.set(c.dynamic, 0);
|
|
725
|
+
m.state.pc = c.pc;
|
|
726
|
+
m.state.frames = c.frames;
|
|
727
|
+
m.state.random = c.random;
|
|
728
|
+
Object.assign(m.state.screen, c.screen);
|
|
729
|
+
m.state.input = c.input;
|
|
730
|
+
m.state.streams = c.streams;
|
|
731
|
+
m.state.counters = c.counters;
|
|
732
|
+
m.state.undo = c.undo;
|
|
733
|
+
m.interrupted = c.interrupted;
|
|
734
|
+
return m;
|
|
735
|
+
}
|
|
736
|
+
// ── undo (§15 save_undo / restore_undo) ───────────────────────────────────
|
|
737
|
+
saveUndo(store) {
|
|
738
|
+
const s = {
|
|
739
|
+
dynamic: this.state.memory.slice(0, this.mem.staticBase),
|
|
740
|
+
frames: cloneFrames(this.state.frames),
|
|
741
|
+
pc: this.state.pc,
|
|
742
|
+
random: { ...this.state.random },
|
|
743
|
+
store,
|
|
744
|
+
};
|
|
745
|
+
this.state.undo.push(s);
|
|
746
|
+
const depth = this.options.undoDepth ?? 10;
|
|
747
|
+
while (this.state.undo.length > depth)
|
|
748
|
+
this.state.undo.shift();
|
|
749
|
+
}
|
|
750
|
+
restoreUndo() {
|
|
751
|
+
const s = this.state.undo.pop();
|
|
752
|
+
if (!s)
|
|
753
|
+
return false;
|
|
754
|
+
// The transcript and fixed-pitch bits of Flags 2 survive (§11.1.7.2… as on restore).
|
|
755
|
+
const flags2 = this.state.memory[H.flags2 + 1] & 0x03;
|
|
756
|
+
this.state.memory.set(s.dynamic, 0);
|
|
757
|
+
this.state.memory[H.flags2 + 1] = (this.state.memory[H.flags2 + 1] & ~0x03) | flags2;
|
|
758
|
+
this.state.frames = cloneFrames(s.frames);
|
|
759
|
+
this.state.pc = s.pc;
|
|
760
|
+
this.state.random = { ...s.random };
|
|
761
|
+
if (s.store !== null)
|
|
762
|
+
this.writeVar(s.store, 2);
|
|
763
|
+
return true;
|
|
764
|
+
}
|
|
765
|
+
// ── restart (§6.1.3, §11.1) ───────────────────────────────────────────────
|
|
766
|
+
restart(first = false) {
|
|
767
|
+
const m = this.state.memory;
|
|
768
|
+
// Flags 2's transcript and fixed-pitch bits survive a restart (§6.1.3).
|
|
769
|
+
const keep = first ? 0 : m[H.flags2 + 1] & 0x03;
|
|
770
|
+
m.set(this.story.subarray(0, this.mem.staticBase), 0);
|
|
771
|
+
m[H.flags2 + 1] = (m[H.flags2 + 1] & ~0x03) | keep;
|
|
772
|
+
this.writeHeader();
|
|
773
|
+
// ZVM resets its seed on restart (update_header): back to the session stream, which goes on.
|
|
774
|
+
this.state.random.mode = 'session';
|
|
775
|
+
this.state.random.xorshift = 0;
|
|
776
|
+
this.state.frames = [{ returnPC: 0, locals: [], evalStack: [], storeVariable: null, argCount: 0 }];
|
|
777
|
+
this.state.pc = this.mem.word(H.initialPc);
|
|
778
|
+
this.state.input.pending = null;
|
|
779
|
+
this.state.streams.memory = [];
|
|
780
|
+
this.screen.erase(-1);
|
|
781
|
+
this.state.screen.style = 0;
|
|
782
|
+
this.dictionaries.clear();
|
|
783
|
+
}
|
|
784
|
+
// ── the checksum (@verify) ────────────────────────────────────────────────
|
|
785
|
+
verify() {
|
|
786
|
+
const scale = this.version <= 3 ? 2 : this.version <= 5 ? 4 : 8;
|
|
787
|
+
const length = Math.min(this.mem.word(H.fileLength) * scale, this.story.length);
|
|
788
|
+
let sum = 0;
|
|
789
|
+
for (let i = 0x40; i < length; i++)
|
|
790
|
+
sum = (sum + this.story[i]) & 0xffff;
|
|
791
|
+
return sum === this.mem.word(H.checksum);
|
|
792
|
+
}
|
|
793
|
+
// ── tables ────────────────────────────────────────────────────────────────
|
|
794
|
+
copyTable(first, second, size) {
|
|
795
|
+
const n = Math.abs(signed(size));
|
|
796
|
+
if (second === 0) {
|
|
797
|
+
for (let i = 0; i < n; i++)
|
|
798
|
+
this.mem.setByte(first + i, 0);
|
|
799
|
+
return;
|
|
800
|
+
}
|
|
801
|
+
// Positive size: copy as if through a buffer, so overlapping tables come out right; negative:
|
|
802
|
+
// forwards, byte by byte, whatever that does to an overlap (§15 copy_table).
|
|
803
|
+
if (signed(size) > 0 && second > first && second < first + n) {
|
|
804
|
+
for (let i = n - 1; i >= 0; i--)
|
|
805
|
+
this.mem.setByte(second + i, this.mem.byte(first + i));
|
|
806
|
+
}
|
|
807
|
+
else
|
|
808
|
+
for (let i = 0; i < n; i++)
|
|
809
|
+
this.mem.setByte(second + i, this.mem.byte(first + i));
|
|
810
|
+
}
|
|
811
|
+
scanTable(x, table, length, form) {
|
|
812
|
+
const words = (form & 0x80) !== 0;
|
|
813
|
+
const step = form & 0x7f;
|
|
814
|
+
if (step === 0)
|
|
815
|
+
return 0;
|
|
816
|
+
for (let i = 0; i < length; i++) {
|
|
817
|
+
const at = table + i * step;
|
|
818
|
+
if ((words ? this.mem.word(at) : this.mem.byte(at)) === x)
|
|
819
|
+
return at;
|
|
820
|
+
}
|
|
821
|
+
return 0;
|
|
822
|
+
}
|
|
823
|
+
printTable(address, width, height, skip) {
|
|
824
|
+
for (let row = 0; row < height; row++) {
|
|
825
|
+
if (row > 0)
|
|
826
|
+
this.print([13]);
|
|
827
|
+
const codes = [];
|
|
828
|
+
for (let c = 0; c < width; c++)
|
|
829
|
+
codes.push(this.mem.byte(address + row * (width + skip) + c));
|
|
830
|
+
this.print(codes);
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
// ── one instruction (§14, §15) ────────────────────────────────────────────
|
|
834
|
+
execute(ins) {
|
|
835
|
+
const ops = ins.operands.map(o => (o.kind === 'variable' ? this.readVar(o.value) : o.value));
|
|
836
|
+
const [a = 0, b = 0, c = 0, d = 0] = ops;
|
|
837
|
+
const s = signed;
|
|
838
|
+
switch (ins.name) {
|
|
839
|
+
// ── arithmetic and logic (§2) ──
|
|
840
|
+
case 'ADD': return this.store(ins, s(a) + s(b));
|
|
841
|
+
case 'SUB': return this.store(ins, s(a) - s(b));
|
|
842
|
+
case 'MUL': return this.store(ins, Math.imul(s(a), s(b)));
|
|
843
|
+
case 'DIV':
|
|
844
|
+
if (s(b) === 0)
|
|
845
|
+
fail('division-by-zero', 'division by zero');
|
|
846
|
+
return this.store(ins, Math.trunc(s(a) / s(b)));
|
|
847
|
+
case 'MOD':
|
|
848
|
+
if (s(b) === 0)
|
|
849
|
+
fail('division-by-zero', 'remainder of division by zero');
|
|
850
|
+
return this.store(ins, s(a) % s(b));
|
|
851
|
+
case 'OR': return this.store(ins, a | b);
|
|
852
|
+
case 'AND': return this.store(ins, a & b);
|
|
853
|
+
case 'NOT': return this.store(ins, ~a);
|
|
854
|
+
case 'LOG_SHIFT': return this.store(ins, s(b) >= 0 ? a << s(b) : a >>> -s(b));
|
|
855
|
+
case 'ART_SHIFT': return this.store(ins, s(b) >= 0 ? s(a) << s(b) : s(a) >> -s(b));
|
|
856
|
+
// ── branches ──
|
|
857
|
+
// @je with one operand never branches (§15: "@je a never jumps").
|
|
858
|
+
case 'JE': return this.branch(ins, ops.slice(1).some(x => x === a));
|
|
859
|
+
case 'JL': return this.branch(ins, s(a) < s(b));
|
|
860
|
+
case 'JG': return this.branch(ins, s(a) > s(b));
|
|
861
|
+
case 'JZ': return this.branch(ins, a === 0);
|
|
862
|
+
case 'TEST': return this.branch(ins, (a & b) === b);
|
|
863
|
+
case 'JUMP':
|
|
864
|
+
this.state.pc = ins.jumpTarget;
|
|
865
|
+
return;
|
|
866
|
+
case 'DEC_CHK': {
|
|
867
|
+
// The variable is named by the operand, read and written in place (§6.3.4); compared signed.
|
|
868
|
+
const v = u16(this.readVarInPlace(a) - 1);
|
|
869
|
+
this.writeVarInPlace(a, v);
|
|
870
|
+
return this.branch(ins, s(v) < s(b));
|
|
871
|
+
}
|
|
872
|
+
case 'INC_CHK': {
|
|
873
|
+
const v = u16(this.readVarInPlace(a) + 1);
|
|
874
|
+
this.writeVarInPlace(a, v);
|
|
875
|
+
return this.branch(ins, s(v) > s(b));
|
|
876
|
+
}
|
|
877
|
+
// ── variables ──
|
|
878
|
+
case 'STORE': return this.writeVarInPlace(a, b);
|
|
879
|
+
case 'LOAD': return this.store(ins, this.readVarInPlace(a));
|
|
880
|
+
case 'INC': return this.writeVarInPlace(a, this.readVarInPlace(a) + 1);
|
|
881
|
+
case 'DEC': return this.writeVarInPlace(a, this.readVarInPlace(a) - 1);
|
|
882
|
+
case 'PUSH': return this.push(a);
|
|
883
|
+
case 'PULL': return this.writeVarInPlace(a, this.pop());
|
|
884
|
+
case 'POP':
|
|
885
|
+
this.pop();
|
|
886
|
+
return;
|
|
887
|
+
// ── memory ──
|
|
888
|
+
case 'LOADW': return this.store(ins, this.mem.word(u16(a + 2 * s(b))));
|
|
889
|
+
case 'LOADB': return this.store(ins, this.mem.byte(u16(a + s(b))));
|
|
890
|
+
case 'STOREW': return this.mem.setWord(u16(a + 2 * s(b)), c);
|
|
891
|
+
case 'STOREB': return this.mem.setByte(u16(a + s(b)), c);
|
|
892
|
+
case 'COPY_TABLE': return this.copyTable(a, b, c);
|
|
893
|
+
case 'SCAN_TABLE': {
|
|
894
|
+
const at = this.scanTable(a, b, c, ops.length > 3 ? d : 0x82);
|
|
895
|
+
this.store(ins, at);
|
|
896
|
+
return this.branch(ins, at !== 0);
|
|
897
|
+
}
|
|
898
|
+
// ── objects ──
|
|
899
|
+
case 'GET_PARENT': return this.store(ins, this.obj.parent(a));
|
|
900
|
+
case 'GET_SIBLING': {
|
|
901
|
+
const o = this.obj.sibling(a);
|
|
902
|
+
this.store(ins, o);
|
|
903
|
+
return this.branch(ins, o !== 0);
|
|
904
|
+
}
|
|
905
|
+
case 'GET_CHILD': {
|
|
906
|
+
const o = this.obj.child(a);
|
|
907
|
+
this.store(ins, o);
|
|
908
|
+
return this.branch(ins, o !== 0);
|
|
909
|
+
}
|
|
910
|
+
case 'JIN': return this.branch(ins, this.obj.parent(a) === b);
|
|
911
|
+
case 'INSERT_OBJ': return this.obj.insert(a, b);
|
|
912
|
+
case 'REMOVE_OBJ': return this.obj.remove(a);
|
|
913
|
+
case 'TEST_ATTR': return this.branch(ins, this.obj.testAttr(a, b));
|
|
914
|
+
case 'SET_ATTR': return this.obj.setAttr(a, b, true);
|
|
915
|
+
case 'CLEAR_ATTR': return this.obj.setAttr(a, b, false);
|
|
916
|
+
case 'GET_PROP': return this.store(ins, this.obj.get(a, b));
|
|
917
|
+
case 'GET_PROP_ADDR': return this.store(ins, this.obj.find(a, b)?.data ?? 0);
|
|
918
|
+
case 'GET_PROP_LEN': return this.store(ins, this.obj.propLen(a));
|
|
919
|
+
case 'GET_NEXT_PROP': return this.store(ins, this.obj.next(a, b));
|
|
920
|
+
case 'PUT_PROP': return this.obj.put(a, b, c);
|
|
921
|
+
// ── calls and returns (§6.4) ──
|
|
922
|
+
case 'CALL':
|
|
923
|
+
case 'CALL_VS':
|
|
924
|
+
case 'CALL_VS2':
|
|
925
|
+
case 'CALL_1S':
|
|
926
|
+
case 'CALL_2S':
|
|
927
|
+
return this.callRoutine(a, ops.slice(1), ins.store ?? null);
|
|
928
|
+
case 'CALL_VN':
|
|
929
|
+
case 'CALL_VN2':
|
|
930
|
+
case 'CALL_1N':
|
|
931
|
+
case 'CALL_2N':
|
|
932
|
+
return this.callRoutine(a, ops.slice(1), null);
|
|
933
|
+
case 'RET': return this.ret(a);
|
|
934
|
+
case 'RTRUE': return this.ret(1);
|
|
935
|
+
case 'RFALSE': return this.ret(0);
|
|
936
|
+
case 'RET_POPPED': return this.ret(this.pop());
|
|
937
|
+
case 'CHECK_ARG_COUNT': return this.branch(ins, a <= this.frame.argCount);
|
|
938
|
+
case 'CATCH': return this.store(ins, this.state.frames.length);
|
|
939
|
+
case 'THROW': {
|
|
940
|
+
// Unwind to the frame @catch named, and return from it (§6.4.4… §15 throw).
|
|
941
|
+
if (b < 1 || b > this.state.frames.length)
|
|
942
|
+
fail('stack-underflow', `@throw to frame ${b}, which isn't on the stack`);
|
|
943
|
+
this.state.frames.length = b;
|
|
944
|
+
return this.ret(a);
|
|
945
|
+
}
|
|
946
|
+
// ── printing (§7) ──
|
|
947
|
+
case 'PRINT': return this.print(zsciiAt(this.text, ins.text.address));
|
|
948
|
+
case 'PRINT_RET':
|
|
949
|
+
this.print(zsciiAt(this.text, ins.text.address));
|
|
950
|
+
this.print([13]);
|
|
951
|
+
return this.ret(1);
|
|
952
|
+
case 'NEW_LINE': return this.print([13]);
|
|
953
|
+
case 'PRINT_CHAR': return this.print([a]);
|
|
954
|
+
case 'PRINT_NUM': return this.printNum(a);
|
|
955
|
+
case 'PRINT_ADDR': return this.print(zsciiAt(this.text, a));
|
|
956
|
+
case 'PRINT_PADDR': return this.print(zsciiAt(this.text, this.unpackString(a)));
|
|
957
|
+
case 'PRINT_OBJ': return this.printObject(a);
|
|
958
|
+
case 'PRINT_TABLE': return this.printTable(a, b, ops.length > 2 ? c : 1, ops.length > 3 ? d : 0);
|
|
959
|
+
case 'PRINT_UNICODE': return this.printUnicode(a);
|
|
960
|
+
case 'CHECK_UNICODE':
|
|
961
|
+
// Bit 0: can print it; bit 1: can read it from the keyboard (§15 check_unicode).
|
|
962
|
+
return this.store(ins, a >= 32 && a <= 0xfffd && !(a >= 0x7f && a <= 0x9f) && !(a >= 0xd800 && a <= 0xdfff) ? 3 : 0);
|
|
963
|
+
case 'OUTPUT_STREAM': return this.outputStream(s(a), b);
|
|
964
|
+
case 'INPUT_STREAM': return;
|
|
965
|
+
// ── the screen (§8) ──
|
|
966
|
+
case 'SHOW_STATUS': return this.showStatus();
|
|
967
|
+
case 'SPLIT_WINDOW': return this.screen.split(a, this.version);
|
|
968
|
+
case 'SET_WINDOW': return this.screen.setWindow(a);
|
|
969
|
+
case 'ERASE_WINDOW': return this.screen.erase(s(a));
|
|
970
|
+
case 'ERASE_LINE':
|
|
971
|
+
if (a === 1)
|
|
972
|
+
this.screen.eraseLine();
|
|
973
|
+
return;
|
|
974
|
+
case 'SET_CURSOR': return this.screen.setCursor(s(a), s(b));
|
|
975
|
+
case 'GET_CURSOR': {
|
|
976
|
+
const [row, col] = this.screen.cursor();
|
|
977
|
+
this.mem.setWord(a, row);
|
|
978
|
+
this.mem.setWord(a + 2, col);
|
|
979
|
+
return;
|
|
980
|
+
}
|
|
981
|
+
case 'SET_TEXT_STYLE':
|
|
982
|
+
// 0 is roman; otherwise styles combine (§8.7.1.1).
|
|
983
|
+
this.state.screen.style = a === 0 ? 0 : this.state.screen.style | a;
|
|
984
|
+
return;
|
|
985
|
+
case 'BUFFER_MODE':
|
|
986
|
+
this.state.screen.lower.bufferMode = a !== 0;
|
|
987
|
+
return;
|
|
988
|
+
case 'SET_COLOUR':
|
|
989
|
+
if (a)
|
|
990
|
+
this.state.screen.colours.fg = a;
|
|
991
|
+
if (b)
|
|
992
|
+
this.state.screen.colours.bg = b;
|
|
993
|
+
return;
|
|
994
|
+
case 'SET_TRUE_COLOUR': return;
|
|
995
|
+
case 'SET_FONT': {
|
|
996
|
+
// Fonts 1 (normal) and 4 (fixed-pitch); 0 asks which is current (§8.1.3).
|
|
997
|
+
const prev = this.state.screen.font;
|
|
998
|
+
if (a === 0)
|
|
999
|
+
return this.store(ins, prev);
|
|
1000
|
+
if (a === 1 || a === 4) {
|
|
1001
|
+
this.state.screen.font = a;
|
|
1002
|
+
return this.store(ins, prev);
|
|
1003
|
+
}
|
|
1004
|
+
return this.store(ins, 0);
|
|
1005
|
+
}
|
|
1006
|
+
case 'SOUND_EFFECT':
|
|
1007
|
+
if (a === 1 || a === 2)
|
|
1008
|
+
this.state.screen.beeps.push(a);
|
|
1009
|
+
return;
|
|
1010
|
+
case 'BUFFER_SCREEN': return this.store(ins, 0);
|
|
1011
|
+
// ── input (§10, §13) ──
|
|
1012
|
+
case 'READ': return this.read(ins, ops);
|
|
1013
|
+
case 'READ_CHAR': {
|
|
1014
|
+
const p = { kind: 'char', store: ins.store ?? null, textBuffer: 0, parseBuffer: 0 };
|
|
1015
|
+
if (b && c)
|
|
1016
|
+
p.timeout = { tenths: b, routine: c };
|
|
1017
|
+
this.state.input.pending = p;
|
|
1018
|
+
this.stop = this.waiting(p);
|
|
1019
|
+
return;
|
|
1020
|
+
}
|
|
1021
|
+
case 'TOKENISE': return this.tokenise(a, b, ops.length > 2 && c ? c : this.dictionary, ops.length > 3 && d !== 0);
|
|
1022
|
+
case 'ENCODE_TEXT': {
|
|
1023
|
+
const codes = [];
|
|
1024
|
+
for (let i = 0; i < b; i++)
|
|
1025
|
+
codes.push(this.mem.byte(a + c + i));
|
|
1026
|
+
const bytes = encodeWord(codes, this.text);
|
|
1027
|
+
bytes.forEach((x, i) => this.mem.setByte(d + i, x));
|
|
1028
|
+
return;
|
|
1029
|
+
}
|
|
1030
|
+
// ── the game ──
|
|
1031
|
+
case 'RANDOM': return this.store(ins, random(this.state.random, s(a)));
|
|
1032
|
+
case 'NOP': return;
|
|
1033
|
+
case 'PIRACY': return this.branch(ins, true);
|
|
1034
|
+
case 'VERIFY': return this.branch(ins, this.verify());
|
|
1035
|
+
case 'QUIT':
|
|
1036
|
+
this.stop = { kind: 'quit' };
|
|
1037
|
+
this.state.pc = this.current;
|
|
1038
|
+
return;
|
|
1039
|
+
case 'RESTART': return this.restart();
|
|
1040
|
+
case 'SAVE':
|
|
1041
|
+
case 'RESTORE': {
|
|
1042
|
+
// v5+'s auxiliary-file forms (@save table bytes name) fail, as the Standard allows (§15).
|
|
1043
|
+
if (ins.operands.length)
|
|
1044
|
+
return this.store(ins, 0);
|
|
1045
|
+
// Quetzal's PC: the store byte (v4+), or the branch byte(s) (v3), which in this 0OP form
|
|
1046
|
+
// follow the opcode byte directly.
|
|
1047
|
+
const at = this.version <= 3 ? ins.address + 1 : ins.address + ins.length - 1;
|
|
1048
|
+
const p = { kind: ins.name === 'SAVE' ? 'save' : 'restore', store: null, textBuffer: 0, parseBuffer: 0, at };
|
|
1049
|
+
this.state.input.pending = p;
|
|
1050
|
+
this.stop = this.waiting(p);
|
|
1051
|
+
return;
|
|
1052
|
+
}
|
|
1053
|
+
case 'SAVE_UNDO':
|
|
1054
|
+
this.saveUndo(ins.store ?? null);
|
|
1055
|
+
return this.store(ins, 1);
|
|
1056
|
+
case 'RESTORE_UNDO':
|
|
1057
|
+
if (!this.restoreUndo())
|
|
1058
|
+
this.store(ins, 0);
|
|
1059
|
+
return;
|
|
1060
|
+
}
|
|
1061
|
+
fail('illegal-opcode', `${ins.name} isn't implemented`);
|
|
1062
|
+
}
|
|
1063
|
+
outputStream(n, table) {
|
|
1064
|
+
const st = this.state.streams;
|
|
1065
|
+
switch (n) {
|
|
1066
|
+
case 1:
|
|
1067
|
+
st.screen = true;
|
|
1068
|
+
return;
|
|
1069
|
+
case -1:
|
|
1070
|
+
st.screen = false;
|
|
1071
|
+
return;
|
|
1072
|
+
case 2:
|
|
1073
|
+
case -2: {
|
|
1074
|
+
st.transcript = n > 0;
|
|
1075
|
+
const f = this.mem.word(H.flags2);
|
|
1076
|
+
this.mem.setWord(H.flags2, n > 0 ? f | 1 : f & ~1);
|
|
1077
|
+
return;
|
|
1078
|
+
}
|
|
1079
|
+
case 3:
|
|
1080
|
+
if (st.memory.length >= 16)
|
|
1081
|
+
fail('stack-overflow', 'output stream 3 nested more than 16 deep (§7.1.2.1.1)');
|
|
1082
|
+
st.memory.push({ table, count: 0 });
|
|
1083
|
+
return;
|
|
1084
|
+
case -3: {
|
|
1085
|
+
const t = st.memory.pop();
|
|
1086
|
+
if (t)
|
|
1087
|
+
this.mem.setWord(t.table, t.count);
|
|
1088
|
+
return;
|
|
1089
|
+
}
|
|
1090
|
+
case 4:
|
|
1091
|
+
st.commands = true;
|
|
1092
|
+
return;
|
|
1093
|
+
case -4:
|
|
1094
|
+
st.commands = false;
|
|
1095
|
+
return;
|
|
1096
|
+
}
|
|
1097
|
+
}
|
|
1098
|
+
}
|
|
1099
|
+
const cloneFrames = (frames) => frames.map(f => ({ ...f, locals: f.locals.slice(), evalStack: f.evalStack.slice() }));
|
|
1100
|
+
/** A deep copy of plain data (Uint8Arrays included), without structuredClone (not in this package's lib). */
|
|
1101
|
+
function structuredCopy(x) {
|
|
1102
|
+
if (x instanceof Uint8Array)
|
|
1103
|
+
return x.slice();
|
|
1104
|
+
if (Array.isArray(x))
|
|
1105
|
+
return x.map(structuredCopy);
|
|
1106
|
+
if (x && typeof x === 'object') {
|
|
1107
|
+
const out = {};
|
|
1108
|
+
for (const [k, v] of Object.entries(x))
|
|
1109
|
+
out[k] = structuredCopy(v);
|
|
1110
|
+
return out;
|
|
1111
|
+
}
|
|
1112
|
+
return x;
|
|
1113
|
+
}
|
|
1114
|
+
//# sourceMappingURL=machine.js.map
|