@cyberart-io/engine 0.0.4 → 0.0.6
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/README.md +102 -21
- package/dist/headless.d.ts +779 -68
- package/dist/headless.js +1 -1
- package/dist/index.d.ts +1144 -9
- package/dist/index.js +1 -1
- package/docs/asset-resolver.md +4 -4
- package/docs/audio.md +152 -0
- package/docs/browser-harness.md +126 -0
- package/docs/capability-manifest.md +18 -7
- package/docs/compositor.md +103 -0
- package/docs/deterministic-mode.md +1 -1
- package/docs/events.md +75 -13
- package/docs/executable-modules.md +112 -0
- package/docs/headless-harness.md +44 -14
- package/docs/midi.md +128 -0
- package/docs/presentation-adapter.md +8 -8
- package/docs/presentation-cue.md +2 -2
- package/docs/replay-inspector.md +88 -0
- package/docs/runtime-group.md +9 -7
- package/docs/snapshots.md +102 -0
- package/package.json +1 -1
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# Executable modules
|
|
2
|
+
|
|
3
|
+
Trusted, versioned factories behind a host allowlist. The engine never `eval`s or `new Function`s untrusted strings. Unknown ids, wrong versions, and unauthorized refs fail closed without invoking. A throwing module does not stop the next allowlisted call.
|
|
4
|
+
|
|
5
|
+
Related: [capability manifest](capability-manifest.md) (`modules.refs`).
|
|
6
|
+
|
|
7
|
+
Back to the [package README](../README.md).
|
|
8
|
+
|
|
9
|
+
## One-command reproduce (this repo)
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
pnpm exec vitest run packages/engine/src/canvas/cyb-27-executable-module.repro.spec.ts
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## `createExecutableModuleHost(options)`
|
|
16
|
+
|
|
17
|
+
```ts
|
|
18
|
+
import { createExecutableModuleHost } from '@cyberart-io/engine';
|
|
19
|
+
|
|
20
|
+
const overlayCalls = { n: 0 };
|
|
21
|
+
const host = createExecutableModuleHost({
|
|
22
|
+
allowlist: [
|
|
23
|
+
{ id: 'overlay-fx', version: '1.0.0' },
|
|
24
|
+
{ id: 'host.module', version: '1.0.0' },
|
|
25
|
+
],
|
|
26
|
+
limits: { maxInvokeMs: 16, maxInvokesPerTurn: 4 },
|
|
27
|
+
capabilities: { seed: '0x27' },
|
|
28
|
+
modules: [
|
|
29
|
+
{
|
|
30
|
+
id: 'overlay-fx',
|
|
31
|
+
version: '1.0.0',
|
|
32
|
+
create: (capabilities) => ({
|
|
33
|
+
invoke: (input, { signal }) => {
|
|
34
|
+
if (signal.aborted) return;
|
|
35
|
+
overlayCalls.n += 1;
|
|
36
|
+
return { seed: capabilities.seed, input };
|
|
37
|
+
},
|
|
38
|
+
}),
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
id: 'host.module',
|
|
42
|
+
version: '1.0.0',
|
|
43
|
+
create: () => ({
|
|
44
|
+
invoke: () => {
|
|
45
|
+
throw new Error('host.module boom');
|
|
46
|
+
},
|
|
47
|
+
}),
|
|
48
|
+
},
|
|
49
|
+
],
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
const allowed = await host.invoke({ id: 'overlay-fx', version: '1.0.0' }, { tape: 'cyb-27' });
|
|
53
|
+
// allowed.ok === true — overlay-fx ran
|
|
54
|
+
|
|
55
|
+
await host.invoke({ id: 'leak.module', version: '1.0.0' });
|
|
56
|
+
// { ok: false, error: { code: 'unknown-module' | 'not-allowlisted' } }
|
|
57
|
+
|
|
58
|
+
await host.invoke({ id: 'overlay-fx', version: '2.0.0' });
|
|
59
|
+
// { ok: false, error: { code: 'version-mismatch' } } — factory is not called
|
|
60
|
+
|
|
61
|
+
const boom = await host.invoke({ id: 'host.module', version: '1.0.0' });
|
|
62
|
+
// boom.error.code === 'invoke-failed'; overlay-fx still runs next:
|
|
63
|
+
await host.invoke({ id: 'overlay-fx', version: '1.0.0' });
|
|
64
|
+
|
|
65
|
+
host.beginTurn();
|
|
66
|
+
host.destroy();
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Register only trusted `create` functions you compiled with the host. Carts declare required refs on the capability manifest; they do not ship source strings into this API.
|
|
70
|
+
|
|
71
|
+
### Options
|
|
72
|
+
|
|
73
|
+
| Option | Default | Meaning |
|
|
74
|
+
|---|---|---|
|
|
75
|
+
| `allowlist` | required | `{ id, version }[]`. `id` may be a dotted segment pattern (`host.*`). **Version is always exact** — `*` never loads. |
|
|
76
|
+
| `modules` | required | Trusted factories keyed by exact `id` + `version`. Duplicate refs throw. |
|
|
77
|
+
| `limits.maxInvokeMs` | none | Cooperative timeout. `invoke` receives `AbortSignal`; hanging thenables fail with `timeout`. |
|
|
78
|
+
| `limits.maxInvokesPerTurn` | none | Allowlisted invoke budget. Reset with `beginTurn()`. |
|
|
79
|
+
| `capabilities` | `{}` | Frozen bag passed into each factory. Nested plain objects are frozen; class instances stay shared handles. |
|
|
80
|
+
|
|
81
|
+
Per-registration `capabilities` overlay the host bag, then the result is frozen.
|
|
82
|
+
|
|
83
|
+
### Handle
|
|
84
|
+
|
|
85
|
+
| Member | Meaning |
|
|
86
|
+
|---|---|
|
|
87
|
+
| `load(ref)` | Instantiate the factory if the exact ref is registered and allowlisted. Idempotent. |
|
|
88
|
+
| `invoke(ref, input?)` | `load` if needed, then call `invoke`. Returns `{ ok: true, value }` or `{ ok: false, error }`. |
|
|
89
|
+
| `beginTurn()` | Increment the turn counter and reset `maxInvokesPerTurn`. |
|
|
90
|
+
| `inspect()` | Allowlist, registered/loaded refs, turn, diagnostics. |
|
|
91
|
+
| `destroy()` | Drop the registry, abort in-flight signals, call optional instance `destroy()`. Idempotent. |
|
|
92
|
+
|
|
93
|
+
`error.code` is one of: `invalid-ref`, `unknown-module`, `version-mismatch`, `not-allowlisted`, `load-failed`, `invoke-failed`, `timeout`, `rate-limited`, `destroyed`. Failures are recorded on `inspect().diagnostics`. Sibling modules and the cart continue.
|
|
94
|
+
|
|
95
|
+
## Isolation
|
|
96
|
+
|
|
97
|
+
This boundary is **in-process**. Factories run on the cart’s thread; there is no worker, iframe, or separate origin. Isolation is the allowlist, exact id+version matching, a frozen capability bag, invoke budgets, and `{ ok: false }` on throw so a sibling can still run. Do not pass untrusted source strings. Untrusted code needs a host-owned worker/iframe **outside** this API.
|
|
98
|
+
|
|
99
|
+
## Capability manifest
|
|
100
|
+
|
|
101
|
+
Optional additive key (omitted on existing carts):
|
|
102
|
+
|
|
103
|
+
```ts
|
|
104
|
+
modules: {
|
|
105
|
+
refs: [
|
|
106
|
+
{ id: 'overlay-fx', version: '1.0.0' },
|
|
107
|
+
{ id: 'host.module', version: '1.0.0' },
|
|
108
|
+
],
|
|
109
|
+
}
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
`validateCapabilityManifest` returns `unsupported-module` when the host does not list a required exact ref on `host.modules.refs`.
|
package/docs/headless-harness.md
CHANGED
|
@@ -12,7 +12,7 @@ Back to the [package README](../README.md).
|
|
|
12
12
|
|
|
13
13
|
| Surface | Import |
|
|
14
14
|
|---|---|
|
|
15
|
-
| Carts, Player, kaleidoscope, `/art` | `@cyberart-io/engine` (`createRuntime`, events, assets, presentation adapter) |
|
|
15
|
+
| Carts, Player, kaleidoscope, `/art` | `@cyberart-io/engine` (`createRuntime`, events, contracts, assets, presentation adapter, presentation cue, capability manifest, geometry, runtime group) |
|
|
16
16
|
| Vitest / jsdom / CI capture | `@cyberart-io/engine/headless` (`createHeadlessHarness`, `createHeadlessMultiCartHarness`, `installHeadlessCanvas`, `captureFrame`) |
|
|
17
17
|
|
|
18
18
|
The main export does not re-export the harness. A browser bundle that only imports `@cyberart-io/engine` must not warn about `node:fs/promises`.
|
|
@@ -38,10 +38,38 @@ import {
|
|
|
38
38
|
installHeadlessCanvas();
|
|
39
39
|
```
|
|
40
40
|
|
|
41
|
-
Mutates `HTMLCanvasElement.prototype`: `ImageData` polyfill (width/height
|
|
41
|
+
Mutates `HTMLCanvasElement.prototype`: `ImageData` polyfill (width/height and `ImageData(data, w, h)`), software `getContext('2d')` (paths, arcs, text via a glyph atlas, transforms, clip, alpha, source-over, linear/radial gradients, `drawImage` from ImageData / headless canvas / `createImageFixture`), `toDataURL` encodes those pixels as PNG. `HEADLESS_PNG_DATA_URL` remains a 1×1 compatibility export for callers that never attached a context. Idempotent. **Do not call from production playback.**
|
|
42
42
|
|
|
43
43
|
`createHeadlessHarness` calls this for you. Default container size is `DEFAULT_HEADLESS_WIDTH` × `DEFAULT_HEADLESS_HEIGHT` (320×180).
|
|
44
44
|
|
|
45
|
+
Coverage is binary (pixel-center inside path / nearest-neighbor images). No antialiasing, no `Math.random`. Identical seeded harness runs produce identical `ImageData`. Unsupported operations throw `HeadlessUnsupportedOperationError` with the operation name (never silent no-ops). `createPattern`, non-empty `setLineDash`, `filter`, shadows, HTMLImageElement `drawImage`, and unknown context methods are in that set.
|
|
46
|
+
|
|
47
|
+
Font/image fixtures: pass `{ glyphAtlas }` to `installHeadlessCanvas`, call `setDefaultGlyphAtlas`, or `ctx.setGlyphAtlas`. `fillText` never loads system fonts. `drawImage` accepts `ImageData`, another headless canvas, or `createImageFixture(width, height, pixels)`.
|
|
48
|
+
|
|
49
|
+
## Visual assertions
|
|
50
|
+
|
|
51
|
+
Import `compareImageData`, `assertPixelsEqual`, `assertPngDataUrlsEqual`, and `writeVisualArtifacts` from `@cyberart-io/engine/headless`.
|
|
52
|
+
|
|
53
|
+
- Exact match: `tolerance` defaults to 0 (every RGBA byte).
|
|
54
|
+
- Perceptual / slack: `tolerance` (max per-channel delta) and/or `perceptualThreshold` (weighted RGB distance with luma weights 0.299 / 0.587 / 0.114).
|
|
55
|
+
- On mismatch the result includes in-memory `expectedPng` / `actualPng` / `diffPng` (magenta where pixels differ) plus data URLs. `writeVisualArtifacts(dir, artifacts)` writes those PNGs (Node `fs` only; same rule as `captureFrame(path)`).
|
|
56
|
+
- Golden update: set `CYBERART_UPDATE_GOLDEN=1` (or `true`) or pass `{ updateGolden: true }`. The compare then treats actual as expected so CI can rewrite goldens. Engine-encoded 8-bit RGBA PNGs round-trip through `decodePng`.
|
|
57
|
+
|
|
58
|
+
```ts
|
|
59
|
+
import { compareImageData, writeVisualArtifacts } from '@cyberart-io/engine/headless';
|
|
60
|
+
|
|
61
|
+
const result = compareImageData(actual, expected, { perceptualThreshold: 2 });
|
|
62
|
+
if (!result.match) {
|
|
63
|
+
await writeVisualArtifacts('/tmp/cyb-62-diff', result.artifacts);
|
|
64
|
+
}
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
One-command visual reproduce in this repo:
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
pnpm exec vitest run packages/engine/src/canvas/cyb-62-headless-canvas2d.repro.spec.ts
|
|
71
|
+
```
|
|
72
|
+
|
|
45
73
|
## `createHeadlessHarness(options)`
|
|
46
74
|
|
|
47
75
|
```ts
|
|
@@ -54,7 +82,7 @@ const harness = createHeadlessHarness({
|
|
|
54
82
|
height: 180,
|
|
55
83
|
origin: 0,
|
|
56
84
|
actions: [{ type: 'asset', atFrame: 0, id: 'room-map', status: 'ready' }],
|
|
57
|
-
initialState: {
|
|
85
|
+
initialState: { scene: 'alpha' },
|
|
58
86
|
gameManager: hostAdapter, // site injection; presentation overlay is attachPresentationAdapter
|
|
59
87
|
onEvent: (event) => {},
|
|
60
88
|
onError: (error, info) => {},
|
|
@@ -92,7 +120,7 @@ Always sets `deterministic: { origin, actions }`.
|
|
|
92
120
|
| `click(x, y)` | Pointer-**down** at `getClock().framesElapsed` (next tick). Canvas pixels, not CSS. Use `schedule` for `move` / `up`. |
|
|
93
121
|
| `key(key)` | Same next-frame `schedule` for a key. Cart must `registerAction`. |
|
|
94
122
|
| `inspect()` | `{ state, events, errors, replay, clock }`. `state` is **exported**, not live `getCartState()`. |
|
|
95
|
-
| `captureFrame(path?)` | `cart.snapshot()
|
|
123
|
+
| `captureFrame(path?)` | `cart.snapshot()` (`canvas.toDataURL('image/png')` on the production buffer). Optional `path` writes PNG bytes (Node only). |
|
|
96
124
|
| `remount(options?)` | `runtime.mount` again while alive. Constructor `actions` replay; prior `click`/`key`/`schedule` dropped. `'onEvent' in options` replaces the listener; `{ onEvent: undefined }` clears it. Invalid after `destroy()`. |
|
|
97
125
|
| `destroy()` | Unload cart, destroy runtime, remove container. Idempotent. Always removes the container even if unload throws. |
|
|
98
126
|
|
|
@@ -118,9 +146,11 @@ First frame throw: `{ phase: 'update', consecutive: 1, stopped: false }`. See [d
|
|
|
118
146
|
|
|
119
147
|
Returns `CartSnapshot`: `{ seed, metadata?, pngDataUrl }`.
|
|
120
148
|
|
|
121
|
-
- Omit `path` for in-memory snapshot (`pngDataUrl` is
|
|
149
|
+
- Omit `path` for in-memory snapshot (`pngDataUrl` is the software Canvas2D frame, not the 1×1 stub).
|
|
122
150
|
- With `path`: Node `fs.promises.writeFile` of decoded PNG bytes. Throws a harness message if not Node, if `node:fs/promises` cannot load, or if the write fails (missing directory, etc.). Write under `os.tmpdir()`, not the repo.
|
|
123
151
|
|
|
152
|
+
Composed stacks use `createCompositor` plus `writeComposedFrame(compositor, path)` from the same headless entry. See [compositor](compositor.md).
|
|
153
|
+
|
|
124
154
|
## Interaction loop (agent recipe)
|
|
125
155
|
|
|
126
156
|
```ts
|
|
@@ -142,17 +172,17 @@ adapter.present({
|
|
|
142
172
|
contractVersion: 1,
|
|
143
173
|
phase: 'ready',
|
|
144
174
|
view: {
|
|
145
|
-
title: '
|
|
175
|
+
title: 'Alpha',
|
|
146
176
|
regions: [
|
|
147
177
|
{
|
|
148
|
-
id: 'north
|
|
178
|
+
id: 'region-north',
|
|
149
179
|
x: 0,
|
|
150
180
|
y: 0,
|
|
151
181
|
width: canvas.width,
|
|
152
182
|
height: canvas.height * 0.25,
|
|
153
183
|
intent: {
|
|
154
|
-
type: '
|
|
155
|
-
payload: { exitId: '
|
|
184
|
+
type: 'host.intent.exit-requested',
|
|
185
|
+
payload: { exitId: 'beta' },
|
|
156
186
|
},
|
|
157
187
|
},
|
|
158
188
|
],
|
|
@@ -163,24 +193,24 @@ harness.click(canvas.width / 2, canvas.height * 0.1);
|
|
|
163
193
|
await harness.step(1);
|
|
164
194
|
|
|
165
195
|
const { events } = await harness.inspect();
|
|
166
|
-
// assert exactly one
|
|
196
|
+
// assert exactly one host.intent.exit-requested; cart state is still Alpha
|
|
167
197
|
|
|
168
198
|
adapter.present({
|
|
169
199
|
contractVersion: 1,
|
|
170
200
|
phase: 'ready',
|
|
171
|
-
view: { title: '
|
|
201
|
+
view: { title: 'Overlook', regions: [] },
|
|
172
202
|
});
|
|
173
203
|
await harness.step(1);
|
|
174
204
|
|
|
175
|
-
await harness.captureFrame('/tmp/
|
|
205
|
+
await harness.captureFrame('/tmp/alpha.png');
|
|
176
206
|
adapter.destroy();
|
|
177
207
|
harness.destroy();
|
|
178
208
|
```
|
|
179
209
|
|
|
180
210
|
Cleanup: cart `teardown` runs on unload; after `destroy`, `step` throws and the container is gone. `afterEach(() => harness.destroy())` so a failed assertion does not leak nodes. `attachPresentationAdapter(harness)` then `adapter.destroy()` tears down the harness too; `attachPresentationAdapter(harness.cart)` only unloads the cart.
|
|
181
211
|
|
|
182
|
-
Host-owned
|
|
212
|
+
Host-owned scenes / hotspots use this adapter (`present` + `host.intent.*`). `gameManager` is site multiplayer, not this contract.
|
|
183
213
|
|
|
184
214
|
## Types
|
|
185
215
|
|
|
186
|
-
`CreateHeadlessHarnessOptions`, `HeadlessHarness`, `HeadlessInspect`, `HeadlessFrameError
|
|
216
|
+
`CreateHeadlessHarnessOptions`, `HeadlessHarness`, `HeadlessInspect`, `HeadlessFrameError`, `InstallHeadlessCanvasOptions`, `HeadlessUnsupportedOperationError`, visual-assert types.
|
package/docs/midi.md
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
# MIDI controller
|
|
2
|
+
|
|
3
|
+
Host-owned live MIDI in and out. Carts and hosts construct `MidiManager`. The runtime does not pass it into `getDefaultState`. This is controller I/O (Web MIDI), not MIDI file playback.
|
|
4
|
+
|
|
5
|
+
Capability manifests already list `midi` under `integrations`. Related: [capability manifest](capability-manifest.md).
|
|
6
|
+
|
|
7
|
+
Back to the [package README](../README.md).
|
|
8
|
+
|
|
9
|
+
## One-command reproduce (this repo)
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
pnpm exec vitest run packages/engine/src/canvas/cyb-19-midi.repro.spec.ts
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Why this exists
|
|
16
|
+
|
|
17
|
+
A piece that maps a keyboard or fader to parameters needs the same API in a browser (real ports) and in CI (no hardware). `inject` delivers inbound note / CC / pitch without `navigator.requestMIDIAccess`. `send` writes note-on/off, CC, pitch, or raw bytes through an injectable port. Missing Web MIDI or a denied permission is a structured result, so silent carts keep running.
|
|
18
|
+
|
|
19
|
+
## `new MidiManager(options?)`
|
|
20
|
+
|
|
21
|
+
```ts
|
|
22
|
+
import { MidiManager } from '@cyberart-io/engine';
|
|
23
|
+
|
|
24
|
+
const sent: number[][] = [];
|
|
25
|
+
const midi = new MidiManager({
|
|
26
|
+
output: {
|
|
27
|
+
send(data) {
|
|
28
|
+
sent.push([...data]);
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
const stop = midi.subscribe('note', (message) => {
|
|
34
|
+
if (message.kind === 'noteon') {
|
|
35
|
+
// message.channel 0–15, message.note, message.velocity, message.status
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
midi.inject({ kind: 'noteon', channel: 0, note: 60, velocity: 100 });
|
|
40
|
+
midi.sendNoteOn(0, 64, 90);
|
|
41
|
+
midi.sendCc(3, 1, 127);
|
|
42
|
+
midi.send([0xf8]);
|
|
43
|
+
|
|
44
|
+
const access = await midi.requestAccess();
|
|
45
|
+
// jsdom / Node: { ok: false, reason: 'unavailable' }
|
|
46
|
+
// user gesture denied: { ok: false, reason: 'denied' }
|
|
47
|
+
// browser with ports: { ok: true, inputs, outputs, sysexEnabled }
|
|
48
|
+
|
|
49
|
+
stop();
|
|
50
|
+
midi.destroy();
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Headless and jsdom have no `navigator.requestMIDIAccess`. Inject and send still work when you pass a test `output`.
|
|
54
|
+
|
|
55
|
+
### Options
|
|
56
|
+
|
|
57
|
+
| Option | Default | Meaning |
|
|
58
|
+
|---|---|---|
|
|
59
|
+
| `output` | none | Port used by `send`. Tests pass a recording fake. When omitted, `requestAccess` adopts the first hardware output and re-adopts after hotplug if that port disconnects. A constructor `output` is never replaced. |
|
|
60
|
+
| `requestMIDIAccess` | `navigator.requestMIDIAccess` | Override for tests (fake access or a rejecting function). |
|
|
61
|
+
|
|
62
|
+
`statechange` on the access object attaches new inputs and **detaches disconnected ports immediately** (not only on `destroy()`). Stale `midimessage` listeners are removed when the input leaves `access.inputs`.
|
|
63
|
+
|
|
64
|
+
### Inbound
|
|
65
|
+
|
|
66
|
+
| Member | Meaning |
|
|
67
|
+
|---|---|
|
|
68
|
+
| `subscribe(kind, listener)` | `kind`: `note` (on and off) \| `cc` \| `pitch` \| `raw` \| `*`. Returns unsubscribe. |
|
|
69
|
+
| `inject(input)` | Deliver without hardware. Voice fields, `Uint8Array`, or raw `number[]`. No-op after `destroy`. |
|
|
70
|
+
|
|
71
|
+
### Outbound
|
|
72
|
+
|
|
73
|
+
| Member | Meaning |
|
|
74
|
+
|---|---|
|
|
75
|
+
| `sendNoteOn(channel, note, velocity?)` | Status `0x90 \| channel`. Velocity default 100. |
|
|
76
|
+
| `sendNoteOff(channel, note, velocity?)` | Status `0x80 \| channel`. Velocity default 0. |
|
|
77
|
+
| `sendCc(channel, controller, value)` | Status `0xB0 \| channel`. |
|
|
78
|
+
| `sendPitch(channel, value)` | 14-bit pitch bend `0–16383` (center `8192`). Status `0xE0 \| channel`. |
|
|
79
|
+
| `send(bytes)` | Raw bytes through the port. |
|
|
80
|
+
|
|
81
|
+
Each send returns `{ ok: true, data }` or `{ ok: false, reason: 'no-port' \| 'invalid' \| 'destroyed' }`. Out-of-range channel (not 0–15), 7-bit data, or pitch is `invalid`, not a throw.
|
|
82
|
+
|
|
83
|
+
### Access and teardown
|
|
84
|
+
|
|
85
|
+
| Member | Meaning |
|
|
86
|
+
|---|---|
|
|
87
|
+
| `requestAccess({ sysex? })` | Wraps Web MIDI when present. Never throws. |
|
|
88
|
+
| `destroy()` | Removes hardware listeners and subscribers. Idempotent. |
|
|
89
|
+
|
|
90
|
+
`requestAccess` results:
|
|
91
|
+
|
|
92
|
+
| Result | When |
|
|
93
|
+
|---|---|
|
|
94
|
+
| `{ ok: true, inputs, outputs, sysexEnabled }` | Access granted. Inputs are attached; a constructor `output` is kept. |
|
|
95
|
+
| `{ ok: false, reason: 'unavailable' }` | No `navigator.requestMIDIAccess` (headless / jsdom / insecure context). |
|
|
96
|
+
| `{ ok: false, reason: 'denied' }` | The request rejected (permission or security). |
|
|
97
|
+
| `{ ok: false, reason: 'destroyed' }` | `destroy()` ran before the promise settled, or after teardown. |
|
|
98
|
+
|
|
99
|
+
## Types, channels, status bytes
|
|
100
|
+
|
|
101
|
+
Channel is **0–15** (MIDI channels 1–16) in the status low nibble. Command lives in the high nibble.
|
|
102
|
+
|
|
103
|
+
| Export | Value | Role |
|
|
104
|
+
|---|---|---|
|
|
105
|
+
| `MIDI_NOTE_OFF` | `0x80` | Note Off command |
|
|
106
|
+
| `MIDI_NOTE_ON` | `0x90` | Note On command |
|
|
107
|
+
| `MIDI_CONTROL_CHANGE` | `0xB0` | Control Change command |
|
|
108
|
+
| `MIDI_PITCH_BEND` | `0xE0` | Pitch Bend command |
|
|
109
|
+
| `MIDI_CHANNEL_MIN` / `MIDI_CHANNEL_MAX` | `0` / `15` | Valid channel range |
|
|
110
|
+
| `MIDI_DATA_MAX` | `127` | Max 7-bit data byte (note, velocity, CC) |
|
|
111
|
+
| `MIDI_PITCH_CENTER` / `MIDI_PITCH_MAX` | `8192` / `16383` | 14-bit pitch bend |
|
|
112
|
+
| `midiStatus(command, channel)` | `(command & 0xF0) \| (channel & 0x0F)` | Build a status byte |
|
|
113
|
+
| `midiChannelFromStatus(status)` | `status & 0x0F` | Channel 0–15 from a status byte |
|
|
114
|
+
| `isMidiChannel(value)` | boolean | Integer 0–15 |
|
|
115
|
+
| `isMidiData(value)` | boolean | Integer 0–127 (`MIDI_DATA_MAX`) |
|
|
116
|
+
| `encodeMidiMessage` / `parseMidiBytes` | bytes ↔ `MidiMessage` | Shared by inject and hardware |
|
|
117
|
+
|
|
118
|
+
`MidiMessage` kinds: `noteon` / `noteoff` / `cc` / `pitch` / `raw`. Each carries `status` and a copied `data` `Uint8Array`. Note-on with velocity `0` parses as `noteoff` (MIDI convention); the status byte stays `0x9n`.
|
|
119
|
+
|
|
120
|
+
Public types for the `requestMIDIAccess` option (tests inject a fake; browsers pass the real Web MIDI objects):
|
|
121
|
+
|
|
122
|
+
| Type | Role |
|
|
123
|
+
|---|---|
|
|
124
|
+
| `MidiRequestAccess` | `(options?: { sysex?: boolean }) => Promise<MidiAccessLike>` |
|
|
125
|
+
| `MidiAccessLike` | `inputs` / `outputs` (`forEach`), `sysexEnabled`, optional `statechange` |
|
|
126
|
+
| `MidiInputLike` | `addEventListener` / `removeEventListener` for `midimessage` (`event.data`) |
|
|
127
|
+
|
|
128
|
+
Hosts that need a controller construct `MidiManager` themselves. Do not add it to `getDefaultState`.
|
|
@@ -46,17 +46,17 @@ const model: PresentationModel = {
|
|
|
46
46
|
reason: undefined, // string when not ready
|
|
47
47
|
view: {
|
|
48
48
|
background: '#1b3a4a',
|
|
49
|
-
title: '
|
|
49
|
+
title: 'Overlook',
|
|
50
50
|
regions: [
|
|
51
51
|
{
|
|
52
|
-
id: 'north
|
|
52
|
+
id: 'region-north',
|
|
53
53
|
x: 0,
|
|
54
54
|
y: 0,
|
|
55
55
|
width: 320,
|
|
56
56
|
height: 45,
|
|
57
57
|
intent: {
|
|
58
|
-
type: '
|
|
59
|
-
payload: { exitId: '
|
|
58
|
+
type: 'host.intent.exit-requested',
|
|
59
|
+
payload: { exitId: 'beta' },
|
|
60
60
|
},
|
|
61
61
|
},
|
|
62
62
|
],
|
|
@@ -81,9 +81,9 @@ The reference cart never writes the host’s world. An unknown `contractVersion`
|
|
|
81
81
|
|
|
82
82
|
### Intents
|
|
83
83
|
|
|
84
|
-
Region `intent.type` should be routable: `*.intent.*` (for example `
|
|
84
|
+
Region `intent.type` should be routable: `*.intent.*` (for example `host.intent.exit-requested`). The reference cart always sets `kind: 'intent'` on clicks (a hotspot cannot emit `state`).
|
|
85
85
|
|
|
86
|
-
Default router `emit` is `['*.intent.*']`, which does **not** match `room.exit.requested`. Prefer `
|
|
86
|
+
Default router `emit` is `['*.intent.*']`, which does **not** match `room.exit.requested`. Prefer `host.intent.*`.
|
|
87
87
|
|
|
88
88
|
## Reference cart + session
|
|
89
89
|
|
|
@@ -121,7 +121,7 @@ const adapter = mountPresentationAdapter(runtime, {
|
|
|
121
121
|
await adapter.start();
|
|
122
122
|
```
|
|
123
123
|
|
|
124
|
-
A host with a custom view shape passes `cart:` into `mountPresentationAdapter` and still `present()`s the same event. Do not import
|
|
124
|
+
A host with a custom view shape passes `cart:` into `mountPresentationAdapter` and still `present()`s the same event. Do not import the host types into engine carts.
|
|
125
125
|
|
|
126
126
|
## Router
|
|
127
127
|
|
|
@@ -131,7 +131,7 @@ router.attach('presentation', runtime.hostChannel, {
|
|
|
131
131
|
subscribe: [...PRESENTATION_SUBSCRIBE_PATTERNS],
|
|
132
132
|
});
|
|
133
133
|
|
|
134
|
-
router.subscribe(['
|
|
134
|
+
router.subscribe(['host.intent.*', 'presentation.intent.unsupported'], (event) => {
|
|
135
135
|
const next = reduce(world, event); // host validates; ignore invalid intents
|
|
136
136
|
if (next) {
|
|
137
137
|
// Do not pass `{ cause: event }` — a second `presentation.state.model`
|
package/docs/presentation-cue.md
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# Presentation cue / timeline
|
|
2
2
|
|
|
3
|
-
Deterministic cue/effect primitive. Carts call `step(frames)` with the same clock they use for
|
|
3
|
+
Deterministic cue/effect primitive. Carts call `step(frames)` with the same clock they use for deterministic mode; there are no `setTimeout` / rAF timers. Overlay pulses, scene transitions, and NPC beats should share this lifecycle instead of ad-hoc frame counters.
|
|
4
4
|
|
|
5
|
-
Back to the [package README](../README.md). Related: [deterministic mode](deterministic-mode.md).
|
|
5
|
+
Back to the [package README](../README.md). Related: [deterministic mode](deterministic-mode.md), [events](events.md) (routed contracts vs local `cue.*` names).
|
|
6
6
|
|
|
7
7
|
## One-command reproduce (this repo)
|
|
8
8
|
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# Replay inspector
|
|
2
|
+
|
|
3
|
+
Embeddable debug API for multi-cart routing. It records router decisions, builds a causation tree, redacts configured payload keys, bounds retention, and exports a JSON tape that the headless harness can replay. There is no Player UI.
|
|
4
|
+
|
|
5
|
+
Related: [events](events.md), [runtime group](runtime-group.md), [headless harness](headless-harness.md), [deterministic mode](deterministic-mode.md).
|
|
6
|
+
|
|
7
|
+
Back to the [package README](../README.md).
|
|
8
|
+
|
|
9
|
+
## One-command reproduce (this repo)
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
pnpm exec vitest run packages/engine/src/canvas/cyb-65-replay-inspector.repro.spec.ts
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## When to use
|
|
16
|
+
|
|
17
|
+
| Surface | Import | Use |
|
|
18
|
+
|---|---|---|
|
|
19
|
+
| Production host | `createReplayInspector` from `@cyberart-io/engine` | Attach to `createEventRouter` or `createRuntimeGroup`. |
|
|
20
|
+
| Vitest / CI | same API, or from `@cyberart-io/engine/headless` | `replayExportedTrace` against `createHeadlessMultiCartHarness`. |
|
|
21
|
+
|
|
22
|
+
Hosts attach this inspector to a router or runtime group. Event types stay host-defined.
|
|
23
|
+
|
|
24
|
+
## `createReplayInspector(options?)`
|
|
25
|
+
|
|
26
|
+
```ts
|
|
27
|
+
import { createReplayInspector } from '@cyberart-io/engine';
|
|
28
|
+
import { createHeadlessMultiCartHarness } from '@cyberart-io/engine/headless';
|
|
29
|
+
|
|
30
|
+
const group = createHeadlessMultiCartHarness({
|
|
31
|
+
origin: 0,
|
|
32
|
+
participants: [
|
|
33
|
+
{ id: 'effects', cart: effectsCart, emit: ['overlay.intent.*'], subscribe: ['host.state.*'] },
|
|
34
|
+
{ id: 'ambience', cart: ambienceCart, subscribe: ['overlay.intent.*'] },
|
|
35
|
+
],
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
const inspector = createReplayInspector({
|
|
39
|
+
redactedKeys: ['token'],
|
|
40
|
+
maxRecords: 512,
|
|
41
|
+
});
|
|
42
|
+
const session = inspector.bind(group);
|
|
43
|
+
session.publish({ type: 'host.state.accepted', kind: 'state', payload: { token: 'secret' } });
|
|
44
|
+
await session.step(2);
|
|
45
|
+
const exported = await session.exportTrace();
|
|
46
|
+
inspector.destroy();
|
|
47
|
+
group.destroy();
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
`host.state.accepted` delivered to `effects` causes `overlay.intent.play` for `ambience`. The inspector tree is that chain plus any later rejects.
|
|
51
|
+
|
|
52
|
+
### Options (`CreateReplayInspectorOptions`)
|
|
53
|
+
|
|
54
|
+
| Option | Default | Meaning |
|
|
55
|
+
|---|---|---|
|
|
56
|
+
| `redactedKeys` | `[]` | Object keys replaced with `REDACTED_VALUE` in payloads and cart state summaries. |
|
|
57
|
+
| `maxRecords` | `512` | Oldest records drop; `report().dropped` counts them. |
|
|
58
|
+
| `registry` | none | `ContractRegistry.get` annotates `payloadSchemaVersion`. |
|
|
59
|
+
|
|
60
|
+
### Bind vs watch
|
|
61
|
+
|
|
62
|
+
| Method | Meaning |
|
|
63
|
+
|---|---|
|
|
64
|
+
| `watchRouter(router)` | Copy existing `inspectDecisions()`, then subscribe. |
|
|
65
|
+
| `bind(group)` | Watch the group router and record a `publish` / `dispatch` / `step` / asset tape. `publish` extras (`cause`) are stored and replayed. `unbind()` drops the decision subscription. |
|
|
66
|
+
| `importTrace(json)` | Load an export for inspection without replaying. |
|
|
67
|
+
| `exportTrace(filter?)` | JSON: participants, tape, redacted records, snapshots. |
|
|
68
|
+
| `report(filter?)` | Machine-readable CI report: participants, records, trees, dropped. |
|
|
69
|
+
| `causationTree(correlationId?)` | Forest of accepted/rejected nodes. Duplicates are omitted. |
|
|
70
|
+
| `replayExportedTrace(export, group)` | Play the tape on a fresh group; compare with `compareReplayTraces`. |
|
|
71
|
+
|
|
72
|
+
`dispatch` of `cyberart.asset.ready` / `cyberart.asset.failed` is stored as tape `kind: 'asset'`.
|
|
73
|
+
|
|
74
|
+
Cart summaries include `emit` / `subscribe` / `authoritative`, clock, redacted state, and `errorCount` / `lastError` (message only).
|
|
75
|
+
|
|
76
|
+
## Router traces
|
|
77
|
+
|
|
78
|
+
`createEventRouter` now also exposes:
|
|
79
|
+
|
|
80
|
+
| Method | Meaning |
|
|
81
|
+
|---|---|
|
|
82
|
+
| `inspectParticipants()` | id, emit, subscribe, authoritative. |
|
|
83
|
+
| `inspectDecisions()` | Bounded decision log (`maxDecisions`, default 256). |
|
|
84
|
+
| `subscribeDecision(listener)` | Live accepted / rejected / duplicate records. |
|
|
85
|
+
|
|
86
|
+
`RuntimeGroup.inspectParticipants()` adds `kind`. Group `inspect()` includes the same attach fields. `maxTrace` bounds the accepted-event envelope log (default 1024).
|
|
87
|
+
|
|
88
|
+
Duplicate idempotency hits are `outcome: 'duplicate'` / `reason: 'duplicate'` and do not fan out. Permission failures stay `unauthorized`; missing targets `unknown-target`; spent TTL `hop-limit`.
|
package/docs/runtime-group.md
CHANGED
|
@@ -4,7 +4,7 @@ Host helper that mounts several production `createRuntime` carts, attaches each
|
|
|
4
4
|
|
|
5
5
|
Capability-manifest integration is optional and structural (`capability?: { emit, subscribe, authoritative }`). This module does not import the manifest.
|
|
6
6
|
|
|
7
|
-
Related: [events](events.md), [headless harness](headless-harness.md), [deterministic mode](deterministic-mode.md).
|
|
7
|
+
Related: [events](events.md), [headless harness](headless-harness.md), [deterministic mode](deterministic-mode.md), [compositor](compositor.md), [replay inspector](replay-inspector.md).
|
|
8
8
|
|
|
9
9
|
Back to the [package README](../README.md).
|
|
10
10
|
|
|
@@ -40,7 +40,7 @@ const group = createRuntimeGroup({
|
|
|
40
40
|
cart: effectsCart,
|
|
41
41
|
seed: `0x${'61'.repeat(32)}`,
|
|
42
42
|
emit: ['ambience.intent.*'],
|
|
43
|
-
subscribe: ['
|
|
43
|
+
subscribe: ['host.state.*'],
|
|
44
44
|
},
|
|
45
45
|
{
|
|
46
46
|
id: 'ambience',
|
|
@@ -53,16 +53,16 @@ const group = createRuntimeGroup({
|
|
|
53
53
|
});
|
|
54
54
|
|
|
55
55
|
group.publish({
|
|
56
|
-
type: '
|
|
56
|
+
type: 'host.state.accepted',
|
|
57
57
|
kind: 'state',
|
|
58
|
-
payload: {
|
|
58
|
+
payload: { id: 'north' },
|
|
59
59
|
});
|
|
60
60
|
await group.step(2);
|
|
61
61
|
const { participants, trace, diagnostics } = await group.inspect();
|
|
62
62
|
group.destroy();
|
|
63
63
|
```
|
|
64
64
|
|
|
65
|
-
|
|
65
|
+
Example mapping: an accepted `host.state.accepted` is consumed by the effects cart, which emits `ambience.intent.play`; the router delivers that cue to the ambience cart. No project-specific bridge.
|
|
66
66
|
|
|
67
67
|
### Options (`CreateRuntimeGroupOptions`)
|
|
68
68
|
|
|
@@ -74,6 +74,7 @@ Adventure-style mapping (fixture, not Adventure Kit): an accepted `adventure.sta
|
|
|
74
74
|
| `createId` / `now` | `evt-1`… / first cart clock | Injected into the **one** shared router. |
|
|
75
75
|
| `validate` | none | Router `validate` for cart-originated events. |
|
|
76
76
|
| `router` | `{}` | Extra `createEventRouter` options (`maxHops`, `maxPerTurn`, …). |
|
|
77
|
+
| `maxTrace` | `1024` | Bound on the accepted-event inspect trace. |
|
|
77
78
|
|
|
78
79
|
### Participant config
|
|
79
80
|
|
|
@@ -98,10 +99,11 @@ Adventure-style mapping (fixture, not Adventure Kit): an accepted `adventure.sta
|
|
|
98
99
|
| `reset` | Remounts every cart to its initial state, clears traces / logs, `router.turn()`. Does not change `paused`; call `resume()` if you need lockstep after a paused reset. |
|
|
99
100
|
| `dispatch(id, event)` | Mailbox inbound on that participant. |
|
|
100
101
|
| `publish(event)` | Host `router.publish`. Unknown `target` → `unknown-target` rejection. |
|
|
101
|
-
| `inspect()` | `{ participants: Record<id, { state, events, errors, kind, clock }>, trace, diagnostics }`. |
|
|
102
|
+
| `inspect()` | `{ participants: Record<id, { state, events, errors, kind, clock, emit, subscribe, authoritative }>, trace, diagnostics }`. |
|
|
103
|
+
| `inspectParticipants()` | Sync attach view (`id`, `kind`, `emit`, `subscribe`, `authoritative`). |
|
|
102
104
|
| `destroy()` | Detach router listeners, `runtime.destroy()` each cart, remove owned containers. Idempotent. |
|
|
103
105
|
|
|
104
|
-
Idempotency, correlation, causation, unauthorized emit, hop/loop checks are the existing router. The group only attaches and locksteps.
|
|
106
|
+
Idempotency, correlation, causation, unauthorized emit, hop/loop checks are the existing router. The group only attaches and locksteps. For “why did this effect not fire?”, attach [`createReplayInspector`](replay-inspector.md).
|
|
105
107
|
|
|
106
108
|
## `createHeadlessMultiCartHarness(options)`
|
|
107
109
|
|