animader 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/src/session.js ADDED
@@ -0,0 +1,480 @@
1
+ // animader/session - the composed frame contract's JS glue (plan 03,
2
+ // phase 4).
3
+ //
4
+ // `Session` holds one machine and one bridge over a shared
5
+ // `animader.wasm` instance, the host's sinks, an optional score shaped
6
+ // like pacer's public `setControl`/`eval` pair, and the registered
7
+ // consumers. `step(events, now)` executes the frame contract's order:
8
+ //
9
+ // derived = union c.derive(events) ; (input-write ...) records, in memory only
10
+ // writes_c = union c.eval(now) ; before the machine steps
11
+ // m = machine.step(merge(events, derived), now)
12
+ // writes_m = bridge.encode(m.outputs)
13
+ // apply registry 2 of writes_c, writes_m ; setControl, BEFORE the score
14
+ // writes_p = score.eval(now) ; its :expr tracks read the pins
15
+ // apply registry 0/1 of writes_c ; raw input first
16
+ // apply registry 0/1 of writes_m ; then the machine
17
+ // apply registry 0/1 of writes_p ; then the score: later wins
18
+ // c.emit(writes_c + writes_m + writes_p, now)
19
+ // host.handle(m.emits) ; the machine's emits, last
20
+ //
21
+ // One rule orders it: the more authored a source is, the later it lands.
22
+ // The score and the consumers are the host's, so the sinks are callbacks
23
+ // (`host.setControl`/`setUniform`/`setParam`/`handle`); a recording fake in
24
+ // the tests, a real pngine/audio/pacer host in production.
25
+ //
26
+ // The machine document and the bridge document are separate because the
27
+ // bridge boundary validates against `schema/bridge.sjon` alone: `machine`
28
+ // is the aggregate document, `bridge` its `(output ...)`/`(route ...)`
29
+ // subset in the machine's declaration order.
30
+ //
31
+ // A consumer is `{ derive(records), eval(now), emit(writes, now) }`, all
32
+ // optional; `derive` returns `(input-write ...)` records stamped with the
33
+ // causing record's `:at` (never appended to the log, never re-stamped) and
34
+ // `eval` returns its own 'PACW'. `emit` receives the merged frame's
35
+ // entries in order. Consumers are the JS glue's business because a sibling
36
+ // runtime is its own wasm.
37
+ //
38
+ // The host's `handle(emits)` step is the contract's last: the boundary
39
+ // carries each stepping call's emitted events out as the 'ANME' blob
40
+ // (`anm_machine_emits`, read after every `machine.step`, `setInput` and
41
+ // `fireTrigger`), and this glue concatenates the frame's calls into the
42
+ // one list the native single-step `Machine.step` would have returned,
43
+ // handing it to the host after every consumer's `emit`.
44
+ //
45
+ // The score's `eval` receives the frame's `now` in session microseconds;
46
+ // a pacer-backed score adapter converts to its own seconds (divide by
47
+ // 1e6), because pacer's clock is f64 seconds while this runtime's is i64
48
+ // microseconds.
49
+
50
+ import { Machine, decodeMeta } from "./machine.js";
51
+ import { decodeWrites, laneValue } from "./route.js";
52
+
53
+ const encoder = new TextEncoder();
54
+ const decoder = new TextDecoder();
55
+
56
+ const IMPORTS = { env: { sjon_host_invoke_plugin: () => 0 } };
57
+
58
+ // 'ANEV' (src/Abi.zig): the blob the log parses to.
59
+ const ANEV_MAGIC = 0x56454e41;
60
+ const ANEV_VERSION = 1;
61
+ const ANEV_HEADER_LEN = 20;
62
+ const ANEV_RECORD_LEN = 32;
63
+ const MAX_EVENTS = 1 << 20;
64
+ const NO_STR = 0xffff;
65
+
66
+ /** Kind ordinals, append-only on the wire (`EventLog.Kind`). */
67
+ const KIND_NAMES = [
68
+ "resize",
69
+ "down",
70
+ "move",
71
+ "up",
72
+ "wheel",
73
+ "key",
74
+ "custom",
75
+ "input-write",
76
+ "mark",
77
+ "adjust",
78
+ "reset",
79
+ "cancel",
80
+ "derived",
81
+ ];
82
+
83
+ const PACT_MAGIC = 0x54434150;
84
+ const BRIDGE_HANDLE = 1;
85
+
86
+ const CONTROL_REGISTRY = 2;
87
+ const UNIFORM_REGISTRY = 0;
88
+ const PARAM_REGISTRY = 1;
89
+
90
+ /** A boundary failure, a diagnosed document or a malformed blob. */
91
+ export class SessionError extends Error {
92
+ constructor(message) {
93
+ super(message);
94
+ this.name = "SessionError";
95
+ }
96
+ }
97
+
98
+ function toBytes(blob) {
99
+ if (blob instanceof Uint8Array) return blob;
100
+ if (blob instanceof ArrayBuffer) return new Uint8Array(blob);
101
+ if (ArrayBuffer.isView(blob)) return new Uint8Array(blob.buffer, blob.byteOffset, blob.byteLength);
102
+ throw new TypeError("animader: expected an ArrayBuffer or a typed array");
103
+ }
104
+
105
+ function micros(value, what) {
106
+ if (!Number.isSafeInteger(value) || value < 0) {
107
+ throw new SessionError(`animader: ${what} must be a safe, non-negative integer (microseconds), got ${value}`);
108
+ }
109
+ return value;
110
+ }
111
+
112
+ /**
113
+ * The 'ANEV' decoder this glue needs for `derive`: the same record shape
114
+ * `Log.decode` answers. Kept local so the module has no import cycle with
115
+ * `index.js`; the session test pins the same blob through `Log.decode`.
116
+ *
117
+ * @param {Uint8Array} bytes
118
+ * @returns {{ records: Array<object>, strings: string[] }}
119
+ */
120
+ function decodeEvents(bytes) {
121
+ if (bytes.byteLength < ANEV_HEADER_LEN) {
122
+ throw new SessionError(`animader: 'ANEV' blob is ${bytes.byteLength} bytes, under the header`);
123
+ }
124
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
125
+ if (view.getUint32(0, true) !== ANEV_MAGIC) throw new SessionError("animader: 'ANEV' magic is missing");
126
+ if (view.getUint32(4, true) !== ANEV_VERSION) {
127
+ throw new SessionError(`animader: 'ANEV' version ${view.getUint32(4, true)} is not ${ANEV_VERSION}`);
128
+ }
129
+ const count = view.getUint32(8, true);
130
+ const strtabLen = view.getUint32(12, true);
131
+ if (count > MAX_EVENTS) throw new SessionError(`animader: 'ANEV' count ${count} over the ceiling`);
132
+ if (ANEV_HEADER_LEN + count * ANEV_RECORD_LEN + strtabLen !== bytes.byteLength) {
133
+ throw new SessionError("animader: 'ANEV' length does not match its header");
134
+ }
135
+
136
+ const strings = [];
137
+ const strtabOff = ANEV_HEADER_LEN + count * ANEV_RECORD_LEN;
138
+ let pos = strtabOff;
139
+ while (pos < bytes.byteLength) {
140
+ const end = bytes.indexOf(0, pos);
141
+ if (end === -1) {
142
+ strings.push(decoder.decode(bytes.subarray(pos)));
143
+ break;
144
+ }
145
+ strings.push(decoder.decode(bytes.subarray(pos, end)));
146
+ pos = end + 1;
147
+ }
148
+
149
+ const records = [];
150
+ for (let i = 0; i < count; i++) {
151
+ const o = ANEV_HEADER_LEN + i * ANEV_RECORD_LEN;
152
+ const kind = KIND_NAMES[view.getUint8(o + 8)];
153
+ if (kind === undefined) throw new SessionError(`animader: 'ANEV' record kind ${view.getUint8(o + 8)} is not a Kind`);
154
+ const at = view.getBigInt64(o, true);
155
+ if (at < 0n || at > BigInt(Number.MAX_SAFE_INTEGER)) {
156
+ throw new SessionError(`animader: 'ANEV' record :at ${at} is outside the writer's domain`);
157
+ }
158
+ const strRaw = view.getUint16(o + 18, true);
159
+ records.push({
160
+ at: Number(at),
161
+ kind,
162
+ ptr: view.getUint8(o + 9),
163
+ buttons: view.getUint8(o + 10),
164
+ mods: view.getUint8(o + 11),
165
+ x: view.getUint16(o + 12, true),
166
+ y: view.getUint16(o + 14, true),
167
+ p: view.getUint16(o + 16, true),
168
+ str: strRaw === NO_STR ? null : (strings[strRaw] ?? null),
169
+ v: [view.getInt32(o + 20, true), view.getInt32(o + 24, true), view.getInt32(o + 28, true)],
170
+ });
171
+ }
172
+ return { records, strings };
173
+ }
174
+
175
+ /** pacer's 'PACT' blob for the host join, `{name, ncomp, registry}`. */
176
+ export function encodeTargets(targets) {
177
+ const names = targets.map((t) => encoder.encode(t.name));
178
+ let len = 8;
179
+ for (const n of names) len += 4 + n.length;
180
+ const blob = new Uint8Array(len);
181
+ const dv = new DataView(blob.buffer);
182
+ dv.setUint32(0, PACT_MAGIC, true);
183
+ dv.setUint32(4, targets.length, true);
184
+ let pos = 8;
185
+ targets.forEach((t, i) => {
186
+ blob[pos] = t.ncomp;
187
+ blob[pos + 1] = t.registry;
188
+ dv.setUint16(pos + 2, names[i].length, true);
189
+ pos += 4;
190
+ blob.set(names[i], pos);
191
+ pos += names[i].length;
192
+ });
193
+ return blob;
194
+ }
195
+
196
+ /**
197
+ * An 'ANEV' blob holding records [start, end) of `blob`, keeping the full
198
+ * string table so every record's index stays valid (the machine corpus
199
+ * test's slice, the exact-time seam for derived records).
200
+ */
201
+ function sliceBlob(blob, start, end) {
202
+ const view = new DataView(blob.buffer, blob.byteOffset, blob.byteLength);
203
+ const count = view.getUint32(8, true);
204
+ const strtabLen = view.getUint32(12, true);
205
+ const strtabOff = ANEV_HEADER_LEN + count * ANEV_RECORD_LEN;
206
+ const out = new Uint8Array(ANEV_HEADER_LEN + (end - start) * ANEV_RECORD_LEN + strtabLen);
207
+ out.set(blob.subarray(0, ANEV_HEADER_LEN), 0);
208
+ new DataView(out.buffer).setUint32(8, end - start, true);
209
+ out.set(blob.subarray(ANEV_HEADER_LEN + start * ANEV_RECORD_LEN, ANEV_HEADER_LEN + end * ANEV_RECORD_LEN), ANEV_HEADER_LEN);
210
+ out.set(blob.subarray(strtabOff, strtabOff + strtabLen), ANEV_HEADER_LEN + (end - start) * ANEV_RECORD_LEN);
211
+ return out;
212
+ }
213
+
214
+ /**
215
+ * Apply every registry-2 entry through `host.setControl`, in wire order.
216
+ * The third argument is the emitter (`machine`, `score` or `consumer`),
217
+ * the frame contract's "more authored lands later" tag.
218
+ */
219
+ function applyControls(entries, host, source) {
220
+ if (typeof host.setControl !== "function") return;
221
+ for (const entry of entries) {
222
+ if (entry.registry !== CONTROL_REGISTRY) continue;
223
+ host.setControl(entry.targetId, laneValue(entry), source);
224
+ }
225
+ }
226
+
227
+ /** Apply registries 0 and 1 through `host.setUniform`/`host.setParam`. */
228
+ function applyValues(entries, host, source) {
229
+ for (const entry of entries) {
230
+ if (entry.registry === UNIFORM_REGISTRY) {
231
+ if (typeof host.setUniform === "function") host.setUniform(entry.targetId, laneValue(entry), source);
232
+ } else if (entry.registry === PARAM_REGISTRY) {
233
+ if (typeof host.setParam === "function") host.setParam(entry.targetId, laneValue(entry), source);
234
+ }
235
+ }
236
+ }
237
+
238
+ /** The composed session over one artifact instance. */
239
+ export class Session {
240
+ #e;
241
+ #machine;
242
+ #bridgeHandle = 0;
243
+ #host;
244
+ #score;
245
+ #consumers = [];
246
+ #inputKinds = new Map();
247
+
248
+ /**
249
+ * Instantiate the artifact synchronously (node, tests).
250
+ * @param {BufferSource} bytes
251
+ * @param {object} opts see the constructor
252
+ * @returns {Session}
253
+ */
254
+ static loadSync(bytes, opts) {
255
+ return new Session(new WebAssembly.Instance(new WebAssembly.Module(bytes), IMPORTS), opts);
256
+ }
257
+
258
+ /**
259
+ * Instantiate the artifact from bytes or a compiled module (browser).
260
+ * @param {BufferSource | WebAssembly.Module} source
261
+ * @param {object} opts see the constructor
262
+ * @returns {Promise<Session>}
263
+ */
264
+ static async load(source, opts) {
265
+ const instance =
266
+ source instanceof WebAssembly.Module
267
+ ? await WebAssembly.instantiate(source, IMPORTS)
268
+ : (await WebAssembly.instantiate(source, IMPORTS)).instance;
269
+ return new Session(instance, opts);
270
+ }
271
+
272
+ /**
273
+ * @param {WebAssembly.Instance} instance one animader.wasm instance,
274
+ * shared by the machine and the bridge (one of each per instance, the
275
+ * module-global caches).
276
+ * @param {object} opts
277
+ * @param {string} opts.machine the machine document (the machine + bridge
278
+ * aggregate)
279
+ * @param {string} opts.bridge the `(output ...)`/`(route ...)` subset,
280
+ * in the machine's declaration order (the bridge boundary validates
281
+ * against the bridge manifest alone)
282
+ * @param {Array<{name: string, ncomp: number, registry: number}>} opts.targets
283
+ * the host join in target_id order
284
+ * @param {number} [opts.registryCount] the host's registry count
285
+ * @param {object} [opts.host] `{ setControl, setUniform, setParam }`
286
+ * @param {object | null} [opts.score] pacer's pair: `setControl(id, value)`
287
+ * and `eval(now)` returning 'PACW'
288
+ * @param {Array<object>} [opts.consumers]
289
+ */
290
+ constructor(instance, opts = {}) {
291
+ const { machine, bridge, targets, registryCount = 0, host = {}, score = null, consumers = [] } = opts;
292
+ if (typeof machine !== "string" || typeof bridge !== "string") {
293
+ throw new SessionError("animader: session needs the machine and bridge documents");
294
+ }
295
+ if (!Array.isArray(targets) || targets.length === 0) {
296
+ throw new SessionError("animader: session needs the host's target join");
297
+ }
298
+ this.#e = instance.exports;
299
+ this.#host = host;
300
+ this.#score = score;
301
+ this.#machine = new Machine(instance, machine);
302
+ this.#inputKinds = new Map(decodeMeta(this.#machine.meta()).inputs.map((i) => [i.name, i.kind]));
303
+ this.#loadBridge(bridge, targets, registryCount);
304
+ for (const consumer of consumers) this.registerConsumer(consumer);
305
+ }
306
+
307
+ /** Register one consumer, in call order. @param {object} consumer @returns {Session} */
308
+ registerConsumer(consumer) {
309
+ if (consumer === null || typeof consumer !== "object") {
310
+ throw new SessionError("animader: a consumer must be an object");
311
+ }
312
+ this.#consumers.push(consumer);
313
+ return this;
314
+ }
315
+
316
+ /** The loaded machine, for a host that needs its meta or snapshot. */
317
+ get machine() {
318
+ return this.#machine;
319
+ }
320
+
321
+ /**
322
+ * One frame: the frame's drained records as an 'ANEV' blob (or null for
323
+ * an empty batch) and the frame's `now` in session microseconds.
324
+ * @param {Uint8Array | ArrayBuffer | null} events
325
+ * @param {number} now
326
+ * @returns {Session}
327
+ */
328
+ step(events, now) {
329
+ const at = micros(now, "now");
330
+ const bytes = events === null || events === undefined ? null : toBytes(events);
331
+ const records = bytes === null ? [] : decodeEvents(bytes).records;
332
+
333
+ // 1. The consumers' derived records, in registration order, then the
334
+ // stable merge by :at (consumer order on ties).
335
+ const derived = [];
336
+ for (const consumer of this.#consumers) {
337
+ if (typeof consumer.derive !== "function") continue;
338
+ for (const record of consumer.derive(records)) {
339
+ if (record.kind !== "input-write") {
340
+ throw new SessionError(
341
+ `animader: a consumer's derived record must be an (input-write ...) record, got ${record.kind}`,
342
+ );
343
+ }
344
+ if (!Number.isSafeInteger(record.at) || record.at < 0 || record.at > at) {
345
+ throw new SessionError(`animader: a derived record's :at ${record.at} is outside 0..now`);
346
+ }
347
+ derived.push(record);
348
+ }
349
+ }
350
+ const ordered = derived
351
+ .map((record, i) => [record, i])
352
+ .sort((a, b) => a[0].at - b[0].at || a[1] - b[1])
353
+ .map(([record]) => record);
354
+
355
+ // 2. The consumers' own writes, before the machine steps.
356
+ const consumerWrites = this.#consumers.map((consumer) => {
357
+ if (typeof consumer.eval !== "function") return [];
358
+ const blob = consumer.eval(at);
359
+ return blob === null || blob === undefined ? [] : decodeWrites(toBytes(blob));
360
+ });
361
+
362
+ // 3. The machine: the log's records and the derived ones, merged by :at
363
+ // (the log first on ties), applied at their exact times. The frame's
364
+ // emits are the concatenation of every stepping call the exact-time
365
+ // seam makes, in order.
366
+ const machineEmits = this.#stepMachine(bytes, records, ordered, at);
367
+
368
+ // 4. The machine's values as pacer's 'PACW'.
369
+ const machineWrites = this.#encodeMachine();
370
+
371
+ // 5. Registry 2: consumers, then the machine, before the score. The
372
+ // score owns the pins (pacer's `setControl`); a scoreless host may
373
+ // still take them through its own `setControl`.
374
+ for (const writes of consumerWrites) this.#applyControls(writes, "consumer");
375
+ this.#applyControls(machineWrites, "machine");
376
+
377
+ // 6. The score, whose :expr tracks read the pins just landed.
378
+ let scoreWrites = [];
379
+ if (this.#score !== null && typeof this.#score.eval === "function") {
380
+ const blob = this.#score.eval(at);
381
+ if (blob !== null && blob !== undefined) scoreWrites = decodeWrites(toBytes(blob));
382
+ }
383
+
384
+ // 7. Registries 0/1: consumers, then the machine, then the score.
385
+ for (const writes of consumerWrites) applyValues(writes, this.#host, "consumer");
386
+ applyValues(machineWrites, this.#host, "machine");
387
+ applyValues(scoreWrites, this.#host, "score");
388
+
389
+ // 8. Each consumer reads the merged frame.
390
+ const merged = [...consumerWrites.flat(), ...machineWrites, ...scoreWrites];
391
+ for (const consumer of this.#consumers) {
392
+ if (typeof consumer.emit === "function") consumer.emit(merged.slice(), at);
393
+ }
394
+
395
+ // 9. The contract's last step: the machine's emitted events go to the
396
+ // host, never to a log.
397
+ if (typeof this.#host.handle === "function") this.#host.handle(machineEmits);
398
+ return this;
399
+ }
400
+
401
+ /** Registry-2 entries: the score's pins, or the host's control sink. */
402
+ #applyControls(entries, source) {
403
+ const score = this.#score;
404
+ const sink = score !== null && typeof score.setControl === "function" ? score : this.#host;
405
+ applyControls(entries, sink, source);
406
+ }
407
+
408
+ #loadBridge(text, targets, registryCount) { const e = this.#e;
409
+ const pact = encodeTargets(targets);
410
+ const doc = encoder.encode(text);
411
+ if (pact.length + doc.length > e.anm_log_inCap()) {
412
+ throw new SessionError("animader: the bridge inputs do not fit the module's input zone");
413
+ }
414
+ new Uint8Array(e.memory.buffer, e.anm_log_inPtr(), pact.length).set(pact);
415
+ new Uint8Array(e.memory.buffer, e.anm_log_inPtr() + pact.length, doc.length).set(doc);
416
+ const status = e.anm_bridge_load(pact.length, doc.length, registryCount);
417
+ if (status !== BRIDGE_HANDLE) {
418
+ throw new SessionError(
419
+ `animader: the bridge join failed (status ${status}); check the documents and the target join`,
420
+ );
421
+ }
422
+ this.#bridgeHandle = status;
423
+ }
424
+
425
+ #encodeMachine() {
426
+ const e = this.#e;
427
+ const outputs = this.#machine.outputs();
428
+ const len = outputs.length * 16;
429
+ if (len > e.anm_log_inCap()) throw new SessionError("animader: the machine's values do not fit the input zone");
430
+ const view = new DataView(e.memory.buffer, e.anm_log_inPtr(), len);
431
+ for (const output of outputs) {
432
+ const off = output.index * 16;
433
+ for (let lane = 0; lane < 4; lane++) view.setFloat32(off + lane * 4, output.value[lane], true);
434
+ }
435
+ const status = e.anm_bridge_encode(this.#bridgeHandle, len);
436
+ if (status < 0) {
437
+ throw new SessionError(
438
+ `animader: the bridge encode failed (status ${status}); the bridge document must list the machine's outputs in order`,
439
+ );
440
+ }
441
+ return decodeWrites(new Uint8Array(e.memory.buffer, e.anm_log_outPtr(), status).slice());
442
+ }
443
+
444
+ /**
445
+ * The machine's exact-time walk over the frame's records and derived
446
+ * inputs, returning every stepping call's emits concatenated in order:
447
+ * the one list a native single-step `Machine.step` would have returned.
448
+ */
449
+ #stepMachine(bytes, records, derived, at) {
450
+ const emits = [];
451
+ if (derived.length === 0) {
452
+ this.#machine.step(bytes, at);
453
+ emits.push(...this.#machine.emits());
454
+ return emits;
455
+ }
456
+ let cursor = 0;
457
+ for (const record of derived) {
458
+ let end = cursor;
459
+ while (end < records.length && records[end].at <= record.at) end += 1;
460
+ this.#machine.step(end > cursor ? sliceBlob(bytes, cursor, end) : null, record.at);
461
+ emits.push(...this.#machine.emits());
462
+ this.#applyInput(record);
463
+ emits.push(...this.#machine.emits());
464
+ cursor = end;
465
+ }
466
+ this.#machine.step(cursor < records.length ? sliceBlob(bytes, cursor, records.length) : null, at);
467
+ emits.push(...this.#machine.emits());
468
+ return emits;
469
+ }
470
+
471
+ #applyInput(record) {
472
+ const kind = this.#inputKinds.get(record.name);
473
+ if (kind === "trigger") {
474
+ this.#machine.fireTrigger(record.name);
475
+ } else {
476
+ const lane0 = Array.isArray(record.v) || (record.v && typeof record.v.length === "number") ? record.v[0] ?? 0 : 0;
477
+ this.#machine.setInput(record.name, lane0 | 0);
478
+ }
479
+ }
480
+ }
Binary file
package/README.md DELETED
@@ -1,3 +0,0 @@
1
- # Temporary Holding Version
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.