diff --git a/CHANGELOG.md b/CHANGELOG.md index af88fdb..f38bcf1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,26 @@ Date-based versions use `YYYYMMDD`. +## 20260808 + +### Changed + +- 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 + +- 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 ### Added diff --git a/README.md b/README.md index d8b0b7d..fe020e7 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-show-complete-cue-tracks/ # static Show Index Player with complete per-ID Cues ``` ## 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-show-complete-cue-tracks/README.md b/data/v2/examples/player-show-complete-cue-tracks/README.md new file mode 100644 index 0000000..6615f1f --- /dev/null +++ b/data/v2/examples/player-show-complete-cue-tracks/README.md @@ -0,0 +1,72 @@ +# 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,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 +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-show-complete-cue-tracks/creator-kit.json b/data/v2/examples/player-show-complete-cue-tracks/creator-kit.json new file mode 100644 index 0000000..cbc8f6c --- /dev/null +++ b/data/v2/examples/player-show-complete-cue-tracks/creator-kit.json @@ -0,0 +1,13 @@ +{ + "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.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..4157ba0 --- /dev/null +++ b/data/v2/examples/player-show-complete-cue-tracks/player.be @@ -0,0 +1,186 @@ + +# 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",[]) +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 +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 +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 +previous_end=base+previous_offset +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"] +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 + +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"] +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() +var x=s["time"] +var e=s["epoch"] +if !R[0]|| e !=R[6]|| x- - 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-show-size.mjs b/tests/player-show-size.mjs new file mode 100644 index 0000000..525eb22 --- /dev/null +++ b/tests/player-show-size.mjs @@ -0,0 +1,22 @@ +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')) +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; 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 new file mode 100644 index 0000000..b886b45 --- /dev/null +++ b/tests/player-show-stub.be @@ -0,0 +1,318 @@ +# 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, cursor_delta + + def init() + self.applied = [] + self.calls = [] + self.cursor_delta = 0 + 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 + self.cursor_delta, + "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) + +# 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) +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")