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/piece.js ADDED
@@ -0,0 +1,222 @@
1
+ // animader - the piece boundary's JS glue (road step 5).
2
+ //
3
+ // `PieceHost` drives the `anm_piece_*` exports of animader.wasm over one
4
+ // instance, mirroring the log tier's zone convention byte for byte: the
5
+ // host writes the document (and, for `golden`, the `(times ...)` text
6
+ // after it) into `exports.anm_log_inPtr()`, calls the export, and reads
7
+ // `anm_log_outLen()` bytes from `anm_log_outPtr()`. Memory views are
8
+ // re-taken on every call (the pacer discipline: a view taken before a
9
+ // grow would detach; these zones never grow, but the glue does not rely
10
+ // on that).
11
+ //
12
+ // The aggregate a wasm host validates is the loader's own default
13
+ // preload (the piece, world, session, machine and bridge manifests,
14
+ // plus tempura's four-manifest bundle since the timeline landed,
15
+ // embedded) plus whatever sibling manifests the host supplies through
16
+ // `preload()`: pngine's for one with an authored program, any other
17
+ // plugin's for a sidecar. A wasm boundary cannot read a checkout, so
18
+ // the host is the one that resolves it; the seats are named
19
+ // (`PIECE_PACER`, `PIECE_PNGINE`, `PIECE_OTHER`) for the placement
20
+ // table's sake, and `PIECE_PACER` is a closed seat: pacer's manifest
21
+ // is embedded inside tempura's bundle, so preloading it is refused by
22
+ // the boundary rather than loaded as a second plugin named pacer.
23
+ //
24
+ // `golden(docText, timesText)` answers the piece corpus golden, the
25
+ // SAME text `src/piece_runner.zig` renders natively: one rendering,
26
+ // two executors, and native agreeing with wasm32 on one golden is the
27
+ // determinism proof. `score(docText)` answers the piece's score text
28
+ // alone, the same bytes that golden's `(score ...)` row hashes: the
29
+ // identity lowering, the authored score, or the timeline's derived
30
+ // export, whichever the aggregate carries. `scoreMap(docText)` answers
31
+ // the spelling of time the piece carries and the map it came with: the
32
+ // timeline spelling's derived score's position map (tempura's T1
33
+ // `'TPMP'` record), the identity spelling's own state (named by the
34
+ // identity table's row), or the authored-score spelling's own state
35
+ // (its positions are its literal roots), never one bare null for both
36
+ // no-timeline spellings. The two calls run the same load switch, so the
37
+ // map's text spans index exactly the score bytes `score` returns. The
38
+ // status convention is the entry's: a negative is a status, never data.
39
+
40
+ export const PIECE_OTHER = 0;
41
+ export const PIECE_PACER = 1;
42
+ export const PIECE_PNGINE = 2;
43
+
44
+ /** `anm_piece_score_map`'s two no-map states, mirrored from the entry.
45
+ * Distinct because the two no-timeline spellings are; a `'TPMP'` blob
46
+ * is never one byte. */
47
+ export const PIECE_MAP_IDENTITY = 0;
48
+ export const PIECE_MAP_AUTHORED = 1;
49
+
50
+ /** The three spellings of time `scoreMap` names (05-model.md's three). */
51
+ export const SPELLING_TIMELINE = "timeline";
52
+ export const SPELLING_IDENTITY = "identity";
53
+ export const SPELLING_AUTHORED = "authored";
54
+
55
+ const IMPORTS = { env: { sjon_host_invoke_plugin: () => 0 } };
56
+
57
+ const ERRORS = new Map([
58
+ [-1, "diagnosed load"],
59
+ [-2, "bad call"],
60
+ [-3, "out of memory"],
61
+ [-4, "output over its zone"],
62
+ ]);
63
+
64
+ export class PieceHost {
65
+ /** @param {WebAssembly.Instance} instance one animader.wasm instance */
66
+ constructor(instance) {
67
+ this.instance = instance;
68
+ this.exports = instance.exports;
69
+ }
70
+
71
+ /** Instantiate from bytes or a module. */
72
+ static async instantiate(source) {
73
+ const result =
74
+ source instanceof WebAssembly.Module
75
+ ? await WebAssembly.instantiate(source, IMPORTS)
76
+ : (await WebAssembly.instantiate(source, IMPORTS)).instance;
77
+ return new PieceHost(result);
78
+ }
79
+
80
+ /** One manifest text into one seat; answers the stored count. */
81
+ preload(manifestText, kind = PIECE_OTHER) {
82
+ const bytes = typeof manifestText === "string"
83
+ ? new TextEncoder().encode(manifestText)
84
+ : manifestText;
85
+ const cap = this.exports.anm_log_inCap();
86
+ if (bytes.length > cap) throw new Error(`animader: manifest over the in zone (${bytes.length} > ${cap})`);
87
+ new Uint8Array(this.exports.memory.buffer, this.exports.anm_log_inPtr(), bytes.length).set(bytes);
88
+ const status = this.exports.anm_piece_preload(bytes.length, kind);
89
+ if (status < 0) throw new Error(`animader: anm_piece_preload failed: ${ERRORS.get(status) ?? status}`);
90
+ return status;
91
+ }
92
+
93
+ /** Drop every preloaded sibling manifest. */
94
+ reset() {
95
+ this.exports.anm_piece_reset();
96
+ }
97
+
98
+ /** The stored-manifest count. */
99
+ get extraCount() {
100
+ return this.exports.anm_piece_extraCount();
101
+ }
102
+
103
+ /**
104
+ * The piece corpus golden: the aggregate document and the
105
+ * `(times ...)` text in, the golden bytes out as a string.
106
+ */
107
+ golden(docText, timesText) {
108
+ const doc = new TextEncoder().encode(docText);
109
+ const times = new TextEncoder().encode(timesText);
110
+ const cap = this.exports.anm_log_inCap();
111
+ if (doc.length + times.length > cap) {
112
+ throw new Error(`animader: document plus times over the in zone (${doc.length + times.length} > ${cap})`);
113
+ }
114
+ const inZone = new Uint8Array(this.exports.memory.buffer, this.exports.anm_log_inPtr(), doc.length + times.length);
115
+ inZone.set(doc, 0);
116
+ inZone.set(times, doc.length);
117
+ const status = this.exports.anm_piece_golden(doc.length, times.length);
118
+ if (status < 0) throw new Error(`animader: anm_piece_golden failed: ${ERRORS.get(status) ?? status}`);
119
+ const out = new Uint8Array(this.exports.memory.buffer, this.exports.anm_log_outPtr(), status);
120
+ return new TextDecoder().decode(out);
121
+ }
122
+
123
+ /**
124
+ * The piece's score text: the identity lowering, the authored score
125
+ * (a file's bytes or the aggregate's own roots), or the timeline's
126
+ * derived export, whichever spelling of time the aggregate carries.
127
+ * The same load `golden` renders and the same switch its `(score ...)`
128
+ * row reads, so a host compiles exactly the bytes the corpus hashed.
129
+ */
130
+ score(docText) {
131
+ const doc = new TextEncoder().encode(docText);
132
+ const cap = this.exports.anm_log_inCap();
133
+ if (doc.length > cap) {
134
+ throw new Error(`animader: document over the in zone (${doc.length} > ${cap})`);
135
+ }
136
+ new Uint8Array(this.exports.memory.buffer, this.exports.anm_log_inPtr(), doc.length).set(doc);
137
+ const status = this.exports.anm_piece_score(doc.length);
138
+ if (status < 0) throw new Error(`animader: anm_piece_score failed: ${ERRORS.get(status) ?? status}`);
139
+ const out = new Uint8Array(this.exports.memory.buffer, this.exports.anm_log_outPtr(), status);
140
+ return new TextDecoder().decode(out);
141
+ }
142
+
143
+ /**
144
+ * The piece's spelling of time and the map it came with: a
145
+ * `{ spelling, map }` pair. `spelling` is `SPELLING_TIMELINE`,
146
+ * `SPELLING_IDENTITY` or `SPELLING_AUTHORED`. `map` is the decoded
147
+ * `'TPMP'` record (one entry per emitted position, `text` a byte span
148
+ * into the score `score()` answers for the same document, `doc` the
149
+ * document spans it came from) for the timeline spelling, and null for
150
+ * the two no-timeline spellings: the identity's positions are named by
151
+ * the identity table's own row, the authored score's by its own
152
+ * literal roots, and the distinct states say which. The two calls are
153
+ * deterministic over the same document and run the same load switch,
154
+ * so the map's text spans index exactly the score bytes `score()`
155
+ * returns.
156
+ *
157
+ * @returns {{ spelling: string, map: { entries: Array<{ text: [number, number], doc: Array<[number, number]> }> } | null }}
158
+ */
159
+ scoreMap(docText) {
160
+ const doc = new TextEncoder().encode(docText);
161
+ const cap = this.exports.anm_log_inCap();
162
+ if (doc.length > cap) {
163
+ throw new Error(`animader: document over the in zone (${doc.length} > ${cap})`);
164
+ }
165
+ new Uint8Array(this.exports.memory.buffer, this.exports.anm_log_inPtr(), doc.length).set(doc);
166
+ const status = this.exports.anm_piece_score_map(doc.length);
167
+ if (status === PIECE_MAP_IDENTITY) return { spelling: SPELLING_IDENTITY, map: null };
168
+ if (status === PIECE_MAP_AUTHORED) return { spelling: SPELLING_AUTHORED, map: null };
169
+ if (status < 0) throw new Error(`animader: anm_piece_score_map failed: ${ERRORS.get(status) ?? status}`);
170
+ const bytes = new Uint8Array(this.exports.memory.buffer, this.exports.anm_piece_mapPtr(), status).slice();
171
+ return { spelling: SPELLING_TIMELINE, map: decodeMap(bytes) };
172
+ }
173
+ }
174
+
175
+ // ---------------------------------------------------------------------------
176
+ // The 'TPMP' position map, tempura's T1 record
177
+ // ---------------------------------------------------------------------------
178
+
179
+ /** `"TPMP"` read little-endian. */
180
+ export const MAP_MAGIC = 0x504d5054;
181
+ export const MAP_VERSION = 1;
182
+
183
+ const MAP_HEADER_LEN = 12;
184
+ const MAP_RECORD_LEN = 12;
185
+ const MAP_SPAN_LEN = 8;
186
+
187
+ /**
188
+ * Decode a `'TPMP'` blob, what `anm_piece_score_map` wrote (tempura's
189
+ * `TprAbi.encodeMap`, the same record tempura's own `decodeMap` reads).
190
+ * One entry per emitted position, in emission order: `text` is a
191
+ * `[start, end]` byte span into the score text, `doc` an array of
192
+ * `[start, end]` byte spans into the document the derivation read, one
193
+ * per document node the position came from, several when the exporter's
194
+ * arithmetic merged sources. A malformed blob throws rather than
195
+ * answering a shorter map.
196
+ */
197
+ export function decodeMap(bytes) {
198
+ if (bytes.length < MAP_HEADER_LEN) throw new Error("animader: map blob too short");
199
+ const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
200
+ const magic = dv.getUint32(0, true);
201
+ if (magic !== MAP_MAGIC) throw new Error(`animader: map magic ${magic.toString(16)}, not 'TPMP'`);
202
+ const version = dv.getUint32(4, true);
203
+ if (version !== MAP_VERSION) throw new Error(`animader: map version ${version}, not ${MAP_VERSION}`);
204
+ const count = dv.getUint32(8, true);
205
+ const entries = [];
206
+ let off = MAP_HEADER_LEN;
207
+ for (let i = 0; i < count; i++) {
208
+ if (off + MAP_RECORD_LEN > bytes.length) throw new Error("animader: map blob truncated at an entry");
209
+ const text = [dv.getUint32(off, true), dv.getUint32(off + 4, true)];
210
+ const docCount = dv.getUint32(off + 8, true);
211
+ off += MAP_RECORD_LEN;
212
+ if (off + docCount * MAP_SPAN_LEN > bytes.length) throw new Error("animader: map blob truncated at a document span");
213
+ const doc = [];
214
+ for (let j = 0; j < docCount; j++) {
215
+ doc.push([dv.getUint32(off, true), dv.getUint32(off + 4, true)]);
216
+ off += MAP_SPAN_LEN;
217
+ }
218
+ entries.push({ text, doc });
219
+ }
220
+ if (off !== bytes.length) throw new Error("animader: map blob carries trailing bytes");
221
+ return { entries };
222
+ }
package/src/route.js ADDED
@@ -0,0 +1,116 @@
1
+ // The bridge's JS routing (plan 03, phase 2): pacer's 'PACW' buffer in,
2
+ // its entries split by registry in pacer's `npm/pacer/src/route.js`
3
+ // shape, with registry 2 applied through pacer's `setControl` before
4
+ // pacer evaluates (the frame-contract rule: a control pin outranks every
5
+ // other write).
6
+ //
7
+ // The entries carry no names: the wire is ids only, so `targetId` is the
8
+ // host's flat target join index and a consumer resolves names itself.
9
+ // `laneValue` is pacer's `lane.js` shape: a scalar target hands a
10
+ // number, a multi-lane target an array.
11
+ //
12
+ // Registry 2 is the pacer control-pin registry; registries 0 (pngine
13
+ // uniforms) and 1 (audio params) are the host's to land.
14
+
15
+ const MAGIC = 0x57434150; // 'PACW' read little-endian
16
+ const VERSION = 1;
17
+ const HEADER_LEN = 12;
18
+ const ENTRY_LEN = 20;
19
+ /** The registry a machine value rides to reach a pacer control pin. */
20
+ export const CONTROL_REGISTRY = 2;
21
+
22
+ /**
23
+ * Decode one 'PACW' buffer into write entries, in wire order:
24
+ * `{ targetId, registry, ncomp, value: Float32Array(4) }`. Throws on a
25
+ * corrupt buffer (bad magic or version, a length that is not exactly
26
+ * header plus count entries). Pacer's own decoders tolerate trailing
27
+ * bytes (`Writes.decode` checks the buffer is at least the encoded
28
+ * length; pacer's JS `#decodeWrites` reads `count` entries whatever the
29
+ * length); this module is stricter on purpose, because the bridge emits
30
+ * this buffer and a length mismatch is a bug worth failing loudly.
31
+ *
32
+ * @param {Uint8Array} bytes
33
+ * @returns {Array<{targetId: number, registry: number, ncomp: number, value: Float32Array}>}
34
+ */
35
+ export function decodeWrites(bytes) {
36
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
37
+ if (view.byteLength < HEADER_LEN) {
38
+ throw new Error(
39
+ `animader: 'PACW' buffer is ${view.byteLength} bytes, short of the ${HEADER_LEN}-byte header`,
40
+ );
41
+ }
42
+ if (view.getUint32(0, true) !== MAGIC) {
43
+ throw new Error("animader: 'PACW' magic is missing");
44
+ }
45
+ if (view.getUint32(4, true) !== VERSION) {
46
+ throw new Error(`animader: 'PACW' version ${view.getUint32(4, true)} is not ${VERSION}`);
47
+ }
48
+ const count = view.getUint32(8, true);
49
+ if (HEADER_LEN + count * ENTRY_LEN !== view.byteLength) {
50
+ throw new Error("animader: 'PACW' length does not match its header (truncated or trailing bytes)");
51
+ }
52
+
53
+ const entries = [];
54
+ for (let i = 0; i < count; i++) {
55
+ const off = HEADER_LEN + i * ENTRY_LEN;
56
+ const value = new Float32Array(4);
57
+ for (let c = 0; c < 4; c++) value[c] = view.getFloat32(off + 4 + c * 4, true);
58
+ entries.push({
59
+ targetId: view.getUint16(off, true),
60
+ registry: view.getUint8(off + 2),
61
+ ncomp: view.getUint8(off + 3),
62
+ value,
63
+ });
64
+ }
65
+ return entries;
66
+ }
67
+
68
+ /**
69
+ * Group entries by registry index: `groups[registry]` is the entries in
70
+ * wire order, borrowing the same objects (pacer's `routeWrites` shape).
71
+ * An entry whose registry is at or above `registryCount` is dropped.
72
+ *
73
+ * @param {ReadonlyArray<{registry: number}>} entries
74
+ * @param {number} registryCount Number of registries (the length of `groups`).
75
+ * @returns {Array<Array<object>>}
76
+ */
77
+ export function routeWrites(entries, registryCount) {
78
+ const groups = Array.from({ length: registryCount }, () => []);
79
+ for (const entry of entries) {
80
+ const group = groups[entry.registry];
81
+ if (group !== undefined) group.push(entry);
82
+ }
83
+ return groups;
84
+ }
85
+
86
+ /**
87
+ * One entry's value as a host wants it: a bare number for a scalar
88
+ * target, else a plain array of its live lanes (pacer's `laneValue`).
89
+ *
90
+ * @param {{ncomp: number, value: Float32Array}} entry
91
+ * @returns {number | number[]}
92
+ */
93
+ export function laneValue(entry) {
94
+ return entry.ncomp === 1 ? entry.value[0] : Array.from(entry.value.subarray(0, entry.ncomp));
95
+ }
96
+
97
+ /**
98
+ * Apply every registry-2 entry through `setControl(targetId, value)`, in
99
+ * wire order. The frame contract calls this before `pacer.eval`, so a
100
+ * control pin is in the score's expression environment for the frame
101
+ * that produced it. Returns how many pieces were handed over; whether a
102
+ * host accepted one is `setControl`'s own return, the host's business.
103
+ *
104
+ * @param {ReadonlyArray<{targetId: number, registry: number, ncomp: number, value: Float32Array}>} entries
105
+ * @param {(id: number, value: number | number[]) => unknown} setControl
106
+ * @returns {number}
107
+ */
108
+ export function applyControls(entries, setControl) {
109
+ let applied = 0;
110
+ for (const entry of entries) {
111
+ if (entry.registry !== CONTROL_REGISTRY) continue;
112
+ setControl(entry.targetId, laneValue(entry));
113
+ applied += 1;
114
+ }
115
+ return applied;
116
+ }