From e2cc752dbbba110b4deb84549fb7e5ad2f0043ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C4=9Bj=20Such=C3=A1nek?= Date: Thu, 6 Aug 2026 05:04:58 +0200 Subject: [PATCH 1/6] DEV-6449: Add the A/B snapshot Track Player example Publish a copy-ready firmware 0.12.11 Berry Player that validates SPM/SPT generations and follows the shared timeline while keeping EventStore authoritative. Include synthetic bundle fixtures, deterministic host regressions, and an isolated-DEVKIT runner with exact WASM, TNGL, NetworkStorage, causal-clock, and A/B readback gates. The Examples catalog now renders the TypeScript runner alongside the public project files. --- CHANGELOG.md | 19 + README.md | 3 +- app/v2/src/examplesData.ts | 4 +- data/v2/README.md | 10 +- .../README.md | 143 +++ .../example.yaml | 39 + .../player-bundle-artifacts.json | 66 ++ .../player-bundle-devkit-smoke.mts | 1038 +++++++++++++++++ .../player-bundle.be | 774 ++++++++++++ tests/player-bundle-stub.be | 252 ++++ 10 files changed, 2340 insertions(+), 8 deletions(-) create mode 100644 data/v2/examples/player-bundle-ab-snapshot-tracks/README.md create mode 100644 data/v2/examples/player-bundle-ab-snapshot-tracks/example.yaml create mode 100644 data/v2/examples/player-bundle-ab-snapshot-tracks/player-bundle-artifacts.json create mode 100644 data/v2/examples/player-bundle-ab-snapshot-tracks/player-bundle-devkit-smoke.mts create mode 100644 data/v2/examples/player-bundle-ab-snapshot-tracks/player-bundle.be create mode 100644 tests/player-bundle-stub.be diff --git a/CHANGELOG.md b/CHANGELOG.md index af88fdb..82f75a7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,25 @@ Date-based versions use `YYYYMMDD`. +## 20260806 + +### Added + +- Added a copy-ready firmware 0.12.11 Project Berry Player for per-ID SPT + snapshot Tracks selected through an A/B SPM manifest. +- Added a synthetic preferred/fallback bundle corpus, a host Berry lifecycle + regression, and an isolated-DEVKIT runner with exact NetworkStorage and TNGL + readback evidence. +- Added TypeScript runner files to the Examples v2 catalog. + +### Impact + +- Projects can keep Player policy in optional Berry while native SEB remains a + reusable atomic event primitive and EventStore stays authoritative. +- Contributors can reproduce pause, external-authority, seek, rewind, loop, + incomplete-generation fallback, and SPM-last publication behavior without + customer data. + ## 20260702 ### Added diff --git a/README.md b/README.md index d8b0b7d..e9d0119 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,8 @@ data/v2/examples/ ├── gledopto-gl-rc-001wl-espnow-remote/ # Berry espnow.rx examples for a GLEDOPTO GL-RC-001WL remote ├── network-nested-element-groups/ # App elementsJson + controlPages group → groups → devices pattern ├── seb-causal-scene-recall/ # recall a mixed-ID SEB scene at the causal event time -└── seb-segmented-id-track-player/ # play an ordered one-ID Track from bounded SEB segments +├── seb-segmented-id-track-player/ # play an ordered one-ID Track from bounded SEB segments +└── player-bundle-ab-snapshot-tracks/ # A/B SPM/SPT Player with per-ID snapshot Tracks ``` ## Example Rules diff --git a/app/v2/src/examplesData.ts b/app/v2/src/examplesData.ts index 3b256d0..725a978 100644 --- a/app/v2/src/examplesData.ts +++ b/app/v2/src/examplesData.ts @@ -1,6 +1,6 @@ import YAML from 'yaml'; -export type ExampleLanguage = 'berry' | 'tngl' | 'json' | 'markdown'; +export type ExampleLanguage = 'berry' | 'tngl' | 'json' | 'markdown' | 'typescript'; export type ExampleCategory = 'controller' | 'espnow-remote' | 'network-pattern'; export type ExampleFileMeta = { @@ -55,7 +55,7 @@ const metaModules = import.meta.glob('../../../data/v2/examples/*/example.yaml', eager: true, }) as Record; -const fileModules = import.meta.glob('../../../data/v2/examples/*/*.{md,be,tngl,json}', { +const fileModules = import.meta.glob('../../../data/v2/examples/*/*.{md,be,tngl,json,mts}', { query: '?raw', import: 'default', eager: true, diff --git a/data/v2/README.md b/data/v2/README.md index 56c5b0a..09814f3 100644 --- a/data/v2/README.md +++ b/data/v2/README.md @@ -15,7 +15,7 @@ data/v2/ └── / ├── example.yaml # metadata sidecar (drives the browsing app) ├── README.md # human explanation: use case, wiring, behavior - └── *.be / *.tngl / *.json # copyable source files (kept as-is) + └── *.be / *.tngl / *.json / *.mts # copyable source files (kept as-is) ``` The directory name (``) describes the use case, not the client. @@ -35,8 +35,8 @@ Source files are kept copyable and are **not** embedded into YAML. | `files` | every file in the folder with `path`, `role` and `language` | | `related` | optional slugs of related examples | -`language` is one of `berry` (`.be`), `tngl` (`.tngl`), `json` (`.json`) or -`markdown` (`.md`). +`language` is one of `berry` (`.be`), `tngl` (`.tngl`), `json` (`.json`), +`typescript` (`.mts`) or `markdown` (`.md`). ## How to add a new example @@ -59,5 +59,5 @@ Source files are kept copyable and are **not** embedded into YAML. must not duplicate them. - This is a lightweight content model. It is intentionally **not** registered as a strict `module-data.v2.json` yaml-only collection, because that validator - would reject the copyable `.be`/`.tngl`/`.json` source files that are the whole - point of an example. + would reject the copyable `.be`/`.tngl`/`.json`/`.mts` source files that are + the whole point of an example. diff --git a/data/v2/examples/player-bundle-ab-snapshot-tracks/README.md b/data/v2/examples/player-bundle-ab-snapshot-tracks/README.md new file mode 100644 index 0000000..465dc84 --- /dev/null +++ b/data/v2/examples/player-bundle-ab-snapshot-tracks/README.md @@ -0,0 +1,143 @@ +# A/B Snapshot Track Player from NetworkStorage + +This copy-ready Project Berry example plays the final SPM/SPT v1 bundle format +on firmware **0.12.11**. The shared Spectoda timeline is the only transport: +play, pause, seek, rewind, and loop require no separate Player events. + +Each SPT is one Spectoda ID. Each Cue is a complete snapshot of every stable +typed channel in that Track. The plugin automatically includes a global ID255 +Track when the selected generation contains one, then adds only the local IDs +configured for this Controller. + +```berry +PlayerBundle({"namespace": "demo", "ids": [1], "debug": false}) +``` + +Configure local IDs only in the range 0–254. Never put `255` in `ids`; global +ID255 is discovered automatically. A Controller which owns IDs 1 and 7 uses +`{"ids": [1, 7]}`. Another Controller may use `{"ids": [2]}` while all of the +same NetworkStorage files still synchronize to both Controllers. + +## Files and publication order + +For namespace `demo`, the manifest is `demo.spm`; Track files are named +`demo..<000-255>.spt`. `player-bundle-artifacts.json` contains a synthetic +two-generation show as exact binary hex: + +- slot A is the fallback generation; +- slot B is the preferred generation; +- both generations contain ID255 and ID1; +- the ID1 brightness snapshot changes in generation B. + +Convert each `hex` value to exact bytes and upload it with the listed filename +and `version`. A real Studio deployment writes every changed inactive-slot SPT, +reads back its exact bytes/fingerprint, and writes the SPM **last**. Do not hand +edit revisions or fingerprints. + +The plugin watches only the active manifest and relevant active Track files. +Inactive-slot uploads therefore do not interrupt a running show. Once SPM +changes, playback halts. The new preferred generation is selected only after +ID255 (when present) and every configured local SPT match their manifest ID, +artifact revision, exact size, and NetworkStorage fingerprint. An incomplete +preferred generation keeps a complete fallback. Reconciliation after an +artifact change deliberately waits for the shared timeline to be paused. + +## Runtime behavior + +The plugin range-reads the manifest, SPT headers/directories, and individual +SEB segments. It never loads a whole large SPT into one Berry `bytes()` value. +Every embedded SEB receives native fail-closed validation before a generation +can activate. Seek, rewind, and loop binary-search the last Cue at or before the +timeline target and land that complete snapshot at the transport's causal local +millis. Forward playback keeps the exact `(SEB bytes, at, cursor)` tuple until +the segment changes. One local timeline-zero mapping is frozen per +epoch/discontinuity and refreshed when a paused timeline resumes. Equal Cue +times in separate Track files therefore receive the same causal timestamp even +when those Tracks execute on different Plugin turns, while time spent paused is +still reflected in later Cue clocks. + +At most one `SEB.land` execution occurs per Plugin turn. A complete Cue can +therefore use the full firmware 0.12.11 limit of **340 event values** without +competing with another Track in the same transient queue. A Cue is never split. +Future records remain outside EventStore until their timeline offsets are due. + +## Requirements and limits + +- firmware exactly 0.12.11 with final SEB v1 and SPM/SPT v1 support; +- every enabled Track starts with a complete Cue at timeline `0 ms`; +- one SEB segment is at most 4,092 bytes, 340 records, and 65,535 ms; +- one SPT is at most 65,535 bytes, but Studio's measured + `maxTrackPayloadBytes`, bundle, metadata, and free-flash gates may be lower; +- NetworkStorage fingerprints are HMAC-SHA256 with UTF-8 key `fingerprint`; +- this distributed Player uses `SEB.land`; do not replace it with `SEB.emit` + on every Controller. + +Turn `debug` on only while commissioning. The example contains no network key, +MAC address, customer name, or other private installation data. + +## Isolated DEVKIT end-to-end runner + +`player-bundle-devkit-smoke.mts` uses the real NodeSerial connector and App +Controller API. It refuses all writes unless you pass an explicit serial port, +the exact monorepo checkout, and `--confirm-isolated-devkit`: + +```bash +cd /path/to/Spectoda-monorepo +SPECTODA_WASM_VERSION=DEBUG_DEV_0.12.11_YYYYMMDD \ +node_modules/.bin/tsx \ + /path/to/examples/data/v2/examples/player-bundle-ab-snapshot-tracks/player-bundle-devkit-smoke.mts \ + --monorepo=/path/to/Spectoda-monorepo \ + --path=/dev/cu.usbserial-XXXX \ + --confirm-isolated-devkit \ + --evidence=/tmp/player-bundle-smoke.json +``` + +Equivalent environment options are `SPECTODA_MONOREPO`, +`SPECTODA_SERIAL_PATH`, and `SPECTODA_WASM_VERSION`. Optional CLI settings are +`--baudrate`, `--scan-timeout`, `--poll-timeout`, `--namespace`, +`--skip-capacity-probe`, and `--evidence`. + +For a `DEBUG_DEV_*` version, copy that exact local build's `.js` and `.wasm` +files into `.webassembly/` below the runner's current directory. The runner +fails before any Controller write when either file is absent; this prevents the +JS loader from silently using an older universal WASM without the Player APIs. + +The runner derives fresh unsigned-48 revisions above every observed namespace +version. It first writes slot A SPTs with exact readback, then writes the A-only +SPM last. It removes copy-only comment lines and indentation, matching Studio's +Project preprocessing, uploads the Berry as `BERRY(...)` through `writeTngl`, +then resumes automatically in a fresh Node/WASM process after the expected +Controller serial restart. The resumed phase physically reads the TNGL bytecode +into a fresh WASM runtime and requires its fingerprint to equal the pre-restart +compiler fingerprint. It then checks global ID255 and local ID1 values plus +identical causal clocks across pause, external-authority, play, seek, rewind, +and a running-rewind loop. It then writes slot B +SPTs, proves that inactive files did not alter playback, writes the new SPM +last, verifies the preferred switch without a mixed snapshot, and repeats the +transport checks. + +By default the same isolated Controller also gets a deterministic 60,000-byte +NetworkStorage transport/readback probe. The deploy gate accepts 60,000 and +rejects 60,001 before the write function can run. Use +`--skip-capacity-probe` when flash headroom has already been measured elsewhere. +After the playback checks, the runner retires the probe with a higher-version +zero-byte marker so that its payload does not remain on flash. +The runner prints and optionally saves revisions, exact sizes, fingerprints, +the final namespace listing, firmware version, serial port, monorepo head, and +all observed causal clocks. It leaves the example Project Berry and named +NetworkStorage files installed; restore the intended Project after testing. + +## Host Berry regression + +From the Examples repository root, run the deterministic lifecycle/failure-path +test with the Berry host interpreter built by the matching firmware checkout: + +```bash +/path/to/firmware/components/spectoda-scripting/berry/berry \ + tests/player-bundle-stub.be +``` + +The test reads this example's fixture corpus and covers preferred/fallback +selection, bounded range reads, transient metadata and timeline-anchor failures, +pause/resume clocks, seek/loop reconstruction, and local-ID validation without +writing to a Controller. diff --git a/data/v2/examples/player-bundle-ab-snapshot-tracks/example.yaml b/data/v2/examples/player-bundle-ab-snapshot-tracks/example.yaml new file mode 100644 index 0000000..6ff4a3a --- /dev/null +++ b/data/v2/examples/player-bundle-ab-snapshot-tracks/example.yaml @@ -0,0 +1,39 @@ +schemaVersion: example.v2 +slug: player-bundle-ab-snapshot-tracks +title: A/B Snapshot Track Event Player +category: controller +summary: >- + Copy-ready firmware 0.12.11 Project Berry player for SPM/SPT v1 bundles. It + selects a complete preferred or fallback generation, automatically lands the + global ID255 Track plus configured local-ID snapshot Tracks, and follows the + shared timeline through play, pause, seek, rewind, and loop. +tags: + - berry + - event-player + - network-storage + - seb + - spm + - spt + - timeline + - ab-deployment +hardware: >- + Requires exactly firmware 0.12.11 with final SEB v1 and SPM/SPT v1 support. + Configure Project Berry with only this Controller's local Spectoda IDs 0-254; + the global ID255 Track is automatic. Upload the SPM/SPT bundle to network-wide + NetworkStorage. +files: + - path: README.md + role: Setup, publication, validation, and runtime behavior + language: markdown + - path: player-bundle.be + role: Copy-ready Project Berry SPM/SPT Player + language: berry + - path: player-bundle-artifacts.json + role: Synthetic two-generation A/B NetworkStorage artifact corpus + language: json + - path: player-bundle-devkit-smoke.mts + role: Explicit isolated-DEVKIT NodeSerial/App Controller end-to-end runner + language: typescript +related: + - seb-causal-scene-recall + - seb-segmented-id-track-player diff --git a/data/v2/examples/player-bundle-ab-snapshot-tracks/player-bundle-artifacts.json b/data/v2/examples/player-bundle-ab-snapshot-tracks/player-bundle-artifacts.json new file mode 100644 index 0000000..f850ee6 --- /dev/null +++ b/data/v2/examples/player-bundle-ab-snapshot-tracks/player-bundle-artifacts.json @@ -0,0 +1,66 @@ +{ + "schemaVersion": "spectoda.player-bundle-fixture.v1", + "namespace": "demo", + "description": "Synthetic two-generation show: slot B preferred, slot A fallback; Tracks ID255 and ID1.", + "stages": [ + { + "slot": "a", + "trackNames": ["demo.a.001.spt", "demo.a.255.spt"], + "manifest": { + "name": "demo.spm", + "version": 1000, + "size": 200, + "fingerprint": "6d82ddbbb766b941c8a424fc964488b62bcb9791e4e2e504809be785138ff8f9", + "hex": "53504d011000c80000ff302c00000000e803000000000200d007000070005800635568db92ed31734ea0e8423979ab0c3a30f64d05262a978a1788ddcd4f755c00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001008400e8030000000000003bf7f8ca29cb42c7b6b8edc40afa10ebe37ee9d007109668925b05d20337b41cff008400e803000000000000155f3c2aca3c104882a353f19e849a888a323d6629f9ab97adfb8f0ae7514d58" + } + }, + { + "slot": "b", + "trackNames": ["demo.b.001.spt", "demo.b.255.spt"], + "manifest": { + "name": "demo.spm", + "version": 2000, + "size": 288, + "fingerprint": "97804f73468e33f3250fb7a49df7c00fb6b1bd2b18a061674b53414b0bb74b7f", + "hex": "53504d01100020010100302c00000000d007000000000200d0070000700058008d224a5201b7fd05d5cd351a14001243bcebbb5d81dba1d6ea4b102e59b60b60e803000000000200d0070000c8005800635568db92ed31734ea0e8423979ab0c3a30f64d05262a978a1788ddcd4f755c01008400d007000000000000cf0b549819df1c708a33cb566299ef4703b8df0458a8285199d54053db33793fff008400d007000000000000221af38f45e0baeff1998e844b613014739b8d45c53701247a3ecec861c47a1601008400e8030000000000003bf7f8ca29cb42c7b6b8edc40afa10ebe37ee9d007109668925b05d20337b41cff008400e803000000000000155f3c2aca3c104882a353f19e849a888a323d6629f9ab97adfb8f0ae7514d58" + } + } + ], + "uploadOrder": [ + { + "name": "demo.a.001.spt", + "version": 1000, + "size": 132, + "fingerprint": "3bf7f8ca29cb42c7b6b8edc40afa10ebe37ee9d007109668925b05d20337b41c", + "hex": "5350540124000100e803000000000c0cd007000001000300010024004800540084000000000000000000000001000000e8030000000001000100e803d0070000000002000100d007000000000000030054003000534542010c000300d0070c000b688909c8914802000001000b703839c8914802e80301000b688909c8914802d0070100" + }, + { + "name": "demo.a.255.spt", + "version": 1000, + "size": 132, + "fingerprint": "155f3c2aca3c104882a353f19e849a888a323d6629f9ab97adfb8f0ae7514d58", + "hex": "535054012400ff00e803000000000c0cd007000001000300010024004800540084000000000000000000000001000000e8030000000001000100e803d0070000000002000100d007000000000000030054003000534542010c000300d0070c003f0055ffd2c33c030000ff003fff5500d2c33c03e803ff003f0055ffd2c33c03d007ff00" + }, + { + "name": "demo.b.001.spt", + "version": 2000, + "size": 132, + "fingerprint": "cf0b549819df1c708a33cb566299ef4703b8df0458a8285199d54053db33793f", + "hex": "5350540124000100d007000000000c0cd007000001000300010024004800540084000000000000000000000001000000e8030000000001000100e803d0070000000002000100d007000000000000030054003000534542010c000300d0070c000bd01213c8914802000001000b105e5fc8914802e80301000bd01213c8914802d0070100" + }, + { + "name": "demo.b.255.spt", + "version": 2000, + "size": 132, + "fingerprint": "221af38f45e0baeff1998e844b613014739b8d45c53701247a3ecec861c47a16", + "hex": "535054012400ff00d007000000000c0cd007000001000300010024004800540084000000000000000000000001000000e8030000000001000100e803d0070000000002000100d007000000000000030054003000534542010c000300d0070c003f0055ffd2c33c030000ff003fff5500d2c33c03e803ff003f0055ffd2c33c03d007ff00" + }, + { + "name": "demo.spm", + "version": 2000, + "size": 288, + "fingerprint": "97804f73468e33f3250fb7a49df7c00fb6b1bd2b18a061674b53414b0bb74b7f", + "hex": "53504d01100020010100302c00000000d007000000000200d0070000700058008d224a5201b7fd05d5cd351a14001243bcebbb5d81dba1d6ea4b102e59b60b60e803000000000200d0070000c8005800635568db92ed31734ea0e8423979ab0c3a30f64d05262a978a1788ddcd4f755c01008400d007000000000000cf0b549819df1c708a33cb566299ef4703b8df0458a8285199d54053db33793fff008400d007000000000000221af38f45e0baeff1998e844b613014739b8d45c53701247a3ecec861c47a1601008400e8030000000000003bf7f8ca29cb42c7b6b8edc40afa10ebe37ee9d007109668925b05d20337b41cff008400e803000000000000155f3c2aca3c104882a353f19e849a888a323d6629f9ab97adfb8f0ae7514d58" + } + ] +} diff --git a/data/v2/examples/player-bundle-ab-snapshot-tracks/player-bundle-devkit-smoke.mts b/data/v2/examples/player-bundle-ab-snapshot-tracks/player-bundle-devkit-smoke.mts new file mode 100644 index 0000000..0b8c98e --- /dev/null +++ b/data/v2/examples/player-bundle-ab-snapshot-tracks/player-bundle-devkit-smoke.mts @@ -0,0 +1,1038 @@ +import { execFile } from "node:child_process"; +import { createHmac } from "node:crypto"; +import { access, mkdir, readFile, writeFile } from "node:fs/promises"; +import { createRequire } from "node:module"; +import path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { promisify } from "node:util"; + +const execFileAsync = promisify(execFile); +const exampleDir = path.dirname(fileURLToPath(import.meta.url)); + +type Options = { + serialPath: string; + monorepo: string; + wasmVersion?: string; + namespace: string; + baudrate: number; + scanTimeout: number; + pollTimeout: number; + evidence?: string; + capacityProbe: boolean; + confirmed: boolean; + resumeAfterTngl: boolean; + expectedTnglFingerprint?: string; + resumeRevisionA?: number; + resumeRevisionB?: number; + observedNamespaceMax?: number; +}; + +type Artifact = { + name: string; + version: number; + bytes: Uint8Array; + fingerprint: string; +}; + +type ObservedEvent = { + label: string; + id: number; + type: number; + value: unknown; + timestamp: number; +}; + +type ObservedSnapshot = { + global: ObservedEvent; + local: ObservedEvent; +}; + +const parseOptions = (): Options => { + const values = new Map(); + const flags = new Set(); + for (const argument of process.argv.slice(2)) { + if (!argument.startsWith("--")) continue; + const equals = argument.indexOf("="); + if (equals < 0) flags.add(argument.slice(2)); + else values.set(argument.slice(2, equals), argument.slice(equals + 1)); + } + + const serialPath = + values.get("path") ?? process.env.SPECTODA_SERIAL_PATH ?? ""; + const monorepo = path.resolve( + values.get("monorepo") ?? process.env.SPECTODA_MONOREPO ?? "", + ); + return { + serialPath, + monorepo, + wasmVersion: + values.get("wasm-version") ?? process.env.SPECTODA_WASM_VERSION, + namespace: values.get("namespace") ?? "demo", + baudrate: Number.parseInt(values.get("baudrate") ?? "1500000", 10), + scanTimeout: Number.parseInt(values.get("scan-timeout") ?? "10000", 10), + pollTimeout: Number.parseInt(values.get("poll-timeout") ?? "30000", 10), + evidence: values.get("evidence"), + capacityProbe: !flags.has("skip-capacity-probe"), + confirmed: flags.has("confirm-isolated-devkit"), + resumeAfterTngl: flags.has("resume-after-tngl"), + expectedTnglFingerprint: values + .get("expected-tngl-fingerprint") + ?.toLowerCase(), + resumeRevisionA: values.has("resume-revision-a") + ? Number.parseInt(values.get("resume-revision-a")!, 10) + : undefined, + resumeRevisionB: values.has("resume-revision-b") + ? Number.parseInt(values.get("resume-revision-b")!, 10) + : undefined, + observedNamespaceMax: values.has("observed-namespace-max") + ? Number.parseInt(values.get("observed-namespace-max")!, 10) + : undefined, + }; +}; + +const assertOptions = (options: Options): void => { + if (!options.confirmed) { + throw new Error( + "Refusing writes without --confirm-isolated-devkit. Never run this against a customer Controller.", + ); + } + if (!options.serialPath) { + throw new Error("Pass an explicit --path or SPECTODA_SERIAL_PATH"); + } + if ( + !process.env.SPECTODA_MONOREPO && + !process.argv.some((v) => v.startsWith("--monorepo=")) + ) { + throw new Error( + "Pass --monorepo or SPECTODA_MONOREPO for the exact DEV-6449 checkout", + ); + } + if (!/^[A-Za-z0-9_-]{1,14}$/.test(options.namespace)) { + throw new Error( + "Namespace must use 1-14 ASCII letters, digits, underscore or hyphen", + ); + } + if ( + options.expectedTnglFingerprint !== undefined && + !/^[0-9a-f]{64}$/.test(options.expectedTnglFingerprint) + ) { + throw new Error("--expected-tngl-fingerprint must be 32 bytes of hex"); + } + for (const [name, value] of [ + ["baudrate", options.baudrate], + ["scan-timeout", options.scanTimeout], + ["poll-timeout", options.pollTimeout], + ] as const) { + if (!Number.isSafeInteger(value) || value <= 0) { + throw new Error(`Invalid ${name}: ${value}`); + } + } + const resumeValues = [ + options.resumeRevisionA, + options.resumeRevisionB, + options.observedNamespaceMax, + ]; + if ( + options.resumeAfterTngl && + resumeValues.some( + (value) => + value === undefined || !Number.isSafeInteger(value) || value < 0, + ) + ) { + throw new Error( + "The fresh readback process requires valid resume revisions and observed namespace max", + ); + } +}; + +const bytesEqual = (left: Uint8Array, right: Uint8Array): boolean => + left.length === right.length && + left.every((value, index) => value === right[index]); + +const fingerprint = (bytes: Uint8Array): string => + createHmac("sha256", "fingerprint").update(bytes).digest("hex"); + +const normalizeFingerprint = (value: unknown): string => + String(value).toLowerCase(); + +const assertTrackPayloadBytes = (size: number): void => { + if (!Number.isSafeInteger(size) || size < 0 || size > 60_000) { + throw new Error(`maxTrackPayloadBytes exceeded: ${size} > 60000`); + } +}; + +const normalizeColor = (value: unknown): string => + String(value).replace(/^#/, "").toLowerCase(); + +// Studio removes comments and indentation before embedding a Project Berry +// source in TNGL. Mirror that safe subset so the physical smoke measures the +// deployable payload, not the copy-ready documentation comments. +const prepareBerryForTngl = (source: string): string => + source + .split("\n") + .map((line) => line.trim()) + .filter((line) => line.length > 0 && !line.startsWith("#")) + .join("\n"); + +const main = async (): Promise => { + const options = parseOptions(); + assertOptions(options); + if (options.wasmVersion) { + if (options.wasmVersion.startsWith("DEBUG_DEV_")) { + const localWasm = path.join( + process.cwd(), + ".webassembly", + options.wasmVersion, + ); + try { + await Promise.all([ + access(`${localWasm}.js`), + access(`${localWasm}.wasm`), + ]); + } catch { + throw new Error( + `Exact local ${options.wasmVersion}.{js,wasm} is required in ${path.dirname(localWasm)}; refusing the library's older universal-WASM fallback`, + ); + } + } + (globalThis as Record).__SPECTODA_WASM_VERSION_OVERRIDE__ = + options.wasmVersion; + } + + const jsModule = await import( + pathToFileURL(path.join(options.monorepo, "packages/js/v012/index.ts")).href + ); + const functionsModule = await import( + pathToFileURL(path.join(options.monorepo, "packages/js/v012/functions.ts")) + .href + ); + const errorModule = await import( + pathToFileURL(path.join(options.monorepo, "packages/error/index.ts")).href + ); + const codec = await import( + pathToFileURL( + path.join(options.monorepo, "packages/js/v012/src/player-bundle.ts"), + ).href + ); + const api = (jsModule.default ?? jsModule) as Record; + const functions = (functionsModule.default ?? functionsModule) as Record< + string, + any + >; + const errors = (errorModule.default ?? errorModule) as Record; + const { CONNECTORS, Spectoda, SPECTODA_APP_EVENTS } = api; + const { sleep } = functions; + const { isError } = errors; + + const requireFromMonorepo = createRequire( + path.join(options.monorepo, "package.json"), + ); + const { SerialPort } = requireFromMonorepo("serialport") as { + SerialPort: { list(): Promise> }; + }; + const availablePorts = (await SerialPort.list()) + .map((entry) => entry.path) + .filter((entry): entry is string => typeof entry === "string"); + if (!availablePorts.includes(options.serialPath)) { + throw new Error( + `Explicit serial path is not present. Requested=${options.serialPath} available=${availablePorts.join(",")}`, + ); + } + + const fixture = JSON.parse( + await readFile( + path.join(exampleDir, "player-bundle-artifacts.json"), + "utf8", + ), + ) as { + uploadOrder: Array<{ + name: string; + hex: string; + }>; + }; + const source = await readFile( + path.join(exampleDir, "player-bundle.be"), + "utf8", + ); + const baseByName = new Map( + fixture.uploadOrder + .filter((entry) => entry.name.endsWith(".spt")) + .map((entry) => [ + entry.name, + Uint8Array.from(Buffer.from(entry.hex, "hex")), + ]), + ); + const staticManifestEntry = fixture.uploadOrder.find( + (entry) => entry.name === "demo.spm", + ); + if (!staticManifestEntry) throw new Error("Fixture has no final SPM"); + const staticManifest = codec.decodePlayerManifest( + Uint8Array.from(Buffer.from(staticManifestEntry.hex, "hex")), + "demo", + ); + if (!staticManifest.fallback) + throw new Error("Fixture has no fallback generation"); + + const spectoda = new Spectoda(CONNECTORS.NONE, false); + const observed: ObservedEvent[] = []; + const evidence: Record = { + schemaVersion: "spectoda.player-bundle-devkit-smoke.v1", + startedAt: new Date().toISOString(), + serialPath: options.serialPath, + baudrate: options.baudrate, + namespace: options.namespace, + wasmVersion: options.wasmVersion ?? null, + capacity: {}, + writes: [], + generations: [], + }; + + const onEvents = (events: ObservedEvent[]): void => { + observed.push(...events); + }; + let unsubscribeStateUpdates = (): void => undefined; + let unsubscribeEmittedEvents = (): void => undefined; + let physicalTnglReadback: Record | undefined; + + const listStorage = async (): Promise< + Array<{ name: string; version: number; fingerprint: string }> + > => { + const result = await spectoda.listNetworkStorageData(); + if (isError(result) || !Array.isArray(result)) { + throw new Error( + `NetworkStorage listing failed: ${JSON.stringify(result)}`, + ); + } + return result; + }; + + const verifyExactReadback = async (artifact: Artifact): Promise => { + const deadline = Date.now() + options.pollTimeout; + let last: unknown; + while (Date.now() < deadline) { + const readback = await spectoda.getNetworkStorageData(artifact.name); + const listing = await listStorage(); + const metadata = listing.find((entry) => entry.name === artifact.name); + last = { readback, metadata }; + if ( + !isError(readback) && + readback.version === artifact.version && + bytesEqual(readback.bytes, artifact.bytes) && + metadata?.version === artifact.version && + metadata.fingerprint.toLowerCase() === artifact.fingerprint + ) { + console.log( + `[player-bundle-smoke] exact readback ${artifact.name} version=${artifact.version} size=${artifact.bytes.length} fingerprint=${artifact.fingerprint}`, + ); + return; + } + await sleep(250); + } + throw new Error( + `Exact readback timed out for ${artifact.name}: ${JSON.stringify(last)}`, + ); + }; + + const writeAndVerify = async (artifact: Artifact): Promise => { + const result = await spectoda.emitNetworkStorageData({ + name: artifact.name, + version: artifact.version, + bytes: artifact.bytes, + }); + if (isError(result)) { + throw new Error( + `NetworkStorage write failed for ${artifact.name}: ${result.id}`, + ); + } + await verifyExactReadback(artifact); + (evidence.writes as unknown[]).push({ + name: artifact.name, + version: artifact.version, + size: artifact.bytes.length, + fingerprint: artifact.fingerprint, + }); + }; + + const waitEvent = async ( + start: number, + label: string, + id: number, + expected: unknown, + afterTimestamp = -1, + timeout = options.pollTimeout, + ): Promise => { + const deadline = Date.now() + timeout; + while (Date.now() < deadline) { + const match = observed.slice(start).find((event) => { + if ( + event.label !== label || + event.id !== id || + event.timestamp <= afterTimestamp + ) + return false; + return label === "color" + ? normalizeColor(event.value) === normalizeColor(expected) + : event.value === expected; + }); + if (match) return match; + await sleep(250); + } + throw new Error( + `Timed out waiting for $${label}[ID${id}]=${String(expected)}; new events=${JSON.stringify(observed.slice(start))}`, + ); + }; + + const waitSnapshot = async ( + start: number, + brightness: number, + color: string, + afterTimestamp = -1, + timeout = options.pollTimeout, + ): Promise => { + const deadline = Date.now() + timeout; + while (Date.now() < deadline) { + const window = observed + .slice(start) + .filter((event) => event.timestamp > afterTimestamp); + for (const global of window) { + if ( + global.label !== "color" || + global.id !== 255 || + normalizeColor(global.value) !== normalizeColor(color) + ) { + continue; + } + const local = window.find( + (event) => + event.label === "brigh" && + event.id === 1 && + event.value === brightness && + event.timestamp === global.timestamp, + ); + if (local) return { global, local }; + } + await sleep(250); + } + throw new Error( + `Timed out waiting for one causal snapshot color[ID255]=${color} + brigh[ID1]=${brightness}; new events=${JSON.stringify(observed.slice(start))}`, + ); + }; + + const snapshotEvidence = ( + timelineMillis: number, + brightness: number, + color: string, + snapshot: ObservedSnapshot, + ): Record => ({ + timelineMillis, + expected: { + global: { label: "color", id: 255, value: normalizeColor(color) }, + local: { label: "brigh", id: 1, value: brightness }, + causalClockEqual: true, + }, + observed: { + global: { + label: snapshot.global.label, + id: snapshot.global.id, + value: normalizeColor(snapshot.global.value), + clock: snapshot.global.timestamp, + }, + local: { + label: snapshot.local.label, + id: snapshot.local.id, + value: snapshot.local.value, + clock: snapshot.local.timestamp, + }, + causalClockEqual: snapshot.global.timestamp === snapshot.local.timestamp, + }, + }); + + const assertNoValues = async ( + start: number, + duration: number, + forbiddenBrightness: number, + forbiddenColor: string, + ): Promise> => { + await sleep(duration); + const window = observed.slice(start); + const forbiddenEvents = window.filter( + (event) => + (event.label === "brigh" && + event.id === 1 && + event.value === forbiddenBrightness) || + (event.label === "color" && + event.id === 255 && + normalizeColor(event.value) === normalizeColor(forbiddenColor)), + ); + if (forbiddenEvents.length > 0) { + throw new Error( + `Paused timeline landed a future value: ${JSON.stringify(forbiddenEvents[0])}`, + ); + } + return { + durationMs: duration, + expectedAbsent: { + global: { + label: "color", + id: 255, + value: normalizeColor(forbiddenColor), + }, + local: { label: "brigh", id: 1, value: forbiddenBrightness }, + }, + observedForbiddenEvents: forbiddenEvents, + pass: true, + }; + }; + + const exerciseTransport = async ( + generationName: string, + values: { at0: number; at1000: number; at2000: number }, + publishedSnapshot?: ObservedSnapshot, + ): Promise> => { + const generationEvidence: Record = { + generation: generationName, + steps: {}, + }; + const steps = generationEvidence.steps as Record; + + let start = observed.length; + let initial = publishedSnapshot; + if (!initial) { + await spectoda.rewindTimeline(true); + initial = await waitSnapshot(start, values.at0, "#ff5500"); + } + steps.initial = snapshotEvidence(0, values.at0, "#ff5500", initial); + const pausedStart = observed.length; + steps.initialPausedGate = await assertNoValues( + pausedStart, + 300, + values.at1000, + "#0055ff", + ); + + const externalStart = observed.length; + await spectoda.emitPercentage("brigh", 33, 1); + const externalLocal = await waitEvent( + externalStart, + "brigh", + 1, + 33, + initial.local.timestamp, + ); + const externalPausedStart = observed.length; + const externalPausedGate = await assertNoValues( + externalPausedStart, + 300, + values.at1000, + "#0055ff", + ); + steps.externalAuthorityWhilePaused = { + expected: { + timelinePaused: true, + global: { label: "color", id: 255, value: "#ff5500" }, + local: { label: "brigh", id: 1, value: 33 }, + playerDoesNotReassertUntilNextDueCue: true, + }, + observed: { + global: { + label: initial.global.label, + id: initial.global.id, + value: normalizeColor(initial.global.value), + clock: initial.global.timestamp, + }, + local: { + label: externalLocal.label, + id: externalLocal.id, + value: externalLocal.value, + clock: externalLocal.timestamp, + }, + localClockAdvanced: externalLocal.timestamp > initial.local.timestamp, + pausedGate: externalPausedGate, + }, + }; + + start = observed.length; + await spectoda.unpauseTimeline(); + const forward1000 = await waitSnapshot( + start, + values.at1000, + "#0055ff", + initial.global.timestamp, + ); + steps.forward1000 = snapshotEvidence( + 1000, + values.at1000, + "#0055ff", + forward1000, + ); + + await spectoda.pauseTimeline(); + start = observed.length; + let seek: ObservedSnapshot | undefined; + for (let attempt = 0; attempt < 3 && !seek; attempt += 1) { + await spectoda.setTimelineMillis(1000); + try { + seek = await waitSnapshot( + start, + values.at1000, + "#0055ff", + forward1000.global.timestamp, + 2_000, + ); + } catch (error) { + if (attempt === 2) throw error; + await sleep(100); + } + } + if (!seek) throw new Error("Timeline seek produced no complete snapshot"); + steps.seek1000 = snapshotEvidence(1000, values.at1000, "#0055ff", seek); + const seekPausedStart = observed.length; + steps.seekPausedGate = await assertNoValues( + seekPausedStart, + 500, + values.at2000, + "#ff5500", + ); + + start = observed.length; + await spectoda.unpauseTimeline(); + const forward2000 = await waitSnapshot( + start, + values.at2000, + "#ff5500", + seek.global.timestamp, + ); + steps.forward2000 = snapshotEvidence( + 2000, + values.at2000, + "#ff5500", + forward2000, + ); + + // A running rewind represents the loop discontinuity. The Player must + // reconstruct t=0, then continue to the next timed Cue. + start = observed.length; + await spectoda.rewindTimeline(false); + const loop0 = await waitSnapshot( + start, + values.at0, + "#ff5500", + forward2000.global.timestamp, + ); + const loop1000 = await waitSnapshot( + start, + values.at1000, + "#0055ff", + loop0.global.timestamp, + ); + steps.loop0 = snapshotEvidence(0, values.at0, "#ff5500", loop0); + steps.loop1000 = snapshotEvidence(1000, values.at1000, "#0055ff", loop1000); + + start = observed.length; + await spectoda.rewindTimeline(true); + const finalPaused = await waitSnapshot( + start, + values.at0, + "#ff5500", + loop1000.global.timestamp, + ); + steps.finalPaused0 = snapshotEvidence( + 0, + values.at0, + "#ff5500", + finalPaused, + ); + return generationEvidence; + }; + + try { + // SpectodaWasm persists its preview filesystem below the process cwd. + await mkdir(path.resolve(process.cwd(), "filesystem"), { + recursive: true, + }); + await spectoda.connect( + "serial", + { path: options.serialPath, baudrate: options.baudrate }, + { autoSelect: true, scanTimeout: options.scanTimeout }, + ); + unsubscribeStateUpdates = spectoda.on( + SPECTODA_APP_EVENTS.EVENT_STATE_UPDATES, + onEvents, + ); + unsubscribeEmittedEvents = spectoda.on( + SPECTODA_APP_EVENTS.EMITTED_EVENTS, + onEvents, + ); + const controllerInfo = await spectoda.readControllerInfo(); + evidence.controller = { + productCode: controllerInfo.productCode, + fwVersion: controllerInfo.fwVersion, + fwVersionFull: controllerInfo.fwVersionFull, + fwVersionCode: controllerInfo.fwVersionCode, + }; + if (options.resumeAfterTngl) { + const expected = options.expectedTnglFingerprint; + if (!expected) { + throw new Error( + "The fresh readback process requires --expected-tngl-fingerprint", + ); + } + + // syncTngl() performs a physical READ_TNGL_BYTECODE request, then loads + // the returned bytes into this fresh WASM runtime. Comparing its hash to + // the pre-restart compiler hash proves exact bytecode readback even on + // banked firmware whose readControllerInfo() fingerprint is all zeroes. + await spectoda.syncTngl(); + const observedFingerprint = normalizeFingerprint( + spectoda.runtime.spectoda_js.getTnglFingerprint(), + ); + if (observedFingerprint !== expected) { + throw new Error( + `Physical TNGL readback differs: expected=${expected} observed=${observedFingerprint}`, + ); + } + physicalTnglReadback = { + method: "syncTngl physical bytecode readback into a fresh WASM runtime", + expectedBytecodeFingerprint: expected, + observedBytecodeFingerprint: observedFingerprint, + controllerInfoFingerprint: controllerInfo.tnglFingerprint, + exactMatch: true, + resumedAfterSerialRestart: true, + }; + } + const { stdout: monorepoHead } = await execFileAsync( + "git", + ["-C", options.monorepo, "rev-parse", "HEAD"], + { encoding: "utf8" }, + ); + evidence.monorepoHead = monorepoHead.trim(); + console.log( + `[player-bundle-smoke] connected path=${options.serialPath} firmware=${controllerInfo.fwVersionFull} monorepo=${monorepoHead.trim()}`, + ); + + const listingBefore = await listStorage(); + const namespacePrefix = `${options.namespace}.`; + const observedMax = options.resumeAfterTngl + ? options.observedNamespaceMax! + : listingBefore + .filter( + (entry) => + entry.name === `${options.namespace}.spm` || + entry.name.startsWith(namespacePrefix), + ) + .reduce((maximum, entry) => Math.max(maximum, entry.version), 0); + const revisionA = options.resumeAfterTngl + ? options.resumeRevisionA! + : Math.max(Date.now(), observedMax + 1); + const revisionB = options.resumeAfterTngl + ? options.resumeRevisionB! + : revisionA + 1; + codec.assertPlayerRevision(revisionA); + codec.assertPlayerRevision(revisionB); + evidence.revisions = { observedMax, revisionA, revisionB }; + + const makeGeneration = async ( + slot: "a" | "b", + revision: number, + source: any, + ): Promise<{ generation: any; tracks: Artifact[] }> => { + const tracks: Artifact[] = []; + const descriptors: any[] = []; + for (const descriptor of source.tracks) { + const baseName = `demo.${slot}.${String(descriptor.id).padStart(3, "0")}.spt`; + const base = baseByName.get(baseName); + if (!base) throw new Error(`Fixture is missing ${baseName}`); + const bytes = codec.stampPlayerTrackRevision(base, revision); + assertTrackPayloadBytes(bytes.length); + const artifact: Artifact = { + name: codec.playerTrackFilename( + options.namespace, + slot, + descriptor.id, + ), + version: revision, + bytes, + fingerprint: fingerprint(bytes), + }; + tracks.push(artifact); + descriptors.push({ + id: descriptor.id, + artifactRevision: revision, + size: bytes.length, + fingerprint: artifact.fingerprint, + }); + } + return { + tracks, + generation: { + slot, + showRevision: revision, + durationMillis: source.durationMillis, + contentFingerprint: source.contentFingerprint, + tracks: descriptors, + }, + }; + }; + + const stageA = await makeGeneration( + "a", + revisionA, + staticManifest.fallback, + ); + const stageB = await makeGeneration( + "b", + revisionB, + staticManifest.preferred, + ); + const manifestABytes = codec.encodePlayerManifest({ + namespace: options.namespace, + preferred: stageA.generation, + fallback: null, + }); + const manifestBBytes = codec.encodePlayerManifest({ + namespace: options.namespace, + preferred: stageB.generation, + fallback: stageA.generation, + }); + const manifestA: Artifact = { + name: codec.playerManifestFilename(options.namespace), + version: revisionA, + bytes: manifestABytes, + fingerprint: fingerprint(manifestABytes), + }; + const manifestB: Artifact = { + name: codec.playerManifestFilename(options.namespace), + version: revisionB, + bytes: manifestBBytes, + fingerprint: fingerprint(manifestBBytes), + }; + + assertTrackPayloadBytes(60_000); + let overLimitWriteAttempted = false; + try { + assertTrackPayloadBytes(60_001); + overLimitWriteAttempted = true; + } catch { + // Expected pre-write rejection. + } + if (overLimitWriteAttempted) { + throw new Error("60,001-byte payload reached the write boundary"); + } + (evidence.capacity as Record).preWriteGate = { + accepted: 60_000, + rejectedBeforeWrite: 60_001, + }; + let capacityProbe: Artifact | undefined; + if (options.capacityProbe) { + const probeBytes = Uint8Array.from( + { length: 60_000 }, + (_, index) => index & 0xff, + ); + capacityProbe = { + name: `${options.namespace}.cap.bin`, + version: revisionA, + bytes: probeBytes, + fingerprint: fingerprint(probeBytes), + }; + if (options.resumeAfterTngl) { + await verifyExactReadback(capacityProbe); + } else { + await writeAndVerify(capacityProbe); + } + (evidence.capacity as Record).exactReadback = { + name: capacityProbe.name, + size: capacityProbe.bytes.length, + version: capacityProbe.version, + fingerprint: capacityProbe.fingerprint, + writtenBeforeTnglRestart: true, + verifiedAfterRestart: options.resumeAfterTngl, + }; + } + + // A/B contract: all inactive-slot SPT writes and exact readbacks precede + // the manifest write. SPM is always the final publication operation. + if (options.resumeAfterTngl) { + for (const artifact of stageA.tracks) await verifyExactReadback(artifact); + await verifyExactReadback(manifestA); + evidence.preRestartPublication = { + revision: revisionA, + artifacts: [...stageA.tracks, manifestA].map((artifact) => ({ + name: artifact.name, + version: artifact.version, + size: artifact.bytes.length, + fingerprint: artifact.fingerprint, + })), + spmLast: true, + exactReadbackAfterRestart: true, + }; + } else { + for (const artifact of stageA.tracks) await writeAndVerify(artifact); + await writeAndVerify(manifestA); + } + + const uploadSource = prepareBerryForTngl(source); + const configuredSource = uploadSource.replace( + 'PlayerBundle({"namespace": "demo", "ids": [1], "debug": false})', + `PlayerBundle({"namespace":"${options.namespace}","ids":[1],"debug":true})`, + ); + if (configuredSource === uploadSource) { + throw new Error( + "Could not replace the example PlayerBundle configuration", + ); + } + const tngl = `BERRY(\`${configuredSource.replaceAll("`", "\\`")}\`);`; + if (!options.resumeAfterTngl) { + await spectoda.writeTngl(tngl, null, 0); + const expectedTnglFingerprint = normalizeFingerprint( + spectoda.runtime.spectoda_js.getTnglFingerprint(), + ); + if (!/^[0-9a-f]{64}$/.test(expectedTnglFingerprint)) { + throw new Error( + `Compiler returned an invalid TNGL fingerprint: ${expectedTnglFingerprint}`, + ); + } + // Big TNGL writes restart the ESP after the JS execute promise resolves. + // Let flash finalization and bank activation settle before any readback + // process reconnects; early connects can legitimately report no bank. + await sleep(15_000); + await spectoda.disconnect().catch(() => undefined); + await spectoda.runtime.destroyConnector().catch(() => undefined); + + // A large Project TNGL restarts the physical Controller and intentionally + // drops NodeSerial. Continue in a fresh process/WASM runtime so all + // playback assertions are made after a real serial reconnect. + const childArguments = process.argv + .slice(2) + .filter( + (argument) => + argument !== "--resume-after-tngl" && + !argument.startsWith("--expected-tngl-fingerprint="), + ); + childArguments.push("--resume-after-tngl"); + childArguments.push( + `--expected-tngl-fingerprint=${expectedTnglFingerprint}`, + ); + childArguments.push(`--resume-revision-a=${revisionA}`); + childArguments.push(`--resume-revision-b=${revisionB}`); + childArguments.push(`--observed-namespace-max=${observedMax}`); + let child: { stdout: string; stderr: string } | undefined; + let lastChildError: unknown; + for (let attempt = 1; attempt <= 3; attempt += 1) { + try { + child = await execFileAsync( + path.join(options.monorepo, "node_modules/.bin/tsx"), + [fileURLToPath(import.meta.url), ...childArguments], + { + cwd: process.cwd(), + encoding: "utf8", + env: process.env, + maxBuffer: 10 * 1024 * 1024, + }, + ); + break; + } catch (error) { + lastChildError = error; + if (attempt === 3) throw error; + console.warn( + `[player-bundle-smoke] fresh TNGL readback process attempt ${attempt} failed; retrying after restart settles`, + ); + await sleep(5_000); + } + } + if (!child) throw lastChildError; + process.stdout.write(child.stdout); + process.stderr.write(child.stderr); + return; + } + + (evidence.controller as Record).postTnglReadback = { + ...physicalTnglReadback, + fwVersionFull: controllerInfo.fwVersionFull, + berrySourceBytes: Buffer.byteLength(configuredSource, "utf8"), + }; + (evidence.generations as unknown[]).push( + await exerciseTransport("slot-a", { at0: 10, at1000: 60, at2000: 10 }), + ); + + const inactiveWriteStart = observed.length; + for (const artifact of stageB.tracks) await writeAndVerify(artifact); + const premature = observed + .slice(inactiveWriteStart) + .find( + (event) => + event.label === "brigh" && event.id === 1 && event.value === 20, + ); + if (premature) { + throw new Error( + `Inactive-slot SPT changed playback before SPM: ${JSON.stringify(premature)}`, + ); + } + const publicationStart = observed.length; + await writeAndVerify(manifestB); + const switched = await waitSnapshot(publicationStart, 20, "#ff5500"); + console.log( + `[player-bundle-smoke] A/B switch PASS slot=b causalClock=${switched.global.timestamp}`, + ); + (evidence.generations as unknown[]).push( + await exerciseTransport( + "slot-b", + { at0: 20, at1000: 100, at2000: 20 }, + switched, + ), + ); + + if (options.capacityProbe) { + const retiredProbe: Artifact = { + name: `${options.namespace}.cap.bin`, + version: revisionB + 1, + bytes: new Uint8Array(), + fingerprint: fingerprint(new Uint8Array()), + }; + codec.assertPlayerRevision(retiredProbe.version, "capacity cleanup"); + await writeAndVerify(retiredProbe); + (evidence.capacity as Record).cleanup = { + name: retiredProbe.name, + version: retiredProbe.version, + size: 0, + fingerprint: retiredProbe.fingerprint, + }; + } + + const finalMetadata = (await listStorage()).filter( + (entry) => + entry.name === `${options.namespace}.spm` || + entry.name.startsWith(namespacePrefix), + ); + const finalListing = []; + for (const entry of finalMetadata) { + const readback = await spectoda.getNetworkStorageData(entry.name); + finalListing.push({ + ...entry, + size: isError(readback) ? null : readback.bytes.length, + }); + } + evidence.finalListing = finalListing; + evidence.completedAt = new Date().toISOString(); + evidence.result = "PASS"; + console.log( + `[player-bundle-smoke] PASS ${JSON.stringify(evidence, null, 2)}`, + ); + if (options.evidence) { + await writeFile( + path.resolve(options.evidence), + `${JSON.stringify(evidence, null, 2)}\n`, + ); + } + } finally { + unsubscribeStateUpdates(); + unsubscribeEmittedEvents(); + await spectoda.disconnect().catch(() => undefined); + } +}; + +main() + .then(() => process.exit(0)) + .catch((error) => { + console.error("[player-bundle-smoke] FAIL", error); + process.exit(1); + }); diff --git a/data/v2/examples/player-bundle-ab-snapshot-tracks/player-bundle.be b/data/v2/examples/player-bundle-ab-snapshot-tracks/player-bundle.be new file mode 100644 index 0000000..af4c609 --- /dev/null +++ b/data/v2/examples/player-bundle-ab-snapshot-tracks/player-bundle.be @@ -0,0 +1,774 @@ +# Project Berry Player for firmware 0.12.11 and SPM/SPT v1. +# +# Public API: +# PlayerBundle({"namespace": "demo", "ids": [1], "debug": false}) +# +# ID255 is discovered automatically when it exists in a generation. Configure +# only Controller-local IDs 0..254. NetworkStorage remains a global union: this +# Controller validates and opens only the global Track and its configured local +# Tracks. +def PlayerBundle(S) + import string + + var ns = S.find("namespace", "player") + var ids = S.find("ids", []) + var dbg = S.find("debug", false) + var integrity_polls = S.find("integrityPolls", 25) + var retry_polls = S.find("retryPolls", 100) + + # The namespace is also part of every NetworkStorage filename. Keeping the + # validation here makes an invalid Project fail before registering Plugin. + if type(ns) != "string" || size(ns) < 1 || size(ns) > 14 + raise "value_error", "Player namespace must contain 1-14 ASCII characters" + end + var allowed = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_-" + for i : 0..size(ns) - 1 + if string.find(allowed, ns[i]) < 0 + raise "value_error", "Player namespace contains an invalid character" + end + end + var manifest_file = ns + ".spm" + if type(ids) != "instance" || classname(ids) != "list" || + type(integrity_polls) != "int" || type(retry_polls) != "int" || + integrity_polls < 1 || retry_polls < 1 + raise "value_error", "Player ids/poll configuration is invalid" + end + + # local_ids is sorted once so activation and playback stay deterministic. + var local_ids = [] + for id : ids + if type(id) != "int" || id < 0 || id > 254 + raise "value_error", "Player local IDs must be integers in 0..254" + end + var at = 0 + while at < local_ids.size() && local_ids[at] < id + at += 1 + end + if at < local_ids.size() && local_ids[at] == id + raise "value_error", "Player local IDs must be unique" + end + local_ids.insert(at, id) + end + + # Mutable state lives in one map because Berry closures capture scalar + # upvalues by value. active is kept when a newer upload is incomplete. + var R = { + "active": nil, + "armed": false, + "storage_dirty": false, + "reconcile": nil, + "last_time": 0, + "last_epoch": 0, + "last_paused": true, + "timeline_zero": nil, + "poll": 0, + "round": 0, + "error": nil + } + + def log_error(message) + if message != R["error"] + R["error"] = message + if dbg + print("PlayerBundle", message) + end + end + end + + def track_name(slot, id) + var digits = str(id) + if id < 10 + digits = "00" + digits + elif id < 100 + digits = "0" + digits + end + return ns + "." + slot + "." + digits + ".spt" + end + + # getNetworkStorageData(name, offset, length) is bounded to 8192 bytes. + # Every read in this player is a header, directory entry, or <=4092B SEB. + def read_exact(name, offset, length) + var b = spectoda.getNetworkStorageData(name, offset, length) + if b == nil || b.size() != length + return nil + end + return b + end + + def exact_size(name, size) + var tail = spectoda.getNetworkStorageData(name, size, 1) + return tail != nil && tail.size() == 0 + end + + def bytes_zero(b, first, last) + for i : first..last + if b.get(i) != 0 + return false + end + end + return true + end + + def bytes_equal(a, ao, b, bo, count) + for i : 0..count - 1 + if a.get(ao + i) != b.get(bo + i) + return false + end + end + return true + end + + def fingerprint_hex(b, offset) + var h = "0123456789abcdef" + var result = "" + for i : 0..31 + var v = b.get(offset + i) + result += h[(v >> 4) & 15] + h[v & 15] + end + return result + end + + def metadata() + var result = {} + var entries = spectoda.listNetworkStorageData() + if entries == nil + return nil + end + for entry : entries + var name = entry.find("name", nil) + var fingerprint = entry.find("fingerprint", nil) + if name != nil && fingerprint != nil + result[name] = fingerprint + end + end + return result + end + + def generation(header, descriptor_offset, slot, expected_table) + var count = header.get(descriptor_offset + 6, 2) + var duration = header.get(descriptor_offset + 8, 4) + var table = header.get(descriptor_offset + 12, 2) + var table_size = header.get(descriptor_offset + 14, 2) + if duration < 0 || table != expected_table || table_size != count * 44 + return nil + end + return { + "slot": slot, + "duration": duration, + "count": count, + "table": table, + "table_size": table_size, + "tracks": {} + } + end + + # Parse both generation tables so gaps, overlap, duplicates, unsorted IDs, + # reserved fields, and trailing bytes fail before any artifact is opened. + def parse_manifest(meta) + var name = manifest_file + var fp = meta.find(name, nil) + var h = read_exact(name, 0, 112) + if fp == nil || h == nil || h.get(0) != 83 || h.get(1) != 80 || h.get(2) != 77 + return nil + end + var file_size = h.get(6, 2) + var ps = h.get(8) + var fs = h.get(9) + if h.get(3) != 1 || h.get(4) != 16 || h.get(5) != 0 || + (ps != 0 && ps != 1) || (fs != 0 && fs != 1 && fs != 255) || + fs == ps || h.get(10) != 48 || h.get(11) != 44 || + !bytes_zero(h, 12, 15) || file_size < 112 || !exact_size(name, file_size) + return nil + end + + var preferred = generation(h, 16, ps == 0 ? "a" : "b", 112) + if preferred == nil + return nil + end + var expected = 112 + preferred["table_size"] + var fallback = nil + if fs == 255 + if !bytes_zero(h, 64, 111) + return nil + end + else + fallback = generation(h, 64, fs == 0 ? "a" : "b", expected) + if fallback == nil + return nil + end + expected += fallback["table_size"] + end + if expected != file_size + return nil + end + + var generations = [preferred] + if fallback != nil + generations.push(fallback) + end + for g : generations + var previous_id = -1 + for i : 0..g["count"] - 1 + var entry = read_exact(name, g["table"] + i * 44, 44) + if entry == nil + return nil + end + var id = entry.get(0) + if id <= previous_id || entry.get(1) != 0 || entry.get(10, 2) != 0 + return nil + end + previous_id = id + g["tracks"][id] = { + "id": id, + "size": entry.get(2, 2), + "revision": entry[4..9], + "fingerprint": fingerprint_hex(entry, 12) + } + end + end + return {"fingerprint": fp, "preferred": preferred, "fallback": fallback} + end + + def read_segment(track, index) + if index < 0 || index >= track["segment_count"] + return nil + end + var b = read_exact(track["name"], track["segment_offset"] + index * 12, 12) + if b == nil + return nil + end + return { + "origin": b.get(0, 4), + "first_cue": b.get(4, 2), + "cue_count": b.get(6, 2), + "seb_offset": b.get(8, 2), + "seb_size": b.get(10, 2) + } + end + + def read_cue(track, index) + if index < 0 || index >= track["cue_count"] + return nil + end + var b = read_exact(track["name"], track["cue_offset"] + index * 12, 12) + if b == nil + return nil + end + return { + "time": b.get(0, 4), + "segment": b.get(4, 2), + "cursor": b.get(6, 2), + "count": b.get(8, 2), + "offset": b.get(10, 2) + } + end + + # Validate the full SPT directory and each embedded SEB without retaining + # a whole SPT in Berry RAM. SEB.land at cursor=record_count is a native, + # mutation-free full-blob validation call. + def validate_track(descriptor, slot, duration, meta) + var name = track_name(slot, descriptor["id"]) + if descriptor["size"] < 36 || descriptor["size"] > 65535 || + meta.find(name, nil) != descriptor["fingerprint"] || + !exact_size(name, descriptor["size"]) + return nil + end + var h = read_exact(name, 0, 36) + if h == nil || h.get(0) != 83 || h.get(1) != 80 || h.get(2) != 84 || + h.get(3) != 1 || h.get(4) != 36 || h.get(5) != 0 || + h.get(6) != descriptor["id"] || h.get(7) != 0 || + !bytes_equal(h, 8, descriptor["revision"], 0, 6) || + h.get(14) != 12 || h.get(15) != 12 || h.get(16, 4) != duration || + h.get(20, 2) < 1 || h.get(20, 2) > 340 || h.get(22, 2) < 1 || + h.get(24, 2) < 1 || h.get(26, 2) != 36 || h.get(34, 2) != 0 || + h.get(32, 2) != descriptor["size"] + return nil + end + var cue_count = h.get(22, 2) + var segment_count = h.get(24, 2) + var segment_offset = h.get(28, 2) + var data_offset = h.get(30, 2) + if segment_offset != 36 + cue_count * 12 || + data_offset != segment_offset + segment_count * 12 || + data_offset > descriptor["size"] + return nil + end + + var track = { + "id": descriptor["id"], + "name": name, + "fingerprint": descriptor["fingerprint"], + "size": descriptor["size"], + "duration": duration, + "channels": h.get(20, 2), + "cue_count": cue_count, + "segment_count": segment_count, + "cue_offset": 36, + "segment_offset": segment_offset, + "data_offset": data_offset, + "play_segment": 0, + "cursor": 0, + "bytes": nil, + "at": nil + } + + var expected_cue = 0 + var expected_data = data_offset + var previous_time = -1 + for segment_index : 0..segment_count - 1 + var segment = read_segment(track, segment_index) + if segment == nil || segment["origin"] < 0 || + segment["first_cue"] != expected_cue || segment["cue_count"] < 1 || + segment["first_cue"] + segment["cue_count"] > cue_count || + segment["seb_offset"] != expected_data || segment["seb_size"] < 12 || + segment["seb_size"] > 4092 || + segment["seb_offset"] + segment["seb_size"] > descriptor["size"] + return nil + end + var seb = read_exact(name, segment["seb_offset"], segment["seb_size"]) + if seb == nil || seb.get(0) != 83 || seb.get(1) != 69 || seb.get(2) != 66 || + seb.get(3) != 1 || seb.get(4) != 12 || seb.get(5) != 0 || + seb.get(10) != 12 || seb.get(11) != 0 + return nil + end + var records = seb.get(6, 2) + var seb_duration = seb.get(8, 2) + if records < 1 || records > 340 || segment["seb_size"] != 12 + records * 12 || + records != segment["cue_count"] * track["channels"] + return nil + end + var inspected = SEB.land(seb, { + "at": controller.millis(), + "cursor": records, + "until": 0 + }) + if !inspected["ok"] || !inspected["done"] || inspected["cursor"] != records || + inspected["consumed"] != 0 + return nil + end + + var expected_record = 0 + for local_cue : 0..segment["cue_count"] - 1 + var cue_index = segment["first_cue"] + local_cue + var cue = read_cue(track, cue_index) + if cue == nil || cue["time"] <= previous_time || cue["time"] > duration || + cue["segment"] != segment_index || cue["cursor"] != expected_record || + cue["count"] != track["channels"] || + cue["offset"] != cue["time"] - segment["origin"] || + cue["offset"] > seb_duration || + (local_cue == 0 && cue["time"] != segment["origin"]) + return nil + end + if cue_index == 0 && cue["time"] != 0 + return nil + end + for record : 0..cue["count"] - 1 + var record_offset = 12 + (cue["cursor"] + record) * 12 + if seb.get(record_offset + 8, 2) != cue["offset"] || + seb.get(record_offset + 10) != track["id"] + return nil + end + end + expected_record += cue["count"] + previous_time = cue["time"] + end + if expected_record != records + return nil + end + expected_cue += segment["cue_count"] + expected_data += segment["seb_size"] + end + if expected_cue != cue_count || expected_data != descriptor["size"] + return nil + end + return track + end + + def validate_generation(manifest, generation, meta) + if generation == nil + return nil + end + var requested = [] + if generation["tracks"].contains(255) + requested.push(255) + end + for id : local_ids + if !generation["tracks"].contains(id) + return nil + end + requested.push(id) + end + + var tracks = [] + for id : requested + var track = validate_track( + generation["tracks"][id], generation["slot"], generation["duration"], meta) + if track == nil + return nil + end + tracks.push(track) + end + return { + "slot": generation["slot"], + "duration": generation["duration"], + "manifest_fingerprint": manifest["fingerprint"], + "tracks": tracks + } + end + + def active_matches(active, meta) + if meta == nil + return nil + end + if active == nil || + meta.find(manifest_file, nil) != active["manifest_fingerprint"] + return false + end + for track : active["tracks"] + if meta.find(track["name"], nil) != track["fingerprint"] + return false + end + end + return true + end + + # Preferred is tried first. A failed preferred never replaces the current + # generation; a fully valid fallback is the only startup/recovery choice. + def load_bundle() + var before = metadata() + if before == nil + return nil + end + var manifest = parse_manifest(before) + if manifest == nil + return nil + end + var candidate = validate_generation(manifest, manifest["preferred"], before) + if candidate == nil + candidate = validate_generation(manifest, manifest["fallback"], before) + end + if candidate == nil + return nil + end + var after = metadata() + if !active_matches(candidate, after) + return nil + end + return candidate + end + + def subtract_local_millis(local_millis, offset) + # Avoid relying on signed-overflow behavior at the 32-bit millis wrap. + var minimum = -2147483648 + if local_millis >= minimum + offset + return local_millis - offset + end + var underflow = offset - (local_millis - minimum) + return 2147483647 - underflow + 1 + end + + def add_local_millis(local_millis, offset) + # Mirror subtract_local_millis while keeping the result in signed + # 32-bit millis space across controller.millis() wrap. + var maximum = 2147483647 + var room = maximum - local_millis + if offset <= room + return local_millis + offset + end + return -2147483648 + (offset - room - 1) + end + + def find_cue(track, target) + var low = 0 + var high = track["cue_count"] - 1 + var found = nil + while low <= high + var middle = (low + high) >> 1 + var cue = read_cue(track, middle) + if cue == nil + return nil + end + if cue["time"] <= target + found = cue + low = middle + 1 + else + high = middle - 1 + end + end + return found + end + + def prepare_seek(track, target, local_now) + var cue = find_cue(track, target) + if cue == nil + return nil + end + var segment = read_segment(track, cue["segment"]) + if segment == nil + return nil + end + return { + "track": track, + "cue": cue, + "segment": segment, + "at": subtract_local_millis(local_now, cue["offset"]) + } + end + + def begin_reconcile(state) + var active = R["active"] + if active == nil || state["time"] < 0 || state["time"] > active["duration"] + R["armed"] = false + R["reconcile"] = nil + log_error("timeline is outside the active show") + return false + end + # Keep the discontinuity trigger armed until reconciliation completes, + # so a transient anchor/directory read failure retries next turn. + R["last_epoch"] = state["epoch"] - 1 + var local_now = timeline.toMillis(state["time"]) + if local_now == nil + R["armed"] = false + log_error("timeline cannot provide a local millis anchor") + return false + end + R["timeline_zero"] = subtract_local_millis(local_now, state["time"]) + # Freeze one mapping per timeline epoch/discontinuity. Calling + # timeline.toMillis(segment origin) separately for each Track can + # quantize equal Cues one millisecond apart on real Controllers. + # Resolve every Cue and segment directory before the first snapshot + # event is landed. SEB bytes are loaded one Track at a time so a large + # multi-ID bundle does not remain resident in Berry RAM. + var operations = [] + for track : active["tracks"] + var operation = prepare_seek(track, state["time"], local_now) + if operation == nil + R["armed"] = false + log_error("failed to prepare Cue") + return false + end + operations.push(operation) + end + R["armed"] = false + R["reconcile"] = { + "target": state["time"], + "epoch": state["epoch"], + "operations": operations, + "index": 0 + } + return true + end + + def reconcile_step(state) + var reconciliation = R["reconcile"] + if reconciliation == nil + return false + end + if state["epoch"] != reconciliation["epoch"] + R["reconcile"] = nil + return begin_reconcile(state) + end + if reconciliation["index"] < reconciliation["operations"].size() + var operation = reconciliation["operations"][reconciliation["index"]] + var matches = active_matches(R["active"], metadata()) + if matches == nil + return false + end + if !matches + R["storage_dirty"] = true + R["reconcile"] = nil + log_error("Player artifacts changed") + return false + end + var cue = operation["cue"] + var segment = operation["segment"] + var seb = read_exact(operation["track"]["name"], + segment["seb_offset"], segment["seb_size"]) + if seb == nil + log_error("failed to range-read Cue SEB") + return false + end + var result = SEB.land(seb, { + "at": operation["at"], + "cursor": cue["cursor"], + "until": cue["offset"] + }) + if !result["ok"] || result["consumed"] != cue["count"] + log_error("Cue snapshot failed: " + str(result["error"])) + return false + end + var track = operation["track"] + var next_cursor = result["cursor"] + if result["done"] + track["play_segment"] = cue["segment"] + 1 + track["cursor"] = 0 + else + track["play_segment"] = cue["segment"] + track["cursor"] = next_cursor + end + track["bytes"] = nil + track["at"] = nil + reconciliation["index"] += 1 + return true + end + + R["reconcile"] = nil + R["armed"] = true + R["last_time"] = state["time"] + R["last_epoch"] = state["epoch"] + R["last_paused"] = state["paused"] + R["error"] = nil + return true + end + + # Return 1 after one SEB call, 0 when this Track has nothing due, and -1 on + # a read/anchor failure. Cursor advances only from a successful native call. + def process_track(track, target) + if track["play_segment"] >= track["segment_count"] + return 0 + end + var segment = read_segment(track, track["play_segment"]) + if segment == nil || target < segment["origin"] + return segment == nil ? -1 : 0 + end + if track["bytes"] == nil + track["bytes"] = read_exact(track["name"], segment["seb_offset"], segment["seb_size"]) + if R["timeline_zero"] != nil + track["at"] = add_local_millis(R["timeline_zero"], segment["origin"]) + end + if track["bytes"] == nil || track["at"] == nil + track["bytes"] = nil + track["at"] = nil + return -1 + end + end + var until = target - segment["origin"] + var duration = track["bytes"].get(8, 2) + if until > duration + until = duration + end + var result = SEB.land(track["bytes"], { + "at": track["at"], + "cursor": track["cursor"], + "until": until + }) + if !result["ok"] + log_error("forward SEB failed: " + str(result["error"])) + return 1 + end + track["cursor"] = result["cursor"] + if result["done"] + track["play_segment"] += 1 + track["cursor"] = 0 + track["bytes"] = nil + track["at"] = nil + end + R["error"] = nil + return 1 + end + + # At most one SEB call is issued per Plugin turn. This makes the native + # 340-value atomic queue available to one complete Cue even with many IDs. + def process_forward(target) + var tracks = R["active"]["tracks"] + if tracks.size() == 0 + return + end + for checked : 0..tracks.size() - 1 + var index = (R["round"] + checked) % tracks.size() + var status = process_track(tracks[index], target) + if status != 0 + R["round"] = (index + 1) % tracks.size() + if status < 0 + log_error("failed to range-read active SPT") + end + return + end + end + end + + return Plugin(def() + var state = timeline.getState() + R["poll"] += 1 + + # Integrity polling watches only the manifest and currently active + # relevant SPTs. Writes to the inactive slot do not interrupt playback; + # the SPM-last publication is the change that arms reconciliation. + if R["active"] != nil && R["poll"] % integrity_polls == 0 + if active_matches(R["active"], metadata()) == false + R["storage_dirty"] = true + R["armed"] = false + R["reconcile"] = nil + log_error("Player artifacts changed; pause") + end + end + + if R["storage_dirty"] + if state["paused"] && R["poll"] % retry_polls == 0 + var candidate = load_bundle() + if candidate != nil + R["active"] = candidate + R["storage_dirty"] = false + begin_reconcile(state) + else + log_error("Player generation incomplete") + end + end + return + end + + if R["active"] == nil + if R["poll"] == 1 || R["poll"] % retry_polls == 0 + var candidate = load_bundle() + if candidate != nil + R["active"] = candidate + begin_reconcile(state) + else + log_error("no complete Player generation") + end + end + return + end + + if R["reconcile"] != nil + reconcile_step(state) + return + end + + # A paused timeline stops advancing while the local/network clock does + # not. Refresh the mapping on resume without landing the current Cue + # again: an external event remains authoritative until the next Cue, + # whose causal clock must still reflect the time spent paused. + if R["last_paused"] && !state["paused"] + var local_now = timeline.toMillis(state["time"]) + if local_now == nil + log_error("timeline resume has no local anchor") + return + end + R["timeline_zero"] = subtract_local_millis(local_now, state["time"]) + for track : R["active"]["tracks"] + track["bytes"] = nil + track["at"] = nil + end + end + + # Rewind, seek, and timeline loop are all reconstructed from the last + # complete Cue snapshot at/before the new shared timeline position. + if state["epoch"] != R["last_epoch"] || state["time"] < R["last_time"] + if begin_reconcile(state) + reconcile_step(state) + end + return + end + + if !state["paused"] && R["armed"] + process_forward(state["time"]) + end + R["last_time"] = state["time"] + R["last_epoch"] = state["epoch"] + R["last_paused"] = state["paused"] + end) +end + +# Copy this call into each Controller's Project and configure only its local IDs. +PlayerBundle({"namespace": "demo", "ids": [1], "debug": false}) diff --git a/tests/player-bundle-stub.be b/tests/player-bundle-stub.be new file mode 100644 index 0000000..8c2fe5b --- /dev/null +++ b/tests/player-bundle-stub.be @@ -0,0 +1,252 @@ +# Host-Berry lifecycle smoke for the public Project script. It reads the same +# two-generation corpus shipped with the example and supplies small firmware +# API stubs; no production Controller APIs are involved. +import json + +class SpectodaStub + var files, fingerprints, reads, list_failures + + def init(fixture) + self.files = {} + self.fingerprints = {} + self.reads = [] + self.list_failures = 0 + for item : fixture["uploadOrder"] + self.files[item["name"]] = bytes(item["hex"]) + self.fingerprints[item["name"]] = item["fingerprint"] + end + end + + def listNetworkStorageData() + if self.list_failures > 0 + self.list_failures -= 1 + return nil + end + var result = [] + for name : self.files.keys() + result.push({"name": name, "fingerprint": self.fingerprints[name]}) + end + return result + end + + def getNetworkStorageData(name, offset, length) + self.reads.push([name, offset, length]) + var data = self.files.find(name, nil) + if data == nil || offset < 0 || offset > data.size() + return nil + end + if length == 0 || offset == data.size() + return bytes() + end + return data[offset..offset + length - 1] + end +end + +class TimelineStub + var state, local_now, anchor_failures + + def init() + self.state = {"time": 0, "paused": true, "epoch": 1} + self.local_now = 1000 + self.anchor_failures = 0 + end + + def getState() + return self.state + end + + def toMillis(timestamp) + if self.anchor_failures > 0 + self.anchor_failures -= 1 + return nil + end + return self.local_now + timestamp - self.state["time"] + end +end + +class ControllerStub + def millis() + return 1000 + end +end + +class SebStub + var applied + + def init() + self.applied = [] + end + + def land(data, args) + var records = data.get(6, 2) + var cursor = args.find("cursor", 0) + var until = args.find("until", 0) + var consumed = 0 + while cursor < records && data.get(12 + cursor * 12 + 8, 2) <= until + var offset = 12 + cursor * 12 + self.applied.push({ + "value": data.get(offset, 4), + "offset": data.get(offset + 8, 2), + "id": data.get(offset + 10), + "at": args["at"] + }) + cursor += 1 + consumed += 1 + end + return { + "ok": true, + "cursor": cursor, + "consumed": consumed, + "done": cursor == records, + "error": nil + } + end +end + +var fixture_file = open( + "data/v2/examples/player-bundle-ab-snapshot-tracks/player-bundle-artifacts.json", "r") +var fixture = json.load(fixture_file.read()) +fixture_file.close() +var final_manifest = nil +for item : fixture["uploadOrder"] + if item["name"] == "demo.spm" + final_manifest = item + end +end +var stage_a_manifest = fixture["stages"][0]["manifest"] +assert(final_manifest != nil) + +spectoda = SpectodaStub(fixture) +timeline = TimelineStub() +controller = ControllerStub() +SEB = SebStub() +stub_state = {"callback": nil} + +def Plugin(callback) + stub_state["callback"] = callback + return callback +end + +compile("data/v2/examples/player-bundle-ab-snapshot-tracks/player-bundle.be", "file")() +assert(stub_state["callback"] != nil) + +# First turn validates preferred slot B and prepares the timeline-zero snapshot. +stub_state["callback"]() +# One complete Track snapshot per turn: global ID255, then configured ID1. +stub_state["callback"]() +stub_state["callback"]() +stub_state["callback"]() +assert(SEB.applied.size() == 2) +assert(SEB.applied[0]["id"] == 255) +assert(SEB.applied[1]["id"] == 1) +# Generation B ID1 starts at 20%; this is its raw portable value_t. +assert(SEB.applied[1]["value"] == bytes("0bd01213").get(0, 4)) + +# Resume and advance the shared timeline. Both 1000 ms Cues are eventually due, +# but still at most one SEB call is made in each callback turn. +# Eight seconds elapsed locally while the timeline stayed paused. The Player +# must refresh its mapping so the next Cue gets clock 9000, not the stale 2000. +timeline.local_now = 9000 +timeline.state["paused"] = false +timeline.state["time"] = 1000 +# A transient local-anchor failure retries on the next Plugin turn without +# disarming forward playback. +timeline.anchor_failures = 1 +for i : 0..3 + stub_state["callback"]() +end +assert(SEB.applied.size() == 4) +assert(SEB.applied[2]["offset"] == 1000) +assert(SEB.applied[3]["offset"] == 1000) +assert(SEB.applied[2]["at"] == 8000) +assert(SEB.applied[3]["at"] == 8000) + +# A transient metadata listing failure at the next integrity poll must not mark +# valid artifacts dirty or stop the next due Cue. +spectoda.list_failures = 1 +for i : 0..16 + stub_state["callback"]() +end +timeline.state["time"] = 2000 +for i : 0..3 + stub_state["callback"]() +end +assert(SEB.applied.size() == 6) +assert(SEB.applied[5]["offset"] == 2000) + +# Publish slot A by changing SPM last. The player halts while dirty, waits for +# the paused retry boundary, then lands one complete t=2000 snapshot per Track. +timeline.state["paused"] = true +spectoda.files["demo.spm"] = bytes(stage_a_manifest["hex"]) +spectoda.fingerprints["demo.spm"] = stage_a_manifest["fingerprint"] +for i : 0..69 + stub_state["callback"]() +end +assert(SEB.applied.size() == 6) +for i : 0..3 + stub_state["callback"]() +end +assert(SEB.applied.size() == 8) +# Generation A ID1 at t=2000 returns to 10%. +assert(SEB.applied[7]["value"] == bytes("0b688909").get(0, 4)) + +# A backward loop reconstructs the complete t=0 snapshot with one fresh anchor +# shared across both Track files. +timeline.local_now = 20000 +timeline.state["time"] = 0 +for i : 0..2 + stub_state["callback"]() +end +assert(SEB.applied.size() == 10) +assert(SEB.applied[8]["at"] == 20000) +assert(SEB.applied[9]["at"] == 20000) + +# Restore the final two-generation manifest for the independent fallback test. +spectoda.files["demo.spm"] = bytes(final_manifest["hex"]) +spectoda.fingerprints["demo.spm"] = final_manifest["fingerprint"] + +# A missing preferred local artifact must select the complete slot-A fallback. +spectoda.files.remove("demo.b.001.spt") +spectoda.fingerprints.remove("demo.b.001.spt") +timeline = TimelineStub() +SEB = SebStub() +stub_state["callback"] = nil +compile("data/v2/examples/player-bundle-ab-snapshot-tracks/player-bundle.be", "file")() +stub_state["callback"]() +stub_state["callback"]() +stub_state["callback"]() +stub_state["callback"]() +assert(SEB.applied.size() == 2) +assert(SEB.applied[1]["id"] == 1) +# Generation A ID1 starts at 10%. +assert(SEB.applied[1]["value"] == bytes("0b688909").get(0, 4)) + +for read : spectoda.reads + assert(read[2] <= 4092) +end + +# Lock both sides of the sorted local-ID contract so valid unsorted IDs and real +# duplicates do not regress behind the single-ID physical fixture. +var player_file = open( + "data/v2/examples/player-bundle-ab-snapshot-tracks/player-bundle.be", "r") +var player_source = player_file.read() +player_file.close() +var valid_multi_id = true +try + compile(player_source + + '\nPlayerBundle({"namespace":"demo","ids":[1,0],"debug":false})', + "string")() +except .. + valid_multi_id = false +end +assert(valid_multi_id) +var duplicate_rejected = false +try + compile(player_source + + '\nPlayerBundle({"namespace":"demo","ids":[5,5],"debug":false})', + "string")() +except .. + duplicate_rejected = true +end +assert(duplicate_rejected) +print("player-bundle preferred/fallback/range smoke: PASS") From b2b3b3d4f574ccd4bcc2e5684a6d4e3126ce2920 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C4=9Bj=20Such=C3=A1nek?= Date: Thu, 6 Aug 2026 11:14:27 +0200 Subject: [PATCH 2/6] Examples: Align Player terminology with complete Cues Rename the unpublished public Player example around complete Cues and per-ID Tracks. Public copy now treats event labels as tracked events within a Track, keeps Capture in native WASM, and presents SEB only as compiled binary. The SPM/SPT fixture bytes and Berry playback policy remain unchanged. --- CHANGELOG.md | 2 +- README.md | 2 +- .../README.md | 22 +++--- .../example.yaml | 9 +-- .../player-bundle-artifacts.json | 0 .../player-bundle-devkit-smoke.mts | 68 +++++++++---------- .../player-bundle.be | 14 ++-- .../seb-segmented-id-track-player/README.md | 5 +- .../example.yaml | 5 +- tests/player-bundle-stub.be | 16 ++--- 10 files changed, 76 insertions(+), 67 deletions(-) rename data/v2/examples/{player-bundle-ab-snapshot-tracks => player-bundle-ab-complete-cue-tracks}/README.md (87%) rename data/v2/examples/{player-bundle-ab-snapshot-tracks => player-bundle-ab-complete-cue-tracks}/example.yaml (78%) rename data/v2/examples/{player-bundle-ab-snapshot-tracks => player-bundle-ab-complete-cue-tracks}/player-bundle-artifacts.json (100%) rename data/v2/examples/{player-bundle-ab-snapshot-tracks => player-bundle-ab-complete-cue-tracks}/player-bundle-devkit-smoke.mts (95%) rename data/v2/examples/{player-bundle-ab-snapshot-tracks => player-bundle-ab-complete-cue-tracks}/player-bundle.be (98%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 82f75a7..048bd3c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,7 @@ Date-based versions use `YYYYMMDD`. ### Added - Added a copy-ready firmware 0.12.11 Project Berry Player for per-ID SPT - snapshot Tracks selected through an A/B SPM manifest. + Tracks with complete Cues selected through an A/B SPM manifest. - Added a synthetic preferred/fallback bundle corpus, a host Berry lifecycle regression, and an isolated-DEVKIT runner with exact NetworkStorage and TNGL readback evidence. diff --git a/README.md b/README.md index e9d0119..b7ac6b3 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ data/v2/examples/ ├── network-nested-element-groups/ # App elementsJson + controlPages group → groups → devices pattern ├── seb-causal-scene-recall/ # recall a mixed-ID SEB scene at the causal event time ├── seb-segmented-id-track-player/ # play an ordered one-ID Track from bounded SEB segments -└── player-bundle-ab-snapshot-tracks/ # A/B SPM/SPT Player with per-ID snapshot Tracks +└── player-bundle-ab-complete-cue-tracks/ # A/B SPM/SPT Player with complete per-ID Cues ``` ## Example Rules diff --git a/data/v2/examples/player-bundle-ab-snapshot-tracks/README.md b/data/v2/examples/player-bundle-ab-complete-cue-tracks/README.md similarity index 87% rename from data/v2/examples/player-bundle-ab-snapshot-tracks/README.md rename to data/v2/examples/player-bundle-ab-complete-cue-tracks/README.md index 465dc84..8696c30 100644 --- a/data/v2/examples/player-bundle-ab-snapshot-tracks/README.md +++ b/data/v2/examples/player-bundle-ab-complete-cue-tracks/README.md @@ -1,12 +1,13 @@ -# A/B Snapshot Track Player from NetworkStorage +# A/B Complete-Cue Track Player from NetworkStorage This copy-ready Project Berry example plays the final SPM/SPT v1 bundle format on firmware **0.12.11**. The shared Spectoda timeline is the only transport: play, pause, seek, rewind, and loop require no separate Player events. -Each SPT is one Spectoda ID. Each Cue is a complete snapshot of every stable -typed channel in that Track. The plugin automatically includes a global ID255 -Track when the selected generation contains one, then adds only the local IDs +Each SPT is one Track for one Spectoda ID; the Track itself has no event label. +A Track defines stable typed tracked events, and every Cue supplies a complete +value for each of them. The plugin automatically includes a global ID255 Track +when the selected generation contains one, then adds only the local IDs configured for this Controller. ```berry @@ -27,13 +28,18 @@ two-generation show as exact binary hex: - slot A is the fallback generation; - slot B is the preferred generation; - both generations contain ID255 and ID1; -- the ID1 brightness snapshot changes in generation B. +- the ID1 brightness Cue values change in generation B. Convert each `hex` value to exact bytes and upload it with the listed filename and `version`. A real Studio deployment writes every changed inactive-slot SPT, reads back its exact bytes/fingerprint, and writes the SPM **last**. Do not hand edit revisions or fingerprints. +Studio authors Tracks, tracked events, and complete Cues. **Capture** is the +native WASM operation which reads current EventStore values into a complete Cue; +this Berry plugin does not implement Capture. SEB exists here only as compiled +binary segments inside SPT files, not as an authoring concept. + The plugin watches only the active manifest and relevant active Track files. Inactive-slot uploads therefore do not interrupt a running show. Once SPM changes, playback halts. The new preferred generation is selected only after @@ -48,7 +54,7 @@ The plugin range-reads the manifest, SPT headers/directories, and individual SEB segments. It never loads a whole large SPT into one Berry `bytes()` value. Every embedded SEB receives native fail-closed validation before a generation can activate. Seek, rewind, and loop binary-search the last Cue at or before the -timeline target and land that complete snapshot at the transport's causal local +timeline target and land that complete Cue at the transport's causal local millis. Forward playback keeps the exact `(SEB bytes, at, cursor)` tuple until the segment changes. One local timeline-zero mapping is frozen per epoch/discontinuity and refreshed when a paused timeline resumes. Equal Cue @@ -85,7 +91,7 @@ the exact monorepo checkout, and `--confirm-isolated-devkit`: cd /path/to/Spectoda-monorepo SPECTODA_WASM_VERSION=DEBUG_DEV_0.12.11_YYYYMMDD \ node_modules/.bin/tsx \ - /path/to/examples/data/v2/examples/player-bundle-ab-snapshot-tracks/player-bundle-devkit-smoke.mts \ + /path/to/examples/data/v2/examples/player-bundle-ab-complete-cue-tracks/player-bundle-devkit-smoke.mts \ --monorepo=/path/to/Spectoda-monorepo \ --path=/dev/cu.usbserial-XXXX \ --confirm-isolated-devkit \ @@ -113,7 +119,7 @@ compiler fingerprint. It then checks global ID255 and local ID1 values plus identical causal clocks across pause, external-authority, play, seek, rewind, and a running-rewind loop. It then writes slot B SPTs, proves that inactive files did not alter playback, writes the new SPM -last, verifies the preferred switch without a mixed snapshot, and repeats the +last, verifies the preferred switch without mixed Cue state, and repeats the transport checks. By default the same isolated Controller also gets a deterministic 60,000-byte diff --git a/data/v2/examples/player-bundle-ab-snapshot-tracks/example.yaml b/data/v2/examples/player-bundle-ab-complete-cue-tracks/example.yaml similarity index 78% rename from data/v2/examples/player-bundle-ab-snapshot-tracks/example.yaml rename to data/v2/examples/player-bundle-ab-complete-cue-tracks/example.yaml index 6ff4a3a..5276929 100644 --- a/data/v2/examples/player-bundle-ab-snapshot-tracks/example.yaml +++ b/data/v2/examples/player-bundle-ab-complete-cue-tracks/example.yaml @@ -1,12 +1,13 @@ schemaVersion: example.v2 -slug: player-bundle-ab-snapshot-tracks -title: A/B Snapshot Track Event Player +slug: player-bundle-ab-complete-cue-tracks +title: A/B Complete-Cue Track Event Player category: controller summary: >- Copy-ready firmware 0.12.11 Project Berry player for SPM/SPT v1 bundles. It selects a complete preferred or fallback generation, automatically lands the - global ID255 Track plus configured local-ID snapshot Tracks, and follows the - shared timeline through play, pause, seek, rewind, and loop. + global ID255 Track plus configured local-ID Tracks, and follows the shared + timeline through play, pause, seek, rewind, and loop. Each Track defines + stable typed tracked events, and every Cue supplies all their values. tags: - berry - event-player diff --git a/data/v2/examples/player-bundle-ab-snapshot-tracks/player-bundle-artifacts.json b/data/v2/examples/player-bundle-ab-complete-cue-tracks/player-bundle-artifacts.json similarity index 100% rename from data/v2/examples/player-bundle-ab-snapshot-tracks/player-bundle-artifacts.json rename to data/v2/examples/player-bundle-ab-complete-cue-tracks/player-bundle-artifacts.json diff --git a/data/v2/examples/player-bundle-ab-snapshot-tracks/player-bundle-devkit-smoke.mts b/data/v2/examples/player-bundle-ab-complete-cue-tracks/player-bundle-devkit-smoke.mts similarity index 95% rename from data/v2/examples/player-bundle-ab-snapshot-tracks/player-bundle-devkit-smoke.mts rename to data/v2/examples/player-bundle-ab-complete-cue-tracks/player-bundle-devkit-smoke.mts index 0b8c98e..ff2e7a0 100644 --- a/data/v2/examples/player-bundle-ab-snapshot-tracks/player-bundle-devkit-smoke.mts +++ b/data/v2/examples/player-bundle-ab-complete-cue-tracks/player-bundle-devkit-smoke.mts @@ -42,7 +42,7 @@ type ObservedEvent = { timestamp: number; }; -type ObservedSnapshot = { +type ObservedCue = { global: ObservedEvent; local: ObservedEvent; }; @@ -382,13 +382,13 @@ const main = async (): Promise => { ); }; - const waitSnapshot = async ( + const waitCue = async ( start: number, brightness: number, color: string, afterTimestamp = -1, timeout = options.pollTimeout, - ): Promise => { + ): Promise => { const deadline = Date.now() + timeout; while (Date.now() < deadline) { const window = observed @@ -414,15 +414,15 @@ const main = async (): Promise => { await sleep(250); } throw new Error( - `Timed out waiting for one causal snapshot color[ID255]=${color} + brigh[ID1]=${brightness}; new events=${JSON.stringify(observed.slice(start))}`, + `Timed out waiting for one causal Cue color[ID255]=${color} + brigh[ID1]=${brightness}; new events=${JSON.stringify(observed.slice(start))}`, ); }; - const snapshotEvidence = ( + const cueEvidence = ( timelineMillis: number, brightness: number, color: string, - snapshot: ObservedSnapshot, + cue: ObservedCue, ): Record => ({ timelineMillis, expected: { @@ -432,18 +432,18 @@ const main = async (): Promise => { }, observed: { global: { - label: snapshot.global.label, - id: snapshot.global.id, - value: normalizeColor(snapshot.global.value), - clock: snapshot.global.timestamp, + label: cue.global.label, + id: cue.global.id, + value: normalizeColor(cue.global.value), + clock: cue.global.timestamp, }, local: { - label: snapshot.local.label, - id: snapshot.local.id, - value: snapshot.local.value, - clock: snapshot.local.timestamp, + label: cue.local.label, + id: cue.local.id, + value: cue.local.value, + clock: cue.local.timestamp, }, - causalClockEqual: snapshot.global.timestamp === snapshot.local.timestamp, + causalClockEqual: cue.global.timestamp === cue.local.timestamp, }, }); @@ -487,7 +487,7 @@ const main = async (): Promise => { const exerciseTransport = async ( generationName: string, values: { at0: number; at1000: number; at2000: number }, - publishedSnapshot?: ObservedSnapshot, + publishedCue?: ObservedCue, ): Promise> => { const generationEvidence: Record = { generation: generationName, @@ -496,12 +496,12 @@ const main = async (): Promise => { const steps = generationEvidence.steps as Record; let start = observed.length; - let initial = publishedSnapshot; + let initial = publishedCue; if (!initial) { await spectoda.rewindTimeline(true); - initial = await waitSnapshot(start, values.at0, "#ff5500"); + initial = await waitCue(start, values.at0, "#ff5500"); } - steps.initial = snapshotEvidence(0, values.at0, "#ff5500", initial); + steps.initial = cueEvidence(0, values.at0, "#ff5500", initial); const pausedStart = observed.length; steps.initialPausedGate = await assertNoValues( pausedStart, @@ -553,13 +553,13 @@ const main = async (): Promise => { start = observed.length; await spectoda.unpauseTimeline(); - const forward1000 = await waitSnapshot( + const forward1000 = await waitCue( start, values.at1000, "#0055ff", initial.global.timestamp, ); - steps.forward1000 = snapshotEvidence( + steps.forward1000 = cueEvidence( 1000, values.at1000, "#0055ff", @@ -568,11 +568,11 @@ const main = async (): Promise => { await spectoda.pauseTimeline(); start = observed.length; - let seek: ObservedSnapshot | undefined; + let seek: ObservedCue | undefined; for (let attempt = 0; attempt < 3 && !seek; attempt += 1) { await spectoda.setTimelineMillis(1000); try { - seek = await waitSnapshot( + seek = await waitCue( start, values.at1000, "#0055ff", @@ -584,8 +584,8 @@ const main = async (): Promise => { await sleep(100); } } - if (!seek) throw new Error("Timeline seek produced no complete snapshot"); - steps.seek1000 = snapshotEvidence(1000, values.at1000, "#0055ff", seek); + if (!seek) throw new Error("Timeline seek produced no complete Cue"); + steps.seek1000 = cueEvidence(1000, values.at1000, "#0055ff", seek); const seekPausedStart = observed.length; steps.seekPausedGate = await assertNoValues( seekPausedStart, @@ -596,13 +596,13 @@ const main = async (): Promise => { start = observed.length; await spectoda.unpauseTimeline(); - const forward2000 = await waitSnapshot( + const forward2000 = await waitCue( start, values.at2000, "#ff5500", seek.global.timestamp, ); - steps.forward2000 = snapshotEvidence( + steps.forward2000 = cueEvidence( 2000, values.at2000, "#ff5500", @@ -613,30 +613,30 @@ const main = async (): Promise => { // reconstruct t=0, then continue to the next timed Cue. start = observed.length; await spectoda.rewindTimeline(false); - const loop0 = await waitSnapshot( + const loop0 = await waitCue( start, values.at0, "#ff5500", forward2000.global.timestamp, ); - const loop1000 = await waitSnapshot( + const loop1000 = await waitCue( start, values.at1000, "#0055ff", loop0.global.timestamp, ); - steps.loop0 = snapshotEvidence(0, values.at0, "#ff5500", loop0); - steps.loop1000 = snapshotEvidence(1000, values.at1000, "#0055ff", loop1000); + steps.loop0 = cueEvidence(0, values.at0, "#ff5500", loop0); + steps.loop1000 = cueEvidence(1000, values.at1000, "#0055ff", loop1000); start = observed.length; await spectoda.rewindTimeline(true); - const finalPaused = await waitSnapshot( + const finalPaused = await waitCue( start, values.at0, "#ff5500", loop1000.global.timestamp, ); - steps.finalPaused0 = snapshotEvidence( + steps.finalPaused0 = cueEvidence( 0, values.at0, "#ff5500", @@ -969,7 +969,7 @@ const main = async (): Promise => { } const publicationStart = observed.length; await writeAndVerify(manifestB); - const switched = await waitSnapshot(publicationStart, 20, "#ff5500"); + const switched = await waitCue(publicationStart, 20, "#ff5500"); console.log( `[player-bundle-smoke] A/B switch PASS slot=b causalClock=${switched.global.timestamp}`, ); diff --git a/data/v2/examples/player-bundle-ab-snapshot-tracks/player-bundle.be b/data/v2/examples/player-bundle-ab-complete-cue-tracks/player-bundle.be similarity index 98% rename from data/v2/examples/player-bundle-ab-snapshot-tracks/player-bundle.be rename to data/v2/examples/player-bundle-ab-complete-cue-tracks/player-bundle.be index af4c609..766bcf9 100644 --- a/data/v2/examples/player-bundle-ab-snapshot-tracks/player-bundle.be +++ b/data/v2/examples/player-bundle-ab-complete-cue-tracks/player-bundle.be @@ -300,7 +300,7 @@ def PlayerBundle(S) "fingerprint": descriptor["fingerprint"], "size": descriptor["size"], "duration": duration, - "channels": h.get(20, 2), + "event_count": h.get(20, 2), "cue_count": cue_count, "segment_count": segment_count, "cue_offset": 36, @@ -334,7 +334,7 @@ def PlayerBundle(S) var records = seb.get(6, 2) var seb_duration = seb.get(8, 2) if records < 1 || records > 340 || segment["seb_size"] != 12 + records * 12 || - records != segment["cue_count"] * track["channels"] + records != segment["cue_count"] * track["event_count"] return nil end var inspected = SEB.land(seb, { @@ -353,7 +353,7 @@ def PlayerBundle(S) var cue = read_cue(track, cue_index) if cue == nil || cue["time"] <= previous_time || cue["time"] > duration || cue["segment"] != segment_index || cue["cursor"] != expected_record || - cue["count"] != track["channels"] || + cue["count"] != track["event_count"] || cue["offset"] != cue["time"] - segment["origin"] || cue["offset"] > seb_duration || (local_cue == 0 && cue["time"] != segment["origin"]) @@ -536,8 +536,8 @@ def PlayerBundle(S) # Freeze one mapping per timeline epoch/discontinuity. Calling # timeline.toMillis(segment origin) separately for each Track can # quantize equal Cues one millisecond apart on real Controllers. - # Resolve every Cue and segment directory before the first snapshot - # event is landed. SEB bytes are loaded one Track at a time so a large + # Resolve every Cue and segment directory before the first Cue value is + # landed. SEB bytes are loaded one Track at a time so a large # multi-ID bundle does not remain resident in Berry RAM. var operations = [] for track : active["tracks"] @@ -594,7 +594,7 @@ def PlayerBundle(S) "until": cue["offset"] }) if !result["ok"] || result["consumed"] != cue["count"] - log_error("Cue snapshot failed: " + str(result["error"])) + log_error("Complete Cue failed: " + str(result["error"])) return false end var track = operation["track"] @@ -753,7 +753,7 @@ def PlayerBundle(S) end # Rewind, seek, and timeline loop are all reconstructed from the last - # complete Cue snapshot at/before the new shared timeline position. + # complete Cue at/before the new shared timeline position. if state["epoch"] != R["last_epoch"] || state["time"] < R["last_time"] if begin_reconcile(state) reconcile_step(state) diff --git a/data/v2/examples/seb-segmented-id-track-player/README.md b/data/v2/examples/seb-segmented-id-track-player/README.md index 76cc843..b0a4fb3 100644 --- a/data/v2/examples/seb-segmented-id-track-player/README.md +++ b/data/v2/examples/seb-segmented-id-track-player/README.md @@ -3,8 +3,9 @@ This firmware 0.12.11 example keeps Player policy in Project Berry and uses the native `SEB.land` component only as an atomic timed EventStore primitive. -The logical Track controls one state: `$brigh[ID1]`. It is represented by two -ordered 500 ms SEB segments: +The logical Track belongs to Spectoda ID1. This minimal Track has one tracked +event, `$brigh`, and every Cue supplies its complete value. It is represented +by two ordered 500 ms compiled SEB segments: | NetworkStorage file | Timeline start | Values | |---|---:|---| diff --git a/data/v2/examples/seb-segmented-id-track-player/example.yaml b/data/v2/examples/seb-segmented-id-track-player/example.yaml index 326a153..22cd09a 100644 --- a/data/v2/examples/seb-segmented-id-track-player/example.yaml +++ b/data/v2/examples/seb-segmented-id-track-player/example.yaml @@ -3,8 +3,9 @@ slug: seb-segmented-id-track-player title: Timeline-Driven Segmented Single-ID SEB Track category: controller summary: >- - Drive one logical brigh[ID1] Track from the shared timeline using bounded SEB - segments. Project Berry owns segment selection, local-millis projections, + Drive one logical ID1 Track with the tracked event brigh from the shared + timeline using bounded compiled SEB segments. Project Berry owns segment + selection, local-millis projections, pause behavior, cursors and explicit checkpoint reconstruction after seek. tags: - berry-plugin diff --git a/tests/player-bundle-stub.be b/tests/player-bundle-stub.be index 8c2fe5b..1aa6aac 100644 --- a/tests/player-bundle-stub.be +++ b/tests/player-bundle-stub.be @@ -104,7 +104,7 @@ class SebStub end var fixture_file = open( - "data/v2/examples/player-bundle-ab-snapshot-tracks/player-bundle-artifacts.json", "r") + "data/v2/examples/player-bundle-ab-complete-cue-tracks/player-bundle-artifacts.json", "r") var fixture = json.load(fixture_file.read()) fixture_file.close() var final_manifest = nil @@ -127,12 +127,12 @@ def Plugin(callback) return callback end -compile("data/v2/examples/player-bundle-ab-snapshot-tracks/player-bundle.be", "file")() +compile("data/v2/examples/player-bundle-ab-complete-cue-tracks/player-bundle.be", "file")() assert(stub_state["callback"] != nil) -# First turn validates preferred slot B and prepares the timeline-zero snapshot. +# First turn validates preferred slot B and prepares the timeline-zero Cues. stub_state["callback"]() -# One complete Track snapshot per turn: global ID255, then configured ID1. +# One complete Cue per turn: global ID255, then configured ID1. stub_state["callback"]() stub_state["callback"]() stub_state["callback"]() @@ -175,7 +175,7 @@ assert(SEB.applied.size() == 6) assert(SEB.applied[5]["offset"] == 2000) # Publish slot A by changing SPM last. The player halts while dirty, waits for -# the paused retry boundary, then lands one complete t=2000 snapshot per Track. +# the paused retry boundary, then lands one complete t=2000 Cue per Track. timeline.state["paused"] = true spectoda.files["demo.spm"] = bytes(stage_a_manifest["hex"]) spectoda.fingerprints["demo.spm"] = stage_a_manifest["fingerprint"] @@ -190,7 +190,7 @@ assert(SEB.applied.size() == 8) # Generation A ID1 at t=2000 returns to 10%. assert(SEB.applied[7]["value"] == bytes("0b688909").get(0, 4)) -# A backward loop reconstructs the complete t=0 snapshot with one fresh anchor +# A backward loop reconstructs the complete t=0 Cues with one fresh anchor # shared across both Track files. timeline.local_now = 20000 timeline.state["time"] = 0 @@ -211,7 +211,7 @@ spectoda.fingerprints.remove("demo.b.001.spt") timeline = TimelineStub() SEB = SebStub() stub_state["callback"] = nil -compile("data/v2/examples/player-bundle-ab-snapshot-tracks/player-bundle.be", "file")() +compile("data/v2/examples/player-bundle-ab-complete-cue-tracks/player-bundle.be", "file")() stub_state["callback"]() stub_state["callback"]() stub_state["callback"]() @@ -228,7 +228,7 @@ end # Lock both sides of the sorted local-ID contract so valid unsorted IDs and real # duplicates do not regress behind the single-ID physical fixture. var player_file = open( - "data/v2/examples/player-bundle-ab-snapshot-tracks/player-bundle.be", "r") + "data/v2/examples/player-bundle-ab-complete-cue-tracks/player-bundle.be", "r") var player_source = player_file.read() player_file.close() var valid_multi_id = true From d30bc861e0e4d6feb37eedb61a486c920a0ea1d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C4=9Bj=20Such=C3=A1nek?= Date: Sat, 8 Aug 2026 14:49:32 +0200 Subject: [PATCH 3/6] Examples: Finalize the copy-ready Event Player plugin Move the public SPM/SPT Player to the released timeline.at API and keep pause/resume timing terminology aligned with the firmware's causal projection. Declare one stable canonical source path for Studio, Documentation, and generated Creator Kit releases, and pin the DEVKIT instructions to the final 0.12.11 WASM. --- .../README.md | 11 +++++++++-- .../example.yaml | 3 ++- .../player-bundle.be | 14 +++++++------- tests/player-bundle-stub.be | 19 +++++++++++-------- 4 files changed, 29 insertions(+), 18 deletions(-) diff --git a/data/v2/examples/player-bundle-ab-complete-cue-tracks/README.md b/data/v2/examples/player-bundle-ab-complete-cue-tracks/README.md index 8696c30..9dd12b4 100644 --- a/data/v2/examples/player-bundle-ab-complete-cue-tracks/README.md +++ b/data/v2/examples/player-bundle-ab-complete-cue-tracks/README.md @@ -14,6 +14,13 @@ configured for this Controller. PlayerBundle({"namespace": "demo", "ids": [1], "debug": false}) ``` +## Canonical source + +`player-bundle.be` in this directory is the single editable, copy-ready source +for Studio, Documentation, and the generated Spectoda Creator Kit. Keep this +public path stable. Creator Kit releases copy its exact bytes from a locked +Examples commit; the generated bundle is never a second authoring copy. + Configure local IDs only in the range 0–254. Never put `255` in `ids`; global ID255 is discovered automatically. A Controller which owns IDs 1 and 7 uses `{"ids": [1, 7]}`. Another Controller may use `{"ids": [2]}` while all of the @@ -89,7 +96,7 @@ the exact monorepo checkout, and `--confirm-isolated-devkit`: ```bash cd /path/to/Spectoda-monorepo -SPECTODA_WASM_VERSION=DEBUG_DEV_0.12.11_YYYYMMDD \ +SPECTODA_WASM_VERSION=DEBUG_UNIVERSAL_0.12.11_20260808 \ node_modules/.bin/tsx \ /path/to/examples/data/v2/examples/player-bundle-ab-complete-cue-tracks/player-bundle-devkit-smoke.mts \ --monorepo=/path/to/Spectoda-monorepo \ @@ -144,6 +151,6 @@ test with the Berry host interpreter built by the matching firmware checkout: ``` The test reads this example's fixture corpus and covers preferred/fallback -selection, bounded range reads, transient metadata and timeline-anchor failures, +selection, bounded range reads, transient metadata and timeline-projection failures, pause/resume clocks, seek/loop reconstruction, and local-ID validation without writing to a Controller. diff --git a/data/v2/examples/player-bundle-ab-complete-cue-tracks/example.yaml b/data/v2/examples/player-bundle-ab-complete-cue-tracks/example.yaml index 5276929..72e1e90 100644 --- a/data/v2/examples/player-bundle-ab-complete-cue-tracks/example.yaml +++ b/data/v2/examples/player-bundle-ab-complete-cue-tracks/example.yaml @@ -21,7 +21,8 @@ hardware: >- Requires exactly firmware 0.12.11 with final SEB v1 and SPM/SPT v1 support. Configure Project Berry with only this Controller's local Spectoda IDs 0-254; the global ID255 Track is automatic. Upload the SPM/SPT bundle to network-wide - NetworkStorage. + NetworkStorage. The player-bundle.be path is a stable public integration + contract used by Studio, Documentation, and Spectoda Creator Kit releases. files: - path: README.md role: Setup, publication, validation, and runtime behavior diff --git a/data/v2/examples/player-bundle-ab-complete-cue-tracks/player-bundle.be b/data/v2/examples/player-bundle-ab-complete-cue-tracks/player-bundle.be index 766bcf9..eecce25 100644 --- a/data/v2/examples/player-bundle-ab-complete-cue-tracks/player-bundle.be +++ b/data/v2/examples/player-bundle-ab-complete-cue-tracks/player-bundle.be @@ -524,17 +524,17 @@ def PlayerBundle(S) return false end # Keep the discontinuity trigger armed until reconciliation completes, - # so a transient anchor/directory read failure retries next turn. + # so a transient projection/directory read failure retries next turn. R["last_epoch"] = state["epoch"] - 1 - var local_now = timeline.toMillis(state["time"]) + var local_now = timeline.at(state["time"]) if local_now == nil R["armed"] = false - log_error("timeline cannot provide a local millis anchor") + log_error("timeline cannot project local millis") return false end R["timeline_zero"] = subtract_local_millis(local_now, state["time"]) # Freeze one mapping per timeline epoch/discontinuity. Calling - # timeline.toMillis(segment origin) separately for each Track can + # timeline.at(segment origin) separately for each Track can # quantize equal Cues one millisecond apart on real Controllers. # Resolve every Cue and segment directory before the first Cue value is # landed. SEB bytes are loaded one Track at a time so a large @@ -622,7 +622,7 @@ def PlayerBundle(S) end # Return 1 after one SEB call, 0 when this Track has nothing due, and -1 on - # a read/anchor failure. Cursor advances only from a successful native call. + # a read/projection failure. Cursor advances only from a successful native call. def process_track(track, target) if track["play_segment"] >= track["segment_count"] return 0 @@ -740,9 +740,9 @@ def PlayerBundle(S) # again: an external event remains authoritative until the next Cue, # whose causal clock must still reflect the time spent paused. if R["last_paused"] && !state["paused"] - var local_now = timeline.toMillis(state["time"]) + var local_now = timeline.at(state["time"]) if local_now == nil - log_error("timeline resume has no local anchor") + log_error("timeline resume has no local projection") return end R["timeline_zero"] = subtract_local_millis(local_now, state["time"]) diff --git a/tests/player-bundle-stub.be b/tests/player-bundle-stub.be index 1aa6aac..82b4896 100644 --- a/tests/player-bundle-stub.be +++ b/tests/player-bundle-stub.be @@ -2,6 +2,7 @@ # two-generation corpus shipped with the example and supplies small firmware # API stubs; no production Controller APIs are involved. import json +import string class SpectodaStub var files, fingerprints, reads, list_failures @@ -43,21 +44,21 @@ class SpectodaStub end class TimelineStub - var state, local_now, anchor_failures + var state, local_now, projection_failures def init() self.state = {"time": 0, "paused": true, "epoch": 1} self.local_now = 1000 - self.anchor_failures = 0 + self.projection_failures = 0 end def getState() return self.state end - def toMillis(timestamp) - if self.anchor_failures > 0 - self.anchor_failures -= 1 + def at(timestamp) + if self.projection_failures > 0 + self.projection_failures -= 1 return nil end return self.local_now + timestamp - self.state["time"] @@ -149,9 +150,9 @@ assert(SEB.applied[1]["value"] == bytes("0bd01213").get(0, 4)) timeline.local_now = 9000 timeline.state["paused"] = false timeline.state["time"] = 1000 -# A transient local-anchor failure retries on the next Plugin turn without +# A transient local-projection failure retries on the next Plugin turn without # disarming forward playback. -timeline.anchor_failures = 1 +timeline.projection_failures = 1 for i : 0..3 stub_state["callback"]() end @@ -190,7 +191,7 @@ assert(SEB.applied.size() == 8) # Generation A ID1 at t=2000 returns to 10%. assert(SEB.applied[7]["value"] == bytes("0b688909").get(0, 4)) -# A backward loop reconstructs the complete t=0 Cues with one fresh anchor +# A backward loop reconstructs the complete t=0 Cues with one fresh projection # shared across both Track files. timeline.local_now = 20000 timeline.state["time"] = 0 @@ -231,6 +232,8 @@ var player_file = open( "data/v2/examples/player-bundle-ab-complete-cue-tracks/player-bundle.be", "r") var player_source = player_file.read() player_file.close() +assert(string.find(player_source, "timeline.at") >= 0) +assert(string.find(player_source, "timeline.toMillis") < 0) var valid_multi_id = true try compile(player_source + From 1089ae2eee9f0437e3a077eb410c6f856868eb73 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C4=9Bj=20Such=C3=A1nek?= Date: Sat, 8 Aug 2026 14:52:59 +0200 Subject: [PATCH 4/6] Examples: Select the Player plugin for Creator Kit Add an explicit fail-closed export sidecar to the canonical Player example. Creator Kit can now copy only the setup guide, metadata, and Berry source from an exact Examples commit without maintaining another editable script. --- .../README.md | 3 ++- .../creator-kit.json | 17 +++++++++++++++++ .../example.yaml | 3 +++ 3 files changed, 22 insertions(+), 1 deletion(-) create mode 100644 data/v2/examples/player-bundle-ab-complete-cue-tracks/creator-kit.json diff --git a/data/v2/examples/player-bundle-ab-complete-cue-tracks/README.md b/data/v2/examples/player-bundle-ab-complete-cue-tracks/README.md index 9dd12b4..09ef74e 100644 --- a/data/v2/examples/player-bundle-ab-complete-cue-tracks/README.md +++ b/data/v2/examples/player-bundle-ab-complete-cue-tracks/README.md @@ -19,7 +19,8 @@ PlayerBundle({"namespace": "demo", "ids": [1], "debug": false}) `player-bundle.be` in this directory is the single editable, copy-ready source for Studio, Documentation, and the generated Spectoda Creator Kit. Keep this public path stable. Creator Kit releases copy its exact bytes from a locked -Examples commit; the generated bundle is never a second authoring copy. +Examples commit according to the fail-closed `creator-kit.json` selection; the +generated bundle is never a second authoring copy. Configure local IDs only in the range 0–254. Never put `255` in `ids`; global ID255 is discovered automatically. A Controller which owns IDs 1 and 7 uses diff --git a/data/v2/examples/player-bundle-ab-complete-cue-tracks/creator-kit.json b/data/v2/examples/player-bundle-ab-complete-cue-tracks/creator-kit.json new file mode 100644 index 0000000..aa30782 --- /dev/null +++ b/data/v2/examples/player-bundle-ab-complete-cue-tracks/creator-kit.json @@ -0,0 +1,17 @@ +{ + "schemaVersion": "creator-kit-example.v1", + "include": true, + "audience": "partner-maker", + "license": "MIT", + "topic": "studio-event-player", + "stability": "firmware-0.12.11", + "compatibility": { + "firmware": "0.12.11", + "wasm": "DEBUG_UNIVERSAL_0.12.11_20260808" + }, + "files": [ + "README.md", + "example.yaml", + "player-bundle.be" + ] +} diff --git a/data/v2/examples/player-bundle-ab-complete-cue-tracks/example.yaml b/data/v2/examples/player-bundle-ab-complete-cue-tracks/example.yaml index 72e1e90..b0805c8 100644 --- a/data/v2/examples/player-bundle-ab-complete-cue-tracks/example.yaml +++ b/data/v2/examples/player-bundle-ab-complete-cue-tracks/example.yaml @@ -27,6 +27,9 @@ files: - path: README.md role: Setup, publication, validation, and runtime behavior language: markdown + - path: creator-kit.json + role: Explicit fail-closed Spectoda Creator Kit export selection + language: json - path: player-bundle.be role: Copy-ready Project Berry SPM/SPT Player language: berry From 32d283b1521b26688f2fe215f6182b96b49e7156 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C4=9Bj=20Such=C3=A1nek?= Date: Sat, 8 Aug 2026 18:31:56 +0200 Subject: [PATCH 5/6] Event Player: Publish the static player.show Berry plugin Replace the unreleased generation-based Player example with one copy-ready firmware 0.12.11 plugin for a static Show Index and deterministic per-ID SEB chains. The example documents the pause, propagation, rewind and play workflow while keeping ID255 global and local IDs explicit.\n\nAdd a public synthetic corpus plus host-Berry lifecycle and size gates for reconciliation, pause/resume, retry, seek, 24-hour wrap, delayed catch-up and the 4 KiB TNGL budget. --- CHANGELOG.md | 27 +- README.md | 2 +- .../README.md | 157 --- .../example.yaml | 44 - .../player-bundle-artifacts.json | 66 -- .../player-bundle-devkit-smoke.mts | 1038 ----------------- .../player-bundle.be | 774 ------------ .../player-show-complete-cue-tracks/README.md | 62 + .../creator-kit.json | 6 +- .../example.yaml | 35 + .../player-show-artifacts.json | 23 + .../player-show-complete-cue-tracks/player.be | 179 +++ tests/player-bundle-stub.be | 255 ---- tests/player-show-size.mjs | 15 + tests/player-show-stub.be | 281 +++++ 15 files changed, 611 insertions(+), 2353 deletions(-) delete mode 100644 data/v2/examples/player-bundle-ab-complete-cue-tracks/README.md delete mode 100644 data/v2/examples/player-bundle-ab-complete-cue-tracks/example.yaml delete mode 100644 data/v2/examples/player-bundle-ab-complete-cue-tracks/player-bundle-artifacts.json delete mode 100644 data/v2/examples/player-bundle-ab-complete-cue-tracks/player-bundle-devkit-smoke.mts delete mode 100644 data/v2/examples/player-bundle-ab-complete-cue-tracks/player-bundle.be create mode 100644 data/v2/examples/player-show-complete-cue-tracks/README.md rename data/v2/examples/{player-bundle-ab-complete-cue-tracks => player-show-complete-cue-tracks}/creator-kit.json (78%) create mode 100644 data/v2/examples/player-show-complete-cue-tracks/example.yaml create mode 100644 data/v2/examples/player-show-complete-cue-tracks/player-show-artifacts.json create mode 100644 data/v2/examples/player-show-complete-cue-tracks/player.be delete mode 100644 tests/player-bundle-stub.be create mode 100644 tests/player-show-size.mjs create mode 100644 tests/player-show-stub.be diff --git a/CHANGELOG.md b/CHANGELOG.md index 048bd3c..f38bcf1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,24 +2,25 @@ Date-based versions use `YYYYMMDD`. -## 20260806 +## 20260808 -### Added +### Changed -- Added a copy-ready firmware 0.12.11 Project Berry Player for per-ID SPT - Tracks with complete Cues selected through an A/B SPM manifest. -- Added a synthetic preferred/fallback bundle corpus, a host Berry lifecycle - regression, and an isolated-DEVKIT runner with exact NetworkStorage and TNGL - readback evidence. -- Added TypeScript runner files to the Examples v2 catalog. +- Replaced the unreleased A/B Player bundle with the canonical firmware 0.12.11 + Project Berry Player for one static `player.show` and deterministic per-ID + SEB chains. +- Added a public synthetic three-Track Show Index/SEB corpus and a host Berry + lifecycle regression for filtering, pause/resume, retry, seek and 24-hour + wrap behavior. ### Impact -- Projects can keep Player policy in optional Berry while native SEB remains a - reusable atomic event primitive and EventStore stays authoritative. -- Contributors can reproduce pause, external-authority, seek, rewind, loop, - incomplete-generation fallback, and SPM-last publication behavior without - customer data. +- Creator Kit and Studio can point to one small copy-ready plugin. Projects keep + Player policy in optional Berry while native SEB remains a reusable atomic + Event-value primitive and EventStore stays authoritative. +- The supported operator flow is explicit: pause, upload, wait for ordinary + NetworkStorage propagation, rewind and play. There is no generation or hot + update protocol in v1. ## 20260702 diff --git a/README.md b/README.md index b7ac6b3..fe020e7 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ data/v2/examples/ ├── network-nested-element-groups/ # App elementsJson + controlPages group → groups → devices pattern ├── seb-causal-scene-recall/ # recall a mixed-ID SEB scene at the causal event time ├── seb-segmented-id-track-player/ # play an ordered one-ID Track from bounded SEB segments -└── player-bundle-ab-complete-cue-tracks/ # A/B SPM/SPT Player with complete per-ID Cues +└── player-show-complete-cue-tracks/ # static Show Index Player with complete per-ID Cues ``` ## Example Rules diff --git a/data/v2/examples/player-bundle-ab-complete-cue-tracks/README.md b/data/v2/examples/player-bundle-ab-complete-cue-tracks/README.md deleted file mode 100644 index 09ef74e..0000000 --- a/data/v2/examples/player-bundle-ab-complete-cue-tracks/README.md +++ /dev/null @@ -1,157 +0,0 @@ -# A/B Complete-Cue Track Player from NetworkStorage - -This copy-ready Project Berry example plays the final SPM/SPT v1 bundle format -on firmware **0.12.11**. The shared Spectoda timeline is the only transport: -play, pause, seek, rewind, and loop require no separate Player events. - -Each SPT is one Track for one Spectoda ID; the Track itself has no event label. -A Track defines stable typed tracked events, and every Cue supplies a complete -value for each of them. The plugin automatically includes a global ID255 Track -when the selected generation contains one, then adds only the local IDs -configured for this Controller. - -```berry -PlayerBundle({"namespace": "demo", "ids": [1], "debug": false}) -``` - -## Canonical source - -`player-bundle.be` in this directory is the single editable, copy-ready source -for Studio, Documentation, and the generated Spectoda Creator Kit. Keep this -public path stable. Creator Kit releases copy its exact bytes from a locked -Examples commit according to the fail-closed `creator-kit.json` selection; the -generated bundle is never a second authoring copy. - -Configure local IDs only in the range 0–254. Never put `255` in `ids`; global -ID255 is discovered automatically. A Controller which owns IDs 1 and 7 uses -`{"ids": [1, 7]}`. Another Controller may use `{"ids": [2]}` while all of the -same NetworkStorage files still synchronize to both Controllers. - -## Files and publication order - -For namespace `demo`, the manifest is `demo.spm`; Track files are named -`demo..<000-255>.spt`. `player-bundle-artifacts.json` contains a synthetic -two-generation show as exact binary hex: - -- slot A is the fallback generation; -- slot B is the preferred generation; -- both generations contain ID255 and ID1; -- the ID1 brightness Cue values change in generation B. - -Convert each `hex` value to exact bytes and upload it with the listed filename -and `version`. A real Studio deployment writes every changed inactive-slot SPT, -reads back its exact bytes/fingerprint, and writes the SPM **last**. Do not hand -edit revisions or fingerprints. - -Studio authors Tracks, tracked events, and complete Cues. **Capture** is the -native WASM operation which reads current EventStore values into a complete Cue; -this Berry plugin does not implement Capture. SEB exists here only as compiled -binary segments inside SPT files, not as an authoring concept. - -The plugin watches only the active manifest and relevant active Track files. -Inactive-slot uploads therefore do not interrupt a running show. Once SPM -changes, playback halts. The new preferred generation is selected only after -ID255 (when present) and every configured local SPT match their manifest ID, -artifact revision, exact size, and NetworkStorage fingerprint. An incomplete -preferred generation keeps a complete fallback. Reconciliation after an -artifact change deliberately waits for the shared timeline to be paused. - -## Runtime behavior - -The plugin range-reads the manifest, SPT headers/directories, and individual -SEB segments. It never loads a whole large SPT into one Berry `bytes()` value. -Every embedded SEB receives native fail-closed validation before a generation -can activate. Seek, rewind, and loop binary-search the last Cue at or before the -timeline target and land that complete Cue at the transport's causal local -millis. Forward playback keeps the exact `(SEB bytes, at, cursor)` tuple until -the segment changes. One local timeline-zero mapping is frozen per -epoch/discontinuity and refreshed when a paused timeline resumes. Equal Cue -times in separate Track files therefore receive the same causal timestamp even -when those Tracks execute on different Plugin turns, while time spent paused is -still reflected in later Cue clocks. - -At most one `SEB.land` execution occurs per Plugin turn. A complete Cue can -therefore use the full firmware 0.12.11 limit of **340 event values** without -competing with another Track in the same transient queue. A Cue is never split. -Future records remain outside EventStore until their timeline offsets are due. - -## Requirements and limits - -- firmware exactly 0.12.11 with final SEB v1 and SPM/SPT v1 support; -- every enabled Track starts with a complete Cue at timeline `0 ms`; -- one SEB segment is at most 4,092 bytes, 340 records, and 65,535 ms; -- one SPT is at most 65,535 bytes, but Studio's measured - `maxTrackPayloadBytes`, bundle, metadata, and free-flash gates may be lower; -- NetworkStorage fingerprints are HMAC-SHA256 with UTF-8 key `fingerprint`; -- this distributed Player uses `SEB.land`; do not replace it with `SEB.emit` - on every Controller. - -Turn `debug` on only while commissioning. The example contains no network key, -MAC address, customer name, or other private installation data. - -## Isolated DEVKIT end-to-end runner - -`player-bundle-devkit-smoke.mts` uses the real NodeSerial connector and App -Controller API. It refuses all writes unless you pass an explicit serial port, -the exact monorepo checkout, and `--confirm-isolated-devkit`: - -```bash -cd /path/to/Spectoda-monorepo -SPECTODA_WASM_VERSION=DEBUG_UNIVERSAL_0.12.11_20260808 \ -node_modules/.bin/tsx \ - /path/to/examples/data/v2/examples/player-bundle-ab-complete-cue-tracks/player-bundle-devkit-smoke.mts \ - --monorepo=/path/to/Spectoda-monorepo \ - --path=/dev/cu.usbserial-XXXX \ - --confirm-isolated-devkit \ - --evidence=/tmp/player-bundle-smoke.json -``` - -Equivalent environment options are `SPECTODA_MONOREPO`, -`SPECTODA_SERIAL_PATH`, and `SPECTODA_WASM_VERSION`. Optional CLI settings are -`--baudrate`, `--scan-timeout`, `--poll-timeout`, `--namespace`, -`--skip-capacity-probe`, and `--evidence`. - -For a `DEBUG_DEV_*` version, copy that exact local build's `.js` and `.wasm` -files into `.webassembly/` below the runner's current directory. The runner -fails before any Controller write when either file is absent; this prevents the -JS loader from silently using an older universal WASM without the Player APIs. - -The runner derives fresh unsigned-48 revisions above every observed namespace -version. It first writes slot A SPTs with exact readback, then writes the A-only -SPM last. It removes copy-only comment lines and indentation, matching Studio's -Project preprocessing, uploads the Berry as `BERRY(...)` through `writeTngl`, -then resumes automatically in a fresh Node/WASM process after the expected -Controller serial restart. The resumed phase physically reads the TNGL bytecode -into a fresh WASM runtime and requires its fingerprint to equal the pre-restart -compiler fingerprint. It then checks global ID255 and local ID1 values plus -identical causal clocks across pause, external-authority, play, seek, rewind, -and a running-rewind loop. It then writes slot B -SPTs, proves that inactive files did not alter playback, writes the new SPM -last, verifies the preferred switch without mixed Cue state, and repeats the -transport checks. - -By default the same isolated Controller also gets a deterministic 60,000-byte -NetworkStorage transport/readback probe. The deploy gate accepts 60,000 and -rejects 60,001 before the write function can run. Use -`--skip-capacity-probe` when flash headroom has already been measured elsewhere. -After the playback checks, the runner retires the probe with a higher-version -zero-byte marker so that its payload does not remain on flash. -The runner prints and optionally saves revisions, exact sizes, fingerprints, -the final namespace listing, firmware version, serial port, monorepo head, and -all observed causal clocks. It leaves the example Project Berry and named -NetworkStorage files installed; restore the intended Project after testing. - -## Host Berry regression - -From the Examples repository root, run the deterministic lifecycle/failure-path -test with the Berry host interpreter built by the matching firmware checkout: - -```bash -/path/to/firmware/components/spectoda-scripting/berry/berry \ - tests/player-bundle-stub.be -``` - -The test reads this example's fixture corpus and covers preferred/fallback -selection, bounded range reads, transient metadata and timeline-projection failures, -pause/resume clocks, seek/loop reconstruction, and local-ID validation without -writing to a Controller. diff --git a/data/v2/examples/player-bundle-ab-complete-cue-tracks/example.yaml b/data/v2/examples/player-bundle-ab-complete-cue-tracks/example.yaml deleted file mode 100644 index b0805c8..0000000 --- a/data/v2/examples/player-bundle-ab-complete-cue-tracks/example.yaml +++ /dev/null @@ -1,44 +0,0 @@ -schemaVersion: example.v2 -slug: player-bundle-ab-complete-cue-tracks -title: A/B Complete-Cue Track Event Player -category: controller -summary: >- - Copy-ready firmware 0.12.11 Project Berry player for SPM/SPT v1 bundles. It - selects a complete preferred or fallback generation, automatically lands the - global ID255 Track plus configured local-ID Tracks, and follows the shared - timeline through play, pause, seek, rewind, and loop. Each Track defines - stable typed tracked events, and every Cue supplies all their values. -tags: - - berry - - event-player - - network-storage - - seb - - spm - - spt - - timeline - - ab-deployment -hardware: >- - Requires exactly firmware 0.12.11 with final SEB v1 and SPM/SPT v1 support. - Configure Project Berry with only this Controller's local Spectoda IDs 0-254; - the global ID255 Track is automatic. Upload the SPM/SPT bundle to network-wide - NetworkStorage. The player-bundle.be path is a stable public integration - contract used by Studio, Documentation, and Spectoda Creator Kit releases. -files: - - path: README.md - role: Setup, publication, validation, and runtime behavior - language: markdown - - path: creator-kit.json - role: Explicit fail-closed Spectoda Creator Kit export selection - language: json - - path: player-bundle.be - role: Copy-ready Project Berry SPM/SPT Player - language: berry - - path: player-bundle-artifacts.json - role: Synthetic two-generation A/B NetworkStorage artifact corpus - language: json - - path: player-bundle-devkit-smoke.mts - role: Explicit isolated-DEVKIT NodeSerial/App Controller end-to-end runner - language: typescript -related: - - seb-causal-scene-recall - - seb-segmented-id-track-player diff --git a/data/v2/examples/player-bundle-ab-complete-cue-tracks/player-bundle-artifacts.json b/data/v2/examples/player-bundle-ab-complete-cue-tracks/player-bundle-artifacts.json deleted file mode 100644 index f850ee6..0000000 --- a/data/v2/examples/player-bundle-ab-complete-cue-tracks/player-bundle-artifacts.json +++ /dev/null @@ -1,66 +0,0 @@ -{ - "schemaVersion": "spectoda.player-bundle-fixture.v1", - "namespace": "demo", - "description": "Synthetic two-generation show: slot B preferred, slot A fallback; Tracks ID255 and ID1.", - "stages": [ - { - "slot": "a", - "trackNames": ["demo.a.001.spt", "demo.a.255.spt"], - "manifest": { - "name": "demo.spm", - "version": 1000, - "size": 200, - "fingerprint": "6d82ddbbb766b941c8a424fc964488b62bcb9791e4e2e504809be785138ff8f9", - "hex": "53504d011000c80000ff302c00000000e803000000000200d007000070005800635568db92ed31734ea0e8423979ab0c3a30f64d05262a978a1788ddcd4f755c00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001008400e8030000000000003bf7f8ca29cb42c7b6b8edc40afa10ebe37ee9d007109668925b05d20337b41cff008400e803000000000000155f3c2aca3c104882a353f19e849a888a323d6629f9ab97adfb8f0ae7514d58" - } - }, - { - "slot": "b", - "trackNames": ["demo.b.001.spt", "demo.b.255.spt"], - "manifest": { - "name": "demo.spm", - "version": 2000, - "size": 288, - "fingerprint": "97804f73468e33f3250fb7a49df7c00fb6b1bd2b18a061674b53414b0bb74b7f", - "hex": "53504d01100020010100302c00000000d007000000000200d0070000700058008d224a5201b7fd05d5cd351a14001243bcebbb5d81dba1d6ea4b102e59b60b60e803000000000200d0070000c8005800635568db92ed31734ea0e8423979ab0c3a30f64d05262a978a1788ddcd4f755c01008400d007000000000000cf0b549819df1c708a33cb566299ef4703b8df0458a8285199d54053db33793fff008400d007000000000000221af38f45e0baeff1998e844b613014739b8d45c53701247a3ecec861c47a1601008400e8030000000000003bf7f8ca29cb42c7b6b8edc40afa10ebe37ee9d007109668925b05d20337b41cff008400e803000000000000155f3c2aca3c104882a353f19e849a888a323d6629f9ab97adfb8f0ae7514d58" - } - } - ], - "uploadOrder": [ - { - "name": "demo.a.001.spt", - "version": 1000, - "size": 132, - "fingerprint": "3bf7f8ca29cb42c7b6b8edc40afa10ebe37ee9d007109668925b05d20337b41c", - "hex": "5350540124000100e803000000000c0cd007000001000300010024004800540084000000000000000000000001000000e8030000000001000100e803d0070000000002000100d007000000000000030054003000534542010c000300d0070c000b688909c8914802000001000b703839c8914802e80301000b688909c8914802d0070100" - }, - { - "name": "demo.a.255.spt", - "version": 1000, - "size": 132, - "fingerprint": "155f3c2aca3c104882a353f19e849a888a323d6629f9ab97adfb8f0ae7514d58", - "hex": "535054012400ff00e803000000000c0cd007000001000300010024004800540084000000000000000000000001000000e8030000000001000100e803d0070000000002000100d007000000000000030054003000534542010c000300d0070c003f0055ffd2c33c030000ff003fff5500d2c33c03e803ff003f0055ffd2c33c03d007ff00" - }, - { - "name": "demo.b.001.spt", - "version": 2000, - "size": 132, - "fingerprint": "cf0b549819df1c708a33cb566299ef4703b8df0458a8285199d54053db33793f", - "hex": "5350540124000100d007000000000c0cd007000001000300010024004800540084000000000000000000000001000000e8030000000001000100e803d0070000000002000100d007000000000000030054003000534542010c000300d0070c000bd01213c8914802000001000b105e5fc8914802e80301000bd01213c8914802d0070100" - }, - { - "name": "demo.b.255.spt", - "version": 2000, - "size": 132, - "fingerprint": "221af38f45e0baeff1998e844b613014739b8d45c53701247a3ecec861c47a16", - "hex": "535054012400ff00d007000000000c0cd007000001000300010024004800540084000000000000000000000001000000e8030000000001000100e803d0070000000002000100d007000000000000030054003000534542010c000300d0070c003f0055ffd2c33c030000ff003fff5500d2c33c03e803ff003f0055ffd2c33c03d007ff00" - }, - { - "name": "demo.spm", - "version": 2000, - "size": 288, - "fingerprint": "97804f73468e33f3250fb7a49df7c00fb6b1bd2b18a061674b53414b0bb74b7f", - "hex": "53504d01100020010100302c00000000d007000000000200d0070000700058008d224a5201b7fd05d5cd351a14001243bcebbb5d81dba1d6ea4b102e59b60b60e803000000000200d0070000c8005800635568db92ed31734ea0e8423979ab0c3a30f64d05262a978a1788ddcd4f755c01008400d007000000000000cf0b549819df1c708a33cb566299ef4703b8df0458a8285199d54053db33793fff008400d007000000000000221af38f45e0baeff1998e844b613014739b8d45c53701247a3ecec861c47a1601008400e8030000000000003bf7f8ca29cb42c7b6b8edc40afa10ebe37ee9d007109668925b05d20337b41cff008400e803000000000000155f3c2aca3c104882a353f19e849a888a323d6629f9ab97adfb8f0ae7514d58" - } - ] -} diff --git a/data/v2/examples/player-bundle-ab-complete-cue-tracks/player-bundle-devkit-smoke.mts b/data/v2/examples/player-bundle-ab-complete-cue-tracks/player-bundle-devkit-smoke.mts deleted file mode 100644 index ff2e7a0..0000000 --- a/data/v2/examples/player-bundle-ab-complete-cue-tracks/player-bundle-devkit-smoke.mts +++ /dev/null @@ -1,1038 +0,0 @@ -import { execFile } from "node:child_process"; -import { createHmac } from "node:crypto"; -import { access, mkdir, readFile, writeFile } from "node:fs/promises"; -import { createRequire } from "node:module"; -import path from "node:path"; -import { fileURLToPath, pathToFileURL } from "node:url"; -import { promisify } from "node:util"; - -const execFileAsync = promisify(execFile); -const exampleDir = path.dirname(fileURLToPath(import.meta.url)); - -type Options = { - serialPath: string; - monorepo: string; - wasmVersion?: string; - namespace: string; - baudrate: number; - scanTimeout: number; - pollTimeout: number; - evidence?: string; - capacityProbe: boolean; - confirmed: boolean; - resumeAfterTngl: boolean; - expectedTnglFingerprint?: string; - resumeRevisionA?: number; - resumeRevisionB?: number; - observedNamespaceMax?: number; -}; - -type Artifact = { - name: string; - version: number; - bytes: Uint8Array; - fingerprint: string; -}; - -type ObservedEvent = { - label: string; - id: number; - type: number; - value: unknown; - timestamp: number; -}; - -type ObservedCue = { - global: ObservedEvent; - local: ObservedEvent; -}; - -const parseOptions = (): Options => { - const values = new Map(); - const flags = new Set(); - for (const argument of process.argv.slice(2)) { - if (!argument.startsWith("--")) continue; - const equals = argument.indexOf("="); - if (equals < 0) flags.add(argument.slice(2)); - else values.set(argument.slice(2, equals), argument.slice(equals + 1)); - } - - const serialPath = - values.get("path") ?? process.env.SPECTODA_SERIAL_PATH ?? ""; - const monorepo = path.resolve( - values.get("monorepo") ?? process.env.SPECTODA_MONOREPO ?? "", - ); - return { - serialPath, - monorepo, - wasmVersion: - values.get("wasm-version") ?? process.env.SPECTODA_WASM_VERSION, - namespace: values.get("namespace") ?? "demo", - baudrate: Number.parseInt(values.get("baudrate") ?? "1500000", 10), - scanTimeout: Number.parseInt(values.get("scan-timeout") ?? "10000", 10), - pollTimeout: Number.parseInt(values.get("poll-timeout") ?? "30000", 10), - evidence: values.get("evidence"), - capacityProbe: !flags.has("skip-capacity-probe"), - confirmed: flags.has("confirm-isolated-devkit"), - resumeAfterTngl: flags.has("resume-after-tngl"), - expectedTnglFingerprint: values - .get("expected-tngl-fingerprint") - ?.toLowerCase(), - resumeRevisionA: values.has("resume-revision-a") - ? Number.parseInt(values.get("resume-revision-a")!, 10) - : undefined, - resumeRevisionB: values.has("resume-revision-b") - ? Number.parseInt(values.get("resume-revision-b")!, 10) - : undefined, - observedNamespaceMax: values.has("observed-namespace-max") - ? Number.parseInt(values.get("observed-namespace-max")!, 10) - : undefined, - }; -}; - -const assertOptions = (options: Options): void => { - if (!options.confirmed) { - throw new Error( - "Refusing writes without --confirm-isolated-devkit. Never run this against a customer Controller.", - ); - } - if (!options.serialPath) { - throw new Error("Pass an explicit --path or SPECTODA_SERIAL_PATH"); - } - if ( - !process.env.SPECTODA_MONOREPO && - !process.argv.some((v) => v.startsWith("--monorepo=")) - ) { - throw new Error( - "Pass --monorepo or SPECTODA_MONOREPO for the exact DEV-6449 checkout", - ); - } - if (!/^[A-Za-z0-9_-]{1,14}$/.test(options.namespace)) { - throw new Error( - "Namespace must use 1-14 ASCII letters, digits, underscore or hyphen", - ); - } - if ( - options.expectedTnglFingerprint !== undefined && - !/^[0-9a-f]{64}$/.test(options.expectedTnglFingerprint) - ) { - throw new Error("--expected-tngl-fingerprint must be 32 bytes of hex"); - } - for (const [name, value] of [ - ["baudrate", options.baudrate], - ["scan-timeout", options.scanTimeout], - ["poll-timeout", options.pollTimeout], - ] as const) { - if (!Number.isSafeInteger(value) || value <= 0) { - throw new Error(`Invalid ${name}: ${value}`); - } - } - const resumeValues = [ - options.resumeRevisionA, - options.resumeRevisionB, - options.observedNamespaceMax, - ]; - if ( - options.resumeAfterTngl && - resumeValues.some( - (value) => - value === undefined || !Number.isSafeInteger(value) || value < 0, - ) - ) { - throw new Error( - "The fresh readback process requires valid resume revisions and observed namespace max", - ); - } -}; - -const bytesEqual = (left: Uint8Array, right: Uint8Array): boolean => - left.length === right.length && - left.every((value, index) => value === right[index]); - -const fingerprint = (bytes: Uint8Array): string => - createHmac("sha256", "fingerprint").update(bytes).digest("hex"); - -const normalizeFingerprint = (value: unknown): string => - String(value).toLowerCase(); - -const assertTrackPayloadBytes = (size: number): void => { - if (!Number.isSafeInteger(size) || size < 0 || size > 60_000) { - throw new Error(`maxTrackPayloadBytes exceeded: ${size} > 60000`); - } -}; - -const normalizeColor = (value: unknown): string => - String(value).replace(/^#/, "").toLowerCase(); - -// Studio removes comments and indentation before embedding a Project Berry -// source in TNGL. Mirror that safe subset so the physical smoke measures the -// deployable payload, not the copy-ready documentation comments. -const prepareBerryForTngl = (source: string): string => - source - .split("\n") - .map((line) => line.trim()) - .filter((line) => line.length > 0 && !line.startsWith("#")) - .join("\n"); - -const main = async (): Promise => { - const options = parseOptions(); - assertOptions(options); - if (options.wasmVersion) { - if (options.wasmVersion.startsWith("DEBUG_DEV_")) { - const localWasm = path.join( - process.cwd(), - ".webassembly", - options.wasmVersion, - ); - try { - await Promise.all([ - access(`${localWasm}.js`), - access(`${localWasm}.wasm`), - ]); - } catch { - throw new Error( - `Exact local ${options.wasmVersion}.{js,wasm} is required in ${path.dirname(localWasm)}; refusing the library's older universal-WASM fallback`, - ); - } - } - (globalThis as Record).__SPECTODA_WASM_VERSION_OVERRIDE__ = - options.wasmVersion; - } - - const jsModule = await import( - pathToFileURL(path.join(options.monorepo, "packages/js/v012/index.ts")).href - ); - const functionsModule = await import( - pathToFileURL(path.join(options.monorepo, "packages/js/v012/functions.ts")) - .href - ); - const errorModule = await import( - pathToFileURL(path.join(options.monorepo, "packages/error/index.ts")).href - ); - const codec = await import( - pathToFileURL( - path.join(options.monorepo, "packages/js/v012/src/player-bundle.ts"), - ).href - ); - const api = (jsModule.default ?? jsModule) as Record; - const functions = (functionsModule.default ?? functionsModule) as Record< - string, - any - >; - const errors = (errorModule.default ?? errorModule) as Record; - const { CONNECTORS, Spectoda, SPECTODA_APP_EVENTS } = api; - const { sleep } = functions; - const { isError } = errors; - - const requireFromMonorepo = createRequire( - path.join(options.monorepo, "package.json"), - ); - const { SerialPort } = requireFromMonorepo("serialport") as { - SerialPort: { list(): Promise> }; - }; - const availablePorts = (await SerialPort.list()) - .map((entry) => entry.path) - .filter((entry): entry is string => typeof entry === "string"); - if (!availablePorts.includes(options.serialPath)) { - throw new Error( - `Explicit serial path is not present. Requested=${options.serialPath} available=${availablePorts.join(",")}`, - ); - } - - const fixture = JSON.parse( - await readFile( - path.join(exampleDir, "player-bundle-artifacts.json"), - "utf8", - ), - ) as { - uploadOrder: Array<{ - name: string; - hex: string; - }>; - }; - const source = await readFile( - path.join(exampleDir, "player-bundle.be"), - "utf8", - ); - const baseByName = new Map( - fixture.uploadOrder - .filter((entry) => entry.name.endsWith(".spt")) - .map((entry) => [ - entry.name, - Uint8Array.from(Buffer.from(entry.hex, "hex")), - ]), - ); - const staticManifestEntry = fixture.uploadOrder.find( - (entry) => entry.name === "demo.spm", - ); - if (!staticManifestEntry) throw new Error("Fixture has no final SPM"); - const staticManifest = codec.decodePlayerManifest( - Uint8Array.from(Buffer.from(staticManifestEntry.hex, "hex")), - "demo", - ); - if (!staticManifest.fallback) - throw new Error("Fixture has no fallback generation"); - - const spectoda = new Spectoda(CONNECTORS.NONE, false); - const observed: ObservedEvent[] = []; - const evidence: Record = { - schemaVersion: "spectoda.player-bundle-devkit-smoke.v1", - startedAt: new Date().toISOString(), - serialPath: options.serialPath, - baudrate: options.baudrate, - namespace: options.namespace, - wasmVersion: options.wasmVersion ?? null, - capacity: {}, - writes: [], - generations: [], - }; - - const onEvents = (events: ObservedEvent[]): void => { - observed.push(...events); - }; - let unsubscribeStateUpdates = (): void => undefined; - let unsubscribeEmittedEvents = (): void => undefined; - let physicalTnglReadback: Record | undefined; - - const listStorage = async (): Promise< - Array<{ name: string; version: number; fingerprint: string }> - > => { - const result = await spectoda.listNetworkStorageData(); - if (isError(result) || !Array.isArray(result)) { - throw new Error( - `NetworkStorage listing failed: ${JSON.stringify(result)}`, - ); - } - return result; - }; - - const verifyExactReadback = async (artifact: Artifact): Promise => { - const deadline = Date.now() + options.pollTimeout; - let last: unknown; - while (Date.now() < deadline) { - const readback = await spectoda.getNetworkStorageData(artifact.name); - const listing = await listStorage(); - const metadata = listing.find((entry) => entry.name === artifact.name); - last = { readback, metadata }; - if ( - !isError(readback) && - readback.version === artifact.version && - bytesEqual(readback.bytes, artifact.bytes) && - metadata?.version === artifact.version && - metadata.fingerprint.toLowerCase() === artifact.fingerprint - ) { - console.log( - `[player-bundle-smoke] exact readback ${artifact.name} version=${artifact.version} size=${artifact.bytes.length} fingerprint=${artifact.fingerprint}`, - ); - return; - } - await sleep(250); - } - throw new Error( - `Exact readback timed out for ${artifact.name}: ${JSON.stringify(last)}`, - ); - }; - - const writeAndVerify = async (artifact: Artifact): Promise => { - const result = await spectoda.emitNetworkStorageData({ - name: artifact.name, - version: artifact.version, - bytes: artifact.bytes, - }); - if (isError(result)) { - throw new Error( - `NetworkStorage write failed for ${artifact.name}: ${result.id}`, - ); - } - await verifyExactReadback(artifact); - (evidence.writes as unknown[]).push({ - name: artifact.name, - version: artifact.version, - size: artifact.bytes.length, - fingerprint: artifact.fingerprint, - }); - }; - - const waitEvent = async ( - start: number, - label: string, - id: number, - expected: unknown, - afterTimestamp = -1, - timeout = options.pollTimeout, - ): Promise => { - const deadline = Date.now() + timeout; - while (Date.now() < deadline) { - const match = observed.slice(start).find((event) => { - if ( - event.label !== label || - event.id !== id || - event.timestamp <= afterTimestamp - ) - return false; - return label === "color" - ? normalizeColor(event.value) === normalizeColor(expected) - : event.value === expected; - }); - if (match) return match; - await sleep(250); - } - throw new Error( - `Timed out waiting for $${label}[ID${id}]=${String(expected)}; new events=${JSON.stringify(observed.slice(start))}`, - ); - }; - - const waitCue = async ( - start: number, - brightness: number, - color: string, - afterTimestamp = -1, - timeout = options.pollTimeout, - ): Promise => { - const deadline = Date.now() + timeout; - while (Date.now() < deadline) { - const window = observed - .slice(start) - .filter((event) => event.timestamp > afterTimestamp); - for (const global of window) { - if ( - global.label !== "color" || - global.id !== 255 || - normalizeColor(global.value) !== normalizeColor(color) - ) { - continue; - } - const local = window.find( - (event) => - event.label === "brigh" && - event.id === 1 && - event.value === brightness && - event.timestamp === global.timestamp, - ); - if (local) return { global, local }; - } - await sleep(250); - } - throw new Error( - `Timed out waiting for one causal Cue color[ID255]=${color} + brigh[ID1]=${brightness}; new events=${JSON.stringify(observed.slice(start))}`, - ); - }; - - const cueEvidence = ( - timelineMillis: number, - brightness: number, - color: string, - cue: ObservedCue, - ): Record => ({ - timelineMillis, - expected: { - global: { label: "color", id: 255, value: normalizeColor(color) }, - local: { label: "brigh", id: 1, value: brightness }, - causalClockEqual: true, - }, - observed: { - global: { - label: cue.global.label, - id: cue.global.id, - value: normalizeColor(cue.global.value), - clock: cue.global.timestamp, - }, - local: { - label: cue.local.label, - id: cue.local.id, - value: cue.local.value, - clock: cue.local.timestamp, - }, - causalClockEqual: cue.global.timestamp === cue.local.timestamp, - }, - }); - - const assertNoValues = async ( - start: number, - duration: number, - forbiddenBrightness: number, - forbiddenColor: string, - ): Promise> => { - await sleep(duration); - const window = observed.slice(start); - const forbiddenEvents = window.filter( - (event) => - (event.label === "brigh" && - event.id === 1 && - event.value === forbiddenBrightness) || - (event.label === "color" && - event.id === 255 && - normalizeColor(event.value) === normalizeColor(forbiddenColor)), - ); - if (forbiddenEvents.length > 0) { - throw new Error( - `Paused timeline landed a future value: ${JSON.stringify(forbiddenEvents[0])}`, - ); - } - return { - durationMs: duration, - expectedAbsent: { - global: { - label: "color", - id: 255, - value: normalizeColor(forbiddenColor), - }, - local: { label: "brigh", id: 1, value: forbiddenBrightness }, - }, - observedForbiddenEvents: forbiddenEvents, - pass: true, - }; - }; - - const exerciseTransport = async ( - generationName: string, - values: { at0: number; at1000: number; at2000: number }, - publishedCue?: ObservedCue, - ): Promise> => { - const generationEvidence: Record = { - generation: generationName, - steps: {}, - }; - const steps = generationEvidence.steps as Record; - - let start = observed.length; - let initial = publishedCue; - if (!initial) { - await spectoda.rewindTimeline(true); - initial = await waitCue(start, values.at0, "#ff5500"); - } - steps.initial = cueEvidence(0, values.at0, "#ff5500", initial); - const pausedStart = observed.length; - steps.initialPausedGate = await assertNoValues( - pausedStart, - 300, - values.at1000, - "#0055ff", - ); - - const externalStart = observed.length; - await spectoda.emitPercentage("brigh", 33, 1); - const externalLocal = await waitEvent( - externalStart, - "brigh", - 1, - 33, - initial.local.timestamp, - ); - const externalPausedStart = observed.length; - const externalPausedGate = await assertNoValues( - externalPausedStart, - 300, - values.at1000, - "#0055ff", - ); - steps.externalAuthorityWhilePaused = { - expected: { - timelinePaused: true, - global: { label: "color", id: 255, value: "#ff5500" }, - local: { label: "brigh", id: 1, value: 33 }, - playerDoesNotReassertUntilNextDueCue: true, - }, - observed: { - global: { - label: initial.global.label, - id: initial.global.id, - value: normalizeColor(initial.global.value), - clock: initial.global.timestamp, - }, - local: { - label: externalLocal.label, - id: externalLocal.id, - value: externalLocal.value, - clock: externalLocal.timestamp, - }, - localClockAdvanced: externalLocal.timestamp > initial.local.timestamp, - pausedGate: externalPausedGate, - }, - }; - - start = observed.length; - await spectoda.unpauseTimeline(); - const forward1000 = await waitCue( - start, - values.at1000, - "#0055ff", - initial.global.timestamp, - ); - steps.forward1000 = cueEvidence( - 1000, - values.at1000, - "#0055ff", - forward1000, - ); - - await spectoda.pauseTimeline(); - start = observed.length; - let seek: ObservedCue | undefined; - for (let attempt = 0; attempt < 3 && !seek; attempt += 1) { - await spectoda.setTimelineMillis(1000); - try { - seek = await waitCue( - start, - values.at1000, - "#0055ff", - forward1000.global.timestamp, - 2_000, - ); - } catch (error) { - if (attempt === 2) throw error; - await sleep(100); - } - } - if (!seek) throw new Error("Timeline seek produced no complete Cue"); - steps.seek1000 = cueEvidence(1000, values.at1000, "#0055ff", seek); - const seekPausedStart = observed.length; - steps.seekPausedGate = await assertNoValues( - seekPausedStart, - 500, - values.at2000, - "#ff5500", - ); - - start = observed.length; - await spectoda.unpauseTimeline(); - const forward2000 = await waitCue( - start, - values.at2000, - "#ff5500", - seek.global.timestamp, - ); - steps.forward2000 = cueEvidence( - 2000, - values.at2000, - "#ff5500", - forward2000, - ); - - // A running rewind represents the loop discontinuity. The Player must - // reconstruct t=0, then continue to the next timed Cue. - start = observed.length; - await spectoda.rewindTimeline(false); - const loop0 = await waitCue( - start, - values.at0, - "#ff5500", - forward2000.global.timestamp, - ); - const loop1000 = await waitCue( - start, - values.at1000, - "#0055ff", - loop0.global.timestamp, - ); - steps.loop0 = cueEvidence(0, values.at0, "#ff5500", loop0); - steps.loop1000 = cueEvidence(1000, values.at1000, "#0055ff", loop1000); - - start = observed.length; - await spectoda.rewindTimeline(true); - const finalPaused = await waitCue( - start, - values.at0, - "#ff5500", - loop1000.global.timestamp, - ); - steps.finalPaused0 = cueEvidence( - 0, - values.at0, - "#ff5500", - finalPaused, - ); - return generationEvidence; - }; - - try { - // SpectodaWasm persists its preview filesystem below the process cwd. - await mkdir(path.resolve(process.cwd(), "filesystem"), { - recursive: true, - }); - await spectoda.connect( - "serial", - { path: options.serialPath, baudrate: options.baudrate }, - { autoSelect: true, scanTimeout: options.scanTimeout }, - ); - unsubscribeStateUpdates = spectoda.on( - SPECTODA_APP_EVENTS.EVENT_STATE_UPDATES, - onEvents, - ); - unsubscribeEmittedEvents = spectoda.on( - SPECTODA_APP_EVENTS.EMITTED_EVENTS, - onEvents, - ); - const controllerInfo = await spectoda.readControllerInfo(); - evidence.controller = { - productCode: controllerInfo.productCode, - fwVersion: controllerInfo.fwVersion, - fwVersionFull: controllerInfo.fwVersionFull, - fwVersionCode: controllerInfo.fwVersionCode, - }; - if (options.resumeAfterTngl) { - const expected = options.expectedTnglFingerprint; - if (!expected) { - throw new Error( - "The fresh readback process requires --expected-tngl-fingerprint", - ); - } - - // syncTngl() performs a physical READ_TNGL_BYTECODE request, then loads - // the returned bytes into this fresh WASM runtime. Comparing its hash to - // the pre-restart compiler hash proves exact bytecode readback even on - // banked firmware whose readControllerInfo() fingerprint is all zeroes. - await spectoda.syncTngl(); - const observedFingerprint = normalizeFingerprint( - spectoda.runtime.spectoda_js.getTnglFingerprint(), - ); - if (observedFingerprint !== expected) { - throw new Error( - `Physical TNGL readback differs: expected=${expected} observed=${observedFingerprint}`, - ); - } - physicalTnglReadback = { - method: "syncTngl physical bytecode readback into a fresh WASM runtime", - expectedBytecodeFingerprint: expected, - observedBytecodeFingerprint: observedFingerprint, - controllerInfoFingerprint: controllerInfo.tnglFingerprint, - exactMatch: true, - resumedAfterSerialRestart: true, - }; - } - const { stdout: monorepoHead } = await execFileAsync( - "git", - ["-C", options.monorepo, "rev-parse", "HEAD"], - { encoding: "utf8" }, - ); - evidence.monorepoHead = monorepoHead.trim(); - console.log( - `[player-bundle-smoke] connected path=${options.serialPath} firmware=${controllerInfo.fwVersionFull} monorepo=${monorepoHead.trim()}`, - ); - - const listingBefore = await listStorage(); - const namespacePrefix = `${options.namespace}.`; - const observedMax = options.resumeAfterTngl - ? options.observedNamespaceMax! - : listingBefore - .filter( - (entry) => - entry.name === `${options.namespace}.spm` || - entry.name.startsWith(namespacePrefix), - ) - .reduce((maximum, entry) => Math.max(maximum, entry.version), 0); - const revisionA = options.resumeAfterTngl - ? options.resumeRevisionA! - : Math.max(Date.now(), observedMax + 1); - const revisionB = options.resumeAfterTngl - ? options.resumeRevisionB! - : revisionA + 1; - codec.assertPlayerRevision(revisionA); - codec.assertPlayerRevision(revisionB); - evidence.revisions = { observedMax, revisionA, revisionB }; - - const makeGeneration = async ( - slot: "a" | "b", - revision: number, - source: any, - ): Promise<{ generation: any; tracks: Artifact[] }> => { - const tracks: Artifact[] = []; - const descriptors: any[] = []; - for (const descriptor of source.tracks) { - const baseName = `demo.${slot}.${String(descriptor.id).padStart(3, "0")}.spt`; - const base = baseByName.get(baseName); - if (!base) throw new Error(`Fixture is missing ${baseName}`); - const bytes = codec.stampPlayerTrackRevision(base, revision); - assertTrackPayloadBytes(bytes.length); - const artifact: Artifact = { - name: codec.playerTrackFilename( - options.namespace, - slot, - descriptor.id, - ), - version: revision, - bytes, - fingerprint: fingerprint(bytes), - }; - tracks.push(artifact); - descriptors.push({ - id: descriptor.id, - artifactRevision: revision, - size: bytes.length, - fingerprint: artifact.fingerprint, - }); - } - return { - tracks, - generation: { - slot, - showRevision: revision, - durationMillis: source.durationMillis, - contentFingerprint: source.contentFingerprint, - tracks: descriptors, - }, - }; - }; - - const stageA = await makeGeneration( - "a", - revisionA, - staticManifest.fallback, - ); - const stageB = await makeGeneration( - "b", - revisionB, - staticManifest.preferred, - ); - const manifestABytes = codec.encodePlayerManifest({ - namespace: options.namespace, - preferred: stageA.generation, - fallback: null, - }); - const manifestBBytes = codec.encodePlayerManifest({ - namespace: options.namespace, - preferred: stageB.generation, - fallback: stageA.generation, - }); - const manifestA: Artifact = { - name: codec.playerManifestFilename(options.namespace), - version: revisionA, - bytes: manifestABytes, - fingerprint: fingerprint(manifestABytes), - }; - const manifestB: Artifact = { - name: codec.playerManifestFilename(options.namespace), - version: revisionB, - bytes: manifestBBytes, - fingerprint: fingerprint(manifestBBytes), - }; - - assertTrackPayloadBytes(60_000); - let overLimitWriteAttempted = false; - try { - assertTrackPayloadBytes(60_001); - overLimitWriteAttempted = true; - } catch { - // Expected pre-write rejection. - } - if (overLimitWriteAttempted) { - throw new Error("60,001-byte payload reached the write boundary"); - } - (evidence.capacity as Record).preWriteGate = { - accepted: 60_000, - rejectedBeforeWrite: 60_001, - }; - let capacityProbe: Artifact | undefined; - if (options.capacityProbe) { - const probeBytes = Uint8Array.from( - { length: 60_000 }, - (_, index) => index & 0xff, - ); - capacityProbe = { - name: `${options.namespace}.cap.bin`, - version: revisionA, - bytes: probeBytes, - fingerprint: fingerprint(probeBytes), - }; - if (options.resumeAfterTngl) { - await verifyExactReadback(capacityProbe); - } else { - await writeAndVerify(capacityProbe); - } - (evidence.capacity as Record).exactReadback = { - name: capacityProbe.name, - size: capacityProbe.bytes.length, - version: capacityProbe.version, - fingerprint: capacityProbe.fingerprint, - writtenBeforeTnglRestart: true, - verifiedAfterRestart: options.resumeAfterTngl, - }; - } - - // A/B contract: all inactive-slot SPT writes and exact readbacks precede - // the manifest write. SPM is always the final publication operation. - if (options.resumeAfterTngl) { - for (const artifact of stageA.tracks) await verifyExactReadback(artifact); - await verifyExactReadback(manifestA); - evidence.preRestartPublication = { - revision: revisionA, - artifacts: [...stageA.tracks, manifestA].map((artifact) => ({ - name: artifact.name, - version: artifact.version, - size: artifact.bytes.length, - fingerprint: artifact.fingerprint, - })), - spmLast: true, - exactReadbackAfterRestart: true, - }; - } else { - for (const artifact of stageA.tracks) await writeAndVerify(artifact); - await writeAndVerify(manifestA); - } - - const uploadSource = prepareBerryForTngl(source); - const configuredSource = uploadSource.replace( - 'PlayerBundle({"namespace": "demo", "ids": [1], "debug": false})', - `PlayerBundle({"namespace":"${options.namespace}","ids":[1],"debug":true})`, - ); - if (configuredSource === uploadSource) { - throw new Error( - "Could not replace the example PlayerBundle configuration", - ); - } - const tngl = `BERRY(\`${configuredSource.replaceAll("`", "\\`")}\`);`; - if (!options.resumeAfterTngl) { - await spectoda.writeTngl(tngl, null, 0); - const expectedTnglFingerprint = normalizeFingerprint( - spectoda.runtime.spectoda_js.getTnglFingerprint(), - ); - if (!/^[0-9a-f]{64}$/.test(expectedTnglFingerprint)) { - throw new Error( - `Compiler returned an invalid TNGL fingerprint: ${expectedTnglFingerprint}`, - ); - } - // Big TNGL writes restart the ESP after the JS execute promise resolves. - // Let flash finalization and bank activation settle before any readback - // process reconnects; early connects can legitimately report no bank. - await sleep(15_000); - await spectoda.disconnect().catch(() => undefined); - await spectoda.runtime.destroyConnector().catch(() => undefined); - - // A large Project TNGL restarts the physical Controller and intentionally - // drops NodeSerial. Continue in a fresh process/WASM runtime so all - // playback assertions are made after a real serial reconnect. - const childArguments = process.argv - .slice(2) - .filter( - (argument) => - argument !== "--resume-after-tngl" && - !argument.startsWith("--expected-tngl-fingerprint="), - ); - childArguments.push("--resume-after-tngl"); - childArguments.push( - `--expected-tngl-fingerprint=${expectedTnglFingerprint}`, - ); - childArguments.push(`--resume-revision-a=${revisionA}`); - childArguments.push(`--resume-revision-b=${revisionB}`); - childArguments.push(`--observed-namespace-max=${observedMax}`); - let child: { stdout: string; stderr: string } | undefined; - let lastChildError: unknown; - for (let attempt = 1; attempt <= 3; attempt += 1) { - try { - child = await execFileAsync( - path.join(options.monorepo, "node_modules/.bin/tsx"), - [fileURLToPath(import.meta.url), ...childArguments], - { - cwd: process.cwd(), - encoding: "utf8", - env: process.env, - maxBuffer: 10 * 1024 * 1024, - }, - ); - break; - } catch (error) { - lastChildError = error; - if (attempt === 3) throw error; - console.warn( - `[player-bundle-smoke] fresh TNGL readback process attempt ${attempt} failed; retrying after restart settles`, - ); - await sleep(5_000); - } - } - if (!child) throw lastChildError; - process.stdout.write(child.stdout); - process.stderr.write(child.stderr); - return; - } - - (evidence.controller as Record).postTnglReadback = { - ...physicalTnglReadback, - fwVersionFull: controllerInfo.fwVersionFull, - berrySourceBytes: Buffer.byteLength(configuredSource, "utf8"), - }; - (evidence.generations as unknown[]).push( - await exerciseTransport("slot-a", { at0: 10, at1000: 60, at2000: 10 }), - ); - - const inactiveWriteStart = observed.length; - for (const artifact of stageB.tracks) await writeAndVerify(artifact); - const premature = observed - .slice(inactiveWriteStart) - .find( - (event) => - event.label === "brigh" && event.id === 1 && event.value === 20, - ); - if (premature) { - throw new Error( - `Inactive-slot SPT changed playback before SPM: ${JSON.stringify(premature)}`, - ); - } - const publicationStart = observed.length; - await writeAndVerify(manifestB); - const switched = await waitCue(publicationStart, 20, "#ff5500"); - console.log( - `[player-bundle-smoke] A/B switch PASS slot=b causalClock=${switched.global.timestamp}`, - ); - (evidence.generations as unknown[]).push( - await exerciseTransport( - "slot-b", - { at0: 20, at1000: 100, at2000: 20 }, - switched, - ), - ); - - if (options.capacityProbe) { - const retiredProbe: Artifact = { - name: `${options.namespace}.cap.bin`, - version: revisionB + 1, - bytes: new Uint8Array(), - fingerprint: fingerprint(new Uint8Array()), - }; - codec.assertPlayerRevision(retiredProbe.version, "capacity cleanup"); - await writeAndVerify(retiredProbe); - (evidence.capacity as Record).cleanup = { - name: retiredProbe.name, - version: retiredProbe.version, - size: 0, - fingerprint: retiredProbe.fingerprint, - }; - } - - const finalMetadata = (await listStorage()).filter( - (entry) => - entry.name === `${options.namespace}.spm` || - entry.name.startsWith(namespacePrefix), - ); - const finalListing = []; - for (const entry of finalMetadata) { - const readback = await spectoda.getNetworkStorageData(entry.name); - finalListing.push({ - ...entry, - size: isError(readback) ? null : readback.bytes.length, - }); - } - evidence.finalListing = finalListing; - evidence.completedAt = new Date().toISOString(); - evidence.result = "PASS"; - console.log( - `[player-bundle-smoke] PASS ${JSON.stringify(evidence, null, 2)}`, - ); - if (options.evidence) { - await writeFile( - path.resolve(options.evidence), - `${JSON.stringify(evidence, null, 2)}\n`, - ); - } - } finally { - unsubscribeStateUpdates(); - unsubscribeEmittedEvents(); - await spectoda.disconnect().catch(() => undefined); - } -}; - -main() - .then(() => process.exit(0)) - .catch((error) => { - console.error("[player-bundle-smoke] FAIL", error); - process.exit(1); - }); diff --git a/data/v2/examples/player-bundle-ab-complete-cue-tracks/player-bundle.be b/data/v2/examples/player-bundle-ab-complete-cue-tracks/player-bundle.be deleted file mode 100644 index eecce25..0000000 --- a/data/v2/examples/player-bundle-ab-complete-cue-tracks/player-bundle.be +++ /dev/null @@ -1,774 +0,0 @@ -# Project Berry Player for firmware 0.12.11 and SPM/SPT v1. -# -# Public API: -# PlayerBundle({"namespace": "demo", "ids": [1], "debug": false}) -# -# ID255 is discovered automatically when it exists in a generation. Configure -# only Controller-local IDs 0..254. NetworkStorage remains a global union: this -# Controller validates and opens only the global Track and its configured local -# Tracks. -def PlayerBundle(S) - import string - - var ns = S.find("namespace", "player") - var ids = S.find("ids", []) - var dbg = S.find("debug", false) - var integrity_polls = S.find("integrityPolls", 25) - var retry_polls = S.find("retryPolls", 100) - - # The namespace is also part of every NetworkStorage filename. Keeping the - # validation here makes an invalid Project fail before registering Plugin. - if type(ns) != "string" || size(ns) < 1 || size(ns) > 14 - raise "value_error", "Player namespace must contain 1-14 ASCII characters" - end - var allowed = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_-" - for i : 0..size(ns) - 1 - if string.find(allowed, ns[i]) < 0 - raise "value_error", "Player namespace contains an invalid character" - end - end - var manifest_file = ns + ".spm" - if type(ids) != "instance" || classname(ids) != "list" || - type(integrity_polls) != "int" || type(retry_polls) != "int" || - integrity_polls < 1 || retry_polls < 1 - raise "value_error", "Player ids/poll configuration is invalid" - end - - # local_ids is sorted once so activation and playback stay deterministic. - var local_ids = [] - for id : ids - if type(id) != "int" || id < 0 || id > 254 - raise "value_error", "Player local IDs must be integers in 0..254" - end - var at = 0 - while at < local_ids.size() && local_ids[at] < id - at += 1 - end - if at < local_ids.size() && local_ids[at] == id - raise "value_error", "Player local IDs must be unique" - end - local_ids.insert(at, id) - end - - # Mutable state lives in one map because Berry closures capture scalar - # upvalues by value. active is kept when a newer upload is incomplete. - var R = { - "active": nil, - "armed": false, - "storage_dirty": false, - "reconcile": nil, - "last_time": 0, - "last_epoch": 0, - "last_paused": true, - "timeline_zero": nil, - "poll": 0, - "round": 0, - "error": nil - } - - def log_error(message) - if message != R["error"] - R["error"] = message - if dbg - print("PlayerBundle", message) - end - end - end - - def track_name(slot, id) - var digits = str(id) - if id < 10 - digits = "00" + digits - elif id < 100 - digits = "0" + digits - end - return ns + "." + slot + "." + digits + ".spt" - end - - # getNetworkStorageData(name, offset, length) is bounded to 8192 bytes. - # Every read in this player is a header, directory entry, or <=4092B SEB. - def read_exact(name, offset, length) - var b = spectoda.getNetworkStorageData(name, offset, length) - if b == nil || b.size() != length - return nil - end - return b - end - - def exact_size(name, size) - var tail = spectoda.getNetworkStorageData(name, size, 1) - return tail != nil && tail.size() == 0 - end - - def bytes_zero(b, first, last) - for i : first..last - if b.get(i) != 0 - return false - end - end - return true - end - - def bytes_equal(a, ao, b, bo, count) - for i : 0..count - 1 - if a.get(ao + i) != b.get(bo + i) - return false - end - end - return true - end - - def fingerprint_hex(b, offset) - var h = "0123456789abcdef" - var result = "" - for i : 0..31 - var v = b.get(offset + i) - result += h[(v >> 4) & 15] + h[v & 15] - end - return result - end - - def metadata() - var result = {} - var entries = spectoda.listNetworkStorageData() - if entries == nil - return nil - end - for entry : entries - var name = entry.find("name", nil) - var fingerprint = entry.find("fingerprint", nil) - if name != nil && fingerprint != nil - result[name] = fingerprint - end - end - return result - end - - def generation(header, descriptor_offset, slot, expected_table) - var count = header.get(descriptor_offset + 6, 2) - var duration = header.get(descriptor_offset + 8, 4) - var table = header.get(descriptor_offset + 12, 2) - var table_size = header.get(descriptor_offset + 14, 2) - if duration < 0 || table != expected_table || table_size != count * 44 - return nil - end - return { - "slot": slot, - "duration": duration, - "count": count, - "table": table, - "table_size": table_size, - "tracks": {} - } - end - - # Parse both generation tables so gaps, overlap, duplicates, unsorted IDs, - # reserved fields, and trailing bytes fail before any artifact is opened. - def parse_manifest(meta) - var name = manifest_file - var fp = meta.find(name, nil) - var h = read_exact(name, 0, 112) - if fp == nil || h == nil || h.get(0) != 83 || h.get(1) != 80 || h.get(2) != 77 - return nil - end - var file_size = h.get(6, 2) - var ps = h.get(8) - var fs = h.get(9) - if h.get(3) != 1 || h.get(4) != 16 || h.get(5) != 0 || - (ps != 0 && ps != 1) || (fs != 0 && fs != 1 && fs != 255) || - fs == ps || h.get(10) != 48 || h.get(11) != 44 || - !bytes_zero(h, 12, 15) || file_size < 112 || !exact_size(name, file_size) - return nil - end - - var preferred = generation(h, 16, ps == 0 ? "a" : "b", 112) - if preferred == nil - return nil - end - var expected = 112 + preferred["table_size"] - var fallback = nil - if fs == 255 - if !bytes_zero(h, 64, 111) - return nil - end - else - fallback = generation(h, 64, fs == 0 ? "a" : "b", expected) - if fallback == nil - return nil - end - expected += fallback["table_size"] - end - if expected != file_size - return nil - end - - var generations = [preferred] - if fallback != nil - generations.push(fallback) - end - for g : generations - var previous_id = -1 - for i : 0..g["count"] - 1 - var entry = read_exact(name, g["table"] + i * 44, 44) - if entry == nil - return nil - end - var id = entry.get(0) - if id <= previous_id || entry.get(1) != 0 || entry.get(10, 2) != 0 - return nil - end - previous_id = id - g["tracks"][id] = { - "id": id, - "size": entry.get(2, 2), - "revision": entry[4..9], - "fingerprint": fingerprint_hex(entry, 12) - } - end - end - return {"fingerprint": fp, "preferred": preferred, "fallback": fallback} - end - - def read_segment(track, index) - if index < 0 || index >= track["segment_count"] - return nil - end - var b = read_exact(track["name"], track["segment_offset"] + index * 12, 12) - if b == nil - return nil - end - return { - "origin": b.get(0, 4), - "first_cue": b.get(4, 2), - "cue_count": b.get(6, 2), - "seb_offset": b.get(8, 2), - "seb_size": b.get(10, 2) - } - end - - def read_cue(track, index) - if index < 0 || index >= track["cue_count"] - return nil - end - var b = read_exact(track["name"], track["cue_offset"] + index * 12, 12) - if b == nil - return nil - end - return { - "time": b.get(0, 4), - "segment": b.get(4, 2), - "cursor": b.get(6, 2), - "count": b.get(8, 2), - "offset": b.get(10, 2) - } - end - - # Validate the full SPT directory and each embedded SEB without retaining - # a whole SPT in Berry RAM. SEB.land at cursor=record_count is a native, - # mutation-free full-blob validation call. - def validate_track(descriptor, slot, duration, meta) - var name = track_name(slot, descriptor["id"]) - if descriptor["size"] < 36 || descriptor["size"] > 65535 || - meta.find(name, nil) != descriptor["fingerprint"] || - !exact_size(name, descriptor["size"]) - return nil - end - var h = read_exact(name, 0, 36) - if h == nil || h.get(0) != 83 || h.get(1) != 80 || h.get(2) != 84 || - h.get(3) != 1 || h.get(4) != 36 || h.get(5) != 0 || - h.get(6) != descriptor["id"] || h.get(7) != 0 || - !bytes_equal(h, 8, descriptor["revision"], 0, 6) || - h.get(14) != 12 || h.get(15) != 12 || h.get(16, 4) != duration || - h.get(20, 2) < 1 || h.get(20, 2) > 340 || h.get(22, 2) < 1 || - h.get(24, 2) < 1 || h.get(26, 2) != 36 || h.get(34, 2) != 0 || - h.get(32, 2) != descriptor["size"] - return nil - end - var cue_count = h.get(22, 2) - var segment_count = h.get(24, 2) - var segment_offset = h.get(28, 2) - var data_offset = h.get(30, 2) - if segment_offset != 36 + cue_count * 12 || - data_offset != segment_offset + segment_count * 12 || - data_offset > descriptor["size"] - return nil - end - - var track = { - "id": descriptor["id"], - "name": name, - "fingerprint": descriptor["fingerprint"], - "size": descriptor["size"], - "duration": duration, - "event_count": h.get(20, 2), - "cue_count": cue_count, - "segment_count": segment_count, - "cue_offset": 36, - "segment_offset": segment_offset, - "data_offset": data_offset, - "play_segment": 0, - "cursor": 0, - "bytes": nil, - "at": nil - } - - var expected_cue = 0 - var expected_data = data_offset - var previous_time = -1 - for segment_index : 0..segment_count - 1 - var segment = read_segment(track, segment_index) - if segment == nil || segment["origin"] < 0 || - segment["first_cue"] != expected_cue || segment["cue_count"] < 1 || - segment["first_cue"] + segment["cue_count"] > cue_count || - segment["seb_offset"] != expected_data || segment["seb_size"] < 12 || - segment["seb_size"] > 4092 || - segment["seb_offset"] + segment["seb_size"] > descriptor["size"] - return nil - end - var seb = read_exact(name, segment["seb_offset"], segment["seb_size"]) - if seb == nil || seb.get(0) != 83 || seb.get(1) != 69 || seb.get(2) != 66 || - seb.get(3) != 1 || seb.get(4) != 12 || seb.get(5) != 0 || - seb.get(10) != 12 || seb.get(11) != 0 - return nil - end - var records = seb.get(6, 2) - var seb_duration = seb.get(8, 2) - if records < 1 || records > 340 || segment["seb_size"] != 12 + records * 12 || - records != segment["cue_count"] * track["event_count"] - return nil - end - var inspected = SEB.land(seb, { - "at": controller.millis(), - "cursor": records, - "until": 0 - }) - if !inspected["ok"] || !inspected["done"] || inspected["cursor"] != records || - inspected["consumed"] != 0 - return nil - end - - var expected_record = 0 - for local_cue : 0..segment["cue_count"] - 1 - var cue_index = segment["first_cue"] + local_cue - var cue = read_cue(track, cue_index) - if cue == nil || cue["time"] <= previous_time || cue["time"] > duration || - cue["segment"] != segment_index || cue["cursor"] != expected_record || - cue["count"] != track["event_count"] || - cue["offset"] != cue["time"] - segment["origin"] || - cue["offset"] > seb_duration || - (local_cue == 0 && cue["time"] != segment["origin"]) - return nil - end - if cue_index == 0 && cue["time"] != 0 - return nil - end - for record : 0..cue["count"] - 1 - var record_offset = 12 + (cue["cursor"] + record) * 12 - if seb.get(record_offset + 8, 2) != cue["offset"] || - seb.get(record_offset + 10) != track["id"] - return nil - end - end - expected_record += cue["count"] - previous_time = cue["time"] - end - if expected_record != records - return nil - end - expected_cue += segment["cue_count"] - expected_data += segment["seb_size"] - end - if expected_cue != cue_count || expected_data != descriptor["size"] - return nil - end - return track - end - - def validate_generation(manifest, generation, meta) - if generation == nil - return nil - end - var requested = [] - if generation["tracks"].contains(255) - requested.push(255) - end - for id : local_ids - if !generation["tracks"].contains(id) - return nil - end - requested.push(id) - end - - var tracks = [] - for id : requested - var track = validate_track( - generation["tracks"][id], generation["slot"], generation["duration"], meta) - if track == nil - return nil - end - tracks.push(track) - end - return { - "slot": generation["slot"], - "duration": generation["duration"], - "manifest_fingerprint": manifest["fingerprint"], - "tracks": tracks - } - end - - def active_matches(active, meta) - if meta == nil - return nil - end - if active == nil || - meta.find(manifest_file, nil) != active["manifest_fingerprint"] - return false - end - for track : active["tracks"] - if meta.find(track["name"], nil) != track["fingerprint"] - return false - end - end - return true - end - - # Preferred is tried first. A failed preferred never replaces the current - # generation; a fully valid fallback is the only startup/recovery choice. - def load_bundle() - var before = metadata() - if before == nil - return nil - end - var manifest = parse_manifest(before) - if manifest == nil - return nil - end - var candidate = validate_generation(manifest, manifest["preferred"], before) - if candidate == nil - candidate = validate_generation(manifest, manifest["fallback"], before) - end - if candidate == nil - return nil - end - var after = metadata() - if !active_matches(candidate, after) - return nil - end - return candidate - end - - def subtract_local_millis(local_millis, offset) - # Avoid relying on signed-overflow behavior at the 32-bit millis wrap. - var minimum = -2147483648 - if local_millis >= minimum + offset - return local_millis - offset - end - var underflow = offset - (local_millis - minimum) - return 2147483647 - underflow + 1 - end - - def add_local_millis(local_millis, offset) - # Mirror subtract_local_millis while keeping the result in signed - # 32-bit millis space across controller.millis() wrap. - var maximum = 2147483647 - var room = maximum - local_millis - if offset <= room - return local_millis + offset - end - return -2147483648 + (offset - room - 1) - end - - def find_cue(track, target) - var low = 0 - var high = track["cue_count"] - 1 - var found = nil - while low <= high - var middle = (low + high) >> 1 - var cue = read_cue(track, middle) - if cue == nil - return nil - end - if cue["time"] <= target - found = cue - low = middle + 1 - else - high = middle - 1 - end - end - return found - end - - def prepare_seek(track, target, local_now) - var cue = find_cue(track, target) - if cue == nil - return nil - end - var segment = read_segment(track, cue["segment"]) - if segment == nil - return nil - end - return { - "track": track, - "cue": cue, - "segment": segment, - "at": subtract_local_millis(local_now, cue["offset"]) - } - end - - def begin_reconcile(state) - var active = R["active"] - if active == nil || state["time"] < 0 || state["time"] > active["duration"] - R["armed"] = false - R["reconcile"] = nil - log_error("timeline is outside the active show") - return false - end - # Keep the discontinuity trigger armed until reconciliation completes, - # so a transient projection/directory read failure retries next turn. - R["last_epoch"] = state["epoch"] - 1 - var local_now = timeline.at(state["time"]) - if local_now == nil - R["armed"] = false - log_error("timeline cannot project local millis") - return false - end - R["timeline_zero"] = subtract_local_millis(local_now, state["time"]) - # Freeze one mapping per timeline epoch/discontinuity. Calling - # timeline.at(segment origin) separately for each Track can - # quantize equal Cues one millisecond apart on real Controllers. - # Resolve every Cue and segment directory before the first Cue value is - # landed. SEB bytes are loaded one Track at a time so a large - # multi-ID bundle does not remain resident in Berry RAM. - var operations = [] - for track : active["tracks"] - var operation = prepare_seek(track, state["time"], local_now) - if operation == nil - R["armed"] = false - log_error("failed to prepare Cue") - return false - end - operations.push(operation) - end - R["armed"] = false - R["reconcile"] = { - "target": state["time"], - "epoch": state["epoch"], - "operations": operations, - "index": 0 - } - return true - end - - def reconcile_step(state) - var reconciliation = R["reconcile"] - if reconciliation == nil - return false - end - if state["epoch"] != reconciliation["epoch"] - R["reconcile"] = nil - return begin_reconcile(state) - end - if reconciliation["index"] < reconciliation["operations"].size() - var operation = reconciliation["operations"][reconciliation["index"]] - var matches = active_matches(R["active"], metadata()) - if matches == nil - return false - end - if !matches - R["storage_dirty"] = true - R["reconcile"] = nil - log_error("Player artifacts changed") - return false - end - var cue = operation["cue"] - var segment = operation["segment"] - var seb = read_exact(operation["track"]["name"], - segment["seb_offset"], segment["seb_size"]) - if seb == nil - log_error("failed to range-read Cue SEB") - return false - end - var result = SEB.land(seb, { - "at": operation["at"], - "cursor": cue["cursor"], - "until": cue["offset"] - }) - if !result["ok"] || result["consumed"] != cue["count"] - log_error("Complete Cue failed: " + str(result["error"])) - return false - end - var track = operation["track"] - var next_cursor = result["cursor"] - if result["done"] - track["play_segment"] = cue["segment"] + 1 - track["cursor"] = 0 - else - track["play_segment"] = cue["segment"] - track["cursor"] = next_cursor - end - track["bytes"] = nil - track["at"] = nil - reconciliation["index"] += 1 - return true - end - - R["reconcile"] = nil - R["armed"] = true - R["last_time"] = state["time"] - R["last_epoch"] = state["epoch"] - R["last_paused"] = state["paused"] - R["error"] = nil - return true - end - - # Return 1 after one SEB call, 0 when this Track has nothing due, and -1 on - # a read/projection failure. Cursor advances only from a successful native call. - def process_track(track, target) - if track["play_segment"] >= track["segment_count"] - return 0 - end - var segment = read_segment(track, track["play_segment"]) - if segment == nil || target < segment["origin"] - return segment == nil ? -1 : 0 - end - if track["bytes"] == nil - track["bytes"] = read_exact(track["name"], segment["seb_offset"], segment["seb_size"]) - if R["timeline_zero"] != nil - track["at"] = add_local_millis(R["timeline_zero"], segment["origin"]) - end - if track["bytes"] == nil || track["at"] == nil - track["bytes"] = nil - track["at"] = nil - return -1 - end - end - var until = target - segment["origin"] - var duration = track["bytes"].get(8, 2) - if until > duration - until = duration - end - var result = SEB.land(track["bytes"], { - "at": track["at"], - "cursor": track["cursor"], - "until": until - }) - if !result["ok"] - log_error("forward SEB failed: " + str(result["error"])) - return 1 - end - track["cursor"] = result["cursor"] - if result["done"] - track["play_segment"] += 1 - track["cursor"] = 0 - track["bytes"] = nil - track["at"] = nil - end - R["error"] = nil - return 1 - end - - # At most one SEB call is issued per Plugin turn. This makes the native - # 340-value atomic queue available to one complete Cue even with many IDs. - def process_forward(target) - var tracks = R["active"]["tracks"] - if tracks.size() == 0 - return - end - for checked : 0..tracks.size() - 1 - var index = (R["round"] + checked) % tracks.size() - var status = process_track(tracks[index], target) - if status != 0 - R["round"] = (index + 1) % tracks.size() - if status < 0 - log_error("failed to range-read active SPT") - end - return - end - end - end - - return Plugin(def() - var state = timeline.getState() - R["poll"] += 1 - - # Integrity polling watches only the manifest and currently active - # relevant SPTs. Writes to the inactive slot do not interrupt playback; - # the SPM-last publication is the change that arms reconciliation. - if R["active"] != nil && R["poll"] % integrity_polls == 0 - if active_matches(R["active"], metadata()) == false - R["storage_dirty"] = true - R["armed"] = false - R["reconcile"] = nil - log_error("Player artifacts changed; pause") - end - end - - if R["storage_dirty"] - if state["paused"] && R["poll"] % retry_polls == 0 - var candidate = load_bundle() - if candidate != nil - R["active"] = candidate - R["storage_dirty"] = false - begin_reconcile(state) - else - log_error("Player generation incomplete") - end - end - return - end - - if R["active"] == nil - if R["poll"] == 1 || R["poll"] % retry_polls == 0 - var candidate = load_bundle() - if candidate != nil - R["active"] = candidate - begin_reconcile(state) - else - log_error("no complete Player generation") - end - end - return - end - - if R["reconcile"] != nil - reconcile_step(state) - return - end - - # A paused timeline stops advancing while the local/network clock does - # not. Refresh the mapping on resume without landing the current Cue - # again: an external event remains authoritative until the next Cue, - # whose causal clock must still reflect the time spent paused. - if R["last_paused"] && !state["paused"] - var local_now = timeline.at(state["time"]) - if local_now == nil - log_error("timeline resume has no local projection") - return - end - R["timeline_zero"] = subtract_local_millis(local_now, state["time"]) - for track : R["active"]["tracks"] - track["bytes"] = nil - track["at"] = nil - end - end - - # Rewind, seek, and timeline loop are all reconstructed from the last - # complete Cue at/before the new shared timeline position. - if state["epoch"] != R["last_epoch"] || state["time"] < R["last_time"] - if begin_reconcile(state) - reconcile_step(state) - end - return - end - - if !state["paused"] && R["armed"] - process_forward(state["time"]) - end - R["last_time"] = state["time"] - R["last_epoch"] = state["epoch"] - R["last_paused"] = state["paused"] - end) -end - -# Copy this call into each Controller's Project and configure only its local IDs. -PlayerBundle({"namespace": "demo", "ids": [1], "debug": false}) diff --git a/data/v2/examples/player-show-complete-cue-tracks/README.md b/data/v2/examples/player-show-complete-cue-tracks/README.md new file mode 100644 index 0000000..338de25 --- /dev/null +++ b/data/v2/examples/player-show-complete-cue-tracks/README.md @@ -0,0 +1,62 @@ +# Complete-Cue Track Player from `player.show` + +This is the canonical copy-ready Project Berry Player for firmware **0.12.11**. +Studio compiles one static `.show` index and deterministic per-ID SEB +chains. The plugin follows the shared timeline; it owns no start, seek or loop +clock of its own. + +```berry +Player({"base": "player", "ids": [1], "debug": false}) +``` + +Configure `ids` with only the local Spectoda IDs 0–254 owned by this Controller. +ID255 is selected automatically when the Show Index contains it. NetworkStorage +still synchronizes every file to every Controller; the plugin opens only ID255 +and its configured local IDs. + +## Authoring and upload + +One Track owns one Spectoda ID. Its Event definitions are stable across the +Track, and every Cue supplies one complete type-correct value for every enabled +Event. SEB files contain those encoded Event values/records; landing them +produces normal EventStore updates. + +Use Studio's Event Player to Capture and edit Cues, then follow this workflow: + +1. pause the shared timeline; +2. upload the changed SEB files and `.show`; +3. wait for ordinary NetworkStorage propagation — App Controller readback does + not prove the whole mesh is ready; +4. rewind the shared timeline; +5. play. + +Hot update and simultaneous writers for the same base are unsupported in v1. +Old deterministic files no longer referenced by `.show` are ignored. + +## Runtime behavior and limits + +The plugin reads only the bounded Show Index into Berry. Each SEB payload is +read and atomically landed by the native `SEB.land(filename, +{"source":"networkStorage", ...})` path, without a Berry payload buffer. + +- one complete Cue contains 1–340 Event values; +- one SEB file is at most 4,092 bytes and spans at most 65,535 ms; +- `.show` is exact-EOF validated and at most 4,096 bytes; +- the base is 1–11 safe ASCII characters and generated names are at most 23 + bytes; +- one plugin turn performs at most one SEB contribution; +- pause performs no landing; resume derives a fresh timeline projection; +- seek, rewind, loop and the 24-hour timeline wrap reconcile the last complete + Cue at or before the target. + +The canonical compact source compiles to a 3,789-byte TNGL BERRY payload +(3,785 source bytes plus framing), 307 bytes below the 4 KiB hard limit. The +1 KiB target remains a stretch measurement, not a correctness promise. + +`player-show-artifacts.json` is a public synthetic three-Track corpus used by +the Berry smoke. It is illustrative compiled output; Project data remains the +authoring source of truth. + +For causal scene recall, use the separate `seb-causal-scene-recall` example. A +scene is an ordinary SEB file landed at its trigger Event time and is independent +of the timeline Player. diff --git a/data/v2/examples/player-bundle-ab-complete-cue-tracks/creator-kit.json b/data/v2/examples/player-show-complete-cue-tracks/creator-kit.json similarity index 78% rename from data/v2/examples/player-bundle-ab-complete-cue-tracks/creator-kit.json rename to data/v2/examples/player-show-complete-cue-tracks/creator-kit.json index aa30782..cbc8f6c 100644 --- a/data/v2/examples/player-bundle-ab-complete-cue-tracks/creator-kit.json +++ b/data/v2/examples/player-show-complete-cue-tracks/creator-kit.json @@ -9,9 +9,5 @@ "firmware": "0.12.11", "wasm": "DEBUG_UNIVERSAL_0.12.11_20260808" }, - "files": [ - "README.md", - "example.yaml", - "player-bundle.be" - ] + "files": ["README.md", "example.yaml", "player.be"] } diff --git a/data/v2/examples/player-show-complete-cue-tracks/example.yaml b/data/v2/examples/player-show-complete-cue-tracks/example.yaml new file mode 100644 index 0000000..f61e32a --- /dev/null +++ b/data/v2/examples/player-show-complete-cue-tracks/example.yaml @@ -0,0 +1,35 @@ +schemaVersion: example.v2 +slug: player-show-complete-cue-tracks +title: Complete-Cue Event Player from player.show +category: controller +summary: >- + Canonical firmware 0.12.11 Project Berry Player for one static player.show + index and deterministic per-ID SEB chains. It lands global ID255 plus explicit + local-ID Tracks from the shared timeline with complete Cue atomicity. +tags: + - berry + - event-player + - network-storage + - player-show + - seb + - timeline +hardware: >- + Requires firmware 0.12.11 with SEB.land NetworkStorage source support. + Configure only local IDs 0-254; ID255 is automatic. Pause before upload, wait + for ordinary propagation, rewind, and then play. +files: + - path: README.md + role: Setup, workflow, runtime behavior, and limits + language: markdown + - path: creator-kit.json + role: Fail-closed Spectoda Creator Kit selection + language: json + - path: player.be + role: Canonical copy-ready Project Berry Player + language: berry + - path: player-show-artifacts.json + role: Synthetic player.show and SEB golden corpus + language: json +related: + - seb-causal-scene-recall + - seb-segmented-id-track-player diff --git a/data/v2/examples/player-show-complete-cue-tracks/player-show-artifacts.json b/data/v2/examples/player-show-complete-cue-tracks/player-show-artifacts.json new file mode 100644 index 0000000..b446ca0 --- /dev/null +++ b/data/v2/examples/player-show-complete-cue-tracks/player-show-artifacts.json @@ -0,0 +1,23 @@ +{ + "schemaVersion": "spectoda.player-show-fixture.v1", + "base": "demo", + "description": "Synthetic complete-Cue Tracks for ID1, ID2 and global ID255.", + "files": [ + { + "name": "demo.001.000.seb", + "hex": "534542010c00040088130c003f0000ffd2c33c03000001000b84d71700d02404000001013fff0000d2c33c03881301000b8c864700d0240488130101" + }, + { + "name": "demo.002.000.seb", + "hex": "534542010c00040088130c003f0000ffd2c33c03000002000b84d71700d02404000002013fff0000d2c33c03881302000b8c864700d0240488130201" + }, + { + "name": "demo.255.000.seb", + "hex": "534542010c00020088130c000b00000000d024040000ff000b105e5f00d024048813ff00" + }, + { + "name": "demo.show", + "hex": "5053480110000300102700000000000001000200010000000000000002000000000088130200020001000000000000000200000000008813ff00010001000000000000000200000000008813" + } + ] +} diff --git a/data/v2/examples/player-show-complete-cue-tracks/player.be b/data/v2/examples/player-show-complete-cue-tracks/player.be new file mode 100644 index 0000000..58136b6 --- /dev/null +++ b/data/v2/examples/player-show-complete-cue-tracks/player.be @@ -0,0 +1,179 @@ + + + +def Player(s) +var b=s.find("base","player") +var ids=s.find("ids",[]) +var d=s.find("debug",false) +if type(b)!="string" || size(b)<1 || size(b)>11 || +type(ids)!="instance" || classname(ids)!="list" +raise "value_error","invalid Player config" +end +for j:0..size(b)-1 +var c=b[j] +if !((c>="A"&&c<="Z")||(c>="a"&&c<="z")|| +(c>="0"&&c<="9")||c=="_"||c=="-") +raise "value_error","invalid Player base" +end +end +var w={} +for i:ids +if type(i)!="int" || i<0 || i>254 || w.find(i,false) +raise "value_error","local IDs must be unique 0..254" +end +w[i]=true +end +var R=[false,[],false,0,0,0,-1] +def wanted(i)return i==255 || w.find(i,false)end +def n3(n) +if n<10 return "00"+str(n)end +if n<100 return "0"+str(n)end +return str(n) +end +def file(i,n)return b+"."+n3(i)+"."+n3(n)+".seb" end + +def load(x) +var z=spectoda.getNetworkStorageData(b+".show") +if z==nil || z.size()<16 || z.size()>4096 || +z.get(0)!=80 || z.get(1)!=83 || z.get(2)!=72 || +z.get(3)!=1 || z.get(4)!=16 || z.get(5)!=0 || +z.get(12,4)!=0 +return false +end +var c=z.get(6,2) +var duration=z.get(8,4) +if c<1 || c>256 || duration<1 || duration>86400000 +return false +end +var tracks=[] +var p=16 +var previous_id=-1 +for ti:0..c - 1 +if p+8>z.size()return false end +var id=z.get(p) +var rpc=z.get(p+2,2) +var sc=z.get(p+4,2) +if z.get(p+1)!=0 || z.get(p+6,2)!=0 || +id<=previous_id || rpc<1 || rpc>340 || sc<1 +return false +end +previous_id=id +p+=8 +var segments=[] +var previous_base=-1 +for si:0..sc - 1 +if p+8>z.size()return false end +var base=z.get(p,4) +var cc=z.get(p+4,2) +if z.get(p+6,2)!=0 || cc<1 || rpc*cc>340 || +base<=previous_base || base>=duration || +(si==0 && base !=0)|| p+8+cc*2>z.size() +return false +end +p+=8 +previous_base=base +var offsets=[] +var previous_offset=-1 +for ci:0..cc - 1 +var offset=z.get(p,2) +p+=2 +if(ci==0 && offset !=0)|| +(ci>0 && offset<=previous_offset)|| +base+offset>=duration +return false +end +previous_offset=offset +offsets.push(offset) +end +segments.push([base,offsets]) +end +if wanted(id)tracks.push([id,rpc,segments,0,0])end +end +if p !=z.size()return false end +R[0]=true R[1]=tracks R[2]=false R[3]=0 R[4]=x +return true +end + +def advance(t,si,ci) +ci+=1 +if ci>=t[2][si][1].size()si+=1 ci=0 end +t[3]=si +t[4]=ci +end + +def reconcile() +if R[3]>=R[1].size()R[2]=true return true end +var t=R[1][R[3]] +var bs=0 +var bc=0 +var found=false +for si:0..t[2].size()- 1 +var seg=t[2][si] +for ci:0..seg[1].size()- 1 +if seg[0]+seg[1][ci]<=R[4] +bs=si bc=ci found=true +end +end +end +if !found return false end +var o=t[2][bs][1][bc] +var at=timeline.at(R[4]- o) +if at==nil return false end +var cur=bc*t[1] +var q=SEB.land(file(t[0],bs),{"source":"networkStorage", +"at":at,"cursor":cur,"until":o}) +if !q["ok"]|| q["cursor"]!=cur+t[1] +if d print("Player reconcile",t[0],q["error"])end +return false +end +t[3]=bs t[4]=bc advance(t,bs,bc)R[3]+=1 +return true +end + +def forward(x) +var pick=nil +var due=nil +for t:R[1] +if t[3]65535 until=65535 end +var cur=pick[4]*pick[1] +var q=SEB.land(file(pick[0],si),{"source":"networkStorage", +"at":at,"cursor":cur,"until":until}) +if !q["ok"]|| q["cursor"]<=cur || q["cursor"]%pick[1]!=0 +if d print("Player forward",pick[0],q["error"])end +return false +end +var n=int(q["cursor"]/ pick[1]) +if n>=seg[1].size()pick[3]=si+1 pick[4]=0 +else pick[4]=n end +return true +end + +return Plugin(def() +var s=timeline.getState() +var x=s["time"] +var e=s["epoch"] +if !R[0]|| e !=R[6]|| x 0 - self.list_failures -= 1 - return nil - end - var result = [] - for name : self.files.keys() - result.push({"name": name, "fingerprint": self.fingerprints[name]}) - end - return result - end - - def getNetworkStorageData(name, offset, length) - self.reads.push([name, offset, length]) - var data = self.files.find(name, nil) - if data == nil || offset < 0 || offset > data.size() - return nil - end - if length == 0 || offset == data.size() - return bytes() - end - return data[offset..offset + length - 1] - end -end - -class TimelineStub - var state, local_now, projection_failures - - def init() - self.state = {"time": 0, "paused": true, "epoch": 1} - self.local_now = 1000 - self.projection_failures = 0 - end - - def getState() - return self.state - end - - def at(timestamp) - if self.projection_failures > 0 - self.projection_failures -= 1 - return nil - end - return self.local_now + timestamp - self.state["time"] - end -end - -class ControllerStub - def millis() - return 1000 - end -end - -class SebStub - var applied - - def init() - self.applied = [] - end - - def land(data, args) - var records = data.get(6, 2) - var cursor = args.find("cursor", 0) - var until = args.find("until", 0) - var consumed = 0 - while cursor < records && data.get(12 + cursor * 12 + 8, 2) <= until - var offset = 12 + cursor * 12 - self.applied.push({ - "value": data.get(offset, 4), - "offset": data.get(offset + 8, 2), - "id": data.get(offset + 10), - "at": args["at"] - }) - cursor += 1 - consumed += 1 - end - return { - "ok": true, - "cursor": cursor, - "consumed": consumed, - "done": cursor == records, - "error": nil - } - end -end - -var fixture_file = open( - "data/v2/examples/player-bundle-ab-complete-cue-tracks/player-bundle-artifacts.json", "r") -var fixture = json.load(fixture_file.read()) -fixture_file.close() -var final_manifest = nil -for item : fixture["uploadOrder"] - if item["name"] == "demo.spm" - final_manifest = item - end -end -var stage_a_manifest = fixture["stages"][0]["manifest"] -assert(final_manifest != nil) - -spectoda = SpectodaStub(fixture) -timeline = TimelineStub() -controller = ControllerStub() -SEB = SebStub() -stub_state = {"callback": nil} - -def Plugin(callback) - stub_state["callback"] = callback - return callback -end - -compile("data/v2/examples/player-bundle-ab-complete-cue-tracks/player-bundle.be", "file")() -assert(stub_state["callback"] != nil) - -# First turn validates preferred slot B and prepares the timeline-zero Cues. -stub_state["callback"]() -# One complete Cue per turn: global ID255, then configured ID1. -stub_state["callback"]() -stub_state["callback"]() -stub_state["callback"]() -assert(SEB.applied.size() == 2) -assert(SEB.applied[0]["id"] == 255) -assert(SEB.applied[1]["id"] == 1) -# Generation B ID1 starts at 20%; this is its raw portable value_t. -assert(SEB.applied[1]["value"] == bytes("0bd01213").get(0, 4)) - -# Resume and advance the shared timeline. Both 1000 ms Cues are eventually due, -# but still at most one SEB call is made in each callback turn. -# Eight seconds elapsed locally while the timeline stayed paused. The Player -# must refresh its mapping so the next Cue gets clock 9000, not the stale 2000. -timeline.local_now = 9000 -timeline.state["paused"] = false -timeline.state["time"] = 1000 -# A transient local-projection failure retries on the next Plugin turn without -# disarming forward playback. -timeline.projection_failures = 1 -for i : 0..3 - stub_state["callback"]() -end -assert(SEB.applied.size() == 4) -assert(SEB.applied[2]["offset"] == 1000) -assert(SEB.applied[3]["offset"] == 1000) -assert(SEB.applied[2]["at"] == 8000) -assert(SEB.applied[3]["at"] == 8000) - -# A transient metadata listing failure at the next integrity poll must not mark -# valid artifacts dirty or stop the next due Cue. -spectoda.list_failures = 1 -for i : 0..16 - stub_state["callback"]() -end -timeline.state["time"] = 2000 -for i : 0..3 - stub_state["callback"]() -end -assert(SEB.applied.size() == 6) -assert(SEB.applied[5]["offset"] == 2000) - -# Publish slot A by changing SPM last. The player halts while dirty, waits for -# the paused retry boundary, then lands one complete t=2000 Cue per Track. -timeline.state["paused"] = true -spectoda.files["demo.spm"] = bytes(stage_a_manifest["hex"]) -spectoda.fingerprints["demo.spm"] = stage_a_manifest["fingerprint"] -for i : 0..69 - stub_state["callback"]() -end -assert(SEB.applied.size() == 6) -for i : 0..3 - stub_state["callback"]() -end -assert(SEB.applied.size() == 8) -# Generation A ID1 at t=2000 returns to 10%. -assert(SEB.applied[7]["value"] == bytes("0b688909").get(0, 4)) - -# A backward loop reconstructs the complete t=0 Cues with one fresh projection -# shared across both Track files. -timeline.local_now = 20000 -timeline.state["time"] = 0 -for i : 0..2 - stub_state["callback"]() -end -assert(SEB.applied.size() == 10) -assert(SEB.applied[8]["at"] == 20000) -assert(SEB.applied[9]["at"] == 20000) - -# Restore the final two-generation manifest for the independent fallback test. -spectoda.files["demo.spm"] = bytes(final_manifest["hex"]) -spectoda.fingerprints["demo.spm"] = final_manifest["fingerprint"] - -# A missing preferred local artifact must select the complete slot-A fallback. -spectoda.files.remove("demo.b.001.spt") -spectoda.fingerprints.remove("demo.b.001.spt") -timeline = TimelineStub() -SEB = SebStub() -stub_state["callback"] = nil -compile("data/v2/examples/player-bundle-ab-complete-cue-tracks/player-bundle.be", "file")() -stub_state["callback"]() -stub_state["callback"]() -stub_state["callback"]() -stub_state["callback"]() -assert(SEB.applied.size() == 2) -assert(SEB.applied[1]["id"] == 1) -# Generation A ID1 starts at 10%. -assert(SEB.applied[1]["value"] == bytes("0b688909").get(0, 4)) - -for read : spectoda.reads - assert(read[2] <= 4092) -end - -# Lock both sides of the sorted local-ID contract so valid unsorted IDs and real -# duplicates do not regress behind the single-ID physical fixture. -var player_file = open( - "data/v2/examples/player-bundle-ab-complete-cue-tracks/player-bundle.be", "r") -var player_source = player_file.read() -player_file.close() -assert(string.find(player_source, "timeline.at") >= 0) -assert(string.find(player_source, "timeline.toMillis") < 0) -var valid_multi_id = true -try - compile(player_source + - '\nPlayerBundle({"namespace":"demo","ids":[1,0],"debug":false})', - "string")() -except .. - valid_multi_id = false -end -assert(valid_multi_id) -var duplicate_rejected = false -try - compile(player_source + - '\nPlayerBundle({"namespace":"demo","ids":[5,5],"debug":false})', - "string")() -except .. - duplicate_rejected = true -end -assert(duplicate_rejected) -print("player-bundle preferred/fallback/range smoke: PASS") diff --git a/tests/player-show-size.mjs b/tests/player-show-size.mjs new file mode 100644 index 0000000..591838c --- /dev/null +++ b/tests/player-show-size.mjs @@ -0,0 +1,15 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' + +const sourcePath = + 'data/v2/examples/player-show-complete-cue-tracks/player.be' +const sourceBytes = Buffer.byteLength(readFileSync(sourcePath, 'utf8')) + +// TnglCompiler encodes BERRY_SCRIPT flag + uint16 length + source bytes and +// the surrounding TNGL stream adds END_OF_TNGL_BYTES. +const tnglBytes = 1 + 2 + sourceBytes + 1 +assert.ok(tnglBytes <= 4096, `Player TNGL is ${tnglBytes} bytes (>4096)`) + +console.log( + `player.show plugin size: ${sourceBytes} source bytes, ${tnglBytes} TNGL bytes, ${tnglBytes - 1024} bytes above the 1024-byte stretch target`, +) diff --git a/tests/player-show-stub.be b/tests/player-show-stub.be new file mode 100644 index 0000000..d1a8931 --- /dev/null +++ b/tests/player-show-stub.be @@ -0,0 +1,281 @@ +# Host-Berry lifecycle smoke for the public static Player plugin. It uses the +# same synthetic player.show + SEB corpus shipped with the example and small +# firmware API stubs; no Controller or customer data is involved. +import json +import string + +# Declare firmware globals before class/function compilation in host Berry. +spectoda = nil +timeline = nil +SEB = nil +stub_state = nil +Player = nil + +class SpectodaStub + var files, reads + + def init(fixture) + self.files = {} + self.reads = [] + for item : fixture["files"] + self.files[item["name"]] = bytes(item["hex"]) + end + end + + def getNetworkStorageData(name) + self.reads.push(name) + return self.files.find(name, nil) + end +end + +class TimelineStub + var state, local_now, projection_failures + + def init() + self.state = {"time": 0, "paused": true, "epoch": 1} + self.local_now = 1000 + self.projection_failures = 0 + end + + def getState() + return self.state + end + + def at(position) + if self.projection_failures > 0 + self.projection_failures -= 1 + return nil + end + return self.local_now + position - self.state["time"] + end +end + +class SebStub + var applied, calls + + def init() + self.applied = [] + self.calls = [] + end + + def land(name, args) + var original_cursor = args.find("cursor", 0) + self.calls.push({"name": name, "args": args}) + if args.find("source", nil) != "networkStorage" + return {"ok": false, "cursor": original_cursor, "consumed": 0, + "done": false, "error": "source"} + end + var data = spectoda.files.find(name, nil) + if data == nil || data.size() < 12 || data.get(0) != 83 || + data.get(1) != 69 || data.get(2) != 66 + return {"ok": false, "cursor": original_cursor, "consumed": 0, + "done": false, "error": "file"} + end + var records = data.get(6, 2) + var cursor = original_cursor + var until = args.find("until", 0) + var consumed = 0 + while cursor < records && data.get(12 + cursor * 12 + 8, 2) <= until + var p = 12 + cursor * 12 + self.applied.push({ + "value": data.get(p, 4), + "offset": data.get(p + 8, 2), + "id": data.get(p + 10), + "at": args["at"] + }) + cursor += 1 + consumed += 1 + end + return {"ok": true, "cursor": cursor, "consumed": consumed, + "done": cursor == records, "error": nil} + end +end + +var fixture_file = open( + "data/v2/examples/player-show-complete-cue-tracks/player-show-artifacts.json", "r") +var fixture = json.load(fixture_file.read()) +fixture_file.close() + +spectoda = SpectodaStub(fixture) +timeline = TimelineStub() +SEB = SebStub() +stub_state = {"callback": nil} + +def Plugin(callback) + stub_state["callback"] = callback + return callback +end + +compile("data/v2/examples/player-show-complete-cue-tracks/player.be", "file")() +Player({"base": "demo", "ids": [1], "debug": false}) +assert(stub_state["callback"] != nil) + +# Initial paused reconciliation lands one complete Cue per relevant Track. ID1 +# wins the time-zero tie; ID255 follows. ID2 is synchronized but never opened. +stub_state["callback"]() +stub_state["callback"]() +stub_state["callback"]() +assert(SEB.applied.size() == 3) +assert(SEB.applied[0]["id"] == 1) +assert(SEB.applied[1]["id"] == 1) +assert(SEB.applied[2]["id"] == 255) +assert(SEB.calls.size() == 2) +assert(SEB.calls[0]["name"] == "demo.001.000.seb") +assert(SEB.calls[1]["name"] == "demo.255.000.seb") + +# Stable pause is silent. Resume derives a fresh projection and lands the +# t=5000 complete Cues, still at most one SEB contribution per turn. +for i : 0..2 stub_state["callback"]() end +assert(SEB.calls.size() == 2) +timeline.local_now = 9000 +timeline.state["time"] = 5000 +timeline.state["paused"] = false +timeline.projection_failures = 1 +stub_state["callback"]() +assert(SEB.calls.size() == 2) +stub_state["callback"]() +stub_state["callback"]() +assert(SEB.applied.size() == 6) +assert(SEB.applied[3]["offset"] == 5000) +assert(SEB.applied[4]["offset"] == 5000) +assert(SEB.applied[5]["offset"] == 5000) +assert(SEB.applied[3]["at"] == 4000) +assert(SEB.applied[5]["at"] == 4000) + +# Pause/resume at an already consumed position never relands it. +timeline.state["paused"] = true +stub_state["callback"]() +timeline.local_now = 12000 +timeline.state["paused"] = false +stub_state["callback"]() +assert(SEB.applied.size() == 6) + +# A backward seek/epoch reloads the Show Index. A missing relevant file retries +# without advancing or starting forward playback; restoring it succeeds. +timeline.state = {"time": 0, "paused": true, "epoch": 2} +timeline.local_now = 20000 +var id1 = spectoda.files["demo.001.000.seb"] +spectoda.files.remove("demo.001.000.seb") +stub_state["callback"]() +stub_state["callback"]() +assert(SEB.applied.size() == 6) +spectoda.files["demo.001.000.seb"] = id1 +stub_state["callback"]() +stub_state["callback"]() +stub_state["callback"]() +assert(SEB.applied.size() == 9) +assert(SEB.applied[6]["at"] == 20000) +assert(SEB.applied[8]["at"] == 20000) + +# The shared 24-hour wrap is another discontinuity and reconciles time zero. +timeline.state = {"time": 0, "paused": true, "epoch": 3} +timeline.local_now = 86421000 +stub_state["callback"]() +stub_state["callback"]() +stub_state["callback"]() +assert(SEB.applied.size() == 12) + +# Exact EOF is fail-closed: a trailing Show Index byte performs no SEB call. +for item : fixture["files"] + if item["name"] == "demo.show" item["hex"] += "00" end +end +spectoda = SpectodaStub(fixture) +timeline = TimelineStub() +SEB = SebStub() +stub_state["callback"] = nil +Player({"base": "demo", "ids": [1], "debug": false}) +stub_state["callback"]() +assert(SEB.calls.size() == 0) + +# A delayed player call clamps a segment contribution to the native uint16 +# due window, then continues with the next deterministic segment on the next +# turn. This also locks the one-SEB-call-per-plugin-turn policy. +var catchup_fixture = { + "files": [ + { + "name": "catch.show", + "hex": "5053480110000100a0860100000000000100010002000000"+ + "0000000002000000000050c300000100010000000000" + }, + { + "name": "catch.001.000.seb", + "hex": "534542010c000200ffff0c000b688909c891480200000100"+ + "0b08af2fc891480250c30100" + }, + { + "name": "catch.001.001.seb", + "hex": "534542010c00010000000c000b688909c891480200000100" + } + ] +} +spectoda = SpectodaStub(catchup_fixture) +timeline = TimelineStub() +SEB = SebStub() +stub_state["callback"] = nil +Player({"base": "catch", "ids": [1], "debug": false}) +stub_state["callback"]() +stub_state["callback"]() +assert(SEB.applied.size() == 1) +timeline.local_now = 91000 +timeline.state = {"time": 90000, "paused": false, "epoch": 1} +stub_state["callback"]() +assert(SEB.calls.size() == 2) +assert(SEB.calls[1]["args"]["until"] == 65535) +assert(SEB.applied[1]["offset"] == 50000) +stub_state["callback"]() +assert(SEB.calls.size() == 3) +assert(SEB.calls[2]["name"] == "catch.001.001.seb") +assert(SEB.calls[2]["args"]["until"] == 24464) +assert(SEB.applied[2]["offset"] == 0) + +# A Cue reached while paused remains unconsumed and lands exactly after an +# ordinary resume at that same shared-timeline position. +spectoda = SpectodaStub(catchup_fixture) +timeline = TimelineStub() +SEB = SebStub() +stub_state["callback"] = nil +Player({"base": "catch", "ids": [1], "debug": false}) +stub_state["callback"]() +stub_state["callback"]() +timeline.local_now = 51000 +timeline.state = {"time": 50000, "paused": true, "epoch": 1} +stub_state["callback"]() +assert(SEB.calls.size() == 1) +timeline.state["paused"] = false +stub_state["callback"]() +assert(SEB.calls.size() == 2) +assert(SEB.calls[1]["args"]["until"] == 50000) +assert(SEB.applied.size() == 2) +assert(SEB.applied[1]["offset"] == 50000) + +# Lock the public API and configuration shape. +var player_file = open( + "data/v2/examples/player-show-complete-cue-tracks/player.be", "r") +var player_source = player_file.read() +player_file.close() +assert(size(player_source) <= 4093) +assert(string.find(player_source, "timeline.at") >= 0) +assert(string.find(player_source, "timeline.toMillis") < 0) +assert(string.find(player_source, '"source":"networkStorage"') >= 0) +var duplicate_rejected = false +try + Player({"base": "demo", "ids": [5, 5]}) +except .. + duplicate_rejected = true +end +assert(duplicate_rejected) +var unsafe_base_rejected = false +try + Player({"base": "bad.name", "ids": [1]}) +except .. + unsafe_base_rejected = true +end +assert(unsafe_base_rejected) +var non_ascii_base_rejected = false +try + Player({"base": "přehrávač", "ids": [1]}) +except .. + non_ascii_base_rejected = true +end +assert(non_ascii_base_rejected) +print("player.show static complete-Cue lifecycle smoke: PASS") From 471d774c8a5387d0c11fc9ca3d63a7213f5d649d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C4=9Bj=20Such=C3=A1nek?= Date: Sat, 8 Aug 2026 20:01:47 +0200 Subject: [PATCH 6/6] DEV-6449 harden static Player wrapper --- .../player-show-complete-cue-tracks/README.md | 16 ++++++-- .../player-show-complete-cue-tracks/player.be | 27 +++++++----- tests/player-show-size.mjs | 9 +++- tests/player-show-stub.be | 41 ++++++++++++++++++- 4 files changed, 77 insertions(+), 16 deletions(-) diff --git a/data/v2/examples/player-show-complete-cue-tracks/README.md b/data/v2/examples/player-show-complete-cue-tracks/README.md index 338de25..6615f1f 100644 --- a/data/v2/examples/player-show-complete-cue-tracks/README.md +++ b/data/v2/examples/player-show-complete-cue-tracks/README.md @@ -49,9 +49,19 @@ read and atomically landed by the native `SEB.land(filename, - seek, rewind, loop and the 24-hour timeline wrap reconcile the last complete Cue at or before the target. -The canonical compact source compiles to a 3,789-byte TNGL BERRY payload -(3,785 source bytes plus framing), 307 bytes below the 4 KiB hard limit. The -1 KiB target remains a stretch measurement, not a correctness promise. +The canonical compact source compiles to a 3,999-byte TNGL BERRY payload +(3,995 source bytes plus framing). Including the copy-ready `return Player(...)` +invocation produces 4,056 bytes, 40 bytes below the 4 KiB hard limit. The 1 KiB +target remains a stretch measurement, not a correctness promise. + +The short local names and positional runtime arrays in `player.be` are +intentional consequences of that hard limit. `R` holds loaded state, selected +Tracks, reconciliation state, the next Track, seek target, last timeline +position, and epoch. Each selected Track stores ID, records per complete Cue, +ordered segments, next segment, and next Cue. The parser verifies those tuple +shapes before playback, rejects chronological segment overlap, and stops a +Track after an already-committed native result disagrees with the Show Index; +it never retries that committed contribution. `player-show-artifacts.json` is a public synthetic three-Track corpus used by the Berry smoke. It is illustrative compiled output; Project data remains the diff --git a/data/v2/examples/player-show-complete-cue-tracks/player.be b/data/v2/examples/player-show-complete-cue-tracks/player.be index 58136b6..4157ba0 100644 --- a/data/v2/examples/player-show-complete-cue-tracks/player.be +++ b/data/v2/examples/player-show-complete-cue-tracks/player.be @@ -1,6 +1,5 @@ - - +# Compact by design: compiled Project Player must fit one 4 KiB TNGL block. def Player(s) var b=s.find("base","player") var ids=s.find("ids",[]) @@ -61,12 +60,14 @@ previous_id=id p+=8 var segments=[] var previous_base=-1 +var previous_end=-1 for si:0..sc - 1 if p+8>z.size()return false end var base=z.get(p,4) var cc=z.get(p+4,2) if z.get(p+6,2)!=0 || cc<1 || rpc*cc>340 || base<=previous_base || base>=duration || +(si>0 && base<=previous_end)|| (si==0 && base !=0)|| p+8+cc*2>z.size() return false end @@ -85,6 +86,7 @@ end previous_offset=offset offsets.push(offset) end +previous_end=base+previous_offset segments.push([base,offsets]) end if wanted(id)tracks.push([id,rpc,segments,0,0])end @@ -122,13 +124,14 @@ if at==nil return false end var cur=bc*t[1] var q=SEB.land(file(t[0],bs),{"source":"networkStorage", "at":at,"cursor":cur,"until":o}) -if !q["ok"]|| q["cursor"]!=cur+t[1] +if q["ok"] +if q["cursor"]==cur+t[1]advance(t,bs,bc) +else t[3]=t[2].size()end +R[3]+=1 return true +end if d print("Player reconcile",t[0],q["error"])end return false end -t[3]=bs t[4]=bc advance(t,bs,bc)R[3]+=1 -return true -end def forward(x) var pick=nil @@ -150,15 +153,19 @@ if until>65535 until=65535 end var cur=pick[4]*pick[1] var q=SEB.land(file(pick[0],si),{"source":"networkStorage", "at":at,"cursor":cur,"until":until}) -if !q["ok"]|| q["cursor"]<=cur || q["cursor"]%pick[1]!=0 -if d print("Player forward",pick[0],q["error"])end -return false -end +if q["ok"] +if q["cursor"]<=cur || q["cursor"]%pick[1]!=0 +pick[3]=pick[2].size() pick[4]=0 +else var n=int(q["cursor"]/ pick[1]) if n>=seg[1].size()pick[3]=si+1 pick[4]=0 else pick[4]=n end +end return true end +if d print("Player forward",pick[0],q["error"])end +return false +end return Plugin(def() var s=timeline.getState() diff --git a/tests/player-show-size.mjs b/tests/player-show-size.mjs index 591838c..525eb22 100644 --- a/tests/player-show-size.mjs +++ b/tests/player-show-size.mjs @@ -4,12 +4,19 @@ import { readFileSync } from 'node:fs' const sourcePath = 'data/v2/examples/player-show-complete-cue-tracks/player.be' const sourceBytes = Buffer.byteLength(readFileSync(sourcePath, 'utf8')) +const invocation = + 'return Player({"base":"player","ids":[1],"debug":false})' // TnglCompiler encodes BERRY_SCRIPT flag + uint16 length + source bytes and // the surrounding TNGL stream adds END_OF_TNGL_BYTES. const tnglBytes = 1 + 2 + sourceBytes + 1 +const projectTnglBytes = tnglBytes + 1 + Buffer.byteLength(invocation) assert.ok(tnglBytes <= 4096, `Player TNGL is ${tnglBytes} bytes (>4096)`) +assert.ok( + projectTnglBytes <= 4096, + `Player plus canonical invocation is ${projectTnglBytes} bytes (>4096)`, +) console.log( - `player.show plugin size: ${sourceBytes} source bytes, ${tnglBytes} TNGL bytes, ${tnglBytes - 1024} bytes above the 1024-byte stretch target`, + `player.show plugin size: ${sourceBytes} source bytes, ${tnglBytes} TNGL bytes; canonical Project is ${projectTnglBytes} bytes, ${4096 - projectTnglBytes} bytes below the hard limit and ${projectTnglBytes - 1024} above the stretch target`, ) diff --git a/tests/player-show-stub.be b/tests/player-show-stub.be index d1a8931..b886b45 100644 --- a/tests/player-show-stub.be +++ b/tests/player-show-stub.be @@ -51,11 +51,12 @@ class TimelineStub end class SebStub - var applied, calls + var applied, calls, cursor_delta def init() self.applied = [] self.calls = [] + self.cursor_delta = 0 end def land(name, args) @@ -86,7 +87,8 @@ class SebStub cursor += 1 consumed += 1 end - return {"ok": true, "cursor": cursor, "consumed": consumed, + return {"ok": true, "cursor": cursor + self.cursor_delta, + "consumed": consumed, "done": cursor == records, "error": nil} end end @@ -228,6 +230,41 @@ assert(SEB.calls[2]["name"] == "catch.001.001.seb") assert(SEB.calls[2]["args"]["until"] == 24464) assert(SEB.applied[2]["offset"] == 0) +# An ok result has already committed. If its cursor disagrees with player.show, +# fail that Track closed instead of retrying and landing the same records again. +fixture_file = open( + "data/v2/examples/player-show-complete-cue-tracks/player-show-artifacts.json", "r") +fixture = json.load(fixture_file.read()) +fixture_file.close() +spectoda = SpectodaStub(fixture) +timeline = TimelineStub() +SEB = SebStub() +SEB.cursor_delta = -1 +stub_state["callback"] = nil +Player({"base": "demo", "ids": [1], "debug": false}) +for i : 0..3 stub_state["callback"]() end +assert(SEB.calls.size() == 2) +assert(SEB.applied.size() == 3) + +# Segment bases must start after the preceding segment's final absolute Cue. +# This Show uses bases 0 and 1000 while the first segment ends at 50000. +var overlap_fixture = { + "files": [ + { + "name": "cross.show", + "hex": "5053480110000100a0860100000000000100010002000000"+ + "0000000002000000000050c3e8030000010000000000" + } + ] +} +spectoda = SpectodaStub(overlap_fixture) +timeline = TimelineStub() +SEB = SebStub() +stub_state["callback"] = nil +Player({"base": "cross", "ids": [1], "debug": false}) +stub_state["callback"]() +assert(SEB.calls.size() == 0) + # A Cue reached while paused remains unconsumed and lands exactly after an # ordinary resume at that same shared-timeline position. spectoda = SpectodaStub(catchup_fixture)