animader 0.0.0-stage → 0.1.1

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/src/machine.js ADDED
@@ -0,0 +1,712 @@
1
+ // animader/machine - the Machine class over the machine boundary (plan 02,
2
+ // phase 6).
3
+ //
4
+ // This file mirrors src/wasm_entry.zig's machine section byte for byte.
5
+ // The host writes a document into the shared input zone, calls
6
+ // `anm_machine_load` once, then drives the cached machine:
7
+ //
8
+ // - `anm_machine_step(events_len, now)` takes an 'ANEV' blob (the log
9
+ // records a session parses to) and writes the outputs wire;
10
+ // - `anm_machine_snapshot()` / `anm_machine_restore(blob_len)` move the
11
+ // `'ANMS'` snapshot;
12
+ // - `anm_machine_setInput(name, lane0)` and `anm_machine_fireTrigger`
13
+ // apply one `(input-write ...)` record at the snapshot's current time;
14
+ // - `anm_machine_meta()` writes the `'ANMM'` listing;
15
+ // - `anm_machine_diag()` writes the last load's `'ANMD'` diagnostics;
16
+ // - `anm_machine_emits()` writes the last stepping call's `'ANME'`
17
+ // emitted events, the frame contract's `host.handle` step;
18
+ // - `anm_machine_eject(doc_len, verb_index)` is the phase-5 export.
19
+ //
20
+ // Return convention, mirrored from the entry header: a call answers a byte
21
+ // length (>= 0) or a negative status, never data:
22
+ //
23
+ // -1 diagnostics; the load is kept so `diag()` answers its blob
24
+ // -2 bad call (over a zone cap, a malformed blob, no clean machine)
25
+ // -3 out of memory
26
+ // -4 output over its zone
27
+ // -5 a component over its cap
28
+ // -6 a well-formed snapshot from a different machine
29
+ //
30
+ // The outputs wire (`anm_machine_step`):
31
+ //
32
+ // [count u32]
33
+ // count x { index u16, arity u8, pad u8 = 0, lanes [4]f64 LE }
34
+ //
35
+ // The snapshot is `'ANMS'`: 28-byte header (magic, version, machine_hash
36
+ // u64, time i64, input_count u16, layer_count u16), then per input a value
37
+ // f64 and an armed byte with 7 pad bytes, then per layer a state u16, a
38
+ // blend_from u16, 4 pad bytes and three i64 clocks, then two u64 counters.
39
+ // The meta is `'ANMM'`: a 32-byte header of six u32 counts (inputs, hit
40
+ // areas, outputs, layers, states, transitions) and the records in that
41
+ // order, each followed by its name bytes. The diagnostics blob is `'ANMD'`:
42
+ // magic, version, count, then per record a source byte, a severity byte, a
43
+ // code u16, two u32 span ends, a message length u32 and the message.
44
+ //
45
+ // The emits blob is `'ANME'` (src/Emits.zig), the `'ANEV'` envelope's own
46
+ // shape: a 20-byte header (magic, version, count, strtab_len, the last
47
+ // four bytes zero pad), then per emit a 10-byte record (`at` i64
48
+ // microseconds, `name` u16 index), then the string table of NUL-separated
49
+ // names in first-occurrence order. An emit carries no value.
50
+ //
51
+ // Memory views are re-taken from `exports.memory.buffer` on every call
52
+ // (the pacer discipline): a view taken before a grow would detach.
53
+
54
+ const encoder = new TextEncoder();
55
+ const decoder = new TextDecoder();
56
+
57
+ const MAGIC_ANMS = 0x534d4e41; // 'ANMS' read little-endian
58
+ const SNAPSHOT_VERSION = 1;
59
+ const SNAPSHOT_HEADER_LEN = 28;
60
+ const INPUT_STATE_LEN = 16;
61
+ const LAYER_STATE_LEN = 32;
62
+ const SNAPSHOT_TAIL_LEN = 16;
63
+ const MAX_INPUTS = 256;
64
+ const MAX_LAYERS = 32;
65
+ const NO_INDEX = 0xffff;
66
+
67
+ const MAGIC_ANMD = 0x444d4e41; // 'ANMD' read little-endian
68
+ const DIAGS_VERSION = 1;
69
+ const DIAGS_HEADER_LEN = 12;
70
+ const DIAG_RECORD_LEN = 16;
71
+
72
+ const MAGIC_ANMM = 0x4d4d4e41; // 'ANMM' read little-endian
73
+ const META_VERSION = 1;
74
+ const META_HEADER_LEN = 32;
75
+ const INPUT_REC_LEN = 24;
76
+ const HIT_AREA_REC_LEN = 40;
77
+ const OUTPUT_REC_LEN = 4;
78
+ const LAYER_REC_LEN = 16;
79
+ const STATE_REC_LEN = 24;
80
+ const TRANSITION_REC_LEN = 24;
81
+
82
+ const MAGIC_ANME = 0x454d4e41; // 'ANME' read little-endian
83
+ const EMITS_VERSION = 1;
84
+ const EMITS_HEADER_LEN = 20;
85
+ const EMIT_RECORD_LEN = 10;
86
+
87
+ const OUTPUTS_HEADER_LEN = 4;
88
+ const OUTPUTS_ENTRY_LEN = 36;
89
+
90
+ const STATUS_DIAGNOSTICS = -1;
91
+ const STATUS_BAD_CALL = -2;
92
+ const STATUS_OOM = -3;
93
+ const STATUS_OUTPUT_TOO_SMALL = -4;
94
+ const STATUS_COMPONENT_TOO_LARGE = -5;
95
+ const STATUS_SNAPSHOT_MISMATCH = -6;
96
+
97
+ /** The machine diagnostic ordinals (`Machine.Code`), append-only. */
98
+ export const MACHINE_CODES = {
99
+ state_duplicate: 0,
100
+ transition_cross_layer: 1,
101
+ output_multiply_owned: 2,
102
+ output_undefined_in_state: 3,
103
+ output_arity_mismatch: 4,
104
+ when_references_time: 5,
105
+ transition_cycle: 6,
106
+ expr_invalid: 7,
107
+ verb_invalid: 8,
108
+ input_cap_exceeded: 9,
109
+ layer_cap_exceeded: 10,
110
+ state_cap_exceeded: 11,
111
+ transition_cap_exceeded: 12,
112
+ snapshot_mismatch: 13,
113
+ };
114
+
115
+ /** The status semantics, so a caller does not have to know the integers. */
116
+ export const STATUS = {
117
+ DIAGNOSTICS: STATUS_DIAGNOSTICS,
118
+ BAD_CALL: STATUS_BAD_CALL,
119
+ OOM: STATUS_OOM,
120
+ OUTPUT_TOO_SMALL: STATUS_OUTPUT_TOO_SMALL,
121
+ COMPONENT_TOO_LARGE: STATUS_COMPONENT_TOO_LARGE,
122
+ SNAPSHOT_MISMATCH: STATUS_SNAPSHOT_MISMATCH,
123
+ };
124
+
125
+ const describeStatus = (status) => {
126
+ switch (status) {
127
+ case STATUS_DIAGNOSTICS:
128
+ return "the document has diagnostics";
129
+ case STATUS_BAD_CALL:
130
+ return "bad call";
131
+ case STATUS_OOM:
132
+ return "out of memory";
133
+ case STATUS_OUTPUT_TOO_SMALL:
134
+ return "the output would not fit its zone";
135
+ case STATUS_COMPONENT_TOO_LARGE:
136
+ return "a component is over its cap";
137
+ case STATUS_SNAPSHOT_MISMATCH:
138
+ return "the snapshot belongs to a different machine";
139
+ default:
140
+ return `unknown status ${status}`;
141
+ }
142
+ };
143
+
144
+ /** A boundary failure, a diagnosed document, or a malformed blob. */
145
+ export class MachineError extends Error {
146
+ /**
147
+ * @param {string} message
148
+ * @param {{ status?: number | null, code?: number | null, diagnostics?: Array<object> | null }} [details]
149
+ * `status` is the boundary's negative return (null for a JS-side
150
+ * defect); `code` is the first machine diagnostic code for a refused
151
+ * restore; `diagnostics` is the decoded `'ANMD'` list for a diagnosed
152
+ * load.
153
+ */
154
+ constructor(message, { status = null, code = null, diagnostics = null } = {}) {
155
+ super(message);
156
+ this.name = "MachineError";
157
+ this.status = status;
158
+ this.code = code;
159
+ this.diagnostics = diagnostics;
160
+ }
161
+ }
162
+
163
+ function toBytes(blob) {
164
+ if (blob instanceof Uint8Array) return blob;
165
+ if (blob instanceof ArrayBuffer) return new Uint8Array(blob);
166
+ if (ArrayBuffer.isView(blob)) return new Uint8Array(blob.buffer, blob.byteOffset, blob.byteLength);
167
+ throw new TypeError("animader: expected an ArrayBuffer or a typed array");
168
+ }
169
+
170
+ function micros(value, what) {
171
+ if (!Number.isSafeInteger(value)) {
172
+ throw new MachineError(`animader: ${what} must be a safe integer (microseconds), got ${value}`, {
173
+ status: STATUS_BAD_CALL,
174
+ });
175
+ }
176
+ return value;
177
+ }
178
+
179
+ function viewOf(bytes) {
180
+ return new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
181
+ }
182
+
183
+ function nameAt(bytes, off, len) {
184
+ return decoder.decode(bytes.subarray(off, off + len));
185
+ }
186
+
187
+ /** One `'ANMS'` blob decoded field by field; throws on any defect. */
188
+ export function decodeSnapshot(blob) {
189
+ const bytes = toBytes(blob);
190
+ if (bytes.byteLength < SNAPSHOT_HEADER_LEN) {
191
+ throw new MachineError(`animader: 'ANMS' blob is ${bytes.byteLength} bytes, under the header`);
192
+ }
193
+ const view = viewOf(bytes);
194
+ if (view.getUint32(0, true) !== MAGIC_ANMS) throw new MachineError("animader: 'ANMS' magic is missing");
195
+ if (view.getUint32(4, true) !== SNAPSHOT_VERSION) {
196
+ throw new MachineError(`animader: 'ANMS' version ${view.getUint32(4, true)} is not ${SNAPSHOT_VERSION}`);
197
+ }
198
+ const machineHash = view.getBigUint64(8, true);
199
+ const time = Number(view.getBigInt64(16, true));
200
+ const inputCount = view.getUint16(24, true);
201
+ const layerCount = view.getUint16(26, true);
202
+ if (inputCount > MAX_INPUTS) throw new MachineError(`animader: 'ANMS' input count ${inputCount} over the cap`);
203
+ if (layerCount > MAX_LAYERS) throw new MachineError(`animader: 'ANMS' layer count ${layerCount} over the cap`);
204
+ const need =
205
+ SNAPSHOT_HEADER_LEN + inputCount * INPUT_STATE_LEN + layerCount * LAYER_STATE_LEN + SNAPSHOT_TAIL_LEN;
206
+ if (bytes.byteLength !== need) {
207
+ throw new MachineError("animader: 'ANMS' length does not match its header");
208
+ }
209
+
210
+ const inputs = [];
211
+ let off = SNAPSHOT_HEADER_LEN;
212
+ for (let i = 0; i < inputCount; i++, off += INPUT_STATE_LEN) {
213
+ inputs.push({
214
+ value: view.getBigUint64(off, true),
215
+ armed: view.getUint8(off + 8) !== 0,
216
+ });
217
+ }
218
+ const layers = [];
219
+ for (let i = 0; i < layerCount; i++, off += LAYER_STATE_LEN) {
220
+ layers.push({
221
+ state: view.getUint16(off, true),
222
+ blendFrom: view.getUint16(off + 2, true),
223
+ enteredAt: Number(view.getBigInt64(off + 8, true)),
224
+ blendStart: Number(view.getBigInt64(off + 16, true)),
225
+ fromEnteredAt: Number(view.getBigInt64(off + 24, true)),
226
+ });
227
+ }
228
+ return {
229
+ machineHash,
230
+ time,
231
+ inputCount,
232
+ layerCount,
233
+ inputs,
234
+ layers,
235
+ chainCapHits: Number(view.getBigUint64(off, true)),
236
+ unknownInputHits: Number(view.getBigUint64(off + 8, true)),
237
+ };
238
+ }
239
+
240
+ /** The `anm_machine_step` outputs wire, decoded; throws on any defect. */
241
+ export function decodeOutputs(blob) {
242
+ const bytes = toBytes(blob);
243
+ if (bytes.byteLength < OUTPUTS_HEADER_LEN) {
244
+ throw new MachineError(`animader: outputs wire is ${bytes.byteLength} bytes, under the header`);
245
+ }
246
+ const view = viewOf(bytes);
247
+ const count = view.getUint32(0, true);
248
+ if (bytes.byteLength !== OUTPUTS_HEADER_LEN + count * OUTPUTS_ENTRY_LEN) {
249
+ throw new MachineError("animader: outputs wire length does not match its header");
250
+ }
251
+ const out = [];
252
+ for (let i = 0; i < count; i++) {
253
+ const off = OUTPUTS_HEADER_LEN + i * OUTPUTS_ENTRY_LEN;
254
+ const bits = [];
255
+ const value = [];
256
+ for (let lane = 0; lane < 4; lane++) {
257
+ const b = view.getBigUint64(off + 4 + lane * 8, true);
258
+ bits.push(b);
259
+ value.push(view.getFloat64(off + 4 + lane * 8, true));
260
+ }
261
+ out.push({ index: view.getUint16(off, true), arity: view.getUint8(off + 2), value, bits });
262
+ }
263
+ return out;
264
+ }
265
+
266
+ /** The `'ANMM'` listing decoded; throws on any defect. */
267
+ export function decodeMeta(blob) {
268
+ const bytes = toBytes(blob);
269
+ if (bytes.byteLength < META_HEADER_LEN) {
270
+ throw new MachineError(`animader: 'ANMM' blob is ${bytes.byteLength} bytes, under the header`);
271
+ }
272
+ const view = viewOf(bytes);
273
+ if (view.getUint32(0, true) !== MAGIC_ANMM) throw new MachineError("animader: 'ANMM' magic is missing");
274
+ if (view.getUint32(4, true) !== META_VERSION) {
275
+ throw new MachineError(`animader: 'ANMM' version ${view.getUint32(4, true)} is not ${META_VERSION}`);
276
+ }
277
+ const counts = {
278
+ inputs: view.getUint32(8, true),
279
+ hitAreas: view.getUint32(12, true),
280
+ outputs: view.getUint32(16, true),
281
+ layers: view.getUint32(20, true),
282
+ states: view.getUint32(24, true),
283
+ transitions: view.getUint32(28, true),
284
+ };
285
+ let off = META_HEADER_LEN;
286
+ const take = (len, what) => {
287
+ if (off + len > bytes.byteLength) throw new MachineError(`animader: 'ANMM' ${what} runs past the blob`);
288
+ const start = off;
289
+ off += len;
290
+ return start;
291
+ };
292
+ const nameOf = (recOff, nameLen) => nameAt(bytes, recOff, nameLen);
293
+
294
+ const inputs = [];
295
+ for (let i = 0; i < counts.inputs; i++) {
296
+ const rec = take(INPUT_REC_LEN, "input record");
297
+ const nameLen = view.getUint16(rec, true);
298
+ inputs.push({
299
+ name: nameOf(take(nameLen, "input name"), nameLen),
300
+ kind: ["number", "bool", "trigger"][view.getUint8(rec + 2)],
301
+ valueSpan: { start: view.getUint32(rec + 4, true), end: view.getUint32(rec + 8, true) },
302
+ defaultBits: view.getBigUint64(rec + 16, true),
303
+ });
304
+ }
305
+ const hitAreas = [];
306
+ for (let i = 0; i < counts.hitAreas; i++) {
307
+ const rec = take(HIT_AREA_REC_LEN, "hit-area record");
308
+ const nameLen = view.getUint16(rec, true);
309
+ hitAreas.push({
310
+ name: nameOf(take(nameLen, "hit-area name"), nameLen),
311
+ rect: [0, 1, 2, 3].map((lane) => view.getFloat64(rec + 8 + lane * 8, true)),
312
+ });
313
+ }
314
+ const outputs = [];
315
+ for (let i = 0; i < counts.outputs; i++) {
316
+ const rec = take(OUTPUT_REC_LEN, "output record");
317
+ const nameLen = view.getUint16(rec, true);
318
+ outputs.push({ name: nameOf(take(nameLen, "output name"), nameLen), arity: view.getUint8(rec + 2) });
319
+ }
320
+ const layers = [];
321
+ for (let i = 0; i < counts.layers; i++) {
322
+ const rec = take(LAYER_REC_LEN, "layer record");
323
+ const nameLen = view.getUint16(rec, true);
324
+ layers.push({
325
+ name: nameOf(take(nameLen, "layer name"), nameLen),
326
+ entry: view.getUint16(rec + 4, true),
327
+ span: { start: view.getUint32(rec + 8, true), end: view.getUint32(rec + 12, true) },
328
+ });
329
+ }
330
+ const states = [];
331
+ for (let i = 0; i < counts.states; i++) {
332
+ const rec = take(STATE_REC_LEN, "state record");
333
+ const nameLen = view.getUint16(rec, true);
334
+ states.push({
335
+ name: nameOf(take(nameLen, "state name"), nameLen),
336
+ layer: view.getUint16(rec + 2, true),
337
+ origin: view.getUint8(rec + 4),
338
+ span: { start: view.getUint32(rec + 8, true), end: view.getUint32(rec + 12, true) },
339
+ verbSpan: { start: view.getUint32(rec + 16, true), end: view.getUint32(rec + 20, true) },
340
+ });
341
+ }
342
+ const transitions = [];
343
+ for (let i = 0; i < counts.transitions; i++) {
344
+ const rec = take(TRANSITION_REC_LEN, "transition record");
345
+ transitions.push({
346
+ from: view.getUint16(rec, true),
347
+ layer: view.getUint16(rec + 2, true),
348
+ to: view.getUint16(rec + 4, true),
349
+ origin: view.getUint8(rec + 6),
350
+ span: { start: view.getUint32(rec + 8, true), end: view.getUint32(rec + 12, true) },
351
+ verbSpan: { start: view.getUint32(rec + 16, true), end: view.getUint32(rec + 20, true) },
352
+ });
353
+ }
354
+ if (off !== bytes.byteLength) throw new MachineError("animader: 'ANMM' has trailing bytes");
355
+ return { counts, inputs, hitAreas, outputs, layers, states, transitions };
356
+ }
357
+
358
+ /** The `'ANMD'` diagnostics decoded; throws on any defect. */
359
+ export function decodeDiags(blob) {
360
+ const bytes = toBytes(blob);
361
+ if (bytes.byteLength < DIAGS_HEADER_LEN) {
362
+ throw new MachineError(`animader: 'ANMD' blob is ${bytes.byteLength} bytes, under the header`);
363
+ }
364
+ const view = viewOf(bytes);
365
+ if (view.getUint32(0, true) !== MAGIC_ANMD) throw new MachineError("animader: 'ANMD' magic is missing");
366
+ if (view.getUint32(4, true) !== DIAGS_VERSION) {
367
+ throw new MachineError(`animader: 'ANMD' version ${view.getUint32(4, true)} is not ${DIAGS_VERSION}`);
368
+ }
369
+ const count = view.getUint32(8, true);
370
+ const diagnostics = [];
371
+ let off = DIAGS_HEADER_LEN;
372
+ for (let i = 0; i < count; i++) {
373
+ if (off + DIAG_RECORD_LEN > bytes.byteLength) throw new MachineError("animader: 'ANMD' record runs past the blob");
374
+ const msgLen = view.getUint32(off + 12, true);
375
+ if (off + DIAG_RECORD_LEN + msgLen > bytes.byteLength) {
376
+ throw new MachineError("animader: 'ANMD' message runs past the blob");
377
+ }
378
+ diagnostics.push({
379
+ source: view.getUint8(off),
380
+ severity: view.getUint8(off + 1),
381
+ code: view.getUint16(off + 2, true),
382
+ span: { start: view.getUint32(off + 4, true), end: view.getUint32(off + 8, true) },
383
+ message: nameAt(bytes, off + DIAG_RECORD_LEN, msgLen),
384
+ });
385
+ off += DIAG_RECORD_LEN + msgLen;
386
+ }
387
+ return { count, diagnostics };
388
+ }
389
+
390
+ /**
391
+ * The `'ANME'` emitted-events blob decoded, `anm_machine_emits()`'s wire
392
+ * (src/Emits.zig): `{ count, emits }` with each emit `{ name, at }`, the
393
+ * name borrowed from the machine's table and `at` the entry instant in
394
+ * microseconds. Throws on any defect.
395
+ * @param {BufferSource} blob
396
+ * @returns {{ count: number, emits: Array<{ name: string, at: number }> }}
397
+ */
398
+ export function decodeEmits(blob) {
399
+ const bytes = toBytes(blob);
400
+ if (bytes.byteLength < EMITS_HEADER_LEN) {
401
+ throw new MachineError(`animader: 'ANME' blob is ${bytes.byteLength} bytes, under the header`);
402
+ }
403
+ const view = viewOf(bytes);
404
+ if (view.getUint32(0, true) !== MAGIC_ANME) throw new MachineError("animader: 'ANME' magic is missing");
405
+ if (view.getUint32(4, true) !== EMITS_VERSION) {
406
+ throw new MachineError(`animader: 'ANME' version ${view.getUint32(4, true)} is not ${EMITS_VERSION}`);
407
+ }
408
+ const count = view.getUint32(8, true);
409
+ const strtabLen = view.getUint32(12, true);
410
+ const strtabOff = EMITS_HEADER_LEN + count * EMIT_RECORD_LEN;
411
+ if (strtabOff + strtabLen !== bytes.byteLength) {
412
+ throw new MachineError("animader: 'ANME' length does not match its header");
413
+ }
414
+
415
+ const strings = [];
416
+ let pos = strtabOff;
417
+ while (pos < bytes.byteLength) {
418
+ const end = bytes.indexOf(0, pos);
419
+ if (end === -1) {
420
+ strings.push(decoder.decode(bytes.subarray(pos)));
421
+ break;
422
+ }
423
+ strings.push(decoder.decode(bytes.subarray(pos, end)));
424
+ pos = end + 1;
425
+ }
426
+
427
+ const emits = [];
428
+ for (let i = 0; i < count; i++) {
429
+ const off = EMITS_HEADER_LEN + i * EMIT_RECORD_LEN;
430
+ const raw = view.getUint16(off + 8, true);
431
+ if (raw >= strings.length) {
432
+ throw new MachineError(`animader: 'ANME' record name index ${raw} has no table entry`);
433
+ }
434
+ emits.push({ name: strings[raw], at: Number(view.getBigInt64(off, true)) });
435
+ }
436
+ return { count, emits };
437
+ }
438
+
439
+ const IMPORTS = { env: { sjon_host_invoke_plugin: () => 0 } };
440
+
441
+ /** The loaded machine boundary and the JS-side blob decoders. */
442
+ export class Machine {
443
+ #e;
444
+ #doc;
445
+ #meta;
446
+ #outputsWire = null;
447
+ #emits = [];
448
+
449
+ /**
450
+ * Instantiate the artifact synchronously (node, tests) and load a
451
+ * document. `bytes` is the built animader.wasm, e.g. from
452
+ * `readFileSync`. Throws `MachineError` on a diagnosed document.
453
+ * @param {BufferSource} bytes
454
+ * @param {string} text
455
+ * @returns {Machine}
456
+ */
457
+ static loadSync(bytes, text) {
458
+ return new Machine(new WebAssembly.Instance(new WebAssembly.Module(bytes), IMPORTS), text);
459
+ }
460
+
461
+ /**
462
+ * Instantiate the artifact from bytes or a compiled module (browser) and
463
+ * load a document.
464
+ * @param {BufferSource | WebAssembly.Module} source
465
+ * @param {string} text
466
+ * @returns {Promise<Machine>}
467
+ */
468
+ static async load(source, text) {
469
+ const instance =
470
+ source instanceof WebAssembly.Module
471
+ ? await WebAssembly.instantiate(source, IMPORTS)
472
+ : (await WebAssembly.instantiate(source, IMPORTS)).instance;
473
+ return new Machine(instance, text);
474
+ }
475
+
476
+ /** @param {WebAssembly.Instance} instance @param {string} text */
477
+ constructor(instance, text) {
478
+ this.#e = instance.exports;
479
+ if (typeof this.#e.animader_abiVersion !== "function" || this.#e.animader_abiVersion() !== 1) {
480
+ throw new MachineError("animader: unsupported or missing ABI version 1");
481
+ }
482
+ for (const name of [
483
+ "anm_log_inPtr",
484
+ "anm_log_outPtr",
485
+ "anm_log_outLen",
486
+ "anm_machine_load",
487
+ "anm_machine_step",
488
+ "anm_machine_snapshot",
489
+ "anm_machine_restore",
490
+ "anm_machine_setInput",
491
+ "anm_machine_fireTrigger",
492
+ "anm_machine_meta",
493
+ "anm_machine_diag",
494
+ "anm_machine_emits",
495
+ "anm_machine_eject",
496
+ "memory",
497
+ ]) {
498
+ if (this.#e[name] === undefined) throw new MachineError(`animader: missing export ${name}`);
499
+ }
500
+ this.#doc = text;
501
+ this.#load(text);
502
+ }
503
+
504
+ /** The boundary ABI version (1). */
505
+ get abiVersion() {
506
+ return this.#e.animader_abiVersion();
507
+ }
508
+
509
+ #writeInput(bytes) {
510
+ const e = this.#e;
511
+ new Uint8Array(e.memory.buffer, e.anm_log_inPtr(), bytes.byteLength).set(bytes);
512
+ }
513
+
514
+ #output(len) {
515
+ return new Uint8Array(this.#e.memory.buffer, this.#e.anm_log_outPtr(), len).slice();
516
+ }
517
+
518
+ #check(status, what) {
519
+ if (status < 0) {
520
+ throw new MachineError(`animader: ${what}: ${describeStatus(status)} (status ${status})`, { status });
521
+ }
522
+ return status;
523
+ }
524
+
525
+ #load(text) {
526
+ const bytes = encoder.encode(text);
527
+ this.#writeInput(bytes);
528
+ const status = this.#e.anm_machine_load(bytes.byteLength);
529
+ if (status === STATUS_DIAGNOSTICS) {
530
+ const blob = this.#output(this.#e.anm_machine_diag());
531
+ const diagnostics = decodeDiags(blob).diagnostics;
532
+ throw new MachineError(
533
+ `animader: the machine document has diagnostics (${diagnostics.length} record(s))`,
534
+ { status, diagnostics },
535
+ );
536
+ }
537
+ this.#check(status, "load");
538
+ this.#meta = decodeMeta(this.#output(this.#e.anm_machine_meta()));
539
+ }
540
+
541
+ /**
542
+ * Apply an 'ANEV' events blob (or null for an empty batch) and advance
543
+ * to `now` (session microseconds). The outputs at `now` are cached for
544
+ * `outputs()` and the emitted events for `emits()`.
545
+ * @param {Uint8Array | ArrayBuffer | null} events
546
+ * @param {number} now
547
+ * @returns {Machine}
548
+ */
549
+ step(events, now) {
550
+ const at = micros(now, "now");
551
+ let bytes = null;
552
+ if (events !== null && events !== undefined) {
553
+ bytes = toBytes(events);
554
+ this.#writeInput(bytes);
555
+ }
556
+ // A failed step leaves no emits (the boundary's own rule), so the
557
+ // cache is cleared before the call, never after a throw.
558
+ this.#emits = [];
559
+ const status = this.#e.anm_machine_step(bytes === null ? 0 : bytes.byteLength, BigInt(at));
560
+ this.#check(status, "step");
561
+ this.#outputsWire = this.#output(status);
562
+ this.#syncEmits();
563
+ return this;
564
+ }
565
+
566
+ /**
567
+ * The last stepping call's emitted events, in order: `{ name, at }` per
568
+ * `(emit ...)` of every state the call entered, `at` the entry instant
569
+ * in microseconds (not necessarily the step's `now`). A clean load
570
+ * reports the empty list until a step runs.
571
+ * @returns {Array<{ name: string, at: number }>}
572
+ */
573
+ emits() {
574
+ return this.#emits.slice();
575
+ }
576
+
577
+ /**
578
+ * The outputs evaluated at the last `step`'s time, in declaration order:
579
+ * `{ name, index, arity, value: [4 numbers] }`. Throws when no step has
580
+ * run.
581
+ * @returns {Array<{ name: string, index: number, arity: number, value: number[] }>}
582
+ */
583
+ outputs() {
584
+ if (this.#outputsWire === null) {
585
+ throw new MachineError("animader: outputs are evaluated by step; call step first", {
586
+ status: STATUS_BAD_CALL,
587
+ });
588
+ }
589
+ return decodeOutputs(this.#outputsWire).map((o) => {
590
+ const decl = this.#meta.outputs[o.index];
591
+ return { name: decl === undefined ? null : decl.name, index: o.index, arity: o.arity, value: o.value };
592
+ });
593
+ }
594
+
595
+ /** The raw outputs wire of the last `step`, or null before one. */
596
+ outputsWire() {
597
+ return this.#outputsWire === null ? null : this.#outputsWire.slice();
598
+ }
599
+
600
+ /**
601
+ * The current `'ANMS'` snapshot.
602
+ * @returns {Uint8Array}
603
+ */
604
+ snapshot() {
605
+ const status = this.#e.anm_machine_snapshot();
606
+ this.#check(status, "snapshot");
607
+ return this.#output(status);
608
+ }
609
+
610
+ /**
611
+ * Replace the cached snapshot from an `'ANMS'` blob. A well-formed blob
612
+ * from a different machine throws with status -6 and the
613
+ * `snapshot_mismatch` code.
614
+ * @param {Uint8Array} blob
615
+ * @returns {Machine}
616
+ */
617
+ restore(blob) {
618
+ const bytes = toBytes(blob);
619
+ this.#writeInput(bytes);
620
+ const status = this.#e.anm_machine_restore(bytes.byteLength);
621
+ if (status === STATUS_SNAPSHOT_MISMATCH) {
622
+ throw new MachineError("animader: the snapshot belongs to a different machine (status -6)", {
623
+ status,
624
+ code: MACHINE_CODES.snapshot_mismatch,
625
+ });
626
+ }
627
+ this.#check(status, "restore");
628
+ return this;
629
+ }
630
+
631
+ /**
632
+ * Apply one `(input-write ...)` record for `name` at the snapshot's current
633
+ * time, exactly as `step` would. `value` is lane 0 (i32): a number
634
+ * input takes it, a bool takes truthiness, a trigger is armed.
635
+ * @param {string} name
636
+ * @param {number} [value]
637
+ * @returns {Machine}
638
+ */
639
+ setInput(name, value = 1) {
640
+ return this.#input(name, value, "setInput");
641
+ }
642
+
643
+ /**
644
+ * Arm the named input for the current instant: `setInput(name, 1)` with
645
+ * the trigger's own name.
646
+ * @param {string} name
647
+ * @returns {Machine}
648
+ */
649
+ fireTrigger(name) {
650
+ return this.#input(name, 1, "fireTrigger");
651
+ }
652
+
653
+ #input(name, value, what) {
654
+ if (!Number.isInteger(value) || value < -2147483648 || value > 2147483647) {
655
+ throw new MachineError(`animader: ${what} value must be an i32, got ${value}`, {
656
+ status: STATUS_BAD_CALL,
657
+ });
658
+ }
659
+ const bytes = encoder.encode(name);
660
+ this.#writeInput(bytes);
661
+ this.#emits = [];
662
+ const status =
663
+ what === "setInput"
664
+ ? this.#e.anm_machine_setInput(bytes.byteLength, value)
665
+ : this.#e.anm_machine_fireTrigger(bytes.byteLength);
666
+ this.#check(status, what);
667
+ this.#syncEmits();
668
+ return this;
669
+ }
670
+
671
+ /** Read and decode the boundary's current 'ANME' blob. */
672
+ #syncEmits() {
673
+ const status = this.#e.anm_machine_emits();
674
+ this.#check(status, "emits");
675
+ this.#emits = decodeEmits(this.#output(status)).emits;
676
+ }
677
+
678
+ /**
679
+ * The loaded machine's `'ANMM'` listing.
680
+ * @returns {Uint8Array}
681
+ */
682
+ meta() {
683
+ const status = this.#e.anm_machine_meta();
684
+ this.#check(status, "meta");
685
+ return this.#output(status);
686
+ }
687
+
688
+ /**
689
+ * The last load's `'ANMD'` diagnostics: a zero-count blob for a clean
690
+ * document.
691
+ * @returns {Uint8Array}
692
+ */
693
+ diag() {
694
+ const status = this.#e.anm_machine_diag();
695
+ this.#check(status, "diag");
696
+ return this.#output(status);
697
+ }
698
+
699
+ /**
700
+ * The document with the verb at `verbIndex` replaced by its emitted
701
+ * core forms, the verb kept above them as a comment.
702
+ * @param {number} verbIndex
703
+ * @returns {string}
704
+ */
705
+ eject(verbIndex) {
706
+ const bytes = encoder.encode(this.#doc);
707
+ this.#writeInput(bytes);
708
+ const status = this.#e.anm_machine_eject(bytes.byteLength, verbIndex);
709
+ this.#check(status, "eject");
710
+ return decoder.decode(this.#output(status));
711
+ }
712
+ }