animader-cli 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.
Files changed (71) hide show
  1. package/LICENSE +121 -0
  2. package/adapters/build-node.mjs +265 -0
  3. package/adapters/devserver.mjs +481 -0
  4. package/adapters/devsession.mjs +619 -0
  5. package/adapters/export-node.mjs +603 -0
  6. package/adapters/export-run.mjs +126 -0
  7. package/adapters/generations.mjs +183 -0
  8. package/adapters/http.mjs +294 -0
  9. package/adapters/launch.mjs +83 -0
  10. package/adapters/node-fs.mjs +80 -0
  11. package/adapters/pngine.mjs +87 -0
  12. package/adapters/snapshot.mjs +143 -0
  13. package/adapters/target.mjs +104 -0
  14. package/adapters/watch.mjs +355 -0
  15. package/adapters/webaudio.mjs +102 -0
  16. package/bin/animader.mjs +31 -0
  17. package/hello/join.js +46 -0
  18. package/hello/run.js +259 -0
  19. package/lib/build.mjs +126 -0
  20. package/lib/buildloop.mjs +254 -0
  21. package/lib/contract.mjs +371 -0
  22. package/lib/errors.mjs +51 -0
  23. package/lib/export.mjs +445 -0
  24. package/lib/new.mjs +304 -0
  25. package/lib/pipeline.mjs +247 -0
  26. package/lib/receipt.mjs +331 -0
  27. package/lib/rmtree.mjs +49 -0
  28. package/lib/routes.mjs +170 -0
  29. package/lib/score.mjs +434 -0
  30. package/lib/show.mjs +458 -0
  31. package/lib/soundtrack.mjs +67 -0
  32. package/lib/vendor.mjs +99 -0
  33. package/lib/wav.mjs +197 -0
  34. package/package.json +47 -4
  35. package/player/boot.mjs +792 -0
  36. package/player/controller.mjs +366 -0
  37. package/player/devchannel.mjs +306 -0
  38. package/player/index.html +35 -0
  39. package/player/page.mjs +134 -0
  40. package/player/player.css +393 -0
  41. package/player/release.mjs +76 -0
  42. package/src/commands/build.mjs +201 -0
  43. package/src/commands/dev.mjs +214 -0
  44. package/src/commands/doctor.mjs +348 -0
  45. package/src/commands/export.mjs +158 -0
  46. package/src/commands/new.mjs +164 -0
  47. package/src/commands/project.mjs +46 -0
  48. package/src/config.mjs +110 -0
  49. package/src/router.mjs +149 -0
  50. package/src/toolchain.mjs +691 -0
  51. package/starters/circle/README.md +50 -0
  52. package/starters/circle/animader.json +4 -0
  53. package/starters/circle/gitignore +9 -0
  54. package/starters/circle/kernels/circle.wgsl +32 -0
  55. package/starters/circle/program.sjon +34 -0
  56. package/starters/circle/timeline.piece.sjon +26 -0
  57. package/starters/ember/bake/spans.bin +0 -0
  58. package/starters/ember/kernels/curl.wgsl +10 -0
  59. package/starters/ember/kernels/ember.wgsl +10 -0
  60. package/starters/ember/kernels/hash.wgsl +12 -0
  61. package/starters/ember/kernels/simplex.wgsl +27 -0
  62. package/starters/ember/program.sjon +328 -0
  63. package/starters/ember/timeline.piece.sjon +34 -0
  64. package/toolchain/licenses/animader.txt +121 -0
  65. package/toolchain/licenses/esbuild.txt +21 -0
  66. package/toolchain/licenses/pacer.txt +121 -0
  67. package/toolchain/licenses/pngine.txt +121 -0
  68. package/toolchain/manifest.json +212 -0
  69. package/toolchain/prepare.mjs +446 -0
  70. package/toolchain/uniform-type-name.js +184 -0
  71. package/README.md +0 -3
@@ -0,0 +1,355 @@
1
+ // The recursive project watcher (plan section 5). One native `fs.watch`
2
+ // handle covers the whole project tree (recursive watching is this Node's
3
+ // own), and every event is filtered before it is forwarded: authored input,
4
+ // kernels and nested relative asset directories forward, while the
5
+ // generated `out/` tree, the default release/staging trees (`dist/`,
6
+ // `dist.next/`), the detached `dist.size.json` report, `.git`,
7
+ // `node_modules` and legacy `.animader-export-work-*` debris never do — so
8
+ // a build's own outputs cannot reach the loop. Exports to other output
9
+ // names need matching entries; the defaults cover the plan's named ones
10
+ // only.
11
+ //
12
+ // The watcher PROMPTS; it never decides. Forwarding is unconditional for
13
+ // every filtered event — a creation, a rewrite, a reappearance, a
14
+ // directory change, even a replayed event for a write that predates the
15
+ // watch (macOS fs.watch({recursive:true}) is FSEvents and answers recent
16
+ // history for a newly watched tree). Whether a prompt means a change is
17
+ // the session's to answer, from the filesystem, against snapshots it owns:
18
+ // a platform's event delivery affects only how soon the loop looks, never
19
+ // whether it looks correctly. There is no event baseline, no start
20
+ // timestamp and no per-path record here to drift — which is also why a
21
+ // tree that cannot be walked cannot suppress anything: nothing is walked.
22
+ // The watcher does not coalesce either: one editor save may legitimately
23
+ // answer several events (an atomic save is a temp-file create plus a
24
+ // rename), and coalescing bursts into one build is the build loop's
25
+ // decision.
26
+ //
27
+ // Beside the watcher stands the stat safety net (`createInputSweep`):
28
+ // some platforms keep reporting the OLD vnode after an atomic save, so the
29
+ // path goes silent for the process's life (measured on obs), and network
30
+ // filesystems answer no event at all. The sweep stats the build's own
31
+ // consumed-input rows — the receipt's `inputs.files`, the same input set
32
+ // the build records, so there is no second list to drift — and lists the
33
+ // filtered entries of the directories that hold them, and feeds any
34
+ // difference to the SAME loop entry point a watcher event feeds. Both are
35
+ // prompts; the session judges both the same way.
36
+ //
37
+ // Failures are actionable, never silent: a folder that cannot be watched
38
+ // throws `watch_failed` before anything starts, and a runtime watcher error
39
+ // reaches `onError` with the same identity — the caller decides to report
40
+ // and keep serving. `close()` is final and idempotent.
41
+
42
+ import { promises as fsp } from 'node:fs';
43
+ import { watch as nodeWatch } from 'node:fs';
44
+ import { isAbsolute, join, relative, sep } from 'node:path';
45
+
46
+ import { serverError } from '../lib/errors.mjs';
47
+ import { LEGACY_WORK_PREFIX, SNAPSHOT_PRUNED_NAMES } from './snapshot.mjs';
48
+
49
+ /**
50
+ * Root-relative paths the watcher excludes by default: the generated
51
+ * `out/` tree, the default release output and its staging directory, and
52
+ * the detached size report.
53
+ */
54
+ export const DEFAULT_WATCH_EXCLUDED_PATHS = Object.freeze(['out', 'dist', 'dist.next', 'dist.size.json']);
55
+
56
+ function watchFailure(cause) {
57
+ return serverError(
58
+ 'watch_failed',
59
+ `watching the project failed: ${cause?.message ?? String(cause)}`,
60
+ 'Check that the project folder exists and is readable, then restart animader dev; serving the last successful generation does not need the watcher.',
61
+ );
62
+ }
63
+
64
+ /**
65
+ * The change filter: true when an absolute path is authored input this
66
+ * dev session must rebuild for. A path is excluded when it leaves the
67
+ * project root, or when it sits inside (or is) any excluded root-relative
68
+ * tree or report, at any depth. `.git`, `node_modules` and legacy work
69
+ * debris are pruned at any depth, exactly like the input snapshot's own
70
+ * filter.
71
+ *
72
+ * @param {{projectRoot: string, excludedPaths?: string[]}} options
73
+ * @returns {(source: string) => boolean}
74
+ */
75
+ export function createProjectWatchFilter({ projectRoot, excludedPaths = DEFAULT_WATCH_EXCLUDED_PATHS }) {
76
+ const excluded = new Set(excludedPaths);
77
+ return (source) => {
78
+ const rel = relative(projectRoot, source);
79
+ if (rel === '') return true;
80
+ if (rel.startsWith(`..${sep}`) || isAbsolute(rel)) return false;
81
+ const segments = rel.split(sep);
82
+ if (segments.some((name) => SNAPSHOT_PRUNED_NAMES.includes(name) || name.startsWith(LEGACY_WORK_PREFIX))) {
83
+ return false;
84
+ }
85
+ // Any excluded root-relative path is a subtree: a file or a deeper
86
+ // directory under it is excluded with it.
87
+ for (let depth = 1; depth <= segments.length; depth += 1) {
88
+ if (excluded.has(segments.slice(0, depth).join('/'))) return false;
89
+ }
90
+ return true;
91
+ };
92
+ }
93
+
94
+ // ─── the watcher: filtered events forward as prompts ────────────────────────
95
+
96
+ /**
97
+ * Watch one project folder recursively. Every filtered event forwards as
98
+ * one prompt (`onChange` with the changed path); an event that carries no
99
+ * filename forwards with null — a prompt the session cannot miss, because
100
+ * a false prompt costs one look at the filesystem while a dropped edit
101
+ * costs a stale generation. The watcher decides nothing else: no start
102
+ * clock, no baseline, no per-path record — whether a prompt means a
103
+ * change is judged downstream, from the filesystem.
104
+ *
105
+ * @param {Object} options
106
+ * @param {string} options.folder the project folder (watched recursively)
107
+ * @param {(source: string|null) => boolean} options.filter receives the
108
+ * absolute changed path (or null when the platform answers no filename)
109
+ * @param {(source: string|null) => void} options.onChange one filtered
110
+ * change, forwarded as a prompt
111
+ * @param {(error: Error) => void} options.onError a runtime watcher failure
112
+ * @param {(folder: string, options: Object, handler: Function) => object} [options.watchImpl]
113
+ * @returns {{close(): void}}
114
+ */
115
+ export function watchProject({ folder, filter, onChange, onError, watchImpl = nodeWatch }) {
116
+ let watcher;
117
+ try {
118
+ watcher = watchImpl(folder, { recursive: true }, (eventType, filename) => {
119
+ if (filename === null || filename === undefined) {
120
+ // No filename to filter on: forward the prompt anyway; the
121
+ // session's decision is a stat pass, never the event's metadata.
122
+ onChange(null);
123
+ return;
124
+ }
125
+ const source = join(folder, String(filename));
126
+ if (!filter(source)) return;
127
+ onChange(source);
128
+ });
129
+ } catch (cause) {
130
+ throw watchFailure(cause);
131
+ }
132
+
133
+ watcher.on('error', (cause) => {
134
+ onError(watchFailure(cause));
135
+ });
136
+
137
+ let closed = false;
138
+ return {
139
+ close() {
140
+ if (closed) return;
141
+ closed = true;
142
+ watcher.close();
143
+ },
144
+ };
145
+ }
146
+
147
+ // ─── the stat safety net ────────────────────────────────────────────────────
148
+
149
+ /** The default sweep period: ~1 s, the order the dev session promises. */
150
+ export const DEFAULT_SWEEP_INTERVAL_MS = 1000;
151
+
152
+ /** Project-relative rows use '/', whatever the host separator is. */
153
+ function rowContainer(row) {
154
+ const cut = row.lastIndexOf('/');
155
+ return cut === -1 ? '' : row.slice(0, cut);
156
+ }
157
+
158
+ /**
159
+ * One file's cheap identity: size, mtime and inode (where the platform
160
+ * answers one — a rename-over always changes the inode on the hosts that
161
+ * have inodes). A row that is gone, replaced by a directory, or currently
162
+ * unreadable is its own identity, so the change is still reported.
163
+ */
164
+ function statIdentity(stats) {
165
+ if (stats === null || stats.isDirectory()) return null;
166
+ return `${stats.size}:${stats.mtimeMs}:${stats.ino ?? '-'}`;
167
+ }
168
+
169
+ /**
170
+ * One path's stat-row identity, shared by the sweep's periodic comparison
171
+ * and the dev session's publish-time freshness check: `statIdentity`'s
172
+ * {size, mtimeMs, inode} for a file, null when the path is gone (a clean
173
+ * ENOENT/ENOTDIR is an answer — a deletion — not an error), and
174
+ * `unreadable:<code>` when the stat cannot answer at all.
175
+ */
176
+ export async function statRow(path) {
177
+ try {
178
+ return statIdentity(await fsp.stat(path));
179
+ } catch (error) {
180
+ if (error.code === 'ENOENT' || error.code === 'ENOTDIR') return null;
181
+ return `unreadable:${error.code ?? 'unknown'}`;
182
+ }
183
+ }
184
+
185
+ /**
186
+ * One directory's cheap identity: the names of its entries that pass the
187
+ * SAME filter the watcher uses — excluded trees (`out/`, `dist/`, `.git`,
188
+ * `node_modules`, legacy work debris) are not part of the identity, so a
189
+ * build's own outputs can never look like a change. A missing directory
190
+ * is its own identity. Entry names are enough here: a replaced file keeps
191
+ * its name, and its own row's stat identity is what reports the swap.
192
+ *
193
+ * Exported for the dev session's own input snapshots, which compare the
194
+ * same directory discipline the sweep compares.
195
+ */
196
+ export async function statDir(dir, filter) {
197
+ let entries;
198
+ try {
199
+ entries = await fsp.readdir(dir, { withFileTypes: true });
200
+ } catch (error) {
201
+ if (error.code === 'ENOENT' || error.code === 'ENOTDIR') return null;
202
+ return `unreadable:${error.code ?? 'unknown'}`;
203
+ }
204
+ return entries
205
+ .filter((entry) => filter(join(dir, entry.name)))
206
+ .map((entry) => `${entry.isDirectory() ? 'd' : 'f'}:${entry.name}`)
207
+ .sort()
208
+ .join('|');
209
+ }
210
+
211
+ /**
212
+ * The stat-based safety net beside the watcher: a bounded, periodic
213
+ * comparison over the project's authored input set — the consumed-input
214
+ * rows the build's own receipt records (program, timeline, soundtrack and
215
+ * every authored resource beside them, kernels included) plus the
216
+ * directories that hold them, so a newly created kernel is noticed. One
217
+ * sweep is N stats and D directory listings, never a tree walk; a
218
+ * difference is reported once through the same `onChange` the watcher
219
+ * feeds, and the baselines move to the observed state, so the same
220
+ * difference is never reported twice.
221
+ *
222
+ * Baseline discipline is what keeps one edit at exactly one build:
223
+ * `accounted()` re-reads every baseline when a build starts (everything
224
+ * up to that instant is inside the running build, watcher event or not),
225
+ * and `inputs()` adopts the finished build's rows WITHOUT resetting the
226
+ * identities it already holds, so an edit that landed mid-build still
227
+ * reports on the next sweep. Before the first successful build the row
228
+ * set is empty and the watcher is the only net; every build then replaces
229
+ * the set with its own record.
230
+ *
231
+ * `close()` is final and idempotent, and leaves no timer behind.
232
+ *
233
+ * @param {Object} options
234
+ * @param {string} options.folder the project folder the rows are relative to
235
+ * @param {(source: string) => boolean} options.filter the watcher's own
236
+ * exclusion filter, applied to directory entries
237
+ * @param {(reasons: string[]) => void} options.onChange one detected
238
+ * difference (the labels name the rows/directories that differ)
239
+ * @param {number} [options.intervalMs] the sweep period
240
+ * @returns {{inputs(rows: string[]): void, accounted(): void, close(): void}}
241
+ */
242
+ export function createInputSweep({ folder, filter, onChange, intervalMs = DEFAULT_SWEEP_INTERVAL_MS }) {
243
+ if (typeof folder !== 'string' || folder === '') {
244
+ throw new TypeError('createInputSweep needs the project folder');
245
+ }
246
+ if (typeof filter !== 'function' || typeof onChange !== 'function') {
247
+ throw new TypeError('createInputSweep needs filter and onChange functions');
248
+ }
249
+ if (!Number.isFinite(intervalMs) || intervalMs <= 0) {
250
+ throw new TypeError('createInputSweep needs a positive intervalMs');
251
+ }
252
+
253
+ let closed = false;
254
+ let timer = null;
255
+ // Every read runs through one queue: a sweep never interleaves with an
256
+ // adoption or an accounting refresh, so a difference is always judged
257
+ // against baselines that were read atomically.
258
+ let queue = Promise.resolve();
259
+ const fileRows = new Map(); // absolute path -> identity
260
+ const dirRows = new Map(); // absolute directory -> filtered entry key
261
+
262
+ const toAbs = (rel) => (rel === '' ? folder : join(folder, ...rel.split('/')));
263
+ const toLabel = (path) => relative(folder, path).split(sep).join('/');
264
+
265
+ async function readRows(paths, directories) {
266
+ const files = await Promise.all([...paths].map(async (path) => [path, await statRow(path)]));
267
+ const dirs = await Promise.all([...directories].map(async (dir) => [dir, await statDir(dir, filter)]));
268
+ return { files, dirs };
269
+ }
270
+
271
+ function store({ files, dirs }) {
272
+ fileRows.clear();
273
+ for (const [path, identity] of files) fileRows.set(path, identity);
274
+ dirRows.clear();
275
+ for (const [dir, key] of dirs) dirRows.set(dir, key);
276
+ }
277
+
278
+ function enqueue(work) {
279
+ queue = queue.then(async () => {
280
+ if (closed) return;
281
+ await work();
282
+ });
283
+ // A programmer error in the sweep must not kill the queue; the next
284
+ // tick still runs and the difference still reports.
285
+ queue = queue.catch(() => {});
286
+ return queue;
287
+ }
288
+
289
+ function sweepOnce() {
290
+ return enqueue(async () => {
291
+ const paths = new Set(fileRows.keys());
292
+ const directories = new Set(dirRows.keys());
293
+ const current = await readRows(paths, directories);
294
+ const reasons = [];
295
+ for (const [path, identity] of current.files) {
296
+ if (fileRows.get(path) !== identity) reasons.push(toLabel(path));
297
+ }
298
+ for (const [dir, key] of current.dirs) {
299
+ if (dirRows.get(dir) !== key) reasons.push(dir === folder ? '/' : `${toLabel(dir)}/`);
300
+ }
301
+ store(current);
302
+ if (reasons.length > 0) onChange(reasons);
303
+ });
304
+ }
305
+
306
+ timer = setInterval(() => {
307
+ sweepOnce();
308
+ }, intervalMs);
309
+
310
+ return {
311
+ /**
312
+ * Adopt the build's consumed-input rows (project-relative, '/'
313
+ * separated). Identities already held are kept — an edit that landed
314
+ * while the build ran must still report — and new rows are baselined
315
+ * as they are now, without notifying.
316
+ *
317
+ * @param {string[]} rows
318
+ */
319
+ inputs(rows) {
320
+ if (!Array.isArray(rows) || rows.some((row) => typeof row !== 'string' || row === '' || row.startsWith('/') || row.split('/').some((name) => name === '..'))) {
321
+ throw new TypeError('createInputSweep inputs needs the receipt\'s project-relative row paths');
322
+ }
323
+ enqueue(async () => {
324
+ const paths = new Set(rows.map(toAbs));
325
+ const directories = new Set(rows.map(rowContainer).map(toAbs));
326
+ const files = await Promise.all([...paths].map(async (path) => [path, fileRows.has(path) ? fileRows.get(path) : await statRow(path)]));
327
+ const dirs = await Promise.all([...directories].map(async (dir) => [dir, dirRows.has(dir) ? dirRows.get(dir) : await statDir(dir, filter)]));
328
+ store({ files, dirs });
329
+ });
330
+ },
331
+
332
+ /**
333
+ * Every baseline is re-read now, without notifying: the build that is
334
+ * starting includes everything up to this instant, so the sweep must
335
+ * not re-report it. Called at the start of every build, successful or
336
+ * not — a build that fails has still accounted its inputs, which is
337
+ * what keeps a persistently failing rebuild from looping.
338
+ */
339
+ accounted() {
340
+ enqueue(async () => {
341
+ store(await readRows(new Set(fileRows.keys()), new Set(dirRows.keys())));
342
+ });
343
+ },
344
+
345
+ /** Final: stop sweeping and leave no timer behind. */
346
+ close() {
347
+ if (closed) return;
348
+ closed = true;
349
+ if (timer !== null) {
350
+ clearInterval(timer);
351
+ timer = null;
352
+ }
353
+ },
354
+ };
355
+ }
@@ -0,0 +1,102 @@
1
+ // The browser WebAudio adapter (M1 slice 2): one port over an injected
2
+ // AudioContext, matching what `createShow`'s transport expects. This is
3
+ // browser-only wiring — no `node:` import, no DOM, and the context
4
+ // itself is injected: the page constructs it (it starts suspended)
5
+ // before decode, so READY needs no gesture; only the Start gesture that
6
+ // calls `show.start()` resumes it. A portable core never owns the
7
+ // platform graph.
8
+ //
9
+ // The graph is the stable QA seam: every started source connects
10
+ // through one AnalyserNode into the context destination, so a real page
11
+ // run can measure a NONZERO signal on the analyser while the show
12
+ // plays instead of trusting the context state alone. Each start is a
13
+ // fresh single-use AudioBufferSourceNode started at the transport's
14
+ // captured origin with its STOP scheduled at origin+seconds — the
15
+ // audio ends itself on time regardless of paint slack.
16
+ //
17
+ // Ownership: the wiring only. Decode, resume/suspend and currentTime
18
+ // belong to the context; the clock policy belongs to lib/show.mjs.
19
+
20
+ /**
21
+ * Build the audio port over one AudioContext.
22
+ *
23
+ * @param {object} options
24
+ * @param {AudioContext} options.context
25
+ * the page's own context, constructed suspended before decode; only
26
+ * the Start gesture resumes it.
27
+ * @returns {{
28
+ * decode: (bytes: ArrayBuffer) => Promise<AudioBuffer>,
29
+ * resume: () => Promise<void>,
30
+ * suspend: () => Promise<void>,
31
+ * now: () => number,
32
+ * play: (decoded: AudioBuffer, origin: number, stopAt: number) => AudioBufferSourceNode,
33
+ * stop: (source: AudioBufferSourceNode) => void,
34
+ * analyser: AnalyserNode,
35
+ * }}
36
+ */
37
+ export function createWebAudioPort({ context }) {
38
+ if (!context
39
+ || typeof context.decodeAudioData !== 'function'
40
+ || typeof context.resume !== 'function'
41
+ || typeof context.suspend !== 'function'
42
+ || typeof context.createBufferSource !== 'function'
43
+ || typeof context.createAnalyser !== 'function'
44
+ || typeof context.destination === 'undefined') {
45
+ throw new TypeError('createWebAudioPort needs an AudioContext-shaped context');
46
+ }
47
+
48
+ const analyser = context.createAnalyser();
49
+ analyser.connect(context.destination);
50
+
51
+ return {
52
+ /** The QA measurement point: source -> analyser -> destination. */
53
+ analyser,
54
+
55
+ async decode(bytes) {
56
+ return await context.decodeAudioData(bytes);
57
+ },
58
+
59
+ async resume() {
60
+ await context.resume();
61
+ },
62
+
63
+ /** Once this settles, the context clock (below) is frozen. */
64
+ async suspend() {
65
+ await context.suspend();
66
+ },
67
+
68
+ now() {
69
+ return context.currentTime;
70
+ },
71
+
72
+ /**
73
+ * One fresh single-use source per start, scheduled on the context's
74
+ * own timeline: begin at `origin`, STOP at `stopAt` (origin+seconds).
75
+ */
76
+ play(decoded, origin, stopAt) {
77
+ const source = context.createBufferSource();
78
+ source.buffer = decoded;
79
+ source.connect(analyser);
80
+ source.start(origin);
81
+ source.stop(stopAt);
82
+ return source;
83
+ },
84
+
85
+ /**
86
+ * End a source now. The platform allows repeated stops (the latest
87
+ * wins); only stop-before-start throws, and teardown ignores that.
88
+ */
89
+ stop(source) {
90
+ try {
91
+ source.stop();
92
+ } catch {
93
+ // The node never started; there is nothing left to end.
94
+ }
95
+ try {
96
+ source.disconnect();
97
+ } catch {
98
+ // A disconnected node is already out of the graph.
99
+ }
100
+ },
101
+ };
102
+ }
@@ -0,0 +1,31 @@
1
+ #!/usr/bin/env node
2
+ // The `animader` executable: the thin process adapter. It parses
3
+ // process.argv.slice(2), dispatches through the router, and maps thrown
4
+ // DemoErrors to their exit codes and the error:/remedy: lines
5
+ // (lib/errors.mjs is the one owner of that vocabulary and format).
6
+ //
7
+ // The whole command layer, its lib and node adapters, the player page, the
8
+ // hello player core and the starters live inside this package
9
+ // (installed-toolchain plan, stage 1): nothing under bin/, src/, lib/,
10
+ // adapters/, player/, hello/, starters/ or toolchain/ imports across the
11
+ // package boundary, so the packed package works without the checkout. The
12
+ // contributor workspace's demo/ entries are compatibility adapters over
13
+ // these modules.
14
+
15
+ import pkg from '../package.json' with { type: 'json' };
16
+ import { createNodeIo } from '../adapters/node-fs.mjs';
17
+ import { errorLines } from '../lib/errors.mjs';
18
+ import { runRouter } from '../src/router.mjs';
19
+
20
+ const context = {
21
+ version: pkg.version,
22
+ env: process.env,
23
+ io: createNodeIo(),
24
+ out: (line) => process.stdout.write(`${line}\n`),
25
+ err: (line) => process.stderr.write(`${line}\n`),
26
+ };
27
+
28
+ runRouter(process.argv.slice(2), context).catch((error) => {
29
+ for (const line of errorLines(error)) context.err(line);
30
+ process.exitCode = error.exitCode ?? 1;
31
+ });
package/hello/join.js ADDED
@@ -0,0 +1,46 @@
1
+ // The hello player's score target join, built from the compiled program's
2
+ // own reflection (step 12's second landing, the seventh ruling's (c)).
3
+ // pngine's `getUniforms` answers the program's reflected fields by name,
4
+ // with the type as WGSL spells it and the array length; registry 0 is that
5
+ // reflection, so a score target the program does not declare is refused by
6
+ // pacer's compile instead of drifting with a hand-written list.
7
+ //
8
+ // The wire target's `ncomp` is 1..4 lanes: a scalar or a vector of at most
9
+ // four. An array field (`elemCount` non-zero) and a matrix are not score
10
+ // targets (a mat3x3f is nine lanes and pacer's target arity is 1..4), and
11
+ // an unknown type is skipped rather than guessed.
12
+
13
+ /** The lanes one reflected WGSL type contributes to a score target. */
14
+ const LANES = {
15
+ f32: 1,
16
+ i32: 1,
17
+ u32: 1,
18
+ vec2f: 2,
19
+ vec2i: 2,
20
+ vec2u: 2,
21
+ vec3f: 3,
22
+ vec3i: 3,
23
+ vec3u: 3,
24
+ vec4f: 4,
25
+ vec4i: 4,
26
+ vec4u: 4,
27
+ };
28
+
29
+ /**
30
+ * One pacer registry from pngine's reflected field table, in the table's
31
+ * own order (the target_id space).
32
+ *
33
+ * @param {Object<string, {type: string, elemCount: number}>} fields
34
+ * `getUniforms`'s answer (or its parsed equivalent).
35
+ * @returns {Array<{name: string, ncomp: number}>}
36
+ */
37
+ export function buildJoin(fields) {
38
+ const join = [];
39
+ for (const [name, field] of Object.entries(fields)) {
40
+ if (field.elemCount) continue;
41
+ const ncomp = LANES[field.type];
42
+ if (ncomp === undefined) continue;
43
+ join.push({ name, ncomp });
44
+ }
45
+ return join;
46
+ }