diff --git a/AGENTS.md b/AGENTS.md
index 1740954..3174e45 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -45,4 +45,14 @@ scoped `AGENTS.md` files before making changes.
- Alternatively, launch the graphical editor with `python devlog_editor.py` for
interactive management of devlog entries and publishing status.
- Run `git diff --check` and review diffs carefully before staging or committing.
-- At handoff, summarize files changed, validation performed, and any next steps.
\ No newline at end of file
+- At handoff, summarize files changed, validation performed, and any next steps.
+
+## Authoritative project documents, in priority order:
+
+- XZBT Exhibit Contract Specification v5.2
+- XZBT-NGN Exhibit Engine Implementation Plan v5.2
+- XZBT Exhibit Authoring Guide v5.2
+- Step 3.7 reference exhibit report
+
+- Do not infer XZBT-NGN behavior from SciFi-XZBT source code.
+- SciFi-XZBT is an external exhibit implementation, not the NGN architecture.
\ No newline at end of file
diff --git a/README.md b/README.md
index 775887c..f93f1e3 100644
--- a/README.md
+++ b/README.md
@@ -1,4 +1,71 @@
-# Labyricorn Project Template
+# XZBT-NGN Exhibit Engine
+
+A minimal, single-exhibit engineering host for XZBT Exhibit Contract 5.2.
+It discovers controls from `describe`, tracks the exhibit's reported state,
+and exposes set/invoke operations, events, sessions, and protocol errors.
+
+## Run locally
+
+Requires Node.js 22 or later. No npm dependencies or build step are needed.
+
+```powershell
+npm start
+```
+
+Open `http://127.0.0.1:4173`. Enter a same-origin exhibit path and select
+**Load exhibit**. The host negotiates a real session, calls `describe` and
+`state.get`, then renders the discovered catalog. **Reconnect** establishes
+a new session without reloading the exhibit; **Refresh state** requests a
+new authoritative snapshot. **Disconnect** removes the exhibit frame.
+
+Included test fixture paths:
+
+```text
+/test-fixtures/reference-exhibits/aquarium/index.html
+/test-fixtures/reference-exhibits/planetarium/index.html
+/test-fixtures/reference-exhibits/haunted-house/index.html
+```
+
+These copied fixtures have documented compatibility corrections; see
+[fixture provenance](test-fixtures/PROVENANCE.md). They are not NGN product
+logic. Additional trusted HTML exhibits can be placed in a directory under
+`public/` and loaded by their same-origin URL. The server exposes only
+`public/`, `src/`, and `test-fixtures/`, and binds to `127.0.0.1`.
+Set the `PORT` environment variable to change the default port.
+
+## Verification
+
+```powershell
+npm test
+python devlog_editor.py --validate
+git diff --check
+```
+
+With the server running, open
+`http://127.0.0.1:4173/test-fixtures/host-verification.html` and select
+**Run verification** for repeatable real-browser transport tests against all
+three exhibits. Manual admin UI and native exhibit gesture checks are recorded
+in [the Step 4 report](STEP4-MVP-REPORT.md).
+
+## Implementation boundaries
+
+- `src/host.js`: session/request correlation, catalog, state cache, events,
+ synchronization, and control operations.
+- `src/validation.js`: descriptor, value, and clarified argument validation.
+- `src/transport/post-message.js`: same-origin frame transport only.
+- `src/ui.js` and `public/`: descriptor-driven engineering interface.
+- `server/serve.js`: loopback static server with explicit served directories.
+- `tests/`: focused automated host and server tests.
+- `test-fixtures/`: copied reference exhibits and real-browser verification.
+
+The state display is a cache of exhibit reports. Events update reported values;
+editable fields are drafts and are submitted only when Set/Invoke is selected.
+Draft fields start from the first snapshot and do not overwrite themselves on
+each incoming event. Registry/capability rediscovery rebuilds the controls.
+Logs retain the latest 200 entries in memory. The interface intentionally has
+no persistence, accounts, MIDI, scenarios, webhooks, packaging, or orchestration.
+
+## Template and publishing records
A starter template and baseline structure for projects compatible with
[Labyricorn](https://www.labyricorn.com).
@@ -210,4 +277,4 @@ applicable `AGENTS.md` before making changes.
## License
-This template is released into the public domain under the [Unlicense](LICENSE).
\ No newline at end of file
+This template is released into the public domain under the [Unlicense](LICENSE).
diff --git a/STEP4-INDEPENDENT-REVIEW.md b/STEP4-INDEPENDENT-REVIEW.md
new file mode 100644
index 0000000..5af2f10
--- /dev/null
+++ b/STEP4-INDEPENDENT-REVIEW.md
@@ -0,0 +1,149 @@
+# Step 4 MVP — Independent Conformance Review
+
+**Reviewer:** independent (not the implementing agent)
+**Date:** 2026-09-14
+**Scope:** XZBT-NGN Step 4 MVP — host, transport, validation, UI, server, tests, and the three copied reference fixtures — against XZBT Exhibit Contract 5.2 and the NGN Implementation Plan v5.2.
+**Method:** read-only. No file in either repository was modified. Every claim below is tied to a command result or a file line.
+
+---
+
+## 1. Verdict
+
+**The Step 4 MVP is a genuine, working implementation of the Contract 5.2 host boundary, and its central genericity claim holds.**
+
+The host is real, not a mock: it negotiates a session, discovers a catalog it has never seen, renders controls from descriptors, drives state and impulses, tracks two independent counters, and recovers from desynchronization. I reproduced the automated suite at 20/20 passing. I independently confirmed that no product source file contains any exhibit-domain vocabulary.
+
+The report's own verdict — `STEP 4 MVP COMPLETE — MINOR FOLLOW-UP REQUIRED` — is **accurate and appropriately hedged**. I found no defect that invalidates the MVP. I found **one substantive conformance gap in the fixture layer** (F-1), **one host-side robustness gap** (F-2), and a set of documentation and evidence-scope issues.
+
+The most important finding is not a bug. It is that **the fixture repairs are load-bearing for the genericity claim, and the report does not say so.** The three fixtures are the only evidence that the host is generic. Those fixtures were edited to make them conform. That is legitimate — but it means the MVP has demonstrated that the host drives three *repaired* exhibits, not three *unmodified* ones. The report's phrase "a single unmodified host drives all three" is true of the host and false of the fixtures, and the distinction matters for what the MVP has actually proven.
+
+---
+
+## 2. What I verified independently
+
+| Claim | How I checked | Result |
+| --- | --- | --- |
+| 20 automated tests pass | `node --test` in `G:\.vibe\XZBT-NGN` | **Confirmed.** `tests 20 / pass 20 / fail 0`, 357 ms. |
+| No domain vocabulary in product source | `Select-String` over `src/*.js`, `src/transport/*.js`, `public/*.html`, `public/*.css`, `server/*.js` for `aquarium\|planetarium\|haunted\|fish\|apparition\|scifi\|universe\|preset\|warp\|speech\|announcement\|tank\|sky\|room\|telemetry` | **Confirmed.** Zero matches. The only hit for a broader pattern was `host.js:3`, the canonical event-type list. |
+| Fixture repairs are exactly as documented | `git diff --no-index` between `G:\.vibe\SciFi-XZBT\exhibit-test\abacusai\reference-exhibits` and `test-fixtures/reference-exhibits` | **Confirmed, and complete.** The entire delta is: 8 × `kind: 'telemetry'` → `kind: 'state'`; 1 × `args:` → `arguments:`; 3 × `sky.magnitudeLimit` → `sky.magnitude-limit`; 32 added lines in `shared/contract-core.js`; and deletion of `host-harness/` and `tests/`. |
+| The upstream source was not modified | `git status` in `G:\.vibe\SciFi-XZBT` | **Confirmed.** `exhibit-test/` is untracked; no tracked file in the exhibit repo was touched by this work. |
+| The magnitude repair was a real bug, not a cosmetic rename | Compared `read()` and `dispatch()` in both copies | **Confirmed.** Upstream `planetarium/exhibit.js:397` read `case 'sky.magnitudeLimit'` while the descriptor at `contract-adapter.js:36` declared `sky.magnitude-limit`. The reader returned `null` for a declared readable target, and native slider writes fell through to no case. The repair is correct and minimal. |
+| The `arguments` repair was necessary | Read `argumentSchema()` in `src/validation.js` and the invoke path in `shared/contract-core.js` | **Confirmed.** Both read `target.arguments`. Upstream Planetarium declared `args:`, so its `minutes` argument was invisible to both the host and the fixture's own validator. |
+
+---
+
+## 3. Findings
+
+### F-1 — Substantive: the fixture invoke path does not enforce `INVALID_ARGUMENTS`
+
+**Severity: medium. Conformance gap in the fixture layer, not in the host.**
+
+Contract §24 lists `INVALID_ARGUMENTS` as a distinct base error code, and the Authoring Guide's conformance checklist (line 522) requires that "unknown target, invalid value, invalid session, and invalid arguments each return the correct distinct error code."
+
+The fixture core's invoke path (`shared/contract-core.js`, the block marked `// Fixture-only correction for the owner's authoritative 5.2 clarification`) returns `INVALID_VALUE` for every argument failure: undeclared key, missing required argument, wrong type, enum miss, bounds, step, and length. `INVALID_ARGUMENTS` appears in the `ERROR_CODES` array at the top of the same file and is never returned by anything.
+
+The host is not at fault. `src/validation.js` validates arguments locally and raises `INVALID_VALUE` before the request leaves the browser, and `src/host.js` faithfully surfaces whatever code the exhibit returns. The host would handle `INVALID_ARGUMENTS` correctly if a fixture emitted it.
+
+The practical consequence is that the MVP has **no end-to-end evidence that a distinct `INVALID_ARGUMENTS` code survives the transport**, because no fixture can produce one. The report's coverage list claims "argument constraints" are covered — true — but does not claim `INVALID_ARGUMENTS` specifically, and it should not, because it is not.
+
+Note also that `planetarium/exhibit.js` *does* return `INVALID_ARGUMENTS` from `advanceTime()` for out-of-range minutes. That path is unreachable through the contract, because the fixture core rejects the value first with `INVALID_VALUE`. So the code exists in the fixture, is declared in the fixture's own error table, and is dead.
+
+**Recommendation:** either return `INVALID_ARGUMENTS` from the fixture core for argument-shape failures (keeping `INVALID_VALUE` for value-constraint failures), or record explicitly in `PROVENANCE.md` and the report that the fixture layer collapses the two codes and that `INVALID_ARGUMENTS` is therefore unverified end-to-end. Do not leave it implicit.
+
+### F-2 — Host: `validateCatalog` does not validate `restorable`, `category`, or `label`
+
+**Severity: low. Robustness, not correctness.**
+
+Contract §9 requires each descriptor to carry `restorable`, `category`, and `requires`, and the Authoring Guide (line 94) repeats the list. `validateCatalog` in `src/validation.js` checks `id`, `kind`, `readable`, `writable`, and `requires` — but not `restorable` or `category`. A descriptor missing either is accepted silently.
+
+This is defensible for an MVP: the host does not currently use `restorable` (no scenario restore yet) or `category` (no grouping in the UI), so an absent field cannot cause a wrong action. But the host is the component that will later drive scenario restore, and `restorable` is exactly the field that decides whether a target belongs in a restore snapshot. Validating it now is cheap and prevents a malformed descriptor from becoming a silent restore bug in Phase 4.
+
+**Recommendation:** add `typeof t.restorable === 'boolean'` and `typeof t.category === 'string'` to the descriptor check in `validateCatalog`. Low priority; safe to defer to the scenario milestone if the owner prefers.
+
+### F-3 — Documentation: the genericity claim is stated more strongly than the evidence supports
+
+**Severity: medium. This is a reporting-accuracy issue, and it is the one I would fix first.**
+
+`test-fixtures/reference-exhibits/README.md` states: "The point of the set is not the exhibits; it is that a single unmodified host drives all three." The host is indeed unmodified and generic — I verified that. But the fixtures are not unmodified, and the README is the upstream document that `PROVENANCE.md` explicitly says "describes upstream verification and is not evidence of NGN verification."
+
+The risk is concrete. A future reader — or a future agent — opening `test-fixtures/reference-exhibits/README.md` sees a claim of three unmodified exhibits driven by one host, and the README's own "Bugs found and fixed during verification" section lists the camelCase-ID bug as already fixed upstream. It is not fixed upstream. The upstream copy at `G:\.vibe\SciFi-XZBT\exhibit-test\abacusai\reference-exhibits` still has `sky.magnitudeLimit` in `read()` and `dispatch()`, still has `kind: 'telemetry'` in eight descriptors, and still has `args:` in Planetarium. Anyone who re-copies the fixtures from the stated source will reintroduce all three defects.
+
+`PROVENANCE.md` is accurate and does list the corrections. The problem is that the README sits in the same directory, is longer, is more confident, and contradicts it.
+
+**Recommendation:** add a short header note to the copied `test-fixtures/reference-exhibits/README.md` stating that this is a locally corrected copy, that the upstream README's verification claims do not apply to it, and pointing at `PROVENANCE.md`. One paragraph. Do not rewrite the upstream document.
+
+### F-4 — Evidence scope: the browser suite is not reproducible from the repository
+
+**Severity: low. Process, not product.**
+
+The report's strongest evidence is the real-browser suite: "3/3 real exhibits passed" via `/test-fixtures/host-verification.html`. I read `test-fixtures/host-verification.js` and it is a real, substantive test — it drives the production `ExhibitHost` and `postMessageTransport` against actual exhibit HTML in iframes, and it covers version rejection, handshake, catalog, state/range/selection writes, no-op revision, impulse execution, argument delivery, error survival, events, reconnect, forced session invalidation, and recovery. That is good evidence and I have no reason to doubt it.
+
+What I cannot do is reproduce it. It requires a human to run `npm start`, open a browser, and click a button. There is no recorded output, no timestamped artifact, and no way for a reviewer to confirm the 3/3 result without re-running it by hand. The report is honest about this ("This is local Chromium evidence, not a cross-browser certification"), which is the right hedge.
+
+**Recommendation:** none required for the MVP. If the owner wants the browser suite to carry weight in future reviews, have it write its result to a file or the console in a form that can be captured. Otherwise, treat the browser suite as supporting evidence and the 20 automated tests as the reproducible baseline — which is how the report already frames it.
+
+### F-5 — Observation: `stateRevision` is not reset on reconnect, and the host depends on that
+
+**Severity: informational. No action needed.**
+
+`shared/contract-core.js` deliberately preserves `stateRevision` across sessions while resetting `sequence`. The host's `refresh()` enforces `snapshot.stateRevision >= baseline` and rejects a regressed snapshot. The browser suite asserts `host.stateRevision >= revisionBeforeReconnect`.
+
+This is correct and matches the README's stated rationale. I flag it only because it is a cross-component invariant that is not written down in the contract itself — Contract §14 says `stateRevision` is monotonic, and §16.1 says `sequence` resets per session, but the contract does not explicitly say what happens to `stateRevision` on a new session. The fixture and host agree; a future exhibit that reset `stateRevision` on handshake would be rejected by the host with "Snapshot revision regressed." Worth a line in the contract's §14 or in the authoring guide.
+
+---
+
+## 4. Contract conformance assessment
+
+Against Contract §29 (Conformance Minimum), for the **host**:
+
+| Requirement | Status |
+| --- | --- |
+| Compatible handshake | Met. `host.js` sends `supportedContractMajors: [5]`, validates the negotiated major, rejects otherwise. |
+| Normalized message envelope | Met. Requests carry `xzbt`, `type`, `requestId`, `sessionId`; responses are matched by `requestId` and expected type. |
+| `describe` | Met. `refresh(true)` requests it, `validateCatalog` checks it, the UI renders from it. |
+| At least one discoverable target | Met. Catalog is built entirely from `describe`; no hard-coded IDs. |
+| `state.get` for readable persistent targets | Met. `refresh()` requests it and enforces that the snapshot contains exactly the readable non-impulse set — both directions. |
+| `stateRevision` | Met. Tracked, displayed, gap-detected, and protected against regression. |
+| Normalized responses | Met. `responses` map enforces the expected result type per request. |
+| Normalized event emission | Met. All six base event types accepted; unknown types rejected. |
+| Session event sequencing | Met. Monotonic check, gap detection, resync, and per-session reset. |
+| Command validation | Met. `validateSet` and `validateArgs` run before the request is sent. |
+| Version reporting | Met. Contract, exhibit identity, registry revision, and state revision are all displayed. |
+
+The host meets the conformance minimum. The two gaps I found (F-1, F-2) are in the fixture layer and in descriptor-field strictness respectively; neither is a conformance-minimum failure.
+
+Against the Implementation Plan §36 (MVP Acceptance Criteria), all twelve criteria are met, including the last one — "no SciFi-specific target vocabulary is hard-coded into core NGN logic" — which I verified directly rather than taking on trust.
+
+---
+
+## 5. Assessment of the report itself
+
+The report is unusually good on the dimension that usually fails: **it separates what was verified from what was not.** Section N names the browser and date and calls the result local evidence. Section Q lists limitations without prompting. Section M states plainly that the fixtures needed repairs. Section O distinguishes the automated suite from the browser suite. The verdict is hedged rather than triumphant.
+
+Three places where it is weaker than it should be:
+
+1. **Section M's framing.** "The copied fixtures needed narrow documented corrections" is accurate but understates the consequence. Those corrections are what makes the genericity demonstration possible. The report should say that the MVP proves the host drives three *repaired* exhibits, and that the repairs are the reason the demonstration works.
+
+2. **Section O's coverage list.** It claims "argument constraints" are covered, which is true, but a reader will reasonably infer that `INVALID_ARGUMENTS` is among them. It is not, and cannot be, given F-1.
+
+3. **Section M's table.** "Aquarium 12 / Planetarium 14 / Haunted House 13" matches the descriptors I counted. Good. But the report does not note that the upstream README claims the same counts while the upstream fixtures would fail to load at all under a strict catalog validator — the camelCase IDs would throw at `Catalog` construction. That is worth one sentence, because it is the clearest evidence that the repairs were necessary rather than cosmetic.
+
+None of these change the verdict. They change how much weight a future reader should put on it.
+
+---
+
+## 6. Recommended follow-up, in priority order
+
+1. **Add the copy-notice header to `test-fixtures/reference-exhibits/README.md`** (F-3). One paragraph. Highest value per unit of effort, because it prevents a future re-copy from silently reintroducing three defects.
+2. **Resolve `INVALID_ARGUMENTS`** (F-1) — either implement it in the fixture core or document the collapse explicitly in `PROVENANCE.md`.
+3. **Tighten `validateCatalog`** (F-2) to check `restorable` and `category`. Deferrable to the scenario milestone.
+4. **Correct the three report framings** in §5 above.
+5. **Optional:** note the `stateRevision`-across-sessions invariant in the contract or authoring guide (F-5).
+
+---
+
+## 7. Bottom line
+
+The MVP does what it claims. The host is genuinely generic, the transport genuinely validates origin and source, the state machine genuinely distinguishes revision from sequence, and the recovery paths are genuinely exercised. The fixture repairs were necessary, correctly diagnosed, and minimal — the magnitude repair in particular fixed a real null-read and a real dead native write path, not a cosmetic naming mismatch.
+
+The one thing I would not let stand is the impression that three pristine exhibits were driven by an unmodified host. Three *repaired* exhibits were driven by an unmodified host, and the repairs are load-bearing. That is a fine result for an MVP — it is arguably the more useful result, because it found real bugs in the reference set. It just needs to be said plainly.
diff --git a/STEP4-MVP-REPORT.md b/STEP4-MVP-REPORT.md
new file mode 100644
index 0000000..7794397
--- /dev/null
+++ b/STEP4-MVP-REPORT.md
@@ -0,0 +1,145 @@
+# A. Executive result
+
+The first Step 4 host is implemented and verified against three unrelated reference exhibits using the same production host, transport, and descriptor-driven UI. Twenty automated tests pass. The repeatable real-browser suite passes for all three fixtures.
+
+The owner resolved the initial argument-schema blocker as an authoritative Contract 5.2 clarification. It is now recorded in contract section 9.5. Work stops at the first MVP; no higher-level NGN features were added.
+
+# B. Repository / stack overview
+
+Stack: Node.js 22+ standard library, browser-native JavaScript modules, HTML/CSS, and Node's built-in test runner. There are no npm dependencies or build step. The NGN implementation plan established local hosting and discovery but did not prescribe a framework; this stack meets that scope with minimal tooling.
+
+Run `npm start`, then open `http://127.0.0.1:4173`. The server binds to loopback and serves only `public/`, `src/`, and `test-fixtures/`. An alternate port can be selected through `PORT`.
+
+The existing Labyricorn publishing template remains present. Its exhibition metadata is still template content; publishing it is outside this implementation milestone.
+
+# C. Transport implementation
+
+`src/transport/post-message.js` implements a replaceable send/subscribe/close transport. Only this module knows about Window message events. Incoming traffic must match both the expected frame window and the host's origin; outgoing traffic uses an explicit target origin.
+
+The first milestone loads same-origin HTML exhibits in an iframe. No remote exhibit connector or external filesystem mount is implemented.
+
+# D. Session handling
+
+The host sends hello with supported major 5 and records the actual exhibit-issued session, negotiated major/minor, and product/version/build metadata. Requests have unique correlation IDs, expected response types, and timeouts.
+
+Reconnect cancels old requests, creates a new session, clears session event tracking, and reloads discovery/state. Old-session events are ignored. INVALID_SESSION makes controls unavailable until reconnect. A real-browser test replaces the session at the exhibit, verifies INVALID_SESSION, and reconnects successfully.
+
+# E. Discovery / describe handling
+
+The host validates describe metadata, revisions, target IDs, kinds, capabilities, and kind-specific control metadata. The catalog has no hard-coded target IDs or count. Metadata, raw descriptors, capability snapshots, and registryRevision are visible.
+
+StateRevision is displayed from accepted snapshots and persistent-state events rather than treating the description alone as a complete state snapshot.
+
+# F. Dynamic target rendering
+
+The UI renders numeric inputs from range bounds/step, boolean state checkboxes, supported scalar state inputs, selection options, and impulse buttons. Read-only targets have no write controls. Descriptor text is rendered with textContent.
+
+Impulse arguments follow the owner's array-based `arguments` clarification. Supported types are string, number, integer, and boolean; enum, bounds, step, and length constraints are validated. Optional arguments have explicit include controls. Missing arguments metadata is treated as empty. Unsupported future schemas leave the impulse visible with an explanation and disabled invocation.
+
+# G. State handling
+
+The local Map is a cache of reported exhibit values. It is populated by state.result and updated by state.changed / selection.changed. No requested value is optimistically fabricated.
+
+Snapshots and events validate known value types and constraints. Invalid snapshots preserve the prior cache and mark synchronization uncertain. Regressive snapshots cannot roll back an observed revision. Events received while a snapshot is pending are replayed after that snapshot to avoid dropping intervening changes.
+
+Sequence/revision gaps and malformed events schedule fresh state.get. Multiple target events sharing a transaction revision are accepted. Successful operations refresh state to include other reported consequences. A manual refresh is also available.
+
+# H. Set / invoke behavior
+
+Set requires a writable persistent descriptor. Invoke requires kind impulse regardless of the descriptor's writable flag. Bounds, step, types, selection membership, required arguments, unknown keys, and declared constraints are checked without clamping.
+
+Successful responses do not substitute for exhibit state reports. Protocol errors are displayed. Timed-out operations are treated as uncertain and trigger state resynchronization.
+
+# I. Event handling
+
+All six base event types are accepted: state.changed, selection.changed, action.executed, capability.changed, registry.changed, and error. The event log exposes the complete received envelope, including source/correlation when present.
+
+Event sequence is tracked separately from persistent-state revision. Gaps, duplicates, and out-of-order sequences are diagnosed; older events are not applied. The first observed sequence establishes the new session's baseline. No missing events are invented.
+
+# J. Capability handling
+
+Capability IDs and lifecycle states are displayed exactly as discovered. Unsupported/error requirements disable operations while leaving targets visible. Available/loading states do not universally prove usability, so attempts may receive the exhibit's own unavailability error; this preserves the contract's allowance for fallback behavior.
+
+Because Contract 5.2 does not standardize capability.changed payload fields, the host uses that event to request a fresh describe snapshot instead of interpreting a fixture-specific payload shape.
+
+Real-browser verification observed Haunted House audio as available, a rejected host action, native loading/ready events, a refreshed ready display, and a successful later host action.
+
+# K. Registry-change handling
+
+registry.changed schedules describe plus state.get through a 100 ms debounce. Normal state changes do not rediscover the catalog. Automated tests verify burst coalescing and replacement of targets/cache entries.
+
+The supplied reference exhibits have fixed registries. Dynamic registry changes were tested with a synthetic peer, not claimed as a naturally occurring reference-exhibit browser scenario.
+
+# L. Error handling
+
+The visible protocol console records stable codes and explanatory messages. Covered failures include INVALID_SESSION, INVALID_VALUE, CAPABILITY_UNAVAILABLE, UNSUPPORTED_VERSION, malformed responses/state, timeout, and unsupported schemas.
+
+Local input checks and exhibit-returned rejections are separately exercised. Browser tests bypass local value validation for selected requests to prove that real exhibit errors survive the transport.
+
+# M. Genericity verification
+
+The owner selected the AbacusAI reference set from `G:/.vibe/SciFi-XZBT/exhibit-test/abacusai/reference-exhibits/`. Copies reside under `test-fixtures/reference-exhibits/`; no source repository was modified and no SciFi application source was used to infer host architecture.
+
+| Fixture | Discovered targets | Result |
+| --- | ---: | --- |
+| Aquarium | 12 | Browser suite and admin UI operations passed |
+| Planetarium | 14 | Browser suite and numeric argument operation passed |
+| Haunted House | 13 | Browser suite and live capability lifecycle passed |
+
+Product source checks found no aquarium/planetarium/haunted-house/SciFi names or domain target prefixes in `src/`, `public/`, or `server/`. Exhibit selection in the repeatable test harness is test configuration only.
+
+The copied fixtures needed narrow documented corrections: undeclared telemetry kinds became read-only state descriptors; Planetarium's args metadata became arguments; fixture invocation gained clarified argument validation; and Planetarium's stale magnitude ID was aligned with its canonical descriptor. These are local fixture repairs, not host-specific exceptions. See `test-fixtures/PROVENANCE.md`.
+
+# N. Browser/runtime verification
+
+Verified on 2026-09-14 in the Codex in-app Chromium browser with the local Node server.
+
+The repeatable page `/test-fixtures/host-verification.html` reported 3/3 real exhibits passed. It imports the production host and transport and loads actual exhibit HTML, scripts, state, and behavior in iframes. Checks include incompatible major rejection, handshake, catalog, state, representative state/range/selection writes, no-op revision, impulse execution, delivered arguments where declared, error responses, events, reconnect, forced session invalidation, and recovery.
+
+Separate admin UI checks verified:
+- Aquarium population 12 to 16, paused false to true, species to Blue Tang, feed invocation, rejected population 41, native species change to Guppy, native resume event, and reconnect with preserved revision.
+- Planetarium discovered optional minutes control delivering 30 and azimuth write to 0.75.
+- Haunted House unavailable audio action before a gesture, native capability initialization, ready display, and subsequent host invocation.
+
+A screenshot inspection confirmed readable control layout. The browser error/warning log was empty during the checked Haunted House run. This is local Chromium evidence, not a cross-browser certification.
+
+# O. Tests
+
+`npm test`: 20 tests passed, zero failures.
+
+Repository checks: `python devlog_editor.py --validate` reported "devlog is valid";
+`git diff --check` passed for tracked changes. New application files remain
+untracked until the owner requests staging/commit.
+
+Coverage includes negotiation/correlation, all persistent kinds, argument constraints, no-op revisions, exhibit errors, invalid sessions, unsupported version, timeouts, native-style events, shared revisions, gap detection/resync, debounced rediscovery, snapshot/event races, stale sessions, malformed events, origin/source checks, server boundaries, invalid reported values, regressive snapshots, dynamic catalog replacement, future argument types, and capability gating.
+
+The real-browser suite passed 3/3 actual exhibits and supplements these synthetic unit cases. It is run through the documented browser page rather than npm test. No success claim relies on mocks alone.
+
+# P. Files created/changed
+
+- Added `package.json`.
+- Added `server/serve.js`.
+- Added `public/index.html` and `public/style.css`.
+- Added `src/host.js`, `src/validation.js`, `src/ui.js`, and `src/transport/post-message.js`.
+- Added `tests/host.test.js`.
+- Added three reference fixture directories and shared dependencies, upstream README, local provenance, and the browser verification HTML/JS.
+- Added the owner-authorized section 9.5 clarification to the supplied Contract 5.2 document.
+- Updated README with run/test instructions and this report with implementation evidence.
+
+Pre-existing AGENTS.md edits and copied architectural/reference documents were preserved. No commit, push, external exhibit edit, or publishing-record edit was performed.
+
+# Q. Known limitations
+
+This is an engineering MVP: one trusted same-origin exhibit, in-memory state/logs, no saved connection, and no external mount configuration. The UI uses numeric inputs rather than separate range sliders. Draft fields are distinct from reported values and reset on rediscovery.
+
+The fixture copies have compatibility repairs. Their continuously changing derived readings are refreshed from snapshots; this milestone does not certify the upstream exhibits' complete event/revision conformance. SciFi-XZBT and other browsers were not tested.
+
+Reference fixtures do not naturally exercise changing registries or every future argument type; those host paths have focused synthetic tests. No timed idle snapshot polling is enabled; recovery is driven by messages, request timeouts, operations, reconnect, and explicit refresh.
+
+# R. Next Step 4 work
+
+The first MVP is finished. Await owner direction. Reasonable follow-up within Step 4 is independent review of fixture conformance and additional browser/exhibit interoperability testing. Publishing metadata can be authored separately once requested. MIDI, scenarios, webhooks, telemetry acquisition, packaging, and later phases remain deferred.
+
+# S. Final verdict
+
+STEP 4 MVP COMPLETE — MINOR FOLLOW-UP REQUIRED
diff --git a/docs/architecture/XZBT-NGN-Exhibit-Engine-Implementation-Plan-v5.2.md b/docs/architecture/XZBT-NGN-Exhibit-Engine-Implementation-Plan-v5.2.md
new file mode 100644
index 0000000..1101b59
--- /dev/null
+++ b/docs/architecture/XZBT-NGN-Exhibit-Engine-Implementation-Plan-v5.2.md
@@ -0,0 +1,855 @@
+# XZBT-NGN Exhibit Engine Implementation Plan
+## Version 5.2
+
+**Status:** Architectural implementation plan
+**Product:** XZBT-NGN
+**Product identity:** XZBT-NGN Exhibit Engine
+**Dependency:** XZBT Exhibit Contract 5.2
+**Current implementation state:** Not yet implemented
+
+---
+
+## 1. Purpose
+
+XZBT-NGN is the host, orchestration, integration, recording, authoring, administration, and packaging environment for XZBT-compatible exhibits.
+
+It does not replace the exhibit.
+
+It extends it.
+
+Version 5.2 assumes no legacy NGN implementation and treats the XZBT Exhibit Contract as the architectural dependency NGN must follow.
+
+---
+
+## 2. Product Boundary
+
+The intended ecosystem is:
+
+### Free standalone exhibit
+
+A complete independently usable experience.
+
+### Free packaged authored experience
+
+A self-running artifact exported by NGN that may contain scenario data and a compact runtime.
+
+### Premium XZBT-NGN Exhibit Engine
+
+Provides:
+
+- hosting;
+- orchestration;
+- recording;
+- scenario authoring;
+- webhooks;
+- telemetry connectors;
+- external automation;
+- administration;
+- packaging;
+- remote control;
+- integrations.
+
+The commercial value comes from added capability, not intentional degradation of free exhibits.
+
+---
+
+## 3. Primary Responsibilities
+
+NGN owns:
+
+- contract session management;
+- exhibit discovery;
+- capability cataloging;
+- state synchronization;
+- event monitoring;
+- scenario runtime;
+- scenario recording;
+- timeline authoring;
+- webhook endpoints;
+- connector framework;
+- telemetry mapping;
+- automation;
+- administration;
+- diagnostics;
+- packaging;
+- distribution support;
+- future multi-exhibit orchestration.
+
+---
+
+## 4. Contract-First Rule
+
+NGN MUST interact with exhibits through the XZBT Exhibit Contract target surface.
+
+Direct exhibit-specific escape hatches are development diagnostics, not architecture.
+
+NGN MUST dynamically discover targets.
+
+Core NGN MUST NOT hard-code SciFi-XZBT target vocabulary.
+
+---
+
+## 5. Initial MVP
+
+The first useful NGN proves only the contract boundary.
+
+It should:
+
+1. host or attach to one SciFi-XZBT instance;
+2. complete contract handshake;
+3. request `describe`;
+4. render discovered capabilities and targets;
+5. request state;
+6. invoke and set selected targets;
+7. receive and display events;
+8. display sequence and state revision;
+9. detect and recover synchronization loss;
+10. log errors.
+
+This milestone exists before scenario authoring, recording, webhooks, or connectors.
+
+---
+
+## 6. Exhibit Hosting
+
+NGN SHOULD be able to host compatible HTML exhibits from a stable local origin.
+
+Hosting enables:
+
+- same-origin communication;
+- controlled dependency delivery;
+- predictable asset paths;
+- future local Web3D hosting;
+- packaging support;
+- browser receiver support.
+
+Hosting is not required for the ordinary standalone artifact.
+
+---
+
+## 7. Session Management
+
+NGN maintains one logical contract session per connected exhibit instance.
+
+Track:
+
+- exhibit identity;
+- contract version;
+- session ID;
+- registry revision;
+- state revision;
+- expected event sequence;
+- capabilities;
+- target descriptors;
+- current state;
+- connection status.
+
+When a new session is established, reset expected event sequence and session-scoped correlation state.
+
+---
+
+## 8. Discovery Catalog
+
+NGN builds its control catalog from `describe`.
+
+Generic UI mapping:
+
+```text
+range -> slider / numeric control
+state -> state editor / toggle where appropriate
+selection -> dropdown / list
+impulse -> trigger control
+```
+
+`kind: impulse` is sufficient to infer invokability.
+
+Exhibit-provided labels may drive UI display.
+
+Core behavior does not depend on presentation hints.
+
+---
+
+## 9. Registry Revision Handling
+
+NGN records `registryRevision` from `describe`.
+
+On `registry.changed`, NGN MUST schedule a fresh `describe`.
+
+Use a short debounce so a burst of registry changes results in one rediscovery.
+
+Recommended initial debounce:
+
+```text
+100 ms
+```
+
+If the exhibit uses a fixed registry, ordinary context changes should not trigger rediscovery.
+
+---
+
+## 10. State Synchronization
+
+NGN keeps a local mirror of contract-visible persistent state.
+
+Synchronization strategy:
+
+1. request initial `state.get`;
+2. record `stateRevision`;
+3. apply normalized state/selection events;
+4. track event sequence;
+5. resynchronize on uncertainty.
+
+Resync triggers include:
+
+- event sequence gap;
+- state revision gap that cannot be explained by one received mutation transaction;
+- reconnect/new session;
+- explicit `registry.changed` requiring rediscovery;
+- configurable synchronization timeout;
+- parse/validation failure on a state event.
+
+On resync, request fresh `state.get`.
+
+---
+
+## 11. Feedback-Loop Prevention
+
+NGN respects authoritative event `source`.
+
+Default recorder sources:
+
+```text
+ui
+midi
+hotkey
+```
+
+Optional:
+
+```text
+host
+```
+
+Normally excluded:
+
+```text
+scenario
+internal
+system
+```
+
+Scenario playback does not record itself by default.
+
+Host-triggered integrations do not recursively retrigger identical integrations unless explicitly configured.
+
+---
+
+## 12. Scenario Model
+
+A scenario is an NGN-owned orchestration document.
+
+The ordinary exhibit does not need to understand the document.
+
+A scenario may include:
+
+- timed state changes;
+- impulses;
+- speech;
+- waits;
+- marks;
+- tracks;
+- ramps;
+- holds;
+- conditions;
+- repetition;
+- random variation;
+- external signals;
+- transitions;
+- metadata;
+- required capabilities;
+- cleanup policy.
+
+---
+
+## 13. Scenario Format
+
+Scenario schema is distinct from the XZBT Exhibit Contract.
+
+It references canonical contract target IDs.
+
+Example:
+
+```json
+{
+ "format": "xzbt-scenario",
+ "version": 1,
+ "requires": ["speech"],
+ "timeline": [
+ {
+ "at": 0,
+ "op": "set",
+ "target": "view.observation",
+ "value": true
+ },
+ {
+ "at": 2000,
+ "op": "invoke",
+ "target": "speech.say",
+ "args": {
+ "text": "Approaching docking perimeter."
+ }
+ }
+ ]
+}
+```
+
+Scenario data is inert data.
+
+It MUST NOT contain arbitrary executable JavaScript, CSS, selectors, or host-language expressions.
+
+The scenario format should eventually receive its own specification.
+
+---
+
+## 14. Scenario Validation
+
+Before playback, validate:
+
+- scenario schema;
+- target existence;
+- target kind;
+- writable/invokable semantics;
+- required capability state;
+- value ranges;
+- selection options;
+- optional exhibit/product compatibility constraints.
+
+Invalid operations should be surfaced before playback where possible.
+
+---
+
+## 15. Scenario Clock
+
+NGN owns the live scenario clock.
+
+Initial support:
+
+- millisecond timing;
+- pause/resume;
+- marks;
+- loops;
+- holds.
+
+Later support may include:
+
+- variable speed;
+- repeat regions;
+- musical timing;
+- advanced conditions.
+
+Browser-hosted exhibit timing is application-level timing, not sample-accurate show control.
+
+---
+
+## 16. State Capture and Cleanup
+
+Scenario playback may capture relevant initial state.
+
+Cleanup policy may:
+
+- leave final state;
+- restore captured state;
+- restore selected targets only.
+
+Impulse targets are never restorable.
+
+Persistent state targets are restorable by default unless the exhibit explicitly sets `restorable: false`.
+
+---
+
+## 17. Scenario Recorder
+
+NGN records normalized exhibit events using canonical target IDs.
+
+Default sources:
+
+```text
+ui
+midi
+hotkey
+```
+
+Optional:
+
+```text
+host
+```
+
+Normally excluded:
+
+```text
+scenario
+internal
+system
+```
+
+The recorder never stores internal SciFi-XZBT IDs.
+
+---
+
+## 18. Recorder Compression
+
+Continuous controls may generate many events.
+
+Later compression may include:
+
+- threshold reduction;
+- sample reduction;
+- ramp fitting;
+- minimum time interval.
+
+This is deferred optimization, not MVP behavior.
+
+---
+
+## 19. Timeline Authoring
+
+Future authoring may support:
+
+- tracks;
+- marks;
+- ranges;
+- impulses;
+- speech;
+- ramps;
+- holds;
+- loops;
+- conditions;
+- external triggers;
+- annotations;
+- metadata.
+
+The first editor can be much simpler.
+
+---
+
+## 20. Webhook Engine
+
+Webhooks terminate at NGN.
+
+NGN handles:
+
+- authentication;
+- authorization;
+- validation;
+- rate limiting;
+- routing;
+- logging;
+- translation.
+
+Webhook actions may target:
+
+- exhibit contract operations;
+- NGN scenario controls;
+- telemetry mappings;
+- connector commands.
+
+The standalone exhibit never needs an HTTP listener.
+
+---
+
+## 21. Connector Framework
+
+NGN may later provide connectors for:
+
+- Streamer.bot;
+- OBS-related systems;
+- REST;
+- MQTT;
+- serial devices;
+- MIDI;
+- simulators;
+- game servers;
+- home automation;
+- future MCP integrations.
+
+Connectors normalize external inputs into:
+
+- exhibit contract operations;
+- NGN scenario controls;
+- NGN telemetry updates.
+
+---
+
+## 22. Telemetry Mapping
+
+Structured telemetry is optional per exhibit.
+
+NGN MUST discover whether telemetry targets actually exist.
+
+It must not assume that SciFi-XZBT has fictional telemetry fields merely because the contract supports them.
+
+When writable telemetry exists, NGN may provide:
+
+- scaling;
+- unit conversion;
+- clamping;
+- smoothing;
+- update-rate control;
+- stale-data policy;
+- ownership lease renewal;
+- fallback to simulation.
+
+---
+
+## 23. Telemetry Staleness
+
+For external telemetry mappings, NGN SHOULD define stale-data behavior.
+
+Options may include:
+
+- keep last value;
+- mark stale;
+- release ownership;
+- return to exhibit simulation;
+- invoke fallback value.
+
+The mapping configuration should make this explicit.
+
+---
+
+## 24. Speech Integration
+
+NGN may send explicit text to an exhibit's `speech.say` target.
+
+Potential sources include:
+
+- scenario text;
+- webhook payload;
+- generated dialogue;
+- simulator data;
+- operator input.
+
+NGN does not reproduce the exhibit's speech engine.
+
+---
+
+## 25. Scenario Packaging
+
+NGN SHOULD eventually generate self-running packaged HTML experiences.
+
+Concept:
+
+```text
+Base Exhibit
+ +
+Scenario Data
+ +
+Compact Scenario Runtime
+ +
+Required Embedded Configuration
+ |
+ v
+Packaged HTML
+```
+
+The packaged artifact may run without NGN.
+
+---
+
+## 26. Shared Scenario Runtime Core
+
+Live and packaged playback MUST share one scenario-runtime core.
+
+The runtime MUST drive the exhibit through the same canonical target surface used in live NGN operation.
+
+It MUST NOT bypass contract semantics by calling arbitrary exhibit internals.
+
+The runtime transport is pluggable:
+
+```text
+live NGN -> host transport / postMessage
+packaged HTML -> direct in-process contract adapter
+```
+
+This keeps scenario semantics aligned while avoiding unnecessary host-bridge overhead inside a packaged artifact.
+
+---
+
+## 27. Compact Packaged Runtime
+
+The exported runtime includes only execution functionality needed by the package.
+
+It may include:
+
+- scenario clock;
+- set/invoke dispatch;
+- waits;
+- marks;
+- loops;
+- required conditions;
+- cleanup logic.
+
+It does not include:
+
+- authoring UI;
+- recorder;
+- webhook server;
+- connector framework;
+- NGN administration;
+- multi-exhibit management.
+
+---
+
+## 28. Packaged Exhibit Contract Access
+
+A packaged exhibit retains the same canonical exhibit target surface.
+
+The embedded runtime is a local consumer of that surface.
+
+External host attachment may remain available if the base exhibit supports it, but is not required for packaged scenario execution.
+
+---
+
+## 29. Package Size Discipline
+
+The compact scenario runtime should remain small relative to the exhibit.
+
+Packaging SHOULD report:
+
+- base exhibit size;
+- runtime size;
+- scenario-data size;
+- final package size.
+
+No hard numeric limit is imposed in v5.2, but the runtime SHOULD NOT approach the size of the base exhibit unless a future capability explicitly justifies it.
+
+---
+
+## 30. Administration
+
+Future NGN administration may include:
+
+- installed exhibits;
+- running exhibits;
+- session health;
+- discovered capabilities;
+- target catalog;
+- current state;
+- action testing;
+- scenarios;
+- webhook configuration;
+- connectors;
+- telemetry mappings;
+- logs;
+- packaging tools.
+
+---
+
+## 31. Security
+
+Because NGN may expose local or network services, it owns the heavier trust boundary.
+
+Eventually address:
+
+- authentication;
+- authorization;
+- webhook tokens;
+- origin restrictions;
+- input validation;
+- rate limiting;
+- connector permissions;
+- bind-address defaults;
+- TLS guidance;
+- secret storage;
+- audit logging;
+- least privilege.
+
+The exhibit still validates every contract message.
+
+---
+
+## 32. Network Defaults
+
+NGN SHOULD default to safe local binding.
+
+Network exposure must be explicit.
+
+A premium feature must not accidentally become an unauthenticated LAN or Internet control surface.
+
+---
+
+## 33. Logging and Diagnostics
+
+NGN should maintain structured logs for:
+
+- session connection;
+- version negotiation;
+- describe results;
+- registry changes;
+- state resync;
+- scenario actions;
+- webhook requests;
+- connector errors;
+- capability failures;
+- packaging operations.
+
+Logging should support diagnosis without requiring browser developer tools.
+
+---
+
+## 34. Version Compatibility
+
+Track separately:
+
+- NGN application version;
+- XZBT contract version;
+- exhibit product version;
+- scenario format version;
+- registry revision;
+- state revision.
+
+These are distinct concepts.
+
+---
+
+## 35. Implementation Phases
+
+### Phase 0 - Contract harness
+
+Build a small synthetic exhibit and host test harness for XZBT Contract 5.2.
+
+### Phase 1 - Single exhibit host
+
+Host SciFi-XZBT and establish a session.
+
+### Phase 2 - Dynamic discovery UI
+
+Render capabilities, targets, state, and events.
+
+### Phase 3 - State synchronization
+
+Implement revision and sequence tracking plus resync behavior.
+
+### Phase 4 - Minimal scenario runtime
+
+Support timed `set` / `invoke` operations and cleanup.
+
+### Phase 5 - Recorder
+
+Capture canonical operator events.
+
+### Phase 6 - Scenario persistence and basic editing
+
+Save/load scenario documents and provide basic editing.
+
+### Phase 7 - Webhooks
+
+Add authenticated external triggers.
+
+### Phase 8 - Telemetry connector framework
+
+Only after at least one exhibit exposes structured writable telemetry.
+
+### Phase 9 - Integration adapters
+
+Add Streamer.bot and other useful adapters.
+
+### Phase 10 - Packaging
+
+Export self-running HTML using the shared runtime core.
+
+### Phase 11 - Advanced authoring
+
+Add richer timeline features, ramps, conditions, musical timing, and repeat regions.
+
+### Phase 12 - Multi-exhibit engine
+
+Add orchestration across multiple sessions when justified.
+
+---
+
+## 36. MVP Acceptance Criteria
+
+NGN MVP succeeds when:
+
+- one SciFi-XZBT exhibit can be hosted or attached;
+- contract handshake succeeds;
+- `describe` renders dynamically;
+- current state is displayed;
+- representative state can be changed;
+- representative impulses can be triggered;
+- events are displayed with source and sequence;
+- state revision changes are tracked;
+- reconnect resets sequence expectations;
+- lost synchronization triggers fresh `state.get`;
+- registry changes trigger debounced rediscovery;
+- no SciFi-specific target vocabulary is hard-coded into core NGN logic.
+
+---
+
+## 37. Scenario Milestone Acceptance Criteria
+
+The first scenario milestone succeeds when:
+
+- a simple scenario document loads;
+- target references validate against `describe`;
+- timed set/invoke operations execute;
+- scenario-origin events do not recursively record themselves;
+- initial-state restore works for restorable targets;
+- abort cleanup is predictable;
+- the same runtime core can be used by the packager.
+
+---
+
+## 38. Packaging Milestone Acceptance Criteria
+
+Packaging succeeds when:
+
+- a base exhibit and scenario combine into one distributable HTML;
+- the package runs without NGN;
+- the scenario runtime drives the canonical target surface;
+- no authoring/admin/webhook stack is included;
+- live and packaged runtime behavior matches for supported operations;
+- package size is reported;
+- the ordinary exhibit remains independently usable outside the package process.
+
+---
+
+## 39. Deferred Features
+
+Intentionally deferred:
+
+- musical timing;
+- complex condition language;
+- multi-exhibit synchronization;
+- distributed receivers;
+- cloud orchestration;
+- advanced telemetry transforms;
+- generic plugin marketplace;
+- arbitrary scripting;
+- sample-accurate show control.
+
+---
+
+## 40. Architectural Rule
+
+Use the same test for every proposed feature.
+
+**Does the exhibit need it to remain a complete standalone exhibit?**
+
+Then it may belong in the exhibit.
+
+**Does it coordinate, automate, connect, record, author, distribute, or externally control exhibits?**
+
+Then it belongs primarily in XZBT-NGN.
+
+---
+
+## 41. Version 5.2 Summary
+
+Version 5.2 preserves the Version 5 architecture and hardens the implementation boundary.
+
+The first responsibility of NGN remains proving reliable discovery, observation, synchronization, and control.
+
+Scenario authoring, webhooks, telemetry, integrations, and packaging all build on that verified contract surface.
diff --git a/docs/authoring/XZBT-Exhibit-Authoring-Guide-v5.2-Step3.7.md b/docs/authoring/XZBT-Exhibit-Authoring-Guide-v5.2-Step3.7.md
new file mode 100644
index 0000000..a1ed9b6
--- /dev/null
+++ b/docs/authoring/XZBT-Exhibit-Authoring-Guide-v5.2-Step3.7.md
@@ -0,0 +1,577 @@
+# XZBT Exhibit Authoring Guide
+
+## Version 5.2
+
+**Status:** Authoring guidance (non-normative except where explicitly marked)
+**Companion to:** XZBT Exhibit Contract Specification 5.2
+**Audience:** developers and coding agents building a new XZBT-compatible exhibit
+
+---
+
+## A. Purpose and Audience
+
+This guide explains how to structure a new, standalone exhibit so that it correctly implements the XZBT Exhibit Contract from the start, without absorbing responsibilities that belong to the XZBT-NGN Exhibit Engine.
+
+It is written for two kinds of reader: a human developer building an exhibit by hand, and a coding agent implementing one from a specification. Both should be able to follow it without prior exposure to any specific existing exhibit.
+
+Two documents work together and answer different questions:
+
+- The **XZBT Exhibit Contract Specification** defines the external behavior a conforming exhibit MUST, SHOULD, or MAY exhibit at the message and semantics level: the handshake, the message envelope, target kinds, revisions, events, capabilities, and error codes.
+- This **Authoring Guide** explains a recommended internal implementation structure that makes satisfying that contract straightforward, consistent, and maintainable, and calls out the mistakes that make it hard.
+
+Where this guide uses MUST, MUST NOT, SHOULD, or SHOULD NOT for something that is not already a contract requirement, that is an authoring recommendation, not a new contract rule. Every such case is called out explicitly. Nothing in this guide expands, narrows, or reinterprets Contract 5.2.
+
+An example exhibit, SciFi-XZBT, is referenced throughout for concreteness. Nothing here is specific to that exhibit's science-fiction subject matter, and none of its vocabulary should be treated as universal. A train exhibit, an aviation exhibit, a castle exhibit, and any future XZBT exhibit are equally valid, and this guide is written so that none of them inherit assumptions that only make sense for a starship bridge.
+
+---
+
+## B. Core Design Principle: Standalone First
+
+**CONTRACT REQUIREMENT.** Contract 5.2 §2 states that an XZBT-compatible exhibit MUST remain operable without XZBT-NGN, and that connection to XZBT-NGN is additive.
+
+This is the single governing constraint on everything else in this guide. In practice it means an exhibit:
+
+- MUST run meaningfully with no host attached at all — full native UI, full native behavior, full native content.
+- MUST NOT require a handshake, a session, or any host message to initialize, animate, play audio, or respond to local input.
+- MUST NOT require network access, a local server, or any orchestration layer for ordinary operation.
+- MUST preserve its native UI's own behavior and feel regardless of whether a host is attached.
+- MUST remain a complete, self-contained artifact a person can open and use with nothing else running.
+
+**AUTHORING RECOMMENDATION.** The contract layer should be dormant, not merely tolerant, when no host is present. Concretely: the exhibit boots, and only when (and if) a host later sends `hello` does any session-related code path activate. A useful test during development is to delete every host-detection code path and confirm the double-clicked file behaves identically. If a future host connects, its presence should feel like an enhancement layered on top of a complete experience, never like flipping the exhibit into a different mode of existence.
+
+---
+
+## C. Recommended Internal Architecture
+
+**AUTHORING RECOMMENDATION.** No specific framework, build system, or language is required. Plain HTML, CSS, and JavaScript with no framework at all remains completely valid, and a conforming exhibit may equally be built with a modern framework, a game engine's UI layer, or a native application shell rendering through the same message-passing conventions where applicable. What matters is the separation of concerns below, not the tooling used to express it.
+
+A recommended internal separation, roughly in dependency order (each layer may call the ones above it, and generally should not be called by them):
+
+1. **Exhibit state/model.** The actual data that constitutes the exhibit's condition: is the transport running, what is the master volume, which scene or theme is selected, what alert state is active. This is the source of truth `state.get` eventually reads from.
+2. **Exhibit services/behavior.** The logic that changes the model in response to something happening: starting playback, applying a scene, running the simulation loop, synthesizing a sound. This layer owns the actual mutation of state 1 and should not care who or what asked for the mutation.
+3. **Native UI.** Buttons, sliders, dropdowns, canvases, whatever the exhibit's own screen presents. This layer calls into layer 2 and renders the current state from layer 1. It has no privileged access to contract internals and no special powers the contract adapter lacks.
+4. **Canonical control layer.** One set of functions — the mutation/invoke chokepoint described in section H — that every input source (UI, hotkeys, host) calls to change state or trigger an action. This is the layer that makes source attribution, transaction semantics, and idempotency actually enforceable, because everything funnels through it.
+5. **Contract adapter.** The layer that knows about XZBT vocabulary: canonical target IDs, descriptors, `describe`, `state.get`, event formatting, capability reporting. It translates between the canonical control layer's internal calls and the wire-level contract semantics. It does not itself contain exhibit behavior.
+6. **Optional host transport.** The thin layer that moves contract messages across whatever channel is in use (same-origin `postMessage`, a wrapper binding, a local IPC channel). It has no knowledge of exhibit semantics; it only frames, validates origin, and forwards.
+
+The dependency direction that matters most: layers 1–3 must work with layers 4–6 entirely absent. Layer 6 must be swappable without touching layers 1–4. A helpful gut check while designing any new piece of functionality is to ask which of these six things it is, and to resist the temptation to let a contract concern (layer 5–6) leak down into exhibit behavior (layer 1–3), or an exhibit behavior concern leak up into the adapter.
+
+---
+
+## D. Canonical Target Registry
+
+**CONTRACT REQUIREMENT.** Target IDs use a canonical dotted namespace (Contract §8): lowercase ASCII letters, digits, hyphens and dots, beginning with a lowercase letter, at least one dot-separated segment boundary, no whitespace, no empty segments, and never interpreted as a JavaScript property path.
+
+**AUTHORING RECOMMENDATION.** Design the registry as one stable, deliberately curated public catalog: canonical dotted IDs, each with a declared kind (state, range, selection, or impulse), current value where applicable, and a clear category. This catalog is what a generic host discovers through `describe`; it should describe the exhibit's meaning, not its markup.
+
+Target IDs should name what a thing *means* to an operator, not where it lives in the DOM or how it happens to be wired internally.
+
+Good examples:
+
+```text
+transport.playing
+environment.intensity
+mode.selected
+event.pulse
+```
+
+Bad examples:
+
+```text
+button-14
+slider-left
+panelB.knob2
+```
+
+The difference is not cosmetic. A meaningful ID survives a UI redesign, a relayout, or a rewrite of the exhibit's internals, because it describes a concept the exhibit owns rather than a widget the exhibit happens to render today. `button-14` tells a host nothing about what will happen if it is invoked and becomes meaningless the day the button moves.
+
+**AUTHORING RECOMMENDATION.** Adopt a fixed catalog rather than a catalog that changes shape as the exhibit's internal context changes. A target that only makes sense in one mode, theme, or scene should still generally remain in the catalog and simply report `CAPABILITY_UNAVAILABLE` (or an equivalent contextual error) when invoked outside that context, rather than disappearing and reappearing. Registry churn driven by ordinary context changes forces every attached host to rediscover the whole catalog constantly, which is expensive and provides no real benefit — the fixed-catalog approach lets a host build its control surface once and treat temporary unavailability as a normal, expected state rather than a structural surprise. See Section N for the discoverable-versus-invokable distinction this depends on.
+
+---
+
+## E. Target Descriptor Design
+
+**CONTRACT REQUIREMENT.** Each target returned by `describe` must include enough metadata for a generic host to inspect and operate it (Contract §9): `id`, `kind`, `readable`, `writable`, `restorable`, `category`, `requires`, and kind-specific fields — `min`/`max`/`step` for range, `valueType` for state, `options` for selection. Where a target accepts structured arguments, those arguments must be validated against a declared schema (Contract §11).
+
+**CONTRACT REQUIREMENT.** Descriptors must reflect real behavior. The contract must not advertise fake actions, unsupported values, or nonexistent capabilities. A published target that can never successfully execute in any context, for reasons no declared capability explains, is a describe-accuracy defect, not a permissible "discoverable but unavailable" case — that allowance exists specifically for context-gated unavailability tied to a real, declared capability (Contract §9, §17).
+
+**AUTHORING RECOMMENDATION — the lesson from Step 3.** During SciFi-XZBT's Step 3 verification, several range descriptors advertised a wider span than the exhibit's own native slider could select, and in one case a native slider could select a value the descriptor explicitly forbade. The forbidden-but-selectable case is the dangerous direction: it lets a normal, local, unremarkable action (dragging a slider) produce a value the exhibit has told every host is impossible, which the canonical mutation path then has to silently reject — the thumb moves, nothing else does, and the operator gets no explanation.
+
+The general rule this produced: **the native UI and the public contract range for the same underlying value should represent one coherent operator-facing range.** If the exhibit's own control can only usefully reach a narrower or coarser range than the descriptor claims, that is usually fine and often desirable — the descriptor can legitimately describe more of the backing capability than any one local widget exposes. If the native control can reach *further* or *finer* than the descriptor claims, that is a real defect regardless of how it happened, because a local, ordinary interaction is now able to produce a contract-invalid state. Author descriptors and native controls together, and treat "control broader than contract" as a bug class to check for explicitly during development, not just at final review.
+
+---
+
+## F. State Design
+
+**CONTRACT REQUIREMENT.** Persistent state belongs in `state.get`; impulses do not appear in a restorable snapshot (Contract §10, §13). A state snapshot must not imply that every internal exhibit variable is externally exposed — only readable persistent targets belong in `values`.
+
+**AUTHORING RECOMMENDATION.** Snapshots should contain stable, externally meaningful state: the things an operator or an authored scenario would reasonably want to read back or restore. Do not build a parallel shadow model purely to satisfy the contract's shape. If the exhibit's real internal state cannot yet answer a question the contract needs answered (for example, "is Observation mode currently on"), add a real readable getter to the exhibit's own model rather than fabricating a separate value the adapter tracks independently. A contract adapter that keeps its own belief about exhibit state, disconnected from the exhibit's actual state, will eventually disagree with it.
+
+**CONTRACT REQUIREMENT.** No-op writes must not produce a new `stateRevision` (Contract §12, §14.3).
+
+**CONTRACT REQUIREMENT — transaction semantics.** One top-level contract-visible mutation is one mutation transaction. All contract-visible state changes committed by that transaction share one resulting `stateRevision`; the exhibit computes and commits the transaction, increments `stateRevision` exactly once if anything externally visible changed, and only then emits the resulting `state.changed`/`selection.changed` events carrying that revision (Contract §14).
+
+**AUTHORING RECOMMENDATION.** When a single operation changes several values coherently — selecting a scene that also selects that scene's default preset is the canonical example — commit all of them together, increment the revision once, and emit every changed target's event carrying that same shared revision. Treat "how many logically-connected values changed as one gesture" as the boundary of a transaction, not "how many individual setter functions happened to run."
+
+---
+
+## G. Absolute Setters and Idempotency
+
+**AUTHORING RECOMMENDATION**, reflecting how Contract 5.2's state model is meant to be exercised (Contract §12 defines `set` as changing writable persistent state to a given value, not toggling it): public setters for persistent state should be absolute and idempotent. Setting a value to its current value twice should leave the exhibit in exactly the same state, with no second state change and no second revision.
+
+Good:
+
+```text
+set mute = true
+set mode = "night"
+set intensity = 0.5
+```
+
+Bad:
+
+```text
+toggle mute
+click mute button
+move slider +10%
+```
+
+An exhibit's native UI is free to use toggle buttons or relative gestures internally — that is a legitimate presentation choice, and native UI is exhibit-owned. But the canonical contract path for the same underlying state must convert any such relative or toggle-shaped local action into an absolute value before it reaches the canonical mutation service. A host, a scenario, or another integration needs to be able to say "make this true" without knowing or caring what the current state was, and a relative-only setter makes that impossible to do reliably.
+
+---
+
+## H. Canonical Mutation / Invoke Path
+
+**AUTHORING RECOMMENDATION**, and the single most load-bearing pattern in this guide: route every externally visible state change and every externally visible action through one canonical internal path.
+
+Host control, native UI, hotkeys, and any other local control surface should converge on the same underlying services wherever practically possible. Concretely, something shaped like:
+
+```text
+applyMutation(targetId, value, source, context)
+invokeAction(targetId, args, source, context)
+```
+
+should be the only way contract-visible state changes or actions actually happen, regardless of what triggered them.
+
+**AUTHORING RECOMMENDATION — avoid DOM-click proxying.** Do not use synthetic DOM events (dispatching a fake `click`, `input`, or `change` event to make something happen) as the canonical mechanism for state mutation. This pattern shows up naturally when a hotkey handler is implemented as "find the button that does this and click it programmatically" — it is easy to write and easy to get wrong. It breaks source attribution (the event now looks identical to a real user click, because the code path *is* the click handler), it can double-apply if both the synthetic dispatch and a canonical call happen, and it makes the actual mutation logic impossible to test or reason about independently of the DOM. This was, concretely, the root cause of a real hotkey source-attribution defect in SciFi-XZBT: two keyboard shortcuts synthesized a button click to trigger their action, which meant the resulting event reported `source=ui` no matter how the action was actually triggered. The fix was to extract the shared logic into one function that both the button's own listener and the hotkey handler call directly, each passing its own true source.
+
+Direct business/state methods — not DOM proxies — should be authoritative. The DOM is a rendering of state, not a control bus.
+
+---
+
+## I. Native UI Integration
+
+**AUTHORING RECOMMENDATION.**
+
+- The UI must remain fully functional with no host attached — this is a restatement of Section B at the UI layer specifically.
+- UI code should call the canonical services described in Section H, not reimplement mutation logic inline in an event handler.
+- Changes originating from the UI should emit the same normalized events a host-originated change would, with `source=ui`.
+- UI state and contract state must stay synchronized: if a host sets a value, the UI must faithfully reflect it, and if the UI changes a value, the contract's readable state must reflect that immediately.
+- Host-set values must render in the native UI exactly as they would if a local operator had set them — the UI must not have a way to silently distinguish or discard a host-originated value it doesn't like the look of. If a native control cannot exactly represent a value (see the range lesson in Section E), it must not simply drop or revert that value on the next unrelated interaction.
+
+**CONTRACT REQUIREMENT.** Not every public target needs a visible UI control (Contract §19: sound and visual actions "should be exposed as impulse targets where useful," which does not imply UI parity). A target may legitimately be external-only when it maps to real, intentional exhibit behavior that simply has no dedicated on-screen control — for example, an event that exists as a synthesis function the exhibit already performs in another context, exposed to hosts without inventing a redundant button for it. What is not acceptable is a target whose only path to actually happening is a UI element that does not exist; the target must resolve to a real, verified, callable action regardless of whether a button triggers it too.
+
+---
+
+## J. Source Attribution
+
+**CONTRACT REQUIREMENT.** Normalized events must identify the authoritative source of the action when known (Contract §15). The base source values are `ui`, `midi`, `hotkey`, `host`, `scenario`, `internal`, and `system`. A `source` value supplied inside a host command must be ignored; the receiving bridge assigns the authoritative source, never the sender.
+
+**AUTHORING RECOMMENDATION.** Assign source at the trusted boundary that actually knows the truth, never by trusting a caller's claim about itself. The button's own click listener knows it is `ui`. The keydown handler knows it is `hotkey`. The host transport, after validating a message came through the negotiated session, knows it is `host`. None of these should accept an override from the thing they're receiving input from.
+
+Do not teach host-provided source passthrough as authoritative: a host cannot spoof local provenance, by design, because source is assigned by the exhibit's own receiving path, not read out of the incoming message.
+
+For future external control routed through XZBT-NGN — MIDI is the concrete example, following its removal from the standalone exhibit in favor of NGN-owned integration — the exhibit sees these operations arrive as ordinary contract requests over its host transport, and correctly reports `source=host`, because from the exhibit's point of view that is exactly what they are. NGN is free to separately retain finer-grained provenance (which connector, which physical controller) on its own side of the boundary; that detail is an NGN concern and never needs to cross into the exhibit's own event stream as a new base source value.
+
+---
+
+## K. Events
+
+**CONTRACT REQUIREMENT.** A conforming exhibit must publish normalized events for contract-visible operations. The base event types are:
+
+```text
+state.changed
+action.executed
+selection.changed
+capability.changed
+registry.changed
+error
+```
+
+**CONTRACT REQUIREMENT — sequencing.** Event `sequence` must increase monotonically within one session and is session-scoped: it resets when a new `sessionId` is issued, and a host must reset its own expected sequence after a reconnect (Contract §16.1). `stateRevision` tracks committed persistent-state history and does not reset with a new session; `sequence` tracks delivery order of every emitted event, including impulses and errors. The two counters serve different purposes and must not be treated as interchangeable (Contract §14.4).
+
+**CONTRACT REQUIREMENT.** Impulses may emit `action.executed` without changing `stateRevision`, because an impulse by definition is not persistent state (Contract §10.4, §14).
+
+**CONTRACT REQUIREMENT — correlation.** When an event is caused directly by a host request, it should carry that request's `requestId` as `correlationId` (Contract §16.2).
+
+**AUTHORING RECOMMENDATION.** Do not introduce new canonical event types beyond the base six without very strong justification, and never as a routine part of building a new exhibit — the base set is deliberately exhibit-generic and sufficient for state, selection, action, capability, registry, and error reporting across very different subject matter.
+
+---
+
+## L. Session and Transport Separation
+
+**CONTRACT REQUIREMENT.** The contract defines semantics and normalized message envelopes, not one mandatory transport. Permitted transports include same-origin `postMessage`, trusted wrapper bindings, a local WebSocket transport, local application IPC, and future host-specific bindings (Contract §4). The exhibit must not need to know whether a request originated from a webhook, an automation tool, a simulator, a telemetry source, an administrative UI, a scenario engine, or any other integration — NGN translates all of these into contract operations before they ever reach the exhibit.
+
+**AUTHORING RECOMMENDATION.** Keep session semantics — handshake, `sessionId` issuance, sequence tracking — cleanly separated from exhibit logic, so that swapping the transport later (say, from `postMessage` to a packaged in-process adapter, per the NGN packaging model) requires changing only the transport layer described in Section C, not the contract adapter or the exhibit itself. Do not let `postMessage`-specific assumptions (message event shape, origin checks) leak into the contract adapter's own logic; keep them isolated in the transport layer that happens to implement that one binding.
+
+**CONTRACT REQUIREMENT — same-origin security.** Where `postMessage` is used, both `event.origin` and `event.source` must be validated against the expected host relationship on every message (Contract §25).
+
+---
+
+## M. Capabilities
+
+**CONTRACT REQUIREMENT.** Capabilities are discoverable and stateful. The base lifecycle states are `unsupported`, `available`, `loading`, `ready`, `busy`, and `error` (Contract §17). A capability may change state during a session, and any such change must emit `capability.changed`. A host must treat the current discovered state as authoritative.
+
+**AUTHORING RECOMMENDATION.** Do not advertise a capability that does not exist, and do not invent lifecycle transitions the exhibit cannot honestly report. Speech is a useful worked example: `available` should mean the exhibit implements speech at all; `loading` should reflect real initialization in progress (voice model loading, engine startup); `ready` should mean speech can actually be used right now; `error` should reflect a genuine initialization or operation failure. `busy` is optional — it describes ongoing occupancy (mid-playback, for instance) and is not required by the contract; wiring it costs real engineering effort (start/end hooks, interruption handling) for a state most hosts do not strictly need. It is entirely acceptable to wire `available → loading → ready/error` for a capability and leave `busy` unimplemented; what is not acceptable is a capability that is hard-coded to a static state that is never actually verified against real subsystem behavior. Not every capability needs to exercise every lifecycle state — use only the states that reflect something real for that capability.
+
+---
+
+## N. Contextual Availability
+
+**CONTRACT REQUIREMENT.** When a required capability is not usable, a target remains discoverable but must not be operated successfully; a generic host should present it as unavailable rather than removing it (Contract §9). `registryRevision` should not change merely because a fixed target becomes contextually unavailable (Contract §23).
+
+**AUTHORING RECOMMENDATION.** Keep discoverable and currently invokable as two distinct questions. A sound effect can remain permanently in the fixed catalog while returning `CAPABILITY_UNAVAILABLE` in the wrong mode or theme — that is normal, expected, and cheap for a host to handle. What must not happen is the registry itself churning (targets appearing and disappearing, `registryRevision` incrementing) merely because the exhibit switched modes; that is exactly the unnecessary rediscovery cost Section D warns against. Reserve `registryRevision` increments for cases where the target set or its descriptor metadata genuinely changed — not for ordinary state transitions the exhibit goes through as part of normal operation.
+
+---
+
+## O. Safe Text Handling
+
+**CONTRACT REQUIREMENT.** Any text surface the exhibit exposes must treat text strictly as data (Contract §20). The contract must not permit arbitrary HTML, JavaScript, CSS, selectors, or executable expressions through a text target. Each text target should declare a maximum accepted length.
+
+**AUTHORING RECOMMENDATION.** Use explicit, narrowly-typed text arguments in a target's schema rather than an open-ended payload object. Reject unknown argument keys (Contract §11: "unexpected argument keys should be rejected"). Render accepted text using safe mechanisms — `textContent` assignment or an equivalent that cannot be interpreted as markup — never `innerHTML` or an equivalent that would let a string become executable content. This applies equally to text arriving through a live contract message and text arriving embedded in scenario data (Section R): scenario data must remain inert, and no contract or scenario argument should ever be capable of causing arbitrary script execution inside the exhibit.
+
+---
+
+## P. Optional Telemetry
+
+**CONTRACT REQUIREMENT.** Telemetry support is entirely optional; the contract defines how telemetry is represented if an exhibit exposes it, without requiring any exhibit to implement it (Contract §21).
+
+**AUTHORING RECOMMENDATION.** Do not invent telemetry fields merely to look complete against the contract. A telemetry target should exist only when the exhibit actually has a meaningful structured value behind it — a real number, from a real subsystem, that changes in a way worth exposing. Manufacturing a `telemetry.*` target with no real backing state produces exactly the kind of describe-accuracy problem Section E warns against for ordinary targets. If the exhibit's current telemetry-like presentation is really just audio activity levels or scrolling text rather than a genuine structured numeric model, building real structured telemetry is a new feature to design deliberately, not a checkbox to tick during initial contract compliance.
+
+Acquisition, mapping, unit conversion, smoothing, and staleness policy for telemetry sourced from something external to the exhibit are XZBT-NGN's responsibility, not the exhibit's (Contract §22, NGN Plan §22–23). The exhibit's job is to declare what it can meaningfully receive or report and to behave sensibly according to its own declared contract when values arrive; it does not need to know or care where a telemetry value ultimately originated.
+
+---
+
+## Q. External Integrations
+
+**CONTRACT REQUIREMENT.** The exhibit exposes what it can do; the contract defines how that is described, observed, and invoked; XZBT-NGN decides how to orchestrate and integrate it (Contract §30). NGN, not the exhibit, owns discovery, coordination, automation, external connectors, recording, and administration (Contract §3.2, NGN Plan §3, §40).
+
+**AUTHORING RECOMMENDATION.** A standalone exhibit generally should not own MIDI, webhooks, external automation buses, game integrations, or other connector-specific logic. The architectural test to apply to any proposed piece of functionality, borrowed directly from the settled NGN/exhibit boundary: if it coordinates, automates, connects, authors, records, distributes, or externally controls the exhibit, it belongs primarily in XZBT-NGN. If it is required for the exhibit to remain a complete standalone experience, it belongs in the exhibit.
+
+This is not an absolute prohibition — an integration that is truly intrinsic to what makes a specific exhibit a complete standalone artifact could still belong in the exhibit itself. But that should be a deliberate, justified exception, argued explicitly against the test above, not a default. When in doubt, expose a clean primitive on the exhibit side (a target that does the underlying thing) and let external integration logic live entirely in NGN, translating whatever external signal into a normal contract operation against that primitive. This is precisely the shape of the resolution used when MIDI-specific code was separated from the generic control-bus abstraction it had been bundled with: the generic bus mechanism is a legitimate exhibit-side primitive; the MIDI-specific binding and mapping logic is not.
+
+---
+
+## R. Scenarios
+
+**CONTRACT REQUIREMENT.** The XZBT Exhibit Contract does not require a general-purpose scenario engine in the standalone exhibit; a scenario is an orchestration concept, and XZBT-NGN executes scenarios by issuing ordinary contract operations over time (Contract §26). Scenario data must remain inert and must never be interpreted as arbitrary executable code.
+
+**AUTHORING RECOMMENDATION.** General scenario authoring, recording, and playback belong to XZBT-NGN, not the exhibit. What the exhibit should provide is a set of clean, controllable primitives — well-designed targets — that a scenario can drive; it does not need to know a scenario is happening at all, and from the exhibit's point of view a scenario-driven operation is just a `set` or `invoke` arriving through its host transport like any other.
+
+A separately packaged exhibit build, produced by NGN for self-running distribution, may later include a minimal runtime that drives the exhibit's own canonical target surface from embedded scenario data (NGN Plan §26–27). That compact runtime is still a consumer of the same contract surface the exhibit already exposes — it is not a reason to build a general scenario editor or player into every ordinary exhibit. Do not embed scenario authoring or playback tooling in the exhibit as a default; if a packaged build needs it, that tooling is added at packaging time by NGN's shared runtime core, not maintained as a permanent part of the standalone artifact.
+
+---
+
+## S. Packaging and Offline Operation
+
+**AUTHORING RECOMMENDATION**, following directly from Section B: a standalone exhibit intended to run as a single distributable artifact should remain fully self-contained wherever that is the intended distribution shape — no server required for normal standalone use, no external script or stylesheet dependency that would break when opened offline or from a local file. Any optional network-dependent enhancement (checking for updates, an optional cloud feature) should fail gracefully and silently fall back to fully local behavior rather than degrading the core experience or throwing a visible error.
+
+Packaging should preserve contract behavior: whatever process turns exhibit source into a distributable artifact should not need special-case handling to keep the contract adapter, canonical mutation path, or event surface intact — if packaging strips or reorders code in a way that breaks contract compliance, that is a packaging defect, not an acceptable tradeoff.
+
+**Do not treat SciFi-XZBT's own specific offline/packaging policy (its particular single-file HTML packaging approach, or any Web3D-related constraint it happens to carry) as a universal requirement.** A different exhibit might reasonably ship as a small set of files, or run inside a different host shell entirely. The universal principle is graceful, complete standalone operation; the specific packaging mechanics are exhibit-owned implementation choices.
+
+---
+
+## T. Error Handling
+
+**CONTRACT REQUIREMENT.** Implementations should use stable, machine-readable error codes rather than relying on message text (Contract §24). The recommended base codes are:
+
+```text
+UNSUPPORTED_VERSION
+INVALID_MESSAGE
+INVALID_SESSION
+UNKNOWN_TARGET
+INVALID_VALUE
+INVALID_ARGUMENTS
+CAPABILITY_UNAVAILABLE
+TARGET_READ_ONLY
+TARGET_NOT_INVOKABLE
+TARGET_NOT_SETTABLE
+INTERNAL_ERROR
+```
+
+Human-readable error text is advisory only; hosts should branch on codes, never on message strings.
+
+**AUTHORING RECOMMENDATION.**
+
+- Reject invalid input cleanly and immediately, with a specific code, rather than accepting it and behaving unpredictably.
+- Never apply a partial mutation — if any part of a validated transaction cannot be committed, commit none of it.
+- Do not silently clamp an out-of-range value to the nearest valid one unless the target's own descriptor explicitly documents clamping as its defined behavior; silent clamping without a declared contract for it produces exactly the kind of state/host disagreement Section E warns about.
+- Never report success when the requested state did not actually change as requested. A response of `ok: true` is a claim that the operation was accepted for execution (Contract §11) — it must correspond to something real actually happening, not to the request merely being well-formed.
+
+---
+
+## U. Testing Strategy
+
+**AUTHORING RECOMMENDATION.** A new exhibit's XZBT compliance should be exercised at several distinct levels, each catching a different class of defect:
+
+1. **Synthetic/unit tests.** Exercise the canonical mutation/invoke path and the contract adapter directly, without a browser or a real host — target validation, no-op detection, revision increment logic, event shape.
+2. **Contract adapter tests.** Verify `describe`, `state.get`, capability reporting, and error codes conform to the wire-level contract shape, independent of any specific transport.
+3. **Real browser host tests.** Attach an actual host (or a minimal test harness acting as one) over the real transport and verify handshake, discovery, state read/write, invoke, and event delivery end to end.
+4. **Native UI convergence tests.** Confirm that UI-originated changes and host-originated changes both flow through the same canonical path and produce equivalent, correctly-sourced events — this is where a lingering DOM-click proxy (Section H) or a UI/contract range mismatch (Section E) tends to surface.
+5. **Standalone no-host tests.** Load the exhibit with no host attached at all and confirm every feature works exactly as it would with one — this is the direct verification of Section B's governing principle.
+6. **Packaging tests.** Confirm the packaged/distributable artifact behaves identically to the development build with respect to contract compliance.
+7. **Local-file/offline tests, where applicable.** For an exhibit intended to run as a double-clicked local file or fully offline, confirm it actually does — including any browser security constraints on `file://` access that a development environment's own tooling may not replicate.
+
+A minimal conformance checklist for quick reference during development is provided in Appendix A.
+
+---
+
+## V. Reference Exhibit Expectations
+
+**AUTHORING RECOMMENDATION**, describing what a later reference-exhibit pass (not undertaken here) should aim to demonstrate. Reference exhibits such as a train exposition or an aviation exposition should be:
+
+- small — deliberately minimal in scope, not a showcase of every contract feature at once;
+- intentionally simple — easy to read end to end as a worked example;
+- fully standalone, per Section B;
+- visibly different in subject domain from any existing exhibit, to prove the contract generalizes rather than merely repeating one domain's shape;
+- contract-correct against every applicable MUST in the specification;
+- free of any dependency on science-fiction-specific vocabulary or assumptions;
+- useful primarily as an example implementation for future exhibit authors to read.
+
+The point of a reference exhibit is to prove genericity, not to demonstrate feature richness. A train exhibit exposing `engine.throttle`, `brake.pressure`, `event.whistle`, and `telemetry.speed` (the exact example given in Contract §27) makes the architectural point far better than a large, feature-complete build would.
+
+---
+
+## W. Anti-Patterns
+
+The following are recurring mistakes to avoid when building a new exhibit, several of them drawn directly from real defects found during SciFi-XZBT's contract verification:
+
+- Using DOM element IDs, CSS selectors, or internal widget names as public contract target IDs.
+- Implementing a persistent, toggle-shaped piece of state as a toggle-only contract action instead of an absolute setter.
+- Maintaining a separate "shadow" adapter state that can drift from the exhibit's real internal state.
+- Using synthetic DOM clicks or dispatched events as the canonical control-mutation mechanism.
+- Trusting a host-supplied `source` value instead of assigning source at the trusted receiving boundary.
+- Publishing a target in `describe` that has no real, verified, callable backing action in any context.
+- Changing the registry, or incrementing `registryRevision`, on every ordinary mode or context switch.
+- Embedding external integrations (MIDI, webhooks, third-party connectors) directly into the standalone exhibit by default.
+- Making any part of ordinary standalone operation depend on XZBT-NGN being present.
+- Putting arbitrary executable code, HTML, or expressions into scenario data or contract text arguments.
+- Adding telemetry fields that have no real backing state, purely to look complete against the contract.
+- Letting a native UI control accept or produce values outside the range its own public descriptor advertises.
+- Silently reverting a committed host- or scenario-set value the next time a local control is touched.
+
+---
+
+## X. Recommended New-Exhibit Build Order
+
+**AUTHORING RECOMMENDATION.** A practical sequence for building a new XZBT-compatible exhibit from scratch:
+
+1. Build the exhibit's own standalone behavior first — visuals, audio, simulation, native UI — with no contract awareness at all.
+2. Define stable internal state and services (layers 1–2 of Section C) that the exhibit's own UI already uses.
+3. Define the canonical public target catalog (Section D) against that real internal model.
+4. Implement absolute setters and idempotent actions (Section G) backing each target.
+5. Route the native UI through the canonical services (Section H) so UI-originated and future host-originated changes share one path.
+6. Add the contract adapter: descriptors, target metadata, capability model (Sections E, M).
+7. Add discovery, state, and event surfaces: `describe`, `state.get`, `state.changed`/`action.executed`/etc. (Sections F, K).
+8. Add the transport layer (Section L), keeping it isolated from exhibit and adapter logic.
+9. Add capability reporting wired to real subsystem lifecycle, not static claims (Section M).
+10. Run the conformance tests in Section U, including the checklist in Appendix A.
+11. Test standalone and offline operation explicitly, with no host present at all.
+12. Only after all of the above is solid, integrate with a real XZBT-NGN instance.
+
+Building in this order keeps the exhibit correct and complete on its own at every step, and treats contract compliance as a layer added on top of a working exhibit rather than a scaffold the exhibit is built inside of.
+
+---
+
+## Y. Minimal Example Architecture
+
+The following are short, conceptual, JavaScript-like pseudocode fragments illustrating the patterns above. They are not a working application and deliberately omit error handling detail already covered in Section T.
+
+**Target registry (Section D, E):**
+
+```js
+const TARGET_REGISTRY = {
+ 'transport.playing': { kind: 'state', valueType: 'boolean', readable: true, writable: true, restorable: true, category: 'transport', requires: [] },
+ 'mix.master': { kind: 'range', min: 0, max: 1, step: 0.01, readable: true, writable: true, restorable: true, category: 'mix', requires: ['audio'] },
+ 'mode.selected': { kind: 'selection', options: [{ value: 'day', label: 'Day' }, { value: 'night', label: 'Night' }], readable: true, writable: true, restorable: true, category: 'mode', requires: [] },
+ 'event.pulse': { kind: 'impulse', readable: false, writable: false, restorable: false, category: 'event', requires: [] },
+};
+```
+
+**Absolute setter (Section G):**
+
+```js
+function setMuted(on) {
+ if (exhibitState.muted === on) return { changed: false };
+ exhibitState.muted = on;
+ audioEngine.setMuted(on);
+ return { changed: true };
+}
+```
+
+**Canonical mutation transaction (Section F, H):**
+
+```js
+function applyMutation(targetId, value, source) {
+ const before = snapshotRelevantState(targetId);
+ const result = dispatchToSetter(targetId, value); // calls setMuted(), etc. directly
+ if (!result.changed) return { ok: true, revisionChanged: false };
+
+ const changedTargets = diffState(before, snapshotRelevantState(targetId));
+ stateRevision += 1;
+ for (const t of changedTargets) {
+ emitEvent('state.changed', { target: t.id, value: t.value, stateRevision, source });
+ }
+ return { ok: true, revisionChanged: true };
+}
+```
+
+**Impulse action (Section G, K):**
+
+```js
+function invokeAction(targetId, args, source) {
+ const descriptor = TARGET_REGISTRY[targetId];
+ if (!descriptor || descriptor.kind !== 'impulse') return { ok: false, code: 'TARGET_NOT_INVOKABLE' };
+ if (!capabilityReady(descriptor.requires)) return { ok: false, code: 'CAPABILITY_UNAVAILABLE' };
+
+ performRealAction(targetId, args); // no synthetic DOM dispatch
+ emitEvent('action.executed', { target: targetId, args, source });
+ return { ok: true };
+}
+```
+
+**State snapshot (Section F):**
+
+```js
+function getStateSnapshot() {
+ const values = {};
+ for (const [id, d] of Object.entries(TARGET_REGISTRY)) {
+ if (d.readable && d.kind !== 'impulse') values[id] = readCurrentValue(id);
+ }
+ return { stateRevision, values };
+}
+```
+
+**Event emission (Section K):**
+
+```js
+let sequence = 0;
+function emitEvent(type, payload) {
+ sequence += 1;
+ hostTransport.send({ xzbt: '5.2', type, sessionId: currentSessionId, sequence, timestamp: Date.now(), ...payload });
+}
+```
+
+**Host transport adapter (Section C, L):**
+
+```js
+window.addEventListener('message', (event) => {
+ if (event.source !== window.parent || !isTrustedOrigin(event.origin)) return;
+ const msg = event.data;
+ if (!isValidEnvelope(msg)) return respondError(msg, 'INVALID_MESSAGE');
+ if (msg.type !== 'hello' && msg.sessionId !== currentSessionId) return respondError(msg, 'INVALID_SESSION');
+
+ switch (msg.type) {
+ case 'hello': return handleHello(msg);
+ case 'describe': return respondDescribe(msg);
+ case 'state.get': return respondState(msg);
+ case 'set': return respond(msg, applyMutation(msg.target, msg.value, 'host'));
+ case 'invoke': return respond(msg, invokeAction(msg.target, msg.args, 'host'));
+ }
+});
+```
+
+---
+
+## Z. Final Author Checklist
+
+Before calling a new exhibit XZBT-compatible, confirm:
+
+- It runs completely, with full native behavior, with no host attached.
+- Every public target ID is meaningful, lowercase-dotted, and free of DOM/widget-specific naming.
+- Every descriptor reflects a real, verified backing action or state — nothing fake, nothing aspirational.
+- Persistent setters are absolute and idempotent; setting the same value twice produces one state, zero extra revisions.
+- All contract-visible mutation and invocation, from every input source, passes through one canonical path.
+- No synthetic DOM dispatch is used as the mutation mechanism.
+- Source is assigned only at the trusted receiving boundary, never trusted from the caller.
+- One mutation transaction produces at most one `stateRevision` increment, and no-ops produce none.
+- Native UI and public contract ranges represent one coherent operator-facing range.
+- Capability states reflect real subsystem lifecycle, using only the states that are genuinely meaningful for that capability.
+- The registry is fixed; context changes produce contextual errors, not registry churn.
+- All text targets are handled as safe data, with declared length limits and no markup/script injection path.
+- Telemetry, if present at all, is backed by real structured values — never fabricated for completeness.
+- MIDI, webhooks, and other external integrations are not embedded in the exhibit by default.
+- No general scenario authoring/recording/playback system has been built into the exhibit.
+- Errors use stable machine-readable codes; no partial mutations; no silent clamping without a declared contract for it.
+- The exhibit has been tested standalone, offline (where applicable), and through a real host, per Section U.
+
+---
+
+## Appendix A: Minimal Conformance Checklist
+
+- [ ] Handshake: exhibit responds to `hello` with a session, or `UNSUPPORTED_VERSION` when incompatible.
+- [ ] `describe` returns identity, contract version, `registryRevision`, `stateRevision`, capabilities, and targets.
+- [ ] At least one discoverable target of each kind actually in use (state/range/selection/impulse).
+- [ ] `state.get` returns only readable persistent targets, matching `describe`.
+- [ ] `set` on a persistent target is idempotent; a no-op set does not change `stateRevision`.
+- [ ] `invoke` on an impulse target executes a real action and emits `action.executed`.
+- [ ] One transaction, one revision: a multi-value change increments `stateRevision` exactly once.
+- [ ] Events carry monotonic `sequence`, reset on new session; `stateRevision` does not reset with session.
+- [ ] `source` is accurate for `ui`, `hotkey`, and `host` origins in real, physically-triggered tests.
+- [ ] Capability states reflect real initialization/readiness, not static placeholders.
+- [ ] Unknown target, invalid value, invalid session, and invalid arguments each return the correct distinct error code.
+- [ ] `postMessage` transport (if used) validates both `event.origin` and `event.source`.
+- [ ] The exhibit runs fully standalone with no host, network, or server present.
+- [ ] The packaged/distributable build behaves identically to the development build for all of the above.
+
+## Appendix B: Recommended Project Structure
+
+This is an illustrative layout, not a required one — see Section C for the underlying separation of concerns it expresses.
+
+```text
+/exhibit-root
+ index.html # entry point; loads with no host required
+ /js
+ model.js # exhibit state (layer 1)
+ services.js # exhibit behavior / mutation logic (layer 2)
+ ui.js # native UI, calls canonical services (layer 3)
+ canonical-control.js # applyMutation / invokeAction chokepoint (layer 4)
+ contract-adapter.js # describe/state.get/events/capabilities (layer 5)
+ host-transport.js # postMessage or other binding (layer 6)
+ /css
+ style.css
+ /assets
+ ...
+```
+
+A single-file packaged build may inline all of the above; the separation should still exist logically within the source even when the distributable artifact is one file.
+
+## Appendix C: Glossary
+
+**XZBT Exhibit Contract** — the versioned specification defining the message envelope, target model, revisions, events, capabilities, and error codes an exhibit and a host share.
+
+**XZBT-compatible exhibit** — any exhibit implementing the XZBT Exhibit Contract.
+
+**Standalone exhibit** — an exhibit's normal, complete, independently usable form, with no host attached.
+
+**XZBT-NGN Exhibit Engine** — the separate orchestration, integration, recording, authoring, and administration layer that connects to one or more exhibits through the contract; never required for standalone exhibit operation.
+
+**Canonical target** — a publicly registered, dotted-ID addressable unit of exhibit state or action.
+
+**State** — a target kind representing persistent value (boolean, string, or bounded scalar).
+
+**Range** — a target kind representing persistent bounded numeric state, with `min`/`max`/`step`.
+
+**Selection** — a target kind representing a persistent value chosen from a declared set of options.
+
+**Impulse** — a target kind representing a non-persistent action or event; never part of a restorable snapshot.
+
+**Capability** — a discoverable, stateful description of subsystem availability (`unsupported`/`available`/`loading`/`ready`/`busy`/`error`).
+
+**stateRevision** — a monotonically increasing counter over committed persistent-state history, incremented at most once per mutation transaction.
+
+**registryRevision** — a counter over the current set and metadata of discoverable targets; should not change for ordinary contextual availability shifts.
+
+**Sequence** — a per-session, monotonically increasing counter over all emitted events, reset on new session.
+
+**Session** — the negotiated context, established by `hello`, under which all other contract requests after the handshake must carry a valid `sessionId`.
diff --git a/docs/contract/XZBT-Exhibit-Contract-Specification-v5.2.md b/docs/contract/XZBT-Exhibit-Contract-Specification-v5.2.md
new file mode 100644
index 0000000..a6697bd
--- /dev/null
+++ b/docs/contract/XZBT-Exhibit-Contract-Specification-v5.2.md
@@ -0,0 +1,1091 @@
+# XZBT Exhibit Contract Specification
+## Version 5.2
+
+**Status:** Proposed normative specification
+**Document version:** 5.2
+**Contract family:** XZBT Exhibit Contract
+**Compatibility major:** 5
+**Primary consumers:** XZBT-compatible exhibits and XZBT-NGN Exhibit Engine
+
+---
+
+## 1. Purpose
+
+The XZBT Exhibit Contract defines a stable, discoverable language through which an external host can inspect, observe, and control an XZBT-compatible exhibit.
+
+The architecture is deliberately separated into three concerns:
+
+- **Exhibit:** presentation, native controls, audio, visuals, simulation, and exhibit-specific behavior.
+- **Contract:** the shared control and observation language.
+- **Exhibit Engine:** orchestration, recording, integration, networking, authoring, packaging, and administration.
+
+The contract does not require XZBT-NGN to exist for an exhibit to operate.
+
+An exhibit implementing this contract remains a complete independently usable artifact.
+
+---
+
+## 2. Governing Architectural Rule
+
+An XZBT-compatible exhibit MUST remain operable without XZBT-NGN.
+
+Connection to XZBT-NGN is additive.
+
+The exhibit exposes addressable capabilities and state.
+
+XZBT-NGN decides how to coordinate, automate, integrate, record, schedule, and package those capabilities.
+
+The contract MUST NOT be expanded merely to move Exhibit Engine responsibilities into the exhibit.
+
+---
+
+## 3. Roles
+
+### 3.1 Exhibit responsibilities
+
+An exhibit owns:
+
+- visual presentation;
+- audio presentation;
+- simulation and ambience;
+- native UI;
+- local input handling;
+- internal state;
+- exhibit-specific actions;
+- standalone behavior;
+- exhibit-native speech rendering;
+- exhibit-native sound rendering;
+- exhibit-native visual effects.
+
+### 3.2 Exhibit Engine responsibilities
+
+XZBT-NGN may:
+
+- discover an exhibit;
+- inspect capabilities and targets;
+- request state;
+- invoke actions;
+- set exposed values;
+- observe normalized events;
+- record operator activity;
+- execute scenarios;
+- author timelines;
+- expose webhooks;
+- manage authentication and authorization;
+- connect external systems;
+- map telemetry;
+- package authored experiences;
+- administer one or more exhibits.
+
+---
+
+## 4. Transport Independence
+
+The contract defines semantics and normalized message envelopes, not one mandatory transport.
+
+Permitted transports may include:
+
+- same-origin `postMessage`;
+- trusted wrapper bindings;
+- local WebSocket transport;
+- local application IPC;
+- future host-specific bindings.
+
+The exhibit MUST NOT need to know whether a request originated from a webhook, Streamer.bot, simulator, telemetry source, administrative UI, scenario engine, or other integration.
+
+NGN translates external sources into contract operations.
+
+---
+
+## 5. Session and Handshake
+
+A host MUST establish a contract session before sending control operations through a negotiated transport.
+
+A standalone exhibit MUST NOT depend on this handshake for normal operation.
+
+`hello` is the only contract message that MAY be sent without a `sessionId`.
+
+Every other request MUST carry a valid `sessionId` issued by the exhibit. Missing or unknown session identifiers MUST be rejected with `INVALID_SESSION`.
+
+### 5.1 Host hello
+
+```json
+{
+ "xzbt": "5.2",
+ "type": "hello",
+ "requestId": "req-001",
+ "host": {
+ "name": "XZBT-NGN",
+ "version": "0.1.0"
+ },
+ "supportedContractMajors": [5]
+}
+```
+
+### 5.2 Exhibit hello response
+
+```json
+{
+ "xzbt": "5.2",
+ "type": "hello.result",
+ "requestId": "req-001",
+ "sessionId": "sess-7f2a",
+ "exhibit": {
+ "product": "scifi-xzbt",
+ "version": "implementation-defined",
+ "build": "implementation-defined"
+ },
+ "contract": {
+ "major": 5,
+ "minor": 2
+ }
+}
+```
+
+If no compatible major version exists, the exhibit MUST respond with an `error` carrying code `UNSUPPORTED_VERSION`, MUST echo the `requestId` when recoverable, and MUST NOT create a session.
+
+---
+
+## 6. Message Envelope
+
+Every request, response, and event transmitted through the contract MUST use a normalized envelope.
+
+### 6.1 Request
+
+```json
+{
+ "xzbt": "5.2",
+ "type": "invoke",
+ "requestId": "req-1042",
+ "sessionId": "sess-7f2a",
+ "target": "sfx.console-beep",
+ "args": {}
+}
+```
+
+A `set` request MAY additionally contain an `options` object when the target explicitly declares supported options.
+
+### 6.2 Response
+
+```json
+{
+ "xzbt": "5.2",
+ "type": "invoke.result",
+ "requestId": "req-1042",
+ "sessionId": "sess-7f2a",
+ "ok": true
+}
+```
+
+### 6.3 Error response
+
+```json
+{
+ "xzbt": "5.2",
+ "type": "error",
+ "requestId": "req-1042",
+ "sessionId": "sess-7f2a",
+ "ok": false,
+ "error": {
+ "code": "UNKNOWN_TARGET",
+ "message": "The requested target is not registered."
+ }
+}
+```
+
+When a malformed message cannot be parsed well enough to recover `requestId` or `sessionId`, either field MAY be `null`.
+
+### 6.4 Correlation
+
+`requestId` MUST be unique within a host session.
+
+Responses MUST echo the corresponding `requestId`.
+
+Events are asynchronous and do not require a `requestId`, but SHOULD include a `correlationId` when directly caused by a specific request.
+
+### 6.5 Version field
+
+The `xzbt` field is advisory metadata for diagnostics.
+
+The negotiated session contract major/minor is authoritative after handshake.
+
+---
+
+## 7. Describe
+
+The host MUST be able to request a complete machine-readable description.
+
+Request:
+
+```json
+{
+ "xzbt": "5.2",
+ "type": "describe",
+ "requestId": "req-010",
+ "sessionId": "sess-7f2a"
+}
+```
+
+Response:
+
+```json
+{
+ "xzbt": "5.2",
+ "type": "describe.result",
+ "requestId": "req-010",
+ "sessionId": "sess-7f2a",
+ "exhibit": {
+ "product": "scifi-xzbt",
+ "version": "implementation-defined",
+ "build": "implementation-defined"
+ },
+ "contract": {
+ "major": 5,
+ "minor": 2
+ },
+ "registryRevision": 1,
+ "stateRevision": 27,
+ "capabilities": [],
+ "targets": []
+}
+```
+
+`describe` is authoritative for the current session.
+
+The `capabilities` array is a snapshot of current capability states at the time of the response.
+
+NGN MUST discover targets instead of assuming that an exhibit exposes a fixed science-fiction vocabulary.
+
+---
+
+## 8. Canonical Target IDs
+
+External contract target IDs use a canonical dotted namespace.
+
+Examples:
+
+```text
+transport.playing
+transport.muted
+mix.master
+view.observation
+view.viewport-frame
+event.alert-red
+alert.active
+sfx.console-beep
+speech.say
+telemetry.engine.temperature
+```
+
+### 8.1 Grammar
+
+A target ID MUST:
+
+- use lowercase ASCII letters, digits, hyphens, and dots;
+- begin with a lowercase letter;
+- contain one or more dot-separated segments;
+- not contain whitespace;
+- not contain empty segments;
+- not be interpreted as a JavaScript property path.
+
+Recommended grammar:
+
+```text
+[a-z][a-z0-9-]*(\.[a-z][a-z0-9-]*)+
+```
+
+### 8.2 Stability
+
+Once published as part of an exhibit's external contract, a canonical target ID becomes part of that exhibit's compatibility surface.
+
+Target IDs SHOULD NOT be renamed casually.
+
+Deprecated targets SHOULD remain discoverable long enough for hosts to migrate.
+
+### 8.3 Internal mapping and normalization
+
+An exhibit MAY map canonical external IDs to existing internal IDs.
+
+Example:
+
+```text
+mix.master -> master-vol
+```
+
+Mapping is not always a rename.
+
+Example:
+
+```text
+transport.playing -> absolute-state adapter around existing transport logic
+```
+
+An exhibit MUST NOT implement an externally visible persistent state target merely by blindly toggling an existing control.
+
+A conforming exhibit may need to build a normalization layer before its existing internals can satisfy the contract.
+
+---
+
+## 9. Target Descriptor
+
+Each target returned by `describe` MUST include enough metadata for a generic host to inspect and operate it.
+
+Base descriptor:
+
+```json
+{
+ "id": "mix.master",
+ "label": "Master Volume",
+ "kind": "range",
+ "readable": true,
+ "writable": true,
+ "restorable": true,
+ "category": "mix",
+ "unit": null,
+ "requires": ["audio"]
+}
+```
+
+`requires` references capability IDs from Section 17.
+
+When a required capability is not usable, the target remains discoverable but MUST NOT be operated successfully. A generic host SHOULD present it as unavailable rather than removing it.
+
+Additional fields depend on kind.
+
+### 9.1 Range
+
+```json
+{
+ "id": "mix.master",
+ "kind": "range",
+ "min": 0,
+ "max": 1,
+ "step": 0.01
+}
+```
+
+### 9.2 State
+
+```json
+{
+ "id": "view.observation",
+ "kind": "state",
+ "valueType": "boolean"
+}
+```
+
+### 9.3 Selection
+
+```json
+{
+ "id": "universe.selected",
+ "kind": "selection",
+ "options": [
+ {"value": "starfleet", "label": "Starflight Command"},
+ {"value": "whataverse", "label": "Whataverse"}
+ ]
+}
+```
+
+Values in examples are illustrative except where a profile document explicitly declares them normative.
+
+### 9.4 Impulse
+
+```json
+{
+ "id": "event.alert-red",
+ "kind": "impulse",
+ "readable": false,
+ "writable": false,
+ "restorable": false
+}
+```
+
+A target with `kind: "impulse"` is invokable.
+
+`kind` is authoritative for invokability. No separate `invokable` field is defined.
+
+### 9.5 Impulse arguments (authoritative Contract 5.2 clarification)
+
+Impulse descriptors use `arguments`, an array of argument descriptors. This
+clarifies Contract 5.2; it does not introduce Contract 5.3. Missing `arguments`
+MUST be tolerated as an empty array for backward compatibility. New 5.2 exhibits
+SHOULD explicitly emit `arguments: []` for argumentless impulses.
+
+```json
+{
+ "id": "speech.say",
+ "kind": "impulse",
+ "readable": false,
+ "writable": false,
+ "restorable": false,
+ "category": "speech",
+ "requires": ["speech"],
+ "arguments": [
+ {"name": "text", "type": "string", "required": true, "maxLength": 2000}
+ ]
+}
+```
+
+Each argument descriptor has:
+
+| Field | Requirement |
+| --- | --- |
+| `name` | REQUIRED string, unique within the target |
+| `type` | REQUIRED: `string`, `number`, `integer`, or `boolean` |
+| `required` | REQUIRED boolean |
+| `label` | OPTIONAL human-readable string |
+| `description` | OPTIONAL human-readable string |
+| `enum` | OPTIONAL array of allowed literal values |
+| `min`, `max` | OPTIONAL number |
+| `step` | OPTIONAL positive number |
+| `minLength`, `maxLength` | OPTIONAL non-negative integer |
+
+Invoke `args` MUST be an object. Every argument with `required: true` MUST be
+present. Undeclared keys MUST be rejected with `INVALID_VALUE`. Values MUST match
+the declared type, and all declared enum, bounds, step, and length constraints
+MUST be enforced.
+
+NGN MAY generate controls directly from this metadata and MUST NOT infer
+undocumented arguments. NGN MUST still surface an impulse with an unsupported
+future argument type and report its schema as unsupported rather than inventing
+behavior.
+
+Established examples: `speech.say` declares required string `text` with
+`maxLength: 2000`; the established impulse form of `display.ticker` declares
+required string `text` with `maxLength: 512`. Argumentless actions, including
+`speech.generate-announcement` and argumentless event/sound impulses, declare
+`arguments: []`. These examples do not establish a host target inventory.
+
+---
+
+## 10. Target Kinds
+
+The base contract defines four kinds.
+
+### 10.1 State
+
+Persistent value such as boolean, string, or bounded scalar.
+
+### 10.2 Range
+
+Persistent bounded numeric state.
+
+### 10.3 Selection
+
+Persistent value selected from a declared set.
+
+### 10.4 Impulse
+
+Non-persistent action or event.
+
+Impulse targets MUST NOT appear in a restorable state snapshot as current state.
+
+---
+
+## 11. Invoke
+
+`invoke` performs an impulse.
+
+```json
+{
+ "xzbt": "5.2",
+ "type": "invoke",
+ "requestId": "req-200",
+ "sessionId": "sess-7f2a",
+ "target": "sfx.console-beep",
+ "args": {}
+}
+```
+
+The exhibit MUST validate the target and arguments before execution.
+
+Arguments MUST be validated against the declared target schema where such a schema exists.
+
+Unexpected argument keys SHOULD be rejected.
+
+A successful `invoke` response means that the operation was accepted for execution, not necessarily that a long-running effect has completed.
+
+---
+
+## 12. Set
+
+`set` changes writable persistent state.
+
+```json
+{
+ "xzbt": "5.2",
+ "type": "set",
+ "requestId": "req-201",
+ "sessionId": "sess-7f2a",
+ "target": "mix.master",
+ "value": 0.75
+}
+```
+
+A successful state-changing set MUST result in a normalized state update.
+
+A successful no-op set MUST NOT increment `stateRevision`.
+
+---
+
+## 13. State Snapshot
+
+The host MUST be able to request externally meaningful persistent state.
+
+Request:
+
+```json
+{
+ "xzbt": "5.2",
+ "type": "state.get",
+ "requestId": "req-300",
+ "sessionId": "sess-7f2a"
+}
+```
+
+Response:
+
+```json
+{
+ "xzbt": "5.2",
+ "type": "state.result",
+ "requestId": "req-300",
+ "sessionId": "sess-7f2a",
+ "stateRevision": 28,
+ "values": {
+ "transport.playing": true,
+ "transport.muted": false,
+ "mix.master": 0.75,
+ "view.observation": true,
+ "universe.selected": "starfleet",
+ "preset.selected": "tng-bridge"
+ }
+}
+```
+
+Only readable persistent targets belong in `values`.
+
+A state snapshot MUST NOT imply that every internal exhibit variable is externally exposed.
+
+---
+
+## 14. State Revision and Mutation Transactions
+
+The exhibit MUST maintain a monotonically increasing `stateRevision` for externally observable persistent state.
+
+### 14.1 Mutation transaction
+
+One top-level contract-visible mutation operation is one **mutation transaction**, unless the exhibit explicitly opens a larger transaction for a coherent multi-value operation.
+
+Examples of one transaction may include:
+
+- one host `set`;
+- one UI slider change;
+- one MIDI mutation;
+- one universe change that also selects a default preset;
+- one preset selection that updates several exposed values.
+
+All contract-visible state changes committed by one transaction share one resulting `stateRevision`.
+
+### 14.2 Ordering
+
+The exhibit MUST:
+
+1. compute and commit the transaction;
+2. increment `stateRevision` exactly once if externally visible state changed;
+3. emit resulting `state.changed` / `selection.changed` events carrying that revision.
+
+### 14.3 No-op behavior
+
+A transaction that produces no externally visible state change MUST NOT increment `stateRevision`.
+
+### 14.4 Why both revision and sequence exist
+
+`stateRevision` tracks committed persistent-state history.
+
+Event `sequence` tracks delivery order of all emitted contract events, including impulses and errors.
+
+The counters serve different purposes and MUST NOT be treated as interchangeable.
+
+---
+
+## 15. Source and Origin
+
+Normalized events MUST identify the authoritative source of the action when known.
+
+Base values:
+
+```text
+ui
+midi
+hotkey
+host
+scenario
+internal
+system
+```
+
+A `source` property supplied by a host command MUST be ignored.
+
+The receiving bridge assigns the authoritative source.
+
+This distinction is required for recording and feedback-loop prevention.
+
+---
+
+## 16. Events
+
+A conforming XZBT 5.2 exhibit MUST publish normalized events for contract-visible operations.
+
+Base event types include:
+
+```text
+state.changed
+action.executed
+selection.changed
+capability.changed
+registry.changed
+error
+```
+
+Example:
+
+```json
+{
+ "xzbt": "5.2",
+ "type": "state.changed",
+ "sessionId": "sess-7f2a",
+ "sequence": 144,
+ "timestamp": 1799781200123,
+ "stateRevision": 29,
+ "target": "mix.master",
+ "value": 0.82,
+ "source": "midi"
+}
+```
+
+Impulse example:
+
+```json
+{
+ "xzbt": "5.2",
+ "type": "action.executed",
+ "sessionId": "sess-7f2a",
+ "sequence": 145,
+ "timestamp": 1799781200234,
+ "target": "sfx.console-beep",
+ "args": {},
+ "source": "ui"
+}
+```
+
+### 16.1 Sequence
+
+Event `sequence` MUST increase monotonically within one session.
+
+It is session-scoped and resets when a new `sessionId` is issued.
+
+A host MUST reset its expected sequence after reconnect.
+
+### 16.2 Correlation
+
+When an event is caused directly by a host request, it SHOULD include the request's `requestId` as `correlationId`.
+
+### 16.3 Selection events
+
+A successful change to a Selection-kind target SHOULD emit `selection.changed`.
+
+### 16.4 Errors
+
+Request-scoped errors MUST be returned as responses.
+
+An exhibit MAY additionally publish an `error` event for observability.
+
+If published, it SHOULD carry the same source and `correlationId` as the rejected request.
+
+### 16.5 Recorder behavior
+
+NGN recording SHOULD normally record operator-originated events such as `ui`, `midi`, and `hotkey`.
+
+It SHOULD NOT automatically record its own `scenario` or host playback events back into the scenario being recorded unless explicitly configured.
+
+---
+
+## 17. Capability Model
+
+Capabilities are discoverable and stateful.
+
+Descriptor example:
+
+```json
+{
+ "id": "speech",
+ "state": "available"
+}
+```
+
+Base states:
+
+```text
+unsupported
+available
+loading
+ready
+busy
+error
+```
+
+Definitions:
+
+- `unsupported`: the exhibit does not implement the capability.
+- `available`: implemented, but not necessarily initialized.
+- `loading`: initialization is in progress.
+- `ready`: usable now.
+- `busy`: currently occupied but still operational.
+- `error`: initialization or operation failed.
+
+A capability may change state during a session.
+
+Capability changes MUST emit `capability.changed`.
+
+A host MUST treat the current discovered state as authoritative.
+
+---
+
+## 18. Speech
+
+An exhibit with speech support SHOULD expose:
+
+```text
+speech.say
+```
+
+Example:
+
+```json
+{
+ "xzbt": "5.2",
+ "type": "invoke",
+ "requestId": "req-410",
+ "sessionId": "sess-7f2a",
+ "target": "speech.say",
+ "args": {
+ "text": "Engineering reports reactor output at eighty-seven percent."
+ }
+}
+```
+
+The exhibit controls synthesis and playback.
+
+NGN supplies content.
+
+The contract does not require a specific TTS engine or a speech queue.
+
+If the exhibit has a usable fallback synthesis path while its preferred path is loading, it MAY accept `speech.say` through that fallback.
+
+A successful `speech.say` response means accepted for playback, not playback completed.
+
+---
+
+## 19. Sound and Visual Events
+
+Exhibit-native sound and visual actions SHOULD be exposed as impulse targets where useful.
+
+Examples:
+
+```text
+sfx.console-beep
+sfx.warning-tone
+event.alert-red
+event.ship-arrival
+```
+
+State and impulse semantics MUST remain distinct.
+
+For example:
+
+```text
+event.alert-red
+```
+
+may be an impulse, while:
+
+```text
+alert.active
+```
+
+may represent current persistent alert state.
+
+NGN requests the action.
+
+The exhibit owns its implementation.
+
+---
+
+## 20. Safe Text Injection
+
+An exhibit MAY expose safe text surfaces such as:
+
+```text
+display.ticker
+```
+
+Text MUST be handled as data.
+
+The contract MUST NOT permit arbitrary:
+
+- HTML;
+- JavaScript;
+- CSS;
+- selectors;
+- executable expressions.
+
+Each text target SHOULD declare a maximum accepted length.
+
+---
+
+## 21. Telemetry
+
+Telemetry support is OPTIONAL.
+
+The contract defines how telemetry is represented if an exhibit exposes structured telemetry. It does not require every exhibit to implement telemetry.
+
+A telemetry target descriptor SHOULD declare:
+
+- identifier;
+- label;
+- value type;
+- unit;
+- minimum and maximum where applicable;
+- writable status;
+- update expectations if useful.
+
+Example:
+
+```json
+{
+ "id": "telemetry.engine.temperature",
+ "label": "Engine Temperature",
+ "kind": "range",
+ "valueType": "number",
+ "unit": "celsius",
+ "readable": true,
+ "writable": true,
+ "restorable": false
+}
+```
+
+Existing SciFi-XZBT telemetry-like presentation is primarily audio activity and ticker text rather than a structured numeric telemetry model.
+
+Therefore structured writable telemetry in SciFi-XZBT is a new exhibit feature, not merely exposure of an existing data object.
+
+It may be implemented incrementally and is not required for the first contract-compatible milestone.
+
+---
+
+## 22. Telemetry Ownership
+
+Writable telemetry MAY support source ownership.
+
+Recommended modes:
+
+```text
+simulation
+external
+```
+
+An external update MAY include a lease duration:
+
+```json
+{
+ "xzbt": "5.2",
+ "type": "set",
+ "requestId": "req-520",
+ "sessionId": "sess-7f2a",
+ "target": "telemetry.engine.temperature",
+ "value": 482.7,
+ "options": {
+ "ownership": "external",
+ "ttlMs": 5000
+ }
+}
+```
+
+After lease expiration, the exhibit MAY return to simulated ownership.
+
+The exact telemetry lease mechanism remains optional in 5.2.
+
+A simple explicit release mechanism is also acceptable.
+
+---
+
+## 23. Registry Revision
+
+`registryRevision` represents the current set and metadata of discoverable targets.
+
+It SHOULD increment when targets or descriptor metadata actually change during a session.
+
+A `registry.changed` event MUST notify the host when the revision changes.
+
+The host then re-runs `describe`.
+
+Registry revision SHOULD NOT change merely because a fixed target becomes contextually unavailable.
+
+---
+
+## 24. Error Codes
+
+Implementations SHOULD use stable machine-readable error codes.
+
+Recommended base codes include:
+
+```text
+UNSUPPORTED_VERSION
+INVALID_MESSAGE
+INVALID_SESSION
+UNKNOWN_TARGET
+INVALID_VALUE
+INVALID_ARGUMENTS
+CAPABILITY_UNAVAILABLE
+TARGET_READ_ONLY
+TARGET_NOT_INVOKABLE
+TARGET_NOT_SETTABLE
+INTERNAL_ERROR
+```
+
+Human-readable error text is advisory.
+
+Hosts SHOULD branch on codes, not message strings.
+
+---
+
+## 25. Security Boundary
+
+The contract MUST NOT provide arbitrary code execution.
+
+Target IDs MUST NOT be interpreted as property traversal.
+
+Contract payloads MUST NOT permit raw executable markup.
+
+The exhibit contract MUST NOT provide a primitive for fetching arbitrary URLs.
+
+External networking belongs to the host or to explicitly defined exhibit-native functionality.
+
+Both host and exhibit MUST validate incoming messages.
+
+Internet-facing authentication and authorization belong to XZBT-NGN, not the standalone exhibit.
+
+Same-origin `postMessage` transports MUST validate both `event.origin` and `event.source` against the expected host relationship.
+
+---
+
+## 26. Scenario Independence
+
+The XZBT Exhibit Contract does not require a general-purpose scenario engine in the standalone exhibit.
+
+A scenario is an orchestration concept.
+
+XZBT-NGN may execute scenarios by issuing contract operations over time.
+
+A separately packaged exhibit may contain:
+
+- authored scenario data;
+- a compact runtime;
+- only the functionality necessary to execute that packaged scenario.
+
+The ordinary standalone exhibit does not need to load arbitrary scenario files.
+
+Scenario data MUST remain inert data and MUST NOT be interpreted as arbitrary executable code.
+
+---
+
+## 27. Extensibility
+
+The base contract is exhibit-generic.
+
+A castle exhibit could expose:
+
+```text
+weather.rain
+fireplace.intensity
+event.drawbridge
+sfx.thunder
+```
+
+A train exhibit could expose:
+
+```text
+engine.throttle
+brake.pressure
+event.whistle
+telemetry.speed
+```
+
+NGN discovers these targets dynamically.
+
+---
+
+## 28. Versioning
+
+### 28.1 Contract major
+
+Breaking protocol changes require a new contract major.
+
+### 28.2 Contract minor
+
+Backward-compatible additions may increase the minor version.
+
+### 28.3 Exhibit version
+
+Exhibit product version is independent from contract version.
+
+### 28.4 Registry revision
+
+Registry revision is runtime target metadata and does not imply a contract version change.
+
+### 28.5 State revision
+
+`stateRevision` is a runtime state-history counter and is not a protocol version.
+
+### 28.6 Scenario format
+
+Scenario format versioning is owned by XZBT-NGN and is outside the Exhibit Contract.
+
+### 28.7 Additive targets
+
+An exhibit may add targets without changing the contract version, provided existing target semantics remain compatible.
+
+---
+
+## 29. Conformance Minimum
+
+An exhibit is minimally XZBT Contract 5.2 compatible when it supports:
+
+- compatible handshake;
+- normalized message envelope;
+- `describe`;
+- at least one discoverable target;
+- `state.get` for readable persistent targets;
+- `stateRevision`;
+- normalized responses;
+- normalized event emission;
+- session event sequencing;
+- command validation;
+- version reporting.
+
+Speech, telemetry, MIDI, scenarios, Web3D, and any particular exhibit namespace are optional capabilities.
+
+---
+
+## 30. Architectural Summary
+
+Version 5.2 keeps the Version 5 architecture intact while tightening implementation semantics.
+
+The key rule remains:
+
+**The exhibit exposes what it can do.**
+
+**The contract defines how that capability is described, observed, and invoked.**
+
+**XZBT-NGN decides how to orchestrate and integrate it.**
diff --git a/docs/reference/SciFi-XZBT-Contract-Implementation-Plan-v5.2.md b/docs/reference/SciFi-XZBT-Contract-Implementation-Plan-v5.2.md
new file mode 100644
index 0000000..6022b30
--- /dev/null
+++ b/docs/reference/SciFi-XZBT-Contract-Implementation-Plan-v5.2.md
@@ -0,0 +1,865 @@
+# SciFi-XZBT XZBT Contract Implementation Plan
+## Version 5.2
+
+**Status:** Proposed implementation plan
+**Product:** SciFi-XZBT
+**Contract:** XZBT Exhibit Contract 5.2
+**Primary goal:** Make the existing standalone exhibit contract-addressable without turning it into XZBT-NGN.
+
+---
+
+## 1. Purpose
+
+SciFi-XZBT will implement the XZBT Exhibit Contract while remaining a self-contained standalone HTML exhibit.
+
+Version 5.2 is grounded in the v5.1 codebase review and deliberately corrects the remaining ambiguity around:
+
+- current control-bus inventory;
+- absolute state versus toggles;
+- mutation/event emission;
+- state revision semantics;
+- observation versus viewport-frame control;
+- universe/preset behavior;
+- display ticker behavior;
+- host validation;
+- packaging-runtime compatibility.
+
+The implementation should extend the real application foundation rather than create a parallel control system.
+
+---
+
+## 2. Standalone Guarantee
+
+SciFi-XZBT MUST continue to operate normally without:
+
+- XZBT-NGN;
+- a host handshake;
+- a web server;
+- webhooks;
+- external telemetry;
+- scenario files;
+- external automation.
+
+The contract layer is dormant when no host is attached.
+
+---
+
+## 3. Existing Verified Foundation and Verification Boundary
+
+The current codebase is known to include:
+
+- `XZBTControlBus`;
+- MIDI routing through that bus;
+- a set of range controls;
+- a set of action controls;
+- speech/TTS;
+- universe/preset setters;
+- universe events;
+- soundboard actions;
+- Observation mode;
+- an Observation viewport-frame toggle;
+- existing bus listeners;
+- no general host bridge;
+- no general scenario/recording system;
+- no structured fictional telemetry model.
+
+### 3.1 Control-bus inventory safeguard
+
+Review materials conflict on the exact total count.
+
+One review states **22 targets: 14 range + 8 action**, but the same review explicitly names only seven action targets:
+
+```text
+master-play
+fnc-enable
+watch-experience
+observation-viewport
+red-alert
+generate-announcement
+mute
+```
+
+Therefore v5.2 MUST NOT hard-code 21 or 22 as a normative implementation fact.
+
+Phase 0 MUST enumerate the actual `register()` / `rangeTarget()` calls in the target commit and produce the authoritative inventory.
+
+The implementation plan below accounts for the known named targets and requires any additional verified target to be added before coding proceeds.
+
+---
+
+## 4. Scope
+
+Version 5.2 implementation includes:
+
+- canonical external target registry;
+- direct adapters to existing setters where possible;
+- new absolute setters where required;
+- `describe`;
+- target descriptors;
+- state snapshot;
+- state revision;
+- event stream;
+- source normalization;
+- capability reporting;
+- host attachment;
+- explicit speech target;
+- sound/event exposure;
+- universe/preset exposure;
+- safe ticker text exposure;
+- compatibility with current MIDI and native UI behavior.
+
+Structured telemetry remains optional and does not block first conformance.
+
+---
+
+## 5. Explicitly Out of Scope
+
+The normal SciFi-XZBT HTML will not gain:
+
+- scenario authoring;
+- general scenario file loading;
+- scenario recording;
+- scenario library management;
+- timeline editing;
+- webhook endpoints;
+- third-party connectors;
+- Streamer.bot integration;
+- Internet-facing authentication;
+- remote administration;
+- external telemetry acquisition;
+- scenario scheduling;
+- scenario chaining;
+- host-side queue management;
+- NGN administration;
+- casting server functionality.
+
+These remain XZBT-NGN responsibilities.
+
+---
+
+## 6. Canonical External Target Registry
+
+The external registry is the single source of truth for:
+
+- `describe`;
+- validation;
+- state readers;
+- state writers;
+- event target IDs;
+- NGN discovery.
+
+Existing internal IDs are not renamed.
+
+### 6.1 Known range mappings
+
+The following known range targets are pure external-to-internal mappings in concept, but their current synthetic-DOM application path must be replaced by direct setter calls during convergence.
+
+```text
+mix.master -> master-vol
+mix.hull.level -> hull-vol
+mix.hull.frequency -> hull-freq
+mix.hull.cutoff -> hull-cutoff
+mix.drive.level -> warp-vol
+mix.drive.pulse-rate -> warp-bpm
+mix.drive.carrier -> warp-carrier
+mix.environment.level -> air-vol
+mix.environment.cutoff -> air-cutoff
+mix.telemetry.level -> telemetry-vol
+mix.telemetry.density -> telemetry-density
+speech.robot-amount -> ai-robot-amount
+fnc.level -> fnc-level
+view.activity -> observation-activity
+```
+
+Phase 0 MUST confirm all 14 names against the repository before implementation.
+
+### 6.2 Known action/state adapters
+
+These are not simple renames:
+
+```text
+transport.playing
+transport.muted
+fnc.enabled
+view.observation
+view.viewport-frame
+event.alert-red
+alert.active
+speech.say
+```
+
+Known internal candidates include:
+
+```text
+master-play
+mute
+fnc-enable
+watch-experience
+observation-viewport
+red-alert
+generate-announcement
+```
+
+The external semantics are defined below and MUST NOT be implemented as blind DOM clicks.
+
+### 6.3 Universe and preset selection
+
+Add new canonical targets:
+
+```text
+universe.selected
+preset.selected
+```
+
+These wrap the existing verified universe/preset setter functions.
+
+They are new contract registrations, not mappings of pre-existing bus targets.
+
+---
+
+## 7. Absolute State Semantics
+
+Persistent contract state MUST be idempotent.
+
+### 7.1 Transport playing
+
+Add:
+
+```text
+setPlaying(on, source)
+```
+
+It MUST:
+
+- read current playing state;
+- do nothing on a no-op;
+- await startup/shutdown transition completion;
+- not silently drop a request because a transition is already in progress;
+- surface failure to the contract adapter.
+
+### 7.2 Transport muted
+
+Add:
+
+```text
+setMuted(on, source)
+```
+
+It MUST be absolute and idempotent.
+
+### 7.3 FNC enabled
+
+Use the existing absolute FNC setter directly where verified.
+
+Do not synthesize a DOM click.
+
+### 7.4 Observation mode
+
+`view.observation` means entering or exiting the overall Observation experience.
+
+Use existing absolute enter/exit functions where available.
+
+Add a readable getter for current Observation state.
+
+### 7.5 Viewport frame
+
+`view.viewport-frame` means the viewport/bezel-frame state inside Observation.
+
+It is separate from `view.observation`.
+
+### 7.6 Alert
+
+`event.alert-red` is an impulse.
+
+`alert.active` is persistent state, preferably a selection:
+
+```text
+none
+red
+yellow
+```
+
+The two semantics MUST remain separate.
+
+---
+
+## 8. Fixed Target Catalog Decision
+
+SciFi-XZBT v5.2 adopts a **fixed canonical target catalog**.
+
+Universe-specific actions and sound effects remain discoverable across universe changes.
+
+If a target is not valid in the current context, an attempt to invoke it returns `CAPABILITY_UNAVAILABLE` or another appropriate context error.
+
+A universe change SHOULD NOT remove and re-add large sections of the target registry.
+
+Therefore `registryRevision` remains stable unless target definitions themselves actually change.
+
+This prevents unnecessary NGN re-description churn.
+
+---
+
+## 9. Describe Implementation
+
+Add a contract description provider containing:
+
+- exhibit identity;
+- build/version metadata;
+- contract version;
+- registry revision;
+- state revision;
+- capability descriptors;
+- target descriptors.
+
+The canonical external registry is the source of truth.
+
+Descriptors MUST NOT be duplicated in separate hand-maintained tables.
+
+---
+
+## 10. State Snapshot
+
+Create one authoritative function that returns contract-visible persistent state.
+
+Initial expected fields include:
+
+```text
+transport.playing
+transport.muted
+mix.master
+mix.hull.level
+mix.hull.frequency
+mix.hull.cutoff
+mix.drive.level
+mix.drive.pulse-rate
+mix.drive.carrier
+mix.environment.level
+mix.environment.cutoff
+mix.telemetry.level
+mix.telemetry.density
+speech.robot-amount
+fnc.enabled
+fnc.level
+view.observation
+view.viewport-frame
+view.activity
+alert.active
+universe.selected
+preset.selected
+```
+
+Phase 0 must verify each read path.
+
+Known required additions include:
+
+- readable Observation state;
+- canonical universe getter;
+- canonical preset getter;
+- canonical alert-state reader if not already directly exposed.
+
+---
+
+## 11. Universe and Preset Behavior
+
+When `universe.selected` changes:
+
+1. validate the universe ID against the real registry;
+2. apply the universe;
+3. select that universe's defined default preset;
+4. commit both values in one mutation transaction;
+5. emit the resulting selection/state events with one resulting `stateRevision`.
+
+The system MUST NOT leave `preset.selected` pointing at a preset invalid for the current universe.
+
+Unknown universe or preset IDs return `INVALID_VALUE`.
+
+---
+
+## 12. Mutation Chokepoint
+
+All contract-visible persistent mutations MUST pass through one canonical mutation service in `js/app.js`.
+
+Recommended conceptual API:
+
+```text
+applyMutation(targetId, value, source, context)
+```
+
+The implementation may use a class or object rather than this exact function name, but there MUST be one authoritative mutation path.
+
+Responsibilities:
+
+- validate target and value;
+- call the direct underlying setter;
+- detect no-op writes;
+- commit one mutation transaction;
+- increment `stateRevision` once when state changes;
+- emit normalized state/selection events;
+- preserve authoritative source;
+- return success/failure.
+
+The existing `XZBTControlBus.listeners` mechanism should be reused or extended as an event-subscription foundation where practical.
+
+---
+
+## 13. Remove Synthetic-DOM Mutation as the Canonical Path
+
+Existing range bus application currently reaches real setters by assigning DOM values and dispatching synthetic `input` / `change` events.
+
+Version 5.2 MUST separate programmatic state mutation from DOM event simulation.
+
+The canonical mutation path MUST call the real setter directly.
+
+UI listeners should call the canonical mutation path, not the other way around.
+
+This prevents double application and makes source attribution reliable.
+
+---
+
+## 14. UI, MIDI, Hotkey, and Host Convergence
+
+Required architecture:
+
+```text
+UI
+MIDI
+Hotkey
+Host Contract
+Scenario-originated local runtime
+ |
+ v
+Canonical Mutation / Invoke Service
+ |
+ v
+Existing SciFi-XZBT subsystem
+```
+
+Every path that changes contract-visible state MUST use the canonical service.
+
+Every path that performs a contract-visible impulse MUST use the canonical invoke service.
+
+The phrase "where practical" is removed for contract-visible behavior.
+
+---
+
+## 15. State Revision
+
+SciFi-XZBT follows the contract's mutation-transaction rule.
+
+One top-level mutation transaction produces at most one new `stateRevision`.
+
+A no-op produces no revision increment.
+
+The revision increments after commit and before resulting events are emitted.
+
+Universe changes plus default-preset selection are one transaction.
+
+Preset application may update multiple exposed values in one transaction.
+
+---
+
+## 16. Event Surface
+
+SciFi-XZBT MUST emit normalized events.
+
+At minimum:
+
+```text
+state.changed
+action.executed
+selection.changed
+capability.changed
+registry.changed
+error
+```
+
+Each event includes:
+
+- sequence;
+- timestamp;
+- target where applicable;
+- value or args;
+- authoritative source;
+- state revision where applicable;
+- correlation ID when directly caused by a host request.
+
+Session event sequence resets on new contract session.
+
+---
+
+## 17. Source Normalization
+
+Use:
+
+```text
+ui
+midi
+hotkey
+host
+scenario
+internal
+system
+```
+
+A host-supplied `source` is ignored.
+
+The bridge assigns `host`.
+
+Packaged scenario runtime assigns `scenario`.
+
+Existing MIDI routing retains `midi`.
+
+---
+
+## 18. Speech
+
+Expose:
+
+```text
+speech.say
+```
+
+This calls the existing explicit `speak(text)` implementation.
+
+Do not confuse it with "generate announcement."
+
+If generated-announcement behavior is exposed separately, it receives a distinct impulse target such as:
+
+```text
+speech.generate-announcement
+```
+
+SciFi-XZBT remains responsible for:
+
+- TTS loading;
+- preferred engine;
+- fallback synthesis;
+- selected voice;
+- robotic voice processing;
+- spoken-unit normalization;
+- interruption behavior;
+- playback.
+
+### 18.1 Speech capability state
+
+Add a readable capability state field to the generative/speech subsystem.
+
+Use:
+
+```text
+available
+loading
+ready
+busy
+error
+```
+
+Use `unsupported` only when speech is not implemented at all.
+
+### 18.2 Queue behavior
+
+Current behavior interrupts prior speech.
+
+Preserve that behavior in the first implementation.
+
+A successful `speech.say` means accepted and started, not completed.
+
+### 18.3 Length limit
+
+Set a conservative maximum accepted text length.
+
+Recommended first limit:
+
+```text
+2000 characters
+```
+
+This limit is an implementation constant and may be tuned later.
+
+Over-limit requests return `INVALID_VALUE`.
+
+---
+
+## 19. Sound Effects
+
+Build a verified stable public inventory from the current soundboard and synthesis dispatch.
+
+Do not automatically expose every internal synthesis helper.
+
+The fixed external catalog may include targets that are contextually unavailable in the current universe.
+
+Unavailable invocation returns a defined error instead of removing the target.
+
+---
+
+## 20. Universe Events
+
+Build a verified stable public event catalog from current universe `events[]` definitions and actual dispatch behavior.
+
+Do not treat examples as authoritative inventory.
+
+Keep persistent alert state separate from alert impulses.
+
+---
+
+## 21. Display Ticker
+
+Expose:
+
+```text
+display.ticker
+```
+
+as a **transient write**.
+
+Semantics:
+
+- text is immediately displayed;
+- it remains subject to normal exhibit ticker replacement;
+- the contract does not claim persistence;
+- the exhibit SHOULD emit a state/action event indicating the accepted write;
+- a future persistent-host-caption feature would use a different target.
+
+Use safe `textContent`-style assignment.
+
+Recommended maximum:
+
+```text
+512 characters
+```
+
+Over-limit input returns `INVALID_VALUE`.
+
+Do not invent `display.status` or `display.transient` until real surfaces exist.
+
+---
+
+## 22. Telemetry
+
+The current codebase does not yet have structured fictional display telemetry fields.
+
+Version 5.2 keeps two stages.
+
+### Stage A - Contract readiness
+
+Ensure future `telemetry.*` targets can be added cleanly.
+
+### Stage B - New exhibit feature
+
+Later create real structured telemetry fields with meaningful visible representation.
+
+Only then advertise writable telemetry capabilities.
+
+Structured telemetry is not required for first conformance.
+
+---
+
+## 23. Host Attachment
+
+Initial host attachment uses:
+
+1. same-origin parent/frame `postMessage`;
+2. optional explicit trusted wrapper binding.
+
+The bridge MUST validate:
+
+- `event.origin`;
+- `event.source`;
+- contract version;
+- session;
+- message type;
+- target;
+- value type;
+- numeric bounds;
+- args schema;
+- unexpected args keys;
+- selection membership;
+- capability state;
+- text length limits.
+
+The base exhibit does not automatically connect to remote services.
+
+No webhook listener belongs in the HTML.
+
+---
+
+## 24. Capability Model
+
+Initial capabilities may include:
+
+```text
+audio
+speech
+midi
+observation
+display-text
+```
+
+Do not advertise telemetry-write until it exists.
+
+A target's `requires` list references these capability IDs.
+
+Context-specific target unavailability does not require capability removal.
+
+---
+
+## 25. Registry Revision
+
+Because v5.2 uses a fixed canonical target catalog, ordinary universe changes SHOULD NOT change `registryRevision`.
+
+Increment it only when:
+
+- a target is added or removed;
+- target descriptor metadata changes;
+- option metadata genuinely changes in a way that changes the registry contract.
+
+Use contextual availability errors rather than registry churn where practical.
+
+---
+
+## 26. Build and Packaging
+
+The existing PowerShell packaging process remains authoritative for the ordinary single-file standalone artifact.
+
+Contract integration MUST preserve the packaging script's expected include patterns.
+
+Ordinary free packaging remains independent of XZBT-NGN.
+
+Future NGN scenario packaging is separate.
+
+---
+
+## 27. Implementation Phases
+
+### Phase 0 - Repository verification
+
+Produce an authoritative inventory from the target commit.
+
+Required output:
+
+- every control-bus registration;
+- all internal IDs;
+- range/action counts;
+- direct backing setter;
+- current source paths;
+- universe/preset accessors;
+- event/sound catalogs;
+- package-script assumptions.
+
+Do not proceed while target count remains disputed.
+
+### Phase 1 - Canonical registry
+
+Create the single source of truth for canonical external target descriptors and internal adapters.
+
+### Phase 2 - Direct setters and absolute state
+
+Implement direct range mutation and absolute setters for toggle-like state.
+
+### Phase 3 - Mutation service
+
+Implement the canonical mutation/invoke chokepoint and transaction semantics.
+
+### Phase 4 - State and discovery
+
+Implement:
+
+- `describe`;
+- `state.get`;
+- `stateRevision`;
+- `registryRevision`;
+- capabilities.
+
+### Phase 5 - Input convergence
+
+Rewrite all contract-visible UI, MIDI, and hotkey mutation paths to call the canonical service.
+
+This is a substantial refactor, not a small wiring task.
+
+No contract-visible inline listener may bypass the canonical mutation path at phase completion.
+
+### Phase 6 - Event normalization
+
+Publish normalized events with source, sequence, revision, and correlation.
+
+### Phase 7 - Speech, SFX, universe events, ticker
+
+Expose verified exhibit functions through canonical targets.
+
+### Phase 8 - Host bridge
+
+Implement same-origin messaging and validation.
+
+### Phase 9 - Standalone verification
+
+Confirm complete application behavior without any host.
+
+### Phase 10 - Optional structured telemetry
+
+Only after intentionally designing real telemetry fields.
+
+---
+
+## 28. First Contract-Compatible Acceptance Criteria
+
+The first compatible release succeeds when:
+
+- SciFi-XZBT still runs normally by itself;
+- a test host completes handshake;
+- a test host calls `describe`;
+- the authoritative target inventory matches the repository;
+- persistent state can be read;
+- representative ranges can be set directly;
+- toggle-like targets are absolute and idempotent;
+- setting a boolean state to its current value twice leaves the same state;
+- universe changes select a valid default preset;
+- Observation and viewport-frame controls are distinct;
+- representative sound and visual impulses can be invoked;
+- `speech.say` accepts explicit text;
+- `display.ticker` behaves according to its transient contract;
+- UI, MIDI, hotkey, and host changes emit normalized events;
+- event source is accurate;
+- session sequence and state revision work;
+- no-op sets do not increment revision;
+- malformed commands fail safely;
+- unexpected origins are ignored;
+- no scenario engine or webhook system has been added.
+
+Structured telemetry is not required.
+
+---
+
+## 29. Future Telemetry Acceptance Criteria
+
+When structured telemetry is later added:
+
+- each field has a real visible or meaningful exhibit representation;
+- metadata is discoverable;
+- read-only and writable fields are explicit;
+- external ownership prevents immediate simulation overwrite;
+- stale or released ownership returns cleanly to simulation;
+- NGN remains responsible for acquiring real telemetry.
+
+---
+
+## 30. Architectural Test
+
+When considering a future feature:
+
+**Does SciFi-XZBT need this to remain a complete standalone exhibit?**
+
+If yes, it may belong in SciFi-XZBT.
+
+**Does the feature coordinate, automate, connect, record, author, distribute, or externally control the exhibit?**
+
+If yes, it belongs primarily in XZBT-NGN.
+
+---
+
+## 31. Version 5.2 Summary
+
+Version 5.2 does not change the Version 5 architecture.
+
+It makes the SciFi-XZBT implementation plan concrete enough to begin coding after Phase 0 verification.
+
+The implementation must normalize the real application rather than pretending that existing DOM-click proxies and synthetic events already satisfy the contract.
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..b1a21e8
--- /dev/null
+++ b/package.json
@@ -0,0 +1,11 @@
+{
+ "name": "xzbt-ngn",
+ "version": "0.1.0",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "start": "node server/serve.js",
+ "test": "node --test"
+ },
+ "engines": { "node": ">=22" }
+}
diff --git a/public/index.html b/public/index.html
new file mode 100644
index 0000000..be85778
--- /dev/null
+++ b/public/index.html
@@ -0,0 +1,20 @@
+
+
+
XZBT-NGN Exhibit Engine
+
+
+
+ Connection
+
+ Disconnected
Refresh state
+ Only load trusted local exhibits. Required capabilities in available/loading states may accept requests through an exhibit fallback; the exhibit decides.
+
+
+ Session and exhibit No negotiated session. Capabilities []
+ Discovered targets Connect to discover the catalog.
+ Current persistent state Last reported exhibit values. This host view may be uncertain while resynchronizing.
{}
+ Incoming events Latest 200 events, including source, sequence, revision, and correlation when supplied.
[]
+
+
+
+
diff --git a/public/style.css b/public/style.css
new file mode 100644
index 0000000..2bff3cd
--- /dev/null
+++ b/public/style.css
@@ -0,0 +1,15 @@
+:root { font: 15px/1.5 system-ui, sans-serif; color: #e5ebf1; background: #121921; color-scheme: dark; }
+body { margin: 0; } header, main { max-width: 1280px; margin: auto; padding: 20px; }
+header { padding-bottom: 0; } h1 { font-size: 25px; } h2 { font-size: 19px; margin-top: 0; }
+h3 { font-size: 16px; } p { color: #b7c4d1; } section { padding: 20px; margin-bottom: 20px; background: #1a2530; border: 1px solid #354658; border-radius: 6px; }
+button, input, select, textarea { font: inherit; padding: 8px; border: 1px solid #587086; border-radius: 4px; }
+button { background: #224b65; color: #fff; cursor: pointer; } button:disabled { opacity: .5; cursor: default; }
+input, select, textarea { background: #111d28; color: #fff; max-width: 100%; box-sizing: border-box; }
+label { display: block; margin: 8px 0; } input[type=checkbox] { margin-right: 8px; }
+.connection-row { display: flex; flex-wrap: wrap; gap: 8px; } #exhibit-url { flex: 1; min-width: 220px; }
+iframe { width: 100%; height: 390px; margin-top: 15px; border: 1px solid #587086; background: white; }
+pre { white-space: pre-wrap; overflow-wrap: anywhere; background: #111d28; padding: 12px; max-height: 350px; overflow: auto; }
+.target { border-top: 1px solid #354658; padding: 16px 0; } .target h3 { margin: 0; }
+.controls { display: flex; align-items: end; flex-wrap: wrap; gap: 12px; } .controls label { min-width: 150px; }
+.notice { color: #ffcd7a; } details { margin: 8px 0; } summary { cursor: pointer; } output { display: block; margin: 8px 0; }
+button:focus-visible, input:focus-visible, select:focus-visible { outline: 2px solid #7cceff; outline-offset: 2px; }
diff --git a/server/serve.js b/server/serve.js
new file mode 100644
index 0000000..c3fb178
--- /dev/null
+++ b/server/serve.js
@@ -0,0 +1,36 @@
+import http from 'node:http';
+import { readFile, realpath, stat } from 'node:fs/promises';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const root = fileURLToPath(new URL('../', import.meta.url));
+const mime = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8',
+ '.json': 'application/json', '.png': 'image/png', '.jpg': 'image/jpeg', '.svg': 'image/svg+xml', '.wav': 'audio/wav', '.mp3': 'audio/mpeg' };
+export function createServer() {
+ return http.createServer(async (req, res) => {
+ try {
+ if (!['GET', 'HEAD'].includes(req.method)) { res.writeHead(405); res.end('Method not allowed'); return; }
+ const url = new URL(req.url, 'http://localhost');
+ const pathname = decodeURIComponent(url.pathname);
+ // Serve only the product surface and explicitly owned fixture subtree.
+ const route = pathname === '/' ? '/public/index.html' : pathname;
+ const mount = ['/public/', '/src/', '/test-fixtures/'].find(prefix => route.startsWith(prefix));
+ if (!mount || route.includes('\\') || route.includes('\0')) throw new Error('Not found');
+ const base = await realpath(path.join(root, mount));
+ let candidate = await realpath(path.join(root, route));
+ const relative = path.relative(base, candidate);
+ if (relative.startsWith('..') || path.isAbsolute(relative)) throw new Error('Not found');
+ if ((await stat(candidate)).isDirectory()) candidate = await realpath(path.join(candidate, 'index.html'));
+ const finalRelative = path.relative(base, candidate);
+ if (finalRelative.startsWith('..') || path.isAbsolute(finalRelative)) throw new Error('Not found');
+ const content = await readFile(candidate);
+ res.writeHead(200, { 'Content-Type': mime[path.extname(candidate)] ?? 'application/octet-stream',
+ 'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff', 'Content-Length': content.length });
+ res.end(req.method === 'HEAD' ? undefined : content);
+ } catch { res.writeHead(404, { 'Content-Type': 'text/plain' }); res.end('Not found'); }
+ });
+}
+if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
+ const port = Number(process.env.PORT ?? 4173);
+ createServer().listen(port, '127.0.0.1', () => console.log(`XZBT-NGN: http://127.0.0.1:${port}`));
+}
diff --git a/src/host.js b/src/host.js
new file mode 100644
index 0000000..2a8f12f
--- /dev/null
+++ b/src/host.js
@@ -0,0 +1,199 @@
+import { ProtocolError, record, counter, check, validateCatalog, validateArgs, validateSet, validateReportedValue } from './validation.js';
+
+const events = new Set(['state.changed', 'selection.changed', 'action.executed', 'capability.changed', 'registry.changed', 'error']);
+const responses = { hello: 'hello.result', describe: 'describe.result', 'state.get': 'state.result', set: 'set.result', invoke: 'invoke.result' };
+
+export class ExhibitHost {
+ constructor({ timeoutMs = 5000, debounceMs = 100 } = {}) {
+ this.timeoutMs = timeoutMs; this.debounceMs = debounceMs;
+ this.listeners = new Set(); this.pending = new Map(); this.logs = [];
+ this.generation = 0; this.requestNumber = 0; this.resetView();
+ }
+ resetView() {
+ this.status = 'disconnected'; this.sessionId = null; this.contract = null; this.exhibit = null;
+ this.catalog = []; this.capabilities = []; this.registryRevision = null; this.stateRevision = null;
+ this.sequence = null; this.values = new Map(); this.sync = 'not synchronized'; this.eventLog = [];
+ this.snapshotEvents = null; this.refreshing = null; this.refreshWanted = false;
+ }
+ subscribe(fn) { this.listeners.add(fn); return () => this.listeners.delete(fn); }
+ changed() { for (const fn of this.listeners) fn(this); }
+ log(code, message, detail = null) {
+ this.logs.push({ time: new Date().toISOString(), code, message, detail });
+ if (this.logs.length > 200) this.logs.shift();
+ this.changed();
+ }
+ report(error) { this.log(error.code ?? 'HOST_ERROR', error.message); }
+ disconnect() {
+ this.generation++;
+ clearTimeout(this.refreshTimer);
+ this.unsubscribe?.(); this.transport?.close(); this.transport = null;
+ for (const p of this.pending.values()) { clearTimeout(p.timer); p.reject(new ProtocolError('DISCONNECTED', 'Session ended.')); }
+ this.pending.clear(); this.resetView(); this.changed();
+ }
+ async connect(transport) {
+ this.disconnect(); this.transport = transport;
+ this.unsubscribe = transport.subscribe(message => this.receive(message));
+ this.status = 'negotiating'; this.changed();
+ const generation = this.generation;
+ try {
+ const hello = await this.request('hello', { host: { name: 'XZBT-NGN', version: '0.1.0' }, supportedContractMajors: [5] });
+ if (generation !== this.generation) return;
+ check(hello.contract?.major === 5 && counter(hello.contract?.minor), 'Exhibit negotiated an unsupported contract.', 'UNSUPPORTED_VERSION');
+ check(typeof hello.sessionId === 'string' && hello.sessionId.length > 0 && record(hello.exhibit), 'Invalid hello result.');
+ this.sessionId = hello.sessionId; this.contract = hello.contract; this.exhibit = hello.exhibit;
+ this.status = 'connected'; this.log('SESSION', 'Exhibit session established.', { sessionId: this.sessionId });
+ await this.refresh(true);
+ } catch (error) {
+ if (generation === this.generation) { this.status = 'error'; this.report(error); }
+ throw error;
+ }
+ }
+ request(type, payload = {}) {
+ if (!this.transport || (type !== 'hello' && (!this.sessionId || this.status !== 'connected'))) {
+ return Promise.reject(new ProtocolError('INVALID_SESSION', 'Connect an exhibit session first.'));
+ }
+ const requestId = `ngn-${this.generation}-${++this.requestNumber}`;
+ return new Promise((resolve, reject) => {
+ const timer = setTimeout(() => {
+ this.pending.delete(requestId);
+ reject(new ProtocolError('TIMEOUT', `${type} timed out; its outcome may be unknown.`));
+ }, this.timeoutMs);
+ this.pending.set(requestId, { resolve, reject, timer, type });
+ try {
+ this.transport.send({ xzbt: '5.2', type, requestId, ...(type === 'hello' ? {} : { sessionId: this.sessionId }), ...payload });
+ } catch (error) { clearTimeout(timer); this.pending.delete(requestId); reject(error); }
+ });
+ }
+ receive(message) {
+ try {
+ check(record(message) && typeof message.type === 'string' && typeof message.xzbt === 'string', 'Malformed contract envelope.');
+ const pending = typeof message.requestId === 'string' && this.pending.get(message.requestId);
+ if (pending && !(message.type === 'error' && counter(message.sequence))) {
+ if (pending.type !== 'hello' && message.sessionId !== this.sessionId) {
+ this.log('STALE_SESSION', 'Ignored response for another session.'); return;
+ }
+ clearTimeout(pending.timer); this.pending.delete(message.requestId);
+ if (message.type === 'error') {
+ const error = new ProtocolError(message.error?.code ?? 'INVALID_MESSAGE', message.error?.message ?? 'Malformed error response.');
+ if (error.code === 'INVALID_SESSION') this.invalidate();
+ pending.reject(error); return;
+ }
+ if (message.type !== responses[pending.type] || (['set', 'invoke'].includes(pending.type) && message.ok !== true)) {
+ pending.reject(new ProtocolError('INVALID_MESSAGE', 'Unexpected response type or success flag.')); return;
+ }
+ pending.resolve(message); return;
+ }
+ if (!this.sessionId || message.sessionId !== this.sessionId) {
+ this.log('STALE_SESSION', 'Ignored message outside the negotiated session.'); return;
+ }
+ check(events.has(message.type), 'Unknown event or uncorrelated response.');
+ check(counter(message.sequence) && Number.isFinite(message.timestamp), 'Invalid event sequence or timestamp.');
+ if (this.sequence !== null && message.sequence !== this.sequence + 1) {
+ this.log('SEQUENCE_GAP', `Expected ${this.sequence + 1}, received ${message.sequence}.`);
+ this.scheduleRefresh(false);
+ if (message.sequence <= this.sequence) return;
+ }
+ this.sequence = message.sequence;
+ this.eventLog.push(message); if (this.eventLog.length > 200) this.eventLog.shift();
+ if (['state.changed', 'selection.changed'].includes(message.type)) {
+ if (this.snapshotEvents) this.snapshotEvents.push(message);
+ this.applyStateEvent(message);
+ }
+ if (message.type === 'registry.changed' || message.type === 'capability.changed') this.scheduleRefresh(true);
+ if (message.type === 'error') {
+ this.log(message.error?.code ?? 'INVALID_MESSAGE', message.error?.message ?? 'Malformed error event.', message);
+ if (message.error?.code === 'INVALID_SESSION') this.invalidate();
+ }
+ this.changed();
+ } catch (error) { this.report(error); if (this.sessionId) this.scheduleRefresh(false); }
+ }
+ invalidate() {
+ this.status = 'invalid session'; this.sync = 'reconnect required';
+ clearTimeout(this.refreshTimer);
+ for (const p of this.pending.values()) { clearTimeout(p.timer); p.reject(new ProtocolError('INVALID_SESSION', 'Reconnect to establish a new session.')); }
+ this.pending.clear(); this.changed();
+ }
+ applyStateEvent(event, replay = false) {
+ const target = this.catalog.find(t => t.id === event.target);
+ check(target && target.readable && target.kind !== 'impulse' && Object.hasOwn(event, 'value')
+ && counter(event.stateRevision), 'Invalid persistent-state event.');
+ validateReportedValue(target, event.value);
+ if (this.stateRevision !== null && event.stateRevision < this.stateRevision) return;
+ if (!replay && this.stateRevision !== null && event.stateRevision > this.stateRevision + 1) {
+ this.log('REVISION_GAP', `State revision advanced from ${this.stateRevision} to ${event.stateRevision}.`);
+ this.scheduleRefresh(false);
+ }
+ this.values.set(event.target, event.value); this.stateRevision = event.stateRevision;
+ }
+ scheduleRefresh(describe) {
+ if (this.status !== 'connected') return;
+ this.sync = 'resynchronizing'; this.refreshWanted ||= describe;
+ clearTimeout(this.refreshTimer);
+ this.refreshTimer = setTimeout(() => {
+ const wanted = this.refreshWanted; this.refreshWanted = false;
+ this.refresh(wanted).catch(error => this.report(error));
+ }, this.debounceMs);
+ }
+ async refresh(describe = false) {
+ if (this.refreshing) {
+ const generation = this.generation;
+ await this.refreshing;
+ if (generation !== this.generation) return;
+ return this.refresh(describe);
+ }
+ const generation = this.generation;
+ const work = async () => {
+ this.sync = 'resynchronizing'; this.changed();
+ if (describe) {
+ const description = await this.request('describe');
+ if (generation !== this.generation) return;
+ validateCatalog(description);
+ this.catalog = description.targets; this.capabilities = description.capabilities;
+ this.registryRevision = description.registryRevision; this.exhibit = description.exhibit;
+ this.changed();
+ }
+ this.snapshotEvents = [];
+ const baseline = this.stateRevision;
+ const snapshot = await this.request('state.get');
+ if (generation !== this.generation) return;
+ check(counter(snapshot.stateRevision) && record(snapshot.values), 'Invalid state snapshot.');
+ check(baseline === null || snapshot.stateRevision >= baseline, 'Snapshot revision regressed.');
+ const readable = new Set(this.catalog.filter(t => t.readable && t.kind !== 'impulse').map(t => t.id));
+ check(Object.keys(snapshot.values).every(id => readable.has(id)), 'Snapshot contains an unknown or non-readable target.');
+ check([...readable].every(id => Object.hasOwn(snapshot.values, id)), 'Snapshot omits readable persistent state.');
+ for (const target of this.catalog) if (readable.has(target.id)) validateReportedValue(target, snapshot.values[target.id]);
+ this.values = new Map(Object.entries(snapshot.values)); this.stateRevision = snapshot.stateRevision;
+ for (const event of this.snapshotEvents) this.applyStateEvent(event, true);
+ this.snapshotEvents = null; this.sync = 'synchronized'; this.changed();
+ };
+ this.refreshing = work();
+ try { await this.refreshing; }
+ catch (error) { if (generation === this.generation) { this.sync = 'uncertain'; this.snapshotEvents = null; } throw error; }
+ finally { if (generation === this.generation) { this.refreshing = null; this.changed(); } }
+ }
+ target(id) {
+ const target = this.catalog.find(t => t.id === id);
+ check(target, 'Unknown target.', 'UNKNOWN_TARGET'); return target;
+ }
+ unavailable(target) {
+ return target.requires.filter(id => {
+ const capability = this.capabilities.find(c => c.id === id);
+ return !capability || ['unsupported', 'error'].includes(capability.state);
+ });
+ }
+ async operate(type, id, data) {
+ try {
+ const target = this.target(id);
+ check(this.unavailable(target).length === 0, 'A required capability is unavailable.', 'CAPABILITY_UNAVAILABLE');
+ if (type === 'set') validateSet(target, data);
+ else { check(target.kind === 'impulse', 'Target is not invokable.', 'TARGET_NOT_INVOKABLE'); validateArgs(target, data); }
+ await this.request(type, { target: id, [type === 'set' ? 'value' : 'args']: data });
+ await this.refresh(false);
+ } catch (error) {
+ if (error.code === 'TIMEOUT') { this.sync = 'uncertain'; this.scheduleRefresh(false); }
+ this.report(error); throw error;
+ }
+ }
+ set(id, value) { return this.operate('set', id, value); }
+ invoke(id, args = {}) { return this.operate('invoke', id, args); }
+}
diff --git a/src/transport/post-message.js b/src/transport/post-message.js
new file mode 100644
index 0000000..f914a3d
--- /dev/null
+++ b/src/transport/post-message.js
@@ -0,0 +1,20 @@
+// This adapter alone knows about Window, origins, and message events.
+export function postMessageTransport(frame, ownWindow = window) {
+ const origin = ownWindow.location.origin;
+ if (origin === 'null' || new URL(frame.src, ownWindow.location.href).origin !== origin) {
+ throw new Error('The exhibit must be served from the same HTTP origin as NGN.');
+ }
+ const peer = frame.contentWindow;
+ const listeners = new Set();
+ const receive = event => {
+ if (event.origin === origin && event.source === peer) {
+ for (const listener of listeners) listener(event.data);
+ }
+ };
+ ownWindow.addEventListener('message', receive);
+ return {
+ send(message) { peer.postMessage(message, origin); },
+ subscribe(listener) { listeners.add(listener); return () => listeners.delete(listener); },
+ close() { ownWindow.removeEventListener('message', receive); listeners.clear(); }
+ };
+}
diff --git a/src/ui.js b/src/ui.js
new file mode 100644
index 0000000..676e2ac
--- /dev/null
+++ b/src/ui.js
@@ -0,0 +1,127 @@
+import { ExhibitHost } from './host.js';
+import { argumentSchema } from './validation.js';
+import { postMessageTransport } from './transport/post-message.js';
+
+const host = new ExhibitHost();
+const byId = id => document.getElementById(id);
+const json = value => JSON.stringify(value, null, 2);
+const rows = new Map();
+let frame = null, renderedCatalog = null;
+function element(tag, text, parent) {
+ const node = document.createElement(tag); if (text !== undefined) node.textContent = text;
+ parent?.append(node); return node;
+}
+function editor(spec, label, parent) {
+ const wrapper = element('label', label, parent);
+ const options = spec.enum;
+ const input = element(options ? 'select' : 'input', undefined, wrapper);
+ input.setAttribute('aria-label', label);
+ if (options) {
+ for (const [index, value] of options.entries()) {
+ const option = element('option', spec.optionLabels?.[index] ?? String(value), input); option.value = String(index);
+ }
+ } else {
+ input.type = spec.type === 'boolean' ? 'checkbox' : ['number', 'integer'].includes(spec.type) ? 'number' : 'text';
+ for (const key of ['min', 'max', 'step', 'minLength', 'maxLength']) if (spec[key] !== undefined) input[key] = spec[key];
+ if (input.type === 'number' && spec.step === undefined) input.step = spec.type === 'integer' ? '1' : 'any';
+ }
+ return {
+ input,
+ read() { return options ? options[Number(input.value)] : spec.type === 'boolean' ? input.checked
+ : ['number', 'integer'].includes(spec.type) ? (input.value === '' ? NaN : Number(input.value)) : input.value; },
+ write(value) {
+ if (options) input.value = String(options.findIndex(v => Object.is(v, value)));
+ else if (spec.type === 'boolean') input.checked = value === true;
+ else input.value = value ?? '';
+ }
+ };
+}
+function buildCatalog() {
+ rows.clear(); byId('catalog').replaceChildren();
+ for (const target of host.catalog) {
+ const section = element('article', undefined, byId('catalog')); section.className = 'target';
+ element('h3', target.label ?? target.id, section); element('code', target.id, section);
+ element('p', `${target.kind} · ${target.readable ? 'readable' : 'not readable'} · ${target.writable ? 'writable' : 'not writable'}`, section);
+ const current = element('output', '', section);
+ const detail = element('details', undefined, section); element('summary', 'Target descriptor', detail); element('pre', json(target), detail);
+ const notice = element('p', '', section); notice.className = 'notice';
+ const form = element('form', undefined, section); form.className = 'controls'; form.noValidate = true;
+ let valueEditor, schemaError = '', argumentEditors = [];
+ if (target.kind === 'impulse') {
+ try {
+ for (const arg of argumentSchema(target)) {
+ const group = element('div', undefined, form);
+ let include = null;
+ if (!arg.required) { const l = element('label', `Include ${arg.label ?? arg.name}`, group); include = element('input', undefined, l); include.type = 'checkbox'; }
+ const field = editor(arg, arg.label ?? arg.name, group);
+ if (arg.description) element('p', arg.description, group);
+ argumentEditors.push({ arg, include, field });
+ }
+ } catch (error) { schemaError = error.message; }
+ } else if (target.writable) {
+ if (target.kind === 'selection') valueEditor = editor({ enum: target.options.map(o => o.value), optionLabels: target.options.map(o => o.label) }, 'New value', form);
+ else if (target.kind === 'range' || ['boolean', 'number', 'integer', 'string'].includes(target.valueType)) {
+ valueEditor = editor({ ...target, type: target.kind === 'range' ? 'number' : target.valueType }, 'New value', form);
+ } else schemaError = 'Unsupported state value type.';
+ }
+ let submit = null;
+ let row;
+ if (target.kind === 'impulse' || target.writable) {
+ submit = element('button', target.kind === 'impulse' ? 'Invoke' : 'Set', form);
+ form.addEventListener('submit', async event => {
+ event.preventDefault(); if (row.busy) return; row.busy = true; submit.disabled = true;
+ try {
+ if (target.kind === 'impulse') {
+ const args = Object.create(null);
+ for (const { arg, include, field } of argumentEditors) if (!include || include.checked) args[arg.name] = field.read();
+ await host.invoke(target.id, args);
+ } else await host.set(target.id, valueEditor.read());
+ } catch { /* Host reports every control failure in the visible console. */ }
+ row.busy = false; render();
+ });
+ }
+ row = { target, current, notice, submit, schemaError, valueEditor, initialized: false, busy: false };
+ rows.set(target.id, row);
+ }
+}
+function render() {
+ byId('status').textContent = `${host.status} · ${host.sync}`;
+ byId('metadata').textContent = json({ sessionId: host.sessionId, contract: host.contract, exhibit: host.exhibit,
+ registryRevision: host.registryRevision, stateRevision: host.stateRevision, sequence: host.sequence });
+ byId('capabilities').textContent = json(host.capabilities);
+ byId('state').textContent = json(Object.fromEntries(host.values));
+ byId('events').textContent = json(host.eventLog);
+ byId('errors').textContent = json(host.logs);
+ byId('target-count').textContent = `(${host.catalog.length})`;
+ byId('refresh').disabled = host.status !== 'connected';
+ byId('reconnect').disabled = !frame;
+ if (host.catalog !== renderedCatalog) { renderedCatalog = host.catalog; buildCatalog(); }
+ for (const row of rows.values()) {
+ row.current.textContent = host.values.has(row.target.id) ? `Reported value: ${json(host.values.get(row.target.id))}` : 'No persistent value reported.';
+ if (!row.initialized && row.valueEditor && host.values.has(row.target.id)) {
+ row.valueEditor.write(host.values.get(row.target.id)); row.initialized = true;
+ }
+ const unavailable = host.unavailable(row.target);
+ row.notice.textContent = row.schemaError || (unavailable.length ? `Unavailable: ${unavailable.join(', ')}` : '');
+ if (row.submit) row.submit.disabled = row.busy || host.status !== 'connected' || !!row.schemaError || unavailable.length > 0;
+ }
+}
+async function attach() {
+ if (!frame) return;
+ try { await host.connect(postMessageTransport(frame)); } catch (error) { if (!error.code) host.report(error); }
+}
+byId('connection').addEventListener('submit', event => {
+ event.preventDefault();
+ try {
+ const url = new URL(byId('exhibit-url').value, location.href);
+ if (url.origin !== location.origin || !['http:', 'https:'].includes(url.protocol)) throw new Error('Enter a same-origin HTTP exhibit URL.');
+ host.disconnect(); frame?.remove();
+ frame = document.createElement('iframe'); frame.title = 'Connected exhibit';
+ frame.addEventListener('load', attach); frame.src = url.href;
+ byId('frame-container').append(frame); render();
+ } catch (error) { host.report(error); }
+});
+byId('reconnect').addEventListener('click', attach);
+byId('disconnect').addEventListener('click', () => { host.disconnect(); frame?.remove(); frame = null; render(); });
+byId('refresh').addEventListener('click', () => host.refresh().catch(error => host.report(error)));
+host.subscribe(render); render();
diff --git a/src/validation.js b/src/validation.js
new file mode 100644
index 0000000..cd48042
--- /dev/null
+++ b/src/validation.js
@@ -0,0 +1,106 @@
+export class ProtocolError extends Error {
+ constructor(code, message) { super(message); this.name = 'ProtocolError'; this.code = code; }
+}
+export const record = value => value !== null && typeof value === 'object' && !Array.isArray(value);
+export const counter = value => Number.isSafeInteger(value) && value >= 0;
+export function check(condition, message, code = 'INVALID_MESSAGE') {
+ if (!condition) throw new ProtocolError(code, message);
+}
+const types = ['string', 'number', 'integer', 'boolean'];
+export function argumentSchema(target) {
+ const args = target.arguments ?? [];
+ check(Array.isArray(args), 'Unsupported argument schema: arguments must be an array.', 'UNSUPPORTED_SCHEMA');
+ const names = new Set();
+ for (const arg of args) {
+ check(record(arg) && typeof arg.name === 'string' && !names.has(arg.name)
+ && types.includes(arg.type) && typeof arg.required === 'boolean',
+ 'Unsupported argument schema: invalid name, type, or required field.', 'UNSUPPORTED_SCHEMA');
+ names.add(arg.name);
+ for (const key of ['min', 'max', 'step']) {
+ check(arg[key] === undefined || (Number.isFinite(arg[key]) && ['number', 'integer'].includes(arg.type)
+ && (key !== 'step' || arg[key] > 0)), `Unsupported argument ${key}.`, 'UNSUPPORTED_SCHEMA');
+ }
+ for (const key of ['minLength', 'maxLength']) {
+ check(arg[key] === undefined || (counter(arg[key]) && arg.type === 'string'),
+ `Unsupported argument ${key}.`, 'UNSUPPORTED_SCHEMA');
+ }
+ check(arg.min === undefined || arg.max === undefined || arg.min <= arg.max, 'Inverted bounds.', 'UNSUPPORTED_SCHEMA');
+ check(arg.minLength === undefined || arg.maxLength === undefined || arg.minLength <= arg.maxLength,
+ 'Inverted length bounds.', 'UNSUPPORTED_SCHEMA');
+ check(arg.enum === undefined || (Array.isArray(arg.enum) && arg.enum.every(v => matches(v, arg.type))),
+ 'Unsupported argument enum.', 'UNSUPPORTED_SCHEMA');
+ }
+ return args;
+}
+function matches(value, type) {
+ return type === 'integer' ? Number.isSafeInteger(value)
+ : type === 'number' ? Number.isFinite(value) : typeof value === type;
+}
+export function validateValue(value, spec) {
+ const fail = message => check(false, message, 'INVALID_VALUE');
+ if (!matches(value, spec.type)) fail(`Expected ${spec.type}.`);
+ if (spec.enum && !spec.enum.includes(value)) fail('Value is not a declared option.');
+ if (typeof value === 'number') {
+ if (spec.min !== undefined && value < spec.min) fail(`Minimum is ${spec.min}.`);
+ if (spec.max !== undefined && value > spec.max) fail(`Maximum is ${spec.max}.`);
+ if (spec.step !== undefined) {
+ const units = (value - (spec.min ?? 0)) / spec.step;
+ if (Math.abs(units - Math.round(units)) > 1e-7) fail(`Value must follow step ${spec.step}.`);
+ }
+ }
+ if (typeof value === 'string') {
+ if (spec.minLength !== undefined && value.length < spec.minLength) fail(`Minimum length is ${spec.minLength}.`);
+ if (spec.maxLength !== undefined && value.length > spec.maxLength) fail(`Maximum length is ${spec.maxLength}.`);
+ }
+}
+export function validateArgs(target, args) {
+ check(record(args), 'Invoke args must be an object.', 'INVALID_VALUE');
+ const schema = argumentSchema(target);
+ const known = new Set(schema.map(a => a.name));
+ for (const key of Object.keys(args)) check(known.has(key), `Undeclared argument: ${key}`, 'INVALID_VALUE');
+ for (const arg of schema) {
+ const present = Object.hasOwn(args, arg.name);
+ check(present || !arg.required, `Required argument: ${arg.name}`, 'INVALID_VALUE');
+ if (present) validateValue(args[arg.name], arg);
+ }
+}
+export function validateSet(target, value) {
+ check(target.kind !== 'impulse', 'Impulse targets cannot be set.', 'TARGET_NOT_SETTABLE');
+ check(target.writable, 'Target is read-only.', 'TARGET_READ_ONLY');
+ if (target.kind === 'selection') {
+ check(target.options.some(o => Object.is(o.value, value)), 'Value is not a declared option.', 'INVALID_VALUE');
+ } else validateValue(value, { ...target, type: target.kind === 'range' ? 'number' : target.valueType });
+}
+export function validateReportedValue(target, value) {
+ // Read-only targets use the same declared value shape without requiring writability.
+ if (target.kind === 'selection') {
+ check(target.options.some(o => Object.is(o.value, value)), `Invalid reported value for ${target.id}.`);
+ } else if (target.kind === 'range' || types.includes(target.valueType)) {
+ try { validateValue(value, { ...target, type: target.kind === 'range' ? 'number' : target.valueType }); }
+ catch (error) { throw new ProtocolError('INVALID_MESSAGE', `${target.id}: ${error.message}`); }
+ }
+}
+export function validateCatalog(message) {
+ check(record(message.exhibit) && record(message.contract) && message.contract.major === 5,
+ 'Invalid description metadata.');
+ check(counter(message.registryRevision) && counter(message.stateRevision), 'Invalid description revisions.');
+ check(Array.isArray(message.targets) && Array.isArray(message.capabilities), 'Invalid catalog arrays.');
+ const ids = new Set();
+ for (const t of message.targets) {
+ check(record(t) && typeof t.id === 'string' && /^[a-z][a-z0-9-]*(\.[a-z][a-z0-9-]*)+$/.test(t.id)
+ && !ids.has(t.id) && ['state', 'range', 'selection', 'impulse'].includes(t.kind)
+ && typeof t.readable === 'boolean' && typeof t.writable === 'boolean'
+ && Array.isArray(t.requires) && t.requires.every(id => typeof id === 'string'), 'Invalid target descriptor.');
+ ids.add(t.id);
+ if (t.kind === 'range') check(Number.isFinite(t.min) && Number.isFinite(t.max) && t.min <= t.max
+ && Number.isFinite(t.step) && t.step > 0, 'Invalid range descriptor.');
+ if (t.kind === 'selection') check(Array.isArray(t.options)
+ && t.options.every(o => record(o) && Object.hasOwn(o, 'value')), 'Invalid selection descriptor.');
+ }
+ const caps = new Set();
+ for (const c of message.capabilities) {
+ check(record(c) && typeof c.id === 'string' && !caps.has(c.id) && typeof c.state === 'string', 'Invalid capability.');
+ caps.add(c.id);
+ }
+ for (const t of message.targets) check(t.requires.every(id => caps.has(id)), 'Undeclared required capability.');
+}
diff --git a/test-fixtures/PROVENANCE.md b/test-fixtures/PROVENANCE.md
new file mode 100644
index 0000000..07195e7
--- /dev/null
+++ b/test-fixtures/PROVENANCE.md
@@ -0,0 +1,26 @@
+# Reference fixture provenance
+
+The owner selected the `abacusai` set on 2026-09-13. The source is
+`G:/.vibe/SciFi-XZBT/exhibit-test/abacusai/reference-exhibits/`.
+Only the three exhibit directories, shared dependencies,
+and upstream README were copied. The external source is unchanged.
+
+These files are NGN test fixtures, not product code. Their upstream README
+describes upstream verification and is not evidence of NGN verification.
+
+Local compatibility corrections:
+
+- Eight read-only descriptors used `kind: telemetry`, which is not
+ one of Contract 5.2's four kinds. They now use `kind: state` with their
+ existing value types; IDs and backing readings are unchanged.
+- Planetarium's action argument metadata was named `args`; it is now named
+ `arguments` under the owner's 5.2 clarification.
+- The shared fixture invoke path now enforces declared argument types,
+ required fields, allowed keys, enums, and numeric/string constraints.
+- Planetarium's magnitude reader and native dispatch still used the old
+ camelCase ID. They now match its existing canonical `sky.magnitude-limit`
+ descriptor, fixing a null snapshot value and failed native writes.
+
+NGN has no fixture-specific target logic. These corrections apply only to
+the local fixture copies. No upstream fixes or independent conformance
+certification are claimed.
diff --git a/test-fixtures/host-verification.html b/test-fixtures/host-verification.html
new file mode 100644
index 0000000..11326cc
--- /dev/null
+++ b/test-fixtures/host-verification.html
@@ -0,0 +1,3 @@
+
+NGN real exhibit verification
+NGN real exhibit verification This uses the production host and transport against the three copied reference exhibits. It does not replace the separate admin UI and native gesture checks.
Run verification Not run.
diff --git a/test-fixtures/host-verification.js b/test-fixtures/host-verification.js
new file mode 100644
index 0000000..46a189c
--- /dev/null
+++ b/test-fixtures/host-verification.js
@@ -0,0 +1,78 @@
+import { ExhibitHost } from '/src/host.js';
+import { postMessageTransport } from '/src/transport/post-message.js';
+
+const results = document.querySelector('#results');
+const run = document.querySelector('#run');
+function assert(condition, message) { if (!condition) throw new Error(message); }
+async function rejected(operation, code) {
+ try { await operation(); } catch (error) { assert(error.code === code, `Expected ${code}, got ${error.code}`); return; }
+ throw new Error(`Expected rejection ${code}`);
+}
+async function raw(transport, message) {
+ return new Promise((resolve, reject) => {
+ const timeout = setTimeout(() => { off(); reject(new Error('Raw request timeout')); }, 3000);
+ const off = transport.subscribe(response => {
+ if (response.requestId !== message.requestId) return;
+ clearTimeout(timeout); off(); resolve(response);
+ });
+ transport.send(message);
+ });
+}
+run.addEventListener('click', async () => {
+ run.disabled = true; results.textContent = ''; document.querySelector('#fixtures').replaceChildren();
+ let passed = 0;
+ for (const name of ['aquarium', 'planetarium', 'haunted-house']) {
+ const host = new ExhibitHost();
+ const frame = document.createElement('iframe'); frame.title = `${name} test exhibit`;
+ try {
+ const loaded = new Promise(resolve => frame.addEventListener('load', resolve, { once: true }));
+ frame.src = `/test-fixtures/reference-exhibits/${name}/index.html`;
+ document.querySelector('#fixtures').append(frame); await loaded;
+ const versionTransport = postMessageTransport(frame);
+ const unsupported = await raw(versionTransport, { xzbt: '5.2', type: 'hello', requestId: 'bad-version', supportedContractMajors: [999] });
+ assert(unsupported.error?.code === 'UNSUPPORTED_VERSION', 'Version rejection'); versionTransport.close();
+ await host.connect(postMessageTransport(frame));
+ assert(host.status === 'connected' && host.sync === 'synchronized', 'Session and snapshot');
+ const session = host.sessionId;
+ assert(host.catalog.length > 0, 'Dynamic catalog');
+ const choose = kind => host.catalog.find(t => t.kind === kind && (kind === 'impulse' || t.writable) && t.requires.length === 0);
+ const state = choose('state'), range = choose('range'), selection = choose('selection'), impulse = choose('impulse');
+ assert(state && range && selection && impulse, 'Representative kinds are present');
+ await host.set(state.id, !host.values.get(state.id));
+ const rangeValue = host.values.get(range.id) === range.min ? range.max : range.min;
+ await host.set(range.id, rangeValue); assert(host.values.get(range.id) === rangeValue, 'Range readback');
+ const option = selection.options.find(o => o.value !== host.values.get(selection.id));
+ await host.set(selection.id, option.value); assert(host.values.get(selection.id) === option.value, 'Selection readback');
+ const revision = host.stateRevision; await host.set(selection.id, option.value);
+ assert(host.stateRevision === revision, 'No-op revision');
+ await host.invoke(impulse.id, {});
+ assert(host.eventLog.some(e => e.type === 'action.executed' && e.target === impulse.id), 'Action event delivered');
+ assert(host.eventLog.some(e => e.type === 'selection.changed'), 'Selection event delivered');
+ await rejected(() => host.set(range.id, range.max + range.step), 'INVALID_VALUE');
+ // Bypass the host's local value checks to prove exhibit errors survive the transport.
+ await rejected(() => host.request('set', { target: range.id, value: range.max + range.step }), 'INVALID_VALUE');
+ await rejected(() => host.request('invoke', { target: impulse.id, args: { undeclared: true } }), 'INVALID_VALUE');
+ const withArguments = host.catalog.find(t => t.kind === 'impulse' && t.arguments?.length);
+ if (withArguments) {
+ const args = Object.fromEntries(withArguments.arguments.map(a => [a.name, a.min ?? 1]));
+ await host.invoke(withArguments.id, args);
+ assert(host.eventLog.some(e => e.target === withArguments.id && Object.keys(e.args ?? {}).length), 'Arguments delivered');
+ }
+ const revisionBeforeReconnect = host.stateRevision;
+ await host.connect(postMessageTransport(frame));
+ assert(host.sessionId !== session && host.sequence === null, 'Reconnect creates new session and resets sequence');
+ assert(host.stateRevision >= revisionBeforeReconnect, 'Reconnect preserves exhibit revision');
+ // A second handshake invalidates the host's session at the real exhibit.
+ const sideTransport = postMessageTransport(frame);
+ await raw(sideTransport, { xzbt: '5.2', type: 'hello', requestId: 'replace-session', supportedContractMajors: [5] });
+ sideTransport.close();
+ await rejected(() => host.request('state.get'), 'INVALID_SESSION');
+ assert(host.status === 'invalid session', 'Invalid session is explicit');
+ await host.connect(postMessageTransport(frame)); assert(host.sync === 'synchronized', 'Recovery after session replacement');
+ results.textContent += `PASS ${name}: ${host.catalog.length} targets; negotiation, discovery, state/range/selection, invoke, errors, events, no-op, reconnect, session invalidation/recovery.\n`;
+ passed++;
+ } catch (error) { results.textContent += `FAIL ${name}: ${error.message}\n`; }
+ finally { host.disconnect(); frame.remove(); }
+ }
+ results.textContent += `${passed}/3 real exhibits passed.\n`; run.disabled = false;
+});
diff --git a/test-fixtures/reference-exhibits/README.md b/test-fixtures/reference-exhibits/README.md
new file mode 100644
index 0000000..6ec9ff6
--- /dev/null
+++ b/test-fixtures/reference-exhibits/README.md
@@ -0,0 +1,219 @@
+> **Local copy notice.** This directory contains locally corrected copies of
+> the upstream reference exhibits. The corrections are documented in
+> `../PROVENANCE.md`. The text below is the original upstream README; its
+> verification claims describe the upstream state, not this corrected copy.
+> Re-copying from the upstream source without reapplying corrections will
+> reintroduce known defects.
+
+# XZBT Exhibit Contract 5.2 — Reference Exhibits
+
+Three unrelated exhibits and one generic host, all speaking XZBT Exhibit
+Contract 5.2. The point of the set is not the exhibits; it is that a single
+unmodified host drives all three, and that the contract layer is shared
+rather than reimplemented per exhibit.
+
+```
+reference-exhibits/
+ shared/
+ contract-core.js generic contract engine (no domain knowledge)
+ host-transport.js same-origin postMessage transport (optional)
+ exhibit-shell.js generic DOM helpers (optional)
+ exhibit-shell.css generic page chrome (optional)
+ aquarium/ exhibit 1 — reef tank
+ planetarium/ exhibit 2 — sky dome
+ haunted-house/ exhibit 3 — the one with a real capability lifecycle
+ host-harness/ a generic host that knows nothing about any of them
+ tests/ Node test suites + a static server for manual checks
+```
+
+## Running it
+
+No build step, no dependencies, no package manager.
+
+**Standalone.** Open any exhibit's `index.html` directly in a browser. It
+works from `file://` with the network off. The header reads `standalone` and
+nothing is ever posted anywhere.
+
+**With a host.** The harness uses `postMessage` with an origin check, and
+`file://` documents report origin `null`, so serve over HTTP:
+
+```
+node tests/serve.js
+```
+
+Then open `http://localhost:8731/host-harness/index.html`, pick an exhibit,
+and press Connect. The harness performs the handshake, reads `describe`, and
+builds its entire control surface from the descriptors the exhibit published.
+
+**Tests.**
+
+```
+node tests/run-all.js
+```
+
+137 checks, no dependencies. See *Verification* below for what they do and
+do not cover.
+
+## The layering
+
+Each exhibit is split into four files, and the split is the whole argument:
+
+| File | Knows about | Does not know about |
+| --- | --- | --- |
+| `exhibit.js` | the domain, the canvas, the native controls | the contract, target IDs, events |
+| `contract-adapter.js` | the contract, the target catalog | rendering, DOM |
+| `boot.js` | composition only | everything else |
+| `index.html` | markup and script order | behaviour |
+
+`exhibit.js` is a complete, working exhibit on its own. Delete
+`contract-adapter.js`, `host-transport.js`, and `boot.js`'s last three lines
+and you still have a functioning aquarium. That is deliberate: the contract is
+a layer added on top of a working exhibit, not a scaffold the exhibit is built
+inside of.
+
+The shared `contract-core.js` is domain-free. It knows the *shape* of the
+contract — envelopes, target kinds, revisions, sequences, error codes,
+capability lifecycle — and nothing about fish, stars, or ghosts. Each adapter
+supplies three things and gets a conforming surface:
+
+1. a target catalog (canonical dotted IDs → descriptors)
+2. a setter table (target ID → absolute, idempotent setter)
+3. an action table (target ID → real impulse implementation)
+
+## The three exhibits
+
+| | Aquarium | Planetarium | Haunted House |
+| --- | --- | --- | --- |
+| Targets | 12 | 14 | 13 |
+| Capabilities | `render` | `render` | `render`, `audio` |
+| Notable | step-validated ranges | action with a typed argument | a genuinely gated action |
+
+The Haunted House is the interesting one. Its audio is synthesized with the
+Web Audio API, and browsers refuse to start an audio context until the user
+has interacted with the page. So `audio` really does begin `available`, really
+does pass through `loading` to `ready` on the first gesture, and really does
+report `error` if the context cannot be created. The adapter mirrors that real
+state into the capability registry; it never invents a transition to make the
+contract look busy. `action.moan` and `action.slam-door` declare
+`requires: ['audio']` and return `CAPABILITY_UNAVAILABLE` until the engine is
+actually ready — while staying discoverable in `describe` the whole time.
+
+## Contract decisions worth stating
+
+**One mutation chokepoint.** Every persistent-state change — native UI,
+hotkey, or host `set` — goes through `applyMutation`. One call is one
+transaction: commit, increment `stateRevision` at most once, then emit the
+resulting events carrying that revision. A host `set` and a slider drag are
+the same transaction with a different `source`.
+
+**No silent clamping.** An out-of-range value is rejected with
+`INVALID_VALUE`, not quietly pinned to the boundary. A value off the declared
+`step` is rejected too. A host that sends nonsense should be told.
+
+**No-ops do not move the revision.** Setters are absolute and idempotent and
+report whether anything actually changed. Setting a value to what it already
+is returns `changed: false` and leaves `stateRevision` alone, so a host can
+poll without inflating history.
+
+**`stateRevision` survives reconnects; `sequence` does not.** A new session
+resets the event sequence but deliberately leaves `stateRevision` intact, so a
+reconnecting host can tell whether it missed anything.
+
+**Source is assigned at the trusted boundary.** A `source` field inside an
+incoming message is ignored. The transport stamps `host`; the UI path stamps
+`ui`.
+
+**Event shape is identical on every path.** `correlationId` is always present
+(null when the change did not originate from a request) so a host can rely on
+one schema regardless of what caused the change.
+
+**Capabilities are honest.** An exhibit that cannot fail declares `ready`
+once. An exhibit with a real lifecycle reports it. Nothing fakes a transition.
+
+**Text is data.** Every string that reaches the DOM goes through
+`textContent`. Nothing in this project assigns `innerHTML`, so a string
+arriving from a host or a scenario can never become markup or script.
+
+## Verification
+
+### Automated — `node tests/run-all.js`
+
+**`conformance.test.js` (126 checks).** Loads the real `contract-core.js` and
+the real `contract-adapter.js` from each exhibit into a sandbox, with a small
+stub standing in for the exhibit's own services. The adapter under test is the
+genuine article; only the domain object behind it is a stub. Covers handshake
+and session validation, `describe` shape, canonical target IDs, `state.get`
+filtering, mutation semantics (commit, revision, no-op, absolute, rejection),
+action semantics, event envelopes and monotonic sequence, session reset,
+source trust, UI/host parity, malformed input, the capability lifecycle, and
+action arguments. A final cross-exhibit group runs one generic request
+sequence against all three.
+
+**`transport.test.js` (11 checks).** Covers the postMessage layer against a
+fake window: origin and source validation, version filtering, and — the reason
+the file exists — that events are *pushed* to a connected host, not just
+responses.
+
+### Manual — browser
+
+Verified in a real browser against a local server:
+
+- All three exhibits render (canvas confirmed painted, not blank) and load
+ with a clean console.
+- Every native control on all three exhibits was exercised; each routes
+ through the canonical path and produces the expected event with
+ `source: 'ui'`.
+- The generic host connected to all three exhibits unmodified, built its UI
+ from their descriptors, and drove them: host `set` → exhibit commits → event
+ pushed back → host revision, sequence, and readout all update.
+- The Haunted House capability lifecycle was observed end to end from a real
+ button click: `available → loading → ready`, with two
+ `capability.changed` events, after which the gated action succeeded.
+- Standalone: each exhibit opened from `file://` with the server stopped.
+ Renders, contract works, native UI works, and
+ `performance.getEntriesByType('resource')` shows zero external resources.
+
+### Not covered
+
+- No automated browser tests. Rendering and the browser's audio policy are
+ verified by hand, as above.
+- The exhibits are reference implementations, not production art. The
+ simulation is simple by design; the contract is the subject.
+
+## Bugs found and fixed during verification
+
+Recorded because they are the kind that pass a unit suite and fail a user:
+
+1. **Target IDs were not canonical.** `action.cleanGlass`,
+ `telemetry.fishCount`, `sky.magnitudeLimit` and others used camelCase
+ segments. The catalog validator rejected them at construction. All IDs are
+ now lowercase-hyphenated (`action.clean-glass`, `telemetry.fish-count`,
+ `sky.magnitude-limit`).
+2. **The transport never forwarded events.** It sent responses only, so a host
+ could issue a `set`, receive an acknowledgement, and never learn that
+ anything had changed. The exhibit looked correct in isolation and the
+ conformance suite passed; only the host/exhibit link was broken. The
+ transport now forwards the core's events to a connected host, and
+ `transport.test.js` pins it down.
+3. **The harness raced its own navigation.** `connect()` called
+ `disconnect()` first, which queued an `about:blank` load that then beat the
+ real navigation, leaving an empty frame.
+4. **The harness page never loaded `exhibit-shell.js`.** `harness.js` uses
+ `XZBTShell` for element lookup, so it threw on load and the page was inert.
+5. **`correlationId` was conditionally present.** It was omitted for
+ UI-originated changes and included for host-originated ones, so the event
+ shape differed by path. It is now always present, null when not applicable.
+
+## Adding a fourth exhibit
+
+1. Write `exhibit.js` — a working exhibit with absolute, idempotent setters
+ that return `{ changed }`, a `read(targetId)`, and real actions. No
+ contract awareness.
+2. Write `contract-adapter.js` — declare the catalog, bind setters, readers,
+ and actions, and route the UI through `core.applyMutation` /
+ `core.invokeAction`.
+3. Write `boot.js` and `index.html` following an existing exhibit.
+4. Add the exhibit to `EXHIBITS` in `tests/conformance.test.js` and to
+ `ADAPTER_EXPORTS`. The shared conformance group then runs against it
+ automatically.
+5. Add it to the harness dropdown. No harness code changes — that is the test.
diff --git a/test-fixtures/reference-exhibits/aquarium/boot.js b/test-fixtures/reference-exhibits/aquarium/boot.js
new file mode 100644
index 0000000..0371236
--- /dev/null
+++ b/test-fixtures/reference-exhibits/aquarium/boot.js
@@ -0,0 +1,54 @@
+/*
+ * Aquarium — composition root.
+ *
+ * The last file to load, and the only one that knows about all the others.
+ * It builds the exhibit, builds the UI, then hands both to the contract
+ * adapter. If the adapter were removed, the first two lines would still
+ * produce a complete, working aquarium.
+ */
+(function () {
+ 'use strict';
+
+ var Shell = window.XZBTShell;
+
+ var announcer = new Shell.Announcer({
+ node: Shell.el('announcement'),
+ idleText: 'Tank settled \u2014 awaiting instruction.',
+ maxLength: 120
+ });
+
+ var exhibit = new window.AquariumExhibit({
+ canvas: Shell.el('tank-canvas'),
+ announcer: announcer
+ });
+
+ var ui = new window.AquariumUI(exhibit, announcer);
+
+ /* Contract layer. Optional by construction: everything above already
+ * works without it. */
+ var core = window.AquariumContract.create(exhibit, ui);
+
+ /* Transport layer. Also optional: with no host attached, nothing here
+ * ever fires and the exhibit is unchanged. */
+ var transport = new window.XZBTHostTransport({
+ core: core,
+ onMessage: function (request, response) {
+ if (response.type === 'hello.result') {
+ Shell.setText(Shell.el('contract-status'), 'host attached');
+ }
+ }
+ });
+
+ exhibit.start();
+
+ window.addEventListener('resize', function () { exhibit.resize(); });
+
+ /* Exposed for the conformance harness and for manual console poking. */
+ window.__exhibit = {
+ core: core,
+ exhibit: exhibit,
+ ui: ui,
+ transport: transport,
+ announcer: announcer
+ };
+})();
diff --git a/test-fixtures/reference-exhibits/aquarium/contract-adapter.js b/test-fixtures/reference-exhibits/aquarium/contract-adapter.js
new file mode 100644
index 0000000..3e2a140
--- /dev/null
+++ b/test-fixtures/reference-exhibits/aquarium/contract-adapter.js
@@ -0,0 +1,217 @@
+/*
+ * Aquarium — XZBT Exhibit Contract 5.2 adapter.
+ *
+ * This file is the only place in the exhibit that knows the contract exists.
+ * It does three things and nothing else:
+ *
+ * 1. declares the public target catalog against the exhibit's real model
+ * 2. binds each target to a real exhibit service (setter / reader / action)
+ * 3. routes the native UI through the canonical mutation path
+ *
+ * It holds no state of its own. Every value it reports is read live from the
+ * exhibit, so there is no shadow copy that can drift (Authoring Guide §W).
+ */
+(function () {
+ 'use strict';
+
+ var Core = window.XZBTContractCore;
+
+ var IDENTITY = {
+ product: 'Aquarium',
+ version: '1.0.0',
+ build: 'reference-exhibit'
+ };
+
+ /* ------------------------------------------------------------------ *
+ * Public target catalog.
+ *
+ * IDs are canonical and domain-shaped. They are not DOM ids, CSS
+ * selectors, or widget names (Authoring Guide §W).
+ * ------------------------------------------------------------------ */
+
+ var DESCRIPTORS = [
+ {
+ id: 'tank.species',
+ kind: 'selection',
+ label: 'Species',
+ description: 'The species currently stocked in the tank.',
+ options: [
+ { value: 'clownfish', label: 'Clownfish' },
+ { value: 'blue-tang', label: 'Blue Tang' },
+ { value: 'neon-tetra', label: 'Neon Tetra' },
+ { value: 'angelfish', label: 'Angelfish' },
+ { value: 'guppy', label: 'Guppy' }
+ ],
+ readable: true, writable: true, restorable: true,
+ category: 'tank', requires: []
+ },
+ {
+ id: 'tank.population',
+ kind: 'range',
+ label: 'Population',
+ description: 'Number of fish in the tank.',
+ min: 1, max: 40, step: 1, unit: 'fish',
+ readable: true, writable: true, restorable: true,
+ category: 'tank', requires: []
+ },
+ {
+ id: 'tank.lighting',
+ kind: 'selection',
+ label: 'Lighting',
+ description: 'Lighting programme for the tank.',
+ options: [
+ { value: 'daylight', label: 'Daylight' },
+ { value: 'twilight', label: 'Twilight' },
+ { value: 'moonlight', label: 'Moonlight' },
+ { value: 'blackwater', label: 'Blackwater' }
+ ],
+ readable: true, writable: true, restorable: true,
+ category: 'tank', requires: []
+ },
+ {
+ id: 'tank.current',
+ kind: 'range',
+ label: 'Water current',
+ description: 'Strength of the circulation pump, 0 (still) to 1 (strong).',
+ min: 0, max: 1, step: 0.01,
+ readable: true, writable: true, restorable: true,
+ category: 'tank', requires: []
+ },
+ {
+ id: 'tank.temperature',
+ kind: 'range',
+ label: 'Temperature',
+ description: 'Heater setting, 0 (18 C) to 1 (28 C).',
+ min: 0, max: 1, step: 0.01,
+ readable: true, writable: true, restorable: true,
+ category: 'tank', requires: []
+ },
+ {
+ id: 'tank.paused',
+ kind: 'state',
+ valueType: 'boolean',
+ label: 'Paused',
+ description: 'Whether the simulation is frozen.',
+ readable: true, writable: true, restorable: true,
+ category: 'tank', requires: []
+ },
+ {
+ id: 'action.feed',
+ kind: 'impulse',
+ label: 'Feed',
+ description: 'Scatter food across the surface.',
+ readable: false, writable: false, restorable: false,
+ category: 'action', requires: []
+ },
+ {
+ id: 'action.startle',
+ kind: 'impulse',
+ label: 'Startle',
+ description: 'Send a pressure wave through the tank.',
+ readable: false, writable: false, restorable: false,
+ category: 'action', requires: []
+ },
+ {
+ id: 'action.clean-glass',
+ kind: 'impulse',
+ label: 'Clean glass',
+ description: 'Run the glass-cleaning cycle.',
+ readable: false, writable: false, restorable: false,
+ category: 'action', requires: []
+ },
+ {
+ id: 'telemetry.fish-count',
+ kind: 'state',
+ valueType: 'number',
+ label: 'Fish count',
+ description: 'Live number of fish currently rendered.',
+ readable: true, writable: false, restorable: false,
+ category: 'telemetry', requires: []
+ },
+ {
+ id: 'telemetry.food-level',
+ kind: 'state',
+ valueType: 'number',
+ label: 'Food level',
+ description: 'Remaining food in the water column, 0 to 1.',
+ readable: true, writable: false, restorable: false,
+ category: 'telemetry', requires: []
+ },
+ {
+ id: 'telemetry.water-temp',
+ kind: 'state',
+ valueType: 'number',
+ unit: 'C',
+ label: 'Water temperature',
+ description: 'Actual water temperature in degrees Celsius.',
+ readable: true, writable: false, restorable: false,
+ category: 'telemetry', requires: []
+ }
+ ];
+
+ /* ------------------------------------------------------------------ *
+ * Wiring
+ * ------------------------------------------------------------------ */
+
+ function createAquariumContract(exhibit, ui) {
+ var catalog = new Core.Catalog(DESCRIPTORS);
+
+ /* The exhibit has no capability that can fail: it is pure canvas and
+ * needs no device, no network, and no permission. Reporting `ready`
+ * once is honest; inventing loading/error transitions would not be
+ * (Authoring Guide §M). */
+ var capabilities = new Core.CapabilityRegistry();
+ capabilities.declare('render', 'ready');
+
+ var setters = {
+ 'tank.species': function (v) { return exhibit.setSpecies(v); },
+ 'tank.population': function (v) { return exhibit.setPopulation(v); },
+ 'tank.lighting': function (v) { return exhibit.setLighting(v); },
+ 'tank.current': function (v) { return exhibit.setCurrent(v); },
+ 'tank.temperature': function (v) { return exhibit.setTemperature(v); },
+ 'tank.paused': function (v) { return exhibit.setPaused(v); }
+ };
+
+ var readers = {};
+ var ids = catalog.ids();
+ for (var i = 0; i < ids.length; i++) {
+ (function (id) {
+ readers[id] = function () { return exhibit.read(id); };
+ })(ids[i]);
+ }
+
+ var actions = {
+ 'action.feed': function () { return exhibit.feed(); },
+ 'action.startle': function () { return exhibit.startle(); },
+ 'action.clean-glass': function () { return exhibit.cleanGlass(); }
+ };
+
+ var core = new Core.ContractCore({
+ identity: IDENTITY,
+ catalog: catalog,
+ capabilities: capabilities,
+ setters: setters,
+ readers: readers,
+ actions: actions
+ });
+
+ /* Route the native UI through the canonical path. From here on, a
+ * slider drag and a host `set` are the same transaction, and both are
+ * reported with the same source semantics (Authoring Guide §H). */
+ ui.dispatchOverride = function (targetId, value) {
+ core.applyMutation(targetId, value, 'ui');
+ ui.sync();
+ };
+ ui.invokeOverride = function (targetId) {
+ core.invokeAction(targetId, {}, 'ui');
+ ui.sync();
+ };
+
+ /* Keep the controls honest when the exhibit changes for any reason. */
+ exhibit.onChange = function () { ui.sync(); };
+
+ return core;
+ }
+
+ window.AquariumContract = { create: createAquariumContract, IDENTITY: IDENTITY };
+})();
diff --git a/test-fixtures/reference-exhibits/aquarium/exhibit.js b/test-fixtures/reference-exhibits/aquarium/exhibit.js
new file mode 100644
index 0000000..17fc00f
--- /dev/null
+++ b/test-fixtures/reference-exhibits/aquarium/exhibit.js
@@ -0,0 +1,694 @@
+/*
+ * Aquarium — exhibit behaviour.
+ *
+ * This file is the exhibit. It owns the tank model, the simulation, the
+ * canvas rendering, and the native controls. It has no contract awareness
+ * whatsoever: it does not know what a target ID is, it never emits an event,
+ * and it would work identically if the contract layer were deleted.
+ *
+ * That is deliberate. The contract adapter is a layer added on top of a
+ * working exhibit (Authoring Guide §X), not a scaffold the exhibit is built
+ * inside of.
+ *
+ * The one thing this file does provide for the layer above it is a set of
+ * absolute, idempotent setters and real actions. Those are the exhibit's own
+ * services — the same functions the native UI calls — so UI-originated and
+ * host-originated changes share exactly one code path (Authoring Guide §H).
+ */
+(function () {
+ 'use strict';
+
+ var TAU = Math.PI * 2;
+
+ var SPECIES = [
+ { id: 'clownfish', label: 'Clownfish', color: '#ff9a3c', size: 9, speed: 1.15, schooling: 0.35 },
+ { id: 'blue-tang', label: 'Blue Tang', color: '#3f8cff', size: 12, speed: 0.95, schooling: 0.55 },
+ { id: 'neon-tetra', label: 'Neon Tetra', color: '#4fe3ff', size: 6, speed: 1.45, schooling: 0.85 },
+ { id: 'angelfish', label: 'Angelfish', color: '#f2e06a', size: 14, speed: 0.7, schooling: 0.2 },
+ { id: 'guppy', label: 'Guppy', color: '#ff6fae', size: 7, speed: 1.3, schooling: 0.45 }
+ ];
+
+ var LIGHTING = [
+ { id: 'daylight', label: 'Daylight', tint: '#bfe9ff', ambient: 1.0, surface: 0.85 },
+ { id: 'twilight', label: 'Twilight', tint: '#6f7fd0', ambient: 0.62, surface: 0.5 },
+ { id: 'moonlight', label: 'Moonlight', tint: '#3d5a9c', ambient: 0.34, surface: 0.28 },
+ { id: 'blackwater', label: 'Blackwater', tint: '#2a1f3d', ambient: 0.18, surface: 0.12 }
+ ];
+
+ var FEED_AMOUNT = 0.22;
+ var FEED_DECAY_PER_SECOND = 0.035;
+ var MAX_FISH = 40;
+
+ function clamp(v, lo, hi) {
+ return v < lo ? lo : (v > hi ? hi : v);
+ }
+
+ function findById(list, id) {
+ for (var i = 0; i < list.length; i++) {
+ if (list[i].id === id) return list[i];
+ }
+ return null;
+ }
+
+ /* ------------------------------------------------------------------ *
+ * Tank model
+ * ------------------------------------------------------------------ */
+
+ function Tank() {
+ this.species = 'clownfish';
+ this.population = 12;
+ this.lighting = 'daylight';
+ this.current = 0.35;
+ this.temperature = 0.5;
+ this.foodLevel = 0.0;
+ this.paused = false;
+ this.fish = [];
+ this.bubbles = [];
+ this.plants = [];
+ this.time = 0;
+ this._spawnAll();
+ this._seedScenery();
+ }
+
+ Tank.prototype._spawnAll = function () {
+ this.fish = [];
+ for (var i = 0; i < this.population; i++) this.fish.push(this._makeFish(i));
+ };
+
+ Tank.prototype._makeFish = function (index) {
+ var species = findById(SPECIES, this.species);
+ var lane = (index % 5) / 5;
+ return {
+ x: Math.random(),
+ y: 0.18 + lane * 0.62 + (Math.random() - 0.5) * 0.08,
+ vx: (Math.random() < 0.5 ? -1 : 1) * (0.02 + Math.random() * 0.03),
+ vy: (Math.random() - 0.5) * 0.012,
+ phase: Math.random() * TAU,
+ wobble: 0.6 + Math.random() * 0.8,
+ size: species.size * (0.8 + Math.random() * 0.45),
+ energy: 0.5 + Math.random() * 0.5
+ };
+ };
+
+ Tank.prototype._seedScenery = function () {
+ this.plants = [];
+ for (var i = 0; i < 7; i++) {
+ this.plants.push({
+ x: 0.06 + i * 0.14 + (Math.random() - 0.5) * 0.05,
+ height: 0.18 + Math.random() * 0.3,
+ phase: Math.random() * TAU,
+ blades: 3 + Math.floor(Math.random() * 3)
+ });
+ }
+ this.bubbles = [];
+ for (var b = 0; b < 26; b++) {
+ this.bubbles.push({
+ x: Math.random(),
+ y: Math.random(),
+ r: 0.002 + Math.random() * 0.006,
+ speed: 0.02 + Math.random() * 0.05,
+ drift: Math.random() * TAU
+ });
+ }
+ };
+
+ /* ------------------------------------------------------------------ *
+ * Simulation
+ * ------------------------------------------------------------------ */
+
+ Tank.prototype.step = function (dt) {
+ if (this.paused) return;
+ this.time += dt;
+
+ var species = findById(SPECIES, this.species);
+ var light = findById(LIGHTING, this.lighting);
+
+ /* Food decays on its own, so a feed is a real, observable event rather
+ * than a flag that stays set forever. */
+ if (this.foodLevel > 0) {
+ this.foodLevel = Math.max(0, this.foodLevel - FEED_DECAY_PER_SECOND * dt);
+ }
+
+ var currentForce = (this.current - 0.5) * 0.09;
+ var tempFactor = 0.6 + this.temperature * 0.9;
+ var hunger = this.foodLevel;
+
+ for (var i = 0; i < this.fish.length; i++) {
+ var f = this.fish[i];
+ f.phase += dt * f.wobble * 2.2;
+
+ /* Schooling: drift toward the shoal's average position. */
+ var avgX = 0, avgY = 0;
+ for (var j = 0; j < this.fish.length; j++) {
+ avgX += this.fish[j].x;
+ avgY += this.fish[j].y;
+ }
+ avgX /= this.fish.length;
+ avgY /= this.fish.length;
+
+ var pull = species.schooling * 0.35;
+ f.vx += (avgX - f.x) * pull * dt;
+ f.vy += (avgY - f.y) * pull * dt;
+
+ /* Feeding: food sinks from the surface, so fish rise toward it. */
+ if (hunger > 0.01) {
+ f.vy -= hunger * 0.5 * dt;
+ f.energy = clamp(f.energy + hunger * dt * 0.6, 0, 1);
+ }
+
+ /* Water current pushes horizontally. */
+ f.vx += currentForce * dt;
+
+ /* Temperature sets overall activity. */
+ var speed = species.speed * tempFactor * (0.7 + f.energy * 0.6);
+ f.vx = clamp(f.vx, -0.09 * speed, 0.09 * speed);
+ f.vy = clamp(f.vy, -0.05 * speed, 0.05 * speed);
+
+ f.x += f.vx * dt * 6;
+ f.y += f.vy * dt * 6;
+
+ /* Wander and keep off the glass. */
+ f.vy += Math.sin(f.phase) * 0.004 * dt;
+ if (f.x < 0.04) { f.x = 0.04; f.vx = Math.abs(f.vx); }
+ if (f.x > 0.96) { f.x = 0.96; f.vx = -Math.abs(f.vx); }
+ if (f.y < 0.1) { f.y = 0.1; f.vy = Math.abs(f.vy); }
+ if (f.y > 0.9) { f.y = 0.9; f.vy = -Math.abs(f.vy); }
+
+ f.energy = clamp(f.energy - dt * 0.02, 0.15, 1);
+ }
+
+ /* Bubbles rise faster in warmer, more turbulent water. */
+ var bubbleSpeed = 0.5 + this.temperature * 0.8 + Math.abs(this.current - 0.5) * 0.6;
+ for (var b = 0; b < this.bubbles.length; b++) {
+ var bubble = this.bubbles[b];
+ bubble.y -= bubble.speed * bubbleSpeed * dt;
+ bubble.drift += dt * 1.4;
+ bubble.x += Math.sin(bubble.drift) * 0.004 * dt * 6;
+ if (bubble.y < -0.02) {
+ bubble.y = 1.02;
+ bubble.x = Math.random();
+ }
+ }
+
+ /* Light level feeds back into the scene's own ambient value. */
+ this.ambient = light.ambient;
+ };
+
+ /* ------------------------------------------------------------------ *
+ * Rendering
+ * ------------------------------------------------------------------ */
+
+ function Renderer(canvas, tank) {
+ this.canvas = canvas;
+ this.ctx = canvas.getContext('2d');
+ this.tank = tank;
+ this.width = 0;
+ this.height = 0;
+ this.resize();
+ }
+
+ Renderer.prototype.resize = function () {
+ var rect = this.canvas.getBoundingClientRect();
+ var dpr = window.devicePixelRatio || 1;
+ this.width = Math.max(1, Math.round(rect.width));
+ this.height = Math.max(1, Math.round(rect.height));
+ this.canvas.width = Math.round(this.width * dpr);
+ this.canvas.height = Math.round(this.height * dpr);
+ this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
+ };
+
+ Renderer.prototype.draw = function () {
+ var ctx = this.ctx;
+ var w = this.width;
+ var h = this.height;
+ var tank = this.tank;
+ var light = findById(LIGHTING, tank.lighting);
+ var species = findById(SPECIES, tank.species);
+
+ ctx.clearRect(0, 0, w, h);
+
+ /* Water column. */
+ var grad = ctx.createLinearGradient(0, 0, 0, h);
+ grad.addColorStop(0, shade(light.tint, 0.55 * light.ambient + 0.12));
+ grad.addColorStop(0.55, shade(light.tint, 0.3 * light.ambient + 0.06));
+ grad.addColorStop(1, shade(light.tint, 0.12 * light.ambient + 0.03));
+ ctx.fillStyle = grad;
+ ctx.fillRect(0, 0, w, h);
+
+ /* Caustic light shafts from the surface. */
+ ctx.save();
+ ctx.globalAlpha = 0.1 * light.surface;
+ ctx.fillStyle = '#ffffff';
+ for (var s = 0; s < 6; s++) {
+ var sx = ((s / 6) + Math.sin(tank.time * 0.25 + s) * 0.03) * w;
+ ctx.beginPath();
+ ctx.moveTo(sx, 0);
+ ctx.lineTo(sx + w * 0.05, 0);
+ ctx.lineTo(sx + w * 0.12, h);
+ ctx.lineTo(sx - w * 0.02, h);
+ ctx.closePath();
+ ctx.fill();
+ }
+ ctx.restore();
+
+ /* Substrate. */
+ ctx.fillStyle = shade(light.tint, 0.1);
+ ctx.beginPath();
+ ctx.moveTo(0, h);
+ for (var x = 0; x <= w; x += 12) {
+ var y = h - 26 - Math.sin(x * 0.02) * 6 - Math.sin(x * 0.007) * 9;
+ ctx.lineTo(x, y);
+ }
+ ctx.lineTo(w, h);
+ ctx.closePath();
+ ctx.fill();
+
+ /* Plants. */
+ for (var p = 0; p < tank.plants.length; p++) {
+ var plant = tank.plants[p];
+ var baseX = plant.x * w;
+ var baseY = h - 24;
+ var sway = Math.sin(tank.time * 0.9 + plant.phase) * (0.02 + tank.current * 0.05);
+ ctx.strokeStyle = shade('#2f7d4f', 0.35 + light.ambient * 0.5);
+ ctx.lineWidth = 2.5;
+ for (var bl = 0; bl < plant.blades; bl++) {
+ var offset = (bl - (plant.blades - 1) / 2) * 7;
+ ctx.beginPath();
+ ctx.moveTo(baseX + offset, baseY);
+ ctx.quadraticCurveTo(
+ baseX + offset + sway * w * 0.5,
+ baseY - plant.height * h * 0.6,
+ baseX + offset + sway * w,
+ baseY - plant.height * h
+ );
+ ctx.stroke();
+ }
+ }
+
+ /* Fish. */
+ for (var i = 0; i < tank.fish.length; i++) {
+ var f = tank.fish[i];
+ var fx = f.x * w;
+ var fy = f.y * h;
+ var facing = f.vx >= 0 ? 1 : -1;
+ var size = f.size * (0.85 + light.ambient * 0.3);
+
+ ctx.save();
+ ctx.translate(fx, fy);
+ ctx.scale(facing, 1);
+
+ /* Tail. */
+ ctx.fillStyle = shade(species.color, 0.55 + light.ambient * 0.4);
+ ctx.beginPath();
+ ctx.moveTo(-size * 0.9, 0);
+ ctx.lineTo(-size * 1.7, -size * 0.55 + Math.sin(f.phase) * size * 0.18);
+ ctx.lineTo(-size * 1.7, size * 0.55 + Math.sin(f.phase) * size * 0.18);
+ ctx.closePath();
+ ctx.fill();
+
+ /* Body. */
+ ctx.fillStyle = shade(species.color, 0.6 + light.ambient * 0.45);
+ ctx.beginPath();
+ ctx.ellipse(0, 0, size, size * 0.52, 0, 0, TAU);
+ ctx.fill();
+
+ /* Eye. */
+ ctx.fillStyle = 'rgba(10,12,18,0.85)';
+ ctx.beginPath();
+ ctx.arc(size * 0.5, -size * 0.1, Math.max(1, size * 0.13), 0, TAU);
+ ctx.fill();
+
+ ctx.restore();
+ }
+
+ /* Bubbles. */
+ ctx.strokeStyle = 'rgba(255,255,255,' + (0.12 + light.ambient * 0.22) + ')';
+ ctx.lineWidth = 1;
+ for (var b = 0; b < tank.bubbles.length; b++) {
+ var bubble = tank.bubbles[b];
+ ctx.beginPath();
+ ctx.arc(bubble.x * w, bubble.y * h, bubble.r * w, 0, TAU);
+ ctx.stroke();
+ }
+
+ /* Food particles sinking from the surface. */
+ if (tank.foodLevel > 0.01) {
+ ctx.fillStyle = 'rgba(226,196,120,' + (0.35 + tank.foodLevel * 0.5) + ')';
+ for (var k = 0; k < 40; k++) {
+ var seed = k * 12.9898;
+ var px = (Math.sin(seed) * 0.5 + 0.5) * w;
+ var py = ((Math.cos(seed * 1.7) * 0.5 + 0.5) * 0.5 + 0.5) * h;
+ var sink = (tank.time * 0.06 + (k / 40)) % 1;
+ ctx.beginPath();
+ ctx.arc(px, (py * 0.3 + sink * 0.7) * h, 1.6, 0, TAU);
+ ctx.fill();
+ }
+ }
+
+ /* Surface line. */
+ ctx.strokeStyle = 'rgba(255,255,255,' + (0.1 + light.surface * 0.25) + ')';
+ ctx.lineWidth = 2;
+ ctx.beginPath();
+ for (var sx2 = 0; sx2 <= w; sx2 += 8) {
+ var sy = 8 + Math.sin(sx2 * 0.03 + tank.time * 1.6) * 3;
+ if (sx2 === 0) ctx.moveTo(sx2, sy); else ctx.lineTo(sx2, sy);
+ }
+ ctx.stroke();
+
+ if (tank.paused) {
+ ctx.fillStyle = 'rgba(0,0,0,0.35)';
+ ctx.fillRect(0, 0, w, h);
+ ctx.fillStyle = 'rgba(255,255,255,0.75)';
+ ctx.font = '600 13px "Segoe UI", sans-serif';
+ ctx.textAlign = 'center';
+ ctx.fillText('PAUSED', w / 2, h / 2);
+ ctx.textAlign = 'start';
+ }
+ };
+
+ /** Lighten/darken a hex colour by a 0..1 factor. */
+ function shade(hex, factor) {
+ var n = parseInt(hex.slice(1), 16);
+ var r = (n >> 16) & 255;
+ var g = (n >> 8) & 255;
+ var b = n & 255;
+ var f = clamp(factor, 0, 1.6);
+ r = Math.round(clamp(r * f, 0, 255));
+ g = Math.round(clamp(g * f, 0, 255));
+ b = Math.round(clamp(b * f, 0, 255));
+ return 'rgb(' + r + ',' + g + ',' + b + ')';
+ }
+
+ /* ------------------------------------------------------------------ *
+ * Exhibit
+ * ------------------------------------------------------------------ */
+
+ function AquariumExhibit(options) {
+ this.canvas = options.canvas;
+ this.tank = new Tank();
+ this.renderer = new Renderer(this.canvas, this.tank);
+ this.announcer = options.announcer;
+ this.onChange = options.onChange || function () {};
+ this._lastFrame = 0;
+ this._running = false;
+ this._boundFrame = this._frame.bind(this);
+ }
+
+ AquariumExhibit.prototype.start = function () {
+ if (this._running) return;
+ this._running = true;
+ this._lastFrame = performance.now();
+ requestAnimationFrame(this._boundFrame);
+ };
+
+ AquariumExhibit.prototype._frame = function (now) {
+ if (!this._running) return;
+ var dt = Math.min(0.05, (now - this._lastFrame) / 1000);
+ this._lastFrame = now;
+ this.tank.step(dt);
+ this.renderer.draw();
+ requestAnimationFrame(this._boundFrame);
+ };
+
+ AquariumExhibit.prototype.resize = function () {
+ this.renderer.resize();
+ };
+
+ /* ------------------------------------------------------------------ *
+ * Absolute, idempotent setters.
+ *
+ * Each returns { changed: boolean }. Returning changed:false for a no-op
+ * is what lets the contract layer leave stateRevision untouched, and it is
+ * also what stops a host-set value from being silently reverted the next
+ * time a local control is touched (Authoring Guide §W).
+ * ------------------------------------------------------------------ */
+
+ AquariumExhibit.prototype.setSpecies = function (id) {
+ if (this.tank.species === id) return { changed: false };
+ this.tank.species = id;
+ /* Restocking is a real consequence of changing species, so the fish are
+ * respawned rather than recoloured in place. */
+ this.tank._spawnAll();
+ this.onChange();
+ return { changed: true };
+ };
+
+ AquariumExhibit.prototype.setPopulation = function (count) {
+ count = Math.round(count);
+ if (this.tank.population === count) return { changed: false };
+ var previous = this.tank.population;
+ this.tank.population = count;
+ if (count > previous) {
+ for (var i = previous; i < count; i++) this.tank.fish.push(this.tank._makeFish(i));
+ } else {
+ this.tank.fish.length = count;
+ }
+ this.onChange();
+ return { changed: true };
+ };
+
+ AquariumExhibit.prototype.setLighting = function (id) {
+ if (this.tank.lighting === id) return { changed: false };
+ this.tank.lighting = id;
+ this.onChange();
+ return { changed: true };
+ };
+
+ AquariumExhibit.prototype.setCurrent = function (value) {
+ if (this.tank.current === value) return { changed: false };
+ this.tank.current = value;
+ this.onChange();
+ return { changed: true };
+ };
+
+ AquariumExhibit.prototype.setTemperature = function (value) {
+ if (this.tank.temperature === value) return { changed: false };
+ this.tank.temperature = value;
+ this.onChange();
+ return { changed: true };
+ };
+
+ AquariumExhibit.prototype.setPaused = function (on) {
+ if (this.tank.paused === on) return { changed: false };
+ this.tank.paused = on;
+ this.onChange();
+ return { changed: true };
+ };
+
+ /* ------------------------------------------------------------------ *
+ * Real actions.
+ * ------------------------------------------------------------------ */
+
+ AquariumExhibit.prototype.feed = function () {
+ this.tank.foodLevel = clamp(this.tank.foodLevel + FEED_AMOUNT, 0, 1);
+ for (var i = 0; i < this.tank.fish.length; i++) {
+ this.tank.fish[i].energy = clamp(this.tank.fish[i].energy + 0.25, 0, 1);
+ }
+ this.announcer.flash('Feeding — food dispersing through the water column.');
+ this.onChange();
+ return { ok: true };
+ };
+
+ AquariumExhibit.prototype.startle = function () {
+ for (var i = 0; i < this.tank.fish.length; i++) {
+ var f = this.tank.fish[i];
+ f.vx += (Math.random() - 0.5) * 0.12;
+ f.vy += (Math.random() - 0.5) * 0.08;
+ f.energy = 1;
+ }
+ this.announcer.flash('Startle — the shoal scatters.');
+ this.onChange();
+ return { ok: true };
+ };
+
+ AquariumExhibit.prototype.cleanGlass = function () {
+ this.tank.bubbles.length = 0;
+ for (var b = 0; b < 26; b++) {
+ this.tank.bubbles.push({
+ x: Math.random(),
+ y: Math.random(),
+ r: 0.002 + Math.random() * 0.006,
+ speed: 0.02 + Math.random() * 0.05,
+ drift: Math.random() * TAU
+ });
+ }
+ this.announcer.flash('Glass cleaned — clarity restored.');
+ this.onChange();
+ return { ok: true };
+ };
+
+ /* ------------------------------------------------------------------ *
+ * Reads
+ * ------------------------------------------------------------------ */
+
+ AquariumExhibit.prototype.read = function (targetId) {
+ var t = this.tank;
+ switch (targetId) {
+ case 'tank.species': return t.species;
+ case 'tank.population': return t.population;
+ case 'tank.lighting': return t.lighting;
+ case 'tank.current': return t.current;
+ case 'tank.temperature': return t.temperature;
+ case 'tank.paused': return t.paused;
+ case 'telemetry.fish-count': return t.fish.length;
+ case 'telemetry.food-level': return Math.round(t.foodLevel * 1000) / 1000;
+ case 'telemetry.water-temp': return Math.round((18 + t.temperature * 10) * 10) / 10;
+ default: return null;
+ }
+ };
+
+ /* ------------------------------------------------------------------ *
+ * Native UI
+ *
+ * Every handler below calls the exhibit's own service method. None of them
+ * dispatches a synthetic DOM event, and none of them writes state directly
+ * (Authoring Guide §W).
+ * ------------------------------------------------------------------ */
+
+ function AquariumUI(exhibit, announcer) {
+ this.exhibit = exhibit;
+ this.announcer = announcer;
+ this.nodes = {
+ species: document.getElementById('species-select'),
+ population: document.getElementById('population-range'),
+ populationReadout: document.getElementById('population-readout'),
+ lighting: document.getElementById('lighting-select'),
+ current: document.getElementById('current-range'),
+ currentReadout: document.getElementById('current-readout'),
+ temperature: document.getElementById('temperature-range'),
+ temperatureReadout: document.getElementById('temperature-readout'),
+ pause: document.getElementById('pause-button'),
+ feed: document.getElementById('feed-button'),
+ startle: document.getElementById('startle-button'),
+ clean: document.getElementById('clean-button')
+ };
+ this._buildOptions();
+ this._bind();
+ this.sync();
+ }
+
+ AquariumUI.prototype._buildOptions = function () {
+ var speciesSelect = this.nodes.species;
+ for (var i = 0; i < SPECIES.length; i++) {
+ var opt = document.createElement('option');
+ opt.value = SPECIES[i].id;
+ opt.textContent = SPECIES[i].label;
+ speciesSelect.appendChild(opt);
+ }
+ var lightSelect = this.nodes.lighting;
+ for (var j = 0; j < LIGHTING.length; j++) {
+ var lopt = document.createElement('option');
+ lopt.value = LIGHTING[j].id;
+ lopt.textContent = LIGHTING[j].label;
+ lightSelect.appendChild(lopt);
+ }
+ };
+
+ AquariumUI.prototype._bind = function () {
+ var self = this;
+ var ex = this.exhibit;
+
+ this.nodes.species.addEventListener('change', function (e) {
+ self.dispatch('tank.species', e.target.value);
+ });
+
+ this.nodes.population.addEventListener('input', function (e) {
+ self.dispatch('tank.population', Number(e.target.value));
+ });
+
+ this.nodes.lighting.addEventListener('change', function (e) {
+ self.dispatch('tank.lighting', e.target.value);
+ });
+
+ this.nodes.current.addEventListener('input', function (e) {
+ self.dispatch('tank.current', Number(e.target.value));
+ });
+
+ this.nodes.temperature.addEventListener('input', function (e) {
+ self.dispatch('tank.temperature', Number(e.target.value));
+ });
+
+ this.nodes.pause.addEventListener('click', function () {
+ self.dispatch('tank.paused', !ex.tank.paused);
+ });
+
+ this.nodes.feed.addEventListener('click', function () {
+ self.invoke('action.feed');
+ });
+ this.nodes.startle.addEventListener('click', function () {
+ self.invoke('action.startle');
+ });
+ this.nodes.clean.addEventListener('click', function () {
+ self.invoke('action.clean-glass');
+ });
+ };
+
+ /**
+ * The UI's route into the canonical path. `dispatch` is installed by the
+ * contract adapter; until then the UI still works, because the fallback
+ * calls the exhibit service directly. Either way the exhibit's own setter
+ * is the only thing that mutates state.
+ */
+ AquariumUI.prototype.dispatch = function (targetId, value) {
+ if (typeof this.dispatchOverride === 'function') {
+ this.dispatchOverride(targetId, value);
+ return;
+ }
+ this._fallbackSet(targetId, value);
+ };
+
+ AquariumUI.prototype.invoke = function (targetId) {
+ if (typeof this.invokeOverride === 'function') {
+ this.invokeOverride(targetId);
+ return;
+ }
+ this._fallbackInvoke(targetId);
+ };
+
+ AquariumUI.prototype._fallbackSet = function (targetId, value) {
+ var ex = this.exhibit;
+ switch (targetId) {
+ case 'tank.species': ex.setSpecies(value); break;
+ case 'tank.population': ex.setPopulation(value); break;
+ case 'tank.lighting': ex.setLighting(value); break;
+ case 'tank.current': ex.setCurrent(value); break;
+ case 'tank.temperature': ex.setTemperature(value); break;
+ case 'tank.paused': ex.setPaused(value); break;
+ default: break;
+ }
+ this.sync();
+ };
+
+ AquariumUI.prototype._fallbackInvoke = function (targetId) {
+ var ex = this.exhibit;
+ if (targetId === 'action.feed') ex.feed();
+ else if (targetId === 'action.startle') ex.startle();
+ else if (targetId === 'action.clean-glass') ex.cleanGlass();
+ this.sync();
+ };
+
+ /** Reflect current exhibit state into the controls. */
+ AquariumUI.prototype.sync = function () {
+ var t = this.exhibit.tank;
+ var n = this.nodes;
+ if (n.species.value !== t.species) n.species.value = t.species;
+ if (Number(n.population.value) !== t.population) n.population.value = String(t.population);
+ if (n.lighting.value !== t.lighting) n.lighting.value = t.lighting;
+ if (Number(n.current.value) !== t.current) n.current.value = String(t.current);
+ if (Number(n.temperature.value) !== t.temperature) n.temperature.value = String(t.temperature);
+
+ n.populationReadout.textContent = t.population + ' fish';
+ n.currentReadout.textContent = window.XZBTShell.formatPercent(t.current);
+ n.temperatureReadout.textContent = window.XZBTShell.formatNumber(18 + t.temperature * 10, 1) + ' \u00b0C';
+ n.pause.setAttribute('aria-pressed', t.paused ? 'true' : 'false');
+ n.pause.textContent = t.paused ? 'Resume' : 'Pause';
+ };
+
+ /* ------------------------------------------------------------------ *
+ * Exports
+ * ------------------------------------------------------------------ */
+
+ window.AquariumExhibit = AquariumExhibit;
+ window.AquariumUI = AquariumUI;
+ window.AquariumData = { SPECIES: SPECIES, LIGHTING: LIGHTING, MAX_FISH: MAX_FISH };
+})();
diff --git a/test-fixtures/reference-exhibits/aquarium/index.html b/test-fixtures/reference-exhibits/aquarium/index.html
new file mode 100644
index 0000000..703cce8
--- /dev/null
+++ b/test-fixtures/reference-exhibits/aquarium/index.html
@@ -0,0 +1,88 @@
+
+
+
+
+
+Aquarium — XZBT Reference Exhibit
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Actions
+
+ Feed
+ Startle
+ Clean glass
+
+
+ Simulation
+ Pause
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/test-fixtures/reference-exhibits/aquarium/style.css b/test-fixtures/reference-exhibits/aquarium/style.css
new file mode 100644
index 0000000..25396c1
--- /dev/null
+++ b/test-fixtures/reference-exhibits/aquarium/style.css
@@ -0,0 +1,54 @@
+/* Aquarium — domain styling only. Layout and controls come from the shell. */
+
+body {
+ background: #071018;
+ color: #dff1ff;
+}
+
+.exhibit-header {
+ background: linear-gradient(90deg, rgba(30, 90, 130, 0.35), rgba(255, 255, 255, 0.03));
+ border-color: rgba(120, 200, 255, 0.22);
+}
+
+.exhibit-header h1 { color: #bfe9ff; }
+
+.stage {
+ border-color: rgba(120, 200, 255, 0.25);
+ box-shadow: inset 0 0 60px rgba(0, 40, 70, 0.8);
+}
+
+.panel {
+ background: rgba(20, 60, 90, 0.22);
+ border-color: rgba(120, 200, 255, 0.18);
+}
+
+.panel h2 { color: #8fd4ff; }
+
+input[type="range"] { accent-color: #4fc3ff; }
+
+button {
+ background: rgba(79, 195, 255, 0.12);
+ border-color: rgba(120, 200, 255, 0.35);
+ color: #dff1ff;
+}
+
+button:hover { background: rgba(79, 195, 255, 0.24); }
+
+button[aria-pressed="true"] {
+ background: rgba(79, 195, 255, 0.4);
+ border-color: rgba(160, 225, 255, 0.7);
+}
+
+select {
+ background: rgba(4, 24, 38, 0.85);
+ border-color: rgba(120, 200, 255, 0.3);
+}
+
+.exhibit-footer {
+ background: rgba(20, 60, 90, 0.22);
+ border-color: rgba(120, 200, 255, 0.18);
+}
+
+.exhibit-footer .announcement-label { color: #8fd4ff; opacity: 0.7; }
+
+.exhibit-header .status { color: #8fd4ff; }
diff --git a/test-fixtures/reference-exhibits/haunted-house/boot.js b/test-fixtures/reference-exhibits/haunted-house/boot.js
new file mode 100644
index 0000000..efa2f75
--- /dev/null
+++ b/test-fixtures/reference-exhibits/haunted-house/boot.js
@@ -0,0 +1,44 @@
+/*
+ * Haunted House — composition root. Last file to load.
+ */
+(function () {
+ 'use strict';
+
+ var Shell = window.XZBTShell;
+
+ var announcer = new Shell.Announcer({
+ node: Shell.el('announcement'),
+ idleText: 'The house is quiet \u2014 awaiting instruction.',
+ maxLength: 120
+ });
+
+ var exhibit = new window.HauntedHouseExhibit({
+ canvas: Shell.el('room-canvas'),
+ announcer: announcer
+ });
+
+ var ui = new window.HauntedHouseUI(exhibit, announcer);
+
+ var core = window.HauntedHouseContract.create(exhibit, ui);
+
+ var transport = new window.XZBTHostTransport({
+ core: core,
+ onMessage: function (request, response) {
+ if (response.type === 'hello.result') {
+ Shell.setText(Shell.el('contract-status'), 'host attached');
+ }
+ }
+ });
+
+ exhibit.start();
+
+ window.addEventListener('resize', function () { exhibit.resize(); });
+
+ window.__exhibit = {
+ core: core,
+ exhibit: exhibit,
+ ui: ui,
+ transport: transport,
+ announcer: announcer
+ };
+})();
diff --git a/test-fixtures/reference-exhibits/haunted-house/contract-adapter.js b/test-fixtures/reference-exhibits/haunted-house/contract-adapter.js
new file mode 100644
index 0000000..444d4c7
--- /dev/null
+++ b/test-fixtures/reference-exhibits/haunted-house/contract-adapter.js
@@ -0,0 +1,218 @@
+/*
+ * Haunted House — XZBT Exhibit Contract 5.2 adapter.
+ *
+ * This adapter is the one that has to be honest about capabilities. The
+ * exhibit's audio engine cannot start until the browser has seen a user
+ * gesture, so `audio` is declared `available` at boot and moves to
+ * `loading` then `ready` when it really does. The adapter mirrors the
+ * exhibit's real state into the capability registry; it never invents a
+ * transition (Authoring Guide §M).
+ *
+ * The audio-dependent actions are gated on that capability, so invoking
+ * `action.moan` before the engine is ready returns CAPABILITY_UNAVAILABLE
+ * rather than silently doing nothing.
+ */
+(function () {
+ 'use strict';
+
+ var Core = window.XZBTContractCore;
+
+ var IDENTITY = {
+ product: 'Haunted House',
+ version: '1.0.0',
+ build: 'reference-exhibit'
+ };
+
+ var DESCRIPTORS = [
+ {
+ id: 'room.current',
+ kind: 'selection',
+ label: 'Room',
+ description: 'Room the visitor is standing in.',
+ options: [
+ { value: 'foyer', label: 'Foyer' },
+ { value: 'library', label: 'Library' },
+ { value: 'cellar', label: 'Cellar' },
+ { value: 'attic', label: 'Attic' },
+ { value: 'nursery', label: 'Nursery' }
+ ],
+ readable: true, writable: true, restorable: true,
+ category: 'room', requires: []
+ },
+ {
+ id: 'room.spirit',
+ kind: 'selection',
+ label: 'Spirit',
+ description: 'Which presence is currently resident.',
+ options: [
+ { value: 'restless', label: 'Restless' },
+ { value: 'vengeful', label: 'Vengeful' },
+ { value: 'mourning', label: 'Mourning' },
+ { value: 'mischievous', label: 'Mischievous' }
+ ],
+ readable: true, writable: true, restorable: true,
+ category: 'room', requires: []
+ },
+ {
+ id: 'room.haunt-level',
+ kind: 'range',
+ label: 'Haunt level',
+ description: 'Intensity of the haunting, 0 (dormant) to 1 (manifest).',
+ min: 0, max: 1, step: 0.01,
+ readable: true, writable: true, restorable: true,
+ category: 'room', requires: []
+ },
+ {
+ id: 'room.fog-density',
+ kind: 'range',
+ label: 'Fog density',
+ description: 'Density of the ground fog.',
+ min: 0, max: 1, step: 0.01,
+ readable: true, writable: true, restorable: true,
+ category: 'room', requires: []
+ },
+ {
+ id: 'room.candlelight',
+ kind: 'range',
+ label: 'Candlelight',
+ description: 'Strength of the candle pool of light.',
+ min: 0, max: 1, step: 0.01,
+ readable: true, writable: true, restorable: true,
+ category: 'room', requires: []
+ },
+ {
+ id: 'room.audio-enabled',
+ kind: 'state',
+ valueType: 'boolean',
+ label: 'Audio enabled',
+ description: 'Whether the house is permitted to make sound.',
+ readable: true, writable: true, restorable: true,
+ category: 'room', requires: []
+ },
+ {
+ id: 'room.paused',
+ kind: 'state',
+ valueType: 'boolean',
+ label: 'Held',
+ description: 'Whether the haunt simulation is frozen.',
+ readable: true, writable: true, restorable: true,
+ category: 'room', requires: []
+ },
+ {
+ id: 'action.moan',
+ kind: 'impulse',
+ label: 'Moan',
+ description: 'Emit a long descending moan.',
+ readable: false, writable: false, restorable: false,
+ category: 'action', requires: ['audio']
+ },
+ {
+ id: 'action.slam-door',
+ kind: 'impulse',
+ label: 'Slam door',
+ description: 'Slam a door somewhere in the house.',
+ readable: false, writable: false, restorable: false,
+ category: 'action', requires: ['audio']
+ },
+ {
+ id: 'action.manifest',
+ kind: 'impulse',
+ label: 'Manifest',
+ description: 'Bring the apparition to full manifestation.',
+ readable: false, writable: false, restorable: false,
+ category: 'action', requires: []
+ },
+ {
+ id: 'action.calm',
+ kind: 'impulse',
+ label: 'Calm',
+ description: 'Settle the house back to dormancy.',
+ readable: false, writable: false, restorable: false,
+ category: 'action', requires: []
+ },
+ {
+ id: 'telemetry.audio-state',
+ kind: 'state',
+ valueType: 'string',
+ label: 'Audio engine state',
+ description: 'Live state of the Web Audio engine.',
+ readable: true, writable: false, restorable: false,
+ category: 'telemetry', requires: []
+ },
+ {
+ id: 'telemetry.room-damp',
+ kind: 'state',
+ valueType: 'number',
+ label: 'Room damping',
+ description: 'How strongly the current room suppresses the haunt.',
+ readable: true, writable: false, restorable: false,
+ category: 'telemetry', requires: []
+ }
+ ];
+
+ function createHauntedHouseContract(exhibit, ui) {
+ var catalog = new Core.Catalog(DESCRIPTORS);
+
+ var capabilities = new Core.CapabilityRegistry();
+ capabilities.declare('render', 'ready');
+ /* Honest initial state: the engine exists but the browser will not let
+ * it start until a gesture arrives. */
+ capabilities.declare('audio', 'available');
+
+ var setters = {
+ 'room.current': function (v) { return exhibit.setRoom(v); },
+ 'room.spirit': function (v) { return exhibit.setSpirit(v); },
+ 'room.haunt-level': function (v) { return exhibit.setHauntLevel(v); },
+ 'room.fog-density': function (v) { return exhibit.setFogDensity(v); },
+ 'room.candlelight': function (v) { return exhibit.setCandlelight(v); },
+ 'room.audio-enabled': function (v) { return exhibit.setAudioEnabled(v); },
+ 'room.paused': function (v) { return exhibit.setPaused(v); }
+ };
+
+ var readers = {};
+ var ids = catalog.ids();
+ for (var i = 0; i < ids.length; i++) {
+ (function (id) {
+ readers[id] = function () { return exhibit.read(id); };
+ })(ids[i]);
+ }
+
+ var actions = {
+ 'action.moan': function () { return exhibit.moan(); },
+ 'action.slam-door': function () { return exhibit.slamDoor(); },
+ 'action.manifest': function () { return exhibit.manifest(); },
+ 'action.calm': function () { return exhibit.calm(); }
+ };
+
+ var core = new Core.ContractCore({
+ identity: IDENTITY,
+ catalog: catalog,
+ capabilities: capabilities,
+ setters: setters,
+ readers: readers,
+ actions: actions
+ });
+
+ /* Mirror the exhibit's real audio lifecycle into the contract. This is
+ * a genuine state change driven by the browser, not a scripted one. */
+ exhibit.audio.onStateChange = function (state) {
+ core.setCapabilityState('audio', state);
+ ui.sync();
+ };
+
+ ui.dispatchOverride = function (targetId, value) {
+ core.applyMutation(targetId, value, 'ui');
+ ui.sync();
+ };
+ ui.invokeOverride = function (targetId) {
+ core.invokeAction(targetId, {}, 'ui');
+ ui.sync();
+ };
+
+ exhibit.onChange = function () { ui.sync(); };
+
+ return core;
+ }
+
+ window.HauntedHouseContract = { create: createHauntedHouseContract, IDENTITY: IDENTITY };
+})();
diff --git a/test-fixtures/reference-exhibits/haunted-house/exhibit.js b/test-fixtures/reference-exhibits/haunted-house/exhibit.js
new file mode 100644
index 0000000..03ed0c5
--- /dev/null
+++ b/test-fixtures/reference-exhibits/haunted-house/exhibit.js
@@ -0,0 +1,764 @@
+/*
+ * Haunted House — exhibit behaviour.
+ *
+ * Owns the room model, the haunt simulation, the canvas rendering, and the
+ * native controls. No contract awareness.
+ *
+ * This exhibit is the one that exercises the capability lifecycle honestly:
+ * its audio is synthesized with the Web Audio API, which browsers refuse to
+ * start until the user has interacted with the page. So `audio` really does
+ * begin `available`, really does go `loading` and then `ready` on the first
+ * gesture, and really does report `error` if the context cannot be created.
+ * Nothing here fakes a transition to make the contract look busy.
+ */
+(function () {
+ 'use strict';
+
+ var TAU = Math.PI * 2;
+
+ var ROOMS = [
+ { id: 'foyer', label: 'Foyer', width: 1.0, damp: 0.35, hue: '#6b5a3e' },
+ { id: 'library', label: 'Library', width: 0.9, damp: 0.6, hue: '#5a4630' },
+ { id: 'cellar', label: 'Cellar', width: 0.8, damp: 0.85, hue: '#3a3a44' },
+ { id: 'attic', label: 'Attic', width: 0.7, damp: 0.5, hue: '#4a4038' },
+ { id: 'nursery', label: 'Nursery', width: 0.75, damp: 0.4, hue: '#5c4a5a' }
+ ];
+
+ var SPIRITS = [
+ { id: 'restless', label: 'Restless', energy: 0.35, color: '#9fd8ff' },
+ { id: 'vengeful', label: 'Vengeful', energy: 0.75, color: '#ff8a6a' },
+ { id: 'mourning', label: 'Mourning', energy: 0.5, color: '#b9a6ff' },
+ { id: 'mischievous', label: 'Mischievous', energy: 0.6, color: '#8affc4' }
+ ];
+
+ var MAX_FOG = 1.0;
+
+ function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
+
+ function findById(list, id) {
+ for (var i = 0; i < list.length; i++) if (list[i].id === id) return list[i];
+ return null;
+ }
+
+ function hash(n) {
+ var x = Math.sin(n * 91.7 + 47.3) * 43758.5453;
+ return x - Math.floor(x);
+ }
+
+ /* ------------------------------------------------------------------ *
+ * Room model
+ * ------------------------------------------------------------------ */
+
+ function House() {
+ this.room = 'foyer';
+ this.spirit = 'restless';
+ this.hauntLevel = 0.3;
+ this.fogDensity = 0.25;
+ this.candlelight = 0.55;
+ this.audioEnabled = true;
+ this.paused = false;
+ this.time = 0;
+ this.embers = [];
+ this.dust = [];
+ this._seed();
+ }
+
+ House.prototype._seed = function () {
+ this.embers = [];
+ for (var i = 0; i < 18; i++) {
+ this.embers.push({
+ x: hash(i * 1.3),
+ y: hash(i * 2.7),
+ vy: -(0.01 + hash(i * 3.9) * 0.03),
+ phase: hash(i * 5.1) * TAU
+ });
+ }
+ this.dust = [];
+ for (var d = 0; d < 60; d++) {
+ this.dust.push({
+ x: hash(d * 7.7),
+ y: hash(d * 8.3),
+ vx: (hash(d * 9.1) - 0.5) * 0.01,
+ vy: (hash(d * 11.3) - 0.5) * 0.006,
+ r: 0.4 + hash(d * 13.7) * 1.2
+ });
+ }
+ }
+
+ House.prototype.step = function (dt) {
+ if (this.paused) return;
+ this.time += dt;
+
+ var spirit = findById(SPIRITS, this.spirit);
+ var room = findById(ROOMS, this.room);
+
+ /* The haunt level drifts toward the spirit's natural energy, damped by
+ * the room. This is what makes the room and spirit selections matter
+ * rather than being cosmetic. */
+ var target = spirit.energy * (1 - room.damp * 0.5);
+ this.hauntLevel += (target - this.hauntLevel) * dt * 0.35;
+
+ for (var i = 0; i < this.embers.length; i++) {
+ var e = this.embers[i];
+ e.y += e.vy * dt * (0.6 + this.hauntLevel);
+ e.phase += dt * 2.2;
+ if (e.y < -0.05) { e.y = 1.05; e.x = hash(i + this.time); }
+ }
+
+ for (var d = 0; d < this.dust.length; d++) {
+ var p = this.dust[d];
+ p.x += p.vx * dt * (0.5 + this.hauntLevel * 2);
+ p.y += p.vy * dt;
+ if (p.x < 0) p.x = 1;
+ if (p.x > 1) p.x = 0;
+ if (p.y < 0) p.y = 1;
+ if (p.y > 1) p.y = 0;
+ }
+ };
+
+ /* ------------------------------------------------------------------ *
+ * Audio service
+ *
+ * A real Web Audio drone, and a real capability lifecycle. The context is
+ * created lazily on the first user gesture because browsers require it.
+ * ------------------------------------------------------------------ */
+
+ function AudioService() {
+ this.ctx = null;
+ this.master = null;
+ this.oscillators = [];
+ this.state = 'available';
+ this.onStateChange = function () {};
+ }
+
+ AudioService.prototype._setState = function (state) {
+ if (this.state === state) return;
+ this.state = state;
+ this.onStateChange(state);
+ };
+
+ /** Called from a real user gesture. */
+ AudioService.prototype.prepare = function () {
+ if (this.ctx) {
+ if (this.ctx.state === 'suspended') this.ctx.resume();
+ this._setState('ready');
+ return true;
+ }
+ this._setState('loading');
+ try {
+ var Ctor = window.AudioContext || window.webkitAudioContext;
+ if (!Ctor) {
+ this._setState('error');
+ return false;
+ }
+ this.ctx = new Ctor();
+ this.master = this.ctx.createGain();
+ this.master.gain.value = 0.0;
+ this.master.connect(this.ctx.destination);
+ this._buildDrone();
+ this._setState('ready');
+ return true;
+ } catch (err) {
+ this.ctx = null;
+ this._setState('error');
+ return false;
+ }
+ };
+
+ AudioService.prototype._buildDrone = function () {
+ var ctx = this.ctx;
+ var freqs = [55, 82.5, 110, 164.8];
+ for (var i = 0; i < freqs.length; i++) {
+ var osc = ctx.createOscillator();
+ osc.type = i % 2 === 0 ? 'sine' : 'triangle';
+ osc.frequency.value = freqs[i];
+ var gain = ctx.createGain();
+ gain.gain.value = 0.12 / (i + 1);
+ osc.connect(gain);
+ gain.connect(this.master);
+ osc.start();
+ this.oscillators.push({ osc: osc, gain: gain, base: freqs[i] });
+ }
+ };
+
+ /** Absolute, idempotent: sets the drone's intensity, not a delta. */
+ AudioService.prototype.setIntensity = function (value) {
+ if (!this.ctx || !this.master) return;
+ var now = this.ctx.currentTime;
+ this.master.gain.cancelScheduledValues(now);
+ this.master.gain.setTargetAtTime(value * 0.5, now, 0.4);
+ for (var i = 0; i < this.oscillators.length; i++) {
+ var o = this.oscillators[i];
+ o.osc.frequency.setTargetAtTime(o.base * (1 + value * 0.35), now, 0.6);
+ }
+ };
+
+ AudioService.prototype.moan = function () {
+ if (!this.ctx || !this.master) return false;
+ var ctx = this.ctx;
+ var now = ctx.currentTime;
+ var osc = ctx.createOscillator();
+ var gain = ctx.createGain();
+ var filter = ctx.createBiquadFilter();
+ filter.type = 'bandpass';
+ filter.frequency.value = 320;
+ filter.Q.value = 6;
+ osc.type = 'sawtooth';
+ osc.frequency.setValueAtTime(180, now);
+ osc.frequency.exponentialRampToValueAtTime(90, now + 1.6);
+ gain.gain.setValueAtTime(0.0001, now);
+ gain.gain.exponentialRampToValueAtTime(0.22, now + 0.25);
+ gain.gain.exponentialRampToValueAtTime(0.0001, now + 1.8);
+ osc.connect(filter);
+ filter.connect(gain);
+ gain.connect(this.master);
+ osc.start(now);
+ osc.stop(now + 1.9);
+ return true;
+ };
+
+ AudioService.prototype.slam = function () {
+ if (!this.ctx || !this.master) return false;
+ var ctx = this.ctx;
+ var now = ctx.currentTime;
+ var bufferSize = Math.floor(ctx.sampleRate * 0.5);
+ var buffer = ctx.createBuffer(1, bufferSize, ctx.sampleRate);
+ var data = buffer.getChannelData(0);
+ for (var i = 0; i < bufferSize; i++) {
+ data[i] = (Math.random() * 2 - 1) * Math.pow(1 - i / bufferSize, 3);
+ }
+ var src = ctx.createBufferSource();
+ src.buffer = buffer;
+ var filter = ctx.createBiquadFilter();
+ filter.type = 'lowpass';
+ filter.frequency.value = 420;
+ var gain = ctx.createGain();
+ gain.gain.value = 0.5;
+ src.connect(filter);
+ filter.connect(gain);
+ gain.connect(this.master);
+ src.start(now);
+ return true;
+ };
+
+ AudioService.prototype.stop = function () {
+ if (!this.ctx) return;
+ for (var i = 0; i < this.oscillators.length; i++) {
+ try { this.oscillators[i].osc.stop(); } catch (e) { /* already stopped */ }
+ }
+ this.oscillators = [];
+ try { this.ctx.close(); } catch (e) { /* already closed */ }
+ this.ctx = null;
+ this.master = null;
+ this._setState('available');
+ };
+
+ /* ------------------------------------------------------------------ *
+ * Rendering
+ * ------------------------------------------------------------------ */
+
+ function Renderer(canvas, house) {
+ this.canvas = canvas;
+ this.ctx = canvas.getContext('2d');
+ this.house = house;
+ this.width = 0;
+ this.height = 0;
+ this.resize();
+ }
+
+ Renderer.prototype.resize = function () {
+ var rect = this.canvas.getBoundingClientRect();
+ var dpr = window.devicePixelRatio || 1;
+ this.width = Math.max(1, Math.round(rect.width));
+ this.height = Math.max(1, Math.round(rect.height));
+ this.canvas.width = Math.round(this.width * dpr);
+ this.canvas.height = Math.round(this.height * dpr);
+ this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
+ };
+
+ Renderer.prototype.draw = function () {
+ var ctx = this.ctx;
+ var w = this.width;
+ var h = this.height;
+ var house = this.house;
+ var room = findById(ROOMS, house.room);
+ var spirit = findById(SPIRITS, house.spirit);
+ var light = house.candlelight;
+
+ ctx.clearRect(0, 0, w, h);
+
+ /* Room shell. */
+ var grad = ctx.createLinearGradient(0, 0, 0, h);
+ grad.addColorStop(0, shade(room.hue, 0.35 + light * 0.5));
+ grad.addColorStop(1, shade(room.hue, 0.12 + light * 0.2));
+ ctx.fillStyle = grad;
+ ctx.fillRect(0, 0, w, h);
+
+ /* Back wall panelling. */
+ ctx.strokeStyle = 'rgba(0,0,0,0.28)';
+ ctx.lineWidth = 1;
+ for (var x = 0; x < w; x += 46) {
+ ctx.beginPath();
+ ctx.moveTo(x, 0);
+ ctx.lineTo(x, h);
+ ctx.stroke();
+ }
+
+ /* Window, brighter with candlelight. */
+ var winW = w * 0.22;
+ var winH = h * 0.3;
+ var winX = w * 0.62;
+ var winY = h * 0.16;
+ ctx.fillStyle = 'rgba(120,150,190,' + (0.1 + light * 0.35) + ')';
+ ctx.fillRect(winX, winY, winW, winH);
+ ctx.strokeStyle = 'rgba(0,0,0,0.5)';
+ ctx.lineWidth = 3;
+ ctx.strokeRect(winX, winY, winW, winH);
+ ctx.beginPath();
+ ctx.moveTo(winX + winW / 2, winY);
+ ctx.lineTo(winX + winW / 2, winY + winH);
+ ctx.moveTo(winX, winY + winH / 2);
+ ctx.lineTo(winX + winW, winY + winH / 2);
+ ctx.stroke();
+
+ /* Floor. */
+ ctx.fillStyle = shade(room.hue, 0.08 + light * 0.12);
+ ctx.beginPath();
+ ctx.moveTo(0, h);
+ ctx.lineTo(w * 0.12, h * 0.72);
+ ctx.lineTo(w * 0.88, h * 0.72);
+ ctx.lineTo(w, h);
+ ctx.closePath();
+ ctx.fill();
+
+ /* Candle pool of light. */
+ var pool = ctx.createRadialGradient(w * 0.3, h * 0.62, 0, w * 0.3, h * 0.62, Math.min(w, h) * 0.55);
+ pool.addColorStop(0, 'rgba(255,196,110,' + (0.28 * light) + ')');
+ pool.addColorStop(1, 'rgba(255,196,110,0)');
+ ctx.fillStyle = pool;
+ ctx.fillRect(0, 0, w, h);
+
+ /* Fog. */
+ if (house.fogDensity > 0.01) {
+ ctx.save();
+ ctx.globalAlpha = clamp(house.fogDensity * 0.5, 0, 0.6);
+ for (var f = 0; f < 5; f++) {
+ var fy = h * (0.55 + f * 0.09);
+ var fx = ((house.time * (0.01 + f * 0.004)) % 1) * w;
+ var fogGrad = ctx.createRadialGradient(fx, fy, 0, fx, fy, w * 0.4);
+ fogGrad.addColorStop(0, 'rgba(190,200,215,0.5)');
+ fogGrad.addColorStop(1, 'rgba(190,200,215,0)');
+ ctx.fillStyle = fogGrad;
+ ctx.fillRect(0, fy - h * 0.2, w, h * 0.4);
+ }
+ ctx.restore();
+ }
+
+ /* The apparition. Its opacity and size follow the haunt level, so the
+ * haunt level is visible rather than merely reported. */
+ var apparitionAlpha = clamp(house.hauntLevel * 0.85, 0, 0.85);
+ if (apparitionAlpha > 0.02) {
+ var ax = w * (0.5 + Math.sin(house.time * 0.35) * 0.12);
+ var ay = h * (0.42 + Math.sin(house.time * 0.21) * 0.05);
+ var ar = Math.min(w, h) * (0.1 + house.hauntLevel * 0.12);
+
+ var aura = ctx.createRadialGradient(ax, ay, 0, ax, ay, ar * 2.4);
+ aura.addColorStop(0, hexToRgba(spirit.color, apparitionAlpha * 0.75));
+ aura.addColorStop(1, hexToRgba(spirit.color, 0));
+ ctx.fillStyle = aura;
+ ctx.beginPath();
+ ctx.arc(ax, ay, ar * 2.4, 0, TAU);
+ ctx.fill();
+
+ ctx.fillStyle = hexToRgba(spirit.color, apparitionAlpha);
+ ctx.beginPath();
+ ctx.ellipse(ax, ay, ar * 0.55, ar, 0, 0, TAU);
+ ctx.fill();
+
+ /* Trailing veil. */
+ ctx.strokeStyle = hexToRgba(spirit.color, apparitionAlpha * 0.5);
+ ctx.lineWidth = 2;
+ ctx.beginPath();
+ for (var t = 0; t <= 1; t += 0.05) {
+ var tx = ax + Math.sin(house.time * 1.4 + t * 6) * ar * 0.5;
+ var ty = ay + ar + t * ar * 1.6;
+ if (t === 0) ctx.moveTo(tx, ty); else ctx.lineTo(tx, ty);
+ }
+ ctx.stroke();
+ }
+
+ /* Embers. */
+ for (var e = 0; e < house.embers.length; e++) {
+ var em = house.embers[e];
+ var ea = 0.25 + Math.sin(em.phase) * 0.2;
+ ctx.fillStyle = 'rgba(255,170,90,' + clamp(ea * light * 2, 0, 0.9) + ')';
+ ctx.beginPath();
+ ctx.arc(em.x * w, em.y * h, 1.6, 0, TAU);
+ ctx.fill();
+ }
+
+ /* Dust motes. */
+ ctx.fillStyle = 'rgba(230,230,240,0.18)';
+ for (var d = 0; d < house.dust.length; d++) {
+ var p = house.dust[d];
+ ctx.beginPath();
+ ctx.arc(p.x * w, p.y * h, p.r, 0, TAU);
+ ctx.fill();
+ }
+
+ /* Vignette. */
+ var vig = ctx.createRadialGradient(w / 2, h / 2, Math.min(w, h) * 0.25, w / 2, h / 2, Math.max(w, h) * 0.75);
+ vig.addColorStop(0, 'rgba(0,0,0,0)');
+ vig.addColorStop(1, 'rgba(0,0,0,0.72)');
+ ctx.fillStyle = vig;
+ ctx.fillRect(0, 0, w, h);
+
+ /* Room label. */
+ ctx.fillStyle = 'rgba(240,230,210,0.7)';
+ ctx.font = '600 12px "Segoe UI", sans-serif';
+ ctx.fillText(room.label.toUpperCase(), 14, 24);
+
+ if (house.paused) {
+ ctx.fillStyle = 'rgba(255,255,255,0.7)';
+ ctx.font = '600 13px "Segoe UI", sans-serif';
+ ctx.fillText('HELD', 14, 44);
+ }
+ };
+
+ function shade(hex, factor) {
+ var n = parseInt(hex.slice(1), 16);
+ var r = clamp(Math.round(((n >> 16) & 255) * factor), 0, 255);
+ var g = clamp(Math.round(((n >> 8) & 255) * factor), 0, 255);
+ var b = clamp(Math.round((n & 255) * factor), 0, 255);
+ return 'rgb(' + r + ',' + g + ',' + b + ')';
+ }
+
+ function hexToRgba(hex, alpha) {
+ var n = parseInt(hex.slice(1), 16);
+ return 'rgba(' + ((n >> 16) & 255) + ',' + ((n >> 8) & 255) + ',' + (n & 255) + ',' + alpha + ')';
+ }
+
+ /* ------------------------------------------------------------------ *
+ * Exhibit
+ * ------------------------------------------------------------------ */
+
+ function HauntedHouseExhibit(options) {
+ this.canvas = options.canvas;
+ this.house = new House();
+ this.audio = new AudioService();
+ this.renderer = new Renderer(this.canvas, this.house);
+ this.announcer = options.announcer;
+ this.onChange = options.onChange || function () {};
+ this._lastFrame = 0;
+ this._running = false;
+ this._boundFrame = this._frame.bind(this);
+ }
+
+ HauntedHouseExhibit.prototype.start = function () {
+ if (this._running) return;
+ this._running = true;
+ this._lastFrame = performance.now();
+ requestAnimationFrame(this._boundFrame);
+ };
+
+ HauntedHouseExhibit.prototype._frame = function (now) {
+ if (!this._running) return;
+ var dt = Math.min(0.05, (now - this._lastFrame) / 1000);
+ this._lastFrame = now;
+ this.house.step(dt);
+ this.renderer.draw();
+ requestAnimationFrame(this._boundFrame);
+ };
+
+ HauntedHouseExhibit.prototype.resize = function () { this.renderer.resize(); };
+
+ /* ------------------------------------------------------------------ *
+ * Absolute, idempotent setters
+ * ------------------------------------------------------------------ */
+
+ HauntedHouseExhibit.prototype.setRoom = function (id) {
+ if (this.house.room === id) return { changed: false };
+ this.house.room = id;
+ this.onChange();
+ return { changed: true };
+ };
+
+ HauntedHouseExhibit.prototype.setSpirit = function (id) {
+ if (this.house.spirit === id) return { changed: false };
+ this.house.spirit = id;
+ this.onChange();
+ return { changed: true };
+ };
+
+ HauntedHouseExhibit.prototype.setHauntLevel = function (value) {
+ if (this.house.hauntLevel === value) return { changed: false };
+ this.house.hauntLevel = value;
+ this.audio.setIntensity(value);
+ this.onChange();
+ return { changed: true };
+ };
+
+ HauntedHouseExhibit.prototype.setFogDensity = function (value) {
+ if (this.house.fogDensity === value) return { changed: false };
+ this.house.fogDensity = value;
+ this.onChange();
+ return { changed: true };
+ };
+
+ HauntedHouseExhibit.prototype.setCandlelight = function (value) {
+ if (this.house.candlelight === value) return { changed: false };
+ this.house.candlelight = value;
+ this.onChange();
+ return { changed: true };
+ };
+
+ HauntedHouseExhibit.prototype.setAudioEnabled = function (on) {
+ if (this.house.audioEnabled === on) return { changed: false };
+ this.house.audioEnabled = on;
+ if (!on) this.audio.setIntensity(0);
+ else this.audio.setIntensity(this.house.hauntLevel);
+ this.onChange();
+ return { changed: true };
+ };
+
+ HauntedHouseExhibit.prototype.setPaused = function (on) {
+ if (this.house.paused === on) return { changed: false };
+ this.house.paused = on;
+ this.onChange();
+ return { changed: true };
+ };
+
+ /* ------------------------------------------------------------------ *
+ * Real actions
+ * ------------------------------------------------------------------ */
+
+ HauntedHouseExhibit.prototype.moan = function () {
+ if (!this.house.audioEnabled) {
+ return { ok: false, code: 'CAPABILITY_UNAVAILABLE', message: 'Audio is disabled.' };
+ }
+ if (!this.audio.moan()) {
+ return { ok: false, code: 'CAPABILITY_UNAVAILABLE', message: 'The audio engine is not ready.' };
+ }
+ this.announcer.flash('A long moan rolls through the ' + this.house.room + '.');
+ this.onChange();
+ return { ok: true };
+ };
+
+ HauntedHouseExhibit.prototype.slamDoor = function () {
+ if (!this.house.audioEnabled) {
+ return { ok: false, code: 'CAPABILITY_UNAVAILABLE', message: 'Audio is disabled.' };
+ }
+ if (!this.audio.slam()) {
+ return { ok: false, code: 'CAPABILITY_UNAVAILABLE', message: 'The audio engine is not ready.' };
+ }
+ this.house.hauntLevel = clamp(this.house.hauntLevel + 0.12, 0, 1);
+ this.announcer.flash('A door slams somewhere above.');
+ this.onChange();
+ return { ok: true };
+ };
+
+ HauntedHouseExhibit.prototype.manifest = function () {
+ this.house.hauntLevel = 1;
+ this.audio.setIntensity(1);
+ this.announcer.flash('The apparition manifests fully.');
+ this.onChange();
+ return { ok: true };
+ };
+
+ HauntedHouseExhibit.prototype.calm = function () {
+ this.house.hauntLevel = 0;
+ this.audio.setIntensity(0);
+ this.announcer.flash('The house settles into silence.');
+ this.onChange();
+ return { ok: true };
+ };
+
+ /* ------------------------------------------------------------------ *
+ * Reads
+ * ------------------------------------------------------------------ */
+
+ HauntedHouseExhibit.prototype.read = function (targetId) {
+ var h = this.house;
+ switch (targetId) {
+ case 'room.current': return h.room;
+ case 'room.spirit': return h.spirit;
+ case 'room.haunt-level': return Math.round(h.hauntLevel * 1000) / 1000;
+ case 'room.fog-density': return h.fogDensity;
+ case 'room.candlelight': return h.candlelight;
+ case 'room.audio-enabled': return h.audioEnabled;
+ case 'room.paused': return h.paused;
+ case 'telemetry.audio-state': return this.audio.state;
+ case 'telemetry.manifestations': return this._manifestations;
+ case 'telemetry.room-damp': return findById(ROOMS, h.room).damp;
+ default: return null;
+ }
+ };
+
+ HauntedHouseExhibit.prototype._manifestations = 0;
+
+ /* ------------------------------------------------------------------ *
+ * Native UI
+ * ------------------------------------------------------------------ */
+
+ function HauntedHouseUI(exhibit, announcer) {
+ this.exhibit = exhibit;
+ this.announcer = announcer;
+ this.nodes = {
+ room: document.getElementById('room-select'),
+ spirit: document.getElementById('spirit-select'),
+ haunt: document.getElementById('haunt-range'),
+ hauntReadout: document.getElementById('haunt-readout'),
+ fog: document.getElementById('fog-range'),
+ fogReadout: document.getElementById('fog-readout'),
+ candle: document.getElementById('candle-range'),
+ candleReadout: document.getElementById('candle-readout'),
+ audio: document.getElementById('audio-button'),
+ pause: document.getElementById('pause-button'),
+ moan: document.getElementById('moan-button'),
+ slam: document.getElementById('slam-button'),
+ manifest: document.getElementById('manifest-button'),
+ calm: document.getElementById('calm-button'),
+ audioState: document.getElementById('audio-state')
+ };
+ this._buildOptions();
+ this._bind();
+ this.sync();
+ }
+
+ HauntedHouseUI.prototype._buildOptions = function () {
+ var r = this.nodes.room;
+ for (var i = 0; i < ROOMS.length; i++) {
+ var opt = document.createElement('option');
+ opt.value = ROOMS[i].id;
+ opt.textContent = ROOMS[i].label;
+ r.appendChild(opt);
+ }
+ var s = this.nodes.spirit;
+ for (var j = 0; j < SPIRITS.length; j++) {
+ var sopt = document.createElement('option');
+ sopt.value = SPIRITS[j].id;
+ sopt.textContent = SPIRITS[j].label;
+ s.appendChild(sopt);
+ }
+ };
+
+ HauntedHouseUI.prototype._bind = function () {
+ var self = this;
+ var ex = this.exhibit;
+
+ this.nodes.room.addEventListener('change', function (e) {
+ self.dispatch('room.current', e.target.value);
+ });
+ this.nodes.spirit.addEventListener('change', function (e) {
+ self.dispatch('room.spirit', e.target.value);
+ });
+ this.nodes.haunt.addEventListener('input', function (e) {
+ self.dispatch('room.haunt-level', Number(e.target.value));
+ });
+ this.nodes.fog.addEventListener('input', function (e) {
+ self.dispatch('room.fog-density', Number(e.target.value));
+ });
+ this.nodes.candle.addEventListener('input', function (e) {
+ self.dispatch('room.candlelight', Number(e.target.value));
+ });
+ this.nodes.audio.addEventListener('click', function () {
+ self.dispatch('room.audio-enabled', !ex.house.audioEnabled);
+ });
+ this.nodes.pause.addEventListener('click', function () {
+ self.dispatch('room.paused', !ex.house.paused);
+ });
+
+ /* The first real gesture is what unlocks the audio engine. This is the
+ * browser's rule, not the exhibit's, so the capability genuinely moves
+ * available -> loading -> ready here. */
+ this.nodes.moan.addEventListener('click', function () {
+ self._ensureAudio();
+ self.invoke('action.moan');
+ });
+ this.nodes.slam.addEventListener('click', function () {
+ self._ensureAudio();
+ self.invoke('action.slam-door');
+ });
+ this.nodes.manifest.addEventListener('click', function () {
+ self._ensureAudio();
+ self.invoke('action.manifest');
+ });
+ this.nodes.calm.addEventListener('click', function () {
+ self.invoke('action.calm');
+ });
+ };
+
+ HauntedHouseUI.prototype._ensureAudio = function () {
+ if (this.exhibit.audio.state === 'ready') return;
+ this.exhibit.audio.prepare();
+ this.sync();
+ };
+
+ HauntedHouseUI.prototype.dispatch = function (targetId, value) {
+ if (typeof this.dispatchOverride === 'function') {
+ this.dispatchOverride(targetId, value);
+ return;
+ }
+ this._fallbackSet(targetId, value);
+ };
+
+ HauntedHouseUI.prototype.invoke = function (targetId) {
+ if (typeof this.invokeOverride === 'function') {
+ this.invokeOverride(targetId);
+ return;
+ }
+ this._fallbackInvoke(targetId);
+ };
+
+ HauntedHouseUI.prototype._fallbackSet = function (targetId, value) {
+ var ex = this.exhibit;
+ switch (targetId) {
+ case 'room.current': ex.setRoom(value); break;
+ case 'room.spirit': ex.setSpirit(value); break;
+ case 'room.haunt-level': ex.setHauntLevel(value); break;
+ case 'room.fog-density': ex.setFogDensity(value); break;
+ case 'room.candlelight': ex.setCandlelight(value); break;
+ case 'room.audio-enabled': ex.setAudioEnabled(value); break;
+ case 'room.paused': ex.setPaused(value); break;
+ default: break;
+ }
+ this.sync();
+ };
+
+ HauntedHouseUI.prototype._fallbackInvoke = function (targetId) {
+ var ex = this.exhibit;
+ if (targetId === 'action.moan') ex.moan();
+ else if (targetId === 'action.slam-door') ex.slamDoor();
+ else if (targetId === 'action.manifest') ex.manifest();
+ else if (targetId === 'action.calm') ex.calm();
+ this.sync();
+ };
+
+ HauntedHouseUI.prototype.sync = function () {
+ var h = this.exhibit.house;
+ var n = this.nodes;
+ var Shell = window.XZBTShell;
+
+ if (n.room.value !== h.room) n.room.value = h.room;
+ if (n.spirit.value !== h.spirit) n.spirit.value = h.spirit;
+ if (Number(n.haunt.value) !== h.hauntLevel) n.haunt.value = String(h.hauntLevel);
+ if (Number(n.fog.value) !== h.fogDensity) n.fog.value = String(h.fogDensity);
+ if (Number(n.candle.value) !== h.candlelight) n.candle.value = String(h.candlelight);
+
+ n.hauntReadout.textContent = Shell.formatPercent(h.hauntLevel);
+ n.fogReadout.textContent = Shell.formatPercent(h.fogDensity);
+ n.candleReadout.textContent = Shell.formatPercent(h.candlelight);
+ n.audio.setAttribute('aria-pressed', h.audioEnabled ? 'true' : 'false');
+ n.audio.textContent = h.audioEnabled ? 'Audio on' : 'Audio off';
+ n.pause.setAttribute('aria-pressed', h.paused ? 'true' : 'false');
+ n.pause.textContent = h.paused ? 'Resume' : 'Hold';
+
+ var state = this.exhibit.audio.state;
+ n.audioState.textContent = state;
+ n.audioState.setAttribute('data-state', state);
+ };
+
+ window.HauntedHouseExhibit = HauntedHouseExhibit;
+ window.HauntedHouseUI = HauntedHouseUI;
+ window.HauntedHouseData = { ROOMS: ROOMS, SPIRITS: SPIRITS };
+})();
diff --git a/test-fixtures/reference-exhibits/haunted-house/index.html b/test-fixtures/reference-exhibits/haunted-house/index.html
new file mode 100644
index 0000000..2d97bd6
--- /dev/null
+++ b/test-fixtures/reference-exhibits/haunted-house/index.html
@@ -0,0 +1,95 @@
+
+
+
+
+
+Haunted House — XZBT Reference Exhibit
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Presence
+
+ Room
+
+
+
+ Spirit
+
+
+
+
+ Haunt level
+ 30%
+
+
+
+
+
+
+
+
+ Actions
+
+ Moan
+ Slam door
+
+
+ Manifest
+ Calm
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/test-fixtures/reference-exhibits/haunted-house/style.css b/test-fixtures/reference-exhibits/haunted-house/style.css
new file mode 100644
index 0000000..366d238
--- /dev/null
+++ b/test-fixtures/reference-exhibits/haunted-house/style.css
@@ -0,0 +1,65 @@
+/* Haunted House — domain styling only. */
+
+body {
+ background: #0d0a08;
+ color: #f0e6d8;
+}
+
+.exhibit-header {
+ background: linear-gradient(90deg, rgba(120, 70, 30, 0.35), rgba(255, 255, 255, 0.03));
+ border-color: rgba(220, 170, 110, 0.22);
+}
+
+.exhibit-header h1 { color: #f0d9b5; }
+
+.stage {
+ border-color: rgba(220, 170, 110, 0.25);
+ box-shadow: inset 0 0 70px rgba(0, 0, 0, 0.9);
+}
+
+.panel {
+ background: rgba(90, 60, 30, 0.2);
+ border-color: rgba(220, 170, 110, 0.18);
+}
+
+.panel h2 { color: #e0b98a; }
+
+input[type="range"] { accent-color: #e0a860; }
+
+button {
+ background: rgba(224, 168, 96, 0.12);
+ border-color: rgba(220, 170, 110, 0.35);
+ color: #f0e6d8;
+}
+
+button:hover { background: rgba(224, 168, 96, 0.24); }
+
+button[aria-pressed="true"] {
+ background: rgba(224, 168, 96, 0.4);
+ border-color: rgba(245, 210, 160, 0.7);
+}
+
+select {
+ background: rgba(20, 14, 10, 0.85);
+ border-color: rgba(220, 170, 110, 0.3);
+}
+
+.exhibit-footer {
+ background: rgba(90, 60, 30, 0.2);
+ border-color: rgba(220, 170, 110, 0.18);
+}
+
+.exhibit-footer .announcement-label { color: #e0b98a; opacity: 0.7; }
+
+.exhibit-header .status { color: #e0b98a; }
+
+/* The audio engine state is a real capability, so it is shown plainly. */
+#audio-state {
+ font-weight: 600;
+ letter-spacing: 0.04em;
+}
+
+#audio-state[data-state="available"] { color: #d8c090; }
+#audio-state[data-state="loading"] { color: #ffd27a; }
+#audio-state[data-state="ready"] { color: #9fe0a0; }
+#audio-state[data-state="error"] { color: #ff8a7a; }
diff --git a/test-fixtures/reference-exhibits/planetarium/boot.js b/test-fixtures/reference-exhibits/planetarium/boot.js
new file mode 100644
index 0000000..062a387
--- /dev/null
+++ b/test-fixtures/reference-exhibits/planetarium/boot.js
@@ -0,0 +1,44 @@
+/*
+ * Planetarium — composition root. Last file to load.
+ */
+(function () {
+ 'use strict';
+
+ var Shell = window.XZBTShell;
+
+ var announcer = new Shell.Announcer({
+ node: Shell.el('announcement'),
+ idleText: 'Dome idle \u2014 awaiting instruction.',
+ maxLength: 120
+ });
+
+ var exhibit = new window.PlanetariumExhibit({
+ canvas: Shell.el('sky-canvas'),
+ announcer: announcer
+ });
+
+ var ui = new window.PlanetariumUI(exhibit, announcer);
+
+ var core = window.PlanetariumContract.create(exhibit, ui);
+
+ var transport = new window.XZBTHostTransport({
+ core: core,
+ onMessage: function (request, response) {
+ if (response.type === 'hello.result') {
+ Shell.setText(Shell.el('contract-status'), 'host attached');
+ }
+ }
+ });
+
+ exhibit.start();
+
+ window.addEventListener('resize', function () { exhibit.resize(); });
+
+ window.__exhibit = {
+ core: core,
+ exhibit: exhibit,
+ ui: ui,
+ transport: transport,
+ announcer: announcer
+ };
+})();
diff --git a/test-fixtures/reference-exhibits/planetarium/contract-adapter.js b/test-fixtures/reference-exhibits/planetarium/contract-adapter.js
new file mode 100644
index 0000000..4f9d8a7
--- /dev/null
+++ b/test-fixtures/reference-exhibits/planetarium/contract-adapter.js
@@ -0,0 +1,215 @@
+/*
+ * Planetarium — XZBT Exhibit Contract 5.2 adapter.
+ *
+ * Declares the catalog, binds targets to real exhibit services, and routes
+ * the native UI through the canonical mutation path. No state of its own.
+ */
+(function () {
+ 'use strict';
+
+ var Core = window.XZBTContractCore;
+
+ var IDENTITY = {
+ product: 'Planetarium',
+ version: '1.0.0',
+ build: 'reference-exhibit'
+ };
+
+ var DESCRIPTORS = [
+ {
+ id: 'sky.constellation',
+ kind: 'selection',
+ label: 'Constellation',
+ description: 'Constellation currently tracked and drawn.',
+ options: [
+ { value: 'orion', label: 'Orion' },
+ { value: 'cygnus', label: 'Cygnus' },
+ { value: 'scorpius', label: 'Scorpius' },
+ { value: 'ursa-major', label: 'Ursa Major' },
+ { value: 'cassiopeia', label: 'Cassiopeia' },
+ { value: 'lyra', label: 'Lyra' }
+ ],
+ readable: true, writable: true, restorable: true,
+ category: 'sky', requires: []
+ },
+ {
+ id: 'sky.magnitude-limit',
+ kind: 'range',
+ label: 'Magnitude limit',
+ description: 'Faintest stellar magnitude rendered. Higher values show more stars.',
+ min: 1, max: 6, step: 0.1, unit: 'mag',
+ readable: true, writable: true, restorable: true,
+ category: 'sky', requires: []
+ },
+ {
+ id: 'sky.azimuth',
+ kind: 'range',
+ label: 'Azimuth',
+ description: 'Compass bearing of the view, 0 to 1 of a full turn.',
+ min: 0, max: 1, step: 0.01,
+ readable: true, writable: true, restorable: true,
+ category: 'sky', requires: []
+ },
+ {
+ id: 'sky.altitude',
+ kind: 'range',
+ label: 'Altitude',
+ description: 'Elevation of the view above the horizon, 0 to 1.',
+ min: 0, max: 1, step: 0.01,
+ readable: true, writable: true, restorable: true,
+ category: 'sky', requires: []
+ },
+ {
+ id: 'sky.time-scale',
+ kind: 'range',
+ label: 'Time scale',
+ description: 'Rate at which sky time advances.',
+ min: 0, max: 4, step: 0.05, unit: 'x',
+ readable: true, writable: true, restorable: true,
+ category: 'sky', requires: []
+ },
+ {
+ id: 'sky.filter',
+ kind: 'selection',
+ label: 'Filter',
+ description: 'Optical filter applied to the view.',
+ options: [
+ { value: 'none', label: 'None' },
+ { value: 'light-pollution', label: 'Light pollution' },
+ { value: 'h-alpha', label: 'H-alpha' },
+ { value: 'oxygen-iii', label: 'Oxygen III' }
+ ],
+ readable: true, writable: true, restorable: true,
+ category: 'sky', requires: []
+ },
+ {
+ id: 'sky.tracking',
+ kind: 'state',
+ valueType: 'boolean',
+ label: 'Tracking',
+ description: 'Whether the mount is tracking the selected constellation.',
+ readable: true, writable: true, restorable: true,
+ category: 'sky', requires: []
+ },
+ {
+ id: 'sky.paused',
+ kind: 'state',
+ valueType: 'boolean',
+ label: 'Sky frozen',
+ description: 'Whether sky time is frozen.',
+ readable: true, writable: true, restorable: true,
+ category: 'sky', requires: []
+ },
+ {
+ id: 'action.advance-time',
+ kind: 'impulse',
+ label: 'Advance time',
+ description: 'Advance sky time. Optional argument: minutes (1 to 240).',
+ arguments: [
+ { name: 'minutes', type: 'number', required: false, min: 1, max: 240, default: 10 }
+ ],
+ readable: false, writable: false, restorable: false,
+ category: 'action', requires: []
+ },
+ {
+ id: 'action.reset-sky',
+ kind: 'impulse',
+ label: 'Reset sky',
+ description: 'Return sky time to epoch.',
+ readable: false, writable: false, restorable: false,
+ category: 'action', requires: []
+ },
+ {
+ id: 'action.toggle-tracking',
+ kind: 'impulse',
+ label: 'Toggle tracking',
+ description: 'Engage or disengage the mount.',
+ readable: false, writable: false, restorable: false,
+ category: 'action', requires: []
+ },
+ {
+ id: 'telemetry.visible-stars',
+ kind: 'state',
+ valueType: 'number',
+ label: 'Visible stars',
+ description: 'Stars currently above the magnitude limit.',
+ readable: true, writable: false, restorable: false,
+ category: 'telemetry', requires: []
+ },
+ {
+ id: 'telemetry.sky-time',
+ kind: 'state',
+ valueType: 'number',
+ unit: 'h',
+ label: 'Sky time',
+ description: 'Elapsed sky time in hours.',
+ readable: true, writable: false, restorable: false,
+ category: 'telemetry', requires: []
+ },
+ {
+ id: 'telemetry.moon-phase',
+ kind: 'state',
+ valueType: 'number',
+ label: 'Moon phase',
+ description: 'Lunar phase as a fraction of the cycle.',
+ readable: true, writable: false, restorable: false,
+ category: 'telemetry', requires: []
+ }
+ ];
+
+ function createPlanetariumContract(exhibit, ui) {
+ var catalog = new Core.Catalog(DESCRIPTORS);
+
+ var capabilities = new Core.CapabilityRegistry();
+ capabilities.declare('render', 'ready');
+
+ var setters = {
+ 'sky.constellation': function (v) { return exhibit.setConstellation(v); },
+ 'sky.magnitude-limit': function (v) { return exhibit.setMagnitudeLimit(v); },
+ 'sky.azimuth': function (v) { return exhibit.setAzimuth(v); },
+ 'sky.altitude': function (v) { return exhibit.setAltitude(v); },
+ 'sky.time-scale': function (v) { return exhibit.setTimeScale(v); },
+ 'sky.filter': function (v) { return exhibit.setFilter(v); },
+ 'sky.tracking': function (v) { return exhibit.setTracking(v); },
+ 'sky.paused': function (v) { return exhibit.setPaused(v); }
+ };
+
+ var readers = {};
+ var ids = catalog.ids();
+ for (var i = 0; i < ids.length; i++) {
+ (function (id) {
+ readers[id] = function () { return exhibit.read(id); };
+ })(ids[i]);
+ }
+
+ var actions = {
+ 'action.advance-time': function (args) { return exhibit.advanceTime(args); },
+ 'action.reset-sky': function () { return exhibit.resetSky(); },
+ 'action.toggle-tracking': function () { return exhibit.toggleTracking(); }
+ };
+
+ var core = new Core.ContractCore({
+ identity: IDENTITY,
+ catalog: catalog,
+ capabilities: capabilities,
+ setters: setters,
+ readers: readers,
+ actions: actions
+ });
+
+ ui.dispatchOverride = function (targetId, value) {
+ core.applyMutation(targetId, value, 'ui');
+ ui.sync();
+ };
+ ui.invokeOverride = function (targetId, args) {
+ core.invokeAction(targetId, args || {}, 'ui');
+ ui.sync();
+ };
+
+ exhibit.onChange = function () { ui.sync(); };
+
+ return core;
+ }
+
+ window.PlanetariumContract = { create: createPlanetariumContract, IDENTITY: IDENTITY };
+})();
diff --git a/test-fixtures/reference-exhibits/planetarium/exhibit.js b/test-fixtures/reference-exhibits/planetarium/exhibit.js
new file mode 100644
index 0000000..9e43c93
--- /dev/null
+++ b/test-fixtures/reference-exhibits/planetarium/exhibit.js
@@ -0,0 +1,564 @@
+/*
+ * Planetarium — exhibit behaviour.
+ *
+ * Owns the sky model, the ephemeris, the canvas rendering, and the native
+ * controls. No contract awareness: no target IDs, no events, no revisions.
+ *
+ * The exhibit's own services (absolute setters and real actions) are what
+ * the contract layer binds to, so the native UI and a future host share one
+ * code path.
+ */
+(function () {
+ 'use strict';
+
+ var TAU = Math.PI * 2;
+
+ var CATALOG = [
+ { id: 'orion', label: 'Orion', stars: 9, brightness: 1.0, hue: '#cfe4ff' },
+ { id: 'cygnus', label: 'Cygnus', stars: 7, brightness: 0.85, hue: '#dbe8ff' },
+ { id: 'scorpius', label: 'Scorpius', stars: 8, brightness: 0.95, hue: '#ffe0c4' },
+ { id: 'ursa-major', label: 'Ursa Major', stars: 7, brightness: 0.8, hue: '#e6f0ff' },
+ { id: 'cassiopeia', label: 'Cassiopeia', stars: 5, brightness: 0.75, hue: '#f0e6ff' },
+ { id: 'lyra', label: 'Lyra', stars: 6, brightness: 0.9, hue: '#e8f4ff' }
+ ];
+
+ var BODIES = [
+ { id: 'moon', label: 'Moon', radius: 0.055, period: 0.09, color: '#e8eef7', phase: 0.2 },
+ { id: 'mars', label: 'Mars', radius: 0.085, period: 0.16, color: '#e07a52', phase: 1.1 },
+ { id: 'jupiter', label: 'Jupiter', radius: 0.115, period: 0.27, color: '#e6c79a', phase: 2.4 },
+ { id: 'saturn', label: 'Saturn', radius: 0.145, period: 0.41, color: '#e8d9a8', phase: 4.0, ring: true }
+ ];
+
+ var FILTERS = [
+ { id: 'none', label: 'None', tint: null, gain: 1.0 },
+ { id: 'light-pollution', label: 'Light pollution', tint: '#3a2a1a', gain: 0.55 },
+ { id: 'h-alpha', label: 'H-alpha', tint: '#5a1020', gain: 0.8 },
+ { id: 'oxygen-iii', label: 'Oxygen III', tint: '#0d3a4a', gain: 0.8 }
+ ];
+
+ var MAX_MAGNITUDE = 6.0;
+ var MIN_MAGNITUDE = 1.0;
+
+ function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
+
+ function findById(list, id) {
+ for (var i = 0; i < list.length; i++) if (list[i].id === id) return list[i];
+ return null;
+ }
+
+ /* Deterministic pseudo-random so a constellation always looks the same. */
+ function hash(n) {
+ var x = Math.sin(n * 127.1 + 311.7) * 43758.5453;
+ return x - Math.floor(x);
+ }
+
+ /* ------------------------------------------------------------------ *
+ * Sky model
+ * ------------------------------------------------------------------ */
+
+ function Sky() {
+ this.constellation = 'orion';
+ this.magnitudeLimit = 4.5;
+ this.azimuth = 0.25;
+ this.altitude = 0.5;
+ this.timeScale = 0.5;
+ this.filter = 'none';
+ this.tracking = true;
+ this.paused = false;
+ this.skyTime = 0;
+ this.stars = [];
+ this._buildStars();
+ }
+
+ Sky.prototype._buildStars = function () {
+ this.stars = [];
+ var count = 420;
+ for (var i = 0; i < count; i++) {
+ var mag = MIN_MAGNITUDE + hash(i * 3.1) * (MAX_MAGNITUDE - MIN_MAGNITUDE);
+ this.stars.push({
+ x: hash(i * 1.7),
+ y: hash(i * 2.3) * 0.92,
+ mag: mag,
+ twinkle: hash(i * 5.9) * TAU,
+ hue: hash(i * 7.3) > 0.82 ? '#ffd9b0' : (hash(i * 9.1) > 0.7 ? '#c9d8ff' : '#ffffff')
+ });
+ }
+ };
+
+ Sky.prototype.step = function (dt) {
+ if (this.paused) return;
+ this.skyTime += dt * this.timeScale;
+ };
+
+ /** Position of a body on the sky dome, in normalised 0..1 coordinates. */
+ Sky.prototype.bodyPosition = function (body) {
+ var angle = body.phase + this.skyTime * TAU * body.period;
+ var cx = 0.5 + (this.azimuth - 0.5) * 0.6;
+ var cy = 0.5 + (this.altitude - 0.5) * 0.6;
+ return {
+ x: cx + Math.cos(angle) * body.radius * 3.2,
+ y: cy + Math.sin(angle) * body.radius * 1.9
+ };
+ };
+
+ /* ------------------------------------------------------------------ *
+ * Rendering
+ * ------------------------------------------------------------------ */
+
+ function Renderer(canvas, sky) {
+ this.canvas = canvas;
+ this.ctx = canvas.getContext('2d');
+ this.sky = sky;
+ this.width = 0;
+ this.height = 0;
+ this.resize();
+ }
+
+ Renderer.prototype.resize = function () {
+ var rect = this.canvas.getBoundingClientRect();
+ var dpr = window.devicePixelRatio || 1;
+ this.width = Math.max(1, Math.round(rect.width));
+ this.height = Math.max(1, Math.round(rect.height));
+ this.canvas.width = Math.round(this.width * dpr);
+ this.canvas.height = Math.round(this.height * dpr);
+ this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
+ };
+
+ Renderer.prototype.draw = function () {
+ var ctx = this.ctx;
+ var w = this.width;
+ var h = this.height;
+ var sky = this.sky;
+ var filter = findById(FILTERS, sky.filter);
+ var constellation = findById(CATALOG, sky.constellation);
+
+ ctx.clearRect(0, 0, w, h);
+
+ /* Sky dome. */
+ var grad = ctx.createRadialGradient(w * 0.5, h * 0.55, 0, w * 0.5, h * 0.55, Math.max(w, h) * 0.75);
+ grad.addColorStop(0, '#0b1226');
+ grad.addColorStop(1, '#03050c');
+ ctx.fillStyle = grad;
+ ctx.fillRect(0, 0, w, h);
+
+ /* Horizon glow. */
+ var horizon = ctx.createLinearGradient(0, h * 0.72, 0, h);
+ horizon.addColorStop(0, 'rgba(20,40,80,0)');
+ horizon.addColorStop(1, 'rgba(30,60,110,0.35)');
+ ctx.fillStyle = horizon;
+ ctx.fillRect(0, h * 0.72, w, h * 0.28);
+
+ /* Stars, filtered by the magnitude limit. */
+ for (var i = 0; i < sky.stars.length; i++) {
+ var s = sky.stars[i];
+ if (s.mag > sky.magnitudeLimit) continue;
+ var norm = 1 - (s.mag - MIN_MAGNITUDE) / (MAX_MAGNITUDE - MIN_MAGNITUDE);
+ var alpha = clamp(norm * 0.95 + 0.05, 0, 1) * filter.gain;
+ var twinkle = 0.85 + Math.sin(sky.skyTime * 2.4 + s.twinkle) * 0.15;
+ var r = 0.6 + norm * 1.9;
+
+ ctx.globalAlpha = clamp(alpha * twinkle, 0, 1);
+ ctx.fillStyle = s.hue;
+ ctx.beginPath();
+ ctx.arc(s.x * w, s.y * h, r, 0, TAU);
+ ctx.fill();
+ }
+ ctx.globalAlpha = 1;
+
+ /* The selected constellation, drawn as a connected figure. */
+ var baseX = (0.5 + (sky.azimuth - 0.5) * 0.7) * w;
+ var baseY = (0.42 + (sky.altitude - 0.5) * 0.5) * h;
+ var scale = Math.min(w, h) * 0.16;
+ var points = [];
+ for (var k = 0; k < constellation.stars; k++) {
+ var angle = (k / constellation.stars) * TAU + hash(k * 4.4) * 0.6;
+ var dist = 0.35 + hash(k * 6.1) * 0.65;
+ points.push({
+ x: baseX + Math.cos(angle) * dist * scale,
+ y: baseY + Math.sin(angle) * dist * scale * 0.7
+ });
+ }
+
+ ctx.strokeStyle = 'rgba(150,200,255,' + (0.28 * filter.gain) + ')';
+ ctx.lineWidth = 1;
+ ctx.beginPath();
+ for (var p = 0; p < points.length; p++) {
+ var next = points[(p + 1) % points.length];
+ ctx.moveTo(points[p].x, points[p].y);
+ ctx.lineTo(next.x, next.y);
+ }
+ ctx.stroke();
+
+ for (var q = 0; q < points.length; q++) {
+ ctx.fillStyle = constellation.hue;
+ ctx.globalAlpha = clamp(constellation.brightness * filter.gain, 0, 1);
+ ctx.beginPath();
+ ctx.arc(points[q].x, points[q].y, 2.4, 0, TAU);
+ ctx.fill();
+ }
+ ctx.globalAlpha = 1;
+
+ /* Label the constellation with textContent-equivalent canvas text. */
+ ctx.fillStyle = 'rgba(190,220,255,0.75)';
+ ctx.font = '600 12px "Segoe UI", sans-serif';
+ ctx.fillText(constellation.label, baseX - scale * 0.5, baseY + scale * 1.15);
+
+ /* Solar-system bodies. */
+ for (var b = 0; b < BODIES.length; b++) {
+ var body = BODIES[b];
+ var pos = sky.bodyPosition(body);
+ var bx = pos.x * w;
+ var by = pos.y * h;
+ var br = body.radius * Math.min(w, h) * 0.5;
+
+ ctx.globalAlpha = clamp(filter.gain, 0, 1);
+ ctx.fillStyle = body.color;
+ ctx.beginPath();
+ ctx.arc(bx, by, br, 0, TAU);
+ ctx.fill();
+
+ if (body.ring) {
+ ctx.strokeStyle = 'rgba(232,217,168,0.7)';
+ ctx.lineWidth = 2;
+ ctx.beginPath();
+ ctx.ellipse(bx, by, br * 1.9, br * 0.6, 0.4, 0, TAU);
+ ctx.stroke();
+ }
+
+ ctx.fillStyle = 'rgba(200,220,255,0.6)';
+ ctx.font = '11px "Segoe UI", sans-serif';
+ ctx.fillText(body.label, bx + br + 5, by + 4);
+ ctx.globalAlpha = 1;
+ }
+
+ /* Filter tint over the whole frame. */
+ if (filter.tint) {
+ ctx.globalAlpha = 0.22;
+ ctx.fillStyle = filter.tint;
+ ctx.fillRect(0, 0, w, h);
+ ctx.globalAlpha = 1;
+ }
+
+ /* Tracking reticle. */
+ if (sky.tracking) {
+ ctx.strokeStyle = 'rgba(120,200,255,0.5)';
+ ctx.lineWidth = 1;
+ ctx.beginPath();
+ ctx.arc(baseX, baseY, scale * 1.35, 0, TAU);
+ ctx.stroke();
+ ctx.beginPath();
+ ctx.moveTo(baseX - scale * 1.5, baseY);
+ ctx.lineTo(baseX - scale * 1.2, baseY);
+ ctx.moveTo(baseX + scale * 1.2, baseY);
+ ctx.lineTo(baseX + scale * 1.5, baseY);
+ ctx.stroke();
+ }
+
+ if (sky.paused) {
+ ctx.fillStyle = 'rgba(255,255,255,0.7)';
+ ctx.font = '600 13px "Segoe UI", sans-serif';
+ ctx.fillText('SKY FROZEN', 14, 24);
+ }
+ };
+
+ /* ------------------------------------------------------------------ *
+ * Exhibit
+ * ------------------------------------------------------------------ */
+
+ function PlanetariumExhibit(options) {
+ this.canvas = options.canvas;
+ this.sky = new Sky();
+ this.renderer = new Renderer(this.canvas, this.sky);
+ this.announcer = options.announcer;
+ this.onChange = options.onChange || function () {};
+ this._lastFrame = 0;
+ this._running = false;
+ this._boundFrame = this._frame.bind(this);
+ }
+
+ PlanetariumExhibit.prototype.start = function () {
+ if (this._running) return;
+ this._running = true;
+ this._lastFrame = performance.now();
+ requestAnimationFrame(this._boundFrame);
+ };
+
+ PlanetariumExhibit.prototype._frame = function (now) {
+ if (!this._running) return;
+ var dt = Math.min(0.05, (now - this._lastFrame) / 1000);
+ this._lastFrame = now;
+ this.sky.step(dt);
+ this.renderer.draw();
+ requestAnimationFrame(this._boundFrame);
+ };
+
+ PlanetariumExhibit.prototype.resize = function () { this.renderer.resize(); };
+
+ /* ------------------------------------------------------------------ *
+ * Absolute, idempotent setters
+ * ------------------------------------------------------------------ */
+
+ PlanetariumExhibit.prototype.setConstellation = function (id) {
+ if (this.sky.constellation === id) return { changed: false };
+ this.sky.constellation = id;
+ this.onChange();
+ return { changed: true };
+ };
+
+ PlanetariumExhibit.prototype.setMagnitudeLimit = function (value) {
+ if (this.sky.magnitudeLimit === value) return { changed: false };
+ this.sky.magnitudeLimit = value;
+ this.onChange();
+ return { changed: true };
+ };
+
+ PlanetariumExhibit.prototype.setAzimuth = function (value) {
+ if (this.sky.azimuth === value) return { changed: false };
+ this.sky.azimuth = value;
+ this.onChange();
+ return { changed: true };
+ };
+
+ PlanetariumExhibit.prototype.setAltitude = function (value) {
+ if (this.sky.altitude === value) return { changed: false };
+ this.sky.altitude = value;
+ this.onChange();
+ return { changed: true };
+ };
+
+ PlanetariumExhibit.prototype.setTimeScale = function (value) {
+ if (this.sky.timeScale === value) return { changed: false };
+ this.sky.timeScale = value;
+ this.onChange();
+ return { changed: true };
+ };
+
+ PlanetariumExhibit.prototype.setFilter = function (id) {
+ if (this.sky.filter === id) return { changed: false };
+ this.sky.filter = id;
+ this.onChange();
+ return { changed: true };
+ };
+
+ PlanetariumExhibit.prototype.setTracking = function (on) {
+ if (this.sky.tracking === on) return { changed: false };
+ this.sky.tracking = on;
+ this.onChange();
+ return { changed: true };
+ };
+
+ PlanetariumExhibit.prototype.setPaused = function (on) {
+ if (this.sky.paused === on) return { changed: false };
+ this.sky.paused = on;
+ this.onChange();
+ return { changed: true };
+ };
+
+ /* ------------------------------------------------------------------ *
+ * Real actions
+ * ------------------------------------------------------------------ */
+
+ PlanetariumExhibit.prototype.advanceTime = function (args) {
+ var minutes = 10;
+ if (args && typeof args.minutes === 'number') {
+ if (!isFinite(args.minutes) || args.minutes < 1 || args.minutes > 240) {
+ return { ok: false, code: 'INVALID_ARGUMENTS', message: 'minutes must be between 1 and 240.' };
+ }
+ minutes = Math.round(args.minutes);
+ }
+ this.sky.skyTime += minutes / 60;
+ this.announcer.flash('Advanced sky time by ' + minutes + ' minutes.');
+ this.onChange();
+ return { ok: true, args: { minutes: minutes } };
+ };
+
+ PlanetariumExhibit.prototype.resetSky = function () {
+ this.sky.skyTime = 0;
+ this.announcer.flash('Sky time reset to epoch.');
+ this.onChange();
+ return { ok: true };
+ };
+
+ PlanetariumExhibit.prototype.toggleTracking = function () {
+ this.sky.tracking = !this.sky.tracking;
+ this.announcer.flash(this.sky.tracking ? 'Tracking engaged.' : 'Tracking disengaged.');
+ this.onChange();
+ return { ok: true };
+ };
+
+ /* ------------------------------------------------------------------ *
+ * Reads
+ * ------------------------------------------------------------------ */
+
+ PlanetariumExhibit.prototype.read = function (targetId) {
+ var s = this.sky;
+ switch (targetId) {
+ case 'sky.constellation': return s.constellation;
+ case 'sky.magnitude-limit': return s.magnitudeLimit;
+ case 'sky.azimuth': return s.azimuth;
+ case 'sky.altitude': return s.altitude;
+ case 'sky.time-scale': return s.timeScale;
+ case 'sky.filter': return s.filter;
+ case 'sky.tracking': return s.tracking;
+ case 'sky.paused': return s.paused;
+ case 'telemetry.visible-stars': return this._visibleStarCount();
+ case 'telemetry.sky-time': return Math.round(s.skyTime * 100) / 100;
+ case 'telemetry.moon-phase': return Math.round(((s.skyTime * 0.09) % 1) * 100) / 100;
+ default: return null;
+ }
+ };
+
+ PlanetariumExhibit.prototype._visibleStarCount = function () {
+ var count = 0;
+ for (var i = 0; i < this.sky.stars.length; i++) {
+ if (this.sky.stars[i].mag <= this.sky.magnitudeLimit) count++;
+ }
+ return count;
+ };
+
+ /* ------------------------------------------------------------------ *
+ * Native UI
+ * ------------------------------------------------------------------ */
+
+ function PlanetariumUI(exhibit, announcer) {
+ this.exhibit = exhibit;
+ this.announcer = announcer;
+ this.nodes = {
+ constellation: document.getElementById('constellation-select'),
+ magnitude: document.getElementById('magnitude-range'),
+ magnitudeReadout: document.getElementById('magnitude-readout'),
+ azimuth: document.getElementById('azimuth-range'),
+ azimuthReadout: document.getElementById('azimuth-readout'),
+ altitude: document.getElementById('altitude-range'),
+ altitudeReadout: document.getElementById('altitude-readout'),
+ timeScale: document.getElementById('timescale-range'),
+ timeScaleReadout: document.getElementById('timescale-readout'),
+ filter: document.getElementById('filter-select'),
+ tracking: document.getElementById('tracking-button'),
+ pause: document.getElementById('pause-button'),
+ advance: document.getElementById('advance-button'),
+ reset: document.getElementById('reset-button')
+ };
+ this._buildOptions();
+ this._bind();
+ this.sync();
+ }
+
+ PlanetariumUI.prototype._buildOptions = function () {
+ var c = this.nodes.constellation;
+ for (var i = 0; i < CATALOG.length; i++) {
+ var opt = document.createElement('option');
+ opt.value = CATALOG[i].id;
+ opt.textContent = CATALOG[i].label;
+ c.appendChild(opt);
+ }
+ var f = this.nodes.filter;
+ for (var j = 0; j < FILTERS.length; j++) {
+ var fopt = document.createElement('option');
+ fopt.value = FILTERS[j].id;
+ fopt.textContent = FILTERS[j].label;
+ f.appendChild(fopt);
+ }
+ };
+
+ PlanetariumUI.prototype._bind = function () {
+ var self = this;
+ var ex = this.exhibit;
+
+ this.nodes.constellation.addEventListener('change', function (e) {
+ self.dispatch('sky.constellation', e.target.value);
+ });
+ this.nodes.magnitude.addEventListener('input', function (e) {
+ self.dispatch('sky.magnitude-limit', Number(e.target.value));
+ });
+ this.nodes.azimuth.addEventListener('input', function (e) {
+ self.dispatch('sky.azimuth', Number(e.target.value));
+ });
+ this.nodes.altitude.addEventListener('input', function (e) {
+ self.dispatch('sky.altitude', Number(e.target.value));
+ });
+ this.nodes.timeScale.addEventListener('input', function (e) {
+ self.dispatch('sky.time-scale', Number(e.target.value));
+ });
+ this.nodes.filter.addEventListener('change', function (e) {
+ self.dispatch('sky.filter', e.target.value);
+ });
+ this.nodes.tracking.addEventListener('click', function () {
+ self.dispatch('sky.tracking', !ex.sky.tracking);
+ });
+ this.nodes.pause.addEventListener('click', function () {
+ self.dispatch('sky.paused', !ex.sky.paused);
+ });
+ this.nodes.advance.addEventListener('click', function () {
+ self.invoke('action.advance-time', { minutes: 30 });
+ });
+ this.nodes.reset.addEventListener('click', function () {
+ self.invoke('action.reset-sky');
+ });
+ };
+
+ PlanetariumUI.prototype.dispatch = function (targetId, value) {
+ if (typeof this.dispatchOverride === 'function') {
+ this.dispatchOverride(targetId, value);
+ return;
+ }
+ this._fallbackSet(targetId, value);
+ };
+
+ PlanetariumUI.prototype.invoke = function (targetId, args) {
+ if (typeof this.invokeOverride === 'function') {
+ this.invokeOverride(targetId, args);
+ return;
+ }
+ this._fallbackInvoke(targetId, args);
+ };
+
+ PlanetariumUI.prototype._fallbackSet = function (targetId, value) {
+ var ex = this.exhibit;
+ switch (targetId) {
+ case 'sky.constellation': ex.setConstellation(value); break;
+ case 'sky.magnitude-limit': ex.setMagnitudeLimit(value); break;
+ case 'sky.azimuth': ex.setAzimuth(value); break;
+ case 'sky.altitude': ex.setAltitude(value); break;
+ case 'sky.time-scale': ex.setTimeScale(value); break;
+ case 'sky.filter': ex.setFilter(value); break;
+ case 'sky.tracking': ex.setTracking(value); break;
+ case 'sky.paused': ex.setPaused(value); break;
+ default: break;
+ }
+ this.sync();
+ };
+
+ PlanetariumUI.prototype._fallbackInvoke = function (targetId, args) {
+ var ex = this.exhibit;
+ if (targetId === 'action.advance-time') ex.advanceTime(args || {});
+ else if (targetId === 'action.reset-sky') ex.resetSky();
+ else if (targetId === 'action.toggle-tracking') ex.toggleTracking();
+ this.sync();
+ };
+
+ PlanetariumUI.prototype.sync = function () {
+ var s = this.exhibit.sky;
+ var n = this.nodes;
+ var Shell = window.XZBTShell;
+
+ if (n.constellation.value !== s.constellation) n.constellation.value = s.constellation;
+ if (Number(n.magnitude.value) !== s.magnitudeLimit) n.magnitude.value = String(s.magnitudeLimit);
+ if (Number(n.azimuth.value) !== s.azimuth) n.azimuth.value = String(s.azimuth);
+ if (Number(n.altitude.value) !== s.altitude) n.altitude.value = String(s.altitude);
+ if (Number(n.timeScale.value) !== s.timeScale) n.timeScale.value = String(s.timeScale);
+ if (n.filter.value !== s.filter) n.filter.value = s.filter;
+
+ n.magnitudeReadout.textContent = 'mag \u2264 ' + Shell.formatNumber(s.magnitudeLimit, 1);
+ n.azimuthReadout.textContent = Shell.formatDegrees(s.azimuth);
+ n.altitudeReadout.textContent = Shell.formatDegrees(s.altitude);
+ n.timeScaleReadout.textContent = Shell.formatNumber(s.timeScale, 2) + '\u00d7';
+ n.tracking.setAttribute('aria-pressed', s.tracking ? 'true' : 'false');
+ n.pause.setAttribute('aria-pressed', s.paused ? 'true' : 'false');
+ n.pause.textContent = s.paused ? 'Resume sky' : 'Freeze sky';
+ };
+
+ window.PlanetariumExhibit = PlanetariumExhibit;
+ window.PlanetariumUI = PlanetariumUI;
+ window.PlanetariumData = { CATALOG: CATALOG, BODIES: BODIES, FILTERS: FILTERS };
+})();
diff --git a/test-fixtures/reference-exhibits/planetarium/index.html b/test-fixtures/reference-exhibits/planetarium/index.html
new file mode 100644
index 0000000..05ba8bb
--- /dev/null
+++ b/test-fixtures/reference-exhibits/planetarium/index.html
@@ -0,0 +1,102 @@
+
+
+
+
+
+Planetarium — XZBT Reference Exhibit
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Optics
+
+ Filter
+
+
+
+ Mount tracking
+ Tracking
+
+
+ Sky time
+ Freeze sky
+
+
+
+
+ Actions
+
+ Advance 30 min
+ Reset sky
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/test-fixtures/reference-exhibits/planetarium/style.css b/test-fixtures/reference-exhibits/planetarium/style.css
new file mode 100644
index 0000000..3c5b7bb
--- /dev/null
+++ b/test-fixtures/reference-exhibits/planetarium/style.css
@@ -0,0 +1,54 @@
+/* Planetarium — domain styling only. */
+
+body {
+ background: #05070f;
+ color: #e2ecff;
+}
+
+.exhibit-header {
+ background: linear-gradient(90deg, rgba(60, 70, 150, 0.35), rgba(255, 255, 255, 0.03));
+ border-color: rgba(150, 180, 255, 0.22);
+}
+
+.exhibit-header h1 { color: #cdd9ff; }
+
+.stage {
+ border-color: rgba(150, 180, 255, 0.25);
+ box-shadow: inset 0 0 80px rgba(0, 0, 30, 0.9);
+}
+
+.panel {
+ background: rgba(40, 50, 110, 0.22);
+ border-color: rgba(150, 180, 255, 0.18);
+}
+
+.panel h2 { color: #a9bcff; }
+
+input[type="range"] { accent-color: #8fa8ff; }
+
+button {
+ background: rgba(143, 168, 255, 0.12);
+ border-color: rgba(150, 180, 255, 0.35);
+ color: #e2ecff;
+}
+
+button:hover { background: rgba(143, 168, 255, 0.24); }
+
+button[aria-pressed="true"] {
+ background: rgba(143, 168, 255, 0.4);
+ border-color: rgba(190, 210, 255, 0.7);
+}
+
+select {
+ background: rgba(6, 10, 26, 0.85);
+ border-color: rgba(150, 180, 255, 0.3);
+}
+
+.exhibit-footer {
+ background: rgba(40, 50, 110, 0.22);
+ border-color: rgba(150, 180, 255, 0.18);
+}
+
+.exhibit-footer .announcement-label { color: #a9bcff; opacity: 0.7; }
+
+.exhibit-header .status { color: #a9bcff; }
diff --git a/test-fixtures/reference-exhibits/scifi/css/style.css b/test-fixtures/reference-exhibits/scifi/css/style.css
new file mode 100644
index 0000000..aaf716e
--- /dev/null
+++ b/test-fixtures/reference-exhibits/scifi/css/style.css
@@ -0,0 +1,2430 @@
+/**
+ * Authentic LCARD & Multi-Era Star Trek Styling
+ * Includes TNG 24th Century, TOS Retro 23rd Century, and NX-01 Industrial themes.
+ */
+
+@import url('https://fonts.googleapis.com/css2?family=Antonio:wght@400;600;700&family=Share+Tech+Mono&display=swap');
+
+:root {
+ /* TNG 24th Century LCARD Palette */
+ --lcars-orange: #ff9900;
+ --lcars-tangerine: #ff6600;
+ --lcars-amber: #cc6600;
+ --lcars-sand: #ffcc66;
+ --lcars-lavender: #cc99cc;
+ --lcars-violet: #9966cc;
+ --lcars-ice-blue: #99ccff;
+ --lcars-blue: #3366cc;
+ --lcars-red: #cc3333;
+ --lcars-crimson: #990000;
+ --lcars-bg: #000000;
+ --lcars-panel-bg: #0d0f14;
+ --lcars-font: 'Antonio', 'Arial Narrow', sans-serif;
+ --mono-font: 'Share Tech Mono', monospace;
+
+ /* Theme variables (Defaults to TNG) */
+ --primary-accent: var(--lcars-orange);
+ --secondary-accent: var(--lcars-lavender);
+ --tertiary-accent: var(--lcars-ice-blue);
+ --warning-accent: var(--lcars-red);
+}
+
+/* Wayfarer Era Palette */
+body.theme-lcars-voyager {
+ --primary-accent: #3399cc;
+ --secondary-accent: #9999ff;
+ --tertiary-accent: #66cccc;
+ --warning-accent: #cc3333;
+}
+
+/* Defiance Combat Palette */
+body.theme-lcars-defiant {
+ --primary-accent: #cc5500;
+ --secondary-accent: #aa3300;
+ --tertiary-accent: #dd8800;
+ --warning-accent: #ff0000;
+}
+
+/* Deep Space Nine Palette */
+body.theme-lcars-ds9 {
+ --primary-accent: #997755;
+ --secondary-accent: #bb8844;
+ --tertiary-accent: #667788;
+ --warning-accent: #bb3322;
+}
+
+/* TOS Retro 23rd Century Palette */
+body.theme-tos-retro {
+ --primary-accent: #ffcc00;
+ --secondary-accent: #0099ff;
+ --tertiary-accent: #cc0000;
+ --lcars-panel-bg: #15181c;
+ --lcars-font: 'Share Tech Mono', monospace;
+}
+
+/* NX Industrial Palette */
+body.theme-nx-industrial {
+ --primary-accent: #33bbee;
+ --secondary-accent: #88aacc;
+ --tertiary-accent: #445566;
+ --lcars-panel-bg: #0e1217;
+ --lcars-font: 'Share Tech Mono', monospace;
+}
+
+* {
+ box-sizing: border-box;
+ margin: 0;
+ padding: 0;
+ user-select: none;
+}
+
+body {
+ background-color: var(--lcars-bg);
+ color: #ffffff;
+ font-family: var(--lcars-font);
+ letter-spacing: 0.05em;
+ overflow-x: hidden;
+ min-height: 100vh;
+ display: flex;
+ flex-direction: column;
+}
+
+/* Header & Frame */
+.lcars-header {
+ display: flex;
+ align-items: stretch;
+ height: 64px;
+ background: transparent;
+ padding: 8px 16px 0 16px;
+ gap: 12px;
+}
+
+.lcars-elbow-left {
+ width: 180px;
+ background-color: var(--primary-accent);
+ border-top-left-radius: 32px;
+ display: flex;
+ align-items: center;
+ justify-content: flex-end;
+ padding-right: 14px;
+ font-size: 1.25rem;
+ font-weight: 700;
+ color: #000000;
+ text-transform: uppercase;
+}
+
+.lcars-top-bar {
+ flex: 1;
+ background-color: var(--secondary-accent);
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 0 16px;
+ font-size: 1.1rem;
+ font-weight: 700;
+ color: #000000;
+ text-transform: uppercase;
+}
+
+.lcars-pill-end {
+ width: 70px;
+ background-color: var(--tertiary-accent);
+ border-top-right-radius: 20px;
+ border-bottom-right-radius: 20px;
+}
+
+/* Header Volume Dock */
+.header-volume-dock {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ background: rgba(0, 0, 0, 0.25);
+ padding: 2px 10px;
+ border-radius: 12px;
+}
+
+.btn-header-mute {
+ background: #000000;
+ color: var(--secondary-accent);
+ border: 1px solid var(--secondary-accent);
+ border-radius: 8px;
+ padding: 2px 8px;
+ font-family: var(--lcars-font);
+ font-size: 0.8rem;
+ font-weight: 700;
+ cursor: pointer;
+ transition: all 0.15s;
+}
+
+.btn-header-mute:hover {
+ background: var(--secondary-accent);
+ color: #000;
+}
+
+.btn-header-mute.muted {
+ background: #ff0000;
+ color: #ffffff;
+ border-color: #ff0000;
+}
+
+.header-vol-val {
+ font-family: var(--mono-font);
+ font-size: 0.85rem;
+ color: #000;
+ font-weight: 700;
+ min-width: 32px;
+}
+
+/* Main Layout Grid */
+.lcars-container {
+ display: grid;
+ grid-template-columns: 240px 1fr 300px;
+ gap: 16px;
+ padding: 16px;
+ flex: 1;
+}
+
+@media (max-width: 1080px) {
+ .lcars-container {
+ grid-template-columns: 220px 1fr;
+ }
+ .right-column {
+ grid-column: span 2;
+ }
+}
+
+@media (max-width: 768px) {
+ .lcars-container {
+ grid-template-columns: 1fr;
+ }
+ .lcars-elbow-left {
+ width: 100px;
+ font-size: 0.9rem;
+ }
+}
+
+/* Left Sidebar - Presets & Ship Selector */
+.left-sidebar {
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+}
+
+.sidebar-heading {
+ background-color: var(--primary-accent);
+ color: #000;
+ padding: 6px 12px;
+ font-weight: 700;
+ font-size: 1rem;
+ text-transform: uppercase;
+ border-radius: 4px;
+}
+
+.preset-list {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ max-height: calc(100vh - 220px);
+ overflow-y: auto;
+ padding-right: 4px;
+}
+
+.preset-btn {
+ background-color: #1a1e27;
+ color: #e0e6ed;
+ border: 2px solid transparent;
+ padding: 10px 12px;
+ text-align: left;
+ font-family: var(--lcars-font);
+ font-size: 0.95rem;
+ font-weight: 600;
+ text-transform: uppercase;
+ border-radius: 0 16px 16px 0;
+ cursor: pointer;
+ transition: all 0.15s ease;
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+}
+
+.preset-btn:hover {
+ background-color: #272d3b;
+ border-left: 6px solid var(--primary-accent);
+ padding-left: 8px;
+}
+
+.preset-btn.active {
+ background-color: var(--primary-accent);
+ color: #000000;
+ font-weight: 700;
+}
+
+.preset-btn .ship-era-tag {
+ font-size: 0.7rem;
+ opacity: 0.75;
+ font-family: var(--mono-font);
+}
+
+/* Center Panel - Main Controls & Mixers */
+.center-panel {
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+}
+
+.panel-card {
+ background-color: var(--lcars-panel-bg);
+ border: 1px solid rgba(255, 255, 255, 0.08);
+ border-radius: 8px;
+ padding: 16px;
+ display: flex;
+ flex-direction: column;
+ gap: 14px;
+}
+
+.panel-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ border-bottom: 2px solid var(--primary-accent);
+ padding-bottom: 6px;
+}
+
+.panel-title {
+ font-size: 1.25rem;
+ font-weight: 700;
+ color: var(--primary-accent);
+ text-transform: uppercase;
+}
+
+.panel-sub {
+ font-family: var(--mono-font);
+ font-size: 0.85rem;
+ color: #8899aa;
+}
+
+/* Transport Controls Bar */
+.transport-bar {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ flex-wrap: wrap;
+}
+
+.btn-lcars-large {
+ background-color: var(--primary-accent);
+ color: #000;
+ font-family: var(--lcars-font);
+ font-size: 1.3rem;
+ font-weight: 700;
+ padding: 12px 28px;
+ border: none;
+ border-radius: 24px;
+ cursor: pointer;
+ text-transform: uppercase;
+ transition: transform 0.1s, filter 0.15s;
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.btn-lcars-large:hover {
+ filter: brightness(1.2);
+}
+
+.btn-lcars-large:active {
+ transform: scale(0.97);
+}
+
+.btn-lcars-large.playing {
+ background-color: #ff3333;
+ color: #fff;
+}
+
+/* Master Engage / Full Stop Play Button */
+#btn-master-play {
+ background: #00cc66;
+ color: #000000;
+ box-shadow: 0 0 12px rgba(0, 204, 102, 0.45);
+ transition: transform 0.1s, filter 0.15s, background-color 0.2s, box-shadow 0.2s;
+}
+
+#btn-master-play:hover {
+ filter: brightness(1.2);
+}
+
+#btn-master-play.playing {
+ background: #ff3333;
+ color: #ffffff;
+ box-shadow: 0 0 14px rgba(255, 51, 51, 0.5);
+}
+
+#btn-master-play.engaging {
+ animation: lcars-engage-flash 0.35s infinite alternate ease-in-out;
+}
+
+#btn-master-play.stopping {
+ animation: lcars-stop-flash 0.35s infinite alternate ease-in-out;
+}
+
+@keyframes lcars-engage-flash {
+ 0% {
+ background: #00cc66;
+ color: #000000;
+ box-shadow: 0 0 6px rgba(0, 204, 102, 0.4);
+ filter: brightness(0.95);
+ }
+ 100% {
+ background: #33ff99;
+ color: #000000;
+ box-shadow: 0 0 24px rgba(0, 255, 136, 0.95);
+ filter: brightness(1.35);
+ }
+}
+
+@keyframes lcars-stop-flash {
+ 0% {
+ background: #ff3333;
+ color: #ffffff;
+ box-shadow: 0 0 6px rgba(255, 51, 51, 0.4);
+ filter: brightness(0.95);
+ }
+ 100% {
+ background: #ff6666;
+ color: #ffffff;
+ box-shadow: 0 0 24px rgba(255, 51, 51, 0.95);
+ filter: brightness(1.35);
+ }
+}
+
+/* Action Pill Buttons */
+.pill-button-group {
+ display: flex;
+ gap: 8px;
+ flex-wrap: wrap;
+}
+
+.btn-lcars-pill {
+ background-color: var(--secondary-accent);
+ color: #000;
+ font-family: var(--lcars-font);
+ font-size: 0.95rem;
+ font-weight: 700;
+ padding: 8px 16px;
+ border: none;
+ border-radius: 14px;
+ cursor: pointer;
+ text-transform: uppercase;
+ transition: all 0.15s;
+}
+
+.btn-lcars-pill:hover {
+ filter: brightness(1.25);
+}
+
+.btn-lcars-pill.active-alert {
+ background-color: #ff0000 !important;
+ color: #ffffff !important;
+ animation: lcars-red-pulse 1s infinite alternate;
+}
+
+@keyframes lcars-red-pulse {
+ 0% { box-shadow: 0 0 4px #ff0000; }
+ 100% { box-shadow: 0 0 20px #ff0000; }
+}
+
+/* Dedicated Master Volume Console */
+.master-volume-console {
+ background: rgba(255, 153, 0, 0.05);
+ border: 1px solid var(--primary-accent);
+ border-left: 6px solid var(--primary-accent);
+ border-radius: 6px;
+ padding: 12px 16px;
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+}
+
+.master-vol-top {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ flex-wrap: wrap;
+ gap: 8px;
+}
+
+.master-vol-title-group {
+ display: flex;
+ align-items: baseline;
+ gap: 10px;
+}
+
+.master-vol-tag {
+ font-family: var(--lcars-font);
+ font-size: 1.05rem;
+ font-weight: 700;
+ color: var(--primary-accent);
+ text-transform: uppercase;
+}
+
+.master-vol-readout {
+ font-family: var(--mono-font);
+ font-size: 1.4rem;
+ font-weight: 700;
+ color: #ffffff;
+}
+
+.master-vol-db {
+ font-family: var(--mono-font);
+ font-size: 0.85rem;
+ color: #00e6ff;
+}
+
+.master-vol-actions {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ flex-wrap: wrap;
+}
+
+.btn-mute {
+ background-color: var(--primary-accent);
+ color: #000;
+ font-weight: 700;
+ min-width: 68px;
+ text-align: center;
+}
+
+.btn-mute.muted {
+ background-color: #ff0000 !important;
+ color: #ffffff !important;
+ box-shadow: 0 0 10px #ff0000;
+}
+
+.master-vol-presets {
+ display: flex;
+ gap: 4px;
+}
+
+.btn-vol-step {
+ background: #1e2430;
+ color: #a0b4cc;
+ border: 1px solid #334455;
+ border-radius: 4px;
+ padding: 4px 8px;
+ font-family: var(--mono-font);
+ font-size: 0.75rem;
+ cursor: pointer;
+ transition: all 0.1s;
+}
+
+.btn-vol-step:hover {
+ background: var(--primary-accent);
+ color: #000;
+ border-color: var(--primary-accent);
+}
+
+.btn-vol-step.active {
+ background: var(--primary-accent);
+ color: #000;
+ font-weight: bold;
+}
+
+/* Master Slider Row & Level Meter */
+.master-slider-row {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+}
+
+.vol-adj-btn {
+ width: 32px;
+ height: 32px;
+ background: #1b212c;
+ color: var(--primary-accent);
+ border: 1px solid var(--primary-accent);
+ border-radius: 50%;
+ font-family: var(--mono-font);
+ font-size: 1.2rem;
+ font-weight: bold;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ cursor: pointer;
+ transition: all 0.1s;
+ flex-shrink: 0;
+}
+
+.vol-adj-btn:hover {
+ background: var(--primary-accent);
+ color: #000;
+}
+
+.vol-adj-btn:active {
+ transform: scale(0.92);
+}
+
+.slider-track-container {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+}
+
+.slider-master-large {
+ height: 12px !important;
+ border-radius: 6px !important;
+ background: #161b24 !important;
+}
+
+.slider-master-large::-webkit-slider-thumb {
+ width: 24px !important;
+ height: 24px !important;
+ background: var(--primary-accent) !important;
+ box-shadow: 0 0 10px rgba(255, 153, 0, 0.8) !important;
+}
+
+/* Segmented LED Volume Meter */
+.vol-meter-leds {
+ display: flex;
+ gap: 3px;
+ height: 6px;
+ width: 100%;
+}
+
+.led-pip {
+ flex: 1;
+ background: #18202c;
+ border-radius: 1px;
+ transition: background 0.08s;
+}
+
+.led-pip.active:nth-child(-n+6) {
+ background: #00e6ff;
+ box-shadow: 0 0 4px #00e6ff;
+}
+
+.led-pip.active:nth-child(n+7):nth-child(-n+8) {
+ background: #ffcc00;
+ box-shadow: 0 0 4px #ffcc00;
+}
+
+.led-pip.active:nth-child(n+9) {
+ background: #ff3333;
+ box-shadow: 0 0 4px #ff3333;
+}
+
+/* Futuristic Noice Cancellation (FNC) */
+.fnc-console{
+ margin-top:2px;padding:10px 12px;border:1px solid rgba(0,230,255,.26);
+ border-left:5px solid var(--tertiary-accent);border-radius:6px;
+ background:rgba(0,230,255,.035);display:flex;flex-direction:column;gap:8px;
+}
+.fnc-top{display:flex;align-items:center;justify-content:space-between;gap:10px;flex-wrap:wrap}
+.fnc-title-group{display:flex;align-items:baseline;gap:9px;flex-wrap:wrap}
+.fnc-title{font-family:var(--lcars-font);font-weight:700;color:var(--tertiary-accent);letter-spacing:.08em}
+.fnc-state{font-family:var(--mono-font);font-size:.72rem;color:#9fb4c9;letter-spacing:.08em}
+.fnc-state.active{color:#00ff88;text-shadow:0 0 7px rgba(0,255,136,.45)}
+.fnc-state.hold{color:#ffcc00}.fnc-state.recovering{color:#7dd3fc}.fnc-state.off{color:#778899}
+.btn-fnc{background:#152333;color:var(--tertiary-accent);border:1px solid var(--tertiary-accent)}
+.btn-fnc.enabled{background:var(--tertiary-accent);color:#000;box-shadow:0 0 10px color-mix(in srgb,var(--tertiary-accent) 55%,transparent)}
+.fnc-level-row{display:grid;grid-template-columns:auto 1fr auto;gap:10px;align-items:center}
+.fnc-level-label,.fnc-level-value{font-family:var(--mono-font);font-size:.72rem;color:#c7d5e5;white-space:nowrap}
+.fnc-level-value{min-width:42px;text-align:right;color:var(--tertiary-accent)}
+.fnc-detail{font-family:var(--mono-font);font-size:.61rem;line-height:1.35;color:#71869a;min-height:1.35em}
+
+/* Multi-Channel Synthesizer Mixer */
+.mixer-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
+ gap: 14px;
+}
+
+.channel-strip {
+ background: rgba(255, 255, 255, 0.03);
+ border-left: 4px solid var(--primary-accent);
+ padding: 12px;
+ border-radius: 0 8px 8px 0;
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+}
+
+.channel-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ font-size: 0.95rem;
+ font-weight: 700;
+ color: var(--primary-accent);
+ text-transform: uppercase;
+}
+
+.control-row {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+}
+
+.control-label {
+ display: flex;
+ justify-content: space-between;
+ font-family: var(--mono-font);
+ font-size: 0.75rem;
+ color: #a0b0c0;
+}
+
+/* Sliders */
+input[type="range"] {
+ -webkit-appearance: none;
+ width: 100%;
+ height: 8px;
+ background: #202634;
+ border-radius: 4px;
+ outline: none;
+}
+
+input[type="range"]::-webkit-slider-thumb {
+ -webkit-appearance: none;
+ width: 18px;
+ height: 18px;
+ border-radius: 50%;
+ background: var(--primary-accent);
+ cursor: pointer;
+ border: 2px solid #000;
+ box-shadow: 0 0 6px rgba(255, 153, 0, 0.5);
+}
+
+/* Right Sidebar - Worp Core & Diagnostic Monitor */
+.right-column {
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+}
+
+.telemetry-triggers {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 8px;
+}
+
+.btn-chirp {
+ background-color: #232b3b;
+ color: var(--tertiary-accent);
+ border: 1px solid var(--tertiary-accent);
+ border-radius: 6px;
+ padding: 8px;
+ font-family: var(--lcars-font);
+ font-size: 0.85rem;
+ font-weight: 600;
+ cursor: pointer;
+ text-transform: uppercase;
+ transition: all 0.1s;
+}
+
+.btn-chirp:hover {
+ background-color: var(--tertiary-accent);
+ color: #000;
+}
+
+.btn-chirp:active {
+ transform: scale(0.95);
+}
+
+/* Sleep Timer Display */
+.timer-readout {
+ font-family: var(--mono-font);
+ font-size: 1.1rem;
+ color: #00e6ff;
+ text-align: center;
+ background: #131a26;
+ padding: 6px;
+ border-radius: 4px;
+ border: 1px solid rgba(0, 230, 255, 0.3);
+}
+
+/* Footer status bar */
+.lcars-footer {
+ margin-top: auto;
+ padding: 8px 16px;
+ background-color: var(--primary-accent);
+ color: #000;
+ display: flex;
+ justify-content: space-between;
+ font-weight: 700;
+ font-size: 0.9rem;
+ text-transform: uppercase;
+}
+
+
+/**
+ * Canvas Visualizers & Diagnostic Displays CSS
+ */
+
+.visualizer-container {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+
+.canvas-wrapper {
+ position: relative;
+ width: 100%;
+ background-color: #080b10;
+ border: 1px solid rgba(255, 153, 0, 0.25);
+ border-radius: 6px;
+ overflow: hidden;
+}
+
+.canvas-wrapper canvas {
+ display: block;
+ width: 100%;
+}
+
+.spectrum-wrapper {
+ height: 140px;
+}
+
+.warp-core-wrapper {
+ height: 240px;
+}
+
+.canvas-label-overlay {
+ position: absolute;
+ top: 6px;
+ left: 8px;
+ font-family: var(--mono-font);
+ font-size: 0.7rem;
+ color: var(--primary-accent);
+ letter-spacing: 0.1em;
+ pointer-events: none;
+ background: rgba(0, 0, 0, 0.6);
+ padding: 2px 6px;
+ border-radius: 3px;
+}
+
+.diagnostic-stats {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 6px;
+ font-family: var(--mono-font);
+ font-size: 0.75rem;
+}
+
+.stat-box {
+ background: rgba(255, 255, 255, 0.04);
+ padding: 6px 8px;
+ border-left: 2px solid var(--secondary-accent);
+ display: flex;
+ flex-direction: column;
+}
+
+.stat-label {
+ color: #8899aa;
+ font-size: 0.65rem;
+}
+
+.stat-value {
+ color: #00e6ff;
+ font-weight: bold;
+}
+
+
+/**
+ * Whataverse (Doctor Who TARDIX) Aesthetic Stylesheet & Universe Switcher Dropdown
+ */
+
+/* Universe Switcher Dropdown */
+.universe-selector-container {
+ position: relative;
+ display: inline-block;
+ cursor: pointer;
+}
+
+.universe-dropdown-menu {
+ display: none;
+ position: absolute;
+ top: 100%;
+ left: 0;
+ min-width: 220px;
+ background-color: #0b111a;
+ border: 2px solid var(--primary-accent);
+ border-radius: 0 0 12px 12px;
+ box-shadow: 0 10px 30px rgba(0, 0, 0, 0.9);
+ z-index: 1000;
+ overflow: hidden;
+}
+
+.universe-dropdown-menu.show {
+ display: flex;
+ flex-direction: column;
+}
+
+.universe-item {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 12px 16px;
+ color: #ffffff;
+ font-family: var(--lcars-font);
+ font-size: 1rem;
+ font-weight: 700;
+ text-transform: uppercase;
+ background: transparent;
+ border: none;
+ border-bottom: 1px solid rgba(255, 255, 255, 0.08);
+ cursor: pointer;
+ text-align: left;
+ transition: all 0.15s ease;
+}
+
+.universe-item:hover {
+ background-color: var(--primary-accent);
+ color: #000000;
+}
+
+.universe-item.active {
+ background-color: rgba(255, 255, 255, 0.12);
+ border-left: 4px solid var(--primary-accent);
+}
+
+.universe-item .u-icon {
+ font-size: 1.2rem;
+ width: 24px;
+ text-align: center;
+}
+
+/* ==========================================================================
+ WHATAVERSE (TARDIX CONSOLE) THEME
+ ========================================================================== */
+
+body.theme-whataverse-tardix {
+ --tardix-blue: #003b6f;
+ --tardix-deep: #001f3f;
+ --tardix-cyan: #00e5ff;
+ --tardix-teal: #008080;
+ --tardix-amber: #d4af37;
+ --tardix-brass: #b8860b;
+ --tardix-roundel: #e0f2fe;
+
+ --primary-accent: var(--tardix-cyan);
+ --secondary-accent: var(--tardix-blue);
+ --tertiary-accent: var(--tardix-amber);
+ --warning-accent: #cc0000;
+
+ --lcars-bg: #030712;
+ --lcars-panel-bg: #070e1b;
+ --lcars-font: 'Share Tech Mono', monospace;
+ background-color: #030712;
+ background-image:
+ radial-gradient(circle at 50% 10%, rgba(0, 59, 111, 0.3) 0%, transparent 60%),
+ radial-gradient(circle at 10% 90%, rgba(0, 229, 255, 0.05) 0%, transparent 40%);
+}
+
+/* Header Styling for TARDIX Police Box */
+body.theme-whataverse-tardix .lcars-elbow-left {
+ background: linear-gradient(135deg, #004a8b, #002244);
+ color: #ffffff;
+ border: 1px solid var(--tardix-cyan);
+ border-radius: 4px 4px 0 0;
+ box-shadow: 0 0 12px rgba(0, 229, 255, 0.3);
+ letter-spacing: 0.15em;
+}
+
+body.theme-whataverse-tardix .lcars-top-bar {
+ background: #001f3f;
+ color: #ffffff;
+ border: 1px solid rgba(0, 229, 255, 0.4);
+ border-radius: 4px;
+}
+
+body.theme-whataverse-tardix .header-vol-val {
+ color: var(--tardix-cyan);
+}
+
+body.theme-whataverse-tardix .lcars-pill-end {
+ background-color: var(--tardix-amber);
+ border-radius: 4px;
+}
+
+/* TARDIX Roundel Buttons & Cards */
+body.theme-whataverse-tardix .sidebar-heading {
+ background: linear-gradient(90deg, #003b6f, #001f3f);
+ color: var(--tardix-cyan);
+ border-left: 4px solid var(--tardix-cyan);
+ letter-spacing: 0.1em;
+}
+
+body.theme-whataverse-tardix .preset-btn {
+ background: rgba(0, 59, 111, 0.2);
+ border: 1px solid rgba(0, 229, 255, 0.2);
+ border-radius: 8px;
+ color: #e2e8f0;
+}
+
+body.theme-whataverse-tardix .preset-btn:hover {
+ background: rgba(0, 229, 255, 0.15);
+ border-color: var(--tardix-cyan);
+ box-shadow: 0 0 8px rgba(0, 229, 255, 0.3);
+}
+
+body.theme-whataverse-tardix .preset-btn.active {
+ background: linear-gradient(135deg, #003b6f, #002244);
+ border: 1px solid var(--tardix-cyan);
+ color: #ffffff;
+ box-shadow: 0 0 15px rgba(0, 229, 255, 0.5);
+}
+
+/* Master Play Button for TARDIX */
+body.theme-whataverse-tardix .btn-lcars-large {
+ background: linear-gradient(135deg, #00e5ff, #0088cc);
+ color: #000000;
+ border-radius: 8px;
+ box-shadow: 0 0 16px rgba(0, 229, 255, 0.4);
+}
+
+body.theme-whataverse-tardix .btn-lcars-large.playing {
+ background: linear-gradient(135deg, #d4af37, #996515);
+ color: #ffffff;
+ box-shadow: 0 0 20px rgba(212, 175, 55, 0.6);
+}
+
+/* Roundel Keypad Buttons */
+body.theme-whataverse-tardix .btn-chirp {
+ background: radial-gradient(circle, rgba(0, 229, 255, 0.1) 0%, rgba(0, 31, 63, 0.4) 100%);
+ border: 1px solid rgba(0, 229, 255, 0.4);
+ border-radius: 20px;
+ color: #b0e0e6;
+ transition: all 0.2s;
+}
+
+body.theme-whataverse-tardix .btn-chirp:hover {
+ background: var(--tardix-cyan);
+ color: #000000;
+ box-shadow: 0 0 12px var(--tardix-cyan);
+}
+
+/* Cloister Chime Active Siren */
+body.theme-whataverse-tardix .btn-lcars-pill.active-alert {
+ background-color: #ff3300 !important;
+ color: #ffffff !important;
+ animation: cloister-pulse 1.2s infinite alternate;
+}
+
+@keyframes cloister-pulse {
+ 0% { box-shadow: 0 0 4px #ff3300; }
+ 100% { box-shadow: 0 0 25px #ff3300; }
+}
+
+/* TARDIX Footer */
+body.theme-whataverse-tardix .lcars-footer {
+ background: linear-gradient(90deg, #001f3f, #003b6f);
+ color: var(--tardix-cyan);
+ border-top: 1px solid rgba(0, 229, 255, 0.3);
+}
+
+
+/**
+ * Expanded Sci-Fi Universes Themes Stylesheet
+ * Custom visual aesthetics for: Industrial, Bioships, Retro Future, Military,
+ * Deep Space, Outlaw / Frontier, Space Stations, and Comedy / Adventure.
+ */
+
+/* ==========================================================================
+ 1. INDUSTRIAL SPACE (Weylan-Yutaro, Firefly, Expanse, Mining Grit)
+ ========================================================================== */
+body.theme-industrial {
+ --primary-accent: #ffaa00;
+ --secondary-accent: #b45309;
+ --tertiary-accent: #d97706;
+ --warning-accent: #dc2626;
+
+ --lcars-bg: #0d0a07;
+ --lcars-panel-bg: #140f0a;
+ --lcars-font: 'Share Tech Mono', monospace;
+
+ background-color: #0d0a07;
+ background-image: repeating-linear-gradient(0deg, rgba(0, 0, 0, 0.25), rgba(0, 0, 0, 0.25) 2px, transparent 2px, transparent 4px);
+}
+
+body.theme-industrial .lcars-elbow-left {
+ background: linear-gradient(135deg, #b45309, #78350f);
+ color: #ffaa00;
+ border: 1px solid #ffaa00;
+ border-radius: 2px;
+ box-shadow: inset 0 0 10px rgba(0, 0, 0, 0.8);
+}
+
+body.theme-industrial .lcars-top-bar {
+ background: #1e150d;
+ color: #ffaa00;
+ border-bottom: 2px solid #b45309;
+}
+
+body.theme-industrial .panel-card {
+ border: 1px solid #451a03;
+ border-left: 4px solid #b45309;
+}
+
+body.theme-industrial .preset-btn {
+ background: #1a120b;
+ border-left: 3px solid #78350f;
+ color: #fdba74;
+}
+
+body.theme-industrial .preset-btn.active {
+ background: #b45309;
+ color: #000000;
+ font-weight: bold;
+}
+
+/* ==========================================================================
+ 2. BIOSHIPS (Living Starships, Moya, Lexx, Species 8675309)
+ ========================================================================== */
+body.theme-bioships {
+ --primary-accent: #10b981;
+ --secondary-accent: #8b5cf6;
+ --tertiary-accent: #065f46;
+ --warning-accent: #ef4444;
+
+ --lcars-bg: #021a12;
+ --lcars-panel-bg: #04271c;
+ --lcars-font: 'Antonio', sans-serif;
+
+ background-color: #021a12;
+ background-image: radial-gradient(circle at 50% 50%, rgba(139, 92, 246, 0.12) 0%, transparent 70%);
+}
+
+body.theme-bioships .lcars-elbow-left {
+ background: linear-gradient(135deg, #059669, #4c1d95);
+ color: #ffffff;
+ border-radius: 28px 0 28px 0;
+ box-shadow: 0 0 18px rgba(16, 185, 129, 0.4);
+}
+
+body.theme-bioships .lcars-top-bar {
+ background: #064e3b;
+ color: #a7f3d0;
+ border-radius: 0 16px 16px 0;
+}
+
+body.theme-bioships .panel-card {
+ border: 1px solid rgba(16, 185, 129, 0.25);
+ border-radius: 14px;
+}
+
+body.theme-bioships .preset-btn {
+ border-radius: 12px;
+ background: rgba(16, 185, 129, 0.08);
+ color: #d1fae5;
+}
+
+body.theme-bioships .preset-btn.active {
+ background: linear-gradient(90deg, #10b981, #7c3aed);
+ color: #000000;
+ font-weight: bold;
+}
+
+/* ==========================================================================
+ 3. RETRO FUTURE (1950s–1970s Golden Age, 2001, Jupiter 2)
+ ========================================================================== */
+body.theme-retrofuture {
+ --primary-accent: #22c55e;
+ --secondary-accent: #15803d;
+ --tertiary-accent: #86efac;
+ --warning-accent: #dc2626;
+
+ --lcars-bg: #03140a;
+ --lcars-panel-bg: #052212;
+ --lcars-font: 'Share Tech Mono', monospace;
+
+ background-color: #03140a;
+}
+
+body.theme-retrofuture .lcars-elbow-left {
+ background: #15803d;
+ color: #bbf7d0;
+ border-radius: 8px;
+ border: 2px solid #22c55e;
+}
+
+body.theme-retrofuture .lcars-top-bar {
+ background: #052e16;
+ color: #22c55e;
+ border: 1px solid #15803d;
+}
+
+body.theme-retrofuture .preset-btn {
+ background: #052914;
+ border: 1px solid #14532d;
+ color: #86efac;
+}
+
+body.theme-retrofuture .preset-btn.active {
+ background: #22c55e;
+ color: #000000;
+ box-shadow: 0 0 12px #22c55e;
+}
+
+/* ==========================================================================
+ 4. MILITARY SPACE (Battlestar Galactica, Sulaco, Fleet Combat)
+ ========================================================================== */
+body.theme-military {
+ --primary-accent: #eab308;
+ --secondary-accent: #71717a;
+ --tertiary-accent: #ca8a04;
+ --warning-accent: #ef4444;
+
+ --lcars-bg: #09090b;
+ --lcars-panel-bg: #18181b;
+ --lcars-font: 'Share Tech Mono', monospace;
+}
+
+body.theme-military .lcars-elbow-left {
+ background: #27272a;
+ color: #eab308;
+ border: 2px solid #eab308;
+ border-radius: 0;
+ font-weight: 900;
+}
+
+body.theme-military .lcars-top-bar {
+ background: #18181b;
+ color: #fef08a;
+ border-bottom: 2px solid #eab308;
+}
+
+body.theme-military .panel-card {
+ border: 1px solid #3f3f46;
+ border-left: 4px solid #eab308;
+}
+
+body.theme-military .preset-btn {
+ background: #27272a;
+ border: 1px solid #3f3f46;
+ color: #e4e4e7;
+ border-radius: 2px;
+}
+
+body.theme-military .preset-btn.active {
+ background: #eab308;
+ color: #000000;
+ font-weight: bold;
+}
+
+/* ==========================================================================
+ 5. DEEP SPACE (Generation Ships, Icarus II, Event Horizon)
+ ========================================================================== */
+body.theme-deepspace {
+ --primary-accent: #6366f1;
+ --secondary-accent: #312e81;
+ --tertiary-accent: #38bdf8;
+ --warning-accent: #f43f5e;
+
+ --lcars-bg: #030712;
+ --lcars-panel-bg: #0b0f19;
+ --lcars-font: 'Antonio', sans-serif;
+ background-image: radial-gradient(circle at 80% 20%, rgba(99, 102, 241, 0.15) 0%, transparent 60%);
+}
+
+body.theme-deepspace .lcars-elbow-left {
+ background: linear-gradient(135deg, #4338ca, #1e1b4b);
+ color: #e0e7ff;
+ border: 1px solid #6366f1;
+}
+
+body.theme-deepspace .lcars-top-bar {
+ background: #111827;
+ color: #818cf8;
+}
+
+body.theme-deepspace .preset-btn {
+ background: rgba(49, 46, 129, 0.25);
+ border: 1px solid rgba(99, 102, 241, 0.25);
+ color: #c7d2fe;
+}
+
+body.theme-deepspace .preset-btn.active {
+ background: #6366f1;
+ color: #ffffff;
+ box-shadow: 0 0 15px rgba(99, 102, 241, 0.6);
+}
+
+/* ==========================================================================
+ 6. OUTLAW / FRONTIER (Cowboy Bebop, Outlaw Star, Milano, Betty)
+ ========================================================================== */
+body.theme-outlaw {
+ --primary-accent: #ec4899;
+ --secondary-accent: #f59e0b;
+ --tertiary-accent: #8b5cf6;
+ --warning-accent: #ef4444;
+
+ --lcars-bg: #0f0714;
+ --lcars-panel-bg: #1a0c23;
+ --lcars-font: 'Share Tech Mono', monospace;
+}
+
+body.theme-outlaw .lcars-elbow-left {
+ background: linear-gradient(135deg, #db2777, #9d174d);
+ color: #fef08a;
+ box-shadow: 0 0 14px rgba(236, 72, 153, 0.4);
+}
+
+body.theme-outlaw .lcars-top-bar {
+ background: #200d2d;
+ color: #f472b6;
+ border-bottom: 2px solid #ec4899;
+}
+
+body.theme-outlaw .preset-btn {
+ background: #261135;
+ border: 1px solid rgba(236, 72, 153, 0.3);
+ color: #fbcfe8;
+}
+
+body.theme-outlaw .preset-btn.active {
+ background: linear-gradient(90deg, #ec4899, #f59e0b);
+ color: #000000;
+ font-weight: bold;
+}
+
+/* ==========================================================================
+ 7. SPACE STATIONS (Babylon 5, Moonbase Alpha, Sevastopol, Tycho)
+ ========================================================================== */
+body.theme-spacestations {
+ --primary-accent: #f97316;
+ --secondary-accent: #0284c7;
+ --tertiary-accent: #38bdf8;
+ --warning-accent: #dc2626;
+
+ --lcars-bg: #050b14;
+ --lcars-panel-bg: #0a1322;
+ --lcars-font: 'Share Tech Mono', monospace;
+}
+
+body.theme-spacestations .lcars-elbow-left {
+ background: #ea580c;
+ color: #000000;
+ border-radius: 4px;
+}
+
+body.theme-spacestations .lcars-top-bar {
+ background: #0f1e33;
+ color: #fdba74;
+ border-bottom: 2px solid #0284c7;
+}
+
+body.theme-spacestations .preset-btn {
+ background: #0e1b2e;
+ border-left: 3px solid #0284c7;
+ color: #bae6fd;
+}
+
+body.theme-spacestations .preset-btn.active {
+ background: #f97316;
+ color: #000000;
+ font-weight: bold;
+}
+
+/* ==========================================================================
+ 8. COMEDY / ADVENTURE (The Orville, Heart of Gold, Spaceballs)
+ ========================================================================== */
+body.theme-comedy {
+ --primary-accent: #06b6d4;
+ --secondary-accent: #f43f5e;
+ --tertiary-accent: #fbbf24;
+ --warning-accent: #ef4444;
+
+ --lcars-bg: #0c0a21;
+ --lcars-panel-bg: #151233;
+ --lcars-font: 'Antonio', sans-serif;
+ background-image: radial-gradient(circle at 50% 10%, rgba(6, 182, 212, 0.2) 0%, transparent 60%);
+}
+
+body.theme-comedy .lcars-elbow-left {
+ background: linear-gradient(135deg, #06b6d4, #f43f5e);
+ color: #ffffff;
+ border-radius: 20px;
+ box-shadow: 0 0 16px rgba(6, 182, 212, 0.5);
+}
+
+body.theme-comedy .lcars-top-bar {
+ background: #1e1b4b;
+ color: #67e8f9;
+}
+
+body.theme-comedy .preset-btn {
+ background: rgba(6, 182, 212, 0.1);
+ border: 1px solid rgba(6, 182, 212, 0.3);
+ border-radius: 12px;
+ color: #cffafe;
+}
+
+body.theme-comedy .preset-btn.active {
+ background: linear-gradient(90deg, #06b6d4, #fbbf24);
+ color: #000000;
+ font-weight: bold;
+}
+
+
+/* ==========================================================================
+ OBSERVATION MODE
+ Passive casting / display view. Click anywhere to return.
+ ========================================================================== */
+
+.btn-observation {
+ background: transparent !important;
+ color: var(--primary-accent) !important;
+ border: 2px solid var(--primary-accent) !important;
+}
+.btn-observation:hover {
+ background: var(--primary-accent) !important;
+ color: #000 !important;
+}
+
+.observation-overlay {
+ position: fixed;
+ inset: 0;
+ z-index: 10000;
+ display: none;
+ background: #010308;
+ overflow: hidden;
+ cursor: default;
+}
+
+.observation-overlay.active {
+ display: block;
+}
+
+/* Layer 0 & 1: 60fps Deep Celestial Canvas */
+.observation-canvas {
+ position: absolute;
+ inset: 0;
+ width: 100%;
+ height: 100%;
+ z-index: 1;
+ pointer-events: none;
+ display: block;
+}
+
+.observation-stage {
+ position: absolute;
+ inset: 0;
+ z-index: 2;
+ overflow: hidden;
+ pointer-events: none;
+}
+
+.observation-stage svg {
+ width: 100%;
+ height: 100%;
+ display: block;
+}
+
+/* Allow 60fps dynamic celestial canvas to show through legacy SVG stage while preserving telemetry & HUDs.
+ NOTE (v2cs): this previously read `rect:first-child`, which never matched -- every renderer's
+ block is the true first child of the , so the background rect is actually the second
+ child (or, more robustly, simply the first element of its own tag type). That silently left an
+ opaque near-black rectangle sitting on z-index 2, directly over the canvas engine at z-index 1,
+ hiding almost everything the canvas draws for every theme. Fixed by matching on type instead of
+ position.
+
+ NOTE (v3co): the selector above is correct but was applied GLOBALLY, which uncovered the
+ shared canvas layer stack in nine themes simultaneously. It is now scoped behind
+ `.obs-canvas-on`, a class toggled in ObservationEngine.start() from the OBSERVATION_CANVAS
+ manifest. Themes declared canvas:false keep their opaque background rect -- their
+ OBSERVATION display is their own bespoke SVG art and nothing else. */
+.observation-overlay.obs-canvas-on .observation-stage svg > rect:first-of-type {
+ display: none;
+}
+.observation-stage svg g[style*="obs-drift-left"],
+.observation-stage svg g[style*="obs-drift-right"] {
+ display: none;
+}
+
+/* Space Stations Native Viewport respect & toggle */
+.obs-station-bulkhead {
+ transition: opacity 0.4s ease;
+}
+.obs-station-frame {
+ transition: opacity 0.4s ease;
+}
+.obs-viewport-hidden .obs-station-bulkhead,
+.obs-viewport-hidden .obs-station-frame {
+ opacity: 0;
+}
+
+/* Layer 2: Architectural Starship Viewport Framing */
+.observation-viewport-frame {
+ position: absolute;
+ inset: 0;
+ z-index: 4;
+ pointer-events: none;
+ transition: opacity 0.4s ease;
+ overflow: hidden;
+}
+
+.observation-viewport-frame svg {
+ width: 100%;
+ height: 100%;
+ display: block;
+}
+
+.observation-viewport-frame.frame-hidden {
+ opacity: 0;
+}
+
+.observation-glass-sheen {
+ position: absolute;
+ inset: 0;
+ z-index: 5;
+ pointer-events: none;
+ background: linear-gradient(135deg, rgba(255,255,255,0.055) 0%, transparent 42%, rgba(255,255,255,0.02) 68%, transparent 100%);
+}
+
+.observation-scanlines {
+ position: absolute;
+ inset: 0;
+ z-index: 6;
+ pointer-events: none;
+ opacity: .14;
+ background: repeating-linear-gradient(
+ 0deg,
+ rgba(255,255,255,.04) 0px,
+ rgba(255,255,255,.04) 1px,
+ transparent 1px,
+ transparent 4px
+ );
+}
+
+.observation-vignette {
+ position: absolute;
+ inset: 0;
+ z-index: 7;
+ pointer-events: none;
+ box-shadow: inset 0 0 16vw rgba(0,0,0,.82);
+}
+
+/* Layer 3: Emergency Alert Lighting Wash */
+.observation-alert-wash {
+ position: absolute;
+ inset: 0;
+ z-index: 8;
+ pointer-events: none;
+ opacity: 0;
+ transition: opacity 0.45s ease;
+}
+
+.observation-alert-wash.alert-red {
+ opacity: 1;
+ background: radial-gradient(circle at center, rgba(255, 0, 0, 0.08) 0%, rgba(220, 20, 20, 0.35) 75%, rgba(120, 0, 0, 0.72) 100%);
+ animation: obsAlertStrobeRed 1.1s ease-in-out infinite alternate;
+}
+
+.observation-alert-wash.alert-yellow {
+ opacity: 1;
+ background: radial-gradient(circle at center, rgba(255, 180, 0, 0.06) 0%, rgba(220, 150, 20, 0.28) 75%, rgba(140, 80, 0, 0.6) 100%);
+ animation: obsAlertStrobeYellow 1.5s ease-in-out infinite alternate;
+}
+
+@keyframes obsAlertStrobeRed {
+ 0% { opacity: 0.2; }
+ 100% { opacity: 0.95; }
+}
+
+@keyframes obsAlertStrobeYellow {
+ 0% { opacity: 0.25; }
+ 100% { opacity: 0.85; }
+}
+
+/* Layer 4: Holographic LCARD HUD */
+.observation-chrome {
+ position: absolute;
+ inset: 0;
+ z-index: 9;
+ pointer-events: none;
+ font-family: var(--mono-font);
+ color: #e8f4ff;
+ text-shadow: 0 0 8px rgba(255,255,255,0.18);
+ opacity: 1;
+ transition: opacity 0.5s ease;
+}
+
+.observation-topline {
+ position: absolute;
+ top: 3.2vh;
+ left: 3vw;
+ right: 3vw;
+ display: flex;
+ justify-content: space-between;
+ align-items: flex-start;
+ gap: 2rem;
+ pointer-events: none;
+}
+
+.observation-mode-label {
+ font-family: var(--lcars-font);
+ font-size: clamp(1rem, 1.6vw, 1.7rem);
+ letter-spacing: 0.22em;
+ font-weight: 700;
+ color: var(--primary-accent);
+}
+
+.observation-profile {
+ margin-top: .45rem;
+ font-size: clamp(.8rem, 1.05vw, 1.1rem);
+ letter-spacing: .08em;
+ color: rgba(235,245,255,.78);
+}
+
+.observation-status {
+ text-align: right;
+ font-size: clamp(.7rem, .9vw, .95rem);
+ letter-spacing: .1em;
+ line-height: 1.65;
+ color: rgba(235,245,255,.62);
+}
+
+.observation-waveform-container {
+ position: absolute;
+ bottom: 2.2vh;
+ left: 50%;
+ transform: translateX(-50%);
+ z-index: 9;
+ pointer-events: none;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 4px;
+ opacity: 0.82;
+}
+
+.observation-waveform-label {
+ font-family: var(--mono-font);
+ font-size: 0.65rem;
+ letter-spacing: 0.2em;
+ color: var(--tertiary-accent);
+ text-shadow: 0 0 6px rgba(0,0,0,0.9);
+}
+
+#observation-waveform-canvas {
+ width: clamp(240px, 32vw, 440px);
+ height: 24px;
+ border-radius: 4px;
+}
+
+/* Scrolling Universe-Specific Status Ticker */
+.observation-ticker {
+ position: absolute;
+ top: 1.05vh;
+ left: 3vw;
+ right: 3vw;
+ z-index: 9;
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ pointer-events: none;
+ opacity: 0.85;
+}
+
+.observation-ticker-label {
+ font-family: var(--lcars-font);
+ font-size: 0.62rem;
+ font-weight: 700;
+ letter-spacing: 0.18em;
+ color: #000;
+ background: var(--tertiary-accent, #99ccff);
+ padding: 2px 9px;
+ border-radius: 8px;
+ flex-shrink: 0;
+ white-space: nowrap;
+}
+
+.observation-ticker-track {
+ flex: 1;
+ min-width: 0;
+ overflow: hidden;
+ white-space: nowrap;
+ -webkit-mask-image: linear-gradient(90deg, transparent 0%, #000 6%, #000 94%, transparent 100%);
+ mask-image: linear-gradient(90deg, transparent 0%, #000 6%, #000 94%, transparent 100%);
+}
+
+.observation-ticker-text {
+ display: inline-block;
+ font-family: var(--mono-font);
+ font-size: 0.7rem;
+ letter-spacing: 0.13em;
+ color: rgba(235,245,255,.7);
+ white-space: nowrap;
+ padding-left: 100%;
+ animation: obsTickerScroll 20s linear infinite;
+}
+
+@keyframes obsTickerScroll {
+ 0% { transform: translateX(0); }
+ 100% { transform: translateX(-100%); }
+}
+
+/* Auto-hiding in-observation dock */
+.observation-control-dock {
+ position: absolute;
+ bottom: 2.5vh;
+ right: 2.5vw;
+ z-index: 12;
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ background: rgba(8, 12, 22, 0.88);
+ backdrop-filter: blur(14px);
+ border: 1px solid color-mix(in srgb, var(--primary-accent) 65%, transparent);
+ box-shadow: 0 4px 28px rgba(0,0,0,0.8), inset 0 0 14px rgba(255,255,255,0.03);
+ border-radius: 20px;
+ padding: 6px 12px;
+ transition: opacity 0.35s ease, transform 0.35s ease;
+ opacity: 1;
+}
+
+.observation-control-dock.dock-autohide {
+ opacity: 0;
+ pointer-events: none;
+ transform: translateY(8px);
+}
+
+.obs-dock-btn {
+ background: rgba(0,0,0,0.55);
+ border: 1px solid color-mix(in srgb, var(--primary-accent) 55%, transparent);
+ color: #e2e8f0;
+ font-family: var(--lcars-font);
+ font-size: 0.82rem;
+ font-weight: 700;
+ letter-spacing: 0.08em;
+ padding: 5px 12px;
+ border-radius: 12px;
+ cursor: pointer;
+ transition: all 0.16s ease;
+ white-space: nowrap;
+ user-select: none;
+}
+
+.obs-dock-btn:hover {
+ background: var(--primary-accent);
+ color: #000;
+ box-shadow: 0 0 10px var(--primary-accent);
+}
+
+.obs-dock-btn.active {
+ background: var(--primary-accent);
+ color: #000;
+ border-color: var(--primary-accent);
+ box-shadow: 0 0 12px var(--primary-accent);
+}
+
+.obs-dock-btn.btn-alert-active {
+ background: #dc2626 !important;
+ color: #fff !important;
+ border-color: #f87171 !important;
+ box-shadow: 0 0 14px #dc2626 !important;
+ animation: obsBtnAlertPulse 0.9s ease-in-out infinite alternate;
+}
+
+.obs-dock-btn.obs-dock-close {
+ border-color: #f87171;
+ color: #fca5a5;
+}
+
+.obs-dock-btn.obs-dock-close:hover {
+ background: #dc2626;
+ color: #fff;
+ box-shadow: 0 0 12px #dc2626;
+}
+
+@keyframes obsBtnAlertPulse {
+ from { opacity: 0.75; }
+ to { opacity: 1; }
+}
+
+.obs-dock-select {
+ background: rgba(0,0,0,0.75);
+ border: 1px solid color-mix(in srgb, var(--tertiary-accent) 60%, transparent);
+ color: var(--tertiary-accent);
+ font-family: var(--mono-font);
+ font-size: 0.78rem;
+ padding: 5px 8px;
+ border-radius: 8px;
+ cursor: pointer;
+ max-width: 170px;
+ outline: none;
+}
+
+.obs-dock-select option {
+ background: #0d1117;
+ color: #e2e8f0;
+}
+
+.observation-return {
+ position: absolute;
+ bottom: 2.8vh;
+ left: 3vw;
+ padding: .45rem .9rem;
+ border: 1px solid color-mix(in srgb, var(--primary-accent) 55%, transparent);
+ border-radius: 999px;
+ background: rgba(0,0,0,.45);
+ backdrop-filter: blur(8px);
+ color: rgba(255,255,255,.78);
+ font-size: clamp(.65rem, .78vw, .82rem);
+ letter-spacing: .16em;
+ white-space: nowrap;
+ cursor: pointer;
+ z-index: 12;
+ transition: background 0.2s ease, color 0.2s ease, opacity 0.5s ease;
+}
+
+.observation-return:hover {
+ background: var(--primary-accent);
+ color: #000;
+}
+
+@keyframes obs-drift-left {
+ from { transform: translateX(0); }
+ to { transform: translateX(-160px); }
+}
+@keyframes obs-drift-right {
+ from { transform: translateX(-100px); }
+ to { transform: translateX(80px); }
+}
+@keyframes obs-pulse {
+ 0%, 100% { opacity: .35; transform: scale(1); }
+ 50% { opacity: 1; transform: scale(1.045); }
+}
+@keyframes obs-rotate {
+ to { transform: rotate(360deg); }
+}
+@keyframes obs-rotate-rev {
+ to { transform: rotate(-360deg); }
+}
+@keyframes obs-sweep {
+ 0% { opacity: 0; transform: translateY(-8%); }
+ 15% { opacity: .65; }
+ 85% { opacity: .65; }
+ 100% { opacity: 0; transform: translateY(108%); }
+}
+@keyframes obs-flicker {
+ 0%, 95%, 100% { opacity: 1; }
+ 96% { opacity: .35; }
+ 97% { opacity: .9; }
+ 98% { opacity: .2; }
+}
+@keyframes obs-blink {
+ 0%, 44%, 100% { opacity: .2; }
+ 45%, 70% { opacity: 1; }
+}
+@keyframes obs-breathe {
+ 0%, 100% { transform: scale(.98); filter: brightness(.86); }
+ 50% { transform: scale(1.04); filter: brightness(1.15); }
+}
+@keyframes obs-wave {
+ 0% { stroke-dashoffset: 0; }
+ 100% { stroke-dashoffset: -180; }
+}
+
+.obs-spin {
+ transform-box: fill-box;
+ transform-origin: center;
+ animation: obs-rotate 18s linear infinite;
+}
+.obs-spin-slow {
+ transform-box: fill-box;
+ transform-origin: center;
+ animation: obs-rotate 42s linear infinite;
+}
+.obs-spin-rev {
+ transform-box: fill-box;
+ transform-origin: center;
+ animation: obs-rotate-rev 28s linear infinite;
+}
+.obs-pulse {
+ transform-box: fill-box;
+ transform-origin: center;
+ animation: obs-pulse 3.8s ease-in-out infinite;
+}
+.obs-breathe {
+ transform-box: fill-box;
+ transform-origin: center;
+ animation: obs-breathe 5.5s ease-in-out infinite;
+}
+.obs-flicker {
+ animation: obs-flicker 8s steps(1,end) infinite;
+}
+.obs-blink {
+ animation: obs-blink 3.5s steps(1,end) infinite;
+}
+.obs-dashflow {
+ stroke-dasharray: 20 12;
+ animation: obs-wave 7s linear infinite;
+}
+
+/* OBSERVATION-specific theme tinting */
+body.theme-whataverse-tardix .observation-overlay { background: #020817; }
+body.theme-industrial .observation-overlay { background: #070706; }
+body.theme-bioships .observation-overlay { background: #010b07; }
+body.theme-retrofuture .observation-overlay { background: #010903; }
+body.theme-military .observation-overlay { background: #050506; }
+body.theme-deepspace .observation-overlay { background: #010207; }
+body.theme-outlaw .observation-overlay { background: #09040c; }
+body.theme-spacestations .observation-overlay { background: #02070d; }
+body.theme-comedy .observation-overlay { background: #07061a; }
+
+@media (prefers-reduced-motion: reduce) {
+ .observation-overlay * {
+ animation-duration: 1ms !important;
+ animation-iteration-count: 1 !important;
+ }
+}
+
+
+/* ==========================================================================
+ OBSERVATION ACTIVITY ENGINE
+ Generative transient visuals + richer ambient motion.
+ ========================================================================== */
+
+.observation-activity-control {
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ min-width: min(330px, 100%);
+ padding: 6px 10px;
+ border: 1px solid color-mix(in srgb, var(--primary-accent) 55%, transparent);
+ border-radius: 14px;
+ background: rgba(255,255,255,.025);
+ font-family: var(--mono-font);
+}
+
+.observation-activity-label {
+ color: var(--primary-accent);
+ font-size: .72rem;
+ font-weight: 700;
+ letter-spacing: .08em;
+ white-space: nowrap;
+}
+
+.observation-activity-control input[type="range"] {
+ flex: 1;
+ min-width: 90px;
+ height: 6px;
+}
+
+.observation-activity-value {
+ min-width: 38px;
+ text-align: right;
+ color: #dbeafe;
+ font-size: .75rem;
+}
+
+.observation-event-layer {
+ position: absolute;
+ inset: 0;
+ z-index: 3;
+ overflow: hidden;
+ pointer-events: none;
+}
+
+.obs-transient {
+ position: absolute;
+ inset: 0;
+ opacity: 0;
+ pointer-events: none;
+ animation: obs-transient-life var(--obs-life, 4.5s) ease-in-out forwards;
+}
+
+.obs-transient svg {
+ width: 100%;
+ height: 100%;
+ display: block;
+}
+
+@keyframes obs-transient-life {
+ 0% { opacity: 0; }
+ 8% { opacity: 1; }
+ 78% { opacity: .92; }
+ 100% { opacity: 0; }
+}
+
+@keyframes obs-transient-pop {
+ 0% { transform: scale(.55); opacity: 0; }
+ 22% { transform: scale(1.08); opacity: 1; }
+ 100% { transform: scale(1); opacity: .2; }
+}
+
+@keyframes obs-transient-ring {
+ from { transform: scale(.15); opacity: .95; }
+ to { transform: scale(2.7); opacity: 0; }
+}
+
+@keyframes obs-slide-horizontal {
+ from { transform: translateX(-130px); }
+ to { transform: translateX(130px); }
+}
+
+@keyframes obs-slide-horizontal-wide {
+ from { transform: translateX(-520px); }
+ to { transform: translateX(520px); }
+}
+
+@keyframes obs-slide-vertical {
+ from { transform: translateY(-160px); }
+ to { transform: translateY(160px); }
+}
+
+@keyframes obs-soft-wobble {
+ 0%,100% { transform: translate(0,0) rotate(0deg); }
+ 25% { transform: translate(4px,-2px) rotate(.25deg); }
+ 60% { transform: translate(-3px,3px) rotate(-.2deg); }
+}
+
+@keyframes obs-glow-cycle {
+ 0%,100% { opacity: .18; }
+ 50% { opacity: .88; }
+}
+
+@keyframes obs-drift-up {
+ from { transform: translateY(70px); opacity: 0; }
+ 15% { opacity: .7; }
+ to { transform: translateY(-120px); opacity: 0; }
+}
+
+@keyframes obs-zoom-in {
+ from { transform: scale(.82); opacity: .15; }
+ to { transform: scale(1.16); opacity: .8; }
+}
+
+@keyframes obs-route-wander {
+ 0%,100% { stroke-dashoffset: 0; opacity: .35; }
+ 50% { stroke-dashoffset: -120; opacity: .9; }
+}
+
+@keyframes obs-planet-drift {
+ from { transform: translateX(-220px); }
+ to { transform: translateX(1900px); }
+}
+
+@keyframes obs-comet-drift {
+ from { transform: translate(-250px, 180px) rotate(-15deg); opacity: 0; }
+ 10% { opacity: .8; }
+ 85% { opacity: .65; }
+ to { transform: translate(1900px, -260px) rotate(-15deg); opacity: 0; }
+}
+
+@keyframes obs-camera-pan {
+ 0%,100% { transform: translateX(-14px) scale(1.03); }
+ 50% { transform: translateX(14px) scale(1.07); }
+}
+
+@keyframes obs-organic-undulate {
+ 0%,100% { transform: scale(1, .97) skewX(-1deg); }
+ 50% { transform: scale(1.035, 1.045) skewX(1deg); }
+}
+
+@keyframes obs-crt-jump {
+ 0%,93%,100% { transform: translateY(0); opacity: 1; }
+ 94% { transform: translateY(9px); opacity: .6; }
+ 95% { transform: translateY(-5px); opacity: .85; }
+ 96% { transform: translateY(2px); opacity: .35; }
+}
+
+/* ==========================================================================
+ INDUSTRIAL OBSERVATION — v13co
+ Video-feed artifacts and mechanical work cycles.
+ EVERY effect below is authored to live INSIDE a camera clip-path so it
+ can never bleed across the console the way the old full-screen sweep and
+ the free-floating steam blobs did.
+ ========================================================================== */
+
+/* Rolling horizontal sync bar — travels down ONE feed, not the screen. */
+@keyframes obs-feed-roll {
+ 0% { transform: translateY(-72px); }
+ 100% { transform: translateY(292px); }
+}
+
+/* Occasional tracking tear: a couple of displaced scan blocks, then gone. */
+@keyframes obs-feed-tear {
+ 0%, 87%, 100% { opacity: 0; transform: translate(0, 120px); }
+ 88% { opacity: .55; transform: translate(-11px, 74px); }
+ 90% { opacity: .32; transform: translate(9px, 158px); }
+ 92% { opacity: .48; transform: translate(-5px, 44px); }
+ 94% { opacity: 0; transform: translate(0, 210px); }
+}
+
+/* Analogue grain: churn opacity only so the turbulence filter stays cached. */
+@keyframes obs-feed-grain {
+ 0%, 100% { opacity: .055; }
+ 25% { opacity: .10; }
+ 50% { opacity: .04; }
+ 75% { opacity: .115; }
+}
+
+/* Automatic gain control hunting for exposure. */
+@keyframes obs-feed-agc {
+ 0%, 100% { opacity: 0; }
+ 40% { opacity: .055; }
+ 70% { opacity: .02; }
+}
+
+/* Active-feed selector; four cameras share this with negative delays. */
+@keyframes obs-cam-focus {
+ 0%, 21% { opacity: 1; }
+ 23%, 100% { opacity: 0; }
+}
+
+/* --- CARGO BAY: gantry crane work cycle. Phases interlock on one clock. --- */
+@keyframes obs-gantry-traverse {
+ 0%, 18% { transform: translateX(0px); }
+ 38%, 62% { transform: translateX(290px); }
+ 82%, 100% { transform: translateX(0px); }
+}
+@keyframes obs-gantry-hoist {
+ 0% { transform: translateY(0px); }
+ 5% { transform: translateY(66px); }
+ 11% { transform: translateY(66px); }
+ 17% { transform: translateY(0px); }
+ 40% { transform: translateY(0px); }
+ 45% { transform: translateY(66px); }
+ 53% { transform: translateY(66px); }
+ 60% { transform: translateY(0px); }
+ 100% { transform: translateY(0px); }
+}
+@keyframes obs-gantry-load {
+ 0%, 11% { opacity: 0; }
+ 13%, 45% { opacity: 1; }
+ 53%, 100% { opacity: 0; }
+}
+@keyframes obs-gantry-taken {
+ 0%, 10% { opacity: 1; }
+ 13%, 96% { opacity: 0; }
+ 100% { opacity: 1; }
+}
+@keyframes obs-gantry-placed {
+ 0%, 52% { opacity: 0; }
+ 56%, 96% { opacity: 1; }
+ 100% { opacity: 0; }
+}
+@keyframes obs-gantry-lamp {
+ 0%, 16%, 62%, 100% { fill: #16a34a; }
+ 19%, 38% { fill: #f59e0b; }
+ 45%, 59% { fill: #f59e0b; }
+}
+@keyframes obs-bay-shudder {
+ 0%, 46%, 52%, 100% { transform: translate(0, 0); }
+ 47% { transform: translate(.9px, -.7px); }
+ 49% { transform: translate(-.8px, .6px); }
+ 51% { transform: translate(.5px, .4px); }
+}
+
+/* Rotating hazard beacon. Pivot comes from a zero-fill bbox anchor circle. */
+@keyframes obs-beacon-sweep { to { transform: rotate(360deg); } }
+@keyframes obs-beacon-flash {
+ 0%, 100% { opacity: .16; }
+ 50% { opacity: .95; }
+}
+
+/* Steam / vapour: rises from a vent and dissipates. Always inside a clip. */
+@keyframes obs-vapour {
+ 0% { transform: translate(0, 0) scale(.3); opacity: 0; }
+ 18% { opacity: .34; }
+ 60% { opacity: .16; }
+ 100% { transform: translate(var(--vx, 12px), -94px) scale(1.9); opacity: 0; }
+}
+
+/* --- REACTOR PLANT --- */
+@keyframes obs-core-breathe {
+ 0%, 100% { transform: scale(.93); opacity: .55; }
+ 50% { transform: scale(1.08); opacity: 1; }
+}
+@keyframes obs-rod-travel {
+ 0%, 100% { transform: translateY(0); }
+ 50% { transform: translateY(11px); }
+}
+@keyframes obs-heat-shimmer {
+ 0%, 100% { transform: translateY(0) scaleY(1); opacity: .14; }
+ 50% { transform: translateY(-3px) scaleY(1.07); opacity: .3; }
+}
+@keyframes obs-needle-hunt {
+ 0%, 100% { transform: rotate(-33deg); }
+ 30% { transform: rotate(19deg); }
+ 55% { transform: rotate(-9deg); }
+ 78% { transform: rotate(30deg); }
+}
+@keyframes obs-pipe-flow { to { stroke-dashoffset: -240; } }
+
+/* --- AIRLOCK --- */
+@keyframes obs-dog-ring {
+ 0%, 30% { transform: rotate(0deg); }
+ 46%, 76% { transform: rotate(45deg); }
+ 92%, 100% { transform: rotate(0deg); }
+}
+@keyframes obs-airlock-cycle {
+ 0%, 24% { opacity: 1; }
+ 26%, 100% { opacity: .1; }
+}
+
+/* --- MACHINE CORRIDOR --- */
+@keyframes obs-lamp-fail {
+ 0%, 61%, 100% { opacity: .85; }
+ 62% { opacity: .1; }
+ 63.5% { opacity: .72; }
+ 65% { opacity: .08; }
+ 67% { opacity: .8; }
+ 70% { opacity: .22; }
+ 72% { opacity: .82; }
+}
+@keyframes obs-drip {
+ 0%, 72% { transform: translateY(0); opacity: 0; }
+ 76% { opacity: .75; }
+ 100% { transform: translateY(54px); opacity: 0; }
+}
+
+.obs-feed-roll { animation: obs-feed-roll 7.5s linear infinite; }
+.obs-feed-grain { animation: obs-feed-grain .45s steps(2, end) infinite; }
+.obs-feed-agc { animation: obs-feed-agc 9s ease-in-out infinite; }
+.obs-feed-tear { animation: obs-feed-tear 13s linear infinite; }
+.obs-cam-focus { animation: obs-cam-focus 32s linear infinite; }
+.obs-beacon-sweep { transform-box: fill-box; transform-origin: center; animation: obs-beacon-sweep 3.4s linear infinite; }
+.obs-beacon-flash { animation: obs-beacon-flash 1.7s ease-in-out infinite; }
+.obs-pipe-flow { animation: obs-pipe-flow 4s linear infinite; }
+.obs-heat-shimmer { transform-box: fill-box; transform-origin: bottom center; animation: obs-heat-shimmer 3.2s ease-in-out infinite; }
+.obs-lamp-fail { animation: obs-lamp-fail 9s linear infinite; }
+.obs-bay-shudder { animation: obs-bay-shudder 17s ease-in-out infinite; }
+.obs-pivot { transform-box: fill-box; transform-origin: center; }
+
+/* Functional link: the OBSERVATION activity slider drives plant output and
+ crane pace, so what you see tracks what the console says it is doing.
+ All six gantry animations share one clock so their phases stay locked. */
+.observation-overlay {
+ --obs-activity: .6;
+ --obs-gantry-clock: calc(34s - var(--obs-activity, .6) * 14s);
+}
+.obs-core-load {
+ transform-box: fill-box;
+ transform-origin: center;
+ animation: obs-core-breathe calc(7.4s - var(--obs-activity, .6) * 4s) ease-in-out infinite;
+}
+.obs-core-halo { opacity: calc(.03 + var(--obs-activity, .6) * .16); }
+
+@media (prefers-reduced-motion: reduce) {
+ .obs-feed-roll, .obs-feed-grain, .obs-feed-tear, .obs-feed-agc,
+ .obs-beacon-sweep, .obs-bay-shudder, .obs-lamp-fail { animation: none; }
+}
+
+.obs-ambient-wobble {
+ transform-box: fill-box;
+ transform-origin: center;
+ animation: obs-soft-wobble 8s ease-in-out infinite;
+}
+
+.obs-ambient-glow {
+ animation: obs-glow-cycle 5.5s ease-in-out infinite;
+}
+
+.obs-camera-pan {
+ transform-box: fill-box;
+ transform-origin: center;
+ animation: obs-camera-pan 14s ease-in-out infinite;
+}
+
+.obs-organic-undulate {
+ transform-box: fill-box;
+ transform-origin: center;
+ animation: obs-organic-undulate 7s ease-in-out infinite;
+}
+
+.obs-crt-jump {
+ animation: obs-crt-jump 11s steps(1,end) infinite;
+}
+
+.obs-route-wander {
+ animation: obs-route-wander 8s linear infinite;
+}
+
+/* OBSERVATION chrome remains above generative effects. */
+.observation-chrome { z-index: 9; }
+.observation-scanlines { z-index: 6; }
+.observation-vignette { z-index: 7; }
+
+@media (max-width: 820px) {
+ .observation-activity-control {
+ width: 100%;
+ }
+}
+
+
+/* ==========================================================================
+ LIVE OBSERVATION INSTRUMENTATION
+ Applies only to fantasy text inside the SVG observation visualization.
+ ========================================================================== */
+.observation-stage text.obs-live-instrument,
+.observation-event-layer text.obs-live-instrument {
+ transition: opacity .12s linear;
+}
+.observation-stage text.obs-live-glitch,
+.observation-event-layer text.obs-live-glitch {
+ opacity: .72;
+}
+
+
+/* ==========================================================================
+ OBSERVATION HUD PERSISTENCE
+ HUD HOLD on = profile/status/return chrome stays visible.
+ HUD HOLD off = chrome fades away over 30 seconds after OBSERVATION starts.
+ ========================================================================== */
+
+.observation-hud-control {
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ padding: 6px 10px;
+ border: 1px solid color-mix(in srgb, var(--primary-accent) 55%, transparent);
+ border-radius: 14px;
+ background: rgba(255,255,255,.025);
+ font-family: var(--mono-font);
+ white-space: nowrap;
+}
+
+.observation-hud-label {
+ color: var(--primary-accent);
+ font-size: .72rem;
+ font-weight: 700;
+ letter-spacing: .08em;
+}
+
+.observation-hud-control input[type="checkbox"] {
+ width: 18px;
+ height: 18px;
+ accent-color: var(--primary-accent);
+ cursor: pointer;
+}
+
+.observation-hud-state {
+ min-width: 34px;
+ color: #dbeafe;
+ font-size: .75rem;
+ text-align: right;
+}
+
+.observation-chrome {
+ opacity: 1;
+ transition: opacity 30s linear;
+}
+
+.observation-chrome.observation-hud-fade {
+ opacity: 0;
+}
+
+
+/* CLICK ANYWHERE TO RETURN always fades away over 30 seconds,
+ independently of the HUD HOLD setting. */
+.observation-return {
+ opacity: 1;
+ transition: opacity 30s linear;
+}
+
+.observation-return.observation-return-fade {
+ opacity: 0;
+}
+
+
+/* ==========================================================================
+ OBSERVATION SCENE ENGINE
+ Persistent generative world layer. Unlike transient alerts, these entities
+ have lifecycles and move through the scene over time.
+ ========================================================================== */
+.observation-sim-layer {
+ position: absolute;
+ inset: 0;
+ z-index: 2;
+ overflow: hidden;
+ pointer-events: none;
+}
+
+.observation-sim-layer svg {
+ width: 100%;
+ height: 100%;
+ display: block;
+}
+
+.obs-scene-entity {
+ will-change: transform, opacity;
+}
+
+.obs-scene-soft {
+ filter: url(#obsSceneGlow);
+}
+
+@keyframes obs-scene-fan-spin {
+ to { transform: rotate(360deg); }
+}
+
+@keyframes obs-scene-breathe {
+ 0%, 100% { opacity: .28; transform: scale(.96); }
+ 50% { opacity: .72; transform: scale(1.06); }
+}
+
+@keyframes obs-scene-flicker {
+ 0%, 92%, 100% { opacity: .72; }
+ 93% { opacity: .28; }
+ 94% { opacity: .86; }
+ 96% { opacity: .42; }
+}
+
+.obs-scene-fan {
+ transform-box: fill-box;
+ transform-origin: center;
+ animation: obs-scene-fan-spin 5s linear infinite;
+}
+
+.obs-scene-breathe {
+ transform-box: fill-box;
+ transform-origin: center;
+ animation: obs-scene-breathe 6s ease-in-out infinite;
+}
+
+.obs-scene-flicker {
+ animation: obs-scene-flicker 10s steps(1,end) infinite;
+}
+
+
+/* === XZBT GENERATIVE EXPERIENCE / WEBLLM + KOKORO + DETERMINISTIC FNC COMFORT FILTER v3.7 === */
+.ai-experience-card .ai-status-line{display:flex;justify-content:space-between;gap:8px;align-items:center;margin-bottom:8px;font-family:var(--mono-font);font-size:.72rem;letter-spacing:.06em}
+.ai-status-badge{padding:3px 7px;border:1px solid var(--primary-accent);border-radius:3px;color:var(--primary-accent);white-space:nowrap}
+.ai-status-badge.ready{color:#00ff88;border-color:#00ff88}.ai-status-badge.busy{color:#ffcc00;border-color:#ffcc00}.ai-status-badge.error{color:#ff6666;border-color:#ff6666}
+.ai-control-grid{display:grid;grid-template-columns:1fr 1fr;gap:7px;margin:8px 0}.ai-control-grid .span-2{grid-column:span 2}
+.ai-experience-card select,.ai-experience-card input[type="range"]{width:100%;box-sizing:border-box}.ai-experience-card select{background:#05070a;color:var(--tertiary-accent);border:1px solid var(--secondary-accent);padding:6px;font-family:var(--mono-font);font-size:.72rem}
+.ai-toggle-row{display:flex;align-items:center;gap:7px;font-family:var(--mono-font);font-size:.72rem;min-height:28px}
+.ai-experience-output{min-height:54px;max-height:116px;overflow-y:auto;margin-top:8px;padding:8px;border-left:3px solid var(--primary-accent);background:rgba(0,0,0,.38);color:#dce7f7;font-family:var(--mono-font);font-size:.72rem;line-height:1.45}
+.ai-progress{margin-top:6px;font-family:var(--mono-font);font-size:.66rem;color:var(--tertiary-accent);min-height:1.1em}
+
+
+
+/* === XZBT v3.7: Watch Experience / TV / Neural Speech / Deterministic FNC Comfort Filter === */
+.watch-experience-wrap{display:flex;flex-direction:column;gap:3px;min-width:190px}
+.watch-experience-wrap .btn-observation{font-weight:700;letter-spacing:.08em}
+.watch-experience-help{font-family:var(--mono-font);font-size:.58rem;line-height:1.2;color:var(--tertiary-accent);opacity:.86;max-width:230px}
+.ai-fixed-model{padding:7px;border:1px solid rgba(255,255,255,.18);background:rgba(0,0,0,.3);font-family:var(--mono-font);font-size:.68rem;line-height:1.45}
+.ai-fixed-model b{color:var(--primary-accent)}
+.ai-engine-row{display:grid;grid-template-columns:1fr 1fr;gap:6px}
+.ai-engine-chip{padding:6px;border:1px solid rgba(255,255,255,.16);font-family:var(--mono-font);font-size:.62rem}
+.ai-engine-chip span{float:right;color:var(--tertiary-accent)}
+
+.tv-mode-card{font-family:var(--mono-font);font-size:.65rem}
+.tv-mode-card select{width:100%;background:#05070a;color:var(--tertiary-accent);border:1px solid var(--secondary-accent);padding:6px;font-family:var(--mono-font)}
+body.xzbt-tv-letterbox{background:#000}
+/* Canonical 16:9 presentation viewport. Children continue using percentage/absolute geometry. */
+body.xzbt-tv-aspect .observation-overlay{
+ width:min(100vw,calc(100vh * 16 / 9));
+ height:min(100vh,calc(100vw * 9 / 16));
+ top:50%;left:50%;right:auto;bottom:auto;
+ transform:translate(-50%,-50%);
+ box-shadow:0 0 0 100vmax #000;
+}
+body.xzbt-tv-aspect .observation-overlay:not(.active){display:none}
+@media (max-width:700px){
+ .watch-experience-wrap{min-width:150px}.watch-experience-help{font-size:.54rem}
+}
diff --git a/test-fixtures/reference-exhibits/scifi/index.html b/test-fixtures/reference-exhibits/scifi/index.html
new file mode 100644
index 0000000..b0ea227
--- /dev/null
+++ b/test-fixtures/reference-exhibits/scifi/index.html
@@ -0,0 +1,447 @@
+
+
+
+
+
+
+ Sci-Fi Ambience Generator - Publish Build v14co
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ▶ ENGAGE
+
+
+
+
+
+
+
WATCH EXPERIENCE
+
Full-screen procedural visuals, soundscape, telemetry, events, and optional local AI announcements.
+
+
+ VIEWPORT
+
+ ON
+
+
+ PILLARS
+
+ OFF
+
+
+ ACTIVITY
+
+ 60%
+
+
+ HUD HOLD
+
+ ON
+
+
+
+
+
+
+
+ MAIN MASTER VOLUME
+ 75%
+ -2.5 dB
+
+
+
MUTE
+
+ 25%
+ 50%
+ 75%
+ MAX
+
+
+
+
+
+
+
+
+
+
+ FNC // FUTURISTIC NOICE CANCELLATION
+ MONITORING
+
+
FNC ENABLED
+
+
+ FNC LEVEL
+
+ 65%
+
+
Comfort suppression active — Life Support, Hull Drone, and Worp Core are reduced according to FNC level.
+
+
+
+
+
+
+
+
+
SPECTRUM BIN: 32 CH // REAL-TIME FFT
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
DAMPING CUTOFF 105 Hz
+
+
+
+
+
+
+
+
+
+
PULSE RATE (BPM) 48 BPM
+
+
+
+
+
+
+
+
+
+
+
AIRFLOW AIR FILTER 1600 Hz
+
+
+
+
+
+
+
+
+
+
BACKGROUND CHIRP DENSITY 65%
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
INTERMIX CHAMBER // ACTIVE
+
+
+
+
+
+
+
+
+ LOCAL GENERATIVE SYSTEM
+ STANDBY
+
+
+ LANGUAGE MODEL
+ Qwen3-1.7B-q4f16_1-MLC
+ Automatically prepared when the experience starts. No model selection required.
+
+
+
WEBLLM OFF
+
KOKORO OFF
+
+
+
PREPARE AI
+
GENERATE NOW
+
AUTO UPDATES
+
SPEAK
+
+ Kokoro voice loads on preparation
+
+
+
VOICE AUTOMATION 84%
+
+
+
+
AUTO CADENCE 90-180 SEC
+
+
+
+
AI and neural voice models load only when needed and are cached by the browser when supported.
+
GENERATIVE CHANNEL STANDBY
+
+
+
+
+
+
+
LOCK EXPERIENCE TO 16:9
+
RENDER DENSITY 1080P TARGET
+
+ 720P PERFORMANCE
+ 1080P STANDARD
+ 1440P HIGH
+ 2160P ULTRA
+
+
The scene keeps a canonical 16:9 layout on phones, PCs, and cast displays. Chrome still negotiates the final Cast stream resolution.
+
+
+
+
+
+
TIMER INACTIVE (CONTINUOUS)
+
+ 15M
+ 30M
+ 60M
+ OFF
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
OBSERVATION
+
ACTIVE PROFILE
+
+
+ OBSERVATION ACTIVE
+ PROCEDURAL AUDIO LINK: STANDBY
+
+
+
+
+
+ SUBSPACE HARMONICS
+
+
+
+
+
+
+ WARP: OFF
+ VIEWPORT: ON
+ PILLARS: OFF
+ RED ALERT
+
+ RETURN ✕
+
+
+ CLICK BACKGROUND TO RETURN
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/test-fixtures/reference-exhibits/scifi/js/app.js b/test-fixtures/reference-exhibits/scifi/js/app.js
new file mode 100644
index 0000000..652be11
--- /dev/null
+++ b/test-fixtures/reference-exhibits/scifi/js/app.js
@@ -0,0 +1,4703 @@
+/**
+ * Main Application Controller - 10 Sci-Fi Universes Architecture
+ * Coordinates Starflight, Whataverse, Industrial, Bioships, Retro Future, Military,
+ * Deep Space, Outlaw, Space Stations, and Comedy universes.
+ */
+
+document.addEventListener('DOMContentLoaded', () => {
+ // 1. Initialize Audio Subsystems
+ const audioManager = new AudioManager();
+ const fncSystem = new FuturisticNoiceCancellation(audioManager);
+ fncSystem.bindUI();
+ window.fncSystem = fncSystem;
+ const hullDrone = new HullDroneSynth(audioManager);
+ const warpCore = new WarpCoreSynth(audioManager);
+ const lifeSupport = new LifeSupportSynth(audioManager);
+ const telemetry = new TelemetrySynth(audioManager);
+ const alerts = new AlertSynth(audioManager);
+ const whataverseAudio = new WhataverseAudioSynth(audioManager);
+ const expandedAudio = new ExpandedSciFiAudioSynth(audioManager);
+ const engineTransitions = new EngineTransitionSynth(audioManager);
+ window.expandedAudio = expandedAudio;
+ window.whataverseAudio = whataverseAudio;
+ window.engineTransitions = engineTransitions;
+ const visualizer = new StarshipVisualizer(audioManager, warpCore);
+ const observationEngine = new ObservationEngine(audioManager, warpCore, alerts, hullDrone, lifeSupport);
+ window.observationEngine = observationEngine;
+
+ let isPlaying = false;
+ let activeUniverseId = 'starfleet';
+ let activePresetId = 'tng-bridge';
+ window.activeUniverseId = activeUniverseId;
+ window.activePresetId = activePresetId;
+
+ // 2. DOM Elements
+ const headerUniverseBtn = document.getElementById('header-universe-btn');
+ const headerUniverseLabel = document.getElementById('header-universe-label');
+ const universeDropdown = document.getElementById('universe-dropdown');
+ const headerShipName = document.getElementById('header-ship-name');
+ const headerMatrixLabel = document.getElementById('header-matrix-label');
+
+ const sidebarTitle = document.getElementById('sidebar-title');
+ const presetContainer = document.getElementById('preset-container');
+ const currentPresetTitle = document.getElementById('current-preset-title');
+ const currentPresetDesc = document.getElementById('current-preset-desc');
+ const statAudioStatus = document.getElementById('stat-audio-status');
+ const statCoreFreq = document.getElementById('stat-core-freq');
+
+ const btnPlay = document.getElementById('btn-master-play');
+ const playIcon = document.getElementById('play-icon');
+ const playText = document.getElementById('play-text');
+
+ // Master Volume Console Elements
+ const sliderMasterVol = document.getElementById('slider-master-vol');
+ const valMasterVol = document.getElementById('val-master-vol');
+ const valMasterDb = document.getElementById('val-master-db');
+ const btnMasterMute = document.getElementById('btn-master-mute');
+ const btnHeaderMute = document.getElementById('btn-header-mute');
+ const headerVolVal = document.getElementById('header-vol-val');
+ const btnVolDown = document.getElementById('btn-vol-down');
+ const btnVolUp = document.getElementById('btn-vol-up');
+ const volMeterLeds = document.getElementById('vol-meter-leds');
+ const volStepButtons = document.querySelectorAll('.btn-vol-step');
+
+ // Channel Sliders
+ const sliderHullVol = document.getElementById('slider-hull-vol');
+ const valHullVol = document.getElementById('val-hull-vol');
+ const sliderHullFreq = document.getElementById('slider-hull-freq');
+ const valHullFreq = document.getElementById('val-hull-freq');
+ const sliderHullCutoff = document.getElementById('slider-hull-cutoff');
+ const valHullCutoff = document.getElementById('val-hull-cutoff');
+
+ const sliderWarpVol = document.getElementById('slider-warp-vol');
+ const valWarpVol = document.getElementById('val-warp-vol');
+ const sliderWarpBpm = document.getElementById('slider-warp-bpm');
+ const valWarpBpm = document.getElementById('val-warp-bpm');
+ const sliderWarpCarrier = document.getElementById('slider-warp-carrier');
+ const valWarpCarrier = document.getElementById('val-warp-carrier');
+
+ const sliderAirVol = document.getElementById('slider-air-vol');
+ const valAirVol = document.getElementById('val-air-vol');
+ const sliderAirCutoff = document.getElementById('slider-air-cutoff');
+ const valAirCutoff = document.getElementById('val-air-cutoff');
+
+ const sliderTelemetryVol = document.getElementById('slider-telemetry-vol');
+ const valTelemetryVol = document.getElementById('val-telemetry-vol');
+ const sliderTelemetryDensity = document.getElementById('slider-telemetry-density');
+ const valTelemetryDensity = document.getElementById('val-telemetry-density');
+
+ // Visualizer & Dynamic Containers
+ const coreVisualizerTitle = document.getElementById('core-visualizer-title');
+ const coreVisualizerLabel = document.getElementById('core-visualizer-label');
+ const soundboardTitle = document.getElementById('soundboard-title');
+ const universeEventsContainer = document.getElementById('universe-events-container');
+ const soundboardContainer = document.getElementById('soundboard-container');
+
+ // Sleep Timer
+ const timerDisplay = document.getElementById('timer-display');
+ const timerButtons = document.querySelectorAll('.btn-timer');
+
+ // XZBT Generative Experience subsystem. Deterministic engine remains authoritative.
+ const generativeExperience = new XZBTGenerativeExperience({
+ getUniverse: () => UniverseRegistry[activeUniverseId],
+ getPreset: () => UniverseRegistry[activeUniverseId]?.presets?.[activePresetId] || null,
+ isPlaying: () => isPlaying,
+ isObservation: () => observationActive,
+ audioManager,
+ onGenerated: (line, state) => {
+ const ticker = document.getElementById('observation-ticker-text');
+ if (ticker && observationActive) ticker.textContent = line.toUpperCase();
+ window.dispatchEvent(new CustomEvent('xzbt:generated-content', { detail: { line, state } }));
+ }
+ });
+ generativeExperience.bindUI();
+ window.generativeExperience = generativeExperience;
+
+ // Forward-declare absolute state setters & helpers for Contract Adapter
+ let setMasterVolumeDirect, setPlayingDirect, setMutedDirect, setFncEnabledDirect;
+ let setObservationDirect, setViewportFrameDirect, setAlertActiveDirect;
+ let setUniverseDirect, selectPresetDirect, getContractStateSnapshot;
+
+ // Semantic control bus & XZBT Contract Adapter
+ const controlBus = new XZBTControlBus();
+ window.xzbtControlBus = controlBus;
+
+ const contractAdapter = new XZBTContractAdapter({
+ product: 'SciFi-XZBT',
+ version: '5.2.0',
+ bindings: {
+ getUniverse: () => activeUniverseId,
+ getPreset: () => activePresetId,
+ setUniverse: (uId, src) => setUniverseDirect(uId, src),
+ selectPreset: (pId, src) => selectPresetDirect(pId, src),
+ getState: () => getContractStateSnapshot(),
+ setters: {
+ 'mix.master': v => setMasterVolumeDirect(v, false),
+ 'mix.hull.level': v => {
+ hullDrone.setVolume(v);
+ sliderHullVol.value = v;
+ valHullVol.textContent = `${Math.round(v * 100)}%`;
+ },
+ 'mix.hull.frequency': v => {
+ hullDrone.setBaseFreq(v);
+ sliderHullFreq.value = v;
+ valHullFreq.textContent = `${v} Hz`;
+ },
+ 'mix.hull.cutoff': v => {
+ hullDrone.setFilterCutoff(v);
+ sliderHullCutoff.value = v;
+ valHullCutoff.textContent = `${v} Hz`;
+ },
+ 'mix.drive.level': v => {
+ warpCore.setVolume(v);
+ sliderWarpVol.value = v;
+ valWarpVol.textContent = `${Math.round(v * 100)}%`;
+ },
+ 'mix.drive.pulse-rate': v => {
+ warpCore.setBpm(v);
+ sliderWarpBpm.value = v;
+ valWarpBpm.textContent = `${v} BPM`;
+ },
+ 'mix.drive.carrier': v => {
+ warpCore.setCarrierFreq(v);
+ sliderWarpCarrier.value = v;
+ valWarpCarrier.textContent = `${v} Hz`;
+ statCoreFreq.textContent = `${Number(v).toFixed(1)} Hz`;
+ },
+ 'mix.environment.level': v => {
+ lifeSupport.setVolume(v);
+ sliderAirVol.value = v;
+ valAirVol.textContent = `${Math.round(v * 100)}%`;
+ },
+ 'mix.environment.cutoff': v => {
+ lifeSupport.setFilterCutoff(v);
+ sliderAirCutoff.value = v;
+ valAirCutoff.textContent = `${v} Hz`;
+ },
+ 'mix.telemetry.level': v => {
+ telemetry.setVolume(v);
+ sliderTelemetryVol.value = v;
+ valTelemetryVol.textContent = `${Math.round(v * 100)}%`;
+ },
+ 'mix.telemetry.density': v => {
+ telemetry.setDensity(v);
+ sliderTelemetryDensity.value = v;
+ valTelemetryDensity.textContent = `${Math.round(v * 100)}%`;
+ },
+ 'speech.robot-amount': v => {
+ if (generativeExperience.ui.robot) {
+ generativeExperience.ui.robot.value = String(v);
+ generativeExperience.syncRobotUI_();
+ }
+ },
+ 'fnc.level': v => {
+ fncSystem.setLevel(v);
+ },
+ 'view.activity': v => {
+ sliderObservationActivity.value = Math.round(v * 100);
+ setObservationActivity(v * 100);
+ if (observationActive) scheduleObservationInstrumentTick();
+ },
+ 'transport.playing': (v, src) => setPlayingDirect(v, src),
+ 'transport.muted': (v, src) => setMutedDirect(v, src),
+ 'fnc.enabled': (v, src) => setFncEnabledDirect(v, src),
+ 'view.observation': (v, src) => setObservationDirect(v, src),
+ 'view.viewport-frame': (v, src) => setViewportFrameDirect(v, src),
+ 'alert.active': (v, src) => setAlertActiveDirect(v, src)
+ },
+ invokers: {
+ 'speech.say': async args => {
+ if (args && args.text) {
+ await generativeExperience.speak(args.text);
+ }
+ },
+ 'speech.generate-announcement': () => generativeExperience.generateAmbientMoment('request'),
+ 'display.ticker': args => {
+ const ticker = document.getElementById('observation-ticker-text');
+ if (ticker && args && typeof args.text === 'string') ticker.textContent = args.text;
+ },
+ 'event.alert-red': () => {
+ alerts.triggerRedAlert('tng');
+ fncSystem.hold('alert', true);
+ if (observationEngine) observationEngine.updateAlertState();
+ }
+ },
+ handleUniverseEvent: async (evtType, src) => {
+ const universe = UniverseRegistry[activeUniverseId];
+ const aliases = { 'flak-barrage': 'flak', 'solar-roar': 'solar', 'ludicrous-speed': 'ludicrous', 'improbability-flip': 'improbability' };
+ const match = universe && universe.events ? universe.events.find(e => e.type === (aliases[evtType] || evtType) || e.id === `btn-${evtType}`) : null;
+ if (!match) return false;
+ await audioManager.resume();
+ const btn = document.getElementById(match.id);
+ handleUniverseEvent(match.type, btn);
+ return true;
+ },
+ handleSoundboardTrigger: async (sfxBtnId, src) => {
+ const aliases = { 'btn-console-chirp': 'btn-chirp-single', 'btn-telepathic-chime': 'btn-telepathic', 'btn-comlock': 'btn-commlock', 'btn-afterburner': 'btn-afterburner-snd' };
+ sfxBtnId = aliases[sfxBtnId] || sfxBtnId;
+ const universe = UniverseRegistry[activeUniverseId];
+ // The HAL override event uses this existing chime without a soundboard button.
+ if (sfxBtnId === 'btn-hal-chime') {
+ if (!universe?.events.some(evt => evt.type === 'hal')) return false;
+ await audioManager.resume();
+ expandedAudio.synthesizeHalChime();
+ return true;
+ }
+ const match = universe && universe.soundboard ? universe.soundboard.find(s => s.id === sfxBtnId) : null;
+ if (!match) return false;
+ await audioManager.resume();
+ handleSoundboardTrigger(sfxBtnId);
+ return true;
+ }
+ }
+ });
+ window.xzbtContractAdapter = contractAdapter;
+ fncSystem.onMutation = (id, value) => contractAdapter.applyMutation(id, value, 'ui');
+ generativeExperience.onMutation = (id, value) => contractAdapter.applyMutation(id, value, 'ui');
+ generativeExperience.onInvoke = id => contractAdapter.invokeAction(id, {}, 'ui');
+ generativeExperience.onSpeechStateChanged = state => contractAdapter.updateCapability('speech', state);
+ observationEngine.onMutation = (id, value) => contractAdapter.applyMutation(id, value, 'ui');
+
+ // Intercept XZBTControlBus calls to route through contractAdapter
+ const origBusSet = controlBus.set.bind(controlBus);
+ const origBusTrigger = controlBus.trigger.bind(controlBus);
+ controlBus.set = function (id, value, source = 'external') {
+ if (contractAdapter.legacyIdMap.has(id) || contractAdapter.targets.has(id)) {
+ const canonicalId = contractAdapter.legacyIdMap.get(id) || id;
+ const target = contractAdapter.targets.get(canonicalId);
+ let normVal = value;
+ if (target && target.kind === 'range') {
+ if (contractAdapter.legacyIdMap.has(id)) {
+ const legacy = controlBus.targets.get(id);
+ if (legacy?.step) normVal = Math.round(Number(value) / legacy.step) * legacy.step;
+ if (canonicalId === 'view.activity') normVal = Number(normVal) / 100;
+ }
+ }
+ contractAdapter.applyMutation(canonicalId, normVal, source);
+ return true;
+ }
+ return origBusSet(id, value, source);
+ };
+ controlBus.trigger = function (id, source = 'external') {
+ if (contractAdapter.legacyIdMap.has(id) || contractAdapter.targets.has(id)) {
+ const canonicalId = contractAdapter.legacyIdMap.get(id) || id;
+ const target = contractAdapter.targets.get(canonicalId);
+ if (target && target.kind === 'state') {
+ // Toggle state if boolean
+ const curr = contractAdapter.getContractState()[canonicalId];
+ contractAdapter.applyMutation(canonicalId, !curr, source);
+ return true;
+ }
+ contractAdapter.invokeAction(canonicalId, {}, source);
+ return true;
+ }
+ return origBusTrigger(id, source);
+ };
+
+ // Register internal slider targets onto XZBTControlBus for contract adapter routing
+ const rangeTarget = (id, label) => {
+ const el = document.getElementById(id);
+ if (!el) return;
+ controlBus.register(id.replace(/^slider-/, ''), {
+ label,
+ type: 'range',
+ min: Number(el.min || 0),
+ max: Number(el.max || 1),
+ step: Number(el.step || 0),
+ apply: v => {
+ el.value = v;
+ el.dispatchEvent(new Event('input', { bubbles: true }));
+ }
+ });
+ };
+ rangeTarget('slider-master-vol', 'Master Volume');
+ rangeTarget('slider-hull-vol', 'Hull Level');
+ rangeTarget('slider-hull-freq', 'Hull Base Tone');
+ rangeTarget('slider-hull-cutoff', 'Hull Damping Cutoff');
+ rangeTarget('slider-warp-vol', 'Core / Drive Level');
+ rangeTarget('slider-warp-bpm', 'Core / Drive Pulse Rate');
+ rangeTarget('slider-warp-carrier', 'Core / Drive Carrier');
+ rangeTarget('slider-air-vol', 'Environmental Level');
+ rangeTarget('slider-air-cutoff', 'Environmental Cutoff');
+ rangeTarget('slider-telemetry-vol', 'Telemetry Level');
+ rangeTarget('slider-telemetry-density', 'Telemetry Density');
+ rangeTarget('ai-robot-amount', 'Speech Automation / Robot');
+ rangeTarget('slider-fnc-level', 'FNC Level');
+ rangeTarget('slider-observation-activity', 'Experience Visual Activity');
+ controlBus.register('master-play', { label: 'Engage / Full Stop', type: 'action' });
+ controlBus.register('fnc-enable', { label: 'FNC Enable / Bypass', type: 'action' });
+ controlBus.register('watch-experience', { label: 'Watch Experience', type: 'action' });
+ controlBus.register('observation-viewport', { label: 'Observation Viewport Toggle', type: 'action' });
+ controlBus.register('red-alert', { label: 'Red Alert Toggle', type: 'action' });
+ controlBus.register('generate-announcement', { label: 'Generate AI Announcement', type: 'action' });
+ controlBus.register('mute', { label: 'Master Mute', type: 'action' });
+
+
+ // TV presentation controls. The final Cast stream quality remains Chrome-controlled.
+ const tvAspect = document.getElementById('tv-aspect-lock'),
+ tvQuality = document.getElementById('tv-quality'),
+ tvQualityValue = document.getElementById('tv-quality-value');
+ const applyTV = () => {
+ document.body.classList.toggle('xzbt-tv-aspect', !!tvAspect?.checked);
+ document.body.classList.toggle('xzbt-tv-letterbox', !!tvAspect?.checked);
+ window.xzbtRenderScale = Number(tvQuality?.value || 1);
+ if (tvQualityValue) {
+ const labels = {
+ 0.667: '720P TARGET',
+ 1: '1080P TARGET',
+ 1.333: '1440P TARGET',
+ 2: '2160P TARGET'
+ };
+ tvQualityValue.textContent = labels[tvQuality.value] || '1080P TARGET';
+ }
+ window.dispatchEvent(new Event('resize'));
+ };
+ tvAspect?.addEventListener('change', applyTV);
+ tvQuality?.addEventListener('change', applyTV);
+ applyTV();
+
+ // 3. Universe Management & Switching
+ function initUniverseDropdown() {
+ universeDropdown.innerHTML = '';
+ Object.values(UniverseRegistry).forEach(u => {
+ const btn = document.createElement('button');
+ btn.className = `universe-item ${u.id === activeUniverseId ? 'active' : ''}`;
+ btn.innerHTML = `${u.icon} ${u.name} `;
+ btn.addEventListener('click', (e) => {
+ e.stopPropagation();
+ contractAdapter.applyMutation('universe.selected', u.id, 'ui');
+ });
+ universeDropdown.appendChild(btn);
+ });
+
+ headerUniverseBtn.addEventListener('click', (e) => {
+ e.stopPropagation();
+ universeDropdown.classList.toggle('show');
+ });
+
+ document.addEventListener('click', () => {
+ universeDropdown.classList.remove('show');
+ });
+ }
+
+ function setUniverse(universeId) {
+ const universe = UniverseRegistry[universeId];
+ if (!universe) return;
+
+ activeUniverseId = universeId;
+ universeDropdown.classList.remove('show');
+ if (window.generativeExperience) window.generativeExperience.onThemeChanged();
+ if (window.fncSystem) window.fncSystem.setTheme(universeId);
+
+ // Update Dropdown UI active class
+ document.querySelectorAll('.universe-item').forEach(el => {
+ el.classList.toggle('active', el.textContent.includes(universe.name));
+ });
+
+ // Update Header and Theme Class
+ headerUniverseLabel.textContent = universe.shortCode;
+ document.body.className = universe.themeClass;
+ // Assigning className wholesale drops the TV presentation classes, so re-apply them.
+ applyTV();
+ headerMatrixLabel.textContent = universe.headerTitle;
+
+ // Custom titles per universe
+ const titlesMap = {
+ 'starfleet': {
+ sidebar: 'STARSHIP PRESETS',
+ visTitle: 'INTERMIX CASCADE',
+ visLabel: 'INTERMIX CHAMBER // ACTIVE',
+ soundboard: 'LCARD KEYPAD'
+ },
+ 'whataverse': {
+ sidebar: 'TARDIX CONSOLES',
+ visTitle: 'CENTRAL TIME ROTOR COLUMN',
+ visLabel: 'VORTEX ROTOR // OPERATIONAL',
+ soundboard: 'TARDIX CONSOLE CONTROLS'
+ },
+ 'industrial': {
+ sidebar: 'INDUSTRIAL FREIGHTERS',
+ visTitle: 'COMPRESSION FURNACE',
+ visLabel: 'CONTAINMENT CORE // ACTIVE',
+ soundboard: 'INDUSTRIAL INSTRUMENTATION'
+ },
+ 'bioships': {
+ sidebar: 'SENTIENT LEVIATHONS',
+ visTitle: 'ORGANIC BIO-HEART SAC',
+ visLabel: 'VASCULAR NEXUS // PULSING',
+ soundboard: 'NEURAL LINK SENSORS'
+ },
+ 'retrofuture': {
+ sidebar: 'GOLDEN AGE EXPLORATION',
+ visTitle: 'ASTROGATOR OSCILLOSCOPE',
+ visLabel: 'ANALOG BEAM // ROTATING',
+ soundboard: 'RETRO CONSOLE SWITCHES'
+ },
+ 'military': {
+ sidebar: 'BATTLEFLEET WARSHIPS',
+ visTitle: 'TACTICAL FLYWHEEL',
+ visLabel: 'CONDITION ONE // ARMED',
+ soundboard: 'CIC TACTICAL KEYPAD'
+ },
+ 'deepspace': {
+ sidebar: 'DEEP EXPEDITION ARKS',
+ visTitle: 'GRAVITY SINGULARITY CORE',
+ visLabel: 'EVENT HORIZON // LOCKED',
+ soundboard: 'VOID ACOUSTIC CONSOLE'
+ },
+ 'outlaw': {
+ sidebar: 'FRONTIER & SALVAGE SHIPS',
+ visTitle: 'PATCHWORK IMPULSE DRIVE',
+ visLabel: 'KINETIC THRUSTERS // PRIMED',
+ soundboard: 'OUTLAW COCKPIT CONTROLS'
+ },
+ 'spacestations': {
+ sidebar: 'ORBITAL STATIONS & DOCKS',
+ visTitle: 'CENTRIFUGAL DISTRIBUTION HUB',
+ visLabel: 'STATION ROTOR // DOCKED',
+ soundboard: 'STATION OPS CONSOLE'
+ },
+ 'comedy': {
+ sidebar: 'WHIMSICAL & ADVENTURE FLEET',
+ visTitle: 'IMPROBABILITY ENGINE',
+ visLabel: 'REALITY FIELD // SHIFTING',
+ soundboard: 'CHEERFUL CONSOLE KEYPAD'
+ }
+ };
+
+ const conf = titlesMap[universeId] || titlesMap['starfleet'];
+ sidebarTitle.textContent = conf.sidebar;
+ coreVisualizerTitle.textContent = conf.visTitle;
+ coreVisualizerLabel.textContent = conf.visLabel;
+ soundboardTitle.textContent = conf.soundboard;
+
+ // Configure Visualizer mode
+ visualizer.setMode(universe.visualizer || 'warp-core');
+
+ // Render Event Buttons & Soundboard
+ renderUniverseEvents(universe);
+ renderUniverseSoundboard(universe);
+
+ // Render Presets
+ renderUniversePresets(universe);
+
+ // Select Default Preset
+ const firstPresetId = Object.keys(universe.presets)[0];
+ if (firstPresetId) {
+ selectPreset(firstPresetId);
+ }
+
+ if (observationActive && typeof refreshObservation === 'function') {
+ refreshObservation();
+ }
+ }
+
+ function renderUniverseEvents(universe) {
+ universeEventsContainer.innerHTML = '';
+ universe.events.forEach(evt => {
+ const btn = document.createElement('button');
+ btn.className = 'btn-lcars-pill';
+ btn.id = evt.id;
+ btn.textContent = evt.label;
+
+ btn.addEventListener('click', () => invokeConsoleEvent(evt, btn, 'ui'));
+
+ universeEventsContainer.appendChild(btn);
+ });
+ }
+
+ async function invokeConsoleEvent(evt, btn, source) {
+ if (evt.type === 'alert-red' || evt.type === 'alert-yellow') {
+ const value = evt.type === 'alert-red' ? 'red' : 'yellow';
+ return contractAdapter.applyMutation('alert.active', alerts.activeAlert === value ? 'none' : value, source);
+ }
+ const canonical = [...contractAdapter.targets.values()].find(t => t.kind === 'impulse' && t.id.startsWith('event.') && (t.id === `event.${evt.type}` || `btn-${t.id.slice(6)}` === evt.id));
+ if (canonical) return contractAdapter.invokeAction(canonical.id, {}, source);
+ await audioManager.resume();
+ handleUniverseEvent(evt.type, btn);
+ }
+
+ function renderUniverseSoundboard(universe) {
+ soundboardContainer.innerHTML = '';
+ universe.soundboard.forEach(key => {
+ const btn = document.createElement('button');
+ btn.className = 'btn-chirp';
+ btn.id = key.id;
+ btn.textContent = key.label;
+ if (key.span) {
+ btn.style.gridColumn = `span ${key.span}`;
+ }
+
+ btn.addEventListener('click', async () => {
+ const aliases = { 'btn-chirp-single': 'sfx.console-chirp', 'btn-telepathic': 'sfx.telepathic-chime', 'btn-commlock': 'sfx.comlock', 'btn-afterburner-snd': 'sfx.afterburner' };
+ const canonical = aliases[key.id] || `sfx.${key.id.replace(/^btn-/, '')}`;
+ if (contractAdapter.targets.has(canonical)) return contractAdapter.invokeAction(canonical, {}, 'ui');
+ await audioManager.resume();
+ handleSoundboardTrigger(key.id);
+ });
+
+ soundboardContainer.appendChild(btn);
+ });
+ }
+
+ function renderUniversePresets(universe) {
+ presetContainer.innerHTML = '';
+ Object.values(universe.presets).forEach(preset => {
+ const btn = document.createElement('button');
+ btn.className = `preset-btn ${preset.id === activePresetId ? 'active' : ''}`;
+ btn.setAttribute('data-preset-id', preset.id);
+
+ btn.innerHTML = `
+ ${preset.name}
+ ${(universe.name || 'SHIP').toUpperCase()}
+ `;
+
+ btn.addEventListener('click', () => {
+ contractAdapter.applyMutation('preset.selected', preset.id, 'ui');
+ });
+
+ presetContainer.appendChild(btn);
+ });
+ }
+
+ // 4a. Phase 2 Preset Ambience
+ // Single funnel that stops every automated loop (legacy + registry) and then
+ // re-engages the ambience matching the currently selected preset.
+ function startPresetAmbience(presetId) {
+ expandedAudio.stopAllLoops();
+ whataverseAudio.stopAmbientLoops();
+
+ // Legacy Phase 1 loops, kept on their original presets
+ if (presetId === 'sta-sevastopol') {
+ expandedAudio.startStationSparks(4000, 11000);
+ } else if (presetId === 'sta-gateway' || presetId.includes('sickbay') || presetId.includes('med')) {
+ expandedAudio.startMedicalMonitor(1.15);
+ }
+
+ // Phase 2 audio_gaps.md rows #25-#45
+ if (presetId === 'bio-vorlon') expandedAudio.startVorlanSong(); // #26
+ if (presetId === 'bio-wraith') expandedAudio.startHullRegen(); // #28
+ if (presetId === 'deep-event-horizon' || presetId === 'deep-icarus-ii') expandedAudio.startHullStrainMoans(); // #30
+ // Phase 3 (audio_gaps.md #46-#54)
+ if (presetId === 'sta-babylon-5') expandedAudio.startZocoloChatter(); // #46
+ if (presetId === 'ind-nostromo') expandedAudio.startTeletypeChatter(); // #51
+ if (presetId === 'ret-jupiter-2') expandedAudio.startHeterodyneDrone(); // #54
+ if (presetId === 'deep-icarus-ii') expandedAudio.startIcarusBeacon(); // #31
+ if (presetId === 'deep-event-horizon' || presetId === 'deep-nightflyer') expandedAudio.startVoidWhispers(); // #32
+ if (presetId === 'com-protector') expandedAudio.startBerylliumThrum(); // #33
+ if (presetId === 'out-marauder') expandedAudio.startRepulsorliftDrone(); // #35
+ if (presetId === 'out-bebop') expandedAudio.startBebopChug(); // #36
+ if (presetId === 'mil-agamemnon') expandedAudio.startCarouselGroan(); // #37
+ if (presetId === 'mil-viper') expandedAudio.startOxygenRegulator(); // #39
+ if (presetId === 'ind-nostromo') expandedAudio.startPipeDrips(); // #40
+ if (presetId === 'ind-serenity' || presetId === 'ind-starbug') expandedAudio.startSteamVent(); // #41
+ if (presetId === 'ret-discovery-one') expandedAudio.startHalBreathing(); // #43
+ if (presetId === 'ret-cygnus') expandedAudio.startCygnusChug(); // #45
+ }
+
+ // 4. Select & Apply Preset
+ function selectPreset(presetId) {
+ const universe = UniverseRegistry[activeUniverseId];
+ if (!universe) return;
+
+ const preset = universe.presets[presetId];
+ if (!preset) return;
+
+ activePresetId = presetId;
+ window.activePresetId = presetId;
+ if (window.fncSystem) window.fncSystem.resetLearning('preset change');
+
+ document.querySelectorAll('.preset-btn').forEach(b => {
+ b.classList.toggle('active', b.getAttribute('data-preset-id') === presetId);
+ });
+
+ headerShipName.textContent = preset.name.toUpperCase();
+ currentPresetTitle.textContent = preset.name.toUpperCase();
+ currentPresetDesc.textContent = preset.description;
+ statCoreFreq.textContent = `${preset.warp.carrierFreq.toFixed(1)} Hz`;
+
+ hullDrone.applyPreset(preset.hull);
+ warpCore.applyPreset(preset.warp);
+ lifeSupport.applyPreset(preset.lifeSupport);
+ telemetry.applyPreset(preset.telemetry);
+
+ syncSlidersToSynthParams();
+
+ expandedAudio.stopAllLoops();
+ whataverseAudio.stopAmbientLoops();
+ if (isPlaying) {
+ startPresetAmbience(presetId);
+ if (activeUniverseId === 'whataverse') whataverseAudio.synthesizeDematSwitch();
+ else if (activeUniverseId === 'industrial') expandedAudio.synthesizeDockingClamp();
+ else telemetry.synthesizeLCARDSingleChirp();
+ }
+
+ if (observationActive && typeof refreshObservation === 'function') {
+ refreshObservation();
+ }
+ }
+
+ function syncSlidersToSynthParams() {
+ sliderHullVol.value = hullDrone.params.volume;
+ valHullVol.textContent = `${Math.round(hullDrone.params.volume * 100)}%`;
+ sliderHullFreq.value = hullDrone.params.baseFreq;
+ valHullFreq.textContent = `${hullDrone.params.baseFreq} Hz`;
+ sliderHullCutoff.value = hullDrone.params.filterCutoff;
+ valHullCutoff.textContent = `${hullDrone.params.filterCutoff} Hz`;
+
+ sliderWarpVol.value = warpCore.params.volume;
+ valWarpVol.textContent = `${Math.round(warpCore.params.volume * 100)}%`;
+ sliderWarpBpm.value = warpCore.params.bpm;
+ valWarpBpm.textContent = `${warpCore.params.bpm} BPM`;
+ sliderWarpCarrier.value = warpCore.params.carrierFreq;
+ valWarpCarrier.textContent = `${warpCore.params.carrierFreq} Hz`;
+
+ sliderAirVol.value = lifeSupport.params.volume;
+ valAirVol.textContent = `${Math.round(lifeSupport.params.volume * 100)}%`;
+ sliderAirCutoff.value = lifeSupport.params.lowpassFreq;
+ valAirCutoff.textContent = `${lifeSupport.params.lowpassFreq} Hz`;
+
+ sliderTelemetryVol.value = telemetry.params.volume;
+ valTelemetryVol.textContent = `${Math.round(telemetry.params.volume * 100)}%`;
+ sliderTelemetryDensity.value = telemetry.params.density;
+ valTelemetryDensity.textContent = `${Math.round(telemetry.params.density * 100)}%`;
+ }
+
+ // 5. Play / Pause Master Toggle
+ let isTransitioning = false;
+ const ENGINE_TRANSITION_SECS = 2.5;
+
+ async function togglePlay() {
+ if (isTransitioning) return;
+
+ const engineProfile = typeof getEngineProfileForPreset === 'function'
+ ? getEngineProfileForPreset(activePresetId, activeUniverseId)
+ : 'galaxy';
+
+ if (!isPlaying) {
+ isTransitioning = true;
+ await audioManager.resume();
+ fncSystem.ensureGraph();
+ if (fncSystem.enabled) fncSystem.start();
+ // Prepare the configured local LLM and neural voice in parallel with startup.
+ generativeExperience.prepareExperience().catch(err => console.warn('Generative preload failed', err));
+
+ // Flashing state while engaging
+ btnPlay.classList.add('engaging');
+ btnPlay.classList.remove('playing', 'stopping');
+ statAudioStatus.textContent = 'ENGAGING...';
+ statAudioStatus.style.color = '#ffcc00';
+
+ // Layered & Staged Procedural Startup Script for this Profile
+ engineTransitions.playStartup(engineProfile, ENGINE_TRANSITION_SECS);
+
+ // Acoustic chime / chirp feedback
+ if (activeUniverseId === 'whataverse') whataverseAudio.synthesizeTelepathicChime();
+ else if (activeUniverseId === 'bioships') expandedAudio.synthesizeStarburst();
+ else telemetry.synthesizeLCARDDoubleChirp();
+
+ // Start engine sounds with smooth spool-up ramp
+ hullDrone.start(ENGINE_TRANSITION_SECS);
+ warpCore.start(ENGINE_TRANSITION_SECS);
+ lifeSupport.start(ENGINE_TRANSITION_SECS);
+ telemetry.start();
+
+ setTimeout(() => {
+ isPlaying = true;
+ isTransitioning = false;
+
+ btnPlay.classList.remove('engaging');
+ btnPlay.classList.add('playing');
+ playIcon.textContent = '■';
+ playText.textContent = 'FULL STOP';
+ statAudioStatus.textContent = 'ONLINE';
+ statAudioStatus.style.color = '#00ff88';
+
+ if (activePresetId) {
+ startPresetAmbience(activePresetId);
+ }
+ if (generativeExperience.ui.auto?.checked) generativeExperience.scheduleNext();
+ }, ENGINE_TRANSITION_SECS * 1000);
+
+ } else {
+ isTransitioning = true;
+
+ // Flashing state while stopping
+ btnPlay.classList.remove('playing', 'engaging');
+ btnPlay.classList.add('stopping');
+ statAudioStatus.textContent = 'STOPPING...';
+ statAudioStatus.style.color = '#ff9900';
+
+ // Layered & Staged Procedural Shutdown Script for this Profile
+ engineTransitions.playShutdown(engineProfile, ENGINE_TRANSITION_SECS);
+
+ if (activeUniverseId === 'whataverse') whataverseAudio.synthesizeLeverClunk();
+ else telemetry.synthesizeLCARDDoubleChirp();
+
+ // Stop engine sounds with smooth spool-down fade
+ hullDrone.stop(ENGINE_TRANSITION_SECS);
+ warpCore.stop(ENGINE_TRANSITION_SECS);
+ lifeSupport.stop(ENGINE_TRANSITION_SECS);
+ telemetry.stop();
+ expandedAudio.stopAllLoops();
+ whataverseAudio.stopAmbientLoops();
+ alerts.stopAlert();
+ whataverseAudio.stopCloisterBell();
+ fncSystem.hold('alert', false);
+ resetAlertButtons();
+ generativeExperience.stop();
+ fncSystem.resetLearning('full stop');
+ fncSystem.stop();
+
+ setTimeout(() => {
+ isPlaying = false;
+ isTransitioning = false;
+
+ btnPlay.classList.remove('stopping');
+ playIcon.textContent = '▶';
+ playText.textContent = 'ENGAGE';
+ statAudioStatus.textContent = 'STANDBY';
+ statAudioStatus.style.color = '#ffcc00';
+ }, ENGINE_TRANSITION_SECS * 1000);
+ }
+ }
+
+ btnPlay.addEventListener('click', () => {
+ contractAdapter.applyMutation('transport.playing', !isPlaying, 'ui');
+ });
+
+ // 6. Master Volume Control System
+ function updateMasterVolumeUI(val, isMuted = false) {
+ sliderMasterVol.value = val;
+ const pct = Math.round(val * 100);
+ valMasterVol.textContent = isMuted ? 'MUTED' : `${pct}%`;
+ headerVolVal.textContent = isMuted ? 'MUTE' : `${pct}%`;
+
+ if (isMuted || val <= 0.001) {
+ valMasterDb.textContent = '-∞ dB';
+ } else {
+ const db = 20 * Math.log10(val);
+ valMasterDb.textContent = `${db >= 0 ? '+' : ''}${db.toFixed(1)} dB`;
+ }
+
+ btnMasterMute.classList.toggle('muted', isMuted);
+ btnHeaderMute.classList.toggle('muted', isMuted);
+ btnMasterMute.textContent = isMuted ? 'UNMUTE' : 'MUTE';
+ btnHeaderMute.textContent = isMuted ? 'MUTED' : 'MUTE';
+
+ if (volMeterLeds) {
+ const pips = volMeterLeds.querySelectorAll('.led-pip');
+ const activeCount = isMuted ? 0 : Math.round(val * pips.length);
+ pips.forEach((pip, idx) => {
+ pip.classList.toggle('active', idx < activeCount);
+ });
+ }
+
+ volStepButtons.forEach(btn => {
+ const stepVal = parseFloat(btn.getAttribute('data-vol'));
+ btn.classList.toggle('active', !isMuted && Math.abs(stepVal - val) < 0.04);
+ });
+ }
+
+ function setMasterVolume(val) {
+ contractAdapter.applyMutation('mix.master', val, 'ui');
+ }
+
+ function toggleMute() {
+ contractAdapter.applyMutation('transport.muted', !audioManager.isMuted, 'ui');
+ }
+
+ sliderMasterVol.addEventListener('input', (e) => {
+ contractAdapter.applyMutation('mix.master', parseFloat(e.target.value), 'ui');
+ });
+ btnMasterMute.addEventListener('click', toggleMute);
+ btnHeaderMute.addEventListener('click', toggleMute);
+ btnVolDown.addEventListener('click', () => {
+ contractAdapter.applyMutation('mix.master', Math.max(0, audioManager.getMasterVolume() - 0.05), 'ui');
+ });
+ btnVolUp.addEventListener('click', () => {
+ contractAdapter.applyMutation('mix.master', Math.min(1, audioManager.getMasterVolume() + 0.05), 'ui');
+ });
+
+ volStepButtons.forEach(btn => {
+ btn.addEventListener('click', () => {
+ const stepVal = parseFloat(btn.getAttribute('data-vol'));
+ contractAdapter.applyMutation('mix.master', stepVal, 'ui');
+ });
+ });
+
+ // 7. Channel Synthesizer Sliders
+ sliderHullVol.addEventListener('input', (e) => {
+ contractAdapter.applyMutation('mix.hull.level', parseFloat(e.target.value), 'ui');
+ });
+ sliderHullFreq.addEventListener('input', (e) => {
+ contractAdapter.applyMutation('mix.hull.frequency', parseFloat(e.target.value), 'ui');
+ });
+ sliderHullCutoff.addEventListener('input', (e) => {
+ contractAdapter.applyMutation('mix.hull.cutoff', parseFloat(e.target.value), 'ui');
+ });
+
+ sliderWarpVol.addEventListener('input', (e) => {
+ contractAdapter.applyMutation('mix.drive.level', parseFloat(e.target.value), 'ui');
+ });
+ sliderWarpBpm.addEventListener('input', (e) => {
+ contractAdapter.applyMutation('mix.drive.pulse-rate', parseInt(e.target.value), 'ui');
+ });
+ sliderWarpCarrier.addEventListener('input', (e) => {
+ contractAdapter.applyMutation('mix.drive.carrier', parseFloat(e.target.value), 'ui');
+ });
+
+ sliderAirVol.addEventListener('input', (e) => {
+ contractAdapter.applyMutation('mix.environment.level', parseFloat(e.target.value), 'ui');
+ });
+ sliderAirCutoff.addEventListener('input', (e) => {
+ contractAdapter.applyMutation('mix.environment.cutoff', parseFloat(e.target.value), 'ui');
+ });
+
+ sliderTelemetryVol.addEventListener('input', (e) => {
+ contractAdapter.applyMutation('mix.telemetry.level', parseFloat(e.target.value), 'ui');
+ });
+ sliderTelemetryDensity.addEventListener('input', (e) => {
+ contractAdapter.applyMutation('mix.telemetry.density', parseFloat(e.target.value), 'ui');
+ });
+
+ // 8. Universe Event Handlers
+ function resetAlertButtons() {
+ document.querySelectorAll('.btn-lcars-pill').forEach(b => b.classList.remove('active-alert'));
+ }
+
+ function handleUniverseEvent(type, btnElement) {
+ fncSystem.holdFor(6500, 'event');
+ switch (type) {
+ case 'warp':
+ case 'quantum':
+ alerts.synthesizeWarpJump();
+ visualizer.spawnWarpPulses();
+ break;
+ case 'alert-red':
+ case 'action-stations':
+ case 'scramble':
+ if (alerts.activeAlert === 'red') {
+ alerts.stopAlert();
+ fncSystem.hold('alert', false);
+ resetAlertButtons();
+ } else {
+ resetAlertButtons();
+ btnElement.classList.add('active-alert');
+ alerts.triggerRedAlert('tng');
+ fncSystem.hold('alert', true);
+ }
+ break;
+ case 'alert-yellow':
+ if (alerts.activeAlert === 'yellow') {
+ alerts.stopAlert();
+ fncSystem.hold('alert', false);
+ resetAlertButtons();
+ } else {
+ resetAlertButtons();
+ btnElement.classList.add('active-alert');
+ alerts.triggerYellowAlert();
+ fncSystem.hold('alert', true);
+ }
+ break;
+ case 'demat':
+ whataverseAudio.synthesizeDematCycle(4);
+ visualizer.spawnWarpPulses();
+ break;
+ case 'vortex':
+ whataverseAudio.synthesizeDematCycle(1);
+ visualizer.spawnWarpPulses();
+ break;
+ case 'cloister':
+ if (whataverseAudio.activeCloister) {
+ whataverseAudio.stopCloisterBell();
+ fncSystem.hold('alert', false);
+ resetAlertButtons();
+ } else {
+ resetAlertButtons();
+ btnElement.classList.add('active-alert');
+ whataverseAudio.triggerCloisterBell();
+ fncSystem.hold('alert', true);
+ }
+ break;
+ case 'epstein':
+ case 'subdrive':
+ expandedAudio.synthesizeEpsteinBurn();
+ visualizer.spawnWarpPulses();
+ break;
+ case 'purge':
+ case 'decompress':
+ case 'docking':
+ expandedAudio.synthesizeDockingClamp();
+ break;
+ case 'vent':
+ whataverseAudio.synthesizeFastReturn();
+ break;
+ case 'starburst':
+ case 'biodefense':
+ expandedAudio.synthesizeStarburst();
+ visualizer.spawnWarpPulses();
+ break;
+ case 'neural':
+ expandedAudio.synthesizeNeuralBondSwell();
+ visualizer.spawnWarpPulses();
+ break;
+ case 'centrifuge':
+ case 'astrogator':
+ expandedAudio.synthesizeRetroAstrogator();
+ break;
+ case 'hal':
+ expandedAudio.synthesizeHalChime();
+ break;
+ case 'ftl-jump':
+ expandedAudio.synthesizeFtlJump();
+ visualizer.spawnWarpPulses();
+ break;
+ case 'flak':
+ expandedAudio.synthesizeFlakBarrageBurst();
+ break;
+ case 'singularity':
+ case 'gravity':
+ case 'rotation':
+ expandedAudio.synthesizeSingularityEngage();
+ visualizer.spawnWarpPulses();
+ break;
+ case 'solar':
+ expandedAudio.synthesizeSolarRoar();
+ visualizer.spawnWarpPulses();
+ break;
+ case 'afterburner':
+ expandedAudio.synthesizeAfterburner();
+ visualizer.spawnWarpPulses();
+ break;
+ case 'ludicrous':
+ expandedAudio.synthesizeLudicrousSpeed();
+ visualizer.spawnWarpPulses();
+ break;
+ case 'improbability':
+ expandedAudio.synthesizeImprobabilityFlip();
+ visualizer.spawnWarpPulses();
+ break;
+ default:
+ telemetry.synthesizeLCARDSingleChirp();
+ break;
+ }
+ generativeExperience.describeExistingEvent(type);
+ }
+
+ function handleSoundboardTrigger(id) {
+ fncSystem.holdFor(2800, 'soundboard');
+ const universe = UniverseRegistry[activeUniverseId];
+ const preset = universe && universe.presets ? universe.presets[activePresetId] : null;
+ const era = preset ? preset.era : 'tng';
+
+ switch (id) {
+ // Star Trek
+ case 'btn-comm-badge':
+ expandedAudio.synthesizeCommBadge();
+ break;
+ case 'btn-door-swish':
+ expandedAudio.synthesizeDoorSwish(era);
+ break;
+ case 'btn-bosun-whistle':
+ expandedAudio.synthesizeBosunWhistle();
+ break;
+ case 'btn-medical-monitor':
+ expandedAudio.synthesizeMedicalMonitor();
+ break;
+ case 'btn-chirp-single':
+ case 'btn-switch-pop':
+ telemetry.synthesizeLCARDSingleChirp();
+ break;
+ case 'btn-chirp-double':
+ telemetry.synthesizeLCARDDoubleChirp();
+ break;
+ case 'btn-chirp-ack':
+ telemetry.synthesizeLCARDSequence();
+ break;
+ case 'btn-chirp-sweep':
+ telemetry.synthesizeSensorSweep();
+ break;
+ case 'btn-chirp-door':
+ telemetry.synthesizeDoorChime();
+ break;
+
+ // Whataverse
+ case 'btn-sonic':
+ whataverseAudio.synthesizeSonicScrewdriver();
+ break;
+ case 'btn-lever-clunk':
+ whataverseAudio.synthesizeLeverClunk();
+ break;
+ case 'btn-tardix-door':
+ whataverseAudio.synthesizeTARDIXDoor();
+ break;
+ case 'btn-fast-return':
+ whataverseAudio.synthesizeFastReturn();
+ break;
+ case 'btn-demat-switch':
+ case 'btn-relay-click':
+ whataverseAudio.synthesizeDematSwitch();
+ break;
+ case 'btn-telepathic':
+ case 'btn-telepathic-chime':
+ whataverseAudio.synthesizeTelepathicChime();
+ break;
+ case 'btn-scanner':
+ whataverseAudio.synthesizeDematSwitch();
+ telemetry.synthesizeSensorSweep();
+ break;
+
+ // Space Stations
+ case 'btn-air-handler':
+ expandedAudio.synthesizeAirHandlerThud();
+ break;
+ case 'btn-mag-boot':
+ expandedAudio.synthesizeMagBootLatch();
+ break;
+ case 'btn-commlock':
+ expandedAudio.synthesizeComlockTone();
+ break;
+ case 'btn-sevas-spark':
+ expandedAudio.synthesizeSevastopolSpark();
+ break;
+ case 'btn-station-ecg':
+ expandedAudio.synthesizeStationMedicalPing();
+ break;
+ case 'btn-tram-gong':
+ whataverseAudio.synthesizeCloisterStrike();
+ break;
+
+ // Deep Space
+ case 'btn-solar-roar':
+ expandedAudio.synthesizeSolarRoar();
+ break;
+ case 'btn-cryo-sigh':
+ expandedAudio.synthesizeCryoDepressurize();
+ break;
+ case 'btn-orion-thump':
+ expandedAudio.synthesizeOrionPulseThump();
+ break;
+
+ // Outlaw & Frontier
+ case 'btn-tape-clunk':
+ expandedAudio.synthesizeCassetteTransport();
+ break;
+ case 'btn-grappler-servo':
+ expandedAudio.synthesizeGrapplerServo();
+ break;
+ case 'btn-radio-static':
+ expandedAudio.synthesizeRadioStatic();
+ break;
+ case 'btn-afterburner-snd':
+ expandedAudio.synthesizeAfterburner();
+ break;
+
+ // Comedy & Adventure
+ case 'btn-cheerful-door':
+ expandedAudio.synthesizeCheerfulDoor();
+ break;
+ case 'btn-tea-machine':
+ expandedAudio.synthesizeTeaDispenser();
+ break;
+ case 'btn-kettle-whistle':
+ expandedAudio.synthesizeKettleWhistle();
+ break;
+ case 'btn-plaid-alarm':
+ expandedAudio.synthesizePlaidAlarm();
+ break;
+ case 'btn-beryllium-pulse':
+ expandedAudio.synthesizeImprobabilityFlip();
+ break;
+
+ // General / Industrial / Military
+ case 'btn-geiger':
+ expandedAudio.synthesizeGeigerBurst();
+ break;
+ case 'btn-dock-clamp':
+ case 'btn-pneumatic-door':
+ expandedAudio.synthesizeDockingClamp();
+ break;
+ case 'btn-dradis-ping':
+ expandedAudio.synthesizeDRADARPing();
+ break;
+ case 'btn-hal-chime':
+ expandedAudio.synthesizeHalChime();
+ break;
+
+ // Phase 2 (audio_gaps.md #21-#45)
+ case 'btn-cardassian-door':
+ expandedAudio.synthesizeCardassianDoor();
+ break;
+ case 'btn-turbolift':
+ expandedAudio.synthesizeTurbochuteWhoosh();
+ break;
+ case 'btn-replicator':
+ expandedAudio.synthesizeFabricatorShimmer();
+ break;
+ case 'btn-scanner-activate':
+ whataverseAudio.synthesizeScannerActivate();
+ break;
+ case 'btn-type-clatter':
+ whataverseAudio.synthesizeTypeClatter();
+ break;
+ case 'btn-vorlon-song':
+ expandedAudio.synthesizeVorlanSingingSwell();
+ break;
+ case 'btn-neural-bond':
+ expandedAudio.synthesizeNeuralBondSwell();
+ break;
+ case 'btn-hull-regen':
+ expandedAudio.synthesizeHullRegenBurst();
+ break;
+ case 'btn-belter-telemetry':
+ expandedAudio.synthesizeBeltworkerTelemetryJitter();
+ break;
+ case 'btn-hull-strain':
+ expandedAudio.synthesizeHullStrainMoan();
+ break;
+ case 'btn-icarus-beacon':
+ expandedAudio.synthesizeIcarusBeacon();
+ break;
+ case 'btn-void-whisper':
+ expandedAudio.synthesizeVoidWhisper();
+ break;
+ case 'btn-beryllium-thrum':
+ expandedAudio.synthesizeBerylliumThrumSwell();
+ break;
+ case 'btn-omega-13':
+ expandedAudio.synthesizeOmega13Whine();
+ break;
+ case 'btn-repulsor-drone':
+ expandedAudio.synthesizeRepulsorliftDrone();
+ break;
+ case 'btn-bebop-chug':
+ expandedAudio.synthesizeBebopChugStroke();
+ break;
+ case 'btn-carousel-groan':
+ expandedAudio.synthesizeCarouselGroanCycle();
+ break;
+ case 'btn-slipstream':
+ expandedAudio.synthesizeSlipstreamSurge();
+ break;
+ case 'btn-oxygen-reg':
+ expandedAudio.synthesizeOxygenRegulatorCycle();
+ break;
+ case 'btn-pipe-drips':
+ expandedAudio.synthesizePipeDrip();
+ break;
+ case 'btn-steam-vent':
+ expandedAudio.synthesizeSteamVentSwell();
+ break;
+ case 'btn-crash-couch':
+ expandedAudio.synthesizeCrashCouchGimbal();
+ break;
+ case 'btn-hal-breath':
+ expandedAudio.synthesizeHalBreathCycle();
+ break;
+ case 'btn-death-blossom':
+ expandedAudio.synthesizeDeathBlossomSurge();
+ break;
+ case 'btn-cygnus-chug':
+ expandedAudio.synthesizeCygnusPistonChug();
+ break;
+
+ // Phase 3 (audio_gaps.md #48-#53)
+ case 'btn-transporter':
+ expandedAudio.synthesizeTranslocatorCycle();
+ break;
+ case 'btn-breech-clank':
+ expandedAudio.synthesizeBreechClank();
+ break;
+ case 'btn-dock-groan':
+ expandedAudio.synthesizeDockingGroan();
+ break;
+ case 'btn-tape-spooler':
+ expandedAudio.synthesizeTapeSpooler();
+ break;
+ case 'btn-teletype':
+ expandedAudio.synthesizeTeletypeChatterPhrase();
+ break;
+ case 'btn-zocalo-chatter':
+ if (expandedAudio.zocaloChatterActive) expandedAudio.stopZocoloChatter();
+ else expandedAudio.startZocoloChatter();
+ break;
+ case 'btn-hetero-drone':
+ if (expandedAudio.heterodyneDroneActive) expandedAudio.stopHeterodyneDrone();
+ else expandedAudio.startHeterodyneDrone();
+ break;
+
+ default:
+ telemetry.synthesizeLCARDSingleChirp();
+ break;
+ }
+ }
+
+ // 9. Sleep Timer
+ timerButtons.forEach(btn => {
+ btn.addEventListener('click', () => {
+ const minutes = parseInt(btn.getAttribute('data-minutes'));
+ if (minutes === 0) {
+ audioManager.stopSleepTimer();
+ timerDisplay.textContent = 'TIMER INACTIVE (CONTINUOUS)';
+ } else {
+ audioManager.startSleepTimer(
+ minutes,
+ (secondsLeft) => {
+ const m = Math.floor(secondsLeft / 60);
+ const s = secondsLeft % 60;
+ timerDisplay.textContent = `SLEEP TIMER: ${m}:${s < 10 ? '0' : ''}${s}`;
+ },
+ () => {
+ timerDisplay.textContent = 'SLEEP TIMER COMPLETED';
+ if (isPlaying) togglePlay();
+ }
+ );
+ }
+ });
+ });
+
+
+ // =========================================================================
+ // OBSERVATION MODE
+ // One passive full-screen view with theme-specific inline SVG displays.
+ // Deliberately exits only on an actual click/tap (or Escape).
+ // =========================================================================
+ const btnObservation = document.getElementById('btn-observation');
+ const observationOverlay = document.getElementById('observation-overlay');
+ const observationStage = document.getElementById('observation-stage');
+ const observationSimLayer = document.getElementById('observation-sim-layer');
+ const observationUniverse = document.getElementById('observation-universe');
+ const observationProfile = document.getElementById('observation-profile');
+ const observationStatus = document.getElementById('observation-status');
+ const observationEventLayer = document.getElementById('observation-event-layer');
+ const sliderObservationActivity = document.getElementById('slider-observation-activity');
+ const valObservationActivity = document.getElementById('val-observation-activity');
+ const toggleObservationHud = document.getElementById('toggle-observation-hud');
+ const valObservationHud = document.getElementById('val-observation-hud');
+ const observationChrome = observationOverlay.querySelector('.observation-chrome');
+ const observationReturn = observationOverlay.querySelector('.observation-return');
+
+ let observationActive = false;
+ let observationActivity = 0.60;
+ window.observationActivity = observationActivity;
+ let observationAmbientTimer = null;
+ let observationTransientSerial = 0;
+
+ function obsClamp(v, lo, hi) {
+ return Math.max(lo, Math.min(hi, v));
+ }
+
+ function obsRand(min, max) {
+ return min + Math.random() * (max - min);
+ }
+
+ function obsInt(min, max) {
+ return Math.floor(obsRand(min, max + 1));
+ }
+
+ function obsPick(items) {
+ return items[Math.floor(Math.random() * items.length)];
+ }
+
+ function obsHex(n, width = 3) {
+ return Math.floor(n).toString(16).toUpperCase().padStart(width, '0');
+ }
+
+
+ // =========================================================================
+ // PERSISTENT OBSERVATION SCENE ENGINE
+ // Original visual worlds, not franchise reproductions. Persistent entities
+ // use simple stateful motion rather than one-shot CSS decorations.
+ // =========================================================================
+ let observationSceneRaf = null;
+ let observationSceneLast = 0;
+ let observationSceneEntities = [];
+ let observationSceneBoostUntil = 0;
+ let observationSplineMorphs = [];
+
+ function obsEaseSmooth(t) {
+ return t * t * (3 - 2 * t);
+ }
+
+ function obsEaseInCubic(t) {
+ return t * t * t;
+ }
+
+ function obsEaseOutCubic(t) {
+ return 1 - Math.pow(1 - t, 3);
+ }
+
+ function obsBezierPoint(t, p0, p1, p2, p3) {
+ const u = 1 - t;
+ const tt = t * t;
+ const uu = u * u;
+ const uuu = uu * u;
+ const ttt = tt * t;
+ return {
+ x: uuu*p0.x + 3*uu*t*p1.x + 3*u*tt*p2.x + ttt*p3.x,
+ y: uuu*p0.y + 3*uu*t*p1.y + 3*u*tt*p2.y + ttt*p3.y
+ };
+ }
+
+ function obsBezierDerivative(t, p0, p1, p2, p3) {
+ const u = 1 - t;
+ return {
+ x: 3*u*u*(p1.x-p0.x) + 6*u*t*(p2.x-p1.x) + 3*t*t*(p3.x-p2.x),
+ y: 3*u*u*(p1.y-p0.y) + 6*u*t*(p2.y-p1.y) + 3*t*t*(p3.y-p2.y)
+ };
+ }
+
+
+ // =========================================================================
+ // LONG-LIVED SPLINE MORPH ENGINE
+ // Applies only to persistent pathwork in the main observation stage.
+ // The goal is subtle recalculation/breathing, not chaotic wriggling.
+ // =========================================================================
+
+ function obsThemeSplineScale(theme) {
+ return ({
+ starfleet: 0.60,
+ whataverse: 1.05,
+ industrial: 0.48,
+ bioships: 1.28,
+ retrofuture: 0.72,
+ military: 0.44,
+ deepspace: 0.34,
+ outlaw: 0.86,
+ spacestations: 0.50,
+ comedy: 0.90
+ })[theme] || 0.65;
+ }
+
+ function obsTokenizePath(d) {
+ return (d.match(/[A-Za-z]|[-+]?(?:\d*\.\d+|\d+)(?:e[-+]?\d+)?/g) || []);
+ }
+
+ function obsParsePathSegments(d) {
+ const tokens = obsTokenizePath(d);
+ const segments = [];
+ let i = 0;
+ while (i < tokens.length) {
+ const cmd = tokens[i++];
+ if (!/^[A-Za-z]$/.test(cmd)) break;
+ const upper = cmd.toUpperCase();
+
+ let count = 0;
+ if (upper === 'M' || upper === 'L' || upper === 'T') count = 2;
+ else if (upper === 'Q' || upper === 'S') count = 4;
+ else if (upper === 'C') count = 6;
+ else if (upper === 'H' || upper === 'V') count = 1;
+ else if (upper === 'Z') count = 0;
+ else count = 0;
+
+ if (upper == 'Z') {
+ segments.push({ cmd, values: [] });
+ continue;
+ }
+
+ const values = [];
+ while (i < tokens.length && !/^[A-Za-z]$/.test(tokens[i])) {
+ values.push(Number(tokens[i++]));
+ }
+ if (!values.length) {
+ segments.push({ cmd, values: [] });
+ continue;
+ }
+
+ if (count == 0) {
+ segments.push({ cmd, values });
+ continue;
+ }
+
+ // Split repeated coordinate groups into separate segments. For repeated M,
+ // SVG treats the subsequent groups as implicit L commands.
+ for (let start = 0, segIdx = 0; start < values.length; start += count, segIdx++) {
+ const slice = values.slice(start, start + count);
+ if (slice.length < count) break;
+ const segCmd = (upper === 'M' && segIdx > 0) ? (cmd === upper ? 'L' : 'l') : cmd;
+ segments.push({ cmd: segCmd, values: slice });
+ }
+ }
+ return segments;
+ }
+
+ function obsBuildPathFromSegments(segments) {
+ return segments.map(seg => {
+ if (!seg.values || !seg.values.length) return seg.cmd;
+ const nums = seg.values.map(v => {
+ const n = Math.abs(v) < 1e-6 ? 0 : v;
+ return Number(n.toFixed(2)).toString();
+ }).join(' ');
+ return `${seg.cmd}${nums}`;
+ }).join(' ');
+ }
+
+ function obsCreateSplineMorphState(pathEl) {
+ if (!pathEl) return null;
+ const d = pathEl.getAttribute('d');
+ if (!d || !/[CQST]/i.test(d)) return null;
+
+ const segments = obsParsePathSegments(d);
+ if (!segments.length) return null;
+
+ const bbox = pathEl.getBBox();
+ const width = Math.max(80, bbox.width || 80);
+ const height = Math.max(80, bbox.height || 80);
+ const routeLike = pathEl.classList.contains('obs-route-wander');
+ const themeScale = obsThemeSplineScale(activeUniverseId);
+ const kindScale = routeLike ? 1.0 : 0.7;
+
+ let drawable = segments
+ .map((seg, idx) => ({ idx, upper: seg.cmd.toUpperCase(), count: seg.values.length }))
+ .filter(item => ['Q', 'T', 'S', 'C'].includes(item.upper));
+ const lastDrawableIdx = drawable.length ? drawable[drawable.length - 1].idx : -1;
+
+ const ampFractions = [];
+ segments.forEach((seg, segIdx) => {
+ const upper = seg.cmd.toUpperCase();
+ let frac = [];
+
+ if (upper === 'M' || upper === 'L') {
+ frac = new Array(seg.values.length).fill(0);
+ } else if (upper === 'C') {
+ frac = [1, 1, 1, 1, segIdx === lastDrawableIdx ? 0 : 0.18, segIdx === lastDrawableIdx ? 0 : 0.18];
+ } else if (upper === 'S') {
+ frac = [1, 1, segIdx === lastDrawableIdx ? 0 : 0.18, segIdx === lastDrawableIdx ? 0 : 0.18];
+ } else if (upper === 'Q') {
+ frac = [0.92, 0.92, segIdx === lastDrawableIdx ? 0 : 0.15, segIdx === lastDrawableIdx ? 0 : 0.15];
+ } else if (upper === 'T') {
+ frac = [segIdx === lastDrawableIdx ? 0 : 0.14, segIdx === lastDrawableIdx ? 0 : 0.14];
+ } else {
+ frac = new Array(seg.values.length).fill(0);
+ }
+ ampFractions.push(frac);
+ });
+
+ const offsets = segments.map(seg => new Array(seg.values.length).fill(0));
+ const targets = segments.map(seg => new Array(seg.values.length).fill(0));
+
+ function amplitudeFor(segIndex, valueIndex) {
+ const seg = segments[segIndex];
+ const frac = (ampFractions[segIndex] && ampFractions[segIndex][valueIndex]) || 0;
+ if (frac <= 0) return 0;
+ const upper = seg.cmd.toUpperCase();
+ const axis = upper === 'H' ? 'x' : upper === 'V' ? 'y' : (valueIndex % 2 === 0 ? 'x' : 'y');
+ const span = axis === 'x' ? width : height;
+ const baseFrac = routeLike ? 0.038 : 0.026;
+ return span * baseFrac * frac * kindScale * themeScale;
+ }
+
+ return {
+ el: pathEl,
+ baseD: d,
+ segments,
+ ampFractions,
+ offsets,
+ targets,
+ nextRetargetAt: performance.now() + obsRand(2200, 7800),
+ lastRetargetAt: performance.now(),
+ width,
+ height,
+ routeLike,
+ targetMultiplier: 1
+ };
+ }
+
+ function initializeObservationSplineMorphs() {
+ observationSplineMorphs = [];
+ if (!observationStage) return;
+
+ const paths = observationStage.querySelectorAll('path.obs-dashflow, path.obs-route-wander');
+ paths.forEach(pathEl => {
+ const state = obsCreateSplineMorphState(pathEl);
+ if (state) observationSplineMorphs.push(state);
+ });
+ }
+
+ function stopObservationSplineMorphs() {
+ observationSplineMorphs.forEach(state => {
+ if (state.el && state.el.isConnected) state.el.setAttribute('d', state.baseD);
+ });
+ observationSplineMorphs = [];
+ }
+
+ function retargetObservationSpline(state, now, forceScale = 1) {
+ const activity = observationActivity;
+ state.targetMultiplier = forceScale;
+ state.lastRetargetAt = now;
+
+ state.segments.forEach((seg, segIndex) => {
+ seg.values.forEach((_, valueIndex) => {
+ const amp = (function() {
+ const frac = (state.ampFractions[segIndex] && state.ampFractions[segIndex][valueIndex]) || 0;
+ if (frac <= 0) return 0;
+ const upper = seg.cmd.toUpperCase();
+ const axis = upper === 'H' ? 'x' : upper === 'V' ? 'y' : (valueIndex % 2 === 0 ? 'x' : 'y');
+ const span = axis === 'x' ? state.width : state.height;
+ const baseFrac = state.routeLike ? 0.038 : 0.026;
+ return span * baseFrac * frac * (state.routeLike ? 1.0 : 0.7) * obsThemeSplineScale(activeUniverseId) * forceScale;
+ })();
+
+ if (amp <= 0) {
+ state.targets[segIndex][valueIndex] = 0;
+ return;
+ }
+
+ const bias = state.routeLike ? 1.0 : 0.75;
+ state.targets[segIndex][valueIndex] = obsRand(-amp * bias, amp * bias);
+ });
+ });
+
+ const slow = state.routeLike ? 32000 : 42000;
+ const fast = state.routeLike ? 8500 : 14000;
+ const next = slow - (slow - fast) * Math.pow(activity, 0.82);
+ state.nextRetargetAt = now + next * obsRand(0.70, 1.35);
+ }
+
+ function updateObservationSplineMorphs(now, speedScale) {
+ if (!observationSplineMorphs.length) return;
+
+ const themeScale = obsThemeSplineScale(activeUniverseId);
+ observationSplineMorphs = observationSplineMorphs.filter(state => state.el && state.el.isConnected);
+
+ observationSplineMorphs.forEach(state => {
+ if (now >= state.nextRetargetAt) {
+ retargetObservationSpline(state, now, 1);
+ }
+
+ const follow = (0.012 + observationActivity * 0.030 + Math.max(0, speedScale - 0.4) * 0.006) * themeScale;
+
+ state.segments.forEach((seg, segIndex) => {
+ seg.values.forEach((baseValue, valueIndex) => {
+ const current = state.offsets[segIndex][valueIndex];
+ const target = state.targets[segIndex][valueIndex];
+ state.offsets[segIndex][valueIndex] = current + (target - current) * follow;
+ });
+ });
+
+ const morphed = state.segments.map((seg, segIndex) => ({
+ cmd: seg.cmd,
+ values: seg.values.map((baseValue, valueIndex) => baseValue + state.offsets[segIndex][valueIndex])
+ }));
+
+ state.el.setAttribute('d', obsBuildPathFromSegments(morphed));
+ });
+ }
+
+ function pulseObservationSplineMorphs() {
+ if (!observationActive || !observationSplineMorphs.length) return;
+
+ const candidates = observationSplineMorphs.filter(state => state.el && state.el.isConnected);
+ if (!candidates.length) return;
+
+ const count = observationActivity > 0.72 && Math.random() < observationActivity ? 2 : 1;
+ const shuffled = [...candidates].sort(() => Math.random() - 0.5);
+
+ for (let i = 0; i < Math.min(count, shuffled.length); i++) {
+ retargetObservationSpline(shuffled[i], performance.now(), 1.22 + observationActivity * 0.38);
+ }
+ }
+
+
+ function obsSceneShip(kind='scout', color='#dbeafe', accent='#38bdf8') {
+ if (kind === 'freighter') {
+ return `
+
+
+
+
+
+
+ `;
+ }
+ if (kind === 'shuttle') {
+ return `
+
+
+
+
+ `;
+ }
+ if (kind === 'tug') {
+ return `
+
+
+
+
+ `;
+ }
+ return `
+
+
+
+
+ `;
+ }
+
+ function obsSceneDefs() {
+ return `
+
+
+
+
+
+
+
+
+ `;
+ }
+
+ function obsFlightMarkup(id, shipMarkup, cfg) {
+ return `
+
+ ${shipMarkup}
+ `;
+ }
+
+ function obsOrbitMarkup(id, body, cfg) {
+ return `
+
+ ${body}
+ `;
+ }
+
+ function obsFloatMarkup(id, body, cfg) {
+ return `
+
+ ${body}
+ `;
+ }
+
+ function buildObservationSceneMarkup(theme) {
+ const defs = obsSceneDefs();
+
+ if (theme === 'starfleet') {
+ return `${defs}
+
+ ${obsFlightMarkup('sf-flight-1', obsSceneShip('scout','#e0f2fe','#38bdf8'), {
+ x0:410,y0:640,x1:520,y1:520,x2:880,y2:245,x3:1110,y3:330,duration:26000,delay:900,mode:'arrival',scale0:.35,scale1:.75,minActivity:.15
+ })}
+ ${obsFlightMarkup('sf-flight-2', obsSceneShip('shuttle','#fde68a','#f97316'), {
+ x0:1190,y0:600,x1:1030,y1:500,x2:680,y2:420,x3:510,y3:260,duration:31000,delay:4600,mode:'cruise',scale0:.28,scale1:.55,minActivity:.35
+ })}
+ ${obsOrbitMarkup('sf-orbit-1', ` `, {
+ cx:800,cy:455,rx:245,ry:155,duration:24000,phase:.2,scale0:.65,scale1:1.05,minActivity:.05
+ })}
+ ${obsOrbitMarkup('sf-orbit-2', ` `, {
+ cx:800,cy:455,rx:165,ry:245,duration:39000,phase:.62,scale0:.5,scale1:.9,minActivity:.5
+ })}
+ `;
+ }
+
+ if (theme === 'whataverse') {
+ const glyph = (char,color,size) => `${char} `;
+ return `${defs}
+ ${obsOrbitMarkup('who-glyph-1', glyph('◎','#00e5ff',38), {cx:800,cy:455,rx:335,ry:160,duration:23000,phase:.1,scale0:.55,scale1:1.2,minActivity:.05})}
+ ${obsOrbitMarkup('who-glyph-2', glyph('∆','#d4af37',30), {cx:800,cy:455,rx:255,ry:280,duration:31000,phase:.42,scale0:.5,scale1:1,minActivity:.2})}
+ ${obsOrbitMarkup('who-glyph-3', glyph('∞','#00e5ff',28), {cx:800,cy:455,rx:420,ry:110,duration:39000,phase:.73,scale0:.4,scale1:1.15,minActivity:.45})}
+ ${obsFloatMarkup('who-fragment-1', ` `,
+ {cx:480,cy:520,ampX:95,ampY:75,duration:17000,phase:.35,minActivity:.25})}
+ ${obsFloatMarkup('who-fragment-2', ` `,
+ {cx:1110,cy:330,ampX:70,ampY:110,duration:21000,phase:.8,minActivity:.55})}`;
+ }
+
+ if (theme === 'industrial') {
+ // v13co: the sim layer sits ABOVE the stage (z-index 2 vs 1), so anything
+ // placed here draws over the camera console. Every entity is therefore
+ // parented to a clip matching one feed's picture area. The v12 entities
+ // (a wheeled cart, a crane hook, a fan and two steam blobs) were free
+ // floating in screen space, which is what made the smoke look detached
+ // and put a rolling "cart" across the cargo bay. They are replaced by
+ // motion that belongs to a specific feed.
+ const feedClip = (id, x, y) =>
+ ` `;
+ return `${defs}
+
+ ${feedClip('indSimCam1', 290, 259)}
+ ${feedClip('indSimCam2', 825, 259)}
+ ${feedClip('indSimCam3', 290, 483)}
+ ${feedClip('indSimCam4', 825, 483)}
+
+
+ ${obsFloatMarkup('ind-bay-drone', `
+
+
+
+
+ `, {cx:532,cy:420,ampX:186,ampY:11,duration:31000,phase:.15,minActivity:.2,opacity:.66})}
+
+
+ ${obsFloatMarkup('ind-ember-1', ` `,
+ {cx:1035,cy:352,ampX:38,ampY:52,duration:13000,phase:.2,minActivity:.25,opacity:.6})}
+ ${obsFloatMarkup('ind-ember-2', ` `,
+ {cx:1092,cy:320,ampX:46,ampY:44,duration:17000,phase:.62,minActivity:.45,opacity:.5})}
+ ${obsFloatMarkup('ind-ember-3', ` `,
+ {cx:1160,cy:372,ampX:30,ampY:40,duration:21000,phase:.85,minActivity:.6,opacity:.42})}
+
+
+ ${obsFloatMarkup('ind-lock-tell', `
+ `,
+ {cx:532,cy:640,ampX:0,ampY:13,duration:23000,phase:.3,minActivity:.5,opacity:.4})}
+
+
+ ${obsFloatMarkup('ind-corridor-lamp', `
+ `,
+ {cx:1067,cy:600,ampX:118,ampY:26,duration:27000,phase:.4,minActivity:.3,opacity:.55})}
+ `;
+ }
+
+ if (theme === 'bioships') {
+ const particle = (color,r=5) => ` `;
+ return `${defs}
+ ${obsFlightMarkup('bio-flow-1', particle('#a7f3d0',5), {x0:300,y0:520,x1:520,y1:220,x2:760,y2:680,x3:1110,y3:350,duration:15000,delay:0,mode:'cruise',scale0:.7,scale1:1,minActivity:.05,opacity:.7})}
+ ${obsFlightMarkup('bio-flow-2', particle('#10b981',4), {x0:1240,y0:610,x1:970,y1:430,x2:880,y2:720,x3:480,y3:390,duration:18000,delay:1600,mode:'cruise',scale0:.5,scale1:.9,minActivity:.15,opacity:.65})}
+ ${obsFlightMarkup('bio-flow-3', particle('#8b5cf6',6), {x0:340,y0:290,x1:620,y1:570,x2:920,y2:180,x3:1300,y3:510,duration:22000,delay:4200,mode:'cruise',scale0:.55,scale1:1.05,minActivity:.35,opacity:.58})}
+ ${obsOrbitMarkup('bio-node-1', ` `, {cx:800,cy:470,rx:210,ry:125,duration:19000,phase:.3,scale0:.65,scale1:1.2,minActivity:.3})}
+ ${obsFloatMarkup('bio-membrane', ` `,
+ {cx:800,cy:470,ampX:26,ampY:18,duration:13000,phase:.1,minActivity:.55,opacity:.6})}`;
+ }
+
+ if (theme === 'retrofuture') {
+ const vectorShip = ` `;
+ return `${defs}
+ ${obsFlightMarkup('retro-v1', vectorShip, {x0:350,y0:610,x1:510,y1:430,x2:950,y2:330,x3:1210,y3:500,duration:25000,delay:0,mode:'cruise',scale0:.5,scale1:.8,minActivity:.1,opacity:.65})}
+ ${obsFlightMarkup('retro-v2', vectorShip, {x0:1180,y0:260,x1:1010,y1:570,x2:610,y2:610,x3:430,y3:330,duration:33000,delay:4300,mode:'cruise',scale0:.35,scale1:.65,minActivity:.45,opacity:.52})}
+ ${obsOrbitMarkup('retro-blip', ` `, {cx:800,cy:460,rx:235,ry:235,duration:30000,phase:.15,scale0:.55,scale1:.95,minActivity:.05})}
+ ${obsFloatMarkup('retro-liss', ` `,
+ {cx:800,cy:460,ampX:45,ampY:25,duration:14000,phase:.42,minActivity:.7,opacity:.5})}`;
+ }
+
+ if (theme === 'military') {
+ const tri = ` `;
+ return `${defs}
+ ${obsFlightMarkup('mil-form', `
+
+ ${tri}
+ ${tri}
+ ${tri}
+ ${tri}
+ `, {x0:390,y0:620,x1:550,y1:410,x2:900,y2:290,x3:1160,y3:390,duration:28000,delay:800,mode:'cruise',scale0:.4,scale1:.7,minActivity:.15,opacity:.65})}
+ ${obsFlightMarkup('mil-hostile', ` `,
+ {x0:1190,y0:650,x1:1020,y1:520,x2:660,y2:430,x3:470,y3:240,duration:22000,delay:6200,mode:'arrival',scale0:.35,scale1:.6,minActivity:.45,opacity:.58})}
+ ${obsOrbitMarkup('mil-cap', ` `,
+ {cx:800,cy:470,rx:300,ry:185,duration:41000,phase:.5,scale0:.45,scale1:.75,minActivity:.65,opacity:.52})}`;
+ }
+
+ if (theme === 'deepspace') {
+ return `${defs}
+ ${obsFlightMarkup('deep-ship', obsSceneShip('shuttle','#e0e7ff','#818cf8'), {
+ x0:230,y0:570,x1:520,y1:500,x2:900,y2:350,x3:1370,y3:300,duration:52000,delay:0,mode:'cruise',scale0:.12,scale1:.28,minActivity:.2,opacity:.6
+ })}
+ ${obsFlightMarkup('deep-comet', ` `, {
+ x0:180,y0:700,x1:480,y1:520,x2:930,y2:230,x3:1490,y3:150,duration:36000,delay:7500,mode:'cruise',scale0:.55,scale1:.9,minActivity:.5,opacity:.52
+ })}
+ ${obsFloatMarkup('deep-planet', `
+
+
+
+
+ `, {cx:1200,cy:590,ampX:80,ampY:24,duration:95000,phase:.22,minActivity:.05,opacity:.55})}`;
+ }
+
+ if (theme === 'outlaw') {
+ return `${defs}
+ ${obsFlightMarkup('out-runner', `
+
+
+
+
+ `, {x0:300,y0:610,x1:480,y1:430,x2:940,y2:300,x3:1290,y3:410,duration:31000,delay:1400,mode:'cruise',scale0:.35,scale1:.75,minActivity:.1,opacity:.62})}
+ ${obsFlightMarkup('out-pursuer', obsSceneShip('scout','#f9a8d4','#8b5cf6'), {
+ x0:1280,y0:590,x1:1070,y1:520,x2:730,y2:520,x3:420,y3:310,duration:27000,delay:7900,mode:'arrival',scale0:.25,scale1:.52,minActivity:.5,opacity:.5
+ })}
+ ${obsFloatMarkup('out-needle', `
+
+
+
+
+ `, {cx:1250,cy:535,ampX:8,ampY:4,duration:9000,phase:.2,minActivity:.25,opacity:.58})}`;
+ }
+
+ if (theme === 'spacestations') {
+ const clipOpen = ``;
+ const clipClose = ` `;
+ return `${defs}${clipOpen}
+ ${obsFlightMarkup('st-arrival', obsSceneShip('shuttle','#e0f2fe','#38bdf8'), {
+ x0:270,y0:520,x1:500,y1:455,x2:920,y2:380,x3:1195,y3:415,duration:36000,delay:0,mode:'arrival',scale0:.16,scale1:.62,minActivity:.05,opacity:.75
+ })}
+ ${obsFlightMarkup('st-depart', obsSceneShip('tug','#bae6fd','#f97316'), {
+ x0:1180,y0:485,x1:1030,y1:430,x2:650,y2:310,x3:260,y3:255,duration:30000,delay:8400,mode:'depart',scale0:.58,scale1:.16,minActivity:.2,opacity:.68
+ })}
+ ${obsFlightMarkup('st-freighter', obsSceneShip('freighter','#cbd5e1','#0ea5e9'), {
+ x0:185,y0:255,x1:530,y1:210,x2:920,y2:230,x3:1420,y3:300,duration:52000,delay:3800,mode:'cruise',scale0:.26,scale1:.42,minActivity:.4,opacity:.55
+ })}
+ ${obsOrbitMarkup('st-hold', obsSceneShip('shuttle','#7dd3fc','#fdba74'), {
+ cx:800,cy:410,rx:290,ry:145,duration:43000,phase:.18,scale0:.18,scale1:.32,minActivity:.62,opacity:.5
+ })}
+ ${obsFlightMarkup('st-nearpass', obsSceneShip('freighter','#f1f5f9','#38bdf8'), {
+ x0:170,y0:650,x1:470,y1:565,x2:950,y2:535,x3:1450,y3:600,duration:42000,delay:14500,mode:'cruise',scale0:.42,scale1:.78,minActivity:.78,opacity:.48
+ })}
+ ${clipClose}`;
+ }
+
+ if (theme === 'comedy') {
+ return `${defs}
+ ${obsFlightMarkup('com-tour', `
+
+
+
+
+ `, {x0:260,y0:600,x1:520,y1:255,x2:1030,y2:670,x3:1350,y3:330,duration:36000,delay:1200,mode:'cruise',scale0:.32,scale1:.62,minActivity:.08,opacity:.65})}
+ ${obsOrbitMarkup('com-orbit', `? `,
+ {cx:800,cy:445,rx:330,ry:160,duration:33000,phase:.35,scale0:.65,scale1:1.15,minActivity:.45,opacity:.58})}
+ ${obsFloatMarkup('com-cube', ` `,
+ {cx:1120,cy:570,ampX:90,ampY:70,duration:18000,phase:.7,minActivity:.7,opacity:.52})}`;
+ }
+
+ return defs;
+ }
+
+ function registerObservationSceneEntities() {
+ observationSceneEntities = [];
+ if (!observationSimLayer) return;
+
+ observationSimLayer.querySelectorAll('[data-obs-kind]').forEach(el => {
+ const d = el.dataset;
+ const sceneNow = performance.now();
+ const common = {
+ el,
+ kind: d.obsKind,
+ minActivity: Number(d.minactivity || 0),
+ baseOpacity: Number(d.opacity || .7),
+ phase: Number(d.phase || 0),
+ startedAt: sceneNow,
+ inactiveUntil: sceneNow
+ };
+
+ if (d.obsKind === 'flight') {
+ const firstStart = sceneNow + Number(d.delay || 0);
+ observationSceneEntities.push({
+ ...common,
+ startedAt: firstStart,
+ inactiveUntil: firstStart,
+ p0:{x:Number(d.x0),y:Number(d.y0)},
+ p1:{x:Number(d.x1),y:Number(d.y1)},
+ p2:{x:Number(d.x2),y:Number(d.y2)},
+ p3:{x:Number(d.x3),y:Number(d.y3)},
+ duration:Number(d.duration || 30000),
+ mode:d.mode || 'cruise',
+ scale0:Number(d.scale0 || 1),
+ scale1:Number(d.scale1 || 1),
+ cycle:0
+ });
+ } else if (d.obsKind === 'orbit') {
+ observationSceneEntities.push({
+ ...common,
+ cx:Number(d.cx),cy:Number(d.cy),
+ rx:Number(d.rx),ry:Number(d.ry),
+ duration:Number(d.duration || 30000),
+ scale0:Number(d.scale0 || 1),
+ scale1:Number(d.scale1 || 1)
+ });
+ } else if (d.obsKind === 'float') {
+ observationSceneEntities.push({
+ ...common,
+ cx:Number(d.cx),cy:Number(d.cy),
+ ampX:Number(d.ampx || 0),ampY:Number(d.ampy || 0),
+ duration:Number(d.duration || 20000)
+ });
+ }
+ });
+ }
+
+ function observationSceneResetFlight(entity, now) {
+ entity.cycle += 1;
+ const restBase = 5500 - observationActivity * 3500;
+ entity.inactiveUntil = now + obsRand(restBase * .35, restBase * 1.35);
+ entity.startedAt = entity.inactiveUntil;
+ }
+
+ function updateObservationSceneEntity(entity, now, speedScale) {
+ const visibleByActivity = observationActivity + .001 >= entity.minActivity;
+ if (!visibleByActivity) {
+ entity.el.style.opacity = '0';
+ return;
+ }
+
+ if (entity.kind === 'flight') {
+ if (now < entity.inactiveUntil) {
+ entity.el.style.opacity = '0';
+ return;
+ }
+
+ const elapsed = (now - entity.startedAt) * speedScale;
+ let raw = elapsed / entity.duration;
+
+ if (raw >= 1) {
+ observationSceneResetFlight(entity, now);
+ entity.el.style.opacity = '0';
+ return;
+ }
+
+ raw = obsClamp(raw, 0, 1);
+ let t = raw;
+ if (entity.mode === 'arrival') t = obsEaseOutCubic(raw);
+ else if (entity.mode === 'depart') t = obsEaseInCubic(raw);
+ else t = obsEaseSmooth(raw);
+
+ const p = obsBezierPoint(t, entity.p0, entity.p1, entity.p2, entity.p3);
+ const v = obsBezierDerivative(t, entity.p0, entity.p1, entity.p2, entity.p3);
+ const angle = Math.atan2(v.y, v.x) * 180 / Math.PI;
+ const scale = entity.scale0 + (entity.scale1 - entity.scale0) * t;
+
+ const edgeFade = Math.min(1, raw / .08, (1-raw) / .08);
+ const opacity = entity.baseOpacity * obsClamp(edgeFade,0,1);
+ entity.el.style.opacity = String(opacity);
+ entity.el.setAttribute('transform', `translate(${p.x.toFixed(2)} ${p.y.toFixed(2)}) rotate(${angle.toFixed(2)}) scale(${scale.toFixed(3)})`);
+ return;
+ }
+
+ if (entity.kind === 'orbit') {
+ const cycle = ((now - entity.startedAt) * speedScale / entity.duration + entity.phase) % 1;
+ const a = cycle * Math.PI * 2;
+ const x = entity.cx + Math.cos(a) * entity.rx;
+ const y = entity.cy + Math.sin(a) * entity.ry;
+ const depth = (Math.sin(a) + 1) / 2;
+ const scale = entity.scale0 + (entity.scale1 - entity.scale0) * depth;
+ entity.el.style.opacity = String(entity.baseOpacity * (.55 + depth * .45));
+ entity.el.setAttribute('transform', `translate(${x.toFixed(2)} ${y.toFixed(2)}) scale(${scale.toFixed(3)})`);
+ return;
+ }
+
+ if (entity.kind === 'float') {
+ const cycle = ((now - entity.startedAt) * speedScale / entity.duration + entity.phase) * Math.PI * 2;
+ const x = entity.cx + Math.sin(cycle) * entity.ampX;
+ const y = entity.cy + Math.cos(cycle * .73) * entity.ampY;
+ entity.el.style.opacity = String(entity.baseOpacity);
+ entity.el.setAttribute('transform', `translate(${x.toFixed(2)} ${y.toFixed(2)})`);
+ }
+ }
+
+ function observationSceneFrame(now) {
+ observationSceneRaf = null;
+ if (!observationActive || !observationSimLayer) return;
+
+ const reduceMotion = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
+ const baseSpeed = reduceMotion ? .08 : (.32 + observationActivity * 1.18);
+ const boost = now < observationSceneBoostUntil ? .38 : 0;
+ const speedScale = baseSpeed + boost;
+
+ for (const entity of observationSceneEntities) {
+ updateObservationSceneEntity(entity, now, speedScale);
+ }
+
+ updateObservationSplineMorphs(now, speedScale);
+
+ observationSceneLast = now;
+ observationSceneRaf = requestAnimationFrame(observationSceneFrame);
+ }
+
+ function startObservationScene() {
+ stopObservationScene();
+ if (!observationSimLayer) return;
+ observationSimLayer.innerHTML = `${buildObservationSceneMarkup(activeUniverseId)} `;
+ registerObservationSceneEntities();
+ observationSceneLast = performance.now();
+ observationSceneRaf = requestAnimationFrame(observationSceneFrame);
+ }
+
+ function stopObservationScene() {
+ if (observationSceneRaf) {
+ cancelAnimationFrame(observationSceneRaf);
+ observationSceneRaf = null;
+ }
+ observationSceneEntities = [];
+ if (observationSimLayer) observationSimLayer.innerHTML = '';
+ }
+
+ function pulseObservationScene() {
+ if (!observationActive) return;
+ observationSceneBoostUntil = performance.now() + 1200 + observationActivity * 900;
+
+ const eligible = observationSceneEntities.filter(e => observationActivity + .001 >= e.minActivity);
+ if (!eligible.length) return;
+ const chosen = obsPick(eligible);
+
+ // Telemetry can wake a waiting scene object early without assigning the
+ // sound a literal fictional meaning.
+ if (chosen.kind === 'flight' && performance.now() < chosen.inactiveUntil && Math.random() < .55) {
+ const wakeNow = performance.now();
+ chosen.inactiveUntil = wakeNow;
+ chosen.startedAt = wakeNow;
+ }
+ }
+
+
+ function transientSvg(body) {
+ return `${body} `;
+ }
+
+ function spawnObservationTransient(body, lifetime = 4200) {
+ if (!observationActive || !observationEventLayer) return;
+ const node = document.createElement('div');
+ node.className = 'obs-transient';
+ node.dataset.serial = String(++observationTransientSerial);
+ node.style.setProperty('--obs-life', `${lifetime}ms`);
+ node.innerHTML = transientSvg(body);
+ observationEventLayer.appendChild(node);
+
+ const maxTransient = 5 + Math.round(observationActivity * 8);
+ while (observationEventLayer.children.length > maxTransient) {
+ observationEventLayer.firstElementChild.remove();
+ }
+
+ window.setTimeout(() => node.remove(), lifetime + 300);
+ }
+
+ function obsTextCard(x, y, title, line, color = 'var(--primary-accent)', width = 290) {
+ return `
+
+
+
+
+ ${title}
+ ${line}
+
+ `;
+ }
+
+ function obsExhibitX(width = 290) {
+ const leftMargin = 240;
+ const rightMargin = 240;
+ return obsInt(leftMargin, 1600 - rightMargin - width);
+ }
+
+ function obsExhibitY(height = 82) {
+ const topMargin = 220;
+ const bottomMargin = 190;
+ return obsInt(topMargin, 900 - bottomMargin - height);
+ }
+
+ function obsExhibitCardPosition(width = 290, height = 82) {
+ return { x: obsExhibitX(width), y: obsExhibitY(height) };
+ }
+
+ function obsContact(x, y, color = 'var(--primary-accent)', label = 'CONTACT') {
+ const size = obsInt(26, 54);
+ return `
+
+
+
+
+
+ ${label}
+
+ `;
+ }
+
+ function obsExpandingRing(x, y, color = 'var(--primary-accent)', radius = 70) {
+ return `
+
+
+
+ `;
+ }
+
+ function generateStarflightActivity(power) {
+ const effect = obsPick(['contact','vector','data','ring','diagnostic','streak']);
+ if (effect === 'contact') {
+ const x = obsInt(420,1180), y = obsInt(230,690);
+ return spawnObservationTransient(
+ obsContact(x,y,'var(--primary-accent)',`CONTACT ${obsInt(2,99).toString().padStart(2,'0')}`) +
+ ` `,
+ 4300
+ );
+ }
+ if (effect === 'vector') {
+ const y1=obsInt(260,680), y2=obsInt(220,690);
+ return spawnObservationTransient(`
+
+
+ `, 5200);
+ }
+ if (effect === 'data') {
+ const pos = obsExhibitCardPosition(290, 82);
+ return spawnObservationTransient(obsTextCard(
+ pos.x, pos.y,
+ obsPick(['SENSOR UPDATE','NAV SOLUTION','SYSTEM QUERY','PASSIVE ARRAY']),
+ obsPick([
+ `BEARING ${obsInt(0,359).toString().padStart(3,'0')}.${obsInt(0,9)}°`,
+ `RANGE ${obsInt(2,980)}.${obsInt(0,9)} Mm`,
+ `SUBSPACE VAR ${obsRand(.01,.99).toFixed(3)}`,
+ `VECTOR ${obsInt(100,999)}-${obsInt(10,99)}`
+ ])
+ ), 4200);
+ }
+ if (effect === 'diagnostic') {
+ const bars = Array.from({length: obsInt(5,9)}, (_,i) =>
+ ` `).join('');
+ return spawnObservationTransient(`${bars} `, 3500);
+ }
+ if (effect === 'streak') {
+ const lines = Array.from({length: obsInt(8,16)}, () => {
+ const y=obsInt(160,760), x=obsInt(150,1300), len=obsInt(70,240);
+ return ` `;
+ }).join('');
+ return spawnObservationTransient(`${lines} `, 3000);
+ }
+ return spawnObservationTransient(obsExpandingRing(obsInt(420,1180),obsInt(250,680),'var(--primary-accent)',obsInt(45,90)), 3200);
+ }
+
+ function generateWhataverseActivity(power) {
+ const effect = obsPick(['echo','glyphs','coordinate','warp','rings']);
+ if (effect === 'echo') {
+ const x=obsInt(430,1170), y=obsInt(270,650);
+ return spawnObservationTransient(`
+ ${[0,1,2].map(i=>`
+
+ `).join('')}
+ `, 4700);
+ }
+ if (effect === 'glyphs') {
+ const glyphs = Array.from({length: obsInt(4,9)}, (_,i) =>
+ `${obsPick(['◉','◎','⌁','∆','∴','∞','⊙','⊛'])} `
+ ).join('');
+ return spawnObservationTransient(glyphs, 6500);
+ }
+ if (effect === 'coordinate') {
+ const pos = obsExhibitCardPosition(290, 82);
+ return spawnObservationTransient(obsTextCard(
+ pos.x, pos.y, 'TEMPORAL COORDINATE',
+ `${obsInt(0,99)}:${obsInt(0,99)}:${obsInt(0,99)} ∴ ${obsInt(1,13)}.${obsInt(0,9)}`, '#00e5ff'
+ ), 4300);
+ }
+ if (effect === 'warp') {
+ return spawnObservationTransient(`
+
+
+
+
+
+ `, 4400);
+ }
+ return spawnObservationTransient(obsExpandingRing(800,450,obsPick(['#00e5ff','#d4af37']),obsInt(60,120)), 3500);
+ }
+
+ function generateIndustrialActivity(power) {
+ // v13co: every industrial transient is clipped to ONE feed's picture area.
+ // In v12 these drew in raw screen space, so steam puffs and signal bars
+ // spilled across bezels and the console furniture.
+ const feeds = [
+ {ix:290, iy:259, id:'CAM 01', where:'CARGO BAY 2'},
+ {ix:825, iy:259, id:'CAM 02', where:'REACTOR ACCESS'},
+ {ix:290, iy:483, id:'CAM 03', where:'AIRLOCK C'},
+ {ix:825, iy:483, id:'CAM 04', where:'MACHINE CORRIDOR'}
+ ];
+ const f = obsPick(feeds);
+ const W = 485, H = 189;
+ const clipId = `indFx-${Math.random().toString(36).slice(2, 9)}`;
+ const inFeed = (body) => `
+
+
+
+
+
+
+
+
+ ${body} `;
+
+ const canCycle = (f.id === 'CAM 01' || f.id === 'CAM 03');
+ const effect = obsPick(['signal', 'dropout', 'vapour', 'motion', 'gain', 'warning']
+ .concat(canCycle ? ['door'] : []));
+
+ // Digital break-up: displaced scan blocks plus a recalibration caption.
+ if (effect === 'signal') {
+ const blocks = Array.from({length: obsInt(9, 15)}, () => {
+ const by = obsInt(0, H - 14);
+ return ` `;
+ }).join('');
+ return spawnObservationTransient(inFeed(`
+
+ ${blocks}
+
+ SIGNAL RECALIBRATION
+ `), 3600);
+ }
+
+ // Total feed loss, then reacquire.
+ if (effect === 'dropout') {
+ return spawnObservationTransient(inFeed(`
+
+
+ NO SIGNAL
+ ${f.id} // REACQUIRING
+
+ `), 2600);
+ }
+
+ // A vent release inside the room the camera is actually looking at.
+ if (effect === 'vapour') {
+ const x = obsInt(90, W - 90), y = H - obsInt(16, 40);
+ const puffs = Array.from({length: obsInt(6, 11)}, (_, i) => {
+ const dur = obsRand(2.8, 5.2).toFixed(1);
+ return ` `;
+ }).join('');
+ return spawnObservationTransient(inFeed(`
+ ${puffs}
+
+ `), 6200);
+ }
+
+ // Motion detection bracket that tracks across the picture.
+ if (effect === 'motion') {
+ const bx = obsInt(40, W - 190), by = obsInt(46, H - 130);
+ return spawnObservationTransient(inFeed(`
+
+
+
+
+
+
+
+ MOTION // ${f.id}
+ `), 4600);
+ }
+
+ // Pressure-door cycle seen head on.
+ if (effect === 'door') {
+ const mid = W / 2, top = 58, bot = H - 52;
+ return spawnObservationTransient(inFeed(`
+
+
+
+
+
+
+
+
+
+
+ HATCH CYCLE // ${f.where}
+ `), 4800);
+ }
+
+ // Auto-iris hunting after a lighting change.
+ if (effect === 'gain') {
+ return spawnObservationTransient(inFeed(`
+
+ AGC // IRIS ADJUST
+ `), 2400);
+ }
+
+ // Plant readout card — stays on the console rail, not over a feed.
+ const pos = obsExhibitCardPosition(290, 82);
+ return spawnObservationTransient(obsTextCard(
+ pos.x, pos.y,
+ obsPick(['PRESSURE TRANSIENT', 'COOLANT LOOP', 'MAINTENANCE BUS', 'LOCAL POWER', 'GANTRY INTERLOCK']),
+ obsPick([
+ `P-${obsInt(1, 9)} ${obsInt(30, 98)}%`,
+ `FLOW ${obsInt(120, 980)} L/M`,
+ `BUS ${obsHex(obsInt(100, 999))} NOMINAL`,
+ `VALVE ${obsInt(1, 44)} CYCLING`,
+ `HOIST ${obsInt(1, 4)} LOAD ${obsRand(.4, 4.2).toFixed(1)} T`
+ ]),
+ '#ffaa00'
+ ), 4500);
+ }
+
+ function generateBioshipActivity(power) {
+ const effect = obsPick(['neural','spores','ripple','tendril','organ','metric']);
+ if (effect === 'neural') {
+ const y1=obsInt(260,650), y2=obsInt(260,650);
+ return spawnObservationTransient(`
+
+ `, 3300);
+ }
+ if (effect === 'spores') {
+ const spores=Array.from({length:obsInt(10,24)},()=>`
+ `).join('');
+ return spawnObservationTransient(spores, 7000);
+ }
+ if (effect === 'tendril') {
+ const sx=obsPick([180,1420]), sy=obsInt(300,660);
+ return spawnObservationTransient(`
+
+ `, 5200);
+ }
+ if (effect === 'organ') {
+ return spawnObservationTransient(`
+
+
+
+
+
+
+ `, 5000);
+ }
+ if (effect === 'metric') {
+ const pos = obsExhibitCardPosition(290, 82);
+ return spawnObservationTransient(obsTextCard(
+ pos.x, pos.y, obsPick(['NEURAL RESPONSE','METABOLIC FIELD','SYMBIOTIC VARIANCE','BIOELECTRIC FLUX']),
+ `${obsRand(0.1,9.9).toFixed(2)} // ${obsInt(20,99)}%`, '#10b981'
+ ), 4300);
+ }
+ return spawnObservationTransient(obsExpandingRing(800,470,obsPick(['#10b981','#8b5cf6','#a7f3d0']),obsInt(55,110)), 3700);
+ }
+
+ function generateRetroActivity(power) {
+ const effect = obsPick(['blip','scope','counter','vector','bloom','reel']);
+ if (effect === 'blip') {
+ return spawnObservationTransient(
+ obsContact(obsInt(470,1130),obsInt(260,660),'#86efac',`OBJ ${obsInt(1,99)}`), 4300
+ );
+ }
+ if (effect === 'scope') {
+ const y=obsInt(560,720);
+ return spawnObservationTransient(`
+
+ `, 4500);
+ }
+ if (effect === 'counter') {
+ const pos = obsExhibitCardPosition(290, 82);
+ return spawnObservationTransient(obsTextCard(
+ pos.x, pos.y, obsPick(['ASTROGATOR','VECTOR MEMORY','TAPE INDEX','OBJECT FILE']),
+ `${obsInt(0,9999).toString().padStart(4,'0')} / ${obsInt(0,9999).toString().padStart(4,'0')}`,'#22c55e'
+ ),4300);
+ }
+ if (effect === 'reel') {
+ return spawnObservationTransient(`
+
+
+
+ `, 4800);
+ }
+ if (effect === 'bloom') {
+ return spawnObservationTransient(obsExpandingRing(obsInt(480,1120),obsInt(270,650),'#86efac',obsInt(35,75)),3000);
+ }
+ return spawnObservationTransient(`
+
+ `, 5200);
+ }
+
+ function generateMilitaryActivity(power) {
+ const effect = obsPick(['contact','intercept','formation','sector','status','sweep']);
+ if (effect === 'contact') {
+ const pos = obsExhibitCardPosition(320, 82);
+ return spawnObservationTransient(
+ obsContact(obsInt(430,1170),obsInt(240,680),'#eab308',obsPick(['UNKNOWN','TRACK','CONTACT','RETURN'])) +
+ obsTextCard(pos.x,pos.y,'TACTICAL UPDATE',`TRACK ${obsHex(obsInt(1,4095))} // ${obsInt(2,98)}%`,'#eab308',320),
+ 4600
+ );
+ }
+ if (effect === 'intercept') {
+ const x=obsInt(450,1150),y=obsInt(250,650);
+ return spawnObservationTransient(`
+
+ ${obsContact(x,y,'#ef4444','VECTOR')}
+ `, 4700);
+ }
+ if (effect === 'formation') {
+ const cx=obsInt(600,1000), cy=obsInt(340,600);
+ return spawnObservationTransient(`
+
+
+
+
+
+
+
+ `, 5200);
+ }
+ if (effect === 'sector') {
+ return spawnObservationTransient(`
+
+ `, 3800);
+ }
+ if (effect === 'status') {
+ const pos = obsExhibitCardPosition(290, 82);
+ return spawnObservationTransient(obsTextCard(
+ pos.x,pos.y,obsPick(['TACTICAL ARRAY','TRACK CORRELATION','FORMATION UPDATE','IFF QUERY']),
+ obsPick([`SECTOR ${obsInt(1,12)} / CLEAR`,`RETURN ${obsHex(obsInt(1,4095))}`,`VECTOR ${obsInt(0,359)}.${obsInt(0,9)}°`,`CONF ${obsInt(34,99)}%`]),'#eab308'
+ ),4200);
+ }
+ return spawnObservationTransient(obsExpandingRing(800,470,'#eab308',obsInt(65,130)),3500);
+ }
+
+ function generateDeepSpaceActivity(power) {
+ const effect = obsPick(['anomaly','comet','lens','spectral','planet','signal']);
+ if (effect === 'anomaly') {
+ const x=obsInt(430,1180), y=obsInt(230,650);
+ return spawnObservationTransient(`
+
+
+
+
+
+
+ `, 6500);
+ }
+ if (effect === 'comet') {
+ return spawnObservationTransient(`
+
+
+
+ `, 5200);
+ }
+ if (effect === 'lens') {
+ return spawnObservationTransient(`
+
+
+
+ `, 6800);
+ }
+ if (effect === 'planet') {
+ const y=obsInt(260,620), r=obsInt(45,110);
+ return spawnObservationTransient(`
+
+
+
+
+
+ `, 10000);
+ }
+ if (effect === 'spectral') {
+ const pos = obsExhibitCardPosition(290, 82);
+ return spawnObservationTransient(obsTextCard(
+ pos.x,pos.y,obsPick(['SPECTRAL EVENT','GRAVITIC VARIANCE','DEEP FIELD RETURN','OPTICAL TRANSIENT']),
+ `${obsRand(.001,9.999).toFixed(3)} / ${obsInt(1,999)} ly`,'#818cf8'
+ ),4800);
+ }
+ return spawnObservationTransient(obsExpandingRing(obsInt(420,1180),obsInt(240,660),'#38bdf8',obsInt(45,95)),4000);
+ }
+
+ function generateOutlawActivity(power) {
+ const effect = obsPick(['glitch','route','contact','signal','gauge','rear']);
+ if (effect === 'glitch') {
+ const bars=Array.from({length:obsInt(8,18)},()=>`
+ `).join('');
+ return spawnObservationTransient(`${bars} `,3000);
+ }
+ if (effect === 'route') {
+ return spawnObservationTransient(`
+
+ ROUTE RECALC...
+ `,5600);
+ }
+ if (effect === 'contact' || effect === 'rear') {
+ return spawnObservationTransient(obsContact(
+ obsInt(430,1170),obsInt(240,670),obsPick(['#ec4899','#f59e0b']),'UNKNOWN'
+ ),4200);
+ }
+ if (effect === 'signal') {
+ const pos = obsExhibitCardPosition(290, 82);
+ return spawnObservationTransient(obsTextCard(
+ pos.x,pos.y,obsPick(['TRANSPONDER HIT','SIGNAL QUALITY','NAV PATCH','RADIO BURST']),
+ `${obsInt(9,99)}% // ${obsHex(obsInt(1,4095))}`,'#ec4899'
+ ),4300);
+ }
+ return spawnObservationTransient(`
+
+
+
+ `,4500);
+ }
+
+ function generateStationActivity(power) {
+ const effect = obsPick(['dock','depart','guidance','traffic','beacon','queue']);
+ if (effect === 'dock') {
+ const y = obsInt(250, 520);
+ return spawnObservationTransient(`
+
+
+
+ DOCK VECTOR ${obsInt(1,18)}
+ `, 6200);
+ }
+ if (effect === 'depart') {
+ const y = obsInt(235, 540);
+ return spawnObservationTransient(`
+
+
+
+
+
+
+
+
+ `, 7000);
+ }
+ if (effect === 'guidance') {
+ const x = obsInt(470, 1100), y = obsInt(230, 570);
+ return spawnObservationTransient(`
+
+
+
+
+ `, 4600);
+ }
+ if (effect === 'beacon') {
+ const x = obsPick([248, 1352]);
+ const y = obsPick([168, 648]);
+ return spawnObservationTransient(`
+
+
+
+ `, 3600);
+ }
+ if (effect === 'queue') {
+ const pos = obsExhibitCardPosition(310, 82);
+ return spawnObservationTransient(obsTextCard(
+ pos.x, pos.y,
+ obsPick(['TRAFFIC CONTROL','DOCK STATUS','PORT AUTHORITY','BERTH SCHEDULER']),
+ obsPick([
+ `QUEUE ${obsInt(2,18)} // CLEARANCE`,
+ `BAY ${obsInt(1,24)} // ARRIVAL WINDOW`,
+ `HOLD ARC ${obsRand(0.8,9.9).toFixed(1)} // ROUTING`,
+ `${obsInt(2,84)} OBJECTS // PORT FLOW`
+ ]),
+ '#38bdf8', 310
+ ), 4600);
+ }
+ const x = obsInt(330, 1270), y = obsInt(205, 610);
+ return spawnObservationTransient(`
+
+ TRAFFIC FLOW ${obsInt(1,9)}
+ `, 5200);
+ }
+
+ function generateComedyActivity(power) {
+ const effect = obsPick(['route','oddity','planet','status','contact','geometry']);
+ if (effect === 'route') {
+ return spawnObservationTransient(`
+
+ `,6000);
+ }
+ if (effect === 'oddity') {
+ const pos = obsExhibitCardPosition(330, 82);
+ return spawnObservationTransient(obsTextCard(
+ pos.x,pos.y,
+ obsPick(['NAVIGATION UPDATE','ENVIRONMENTAL NOTE','SERVICE MESSAGE','SENSOR CONCLUSION']),
+ obsPick([
+ `ROUTE CONFIDENCE ${obsRand(96,103).toFixed(1)}%`,
+ `TEA PROBABILITY ${obsInt(12,99)}%`,
+ `MOSTLY NOMINAL`,
+ `OBJECT: PROBABLY HARMLESS`,
+ `PANIC INDEX ${obsInt(0,3)} / 10`,
+ `SCENIC DETOUR +${obsInt(2,48)} MIN`
+ ]),'#06b6d4',330
+ ),5000);
+ }
+ if (effect === 'planet') {
+ return spawnObservationTransient(`
+
+
+
+
+
+ `,9200);
+ }
+ if (effect === 'geometry') {
+ const sides=obsPick([3,4,5,6]);
+ const r=obsInt(55,110), cx=obsInt(500,1100),cy=obsInt(300,620);
+ const pts=Array.from({length:sides},(_,i)=>{
+ const a=-Math.PI/2+i*Math.PI*2/sides;
+ return `${(cx+Math.cos(a)*r).toFixed(1)},${(cy+Math.sin(a)*r).toFixed(1)}`;
+ }).join(' ');
+ return spawnObservationTransient(` `,5200);
+ }
+ if (effect === 'contact') {
+ return spawnObservationTransient(obsContact(obsInt(450,1150),obsInt(250,650),'#67e8f9',obsPick(['FRIENDLY?','THING','OBJECT','PROBABLY FINE'])),4300);
+ }
+ return spawnObservationTransient(obsExpandingRing(obsInt(450,1150),obsInt(250,650),obsPick(['#06b6d4','#f43f5e','#fbbf24']),obsInt(45,100)),3500);
+ }
+
+ function triggerObservationActivity(source = 'ambient') {
+ if (!observationActive || observationActivity <= 0.001) return;
+
+ // Telemetry is a synchronization pulse, but the slider controls whether
+ // that pulse becomes visible. At maximum every telemetry pulse produces
+ // a visible response; at low levels many pass quietly.
+ if (source === 'telemetry') {
+ const responseChance = 0.18 + observationActivity * 0.82;
+ if (Math.random() > responseChance) return;
+ }
+
+ let count = 1;
+ if (source === 'telemetry' && observationActivity > .55 && Math.random() < observationActivity) count++;
+ if (observationActivity > .82 && Math.random() < (observationActivity - .7)) count++;
+
+ const generator = {
+ starfleet: generateStarflightActivity,
+ whataverse: generateWhataverseActivity,
+ industrial: generateIndustrialActivity,
+ bioships: generateBioshipActivity,
+ retrofuture: generateRetroActivity,
+ military: generateMilitaryActivity,
+ deepspace: generateDeepSpaceActivity,
+ outlaw: generateOutlawActivity,
+ spacestations: generateStationActivity,
+ comedy: generateComedyActivity
+ }[activeUniverseId] || generateStarflightActivity;
+
+ for (let i = 0; i < count; i++) {
+ window.setTimeout(() => generator(observationActivity), i * obsInt(120,380));
+ }
+ }
+
+ function scheduleObservationAmbientActivity() {
+ if (observationAmbientTimer) {
+ clearTimeout(observationAmbientTimer);
+ observationAmbientTimer = null;
+ }
+ if (!observationActive || observationActivity <= .01) return;
+
+ // Quiet = occasional subtle life. Active = a busy generative display.
+ const slow = 12500;
+ const fast = 1500;
+ const base = slow - (slow - fast) * Math.pow(observationActivity, .82);
+ const jitter = base * obsRand(.25,.75);
+
+ observationAmbientTimer = window.setTimeout(() => {
+ triggerObservationActivity('ambient');
+ scheduleObservationAmbientActivity();
+ }, base + jitter);
+ }
+
+ function setObservationActivity(raw) {
+ observationActivity = obsClamp(Number(raw) / 100, 0, 1);
+ window.observationActivity = observationActivity;
+ observationOverlay.style.setProperty('--obs-activity', String(observationActivity));
+ const pct = Math.round(observationActivity * 100);
+ valObservationActivity.textContent = `${pct}%`;
+ if (observationActive) {
+ observationStatus.innerHTML =
+ `OBSERVATION ACTIVE PROCEDURAL AUDIO LINK: ${isPlaying ? 'ONLINE' : 'STANDBY'} VISUAL ACTIVITY: ${pct}%`;
+ scheduleObservationAmbientActivity();
+ }
+ }
+
+ function resetObservationReturnFade() {
+ if (!observationReturn) return;
+
+ observationReturn.style.transition = 'none';
+ observationReturn.classList.remove('observation-return-fade');
+ void observationReturn.offsetWidth;
+ observationReturn.style.transition = '';
+
+ requestAnimationFrame(() => {
+ requestAnimationFrame(() => {
+ if (observationActive) {
+ observationReturn.classList.add('observation-return-fade');
+ }
+ });
+ });
+ }
+
+ function resetObservationHudFade() {
+ if (!observationChrome) return;
+
+ // Remove transition momentarily so each OBSERVATION session starts fully visible.
+ observationChrome.style.transition = 'none';
+ observationChrome.classList.remove('observation-hud-fade');
+ void observationChrome.offsetWidth;
+ observationChrome.style.transition = '';
+
+ if (!toggleObservationHud.checked) {
+ // Start the thirty-second fade on the next frame.
+ requestAnimationFrame(() => {
+ requestAnimationFrame(() => {
+ if (observationActive && !toggleObservationHud.checked) {
+ observationChrome.classList.add('observation-hud-fade');
+ }
+ });
+ });
+ }
+ }
+
+ function updateObservationHudMode() {
+ const hold = toggleObservationHud.checked;
+ valObservationHud.textContent = hold ? 'ON' : 'FADE';
+
+ if (!observationActive || !observationChrome) return;
+
+ if (hold) {
+ observationChrome.style.transition = 'none';
+ observationChrome.classList.remove('observation-hud-fade');
+ void observationChrome.offsetWidth;
+ observationChrome.style.transition = '';
+ } else {
+ resetObservationHudFade();
+ }
+ }
+
+ toggleObservationHud.addEventListener('change', updateObservationHudMode);
+ updateObservationHudMode();
+
+ sliderObservationActivity.addEventListener('input', () => {
+ contractAdapter.applyMutation('view.activity', Number(sliderObservationActivity.value) / 100, 'ui');
+ });
+
+ window.addEventListener('scifi-telemetry-activity', (event) => {
+ if (!observationActive) return;
+ triggerObservationActivity('telemetry');
+ nudgeObservationInstruments(event.detail ? event.detail.density : null);
+ pulseObservationScene();
+ pulseObservationSplineMorphs();
+ });
+
+ setObservationActivity(sliderObservationActivity.value);
+
+
+ // =========================================================================
+ // LIVE OBSERVATION INSTRUMENTATION
+ // The fixed OBSERVATION chrome is intentionally outside this system.
+ // Only fantasy elements inside the main SVG/event layers participate.
+ // =========================================================================
+
+ let observationInstrumentStates = [];
+ let observationInstrumentTimer = null;
+ let observationTransientObserver = null;
+
+ const observationTextVocabulary = {
+ starfleet: ['NOMINAL','PASSIVE','SCANNING','CORRELATING','TRACKING','VERIFIED','STABLE'],
+ whataverse: ['VORTEX LOCK','CONVERGING','DRIFTING','RECURSIVE','RESOLVING','TEMPORAL HOLD'],
+ industrial: ['SECURE FEED','MONITORING','SIGNAL HOLD','CYCLING','LOCAL CHECK','STABLE'],
+ bioships: ['QUIESCENT','RESPONDING','ADAPTING','RESONANT','SYNCHRONIZED','PULSING'],
+ retrofuture: ['READY','READ','PROCESS','HOLD','TRACK','STABLE'],
+ military: ['PASSIVE','TRACKING','CORRELATING','VERIFIED','WATCH','CLEAR'],
+ deepspace: ['DEEP FIELD','OBSERVING','SPECTRAL HOLD','PASSIVE','RESOLVING','STABLE'],
+ outlaw: ['MANUAL','INTERMITTENT','PATCHED','SEARCHING','HOLDING','DIRTY LOCK'],
+ spacestations: ['PASSIVE WATCH','TRAFFIC HOLD','DOCKING','ROUTING','OBSERVING','CLEAR'],
+ comedy: ['PROBABLY FINE','MOSTLY NOMINAL','STILL FINE','SCENIC','TEA ADJACENT','UNNECESSARILY PRECISE']
+ };
+
+ const observationPlainTextVariants = {
+ comedy: {
+ 'TEA SOURCE DETECTED': ['TEA SOURCE DETECTED','TEA SOURCE PROBABLE','TEA SOURCE LOST','BEVERAGE VECTOR ACQUIRED'],
+ 'NO IMMEDIATE CAUSE FOR PANIC': ['NO IMMEDIATE CAUSE FOR PANIC','STILL NO CAUSE FOR PANIC','PANIC NOT CURRENTLY REQUIRED','SITUATION MOSTLY NORMAL'],
+ 'SCENIC NAVIGATION': ['SCENIC NAVIGATION','SCENIC RECALCULATION','ROUTE APPRECIATION','NAVIGATION, BUT NICER']
+ }
+ };
+
+ function getObservationTelemetryDensity() {
+ if (typeof telemetry !== 'undefined' && telemetry && telemetry.params) {
+ return obsClamp(Number(telemetry.params.density) || 0, 0, 1);
+ }
+ const slider = document.getElementById('slider-telemetry-density');
+ return slider ? obsClamp(Number(slider.value) || 0, 0, 1) : 0.5;
+ }
+
+ function classifyObservationNumber(source, raw, index) {
+ const upper = source.toUpperCase();
+ const decimals = raw.includes('.') ? raw.split('.')[1].length : 0;
+ const unsignedRaw = raw.replace(/^[-+]/, '');
+ const integerPart = unsignedRaw.split('.')[0];
+ const leadingZero = integerPart.length > 1 && integerPart.startsWith('0');
+ const resolution = Math.pow(10, -decimals);
+ const base = Number(raw);
+
+ let kind = 'generic';
+ let min = -Infinity;
+ let max = Infinity;
+ let circular = false;
+ let macroScale = Math.max(resolution * 2, Math.abs(base || 1) * 0.025);
+
+ const after = source.slice(index + raw.length, index + raw.length + 4);
+ const before = source.slice(Math.max(0, index - 24), index).toUpperCase();
+
+ if (after.includes('%') || upper.includes('QUALITY') || upper.includes('CONFIDENCE') || upper.includes('EFFICIENCY')) {
+ kind = 'percent';
+ min = 0;
+ max = upper.includes('CONFIDENCE') ? 103 : 100;
+ macroScale = 8;
+ } else if (upper.includes('NAV VECTOR') || upper.includes('BEARING') || upper.includes('HEADING') || upper.includes('VECTOR ')) {
+ kind = 'heading';
+ min = 0;
+ max = 360;
+ circular = true;
+ macroScale = 18;
+ } else if (upper.includes('CAM ') || upper.includes('CONTACT ') || upper.includes('TRACK ') ||
+ upper.includes('BAY ') || upper.includes('SECTOR ') || upper.includes('OBJECT ') ||
+ before.endsWith('CAM ') || before.endsWith('TRACK ')) {
+ kind = 'identifier';
+ min = 0;
+ max = leadingZero ? Math.pow(10, integerPart.length) - 1 : Math.max(99, base + 24);
+ macroScale = Math.max(1, Math.min(4, 1 + base * .02));
+ } else if (upper.includes('RANGE') || upper.includes('DIST') || upper.includes(' LY') || upper.includes(' MM')) {
+ kind = 'range';
+ min = 0;
+ max = Math.max(base * 3, base + 1000);
+ macroScale = Math.max(resolution * 3, Math.abs(base || 1) * .18);
+ } else if (upper.includes('TIME') || upper.includes(':')) {
+ kind = 'clockish';
+ min = 0;
+ max = decimals ? 99.9 : 99;
+ macroScale = decimals ? 2.5 : 5;
+ } else if (base >= 0 && base <= 100) {
+ min = 0;
+ max = 100;
+ macroScale = Math.max(resolution * 3, 4);
+ }
+
+ return {
+ raw,
+ base,
+ current: base,
+ target: base,
+ decimals,
+ integerWidth: integerPart.length,
+ leadingZero,
+ resolution,
+ kind,
+ min,
+ max,
+ circular,
+ macroScale,
+ flutterOffset: 0,
+ nextFlutterAt: performance.now() + obsRand(350, 1400),
+ correctionKick: 0,
+ correctionUntil: 0
+ };
+ }
+
+ function getStatusMutationRule(source) {
+ const text = source.trim();
+ const upper = text.toUpperCase();
+
+ // Camera location names are identities, not status fields.
+ if (/^CAM\s+\d+\s*\/\//i.test(text)) return null;
+
+ if (text.includes('//')) {
+ const splitAt = text.lastIndexOf('//');
+ const prefix = text.slice(0, splitAt + 2).trimEnd();
+ const suffix = text.slice(splitAt + 2).trim();
+
+ // These are display/status phrases and can safely wander.
+ if (/(PASSIVE|NOMINAL|ACTIVE|OBSERVATION|WATCH|FEED|SENSORS|SECURE|FIELD|ARRAY|CONTROL)/i.test(suffix) ||
+ /(SENSOR|VECTOR|TACTICAL|OPTICAL|BIOLOGICAL|TRAFFIC|LIVE)/i.test(prefix)) {
+ return { type: 'suffix', prefix, original: suffix };
+ }
+ }
+
+ // A few colon-delimited fantasy statuses.
+ const colonMatch = text.match(/^(ROUTE|TRANSPONDER)\s*:\s*(.+)$/i);
+ if (colonMatch) {
+ return { type: 'colon', prefix: colonMatch[1].toUpperCase(), original: colonMatch[2].trim() };
+ }
+
+ const plain = observationPlainTextVariants[activeUniverseId];
+ if (plain && plain[upper]) {
+ return { type: 'plain', original: upper, variants: plain[upper] };
+ }
+
+ return null;
+ }
+
+ function instrumentObservationTextNode(node) {
+ if (!node || node.nodeType !== 1 || node.dataset.obsLiveInstrumented === '1') return;
+
+ const source = (node.textContent || '').trim();
+ if (!source) return;
+
+ const numericMatches = [...source.matchAll(/[-+]?\d+(?:\.\d+)?/g)];
+ const numbers = numericMatches.map(m => classifyObservationNumber(source, m[0], m.index));
+
+ const state = {
+ node,
+ source,
+ numbers,
+ numberMatches: numericMatches.map(m => ({ raw: m[0], index: m.index })),
+ statusRule: getStatusMutationRule(source),
+ statusOverride: null,
+ statusUntil: 0,
+ nextTextGlitchAt: performance.now() + obsRand(2500, 9000),
+ glitchUntil: 0,
+ glitchSeed: 0
+ };
+
+ node.dataset.obsLiveInstrumented = '1';
+ node.classList.add('obs-live-instrument');
+ observationInstrumentStates.push(state);
+ }
+
+ function instrumentObservationTree(root) {
+ if (!root || !root.querySelectorAll) return;
+ if (root.matches && root.matches('text')) instrumentObservationTextNode(root);
+ root.querySelectorAll('text').forEach(instrumentObservationTextNode);
+ }
+
+ function initializeObservationLiveInstrumentation() {
+ stopObservationLiveInstrumentation(false);
+ observationInstrumentStates = [];
+
+ instrumentObservationTree(observationStage);
+ instrumentObservationTree(observationEventLayer);
+
+ observationTransientObserver = new MutationObserver((mutations) => {
+ for (const mutation of mutations) {
+ mutation.addedNodes.forEach(node => {
+ if (node.nodeType === 1) instrumentObservationTree(node);
+ });
+ }
+ });
+ if (observationEventLayer) observationTransientObserver.observe(observationEventLayer, { childList: true, subtree: true });
+
+ scheduleObservationInstrumentTick();
+ }
+
+ function stopObservationLiveInstrumentation(clearStates = true) {
+ if (observationInstrumentTimer) {
+ clearTimeout(observationInstrumentTimer);
+ observationInstrumentTimer = null;
+ }
+ if (observationTransientObserver) {
+ observationTransientObserver.disconnect();
+ observationTransientObserver = null;
+ }
+ if (clearStates) observationInstrumentStates = [];
+ }
+
+ function formatObservationNumber(value, token) {
+ let v = value;
+
+ if (token.circular) {
+ const span = token.max - token.min;
+ v = ((v - token.min) % span + span) % span + token.min;
+ } else {
+ if (Number.isFinite(token.min)) v = Math.max(token.min, v);
+ if (Number.isFinite(token.max)) v = Math.min(token.max, v);
+ }
+
+ // Identity-like fields stay integer but can still "flutter" by one count.
+ if (token.kind === 'identifier') v = Math.round(v);
+
+ let result = token.decimals > 0 ? v.toFixed(token.decimals) : String(Math.round(v));
+
+ if (token.leadingZero) {
+ const sign = result.startsWith('-') ? '-' : '';
+ const unsigned = result.replace(/^-/, '');
+ const parts = unsigned.split('.');
+ parts[0] = parts[0].padStart(token.integerWidth, '0');
+ result = sign + parts.join('.');
+ }
+ return result;
+ }
+
+ function shortestCircularDelta(current, target, min, max) {
+ const span = max - min;
+ let delta = target - current;
+ if (delta > span / 2) delta -= span;
+ if (delta < -span / 2) delta += span;
+ return delta;
+ }
+
+ function rebuildObservationText(state, now) {
+ if (!state.node || !state.node.isConnected) return;
+
+ let cursor = 0;
+ let built = '';
+
+ state.numberMatches.forEach((match, i) => {
+ const token = state.numbers[i];
+ built += state.source.slice(cursor, match.index);
+
+ let displayValue = token.current + token.flutterOffset;
+
+ if (token.correctionUntil > now) {
+ const remaining = (token.correctionUntil - now) / 1400;
+ displayValue += token.correctionKick * Math.max(0, Math.min(1, remaining));
+ }
+
+ built += formatObservationNumber(displayValue, token);
+ cursor = match.index + match.raw.length;
+ });
+
+ built += state.source.slice(cursor);
+
+ if (state.statusOverride && state.statusUntil > now && state.statusRule) {
+ const rule = state.statusRule;
+ if (rule.type === 'suffix') {
+ const splitAt = built.lastIndexOf('//');
+ if (splitAt >= 0) built = `${built.slice(0, splitAt + 2)} ${state.statusOverride}`;
+ } else if (rule.type === 'colon') {
+ const colonAt = built.indexOf(':');
+ if (colonAt >= 0) built = `${built.slice(0, colonAt + 1)} ${state.statusOverride}`;
+ } else if (rule.type === 'plain') {
+ built = state.statusOverride;
+ }
+ } else if (state.statusOverride && state.statusUntil <= now) {
+ state.statusOverride = null;
+ }
+
+ // Very brief display corruption. This is visual noise only and always recovers.
+ if (state.glitchUntil > now && built.length > 3) {
+ const chars = [...built];
+ const candidates = [];
+ for (let i = 1; i < chars.length - 1; i++) {
+ if (/[A-Z0-9]/i.test(chars[i])) candidates.push(i);
+ }
+ if (candidates.length) {
+ const idx = candidates[state.glitchSeed % candidates.length];
+ chars[idx] = obsPick(['_','?','·','Ξ','0','1']);
+ built = chars.join('');
+ state.node.classList.add('obs-live-glitch');
+ }
+ } else {
+ state.node.classList.remove('obs-live-glitch');
+ }
+
+ state.node.textContent = built;
+ }
+
+ function updateObservationNumberToken(token, now) {
+ const activity = observationActivity;
+
+ let delta = token.circular
+ ? shortestCircularDelta(token.current, token.target, token.min, token.max)
+ : token.target - token.current;
+
+ // Activity controls how quickly the displayed instrument tracks its target.
+ const trackingFraction = 0.07 + activity * 0.31;
+ if (Math.abs(delta) > token.resolution * .15) {
+ token.current += delta * trackingFraction;
+ } else {
+ token.current = token.target;
+ }
+
+ // Micro flutter: the least-significant digit hunts around the real value.
+ if (now >= token.nextFlutterAt) {
+ const flutterChance = 0.34 + activity * 0.56;
+ if (Math.random() < flutterChance) {
+ const direction = obsPick([-1, 1]);
+ const steps = Math.random() < (.18 + activity * .2) ? 2 : 1;
+ const identifierDamping = token.kind === 'identifier' ? .55 : 1;
+ token.flutterOffset = direction * token.resolution * steps * identifierDamping;
+ } else {
+ token.flutterOffset = 0;
+ }
+
+ const slow = 1850;
+ const fast = 240;
+ token.nextFlutterAt = now + (slow - (slow-fast) * activity) * obsRand(.65, 1.35);
+ } else if (Math.random() < .16 + activity * .18) {
+ token.flutterOffset *= .45;
+ if (Math.abs(token.flutterOffset) < token.resolution * .15) token.flutterOffset = 0;
+ }
+
+ if (token.circular) {
+ const span = token.max - token.min;
+ token.current = ((token.current - token.min) % span + span) % span + token.min;
+ } else {
+ if (Number.isFinite(token.min)) token.current = Math.max(token.min, token.current);
+ if (Number.isFinite(token.max)) token.current = Math.min(token.max, token.current);
+ }
+ }
+
+ function maybeGlitchObservationText(state, now) {
+ if (now < state.nextTextGlitchAt) return;
+
+ const activity = observationActivity;
+ const density = getObservationTelemetryDensity();
+ const chance = .06 + activity * .18 + density * .05;
+
+ if (Math.random() < chance) {
+ state.glitchSeed = obsInt(0, 100000);
+ state.glitchUntil = now + obsRand(90, 260 + activity * 180);
+ }
+
+ const slow = 17000;
+ const fast = 2800;
+ state.nextTextGlitchAt = now + (slow - (slow-fast) * activity) * obsRand(.6, 1.4);
+ }
+
+ function observationInstrumentTick() {
+ observationInstrumentTimer = null;
+ if (!observationActive) return;
+
+ const now = performance.now();
+ observationInstrumentStates = observationInstrumentStates.filter(state => state.node && state.node.isConnected);
+
+ for (const state of observationInstrumentStates) {
+ state.numbers.forEach(token => updateObservationNumberToken(token, now));
+ maybeGlitchObservationText(state, now);
+ rebuildObservationText(state, now);
+ }
+
+ scheduleObservationInstrumentTick();
+ }
+
+ function scheduleObservationInstrumentTick() {
+ if (!observationActive) return;
+ if (observationInstrumentTimer) clearTimeout(observationInstrumentTimer);
+
+ // Observation level directly controls the "instrument refresh rate".
+ // At quiet settings values settle visibly and slowly; at high settings
+ // they look like fast live telemetry.
+ const slowMs = 900;
+ const fastMs = 105;
+ const delay = slowMs - (slowMs - fastMs) * Math.pow(observationActivity, .88);
+ observationInstrumentTimer = setTimeout(observationInstrumentTick, delay);
+ }
+
+ function chooseObservationTarget(token, density) {
+ const amplitudeFloor = token.resolution;
+ const amplitude = Math.max(
+ amplitudeFloor,
+ token.macroScale * (.06 + Math.pow(density, 1.12))
+ );
+
+ let delta;
+ if (token.kind === 'identifier') {
+ const maxStep = Math.max(1, Math.round(1 + density * 3));
+ delta = obsPick([-1,1]) * obsInt(1, maxStep);
+ } else {
+ delta = obsRand(-amplitude, amplitude);
+ }
+
+ let target = token.target + delta;
+
+ if (token.circular) {
+ const span = token.max - token.min;
+ target = ((target - token.min) % span + span) % span + token.min;
+ } else {
+ if (Number.isFinite(token.min)) target = Math.max(token.min, target);
+ if (Number.isFinite(token.max)) target = Math.min(token.max, target);
+ }
+
+ token.target = target;
+
+ // Sometimes a display control-loop overshoots and corrects itself.
+ if (Math.random() < .28 + observationActivity * .24) {
+ const sign = Math.random() < .5 ? -1 : 1;
+ const kickSteps = token.kind === 'identifier' ? 1 : obsRand(.6, 2.2 + observationActivity * 1.5);
+ token.correctionKick = sign * token.resolution * kickSteps;
+ token.correctionUntil = performance.now() + obsRand(550, 1450);
+ }
+ }
+
+ function chooseObservationStatusOverride(state, density) {
+ if (!state.statusRule) return;
+
+ let variants;
+ const rule = state.statusRule;
+
+ if (rule.type === 'plain') {
+ variants = rule.variants || [];
+ } else {
+ variants = observationTextVocabulary[activeUniverseId] || observationTextVocabulary.starfleet;
+ }
+
+ if (!variants.length) return;
+
+ const original = rule.original.toUpperCase();
+ const choices = variants.filter(v => v !== original);
+ if (!choices.length) return;
+
+ state.statusOverride = obsPick(choices);
+
+ // Higher observation activity means state changes read faster and clear sooner.
+ const holdSlow = 6200;
+ const holdFast = 1700;
+ state.statusUntil = performance.now() +
+ (holdSlow - (holdSlow-holdFast) * observationActivity) * obsRand(.7, 1.35);
+ }
+
+ function nudgeObservationInstruments(densityOverride = null) {
+ if (!observationActive) return;
+
+ const density = densityOverride == null
+ ? getObservationTelemetryDensity()
+ : obsClamp(Number(densityOverride) || 0, 0, 1);
+
+ const liveStates = observationInstrumentStates.filter(s => s.node && s.node.isConnected);
+ const numericStates = liveStates.filter(s => s.numbers.length);
+ const statusStates = liveStates.filter(s => s.statusRule);
+
+ // Density controls how much of the imaginary system is disturbed by a chirp.
+ const desiredNumeric = Math.min(
+ numericStates.length,
+ Math.max(1, Math.round(1 + density * (2 + numericStates.length * .22)))
+ );
+
+ const shuffledNumbers = [...numericStates].sort(() => Math.random() - .5);
+ for (let i = 0; i < desiredNumeric; i++) {
+ const state = shuffledNumbers[i];
+ state.numbers.forEach(token => {
+ // A dense chirp can perturb several values in a compound readout.
+ if (Math.random() < .55 + density * .4) chooseObservationTarget(token, density);
+ });
+ }
+
+ // Text states are less frequent than numeric movement. They read as system
+ // transitions rather than a constantly changing label soup.
+ if (statusStates.length && Math.random() < .28 + density * .5) {
+ const desiredText = density > .72 && Math.random() < density ? 2 : 1;
+ const shuffledText = [...statusStates].sort(() => Math.random() - .5);
+ for (let i = 0; i < Math.min(desiredText, shuffledText.length); i++) {
+ chooseObservationStatusOverride(shuffledText[i], density);
+ }
+ }
+ }
+
+
+ function seededUnit(seed) {
+ let x = Math.sin(seed * 12.9898 + 78.233) * 43758.5453;
+ return x - Math.floor(x);
+ }
+
+ function makeStars(count = 90, width = 1600, height = 900, tint = '#dbeafe', seedOffset = 0) {
+ let stars = '';
+ for (let i = 0; i < count; i++) {
+ const x = Math.floor(seededUnit(i + 1 + seedOffset) * width);
+ const y = Math.floor(seededUnit((i + 1 + seedOffset) * 7.31) * height);
+ const r = (0.55 + seededUnit((i + seedOffset + 4) * 3.17) * 1.65).toFixed(2);
+ const o = (0.25 + seededUnit((i + seedOffset + 9) * 5.19) * 0.72).toFixed(2);
+ stars += ` `;
+ }
+ return stars;
+ }
+
+ function svgFrame(inner, defs = '') {
+ return `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ${defs}
+
+ ${inner}
+ `;
+ }
+
+ function renderObservationStarflight() {
+ const starsA = makeStars(115, 1750, 900, '#dbeafe', 2);
+ const starsB = makeStars(55, 1750, 900, '#93c5fd', 220);
+ return svgFrame(`
+
+ ${starsA}
+ ${starsB}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ LONG RANGE SENSOR ARRAY // PASSIVE
+ NAV VECTOR 034.8 // NOMINAL
+ `);
+ }
+
+ function renderObservationWhataverse() {
+ return svgFrame(`
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ◉
+ ◎
+ ⌁ 37.4 ∴ 9
+ ∆ 04:11:∞
+
+
+ TEMPORAL DISPLACEMENT // VORTEX OBSERVATION
+
+ `);
+ }
+
+ function renderObservationIndustrial() {
+ // The industrial viewport frame (getViewportFrameSvg) masks everything
+ // outside x 140..1460, y 160..740, so the whole surveillance console is
+ // laid out inside that safe area. v12 ran the feeds to x 110..1490 and had
+ // its edges eaten by the bulkhead.
+ const SAFE_X0 = 156, SAFE_X1 = 1444;
+
+ // Feeds are authored at 650x270 and drawn through a uniform scale, so the
+ // scenes below can keep one readable coordinate space.
+ const CW = 650, CH = 270, S = 0.78;
+ const IX = 14, IY = 14, IW = 622, IH = 242;
+ const CAM_X = [279, 814], CAM_Y = [248, 472];
+
+ // Identity text (camera names, equipment designations, container IDs) is
+ // opted OUT of the live-instrumentation number drift by pre-stamping the
+ // flag it checks. Only telemetry should wander; "CAM 02" must not quietly
+ // become "CAM 01", which is exactly what v12 did to every label it drew.
+ const ID = 'data-obs-live-instrumented="1"';
+
+ // ---------------------------------------------------------------- parts
+ const crate = (x, y, w, h, body, edge, id, idColor) => {
+ let ribs = '';
+ for (let i = 9; i < w - 5; i += 9) ribs += `M${i} 4V${h - 4}`;
+ return `
+
+
+
+
+
+
+
+
+
+ ${id}
+ `;
+ };
+
+ // Vent vapour authored as in-scene geometry so the camera clip contains it.
+ // v12 floated these on the sim layer, which is why steam drifted over the
+ // bezels and read as detached from any feed.
+ const vapour = (x, y, count, seed, scale = 1) => {
+ let out = '';
+ for (let i = 0; i < count; i++) {
+ const dur = (4.5 + seededUnit(seed + i) * 3.6).toFixed(1);
+ const delay = (i * (dur / count)).toFixed(1);
+ const vx = Math.round(-24 + seededUnit(seed + i * 3.1) * 48);
+ const r = ((9 + seededUnit(seed + i * 5.7) * 13) * scale).toFixed(1);
+ out += ` `;
+ }
+ return out;
+ };
+
+ const gauge = (x, y, r, label, needleColor, delay) => {
+ let ticks = '';
+ for (let a = -120; a <= 120; a += 30) {
+ const rad = (a - 90) * Math.PI / 180;
+ ticks += `M${(Math.cos(rad) * (r - 3)).toFixed(1)} ${(Math.sin(rad) * (r - 3)).toFixed(1)}` +
+ `L${(Math.cos(rad) * (r - 8)).toFixed(1)} ${(Math.sin(rad) * (r - 8)).toFixed(1)}`;
+ }
+ return `
+
+
+
+
+
+
+
+
+ ${label}
+ `;
+ };
+
+ // Per-feed video artifacts. This replaces v12's single screen-wide sweep
+ // bar: each feed rolls, tears and grains on its own clock, inside its own
+ // clip, so the artifacts read as video rather than as a screen overlay.
+ const feedFx = (i) => `
+
+
+
+
+
+
+
+
+
+
+
+
+
+ `;
+
+ const cam = (i, label, code, status, scene) => `
+
+
+
+
+
+
+
+
+
+ ${scene}
+
+ ${feedFx(i)}
+
+
+
+ ${label}
+ ${code}
+
+ ${status}
+ LIVE // SECURE FEED
+
+
+
+
+ SELECTED
+
+ `;
+
+ // ------------------------------------------------- procedural geometry
+ let bayChev = '';
+ for (let i = 0; i < 660; i += 16) bayChev += `M${i} 222L${i + 8} 212H${i + 16}L${i + 8} 222Z`;
+ let bayDeck = '';
+ for (let i = 20; i < 640; i += 44) bayDeck += `M${i} 214V256`;
+
+ let coreSeg = '';
+ for (let a = 0; a < 360; a += 45) {
+ const rad = a * Math.PI / 180;
+ coreSeg += `M${(Math.cos(rad) * 62).toFixed(1)} ${(Math.sin(rad) * 62).toFixed(1)}` +
+ `L${(Math.cos(rad) * 86).toFixed(1)} ${(Math.sin(rad) * 86).toFixed(1)}`;
+ }
+ let rods = '';
+ for (let i = 0; i < 7; i++) {
+ const rx = -60 + i * 20;
+ rods += `
+
+
+ `;
+ }
+ const trefoil = [0, 120, 240].map(a =>
+ ` `).join('');
+ let plantGrate = '';
+ for (let i = 20; i < 640; i += 40) plantGrate += `M${i} 210V256`;
+
+ let dogs = '';
+ for (let a = 0; a < 360; a += 45) {
+ const rad = a * Math.PI / 180;
+ dogs += `
+
+
+ `;
+ }
+ let hazRing = '';
+ for (let a = 0; a < 360; a += 15) {
+ if ((a / 15) % 2) continue;
+ const r0 = 78, r1 = 90, a0 = a * Math.PI / 180, a1 = (a + 15) * Math.PI / 180;
+ hazRing += `M${(Math.cos(a0) * r0).toFixed(1)} ${(Math.sin(a0) * r0).toFixed(1)}` +
+ `L${(Math.cos(a0) * r1).toFixed(1)} ${(Math.sin(a0) * r1).toFixed(1)}` +
+ `A${r1} ${r1} 0 0 1 ${(Math.cos(a1) * r1).toFixed(1)} ${(Math.sin(a1) * r1).toFixed(1)}` +
+ `L${(Math.cos(a1) * r0).toFixed(1)} ${(Math.sin(a1) * r0).toFixed(1)}` +
+ `A${r0} ${r0} 0 0 0 ${(Math.cos(a0) * r0).toFixed(1)} ${(Math.sin(a0) * r0).toFixed(1)}Z`;
+ }
+ const lamp = (y, txt, col, i) => `
+
+
+
+
+
+
+ ${txt}
+ `;
+ const suits = [0, 1, 2].map(i => `
+
+
+
+
+
+
+ `).join('');
+ let lockFloor = '';
+ for (let i = 20; i < 640; i += 40) lockFloor += `M${i} 214V256`;
+
+ const vpx = 325, vpy = 128;
+ let corrFrames = '', corrFloor = '', corrLamps = '';
+ for (let i = 0; i < 7; i++) {
+ const s = Math.pow(0.76, i);
+ const x0 = vpx + (24 - vpx) * s, x1 = vpx + (626 - vpx) * s;
+ const y0 = vpy + (22 - vpy) * s, y1 = vpy + (248 - vpy) * s;
+ corrFrames += ` `;
+ corrFloor += ` `;
+ }
+ for (let x = 24; x <= 626; x += 60) {
+ corrFloor += ` `;
+ }
+ for (let i = 1; i < 5; i++) {
+ const s = Math.pow(0.76, i);
+ const y = vpy + (34 - vpy) * s, w = 62 * s;
+ corrLamps += `
+
+
+ `;
+ }
+ const fanBlades = [0, 72, 144, 216, 288].map(a =>
+ ` `).join('');
+ const drip = (x, y, d) =>
+ `
+ `;
+
+ // ------------------------------------------------------- CAM 01 :: BAY
+ // A working cargo hold: a gantry crane on an overhead rail running a real
+ // pick-and-place cycle over stacked containers. v12 had a wheeled cart
+ // sliding back and forth, which read as a car and made no sense on a ship.
+ const sceneCargo = `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ BAY 2 // AFT
+
+
+
+
+
+
+
+
+
+
+
+
+ ${crate(90, 174, 88, 40, '#5d4427', '#8a6a42', 'CB-4471', '#e8c88a')}
+
+ ${crate(90, 134, 88, 40, '#6b4d2c', '#96754a', 'CB-4472', '#e8c88a')}
+
+ ${crate(182, 174, 88, 40, '#4f3a22', '#7d6039', 'HAZ-3/09', '#f0b64f')}
+
+
+
+ ${crate(380, 174, 88, 40, '#5a4426', '#886946', 'CB-3310', '#e8c88a')}
+
+ ${crate(380, 134, 88, 40, '#6b4d2c', '#96754a', 'CB-4472', '#e8c88a')}
+
+ ${crate(472, 174, 88, 40, '#453120', '#6f5535', 'ORE-C', '#d9b070')}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ${crate(-44, 6, 88, 40, '#6b4d2c', '#96754a', 'CB-4472', '#e8c88a')}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ${vapour(250, 250, 6, 12.4, .8)}
+
+
+
+
+
+ GANTRY 4 // AUTO CYCLE
+ LOAD 2.4 T
+ `;
+
+ // --------------------------------------------------- CAM 02 :: REACTOR
+ const sceneReactor = `
+
+
+
+
+
+
+
+
+ ${rods}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ${gauge(424, 74, 26, 'TEMP', '#f59e0b', 0)}
+ ${gauge(490, 74, 26, 'PRESS', '#38bdf8', 1.4)}
+ ${gauge(556, 74, 26, 'FLUX', '#22c55e', 2.8)}
+
+
+
+ ${trefoil}
+
+
+
+ ${vapour(392, 206, 5, 31.7, .85)}
+
+
+
+
+
+
+
+
+
+ FUSION PLANT 1
+ OUTPUT 68%
+ `;
+
+ // --------------------------------------------------- CAM 03 :: AIRLOCK
+ const sceneAirlock = `
+
+
+
+
+
+
+ ${suits}
+
+
+
+
+
+
+
+ ${dogs}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ CYCLE STATE
+ ${lamp(20, 'VACUUM', '#38bdf8', 0)}
+ ${lamp(44, 'EQUALIZE', '#f59e0b', 1)}
+ ${lamp(68, 'PRESSURE', '#22c55e', 2)}
+
+
+ ${gauge(556, 172, 28, 'CHAMBER kPa', '#38bdf8', 1.1)}
+ ${vapour(300, 212, 5, 57.3, .7)}
+
+
+
+
+
+ AIRLOCK C
+ OUTER DOOR SEALED
+ `;
+
+ // -------------------------------------------------- CAM 04 :: CORRIDOR
+ const sceneCorridor = `
+
+
+
+ ${corrFloor}
+
+
+
+
+
+
+
+
+
+
+ ${corrFrames}
+ ${corrLamps}
+
+
+
+
+
+
+ ${fanBlades}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ${drip(196, 96, 1.4)}
+ ${drip(438, 82, 3.1)}
+ ${drip(268, 66, 5.2)}
+ ${vapour(520, 236, 5, 78.1, .75)}
+
+
+ FRAME 22 TO 34
+ EXTRACTOR 3 ONLINE
+ `;
+
+ // ------------------------------------------------- console side rails
+ let rails = '';
+ for (let i = 0; i < 8; i++) {
+ const y = 252 + i * 54;
+ const fill = 22 + ((i * 41) % 62);
+ rails += `
+
+
+ `;
+ }
+
+ const defs = `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ `;
+
+ return svgFrame(`
+
+
+
+ ${cam(0, 'CAM 01 // CARGO BAY 2', 'C-01 4K/IR', 'GANTRY 4 AUTO // 2 CONTACTS', sceneCargo)}
+ ${cam(1, 'CAM 02 // REACTOR ACCESS', 'C-02 4K/RAD', 'PLANT 1 NOMINAL // LOOP A', sceneReactor)}
+ ${cam(2, 'CAM 03 // AIRLOCK C', 'C-03 4K/LL', 'OUTER DOOR SEALED // NO EVA', sceneAirlock)}
+ ${cam(3, 'CAM 04 // MACHINE CORRIDOR', 'C-04 4K/IR', 'FRAME 22-34 // EXTRACTOR ON', sceneCorridor)}
+
+
+
+
+ CSV KESTREL - HEAVY HAULER
+ REG CB-4471 // DECK 4 SURVEILLANCE RING
+
+
+ SHIP TIME
+ 04:17:33
+ RECORDER
+ ARMED
+
+
+
+ 4 OF 4 FEEDS LOCKED
+ BANDWIDTH 62% // ARCHIVE 71% FULL
+
+
+
+
+ ${rails}
+
+
+
+
+
+ BUS A
+ BUS B
+
+
+
+
+
+ MANIFEST 4471-C // ORE CONCENTRATE + SEALED HAZ-3
+
+ HULL 12%
+ BAY 101.3 KPA
+ RCTR 68%
+ O2 20.9%
+ GRAV 0.94 G
+
+ NOMINAL
+
+ `, defs);
+ }
+
+ function renderObservationBioships() {
+ return svgFrame(`
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ BIOLOGICAL SYSTEMS // PASSIVE NEURAL OBSERVATION
+
+ `);
+ }
+
+ function renderObservationRetro() {
+ const stars = makeStars(95, 1600, 900, '#86efac', 720);
+ return svgFrame(`
+
+ ${stars}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ASTROGATION ARRAY
+ VECTOR MEMORY: LOCKED
+ SCAN 360°
+ ANALOG BEAM: ACTIVE
+
+ `);
+ }
+
+ function renderObservationMilitary() {
+ const stars = makeStars(110, 1650, 900, '#fef3c7', 960);
+ return svgFrame(`
+
+ ${stars}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ CONTACT 03 // TRACKING
+ TACTICAL WATCH // PASSIVE
+ WEAPONS SAFE // SENSORS ACTIVE
+
+
+
+
+
+
+
+ `);
+ }
+
+ function renderObservationDeepSpace() {
+ const starsA = makeStars(155, 1800, 900, '#eef2ff', 1220);
+ const starsB = makeStars(65, 1800, 900, '#818cf8', 1430);
+ // Planets, nebula clouds, dust lanes, derelicts and anomalies now live in the canvas
+ // engine's procedural, session-seeded Deep Space field (see ObservationEngine's
+ // regenerateDeepSpaceField / renderDeepSpaceField). This legacy SVG layer is kept
+ // intentionally sparse -- just a comet accent and the HUD label -- so it never paints a
+ // fixed duplicate planet/nebula on top of that varying scene.
+ return svgFrame(`
+
+ ${starsA}
+ ${starsB}
+
+
+
+
+
+ LONG RANGE OPTICAL // DEEP FIELD OBSERVATION
+
+ `);
+ }
+
+ function renderObservationOutlaw() {
+ const stars = makeStars(115, 1700, 900, '#fbcfe8', 1710);
+
+ const gaugeArc = (r, startDeg = -140, endDeg = -40) => {
+ const sa = startDeg * Math.PI / 180;
+ const ea = endDeg * Math.PI / 180;
+ const sx = (Math.cos(sa) * r).toFixed(1);
+ const sy = (Math.sin(sa) * r).toFixed(1);
+ const ex = (Math.cos(ea) * r).toFixed(1);
+ const ey = (Math.sin(ea) * r).toFixed(1);
+ return `M ${sx} ${sy} A ${r} ${r} 0 0 1 ${ex} ${ey}`;
+ };
+
+ const gaugeTicks = (r, color) => {
+ const ticks = [-140, -115, -90, -65, -40];
+ return ticks.map((deg, idx) => {
+ const a = deg * Math.PI / 180;
+ const ix = Math.cos(a) * (r - 9);
+ const iy = Math.sin(a) * (r - 9);
+ const ox = Math.cos(a) * (r + (idx === 2 ? 10 : 5));
+ const oy = Math.sin(a) * (r + (idx === 2 ? 10 : 5));
+ return ` `;
+ }).join('');
+ };
+
+ const outlawGauge = ({ x, y, r, label, sublabel, arcColor, nodeColor, pointerColor, values, dur, begin = '0s', opacity = '.95', drift = false }) => {
+ const tipR = (r * 0.8).toFixed(1);
+ const arcPath = gaugeArc(r);
+ const smallArcPath = gaugeArc(r - 10, -132, -48);
+ return `
+
+ ${label}
+ ${sublabel}
+
+
+
+
+
+ ${gaugeTicks(r, '#fbcfe8')}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ `;
+ };
+
+ return svgFrame(`
+
+ ${stars}
+
+
+
+
+
+
+
+
+
+ REAR FEED // 02
+
+
+
+
+
+
+ FIELD TUNING ARRAY
+
+
+ ${outlawGauge({ x: 450, y: 250, r: 64, label: 'PORT BUS', sublabel: 'FLOAT', arcColor: '#fb923c', nodeColor: '#fde68a', pointerColor: '#f472b6', values: '-24 0 0; 18 0 0; -12 0 0; 22 0 0; -20 0 0; -24 0 0', dur: '8.4s', begin: '0s' })}
+ ${outlawGauge({ x: 800, y: 244, r: 76, label: 'JUMP BIAS', sublabel: 'PEG / BOUNCE', arcColor: '#ec4899', nodeColor: '#fb923c', pointerColor: '#fde68a', values: '-6 0 0; 46 0 0; 38 0 0; 44 0 0; 10 0 0; -12 0 0; -6 0 0', dur: '5.8s', begin: '1.2s', drift: true })}
+ ${outlawGauge({ x: 1150, y: 250, r: 64, label: 'VECTOR', sublabel: 'CENTER RANGE', arcColor: '#8b5cf6', nodeColor: '#fbcfe8', pointerColor: '#fb923c', values: '-38 0 0; 28 0 0; -14 0 0; 12 0 0; -3 0 0; 0 0 0; 7 0 0; 0 0 0', dur: '7.6s', begin: '.6s' })}
+
+
+
+ ROUTE: MANUAL
+ TRANSPONDER: INTERMITTENT
+ SIGNAL QUALITY: 62%
+
+
+ `);
+ }
+
+ function renderObservationStations() {
+ const starsNear = makeStars(105, 1160, 540, '#e0f2fe', 1990);
+ const starsFar = makeStars(55, 1160, 540, '#7dd3fc', 2310);
+ const telemetryModule = (x, y, w, h, title, line1, line2 = '') => `
+
+
+
+ ${title}
+ ${line1}
+ ${line2 ? `${line2} ` : ''}
+ `;
+
+ return svgFrame(`
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ${starsNear}
+ ${starsFar}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ${telemetryModule(105, 170, 300, 96, 'OBSERVATION DECK', 'RING RATE 01.8 // STABLE', 'STARFIELD DRIFT 004.2')}
+ ${telemetryModule(1195, 170, 300, 96, 'TRAFFIC CONTROL', 'DOCK VECTOR 118.4 // ROUTING', 'QUEUE 03 // CLEARANCE')}
+ ${telemetryModule(105, 590, 320, 96, 'PORT STATUS', 'BAY 12 // ARRIVAL WINDOW', 'HOLDING ARC 02.7')}
+ ${telemetryModule(1175, 590, 320, 96, 'TRANSIT SPINE', 'LOCAL FLOW 07 // NOMINAL', 'HABITAT SPIN 00.9')}
+
+
+
+
+
+
+
+
+ STATION OBSERVATION // ROTATIONAL TRAFFIC WINDOW
+
+ `);
+ }
+
+ function renderObservationComedy() {
+ const stars = makeStars(120, 1700, 900, '#cffafe', 2260);
+ return svgFrame(`
+
+ ${stars}
+
+
+
+
+
+
+
+
+ SCENIC NAVIGATION
+ ROUTE CONFIDENCE: 99.7% PROBABLY FINE
+ TEA SOURCE DETECTED
+ NO IMMEDIATE CAUSE FOR PANIC
+
+
+
+
+
+
+ `);
+ }
+
+ function getObservationSvg(universeId) {
+ switch (universeId) {
+ case 'starfleet': return renderObservationStarflight();
+ case 'whataverse': return renderObservationWhataverse();
+ case 'industrial': return renderObservationIndustrial();
+ case 'bioships': return renderObservationBioships();
+ case 'retrofuture': return renderObservationRetro();
+ case 'military': return renderObservationMilitary();
+ case 'deepspace': return renderObservationDeepSpace();
+ case 'outlaw': return renderObservationOutlaw();
+ case 'spacestations': return renderObservationStations();
+ case 'comedy': return renderObservationComedy();
+ default: return renderObservationStarflight();
+ }
+ }
+
+ function refreshObservation() {
+ const universe = UniverseRegistry[activeUniverseId];
+ const preset = universe && universe.presets ? universe.presets[activePresetId] : null;
+ if (!universe || !preset) return;
+
+ observationUniverse.textContent = `${universe.shortCode} // OBSERVATION`;
+ observationProfile.textContent = preset.name.toUpperCase();
+ observationStatus.innerHTML =
+ `OBSERVATION ACTIVE PROCEDURAL AUDIO LINK: ${isPlaying ? 'ONLINE' : 'STANDBY'} VISUAL ACTIVITY: ${Math.round(observationActivity * 100)}%`;
+
+ observationOverlay.style.setProperty('--obs-activity', String(observationActivity));
+ observationStage.innerHTML = getObservationSvg(activeUniverseId);
+ initializeObservationSplineMorphs();
+ startObservationScene();
+ initializeObservationLiveInstrumentation();
+
+ if (observationEngine) {
+ observationEngine.start(activeUniverseId);
+ }
+ }
+
+ function enterObservation() {
+ if (observationActive) return;
+ generativeExperience.prepareExperience().catch(err => console.warn('Generative preload failed', err));
+ observationActive = true;
+ observationOverlay.classList.add('active');
+ observationOverlay.setAttribute('aria-hidden', 'false');
+ refreshObservation();
+ resetObservationHudFade();
+ resetObservationReturnFade();
+ scheduleObservationAmbientActivity();
+
+ // Give the display an immediate but restrained sign of life.
+ if (observationActivity > .08) {
+ window.setTimeout(() => triggerObservationActivity('ambient'), 700);
+ }
+ }
+
+ function exitObservation() {
+ if (!observationActive) return;
+ observationActive = false;
+
+ if (observationEngine) {
+ observationEngine.stop();
+ }
+
+ if (observationAmbientTimer) {
+ clearTimeout(observationAmbientTimer);
+ observationAmbientTimer = null;
+ }
+
+ stopObservationLiveInstrumentation();
+ stopObservationSplineMorphs();
+ stopObservationScene();
+
+ if (observationChrome) {
+ observationChrome.style.transition = 'none';
+ observationChrome.classList.remove('observation-hud-fade');
+ void observationChrome.offsetWidth;
+ observationChrome.style.transition = '';
+ }
+
+ if (observationReturn) {
+ observationReturn.style.transition = 'none';
+ observationReturn.classList.remove('observation-return-fade');
+ void observationReturn.offsetWidth;
+ observationReturn.style.transition = '';
+ }
+
+ observationOverlay.classList.remove('active');
+ observationOverlay.setAttribute('aria-hidden', 'true');
+ observationStage.innerHTML = '';
+ if (observationEventLayer) observationEventLayer.innerHTML = '';
+ }
+
+ window.exitObservation = exitObservation;
+ window.refreshObservation = refreshObservation;
+ window.enterObservation = enterObservation;
+ window.selectPreset = selectPreset;
+ window.setUniverse = setUniverse;
+
+ btnObservation.addEventListener('click', (e) => {
+ e.stopPropagation();
+ contractAdapter.applyMutation('view.observation', !observationActive, 'ui');
+ });
+
+ // Click background to exit, but ignore clicks on the control dock or its buttons
+ observationOverlay.addEventListener('click', (e) => {
+ if (e.target.closest('#observation-control-dock')) return;
+ e.preventDefault();
+ e.stopPropagation();
+ contractAdapter.applyMutation('view.observation', false, 'ui');
+ });
+
+ const observationReturnPill = document.getElementById('observation-return-pill');
+ if (observationReturnPill) {
+ observationReturnPill.addEventListener('click', (e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ contractAdapter.applyMutation('view.observation', false, 'ui');
+ });
+ }
+
+ // 10. Keyboard Shortcuts
+ window.addEventListener('keydown', (e) => {
+ if (e.target.tagName === 'INPUT' || e.target.tagName === 'SELECT') return;
+
+ if (e.code === 'Space') {
+ e.preventDefault();
+ contractAdapter.applyMutation('transport.playing', !isPlaying, 'hotkey');
+ } else if (e.code === 'KeyM') {
+ contractAdapter.applyMutation('transport.muted', !audioManager.isMuted, 'hotkey');
+ } else if (e.code === 'KeyW') {
+ if (observationActive && observationEngine) {
+ observationEngine.toggleWarpFlight();
+ return;
+ }
+ const primaryActionBtn = universeEventsContainer.children[0];
+ const primaryEvent = UniverseRegistry[activeUniverseId].events[0];
+ if (primaryEvent) invokeConsoleEvent(primaryEvent, primaryActionBtn, 'hotkey');
+ } else if (e.code === 'KeyF') {
+ if (observationActive && observationEngine) {
+ contractAdapter.applyMutation('view.viewport-frame', !observationEngine.showViewport, 'hotkey');
+ return;
+ }
+ } else if (e.code === 'KeyP') {
+ if (observationActive && observationEngine) {
+ observationEngine.togglePillars();
+ return;
+ }
+ } else if (e.code === 'KeyR') {
+ if (observationActive && observationEngine) {
+ const nextAlert = alerts.activeAlert === 'red' ? 'none' : 'red';
+ contractAdapter.applyMutation('alert.active', nextAlert, 'hotkey');
+ return;
+ }
+ const firstAlertBtn = universeEventsContainer.children[1] || universeEventsContainer.children[0];
+ const events = UniverseRegistry[activeUniverseId].events;
+ const alertEvent = events[1] || events[0];
+ if (alertEvent) invokeConsoleEvent(alertEvent, firstAlertBtn, 'hotkey');
+ } else if (e.code === 'ArrowUp' || e.code === 'Equal' || e.code === 'NumpadAdd') {
+ e.preventDefault();
+ contractAdapter.applyMutation('mix.master', Math.min(1, audioManager.getMasterVolume() + 0.05), 'hotkey');
+ } else if (e.code === 'ArrowDown' || e.code === 'Minus' || e.code === 'NumpadSubtract') {
+ e.preventDefault();
+ contractAdapter.applyMutation('mix.master', Math.max(0, audioManager.getMasterVolume() - 0.05), 'hotkey');
+ } else if (e.code >= 'Digit1' && e.code <= 'Digit9') {
+ const num = parseInt(e.code.replace('Digit', ''));
+ contractAdapter.applyMutation('mix.master', num * 0.1, 'hotkey');
+ } else if (e.code === 'Digit0') {
+ contractAdapter.applyMutation('mix.master', 1.0, 'hotkey');
+ } else if (e.code === 'Escape') {
+ if (observationActive) {
+ contractAdapter.applyMutation('view.observation', false, 'hotkey');
+ return;
+ }
+ contractAdapter.applyMutation('alert.active', 'none', 'hotkey');
+ }
+ });
+
+ // --- Implementation of Absolute Setters and Contract Helpers ---
+ setMasterVolumeDirect = function (val, isMuted = false) {
+ const clamped = Math.max(0, Math.min(1, Number(val)));
+ if (audioManager.isMuted) audioManager.setMute(false);
+ audioManager.setMasterVolume(clamped);
+ updateMasterVolumeUI(clamped, isMuted);
+ };
+
+ setPlayingDirect = async function (on) {
+ const targetOn = !!on;
+ if (isPlaying === targetOn && !isTransitioning) return;
+ // If a transition is currently in progress, wait for it to complete
+ while (isTransitioning) {
+ await new Promise(r => setTimeout(r, 100));
+ }
+ if (isPlaying === targetOn) return;
+ await togglePlay();
+ // Await transition completion so caller gets deterministic state
+ while (isTransitioning) {
+ await new Promise(r => setTimeout(r, 100));
+ }
+ };
+
+ setMutedDirect = function (on) {
+ const targetMuted = !!on;
+ if (audioManager.isMuted === targetMuted) return;
+ audioManager.setMute(targetMuted);
+ updateMasterVolumeUI(audioManager.getMasterVolume(), targetMuted);
+ };
+
+ setFncEnabledDirect = function (on) {
+ const targetEnabled = !!on;
+ if (fncSystem.enabled === targetEnabled) return;
+ fncSystem.setEnabled(targetEnabled);
+ };
+
+ setObservationDirect = function (on) {
+ const targetOn = !!on;
+ if (observationActive === targetOn) return;
+ if (targetOn) {
+ enterObservation();
+ } else {
+ exitObservation();
+ }
+ };
+
+ setViewportFrameDirect = function (on) {
+ const targetOn = !!on;
+ if (observationEngine) {
+ observationEngine.setViewport(targetOn);
+ }
+ };
+
+ setAlertActiveDirect = function (status) {
+ const targetStatus = status || 'none';
+ if (alerts.activeAlert === targetStatus || (targetStatus === 'none' && !alerts.activeAlert)) return;
+
+ if (targetStatus === 'none') {
+ alerts.stopAlert();
+ whataverseAudio.stopCloisterBell();
+ fncSystem.hold('alert', false);
+ resetAlertButtons();
+ } else if (targetStatus === 'red') {
+ resetAlertButtons();
+ alerts.triggerRedAlert('tng');
+ fncSystem.hold('alert', true);
+ document.getElementById('btn-red-alert')?.classList.add('active-alert');
+ } else if (targetStatus === 'yellow') {
+ resetAlertButtons();
+ alerts.triggerYellowAlert();
+ fncSystem.hold('alert', true);
+ document.getElementById('btn-yellow-alert')?.classList.add('active-alert');
+ }
+ if (observationEngine) observationEngine.updateAlertState();
+ };
+
+ setUniverseDirect = async function (universeId) {
+ setUniverse(universeId);
+ };
+
+ selectPresetDirect = async function (presetId) {
+ selectPreset(presetId);
+ };
+
+ getContractStateSnapshot = function () {
+ const vol = audioManager ? audioManager.getMasterVolume() : 0.75;
+ const isMuted = audioManager ? audioManager.isMuted : false;
+ let alertStatus = 'none';
+ if (alerts && alerts.activeAlert) alertStatus = alerts.activeAlert;
+
+ return {
+ 'transport.playing': isPlaying,
+ 'transport.muted': isMuted,
+ 'mix.master': vol,
+ 'mix.hull.level': hullDrone ? hullDrone.params.volume : 0.5,
+ 'mix.hull.frequency': hullDrone ? hullDrone.params.baseFreq : 50,
+ 'mix.hull.cutoff': hullDrone ? hullDrone.params.filterCutoff : 105,
+ 'mix.drive.level': warpCore ? warpCore.params.volume : 0.5,
+ 'mix.drive.pulse-rate': warpCore ? warpCore.params.bpm : 48,
+ 'mix.drive.carrier': warpCore ? warpCore.params.carrierFreq : 58,
+ 'mix.environment.level': lifeSupport ? lifeSupport.params.volume : 0.5,
+ 'mix.environment.cutoff': lifeSupport ? lifeSupport.params.lowpassFreq : 1600,
+ 'mix.telemetry.level': telemetry ? telemetry.params.volume : 0.5,
+ 'mix.telemetry.density': telemetry ? telemetry.params.density : 0.5,
+ 'speech.robot-amount': generativeExperience ? generativeExperience.roboticAmount() : 0,
+ 'fnc.enabled': fncSystem ? fncSystem.enabled : false,
+ 'fnc.level': fncSystem ? fncSystem.level : 0.5,
+ 'view.observation': observationActive,
+ 'view.viewport-frame': observationEngine ? observationEngine.showViewport : true,
+ 'view.activity': observationActivity,
+ 'alert.active': alertStatus,
+ 'universe.selected': activeUniverseId,
+ 'preset.selected': activePresetId
+ };
+ };
+
+ // 11. Initial Setup
+ initUniverseDropdown();
+ setUniverse('starfleet');
+ updateMasterVolumeUI(0.75, false);
+ visualizer.init('spectrum-canvas', 'warp-core-canvas');
+});
diff --git a/test-fixtures/reference-exhibits/scifi/js/audio.js b/test-fixtures/reference-exhibits/scifi/js/audio.js
new file mode 100644
index 0000000..7ce266a
--- /dev/null
+++ b/test-fixtures/reference-exhibits/scifi/js/audio.js
@@ -0,0 +1,6889 @@
+class AudioManager {
+ constructor() {
+ this.ctx = null;
+ this.isInitialized = false;
+ this.isPlaying = false;
+ this.masterGain = null;
+ this.compressor = null;
+ this.analyser = null;
+
+ this.currentVolume = 0.75;
+ this.isMuted = false;
+
+ // Sleep Timer
+ this.timerId = null;
+ this.timerRemainingSeconds = 0;
+ this.onTimerTick = null;
+ this.onTimerComplete = null;
+ }
+
+ init() {
+ if (this.isInitialized) return;
+
+ const AudioContextClass = window.AudioContext || window.webkitAudioContext;
+ this.ctx = new AudioContextClass();
+
+ // Master Dynamics Compressor / Limiter for studio-quality mastering & anti-clipping
+ this.compressor = this.ctx.createDynamicsCompressor();
+ this.compressor.threshold.setValueAtTime(-12, this.ctx.currentTime);
+ this.compressor.knee.setValueAtTime(8, this.ctx.currentTime);
+ this.compressor.ratio.setValueAtTime(4, this.ctx.currentTime);
+ this.compressor.attack.setValueAtTime(0.003, this.ctx.currentTime);
+ this.compressor.release.setValueAtTime(0.25, this.ctx.currentTime);
+
+ // Master Gain
+ this.masterGain = this.ctx.createGain();
+ this.masterGain.gain.setValueAtTime(this.isMuted ? 0 : this.currentVolume, this.ctx.currentTime);
+
+ // Master Analyser Node for Visualizers
+ this.analyser = this.ctx.createAnalyser();
+ this.analyser.fftSize = 512;
+ this.analyser.smoothingTimeConstant = 0.82;
+
+ // Route: Nodes -> Compressor -> MasterGain -> Analyser -> Destination
+ this.compressor.connect(this.masterGain);
+ this.masterGain.connect(this.analyser);
+ this.analyser.connect(this.ctx.destination);
+
+ this.isInitialized = true;
+ }
+
+ async resume() {
+ if (!this.isInitialized) this.init();
+ if (this.ctx.state === 'suspended') {
+ await this.ctx.resume();
+ }
+ }
+
+ setMasterVolume(val, smoothTime = 0.05) {
+ const clamped = Math.max(0, Math.min(1, val));
+ this.currentVolume = clamped;
+ if (this.masterGain && this.ctx) {
+ const target = this.isMuted ? 0 : clamped;
+ const now = this.ctx.currentTime;
+ this.masterGain.gain.cancelScheduledValues(now);
+ this.masterGain.gain.linearRampToValueAtTime(target, now + smoothTime);
+ }
+ }
+
+ getMasterVolume() {
+ return this.currentVolume;
+ }
+
+ toggleMute() {
+ return this.setMute(!this.isMuted);
+ }
+
+ setMute(muted) {
+ this.isMuted = !!muted;
+ if (this.masterGain && this.ctx) {
+ const target = this.isMuted ? 0 : this.currentVolume;
+ const now = this.ctx.currentTime;
+ this.masterGain.gain.cancelScheduledValues(now);
+ this.masterGain.gain.linearRampToValueAtTime(target, now + 0.05);
+ }
+ return this.isMuted;
+ }
+
+ // Noise Buffer Helper (White, Pink, Brown)
+ createNoiseBuffer(type = 'pink', durationSeconds = 5) {
+ if (!this.ctx) this.init();
+ const sampleRate = this.ctx.sampleRate;
+ const bufferSize = sampleRate * durationSeconds;
+ const buffer = this.ctx.createBuffer(2, bufferSize, sampleRate);
+ const left = buffer.getChannelData(0);
+ const right = buffer.getChannelData(1);
+
+ if (type === 'white') {
+ for (let i = 0; i < bufferSize; i++) {
+ left[i] = Math.random() * 2 - 1;
+ right[i] = Math.random() * 2 - 1;
+ }
+ } else if (type === 'pink') {
+ let b0L = 0, b1L = 0, b2L = 0, b3L = 0, b4L = 0, b5L = 0, b6L = 0;
+ let b0R = 0, b1R = 0, b2R = 0, b3R = 0, b4R = 0, b5R = 0, b6R = 0;
+ for (let i = 0; i < bufferSize; i++) {
+ const whiteL = Math.random() * 2 - 1;
+ b0L = 0.99886 * b0L + whiteL * 0.0555179;
+ b1L = 0.99332 * b1L + whiteL * 0.0750759;
+ b2L = 0.96900 * b2L + whiteL * 0.1538520;
+ b3L = 0.86650 * b3L + whiteL * 0.3104856;
+ b4L = 0.55000 * b4L + whiteL * 0.5329522;
+ b5L = -0.7616 * b5L - whiteL * 0.0168980;
+ left[i] = (b0L + b1L + b2L + b3L + b4L + b5L + b6L + whiteL * 0.5362) * 0.11;
+ b6L = whiteL * 0.115926;
+
+ const whiteR = Math.random() * 2 - 1;
+ b0R = 0.99886 * b0R + whiteR * 0.0555179;
+ b1R = 0.99332 * b1R + whiteR * 0.0750759;
+ b2R = 0.96900 * b2R + whiteR * 0.1538520;
+ b3R = 0.86650 * b3R + whiteR * 0.3104856;
+ b4R = 0.55000 * b4R + whiteR * 0.5329522;
+ b5R = -0.7616 * b5R - whiteR * 0.0168980;
+ right[i] = (b0R + b1R + b2R + b3R + b4R + b5R + b6R + whiteR * 0.5362) * 0.11;
+ b6R = whiteR * 0.115926;
+ }
+ } else if (type === 'brown') {
+ let lastOutL = 0.0;
+ let lastOutR = 0.0;
+ for (let i = 0; i < bufferSize; i++) {
+ const whiteL = Math.random() * 2 - 1;
+ lastOutL = (lastOutL + 0.02 * whiteL) / 1.02;
+ left[i] = lastOutL * 3.5;
+
+ const whiteR = Math.random() * 2 - 1;
+ lastOutR = (lastOutR + 0.02 * whiteR) / 1.02;
+ right[i] = lastOutR * 3.5;
+ }
+ }
+
+ return buffer;
+ }
+
+ // Sleep Timer System
+ startSleepTimer(minutes, onTick, onComplete) {
+ this.stopSleepTimer();
+ this.timerRemainingSeconds = Math.round(minutes * 60);
+ this.onTimerTick = onTick;
+ this.onTimerComplete = onComplete;
+
+ if (this.onTimerTick) this.onTimerTick(this.timerRemainingSeconds);
+
+ this.timerId = setInterval(() => {
+ this.timerRemainingSeconds--;
+ if (this.onTimerTick) this.onTimerTick(this.timerRemainingSeconds);
+
+ // Begin exponential smooth fadeout during final 30 seconds
+ if (this.timerRemainingSeconds <= 30 && this.timerRemainingSeconds > 0) {
+ const factor = this.timerRemainingSeconds / 30;
+ if (this.masterGain && this.ctx) {
+ const targetVol = this.getMasterVolume() * factor;
+ this.masterGain.gain.setValueAtTime(Math.max(0, targetVol), this.ctx.currentTime);
+ }
+ }
+
+ if (this.timerRemainingSeconds <= 0) {
+ this.stopSleepTimer();
+ if (this.onTimerComplete) this.onTimerComplete();
+ }
+ }, 1000);
+ }
+
+ stopSleepTimer() {
+ if (this.timerId) {
+ clearInterval(this.timerId);
+ this.timerId = null;
+ this.timerRemainingSeconds = 0;
+ }
+ }
+}
+
+window.AudioManager = AudioManager;
+
+/**
+ * FNC - Futuristic Noice Cancellation v3.7
+ * Deterministic source-level comfort filtering for the three continuous ambience beds.
+ *
+ * FNC OFF: sources play at the operator-selected channel levels.
+ * FNC ON: the user's FNC level (0..1) immediately adds these smooth reductions:
+ * Life Support: 50% -> 100% reduction
+ * Hull Drone: 10% -> 70% reduction
+ * Worp Core: 5% -> 50% reduction
+ *
+ * This is deliberately an overall source-level comfort filter, not a subtle EQ effect.
+ * The mild global spectral watchdog remains only as a secondary refinement and HOLD
+ * pauses that learning without bypassing the deterministic comfort attenuation.
+ */
+class FuturisticNoiceCancellation {
+ constructor(audioManager) {
+ this.am = audioManager;
+ this.enabled = true;
+ this.level = 0.65;
+ this.state = 'ACTIVE';
+ this.themeId = 'starfleet';
+
+ // Mild global watchdog, retained as a secondary refinement only.
+ this.analyser = null;
+ this.filters = [];
+ this.connected = false;
+ this.persistence = new Map();
+ this.activeBuckets = [];
+ this.lastIntervention = 0;
+
+ // Deterministic source comfort gains.
+ this.sourceChains = new Map();
+
+ this.timer = null;
+ this.intervalMs = 1000;
+ this.holds = new Set();
+ this.holdTimers = new Map();
+ this.ui = {};
+ this.storageKey = 'xzbt-fnc-v33';
+
+ // Used only by the optional global watchdog.
+ this.themeProfiles = {
+ starfleet: { protected: [[35, 180]], bias: 1.0 },
+ whataverse: {
+ protected: [
+ [35, 210],
+ [260, 520]
+ ],
+ bias: 0.9
+ },
+ industrial: { protected: [[25, 170]], bias: 1.18 },
+ bioships: {
+ protected: [
+ [20, 240],
+ [280, 520]
+ ],
+ bias: 0.78
+ },
+ retrofuture: {
+ protected: [
+ [30, 150],
+ [330, 520]
+ ],
+ bias: 1.04
+ },
+ military: { protected: [[25, 190]], bias: 1.1 },
+ deepspace: { protected: [[20, 170]], bias: 0.92 },
+ outlaw: { protected: [[25, 220]], bias: 0.9 },
+ spacestations: {
+ protected: [
+ [25, 190],
+ [260, 430]
+ ],
+ bias: 1.0
+ },
+ comedy: { protected: [[30, 180]], bias: 0.86 }
+ };
+ }
+
+ bindUI() {
+ const g = id => document.getElementById(id);
+ this.ui = {
+ button: g('btn-fnc-enable'),
+ slider: g('slider-fnc-level'),
+ value: g('val-fnc-level'),
+ state: g('fnc-state'),
+ detail: g('fnc-detail')
+ };
+ this.load();
+ if (this.ui.button)
+ this.ui.button.addEventListener('click', () => this.onMutation ? this.onMutation('fnc.enabled', !this.enabled) : this.setEnabled(!this.enabled));
+ if (this.ui.slider)
+ this.ui.slider.addEventListener('input', () => this.onMutation ? this.onMutation('fnc.level', Number(this.ui.slider.value)) : this.setLevel(Number(this.ui.slider.value)));
+ this.syncUI();
+ }
+
+ load() {
+ try {
+ let v = JSON.parse(localStorage.getItem(this.storageKey) || 'null');
+ if (!v) v = JSON.parse(localStorage.getItem('xzbt-fnc-v31') || 'null');
+ if (!v) v = JSON.parse(localStorage.getItem('xzbt-fnc-v30') || 'null');
+ if (v) {
+ if (typeof v.enabled === 'boolean') this.enabled = v.enabled;
+ if (Number.isFinite(v.level)) this.level = Math.max(0, Math.min(1, v.level));
+ }
+ } catch (_) {}
+ }
+ save() {
+ try {
+ localStorage.setItem(
+ this.storageKey,
+ JSON.stringify({ enabled: this.enabled, level: this.level })
+ );
+ } catch (_) {}
+ }
+
+ ensureGraph() {
+ if (!this.am?.isInitialized || !this.am.ctx || !this.am.compressor || !this.am.masterGain)
+ return false;
+ if (this.connected) return true;
+ const ctx = this.am.ctx;
+ this.analyser = ctx.createAnalyser();
+ this.analyser.fftSize = 4096;
+ this.analyser.smoothingTimeConstant = 0.93;
+ this.analyser.minDecibels = -110;
+ this.analyser.maxDecibels = -10;
+ this.filters = [];
+ for (let i = 0; i < 2; i++) {
+ const f = ctx.createBiquadFilter();
+ f.type = 'peaking';
+ f.frequency.value = 1200;
+ f.Q.value = 5;
+ f.gain.value = 0;
+ this.filters.push(f);
+ }
+ try {
+ this.am.compressor.disconnect(this.am.masterGain);
+ } catch (_) {}
+ this.am.compressor.connect(this.analyser);
+ let node = this.analyser;
+ this.filters.forEach(f => {
+ node.connect(f);
+ node = f;
+ });
+ node.connect(this.am.masterGain);
+ this.connected = true;
+ this.start();
+ return true;
+ }
+
+ start() {
+ if (this.timer || !this.enabled) return;
+ this.timer = setInterval(() => this.tick(), this.intervalMs);
+ }
+ stop() {
+ if (this.timer) {
+ clearInterval(this.timer);
+ this.timer = null;
+ }
+ }
+
+ setEnabled(on) {
+ this.enabled = !!on;
+ this.save();
+ if (this.enabled) {
+ this.ensureGraph();
+ this.start();
+ this.applySourceConditioning(false);
+ this.setState(this.holds.size ? 'HOLD' : 'ACTIVE');
+ } else {
+ this.stop();
+ this.releaseAll(1.5);
+ this.applySourceConditioning(false);
+ this.setState('OFF');
+ }
+ this.syncUI();
+ }
+
+ setLevel(v) {
+ this.level = Math.max(0, Math.min(1, Number(v) || 0));
+ this.save();
+ this.applySourceConditioning(false);
+ this.applyCurrentDepths();
+ this.updateOperationalState();
+ this.syncUI();
+ }
+
+ setTheme(id) {
+ this.themeId = this.themeProfiles[id] ? id : 'starfleet';
+ this.resetLearning('theme change');
+ this.applySourceConditioning(false);
+ }
+
+ resetLearning(reason = 'reset') {
+ this.persistence.clear();
+ this.activeBuckets = [];
+ this.lastIntervention = 0;
+ this.releaseAll(1.5);
+ if (this.enabled) this.setState(this.holds.size ? 'HOLD' : 'ACTIVE');
+ if (this.ui.detail)
+ this.ui.detail.textContent = `FNC comfort filter remains applied. Adaptive watchdog memory reset: ${reason}.`;
+ }
+
+ hold(reason = 'transient', on = true) {
+ if (on) {
+ this.holds.add(reason);
+ this.setState(this.enabled ? 'HOLD' : 'OFF');
+ } else {
+ this.holds.delete(reason);
+ if (this.enabled) this.updateOperationalState();
+ }
+ }
+
+ holdFor(ms = 4000, reason = 'transient') {
+ this.hold(reason, true);
+ if (this.holdTimers.has(reason)) clearTimeout(this.holdTimers.get(reason));
+ this.holdTimers.set(
+ reason,
+ setTimeout(() => {
+ this.holdTimers.delete(reason);
+ this.hold(reason, false);
+ }, ms)
+ );
+ }
+
+ // Direct multiplier remaining after FNC reduction.
+ // level 0 -> Life 0.50, Hull 0.90, Worp 0.95
+ // level 1 -> Life 0.00, Hull 0.30, Worp 0.50
+ sourceMultiplier(name) {
+ if (!this.enabled) return 1;
+ const x = this.level;
+ if (name === 'lifeSupport') return Math.max(0, 0.5 - 0.5 * x);
+ if (name === 'hull') return Math.max(0, 0.9 - 0.6 * x);
+ if (name === 'worp') return Math.max(0, 0.95 - 0.45 * x);
+ return 1;
+ }
+
+ sourceReductionPercent(name) {
+ return Math.round((1 - this.sourceMultiplier(name)) * 100);
+ }
+
+ // Route each continuous ambience source through a dedicated comfort gain.
+ connectSource(name, sourceNode, paramsGetter = () => ({})) {
+ if (!sourceNode || !this.am?.ctx || !this.am?.compressor) {
+ if (sourceNode && this.am?.compressor) sourceNode.connect(this.am.compressor);
+ return null;
+ }
+ this.ensureGraph();
+ this.detachSource(name);
+ const ctx = this.am.ctx;
+ const comfortGain = ctx.createGain();
+ comfortGain.gain.setValueAtTime(this.sourceMultiplier(name), ctx.currentTime);
+ sourceNode.connect(comfortGain);
+ comfortGain.connect(this.am.compressor);
+ const chain = { name, sourceNode, paramsGetter, comfortGain, nodes: [comfortGain] };
+ this.sourceChains.set(name, chain);
+ this.applySourceConditioning(true);
+ this.updateOperationalState();
+ return chain;
+ }
+
+ detachSource(name) {
+ const chain = this.sourceChains.get(name);
+ if (!chain) return;
+ try {
+ chain.sourceNode?.disconnect();
+ } catch (_) {}
+ for (const n of chain.nodes || []) {
+ try {
+ n.disconnect();
+ } catch (_) {}
+ }
+ this.sourceChains.delete(name);
+ this.updateOperationalState();
+ }
+
+ applySourceConditioning(immediate = false) {
+ if (!this.am?.ctx) return;
+ const now = this.am.ctx.currentTime;
+ const tau = immediate ? 0.05 : 0.22; // ~1 second audible smoothing without sluggishness.
+ for (const [name, c] of this.sourceChains) {
+ const target = this.sourceMultiplier(name);
+ try {
+ c.comfortGain.gain.cancelScheduledValues(now);
+ c.comfortGain.gain.setTargetAtTime(target, now, tau);
+ } catch (_) {}
+ }
+ this.syncUI();
+ }
+
+ releaseSourceConditioning(seconds = 1) {
+ if (!this.am?.ctx) return;
+ const now = this.am.ctx.currentTime,
+ tau = Math.max(0.05, seconds / 4);
+ for (const c of this.sourceChains.values()) {
+ try {
+ c.comfortGain.gain.cancelScheduledValues(now);
+ c.comfortGain.gain.setTargetAtTime(1, now, tau);
+ } catch (_) {}
+ }
+ }
+
+ tick() {
+ if (!this.enabled) return;
+ this.ensureGraph();
+ // Deterministic comfort attenuation is always active. HOLD only pauses watchdog learning.
+ if (!this.holds.size && this.level > 0) this.analyzeGlobalWatchdog();
+ this.updateOperationalState();
+ }
+
+ updateOperationalState() {
+ if (!this.enabled) {
+ this.setState('OFF');
+ return;
+ }
+ if (this.holds.size) {
+ this.setState('HOLD');
+ return;
+ }
+ this.setState('ACTIVE');
+ if (this.ui.detail) {
+ const l = this.sourceReductionPercent('lifeSupport');
+ const h = this.sourceReductionPercent('hull');
+ const w = this.sourceReductionPercent('worp');
+ this.ui.detail.textContent = `Comfort suppression active — Life Support ${l}%, Hull ${h}%, Worp Core ${w}% reduction.`;
+ }
+ }
+
+ isProtected(freq) {
+ const p = this.themeProfiles[this.themeId] || this.themeProfiles.starfleet;
+ return p.protected.some(([lo, hi]) => freq >= lo && freq <= hi);
+ }
+ bucketFor(freq) {
+ return Math.round(24 * Math.log2(freq / 100));
+ }
+ freqForBucket(bucket) {
+ return 100 * Math.pow(2, bucket / 24);
+ }
+
+ analyzeGlobalWatchdog() {
+ if (this.holds.size || !this.analyser) return;
+ const data = new Float32Array(this.analyser.frequencyBinCount);
+ this.analyser.getFloatFrequencyData(data);
+ const nyquist = this.am.ctx.sampleRate / 2,
+ binHz = nyquist / data.length,
+ candidates = [];
+ for (
+ let i = Math.max(3, Math.floor(220 / binHz));
+ i < Math.min(data.length - 4, Math.ceil(8500 / binHz));
+ i += 2
+ ) {
+ const freq = i * binHz;
+ if (this.isProtected(freq)) continue;
+ const db = data[i];
+ if (!Number.isFinite(db) || db < -68) continue;
+ const local = (data[i - 3] + data[i - 2] + data[i + 2] + data[i + 3]) / 4;
+ const prominence = db - local;
+ const threshold = 10.5 - this.level * 1.5;
+ if (prominence > threshold)
+ candidates.push({ freq, db, prominence, bucket: this.bucketFor(freq) });
+ }
+ candidates.sort((a, b) => b.prominence + b.db * 0.03 - (a.prominence + a.db * 0.03));
+ const seen = new Set();
+ for (const c of candidates.slice(0, 4)) {
+ if (seen.has(c.bucket)) continue;
+ seen.add(c.bucket);
+ const old = this.persistence.get(c.bucket) || { seconds: 0, prominence: 0 };
+ old.seconds += this.intervalMs / 1000;
+ old.prominence = old.prominence * 0.8 + c.prominence * 0.2;
+ this.persistence.set(c.bucket, old);
+ }
+ for (const [b, v] of [...this.persistence])
+ if (!seen.has(b)) {
+ v.seconds = Math.max(0, v.seconds - (this.intervalMs / 1000) * 1.5);
+ v.prominence *= 0.9;
+ if (v.seconds <= 0.01) this.persistence.delete(b);
+ }
+ this.updateGlobalInterventions();
+ }
+
+ updateGlobalInterventions() {
+ const profile = this.themeProfiles[this.themeId] || this.themeProfiles.starfleet;
+ const persistenceNeed = (95 - this.level * 20) / profile.bias;
+ const eligible = [...this.persistence.entries()]
+ .filter(([b, v]) => v.seconds >= persistenceNeed && !this.isProtected(this.freqForBucket(b)))
+ .sort((a, b) => b[1].seconds + b[1].prominence * 2 - (a[1].seconds + a[1].prominence * 2))
+ .slice(0, 2);
+ if (eligible.length) {
+ this.activeBuckets = eligible.map(([bucket, v]) => ({
+ bucket,
+ seconds: v.seconds,
+ prominence: v.prominence
+ }));
+ this.applyActiveFilters();
+ this.lastIntervention = performance.now();
+ } else if (this.activeBuckets.length && performance.now() - this.lastIntervention > 12000) {
+ this.activeBuckets = [];
+ this.releaseAll(20);
+ }
+ }
+
+ targetDepth() {
+ return -1.75 * (0.25 + this.level * 0.75);
+ }
+
+ applyActiveFilters() {
+ if (!this.filters.length) return;
+ const now = this.am.ctx.currentTime,
+ depth = this.targetDepth();
+ this.filters.forEach((f, i) => {
+ const a = this.activeBuckets[i];
+ f.frequency.cancelScheduledValues(now);
+ f.Q.cancelScheduledValues(now);
+ f.gain.cancelScheduledValues(now);
+ if (a) {
+ const freq = this.freqForBucket(a.bucket);
+ f.frequency.setTargetAtTime(freq, now, 4.5);
+ f.Q.setTargetAtTime(5.5 + this.level * 2, now, 5.5);
+ f.gain.setTargetAtTime(depth, now, 8);
+ } else f.gain.setTargetAtTime(0, now, 8);
+ });
+ }
+
+ applyCurrentDepths() {
+ if (this.activeBuckets.length) this.applyActiveFilters();
+ else if (this.level <= 0) this.releaseAll(1.5);
+ }
+ releaseAll(seconds = 4) {
+ if (!this.filters.length || !this.am.ctx) return;
+ const now = this.am.ctx.currentTime;
+ this.filters.forEach(f => {
+ f.gain.cancelScheduledValues(now);
+ f.gain.setTargetAtTime(0, now, Math.max(0.25, seconds / 4));
+ });
+ }
+ setState(state) {
+ this.state = state;
+ this.syncUI();
+ }
+
+ syncUI() {
+ if (this.ui.slider) this.ui.slider.value = this.level;
+ if (this.ui.value) this.ui.value.textContent = `${Math.round(this.level * 100)}%`;
+ if (this.ui.button) {
+ this.ui.button.textContent = this.enabled ? 'FNC ENABLED' : 'FNC BYPASS';
+ this.ui.button.classList.toggle('enabled', this.enabled);
+ this.ui.button.setAttribute('aria-pressed', String(this.enabled));
+ }
+ if (this.ui.state) {
+ this.ui.state.textContent = this.enabled ? this.state : 'OFF';
+ this.ui.state.className = `fnc-state ${(this.enabled ? this.state : 'OFF').toLowerCase()}`;
+ }
+ if (this.ui.detail && !this.enabled)
+ this.ui.detail.textContent =
+ 'FNC bypassed. Hull Drone, Worp Core, and Life Support play at their configured channel levels.';
+ else if (this.ui.detail && this.enabled && this.state === 'HOLD') {
+ const l = this.sourceReductionPercent('lifeSupport'),
+ h = this.sourceReductionPercent('hull'),
+ w = this.sourceReductionPercent('worp');
+ this.ui.detail.textContent = `Comfort suppression remains active during HOLD — Life Support ${l}%, Hull ${h}%, Worp Core ${w}%. Watchdog learning paused.`;
+ } else if (this.ui.detail && this.enabled) {
+ const l = this.sourceReductionPercent('lifeSupport'),
+ h = this.sourceReductionPercent('hull'),
+ w = this.sourceReductionPercent('worp');
+ this.ui.detail.textContent = `Comfort suppression active — Life Support ${l}%, Hull ${h}%, Worp Core ${w}% reduction.`;
+ }
+ }
+}
+window.FuturisticNoiceCancellation = FuturisticNoiceCancellation;
+
+
+/**
+ * Hull Drone & Environmental Sub-Bass Synthesizer
+ * Generates organic, continuous low-frequency starship structural vibration & room tone.
+ */
+
+class HullDroneSynth {
+ constructor(audioManager) {
+ this.am = audioManager;
+ this.nodes = [];
+ this.gainNode = null;
+ this.filterNode = null;
+ this.subOsc1 = null;
+ this.subOsc2 = null;
+ this.noiseSource = null;
+ this.isMuted = false;
+
+ // Default configuration parameters
+ this.params = {
+ volume: 0.7,
+ baseFreq: 50, // Fundamental frequency (e.g. 50Hz for TNG bridge)
+ filterCutoff: 110, // Lowpass filter cutoff
+ resonance: 2.5, // Filter Q / resonance peak
+ noiseMix: 0.45, // Brown noise texture mix
+ harmonicSpread: 1.02 // Slight frequency detune between sub-oscillators for phasing
+ };
+ }
+
+ start(rampDuration = 0) {
+ this.stop();
+ const ctx = this.am.ctx;
+ if (!ctx) return;
+
+ // Channel Gain Node
+ this.gainNode = ctx.createGain();
+ const targetVol = this.isMuted ? 0 : this.params.volume;
+ if (rampDuration > 0) {
+ this.gainNode.gain.setValueAtTime(0.0001, ctx.currentTime);
+ this.gainNode.gain.linearRampToValueAtTime(targetVol, ctx.currentTime + rampDuration);
+ } else {
+ this.gainNode.gain.setValueAtTime(targetVol, ctx.currentTime);
+ }
+
+ // Steep Lowpass Filter (24dB/oct via 2 cascading biquads)
+ this.filterNode = ctx.createBiquadFilter();
+ this.filterNode.type = 'lowpass';
+ this.filterNode.frequency.setValueAtTime(this.params.filterCutoff, ctx.currentTime);
+ this.filterNode.Q.setValueAtTime(this.params.resonance, ctx.currentTime);
+
+ const filterStage2 = ctx.createBiquadFilter();
+ filterStage2.type = 'lowpass';
+ filterStage2.frequency.setValueAtTime(this.params.filterCutoff * 1.5, ctx.currentTime);
+ filterStage2.Q.setValueAtTime(1.0, ctx.currentTime);
+
+ // Sub-bass Oscillator 1 (Sine)
+ this.subOsc1 = ctx.createOscillator();
+ this.subOsc1.type = 'sine';
+ this.subOsc1.frequency.setValueAtTime(this.params.baseFreq, ctx.currentTime);
+
+ const osc1Gain = ctx.createGain();
+ osc1Gain.gain.setValueAtTime(0.5, ctx.currentTime);
+ this.subOsc1.connect(osc1Gain);
+ osc1Gain.connect(this.filterNode);
+
+ // Sub-bass Oscillator 2 (Triangle/Sine detuned for slow, natural phase beating)
+ this.subOsc2 = ctx.createOscillator();
+ this.subOsc2.type = 'triangle';
+ this.subOsc2.frequency.setValueAtTime(this.params.baseFreq * this.params.harmonicSpread, ctx.currentTime);
+
+ const osc2Gain = ctx.createGain();
+ osc2Gain.gain.setValueAtTime(0.3, ctx.currentTime);
+ this.subOsc2.connect(osc2Gain);
+ osc2Gain.connect(this.filterNode);
+
+ // Brown Noise Structural Rumble Layer
+ const brownBuffer = this.am.createNoiseBuffer('brown', 6);
+ this.noiseSource = ctx.createBufferSource();
+ this.noiseSource.buffer = brownBuffer;
+ this.noiseSource.loop = true;
+
+ const noiseGain = ctx.createGain();
+ noiseGain.gain.setValueAtTime(this.params.noiseMix * 0.7, ctx.currentTime);
+ this.noiseSource.connect(noiseGain);
+ noiseGain.connect(this.filterNode);
+
+ // Slow LFO for organic drifting movement
+ const lfo = ctx.createOscillator();
+ lfo.type = 'sine';
+ lfo.frequency.setValueAtTime(0.1, ctx.currentTime); // 10 second cycle
+
+ const lfoGain = ctx.createGain();
+ lfoGain.gain.setValueAtTime(12, ctx.currentTime); // Modulate cutoff by ±12Hz
+ lfo.connect(lfoGain);
+ lfoGain.connect(this.filterNode.frequency);
+
+ // Connect Graph
+ this.filterNode.connect(filterStage2);
+ filterStage2.connect(this.gainNode);
+ if (window.fncSystem) window.fncSystem.connectSource('hull', this.gainNode, () => this.params);
+ else this.gainNode.connect(this.am.compressor);
+
+ // Start Sources
+ this.subOsc1.start();
+ this.subOsc2.start();
+ this.noiseSource.start();
+ lfo.start();
+
+ this.nodes = [this.subOsc1, this.subOsc2, this.noiseSource, lfo, osc1Gain, osc2Gain, noiseGain, lfoGain, this.filterNode, filterStage2, this.gainNode];
+ }
+
+ stop(fadeDuration = 0) {
+ if (fadeDuration > 0 && this.nodes.length > 0 && this.gainNode && this.am.ctx) {
+ const now = this.am.ctx.currentTime;
+ this.gainNode.gain.cancelScheduledValues(now);
+ this.gainNode.gain.setValueAtTime(Math.max(0.0001, this.gainNode.gain.value), now);
+ this.gainNode.gain.linearRampToValueAtTime(0.0001, now + fadeDuration);
+ if (this.stoppingTimeout) clearTimeout(this.stoppingTimeout);
+ this.stoppingTimeout = setTimeout(() => {
+ this.stop(0);
+ }, fadeDuration * 1000);
+ return;
+ }
+ if (this.stoppingTimeout) {
+ clearTimeout(this.stoppingTimeout);
+ this.stoppingTimeout = null;
+ }
+ if (this.nodes.length > 0) {
+ if (window.fncSystem) window.fncSystem.detachSource('hull');
+ try {
+ if (this.subOsc1) this.subOsc1.stop();
+ if (this.subOsc2) this.subOsc2.stop();
+ if (this.noiseSource) this.noiseSource.stop();
+ } catch (e) {
+ // Ignore if already stopped
+ }
+ this.nodes.forEach(node => {
+ try { node.disconnect(); } catch (e) {}
+ });
+ this.nodes = [];
+ this.subOsc1 = null;
+ this.subOsc2 = null;
+ this.noiseSource = null;
+ this.gainNode = null;
+ }
+ }
+
+ setVolume(val) {
+ this.params.volume = Math.max(0, Math.min(1, val));
+ if (this.gainNode && this.am.ctx && !this.isMuted) {
+ const now = this.am.ctx.currentTime;
+ this.gainNode.gain.cancelScheduledValues(now);
+ this.gainNode.gain.linearRampToValueAtTime(this.params.volume, now + 0.05);
+ }
+ }
+
+ setBaseFreq(freq) {
+ this.params.baseFreq = freq;
+ if (this.subOsc1 && this.subOsc2 && this.am.ctx) {
+ const now = this.am.ctx.currentTime;
+ this.subOsc1.frequency.linearRampToValueAtTime(freq, now + 0.1);
+ this.subOsc2.frequency.linearRampToValueAtTime(freq * this.params.harmonicSpread, now + 0.1);
+ }
+ }
+
+ setFilterCutoff(cutoff) {
+ this.params.filterCutoff = cutoff;
+ if (this.filterNode && this.am.ctx) {
+ const now = this.am.ctx.currentTime;
+ this.filterNode.frequency.linearRampToValueAtTime(cutoff, now + 0.1);
+ }
+ }
+
+ applyPreset(config) {
+ if (config.volume !== undefined) this.params.volume = config.volume;
+ if (config.baseFreq !== undefined) this.setBaseFreq(config.baseFreq);
+ if (config.filterCutoff !== undefined) this.setFilterCutoff(config.filterCutoff);
+ if (config.resonance !== undefined) this.params.resonance = config.resonance;
+ if (config.noiseMix !== undefined) this.params.noiseMix = config.noiseMix;
+ if (config.harmonicSpread !== undefined) this.params.harmonicSpread = config.harmonicSpread;
+ this.setVolume(this.params.volume);
+ }
+}
+
+window.HullDroneSynth = HullDroneSynth;
+
+
+/**
+ * Worp Core & Reactor Pulse Synthesizer
+ * Generates the iconic pulsating magnetic intermix thrum of Star Trek worp cores.
+ */
+
+class WarpCoreSynth {
+ constructor(audioManager) {
+ this.am = audioManager;
+ this.nodes = [];
+ this.gainNode = null;
+ this.isMuted = false;
+
+ // Pulse parameters
+ this.params = {
+ volume: 0.8,
+ bpm: 48, // Pulse rate (TNG is ~46-52 BPM, Wayfarer is ~68-75 BPM)
+ carrierFreq: 58, // Fundamental carrier pitch (Hz)
+ modFreqRatio: 2.0, // FM modulation frequency multiplier
+ modIndex: 40, // FM modulation depth
+ filterCutoff: 180, // Lowpass filter cutoff
+ pulseShape: 'tng', // 'tng', 'voyager', 'tos', 'defiant', 'nx'
+ resonance: 3.0,
+ swirlMix: 0.35 // Stereo phase swirl
+ };
+
+ this.pulseInterval = null;
+ this.pulsePhase = 0;
+ this.onPulse = null; // Callback for UI visualizer pulse animation!
+ }
+
+ start(rampDuration = 0) {
+ this.stop();
+ const ctx = this.am.ctx;
+ if (!ctx) return;
+
+ this.gainNode = ctx.createGain();
+ const targetVol = this.isMuted ? 0 : this.params.volume;
+ if (rampDuration > 0) {
+ this.gainNode.gain.setValueAtTime(0.0001, ctx.currentTime);
+ this.gainNode.gain.linearRampToValueAtTime(targetVol, ctx.currentTime + rampDuration);
+ } else {
+ this.gainNode.gain.setValueAtTime(targetVol, ctx.currentTime);
+ }
+
+ // Filter Node
+ this.filterNode = ctx.createBiquadFilter();
+ this.filterNode.type = 'lowpass';
+ this.filterNode.frequency.setValueAtTime(this.params.filterCutoff, ctx.currentTime);
+ this.filterNode.Q.setValueAtTime(this.params.resonance, ctx.currentTime);
+
+ // Stereo Panner for magnetic swirl
+ this.panner = ctx.createStereoPanner ? ctx.createStereoPanner() : null;
+
+ // Carrier & Modulator Oscillators (FM Engine)
+ this.carrier = ctx.createOscillator();
+ this.carrier.type = this.params.pulseShape === 'tos' ? 'sawtooth' : (this.params.pulseShape === 'nx' ? 'triangle' : 'sine');
+ if (rampDuration > 0) {
+ this.carrier.frequency.setValueAtTime(Math.max(20, this.params.carrierFreq * 0.45), ctx.currentTime);
+ this.carrier.frequency.exponentialRampToValueAtTime(this.params.carrierFreq, ctx.currentTime + rampDuration);
+ } else {
+ this.carrier.frequency.setValueAtTime(this.params.carrierFreq, ctx.currentTime);
+ }
+
+ // Sub-harmonic oscillator for massive bottom end
+ this.subOsc = ctx.createOscillator();
+ this.subOsc.type = 'sine';
+ if (rampDuration > 0) {
+ this.subOsc.frequency.setValueAtTime(Math.max(12, this.params.carrierFreq * 0.22), ctx.currentTime);
+ this.subOsc.frequency.exponentialRampToValueAtTime(this.params.carrierFreq * 0.5, ctx.currentTime + rampDuration);
+ } else {
+ this.subOsc.frequency.setValueAtTime(this.params.carrierFreq * 0.5, ctx.currentTime);
+ }
+
+ const subGain = ctx.createGain();
+ subGain.gain.setValueAtTime(0.6, ctx.currentTime);
+ this.subOsc.connect(subGain);
+ subGain.connect(this.filterNode);
+
+ // Pulse Envelope Modulator Gain Node
+ this.pulseGain = ctx.createGain();
+ this.pulseGain.gain.setValueAtTime(0.2, ctx.currentTime);
+
+ // Connect Carrier -> PulseGain -> Filter -> Panner -> ChannelGain -> Master
+ this.carrier.connect(this.pulseGain);
+ this.pulseGain.connect(this.filterNode);
+
+ if (this.panner) {
+ this.filterNode.connect(this.panner);
+ this.panner.connect(this.gainNode);
+ } else {
+ this.filterNode.connect(this.gainNode);
+ }
+
+ if (window.fncSystem) window.fncSystem.connectSource('worp', this.gainNode, () => this.params);
+ else this.gainNode.connect(this.am.compressor);
+
+ this.carrier.start();
+ this.subOsc.start();
+
+ this.nodes = [this.carrier, this.subOsc, subGain, this.pulseGain, this.filterNode, this.gainNode];
+ if (this.panner) this.nodes.push(this.panner);
+
+ // Start precision pulse scheduler
+ this.startPulseLoop();
+ }
+
+ startPulseLoop() {
+ if (this.pulseInterval) clearInterval(this.pulseInterval);
+
+ const intervalMs = (60 / this.params.bpm) * 1000;
+ this.scheduleNextPulse();
+
+ this.pulseInterval = setInterval(() => {
+ this.scheduleNextPulse();
+ }, intervalMs);
+ }
+
+ scheduleNextPulse() {
+ const ctx = this.am.ctx;
+ if (!ctx || !this.pulseGain || !this.filterNode) return;
+
+ const now = ctx.currentTime;
+ const pulseDuration = (60 / this.params.bpm);
+
+ this.pulsePhase = (this.pulsePhase + 1) % 4;
+
+ // Trigger visualizer callback
+ if (this.onPulse) {
+ this.onPulse(this.pulsePhase, pulseDuration);
+ }
+
+ // Dynamic envelope shaping based on ship era
+ if (this.params.pulseShape === 'tng') {
+ // Iconic 4-stage Galaxy-class magnetic warp pulse
+ // Soft attack, deep swelling peak, secondary reverberant harmonic bloom, smooth decay
+ const peakTime = now + pulseDuration * 0.28;
+ const secondPeak = now + pulseDuration * 0.58;
+
+ this.pulseGain.gain.cancelScheduledValues(now);
+ this.pulseGain.gain.setValueAtTime(0.18, now);
+ this.pulseGain.gain.linearRampToValueAtTime(0.95, peakTime);
+ this.pulseGain.gain.exponentialRampToValueAtTime(0.45, now + pulseDuration * 0.42);
+ this.pulseGain.gain.linearRampToValueAtTime(0.65, secondPeak);
+ this.pulseGain.gain.exponentialRampToValueAtTime(0.18, now + pulseDuration * 0.95);
+
+ // Modulate filter cutoff in sync with the pulse
+ this.filterNode.frequency.cancelScheduledValues(now);
+ this.filterNode.frequency.setValueAtTime(this.params.filterCutoff * 0.7, now);
+ this.filterNode.frequency.exponentialRampToValueAtTime(this.params.filterCutoff * 1.6, peakTime);
+ this.filterNode.frequency.exponentialRampToValueAtTime(this.params.filterCutoff * 0.7, now + pulseDuration * 0.95);
+
+ } else if (this.params.pulseShape === 'voyager') {
+ // Faster, sharper, higher-resonance Class 9 worp core
+ const peakTime = now + pulseDuration * 0.2;
+ this.pulseGain.gain.cancelScheduledValues(now);
+ this.pulseGain.gain.setValueAtTime(0.25, now);
+ this.pulseGain.gain.linearRampToValueAtTime(1.0, peakTime);
+ this.pulseGain.gain.exponentialRampToValueAtTime(0.25, now + pulseDuration * 0.85);
+
+ this.filterNode.frequency.cancelScheduledValues(now);
+ this.filterNode.frequency.linearRampToValueAtTime(this.params.filterCutoff * 1.8, peakTime);
+ this.filterNode.frequency.linearRampToValueAtTime(this.params.filterCutoff * 0.8, now + pulseDuration * 0.85);
+
+ } else if (this.params.pulseShape === 'tos') {
+ // TOS Electromechanical oscillating engine thrum
+ const halfTime = now + pulseDuration * 0.5;
+ this.pulseGain.gain.cancelScheduledValues(now);
+ this.pulseGain.gain.setValueAtTime(0.4, now);
+ this.pulseGain.gain.linearRampToValueAtTime(0.9, halfTime);
+ this.pulseGain.gain.linearRampToValueAtTime(0.4, now + pulseDuration);
+
+ } else if (this.params.pulseShape === 'defiant') {
+ // Defiance: tight, aggressive pulse with rapid decay
+ const peakTime = now + pulseDuration * 0.15;
+ this.pulseGain.gain.cancelScheduledValues(now);
+ this.pulseGain.gain.setValueAtTime(0.3, now);
+ this.pulseGain.gain.linearRampToValueAtTime(1.0, peakTime);
+ this.pulseGain.gain.exponentialRampToValueAtTime(0.3, now + pulseDuration * 0.75);
+
+ } else {
+ // NX / Industrial reactor chug
+ const peakTime = now + pulseDuration * 0.35;
+ this.pulseGain.gain.cancelScheduledValues(now);
+ this.pulseGain.gain.setValueAtTime(0.2, now);
+ this.pulseGain.gain.linearRampToValueAtTime(0.85, peakTime);
+ this.pulseGain.gain.linearRampToValueAtTime(0.2, now + pulseDuration);
+ }
+
+ // Subtle stereo panning drift
+ if (this.panner && this.params.swirlMix > 0) {
+ const panTarget = Math.sin(this.pulsePhase * Math.PI * 0.5) * this.params.swirlMix;
+ this.panner.pan.linearRampToValueAtTime(panTarget, now + pulseDuration * 0.5);
+ }
+ }
+
+ setBpm(bpm) {
+ this.params.bpm = Math.max(20, Math.min(160, bpm));
+ if (this.nodes.length > 0) {
+ this.startPulseLoop();
+ }
+ }
+
+ setVolume(val) {
+ this.params.volume = Math.max(0, Math.min(1, val));
+ if (this.gainNode && this.am.ctx && !this.isMuted) {
+ const now = this.am.ctx.currentTime;
+ this.gainNode.gain.cancelScheduledValues(now);
+ this.gainNode.gain.linearRampToValueAtTime(this.params.volume, now + 0.05);
+ }
+ }
+
+ setCarrierFreq(freq) {
+ this.params.carrierFreq = freq;
+ if (this.carrier && this.subOsc && this.am.ctx) {
+ const now = this.am.ctx.currentTime;
+ this.carrier.frequency.linearRampToValueAtTime(freq, now + 0.1);
+ this.subOsc.frequency.linearRampToValueAtTime(freq * 0.5, now + 0.1);
+ }
+ }
+
+ stop(fadeDuration = 0) {
+ if (fadeDuration > 0 && this.nodes.length > 0 && this.gainNode && this.am.ctx) {
+ const now = this.am.ctx.currentTime;
+ this.gainNode.gain.cancelScheduledValues(now);
+ this.gainNode.gain.setValueAtTime(Math.max(0.0001, this.gainNode.gain.value), now);
+ this.gainNode.gain.linearRampToValueAtTime(0.0001, now + fadeDuration);
+ if (this.carrier) {
+ try {
+ this.carrier.frequency.cancelScheduledValues(now);
+ this.carrier.frequency.setValueAtTime(this.carrier.frequency.value, now);
+ this.carrier.frequency.linearRampToValueAtTime(Math.max(20, this.params.carrierFreq * 0.35), now + fadeDuration);
+ } catch (e) {}
+ }
+ if (this.stoppingTimeout) clearTimeout(this.stoppingTimeout);
+ this.stoppingTimeout = setTimeout(() => {
+ this.stop(0);
+ }, fadeDuration * 1000);
+ return;
+ }
+ if (this.stoppingTimeout) {
+ clearTimeout(this.stoppingTimeout);
+ this.stoppingTimeout = null;
+ }
+ if (this.pulseInterval) {
+ clearInterval(this.pulseInterval);
+ this.pulseInterval = null;
+ }
+ if (this.nodes.length > 0) {
+ if (window.fncSystem) window.fncSystem.detachSource('worp');
+ try {
+ if (this.carrier) this.carrier.stop();
+ if (this.subOsc) this.subOsc.stop();
+ } catch (e) {}
+ this.nodes.forEach(node => {
+ try { node.disconnect(); } catch (e) {}
+ });
+ this.nodes = [];
+ this.carrier = null;
+ this.subOsc = null;
+ this.gainNode = null;
+ }
+ }
+
+ applyPreset(config) {
+ if (config.volume !== undefined) this.params.volume = config.volume;
+ if (config.bpm !== undefined) this.setBpm(config.bpm);
+ if (config.carrierFreq !== undefined) this.setCarrierFreq(config.carrierFreq);
+ if (config.filterCutoff !== undefined) this.params.filterCutoff = config.filterCutoff;
+ if (config.pulseShape !== undefined) this.params.pulseShape = config.pulseShape;
+ if (config.resonance !== undefined) this.params.resonance = config.resonance;
+ if (config.swirlMix !== undefined) this.params.swirlMix = config.swirlMix;
+ this.setVolume(this.params.volume);
+ }
+}
+
+window.WarpCoreSynth = WarpCoreSynth;
+
+
+/**
+ * Environmental Life Support & Airflow Synthesizer
+ * Generates continuous ventilation airflow, atmospheric hiss, and room acoustic damping.
+ */
+
+class LifeSupportSynth {
+ constructor(audioManager) {
+ this.am = audioManager;
+ this.nodes = [];
+ this.gainNode = null;
+ this.isMuted = false;
+
+ this.params = {
+ volume: 0.5,
+ noiseType: 'pink', // 'pink' (warm TNG), 'white' (crisp Wayfarer), 'brown' (heavy NX-01)
+ highpassFreq: 180, // Cuts extreme sub rumble to isolate air movement
+ lowpassFreq: 1800, // Gentle top-end rolloff
+ airflowModSpeed: 0.15, // Subtle breathing movement of the environmental airflow
+ airflowModDepth: 0.12 // Depth of airflow intensity modulation
+ };
+ }
+
+ start(rampDuration = 0) {
+ this.stop();
+ const ctx = this.am.ctx;
+ if (!ctx) return;
+
+ this.gainNode = ctx.createGain();
+ const targetVol = this.isMuted ? 0 : this.params.volume;
+ if (rampDuration > 0) {
+ this.gainNode.gain.setValueAtTime(0.0001, ctx.currentTime);
+ this.gainNode.gain.linearRampToValueAtTime(targetVol, ctx.currentTime + rampDuration);
+ } else {
+ this.gainNode.gain.setValueAtTime(targetVol, ctx.currentTime);
+ }
+
+ // Highpass filter (cuts muddy lows)
+ const hpFilter = ctx.createBiquadFilter();
+ hpFilter.type = 'highpass';
+ hpFilter.frequency.setValueAtTime(this.params.highpassFreq, ctx.currentTime);
+
+ // Lowpass filter (shapes the crispness vs warmth of the air)
+ this.lpFilter = ctx.createBiquadFilter();
+ this.lpFilter.type = 'lowpass';
+ this.lpFilter.frequency.setValueAtTime(this.params.lowpassFreq, ctx.currentTime);
+ this.lpFilter.Q.setValueAtTime(0.7, ctx.currentTime);
+
+ // Noise buffer source
+ const noiseBuffer = this.am.createNoiseBuffer(this.params.noiseType, 6);
+ this.noiseSource = ctx.createBufferSource();
+ this.noiseSource.buffer = noiseBuffer;
+ this.noiseSource.loop = true;
+
+ // Slow airflow modulation LFO for organic breath
+ const airflowLfo = ctx.createOscillator();
+ airflowLfo.type = 'sine';
+ airflowLfo.frequency.setValueAtTime(this.params.airflowModSpeed, ctx.currentTime);
+
+ const lfoGain = ctx.createGain();
+ lfoGain.gain.setValueAtTime(this.params.airflowModDepth, ctx.currentTime);
+
+ const modGain = ctx.createGain();
+ modGain.gain.setValueAtTime(0.8, ctx.currentTime);
+
+ airflowLfo.connect(lfoGain);
+ lfoGain.connect(modGain.gain);
+
+ // Stereo widener using delay
+ const splitter = ctx.createChannelSplitter(2);
+ const merger = ctx.createChannelMerger(2);
+ const delayRight = ctx.createDelay();
+ delayRight.delayTime.setValueAtTime(0.018, ctx.currentTime); // 18ms Haas effect widening
+
+ // Graph: Noise -> ModGain -> HP -> LP -> Splitter -> (Left direct, Right delay) -> Merger -> Gain -> Compressor
+ this.noiseSource.connect(modGain);
+ modGain.connect(hpFilter);
+ hpFilter.connect(this.lpFilter);
+ this.lpFilter.connect(splitter);
+
+ splitter.connect(merger, 0, 0); // Left channel
+ splitter.connect(delayRight, 1);
+ delayRight.connect(merger, 0, 1); // Right delayed channel
+
+ merger.connect(this.gainNode);
+ if (window.fncSystem) window.fncSystem.connectSource('lifeSupport', this.gainNode, () => this.params);
+ else this.gainNode.connect(this.am.compressor);
+
+ this.noiseSource.start();
+ airflowLfo.start();
+
+ this.nodes = [
+ this.noiseSource, airflowLfo, lfoGain, modGain,
+ hpFilter, this.lpFilter, splitter, delayRight, merger, this.gainNode
+ ];
+ }
+
+ stop(fadeDuration = 0) {
+ if (fadeDuration > 0 && this.nodes.length > 0 && this.gainNode && this.am.ctx) {
+ const now = this.am.ctx.currentTime;
+ this.gainNode.gain.cancelScheduledValues(now);
+ this.gainNode.gain.setValueAtTime(Math.max(0.0001, this.gainNode.gain.value), now);
+ this.gainNode.gain.linearRampToValueAtTime(0.0001, now + fadeDuration);
+ if (this.stoppingTimeout) clearTimeout(this.stoppingTimeout);
+ this.stoppingTimeout = setTimeout(() => {
+ this.stop(0);
+ }, fadeDuration * 1000);
+ return;
+ }
+ if (this.stoppingTimeout) {
+ clearTimeout(this.stoppingTimeout);
+ this.stoppingTimeout = null;
+ }
+ if (this.nodes.length > 0) {
+ if (window.fncSystem) window.fncSystem.detachSource('lifeSupport');
+ try {
+ if (this.noiseSource) this.noiseSource.stop();
+ } catch (e) {}
+ this.nodes.forEach(node => {
+ try { node.disconnect(); } catch (e) {}
+ });
+ this.nodes = [];
+ this.noiseSource = null;
+ this.gainNode = null;
+ }
+ }
+
+ setVolume(val) {
+ this.params.volume = Math.max(0, Math.min(1, val));
+ if (this.gainNode && this.am.ctx && !this.isMuted) {
+ const now = this.am.ctx.currentTime;
+ this.gainNode.gain.cancelScheduledValues(now);
+ this.gainNode.gain.linearRampToValueAtTime(this.params.volume, now + 0.05);
+ }
+ }
+
+ setFilterCutoff(freq) {
+ this.params.lowpassFreq = freq;
+ if (this.lpFilter && this.am.ctx) {
+ const now = this.am.ctx.currentTime;
+ this.lpFilter.frequency.linearRampToValueAtTime(freq, now + 0.1);
+ }
+ }
+
+ applyPreset(config) {
+ if (config.volume !== undefined) this.params.volume = config.volume;
+ if (config.noiseType !== undefined) this.params.noiseType = config.noiseType;
+ if (config.highpassFreq !== undefined) this.params.highpassFreq = config.highpassFreq;
+ if (config.lowpassFreq !== undefined) this.setFilterCutoff(config.lowpassFreq);
+ if (config.airflowModSpeed !== undefined) this.params.airflowModSpeed = config.airflowModSpeed;
+ if (config.airflowModDepth !== undefined) this.params.airflowModDepth = config.airflowModDepth;
+ this.setVolume(this.params.volume);
+ }
+}
+
+window.LifeSupportSynth = LifeSupportSynth;
+
+
+/**
+ * Procedural Starship Telemetry, LCARD Chirps, Beeps & Console Synthesizer
+ * 100% synthesized programmatically via Web Audio API oscillators, FM synthesis, and envelopes.
+ * Zero stored audio samples.
+ */
+
+class TelemetrySynth {
+ constructor(audioManager) {
+ this.am = audioManager;
+ this.gainNode = null;
+ this.isMuted = false;
+ this.schedulerTimer = null;
+
+ this.params = {
+ volume: 0.4,
+ density: 0.5, // How often background telemetry chirps occur (0 = off, 1 = busy bridge)
+ era: 'tng', // 'tng', 'voyager', 'tos', 'ds9', 'nx', 'whataverse', 'bioship', 'belter'
+ reverbMix: 0.25
+ };
+
+ // Musical pitch frequencies for authentic LCARD musical intervals (major/minor pentatonic & perfect 4ths/5ths)
+ this.lcarsPitches = [
+ 880, 987.77, 1046.50, 1174.66, 1318.51, 1396.91, 1567.98, 1760, 1975.53, 2093.00, 2349.32, 2637.02
+ ];
+
+ // TOS Bridge oscillator warble frequencies
+ this.tosFrequencies = [
+ 440, 554.37, 659.25, 830.61, 880, 1108.73, 1318.51, 1661.22, 2217.46
+ ];
+ }
+
+ /**
+ * Lazily creates the telemetry gain node so soundboard one-shots (LCARD
+ * chirps, era-specific one-shots) work standalone even before the ambient
+ * bed has been engaged via start(). Mirrors the init() self-heal pattern
+ * used by ExpandedSciFiAudioSynth and WhataverseAudioSynth. start() below
+ * still unconditionally rebuilds the node on every engage so a fresh
+ * preset's volume takes effect immediately; this only fills the gap
+ * before that has ever happened.
+ */
+ init() {
+ if (this.gainNode || !this.am.ctx) return;
+ const ctx = this.am.ctx;
+ this.gainNode = ctx.createGain();
+ this.gainNode.gain.setValueAtTime(this.isMuted ? 0 : this.params.volume, ctx.currentTime);
+ this.gainNode.connect(this.am.compressor);
+ }
+
+ start() {
+ this.stop();
+ const ctx = this.am.ctx;
+ if (!ctx) return;
+
+ this.gainNode = ctx.createGain();
+ this.gainNode.gain.setValueAtTime(this.isMuted ? 0 : this.params.volume, ctx.currentTime);
+ this.gainNode.connect(this.am.compressor);
+
+ this.startAutoTelemetryScheduler();
+ }
+
+ stop() {
+ if (this.schedulerTimer) {
+ clearTimeout(this.schedulerTimer);
+ this.schedulerTimer = null;
+ }
+ }
+
+ setVolume(val) {
+ this.params.volume = Math.max(0, Math.min(1, val));
+ if (this.gainNode && this.am.ctx && !this.isMuted) {
+ const now = this.am.ctx.currentTime;
+ this.gainNode.gain.cancelScheduledValues(now);
+ this.gainNode.gain.linearRampToValueAtTime(this.params.volume, now + 0.05);
+ }
+ }
+
+ setDensity(val) {
+ this.params.density = Math.max(0, Math.min(1, val));
+ }
+
+ startAutoTelemetryScheduler() {
+ if (this.schedulerTimer) clearTimeout(this.schedulerTimer);
+ if (this.params.density <= 0.01) return;
+
+ // Calculate delay inversely proportional to density (2s to 12s)
+ const baseDelay = 12000 * (1.05 - this.params.density);
+ const jitter = Math.random() * 4000;
+ let nextInterval = Math.max(800, baseDelay + jitter);
+ if (['whataverse', 'tardix', 'what', 'who'].includes(this.params.era)) {
+ nextInterval = Math.max(1200, nextInterval * 0.72);
+ }
+
+ this.schedulerTimer = setTimeout(() => {
+ this.playRandomTelemetrySound();
+ this.startAutoTelemetryScheduler();
+ }, nextInterval);
+ }
+
+ playRandomTelemetrySound() {
+ if (this.isMuted || this.params.volume <= 0.01 || !this.am.ctx) return;
+
+ switch (this.params.era) {
+ case 'whataverse':
+ case 'tardix':
+ case 'what':
+ case 'who': // legacy preset compatibility
+ this.synthesizeWhataverseMechanicalTelemetry_();
+ break;
+ case 'bioship':
+ this.synthesizeBioshipOrganicTelemetry_();
+ break;
+ case 'belter':
+ this.synthesizeBeltworkerJuryRiggedTelemetry_();
+ break;
+ case 'tos':
+ Math.random() > 0.4 ? this.synthesizeTOSWarble() : this.synthesizeTOSRelayClick();
+ break;
+ case 'ds9':
+ Math.random() > 0.5 ? this.synthesizeCardassianSensor() : this.synthesizeLCARDSingleChirp();
+ break;
+ case 'voyager': {
+ const r = Math.random();
+ if (r < 0.12) this.synthesizeCommBadge();
+ else if (r < 0.55) this.synthesizeLCARDDoubleChirp();
+ else this.synthesizeSensorSweep();
+ break;
+ }
+ case 'nx':
+ Math.random() > 0.5 ? this.synthesizeNXRelay() : this.synthesizeNXIndicatorBeep();
+ break;
+ case 'tng':
+ default: {
+ const r = Math.random();
+ if (r < 0.10) this.synthesizeCommBadge();
+ else if (r < 0.45) this.synthesizeLCARDSingleChirp();
+ else if (r < 0.72) this.synthesizeLCARDDoubleChirp();
+ else if (r < 0.88) this.synthesizeLCARDSequence();
+ else this.synthesizeSensorSweep();
+ break;
+ }
+ }
+
+ // OBSERVATION intentionally treats telemetry as an abstract activity pulse,
+ // not as a claim that a specific fictional beep means a specific thing.
+ window.dispatchEvent(new CustomEvent('scifi-telemetry-activity', {
+ detail: {
+ era: this.params.era,
+ density: this.params.density,
+ firedAt: performance.now()
+ }
+ }));
+ }
+
+ /**
+ * Star Trek Com Badge Confirmation Chirp
+ */
+ synthesizeCommBadge() {
+ this.init();
+ if (window.expandedAudio) {
+ window.expandedAudio.synthesizeCommBadge();
+ return;
+ }
+ const ctx = this.am.ctx;
+ if (!ctx || !this.gainNode) return;
+ const now = ctx.currentTime;
+ [784, 1396.91].forEach((freq, idx) => {
+ const t = now + idx * 0.038;
+ const osc = ctx.createOscillator();
+ osc.type = 'sine';
+ osc.frequency.setValueAtTime(freq, t);
+ const env = ctx.createGain();
+ env.gain.setValueAtTime(0.001, t);
+ env.gain.linearRampToValueAtTime(0.35, t + 0.005);
+ env.gain.exponentialRampToValueAtTime(0.001, t + (idx === 0 ? 0.045 : 0.09));
+ osc.connect(env);
+ env.connect(this.gainNode);
+ osc.start(t);
+ osc.stop(t + (idx === 0 ? 0.05 : 0.095));
+ });
+ }
+
+ /**
+ * TNG/Wayfarer Single LCARD Touch Tone (Soft sine with gentle attack and rapid exponential decay)
+ */
+ synthesizeLCARDSingleChirp(pitch = null) {
+ this.init();
+ const ctx = this.am.ctx;
+ if (!ctx || !this.gainNode) return;
+
+ const now = ctx.currentTime;
+ const freq = pitch || this.lcarsPitches[Math.floor(Math.random() * this.lcarsPitches.length)];
+ const duration = 0.09;
+
+ const osc = ctx.createOscillator();
+ osc.type = 'sine';
+ osc.frequency.setValueAtTime(freq, now);
+ // Subtle downward micro-pitch glide (12Hz) for that warm capacitive touch feel
+ osc.frequency.exponentialRampToValueAtTime(freq * 0.98, now + duration);
+
+ const env = ctx.createGain();
+ env.gain.setValueAtTime(0.001, now);
+ env.gain.linearRampToValueAtTime(0.35, now + 0.008);
+ env.gain.exponentialRampToValueAtTime(0.0001, now + duration);
+
+ // Filter to eliminate any click
+ const filter = ctx.createBiquadFilter();
+ filter.type = 'lowpass';
+ filter.frequency.setValueAtTime(3200, now);
+
+ osc.connect(env);
+ env.connect(filter);
+ filter.connect(this.gainNode);
+
+ osc.start(now);
+ osc.stop(now + duration);
+ }
+
+ /**
+ * TNG LCARD Double Chirp (Iconic standard confirmation tone)
+ */
+ synthesizeLCARDDoubleChirp() {
+ this.init();
+ const ctx = this.am.ctx;
+ if (!ctx) return;
+ const idx = Math.floor(Math.random() * (this.lcarsPitches.length - 2));
+ const p1 = this.lcarsPitches[idx];
+ const p2 = this.lcarsPitches[idx + 2]; // Minor third or fourth higher
+
+ this.synthesizeLCARDSingleChirp(p1);
+ setTimeout(() => {
+ this.synthesizeLCARDSingleChirp(p2);
+ }, 65);
+ }
+
+ /**
+ * TNG LCARD Multi-Tone Data Acknowledgment Sequence
+ */
+ synthesizeLCARDSequence() {
+ this.init();
+ const ctx = this.am.ctx;
+ if (!ctx) return;
+ const notes = [
+ this.lcarsPitches[Math.floor(Math.random() * 4) + 4],
+ this.lcarsPitches[Math.floor(Math.random() * 4) + 6],
+ this.lcarsPitches[Math.floor(Math.random() * 4) + 2]
+ ];
+
+ notes.forEach((freq, i) => {
+ setTimeout(() => {
+ this.synthesizeLCARDSingleChirp(freq);
+ }, i * 75);
+ });
+ }
+
+ /**
+ * High-tech Sensor Sweep Tone (Wayfarer/TNG Long-Range Sensor telemetry)
+ */
+ synthesizeSensorSweep() {
+ this.init();
+ const ctx = this.am.ctx;
+ if (!ctx || !this.gainNode) return;
+
+ const now = ctx.currentTime;
+ const duration = 0.38;
+ const startFreq = 1200 + Math.random() * 800;
+ const endFreq = startFreq * (Math.random() > 0.5 ? 1.6 : 0.65);
+
+ const osc = ctx.createOscillator();
+ osc.type = 'sine';
+ osc.frequency.setValueAtTime(startFreq, now);
+ osc.frequency.exponentialRampToValueAtTime(endFreq, now + duration);
+
+ const env = ctx.createGain();
+ env.gain.setValueAtTime(0.001, now);
+ env.gain.linearRampToValueAtTime(0.18, now + 0.05);
+ env.gain.exponentialRampToValueAtTime(0.0001, now + duration);
+
+ osc.connect(env);
+ env.connect(this.gainNode);
+
+ osc.start(now);
+ osc.stop(now + duration);
+ }
+
+ /**
+ * TOS Original Series Bridge Electronic Computer Warble
+ * Two detuned square/triangle oscillators modulated by high-speed vibrato LFO
+ */
+ synthesizeTOSWarble() {
+ this.init();
+ const ctx = this.am.ctx;
+ if (!ctx || !this.gainNode) return;
+
+ const now = ctx.currentTime;
+ const duration = 0.45;
+ const baseFreq = this.tosFrequencies[Math.floor(Math.random() * this.tosFrequencies.length)];
+
+ const osc1 = ctx.createOscillator();
+ osc1.type = 'triangle';
+ osc1.frequency.setValueAtTime(baseFreq, now);
+
+ const osc2 = ctx.createOscillator();
+ osc2.type = 'sawtooth';
+ osc2.frequency.setValueAtTime(baseFreq * 1.5, now);
+
+ // Fast Vibrato LFO
+ const lfo = ctx.createOscillator();
+ lfo.type = 'sine';
+ lfo.frequency.setValueAtTime(14 + Math.random() * 8, now); // 14-22 Hz warble
+
+ const lfoGain = ctx.createGain();
+ lfoGain.gain.setValueAtTime(35, now);
+ lfo.connect(lfoGain);
+ lfoGain.connect(osc1.frequency);
+ lfoGain.connect(osc2.frequency);
+
+ const env = ctx.createGain();
+ env.gain.setValueAtTime(0.001, now);
+ env.gain.linearRampToValueAtTime(0.22, now + 0.04);
+ env.gain.setValueAtTime(0.22, now + duration * 0.7);
+ env.gain.exponentialRampToValueAtTime(0.0001, now + duration);
+
+ // Bandpass filter to create that vintage analog 1960s telephone/relay resonance
+ const filter = ctx.createBiquadFilter();
+ filter.type = 'bandpass';
+ filter.frequency.setValueAtTime(baseFreq * 1.2, now);
+ filter.Q.setValueAtTime(3.5, now);
+
+ osc1.connect(env);
+ osc2.connect(env);
+ env.connect(filter);
+ filter.connect(this.gainNode);
+
+ osc1.start(now);
+ osc2.start(now);
+ lfo.start(now);
+
+ osc1.stop(now + duration);
+ osc2.stop(now + duration);
+ lfo.stop(now + duration);
+ }
+
+ /**
+ * TOS Mechanical Relay Solenoid Click
+ */
+ synthesizeTOSRelayClick() {
+ this.init();
+ const ctx = this.am.ctx;
+ if (!ctx || !this.gainNode) return;
+
+ const now = ctx.currentTime;
+ const duration = 0.025;
+
+ const osc = ctx.createOscillator();
+ osc.type = 'square';
+ osc.frequency.setValueAtTime(1400, now);
+ osc.frequency.exponentialRampToValueAtTime(300, now + duration);
+
+ const env = ctx.createGain();
+ env.gain.setValueAtTime(0.3, now);
+ env.gain.exponentialRampToValueAtTime(0.001, now + duration);
+
+ osc.connect(env);
+ env.connect(this.gainNode);
+
+ osc.start(now);
+ osc.stop(now + duration);
+ }
+
+ /**
+ * DS9 / Cardassian Cavernous Sensor Tone (Resonant metallic ring)
+ */
+ synthesizeCardassianSensor() {
+ this.init();
+ const ctx = this.am.ctx;
+ if (!ctx || !this.gainNode) return;
+
+ const now = ctx.currentTime;
+ const duration = 0.55;
+ const freq = 420 + Math.random() * 200;
+
+ const osc1 = ctx.createOscillator();
+ osc1.type = 'sine';
+ osc1.frequency.setValueAtTime(freq, now);
+
+ const osc2 = ctx.createOscillator();
+ osc2.type = 'sine';
+ osc2.frequency.setValueAtTime(freq * 1.414, now); // Tritone metallic dissonance
+
+ const env = ctx.createGain();
+ env.gain.setValueAtTime(0.001, now);
+ env.gain.linearRampToValueAtTime(0.2, now + 0.015);
+ env.gain.exponentialRampToValueAtTime(0.0001, now + duration);
+
+ osc1.connect(env);
+ osc2.connect(env);
+ env.connect(this.gainNode);
+
+ osc1.start(now);
+ osc2.start(now);
+ osc1.stop(now + duration);
+ osc2.stop(now + duration);
+ }
+
+ /**
+ * NX-01 Industrial Hydraulic Relay Click
+ */
+ synthesizeNXRelay() {
+ this.init();
+ const ctx = this.am.ctx;
+ if (!ctx || !this.gainNode) return;
+
+ const now = ctx.currentTime;
+ const duration = 0.04;
+
+ const osc = ctx.createOscillator();
+ osc.type = 'triangle';
+ osc.frequency.setValueAtTime(750, now);
+ osc.frequency.exponentialRampToValueAtTime(120, now + duration);
+
+ const env = ctx.createGain();
+ env.gain.setValueAtTime(0.25, now);
+ env.gain.exponentialRampToValueAtTime(0.001, now + duration);
+
+ osc.connect(env);
+ env.connect(this.gainNode);
+
+ osc.start(now);
+ osc.stop(now + duration);
+ }
+
+ /**
+ * NX-01 Indicator Beep (Early 22nd century industrial tone)
+ */
+ synthesizeNXIndicatorBeep() {
+ this.init();
+ const ctx = this.am.ctx;
+ if (!ctx || !this.gainNode) return;
+
+ const now = ctx.currentTime;
+ const duration = 0.08;
+
+ const osc = ctx.createOscillator();
+ osc.type = 'sine';
+ osc.frequency.setValueAtTime(950, now);
+
+ const env = ctx.createGain();
+ env.gain.setValueAtTime(0.001, now);
+ env.gain.linearRampToValueAtTime(0.2, now + 0.005);
+ env.gain.setValueAtTime(0.2, now + duration * 0.8);
+ env.gain.exponentialRampToValueAtTime(0.001, now + duration);
+
+ osc.connect(env);
+ env.connect(this.gainNode);
+
+ osc.start(now);
+ osc.stop(now + duration);
+ }
+
+ /**
+ * Iconic TNG 2-Tone Door Chime ("Come in")
+ */
+ synthesizeDoorChime() {
+ this.init();
+ const ctx = this.am.ctx;
+ if (!ctx || !this.gainNode) return;
+
+ const now = ctx.currentTime;
+ const f1 = 880; // A5
+ const f2 = 1174.66; // D6 (Up a fourth)
+
+ const osc1 = ctx.createOscillator();
+ osc1.type = 'sine';
+ osc1.frequency.setValueAtTime(f1, now);
+
+ const env1 = ctx.createGain();
+ env1.gain.setValueAtTime(0.001, now);
+ env1.gain.linearRampToValueAtTime(0.35, now + 0.015);
+ env1.gain.exponentialRampToValueAtTime(0.001, now + 0.45);
+
+ osc1.connect(env1);
+ env1.connect(this.gainNode);
+ osc1.start(now);
+ osc1.stop(now + 0.45);
+
+ // Second tone starts at 0.16s
+ const osc2 = ctx.createOscillator();
+ osc2.type = 'sine';
+ osc2.frequency.setValueAtTime(f2, now + 0.16);
+
+ const env2 = ctx.createGain();
+ env2.gain.setValueAtTime(0.001, now + 0.16);
+ env2.gain.linearRampToValueAtTime(0.4, now + 0.175);
+ env2.gain.exponentialRampToValueAtTime(0.001, now + 0.7);
+
+ osc2.connect(env2);
+ env2.connect(this.gainNode);
+ osc2.start(now + 0.16);
+ osc2.stop(now + 0.7);
+ }
+
+ /**
+ * Whataverse Mechanical Telemetry Engine (audio_gaps.md #25, Phase 3 era)
+ * The TARDIX console never chirps like Starflight gear: each tick emits a
+ * 0.25-0.6 s cluster of mechanical foley - dual-filtered toggle snaps,
+ * low 60-120 Hz spring-lever clicks and damped rotary selector pings
+ * (no electronic beeps). All transients scheduled on absolute ctx time so
+ * the single scheduler timeout can never orphan nodes.
+ */
+ synthesizeWhataverseMechanicalTelemetry_() {
+ this.init();
+ const ctx = this.am.ctx;
+ if (!ctx || !this.gainNode) return;
+
+ const now = ctx.currentTime;
+ // Whataverse telemetry should read as an electromechanical console at room
+ // distance, not as tiny UI clicks. Use a short phrase of clearly distinct
+ // mechanical events with enough mid-band energy to survive the ambient bed.
+ const eventCount = 3 + Math.floor(Math.random() * 4); // 3-6 events
+ let cursor = 0.02 + Math.random() * 0.10;
+
+ const noiseBurst = (t, type, freq, q, peak, dur) => {
+ const buf = this.am.createNoiseBuffer(type, Math.max(0.12, dur + 0.04));
+ const src = ctx.createBufferSource();
+ src.buffer = buf;
+ const bp = ctx.createBiquadFilter();
+ bp.type = 'bandpass';
+ bp.frequency.setValueAtTime(freq, t);
+ bp.Q.setValueAtTime(q, t);
+ const env = ctx.createGain();
+ env.gain.setValueAtTime(0.001, t);
+ env.gain.linearRampToValueAtTime(peak, t + 0.004);
+ env.gain.exponentialRampToValueAtTime(0.001, t + dur);
+ src.connect(bp);
+ bp.connect(env);
+ env.connect(this.gainNode);
+ src.start(t);
+ src.stop(t + dur + 0.03);
+ };
+
+ // Dry toggle snap with a wood/plastic body and a small high-frequency edge.
+ const toggleSnap = (t, peak) => {
+ noiseBurst(t, 'pink', 720 + Math.random() * 520, 5.5, peak, 0.055 + Math.random() * 0.025);
+ noiseBurst(t + 0.004, 'white', 1800 + Math.random() * 900, 4.0, peak * 0.34, 0.028);
+ };
+
+ // Spring-loaded lever: audible mid-bass knock plus a metallic return tick.
+ const springLever = (t, peak) => {
+ const osc = ctx.createOscillator();
+ osc.type = 'triangle';
+ const f0 = 150 + Math.random() * 90;
+ osc.frequency.setValueAtTime(f0, t);
+ osc.frequency.exponentialRampToValueAtTime(70 + Math.random() * 25, t + 0.075);
+ const env = ctx.createGain();
+ env.gain.setValueAtTime(0.001, t);
+ env.gain.linearRampToValueAtTime(peak, t + 0.004);
+ env.gain.exponentialRampToValueAtTime(0.001, t + 0.11);
+ osc.connect(env);
+ env.connect(this.gainNode);
+ osc.start(t);
+ osc.stop(t + 0.13);
+ noiseBurst(t + 0.065, 'white', 1250 + Math.random() * 650, 7, peak * 0.42, 0.035);
+ };
+
+ // Rotary selector: two or three obvious detents rather than one sine ping.
+ const rotaryDetent = (t, peak) => {
+ const detents = 2 + Math.floor(Math.random() * 2);
+ for (let i = 0; i < detents; i++) {
+ const dt = t + i * (0.045 + Math.random() * 0.018);
+ noiseBurst(dt, 'pink', 520 + Math.random() * 420, 8, peak * (1 - i * 0.16), 0.042);
+ const osc = ctx.createOscillator();
+ osc.type = 'triangle';
+ osc.frequency.setValueAtTime(330 + Math.random() * 160, dt);
+ osc.frequency.exponentialRampToValueAtTime(190 + Math.random() * 60, dt + 0.055);
+ const env = ctx.createGain();
+ env.gain.setValueAtTime(peak * 0.55, dt);
+ env.gain.exponentialRampToValueAtTime(0.001, dt + 0.07);
+ osc.connect(env);
+ env.connect(this.gainNode);
+ osc.start(dt);
+ osc.stop(dt + 0.08);
+ }
+ };
+
+ // Heavy relay/contactor gives the phrase a recognisable console-machine thunk.
+ const relayThunk = (t, peak) => {
+ const osc = ctx.createOscillator();
+ osc.type = 'square';
+ osc.frequency.setValueAtTime(190 + Math.random() * 70, t);
+ osc.frequency.exponentialRampToValueAtTime(65 + Math.random() * 20, t + 0.065);
+ const env = ctx.createGain();
+ env.gain.setValueAtTime(0.001, t);
+ env.gain.linearRampToValueAtTime(peak, t + 0.003);
+ env.gain.exponentialRampToValueAtTime(0.001, t + 0.105);
+ osc.connect(env);
+ env.connect(this.gainNode);
+ osc.start(t);
+ osc.stop(t + 0.12);
+ noiseBurst(t + 0.006, 'pink', 900 + Math.random() * 450, 5, peak * 0.48, 0.05);
+ };
+
+ for (let i = 0; i < eventCount; i++) {
+ const t = now + cursor;
+ const r = Math.random();
+ const peak = 0.16 + Math.random() * 0.12;
+ if (r < 0.34) toggleSnap(t, peak);
+ else if (r < 0.60) springLever(t, peak);
+ else if (r < 0.82) rotaryDetent(t, peak);
+ else relayThunk(t, peak);
+ cursor += 0.08 + Math.random() * 0.18;
+ }
+ }
+
+ /**
+ * Bioship Organic Telemetry Engine (audio_gaps.md #47, Phase 3 era)
+ * Living-ship telemetry is wet and pulse-driven, never electronic: vascular
+ * surges (30-90 Hz asymmetric sine thumps, sometimes lub-dub double beats),
+ * chitinous joint snaps, bioplasmic fluid pops and membrane flutters.
+ * One 0.5-1.1 s phrase of 2-4 events on absolute ctx time.
+ */
+ synthesizeBioshipOrganicTelemetry_() {
+ this.init();
+ const ctx = this.am.ctx;
+ if (!ctx || !this.gainNode) return;
+
+ const now = ctx.currentTime;
+ const eventCount = 2 + Math.floor(Math.random() * 3); // 2-4 events
+ let cursor = Math.random() * 0.2;
+
+ const fireEvent = (t) => {
+ const r = Math.random();
+ if (r < 0.35) {
+ // Vascular surge: asymmetric sine thump with pitch drop + lub-dub echo
+ const f0 = 30 + Math.random() * 60;
+ const dur = 0.2 + Math.random() * 0.15;
+ const surge = (st, peak, freq) => {
+ const osc = ctx.createOscillator();
+ osc.type = 'sine';
+ osc.frequency.setValueAtTime(freq, st);
+ osc.frequency.linearRampToValueAtTime(freq * 0.82, st + dur * 0.7);
+ const env = ctx.createGain();
+ env.gain.setValueAtTime(0.001, st);
+ env.gain.linearRampToValueAtTime(peak, st + 0.06 + Math.random() * 0.05);
+ env.gain.exponentialRampToValueAtTime(0.001, st + dur);
+ osc.connect(env);
+ env.connect(this.gainNode);
+ osc.start(st);
+ osc.stop(st + dur + 0.05);
+ };
+ surge(t, 0.14 + Math.random() * 0.06, f0);
+ if (Math.random() < 0.5) {
+ surge(t + 0.22 + Math.random() * 0.06, 0.09, f0 * 0.9); // lub-dub
+ }
+ } else if (r < 0.6) {
+ // Chitin snap: very short high-Q bandpass noise tick
+ const buf = this.am.createNoiseBuffer('white', 0.03);
+ const noise = ctx.createBufferSource();
+ noise.buffer = buf;
+ const bp = ctx.createBiquadFilter();
+ bp.type = 'bandpass';
+ bp.frequency.setValueAtTime(1200 + Math.random() * 1400, t);
+ bp.Q.setValueAtTime(10 + Math.random() * 4, t);
+ const env = ctx.createGain();
+ env.gain.setValueAtTime(0.12 + Math.random() * 0.06, t);
+ env.gain.exponentialRampToValueAtTime(0.001, t + 0.008 + Math.random() * 0.01);
+ noise.connect(bp);
+ bp.connect(env);
+ env.connect(this.gainNode);
+ noise.start(t);
+ noise.stop(t + 0.03);
+ } else if (r < 0.85) {
+ // Bioplasmic fluid pop: bandpass wet impulse with resonant tail
+ const buf = this.am.createNoiseBuffer('pink', 0.08);
+ const noise = ctx.createBufferSource();
+ noise.buffer = buf;
+ const bp = ctx.createBiquadFilter();
+ bp.type = 'bandpass';
+ bp.frequency.setValueAtTime(500 + Math.random() * 900, t);
+ bp.frequency.exponentialRampToValueAtTime(380, t + 0.05);
+ bp.Q.setValueAtTime(3 + Math.random() * 2, t);
+ const env = ctx.createGain();
+ env.gain.setValueAtTime(0.001, t);
+ env.gain.linearRampToValueAtTime(0.14, t + 0.008);
+ env.gain.exponentialRampToValueAtTime(0.001, t + 0.05);
+ noise.connect(bp);
+ bp.connect(env);
+ env.connect(this.gainNode);
+ noise.start(t);
+ noise.stop(t + 0.09);
+ } else {
+ // Membrane flutter: pink noise AM-stepped between 300-700 Hz
+ const buf = this.am.createNoiseBuffer('pink', 0.2);
+ const noise = ctx.createBufferSource();
+ noise.buffer = buf;
+ const bp = ctx.createBiquadFilter();
+ bp.type = 'bandpass';
+ bp.frequency.setValueAtTime(300 + Math.random() * 400, t);
+ bp.Q.setValueAtTime(2, t);
+ const env = ctx.createGain();
+ const flutterRate = 18 + Math.random() * 12;
+ const flutterDur = 0.08 + Math.random() * 0.07;
+ env.gain.setValueAtTime(0.001, t);
+ let step = 0;
+ while (step < flutterDur) {
+ env.gain.linearRampToValueAtTime(0.09 + Math.random() * 0.05, t + step + (1 / flutterRate) * 0.6);
+ env.gain.linearRampToValueAtTime(0.001, t + step + (1 / flutterRate));
+ step += 1 / flutterRate;
+ }
+ env.gain.setValueAtTime(0.001, t + flutterDur + 0.02);
+ noise.connect(bp);
+ bp.connect(env);
+ env.connect(this.gainNode);
+ noise.start(t);
+ noise.stop(t + flutterDur + 0.05);
+ }
+ };
+
+ for (let i = 0; i < eventCount; i++) {
+ fireEvent(cursor);
+ cursor += 0.16 + Math.random() * 0.3;
+ }
+ }
+
+ /**
+ * Beltworker Jury-Rigged Telemetry Engine (audio_gaps.md #29, Phase 3 era)
+ * Rattling relays, worn copper contactors and erratic voltage on Ceres /
+ * Tycho / Rocinante hardware: jittered square pulses with randomized
+ * micro-dropouts, contact-bounce double clicks, harsh metallic ticks and
+ * unstable clock pings. One 3-6 event volley on absolute ctx time, with an
+ * occasional late sub-cluster to mimic an unsteady clock.
+ */
+ synthesizeBeltworkerJuryRiggedTelemetry_() {
+ this.init();
+ const ctx = this.am.ctx;
+ if (!ctx || !this.gainNode) return;
+
+ const now = ctx.currentTime;
+ let cursor = 0.05 + Math.random() * 0.25;
+
+ const relayPulse = (t) => {
+ const osc = ctx.createOscillator();
+ osc.type = 'square';
+ const baseFreq = 60 + Math.random() * 60;
+ osc.frequency.setValueAtTime(baseFreq, t);
+ osc.frequency.linearRampToValueAtTime(baseFreq * 0.8, t + 0.06);
+ const lp = ctx.createBiquadFilter();
+ lp.type = 'lowpass';
+ lp.frequency.setValueAtTime(420, t);
+ const env = ctx.createGain();
+ // Erratic voltage: randomized micro-dropouts in the pulse body
+ env.gain.setValueAtTime(0.15, t);
+ env.gain.setValueAtTime(0.001, t + 0.012 + Math.random() * 0.015);
+ env.gain.setValueAtTime(0.12, t + 0.026 + Math.random() * 0.02);
+ env.gain.setValueAtTime(0.001, t + 0.05 + Math.random() * 0.025);
+ env.gain.setValueAtTime(0.09, t + 0.07);
+ env.gain.exponentialRampToValueAtTime(0.001, t + 0.1);
+ osc.connect(lp);
+ lp.connect(env);
+ env.connect(this.gainNode);
+ osc.start(t);
+ osc.stop(t + 0.11);
+ };
+
+ const contactBounce = (t) => {
+ const clicks = 2 + (Math.random() < 0.35 ? 1 : 0);
+ for (let i = 0; i < clicks; i++) {
+ const ct = t + i * (0.001 + Math.random() * 0.002);
+ const osc = ctx.createOscillator();
+ osc.type = 'square';
+ osc.frequency.setValueAtTime(500 + Math.random() * 500, ct);
+ osc.frequency.exponentialRampToValueAtTime(120, ct + 0.008);
+ const env = ctx.createGain();
+ env.gain.setValueAtTime(0.12, ct);
+ env.gain.exponentialRampToValueAtTime(0.001, ct + 0.012);
+ osc.connect(env);
+ env.connect(this.gainNode);
+ osc.start(ct);
+ osc.stop(ct + 0.015);
+ }
+ };
+
+ const metallicTick = (t) => {
+ const buf = this.am.createNoiseBuffer('white', 0.05);
+ const noise = ctx.createBufferSource();
+ noise.buffer = buf;
+ const bp = ctx.createBiquadFilter();
+ bp.type = 'bandpass';
+ bp.frequency.setValueAtTime(400 + Math.random() * 1400, t);
+ bp.Q.setValueAtTime(6, t);
+ const env = ctx.createGain();
+ env.gain.setValueAtTime(0.08, t);
+ env.gain.exponentialRampToValueAtTime(0.001, t + 0.02);
+ noise.connect(bp);
+ bp.connect(env);
+ env.connect(this.gainNode);
+ noise.start(t);
+ noise.stop(t + 0.025);
+ };
+
+ const clockPing = (t) => {
+ // Unstable clock: dulled square collapsing hard, like a dying oscillator
+ const osc = ctx.createOscillator();
+ osc.type = 'square';
+ osc.frequency.setValueAtTime(700 + Math.random() * 400, t);
+ osc.frequency.exponentialRampToValueAtTime(200, t + 0.025);
+ const env = ctx.createGain();
+ env.gain.setValueAtTime(0.05, t);
+ env.gain.exponentialRampToValueAtTime(0.001, t + 0.04);
+ osc.connect(env);
+ env.connect(this.gainNode);
+ osc.start(t);
+ osc.stop(t + 0.045);
+ };
+
+ const fireEvent = (t) => {
+ const r = Math.random();
+ if (r < 0.35) relayPulse(t);
+ else if (r < 0.6) contactBounce(t);
+ else if (r < 0.85) metallicTick(t);
+ else clockPing(t);
+ };
+
+ const eventCount = 3 + Math.floor(Math.random() * 4); // 3-6 events
+ for (let i = 0; i < eventCount; i++) {
+ fireEvent(cursor);
+ cursor += 0.06 + Math.random() * 0.16; // jittered clock drift
+ }
+ // Occasional second volley: the station clock skips then catches up
+ if (Math.random() < 0.35) {
+ const volleyBase = cursor + 0.1 + Math.random() * 0.4;
+ const volleyCount = 2 + Math.floor(Math.random() * 2);
+ for (let i = 0; i < volleyCount; i++) {
+ fireEvent(volleyBase + i * (0.04 + Math.random() * 0.1));
+ }
+ }
+ }
+
+ applyPreset(config) {
+ if (config.volume !== undefined) this.params.volume = config.volume;
+ if (config.density !== undefined) this.setDensity(config.density);
+ if (config.era !== undefined) this.params.era = config.era;
+ this.setVolume(this.params.volume);
+ this.startAutoTelemetryScheduler();
+ }
+}
+
+window.TelemetrySynth = TelemetrySynth;
+
+
+/**
+ * Procedural Starship Alert & Event Synthesizer
+ * 100% synthesized programmatically in Web Audio API.
+ * Includes TNG Red Alert (3-tone), TOS Red Alert (hooter buzzer), Movie-era descending klaxon,
+ * Yellow Alert chime, and dynamic Warp Drive Throttle swell.
+ */
+
+class AlertSynth {
+ constructor(audioManager) {
+ this.am = audioManager;
+ this.gainNode = null;
+ this.activeAlert = null; // 'red', 'yellow', null
+ this.alertTimer = null;
+ this.alertType = 'tng'; // 'tng', 'tos', 'movie'
+
+ this.params = {
+ volume: 0.6
+ };
+ }
+
+ init() {
+ if (this.gainNode || !this.am.ctx) return;
+ const ctx = this.am.ctx;
+ this.gainNode = ctx.createGain();
+ this.gainNode.gain.setValueAtTime(this.params.volume, ctx.currentTime);
+ this.gainNode.connect(this.am.compressor);
+ }
+
+ setVolume(val) {
+ this.params.volume = Math.max(0, Math.min(1, val));
+ if (this.gainNode && this.am.ctx) {
+ const now = this.am.ctx.currentTime;
+ this.gainNode.gain.linearRampToValueAtTime(this.params.volume, now + 0.05);
+ }
+ }
+
+ triggerRedAlert(type = 'tng') {
+ this.init();
+ this.stopAlert();
+ this.activeAlert = 'red';
+ this.alertType = type;
+
+ const playLoop = () => {
+ if (this.activeAlert !== 'red') return;
+ let loopDuration = 1.35;
+
+ if (this.alertType === 'tng') {
+ this.synthesizeTNGRedAlertCycle();
+ loopDuration = 1.35;
+ } else if (this.alertType === 'tos') {
+ this.synthesizeTOSRedAlertCycle();
+ loopDuration = 1.1;
+ } else {
+ this.synthesizeMovieRedAlertCycle();
+ loopDuration = 1.4;
+ }
+
+ this.alertTimer = setTimeout(playLoop, loopDuration * 1000);
+ };
+
+ playLoop();
+ }
+
+ triggerYellowAlert() {
+ this.init();
+ this.stopAlert();
+ this.activeAlert = 'yellow';
+
+ const playLoop = () => {
+ if (this.activeAlert !== 'yellow') return;
+ this.synthesizeYellowAlertCycle();
+ this.alertTimer = setTimeout(playLoop, 2200);
+ };
+
+ playLoop();
+ }
+
+ stopAlert() {
+ this.activeAlert = null;
+ if (this.alertTimer) {
+ clearTimeout(this.alertTimer);
+ this.alertTimer = null;
+ }
+ }
+
+ /**
+ * TNG Red Alert Klaxon (3-tone rising & cascading electronic horn with resonant envelope)
+ */
+ synthesizeTNGRedAlertCycle() {
+ const ctx = this.am.ctx;
+ if (!ctx || !this.gainNode) return;
+
+ const now = ctx.currentTime;
+ // Frequencies for the iconic TNG 3-tone klaxon chord: F5 (698.46Hz), Ab5 (830.61Hz), C6 (1046.50Hz)
+ const freqs = [698.46, 830.61, 1046.50];
+
+ freqs.forEach((freq, idx) => {
+ const osc = ctx.createOscillator();
+ osc.type = 'sawtooth';
+ osc.frequency.setValueAtTime(freq * 0.94, now);
+ // Fast upward swoop on trigger
+ osc.frequency.exponentialRampToValueAtTime(freq, now + 0.12);
+
+ // Lowpass filter to give that brassy starship horn acoustic resonance
+ const filter = ctx.createBiquadFilter();
+ filter.type = 'lowpass';
+ filter.frequency.setValueAtTime(1600, now);
+ filter.Q.setValueAtTime(4.0, now);
+
+ const env = ctx.createGain();
+ env.gain.setValueAtTime(0.001, now);
+ env.gain.linearRampToValueAtTime(0.25 / freqs.length, now + 0.08);
+ env.gain.setValueAtTime(0.25 / freqs.length, now + 0.45);
+ env.gain.exponentialRampToValueAtTime(0.0001, now + 0.85);
+
+ osc.connect(filter);
+ filter.connect(env);
+ env.connect(this.gainNode);
+
+ osc.start(now);
+ osc.stop(now + 0.86);
+ });
+ }
+
+ /**
+ * TOS Red Alert Buzzer / Hooter Siren (Pulsing 2-tone frequency modulation)
+ */
+ synthesizeTOSRedAlertCycle() {
+ const ctx = this.am.ctx;
+ if (!ctx || !this.gainNode) return;
+
+ const now = ctx.currentTime;
+ const duration = 0.85;
+
+ const osc = ctx.createOscillator();
+ osc.type = 'sawtooth';
+ osc.frequency.setValueAtTime(540, now);
+ osc.frequency.linearRampToValueAtTime(920, now + duration * 0.5);
+ osc.frequency.linearRampToValueAtTime(540, now + duration);
+
+ const filter = ctx.createBiquadFilter();
+ filter.type = 'bandpass';
+ filter.frequency.setValueAtTime(800, now);
+ filter.Q.setValueAtTime(2.2, now);
+
+ const env = ctx.createGain();
+ env.gain.setValueAtTime(0.01, now);
+ env.gain.linearRampToValueAtTime(0.28, now + 0.05);
+ env.gain.setValueAtTime(0.28, now + duration * 0.85);
+ env.gain.exponentialRampToValueAtTime(0.001, now + duration);
+
+ osc.connect(filter);
+ filter.connect(env);
+ env.connect(this.gainNode);
+
+ osc.start(now);
+ osc.stop(now + duration);
+ }
+
+ /**
+ * Star Trek Movie Era Refit Descending Red Alert Klaxon
+ */
+ synthesizeMovieRedAlertCycle() {
+ const ctx = this.am.ctx;
+ if (!ctx || !this.gainNode) return;
+
+ const now = ctx.currentTime;
+ const duration = 1.05;
+
+ const osc = ctx.createOscillator();
+ osc.type = 'sawtooth';
+ osc.frequency.setValueAtTime(1350, now);
+ osc.frequency.exponentialRampToValueAtTime(420, now + duration * 0.9);
+
+ const filter = ctx.createBiquadFilter();
+ filter.type = 'lowpass';
+ filter.frequency.setValueAtTime(2200, now);
+ filter.Q.setValueAtTime(3.5, now);
+
+ const env = ctx.createGain();
+ env.gain.setValueAtTime(0.01, now);
+ env.gain.linearRampToValueAtTime(0.3, now + 0.06);
+ env.gain.exponentialRampToValueAtTime(0.001, now + duration);
+
+ osc.connect(filter);
+ filter.connect(env);
+ env.connect(this.gainNode);
+
+ osc.start(now);
+ osc.stop(now + duration);
+ }
+
+ /**
+ * Yellow Alert Pulsing Warning Chime
+ */
+ synthesizeYellowAlertCycle() {
+ const ctx = this.am.ctx;
+ if (!ctx || !this.gainNode) return;
+
+ const now = ctx.currentTime;
+ const f1 = 660; // E5
+ const f2 = 880; // A5
+
+ [0, 0.22].forEach((offset, idx) => {
+ const freq = idx === 0 ? f1 : f2;
+ const osc = ctx.createOscillator();
+ osc.type = 'sine';
+ osc.frequency.setValueAtTime(freq, now + offset);
+
+ const env = ctx.createGain();
+ env.gain.setValueAtTime(0.001, now + offset);
+ env.gain.linearRampToValueAtTime(0.32, now + offset + 0.015);
+ env.gain.exponentialRampToValueAtTime(0.001, now + offset + 0.5);
+
+ osc.connect(env);
+ env.connect(this.gainNode);
+
+ osc.start(now + offset);
+ osc.stop(now + offset + 0.52);
+ });
+ }
+
+ /**
+ * Procedural Warp Drive Acceleration Swell ("Engage!")
+ * Synthesizes rising plasma induction whine + deep bass detonation
+ */
+ synthesizeWarpJump() {
+ this.init();
+ const ctx = this.am.ctx;
+ if (!ctx || !this.gainNode) return;
+
+ const now = ctx.currentTime;
+ const duration = 2.8;
+
+ // 1. Rising High Induction Whine
+ const whineOsc = ctx.createOscillator();
+ whineOsc.type = 'sawtooth';
+ whineOsc.frequency.setValueAtTime(80, now);
+ whineOsc.frequency.exponentialRampToValueAtTime(3800, now + 1.8);
+ whineOsc.frequency.exponentialRampToValueAtTime(14000, now + 2.5);
+
+ const whineFilter = ctx.createBiquadFilter();
+ whineFilter.type = 'bandpass';
+ whineFilter.frequency.setValueAtTime(200, now);
+ whineFilter.frequency.exponentialRampToValueAtTime(4500, now + 1.8);
+ whineFilter.Q.setValueAtTime(5.0, now);
+
+ const whineEnv = ctx.createGain();
+ whineEnv.gain.setValueAtTime(0.01, now);
+ whineEnv.gain.linearRampToValueAtTime(0.35, now + 1.6);
+ whineEnv.gain.exponentialRampToValueAtTime(0.001, now + 2.7);
+
+ whineOsc.connect(whineFilter);
+ whineFilter.connect(whineEnv);
+ whineEnv.connect(this.gainNode);
+
+ // 2. Sub-bass Matter-Antimatter Boom
+ const subOsc = ctx.createOscillator();
+ subOsc.type = 'sine';
+ subOsc.frequency.setValueAtTime(140, now + 1.4);
+ subOsc.frequency.exponentialRampToValueAtTime(32, now + 2.6);
+
+ const subEnv = ctx.createGain();
+ subEnv.gain.setValueAtTime(0.001, now + 1.4);
+ subEnv.gain.linearRampToValueAtTime(0.7, now + 1.7);
+ subEnv.gain.exponentialRampToValueAtTime(0.0001, now + duration);
+
+ subOsc.connect(subEnv);
+ subEnv.connect(this.gainNode);
+
+ whineOsc.start(now);
+ whineOsc.stop(now + 2.7);
+ subOsc.start(now + 1.4);
+ subOsc.stop(now + duration);
+ }
+}
+
+window.AlertSynth = AlertSynth;
+
+
+/**
+ * Procedural Doctor Who & TARDIX Sound Synthesizer
+ * 100% synthesized programmatically via Web Audio API.
+ * Includes Dematerialization Wheeze-Groan, Cloister Chime, Acoustic Screwdriver, and TARDIX Console Foley.
+ */
+
+class WhataverseAudioSynth {
+ constructor(audioManager) {
+ this.am = audioManager;
+ this.gainNode = null;
+ this.activeCloister = false;
+ this.cloisterTimer = null;
+ }
+
+ init() {
+ if (this.gainNode || !this.am.ctx) return;
+ const ctx = this.am.ctx;
+ this.gainNode = ctx.createGain();
+ this.gainNode.gain.setValueAtTime(0.7, ctx.currentTime);
+ this.gainNode.connect(this.am.compressor);
+ }
+
+ /**
+ * Procedural TARDIX Materialization / Dematerialization ("Wheeze-Groan")
+ * Modeled after Brian Hodgson's 1963 BBC Radiophonic technique:
+ * Dragging keys on piano bass strings -> reverse playback -> slow tape speed -> feedback loop.
+ */
+ synthesizeDematCycle(cycles = 4) {
+ this.init();
+ const ctx = this.am.ctx;
+ if (!ctx) return;
+
+ for (let c = 0; c < cycles; c++) {
+ const cycleStart = ctx.currentTime + c * 1.85;
+ this.synthesizeSingleDematSwell(cycleStart, c, cycles);
+ }
+ }
+
+ synthesizeSingleDematSwell(startTime, cycleIndex, totalCycles) {
+ const ctx = this.am.ctx;
+ const duration = 1.75;
+
+ // Intensity fades slightly on later cycles
+ const intensity = 1.0 - (cycleIndex / totalCycles) * 0.35;
+
+ // 1. Friction Scrape Carrier (Sawtooth through resonant highpass/bandpass with frequency glide)
+ const frictionOsc = ctx.createOscillator();
+ frictionOsc.type = 'sawtooth';
+ // Frequency glides up then groans down
+ frictionOsc.frequency.setValueAtTime(120, startTime);
+ frictionOsc.frequency.exponentialRampToValueAtTime(840, startTime + 0.65);
+ frictionOsc.frequency.exponentialRampToValueAtTime(95, startTime + duration);
+
+ // Filter modeling the piano soundboard metallic scraping resonance
+ const frictionFilter = ctx.createBiquadFilter();
+ frictionFilter.type = 'bandpass';
+ frictionFilter.frequency.setValueAtTime(320, startTime);
+ frictionFilter.frequency.exponentialRampToValueAtTime(1450, startTime + 0.65);
+ frictionFilter.frequency.exponentialRampToValueAtTime(220, startTime + duration);
+ frictionFilter.Q.setValueAtTime(4.5, startTime);
+
+ const frictionGain = ctx.createGain();
+ frictionGain.gain.setValueAtTime(0.001, startTime);
+ frictionGain.gain.linearRampToValueAtTime(0.4 * intensity, startTime + 0.45);
+ frictionGain.gain.exponentialRampToValueAtTime(0.001, startTime + duration);
+
+ // 2. Sub-Vortex Resonant Groan (FM synthesis for the deep cosmic groaning undertone)
+ const groanCarrier = ctx.createOscillator();
+ groanCarrier.type = 'triangle';
+ groanCarrier.frequency.setValueAtTime(55, startTime);
+ groanCarrier.frequency.linearRampToValueAtTime(138, startTime + 0.55);
+ groanCarrier.frequency.exponentialRampToValueAtTime(48, startTime + duration);
+
+ const groanMod = ctx.createOscillator();
+ groanMod.type = 'sine';
+ groanMod.frequency.setValueAtTime(28, startTime); // Phasing FM modulator
+ groanMod.frequency.linearRampToValueAtTime(65, startTime + 0.6);
+
+ const groanModGain = ctx.createGain();
+ groanModGain.gain.setValueAtTime(45, startTime);
+ groanMod.connect(groanModGain);
+ groanModGain.connect(groanCarrier.frequency);
+
+ const groanGain = ctx.createGain();
+ groanGain.gain.setValueAtTime(0.001, startTime);
+ groanGain.gain.linearRampToValueAtTime(0.6 * intensity, startTime + 0.5);
+ groanGain.gain.exponentialRampToValueAtTime(0.001, startTime + duration);
+
+ // 3. Phasing Flutter / Swell (Tape-flange simulation via slow LFO)
+ const lfo = ctx.createOscillator();
+ lfo.type = 'sine';
+ lfo.frequency.setValueAtTime(5.5, startTime); // 5.5 Hz flanging flutter
+
+ const lfoDepth = ctx.createGain();
+ lfoDepth.gain.setValueAtTime(0.25, startTime);
+ lfo.connect(lfoDepth);
+ lfoDepth.connect(frictionGain.gain);
+
+ // Connect Graph
+ frictionOsc.connect(frictionFilter);
+ frictionFilter.connect(frictionGain);
+ frictionGain.connect(this.gainNode);
+
+ groanCarrier.connect(groanGain);
+ groanGain.connect(this.gainNode);
+
+ // Trigger Nodes
+ frictionOsc.start(startTime);
+ frictionOsc.stop(startTime + duration);
+ groanCarrier.start(startTime);
+ groanCarrier.stop(startTime + duration);
+ groanMod.start(startTime);
+ groanMod.stop(startTime + duration);
+ lfo.start(startTime);
+ lfo.stop(startTime + duration);
+ }
+
+ /**
+ * Procedural Cloister Chime (Deep, ominous bronze cathedral bell)
+ */
+ triggerCloisterBell() {
+ this.init();
+ this.stopCloisterBell();
+ this.activeCloister = true;
+
+ const ringLoop = () => {
+ if (!this.activeCloister) return;
+ this.synthesizeCloisterStrike();
+ this.cloisterTimer = setTimeout(ringLoop, 3200); // Canonical cloister chime repetition rate
+ };
+
+ ringLoop();
+ }
+
+ stopCloisterBell() {
+ this.activeCloister = false;
+ if (this.cloisterTimer) {
+ clearTimeout(this.cloisterTimer);
+ this.cloisterTimer = null;
+ }
+ }
+
+ synthesizeCloisterStrike() {
+ const ctx = this.am.ctx;
+ if (!ctx || !this.gainNode) return;
+
+ const now = ctx.currentTime;
+ const duration = 4.2;
+
+ // Authentic bell inharmonic partial ratios: Fundamental, Minor 3rd, 5th, Octave, Major 7th
+ const bellPartials = [
+ { freqRatio: 1.0, gain: 0.65, decay: 4.2 }, // Fundamental ~108 Hz
+ { freqRatio: 1.19, gain: 0.45, decay: 3.6 }, // Minor third
+ { freqRatio: 1.51, gain: 0.40, decay: 3.1 }, // Fifth
+ { freqRatio: 2.01, gain: 0.30, decay: 2.4 }, // Octave
+ { freqRatio: 2.74, gain: 0.22, decay: 1.8 }, // Upper strike tone
+ { freqRatio: 3.42, gain: 0.15, decay: 1.2 } // High strike transient
+ ];
+
+ const basePitch = 108.0; // Deep bronze bell pitch
+
+ bellPartials.forEach(p => {
+ const osc = ctx.createOscillator();
+ osc.type = 'sine';
+ osc.frequency.setValueAtTime(basePitch * p.freqRatio, now);
+
+ const env = ctx.createGain();
+ env.gain.setValueAtTime(0.001, now);
+ env.gain.linearRampToValueAtTime(p.gain * 0.35, now + 0.012); // Sharp hammer impact
+ env.gain.exponentialRampToValueAtTime(0.0001, now + p.decay);
+
+ osc.connect(env);
+ env.connect(this.gainNode);
+
+ osc.start(now);
+ osc.stop(now + p.decay + 0.05);
+ });
+ }
+
+ /**
+ * Procedural Acoustic Screwdriver (High-frequency modulated sweep & vibrato warble)
+ */
+ synthesizeSonicScrewdriver(durationSeconds = 1.2) {
+ this.init();
+ const ctx = this.am.ctx;
+ if (!ctx || !this.gainNode) return;
+
+ const now = ctx.currentTime;
+ const duration = durationSeconds;
+
+ // Dual square/saw oscillators
+ const osc1 = ctx.createOscillator();
+ osc1.type = 'square';
+ osc1.frequency.setValueAtTime(2350, now);
+ osc1.frequency.linearRampToValueAtTime(2650, now + duration * 0.5);
+ osc1.frequency.linearRampToValueAtTime(2350, now + duration);
+
+ const osc2 = ctx.createOscillator();
+ osc2.type = 'sawtooth';
+ osc2.frequency.setValueAtTime(2362, now); // 12Hz natural phase beat
+
+ // Rapid Vibrato LFO
+ const vibrato = ctx.createOscillator();
+ vibrato.type = 'sine';
+ vibrato.frequency.setValueAtTime(32, now); // 32 Hz warble rate
+
+ const vibGain = ctx.createGain();
+ vibGain.gain.setValueAtTime(140, now);
+ vibrato.connect(vibGain);
+ vibGain.connect(osc1.frequency);
+ vibGain.connect(osc2.frequency);
+
+ // Bandpass filter for metallic resonance
+ const filter = ctx.createBiquadFilter();
+ filter.type = 'bandpass';
+ filter.frequency.setValueAtTime(2500, now);
+ filter.Q.setValueAtTime(4.0, now);
+
+ const env = ctx.createGain();
+ env.gain.setValueAtTime(0.001, now);
+ env.gain.linearRampToValueAtTime(0.28, now + 0.03);
+ env.gain.setValueAtTime(0.28, now + duration * 0.85);
+ env.gain.exponentialRampToValueAtTime(0.0001, now + duration);
+
+ osc1.connect(filter);
+ osc2.connect(filter);
+ filter.connect(env);
+ env.connect(this.gainNode);
+
+ osc1.start(now);
+ osc2.start(now);
+ vibrato.start(now);
+ osc1.stop(now + duration);
+ osc2.stop(now + duration);
+ vibrato.stop(now + duration);
+ }
+
+ /**
+ * Fast-Return Spring Lever (Heavy spring recoil clack + resonant ring)
+ */
+ synthesizeFastReturn() {
+ this.init();
+ const ctx = this.am.ctx;
+ if (!ctx || !this.gainNode) return;
+
+ const now = ctx.currentTime;
+ const duration = 0.35;
+
+ const osc = ctx.createOscillator();
+ osc.type = 'triangle';
+ osc.frequency.setValueAtTime(620, now);
+ osc.frequency.exponentialRampToValueAtTime(95, now + 0.08);
+
+ const env = ctx.createGain();
+ env.gain.setValueAtTime(0.45, now);
+ env.gain.exponentialRampToValueAtTime(0.001, now + duration);
+
+ osc.connect(env);
+ env.connect(this.gainNode);
+
+ osc.start(now);
+ osc.stop(now + duration);
+ }
+
+ /**
+ * TARDIX Demat Switch / Relay Solenoid
+ */
+ synthesizeDematSwitch() {
+ this.init();
+ const ctx = this.am.ctx;
+ if (!ctx || !this.gainNode) return;
+
+ const now = ctx.currentTime;
+ const duration = 0.06;
+
+ const osc = ctx.createOscillator();
+ osc.type = 'square';
+ osc.frequency.setValueAtTime(850, now);
+ osc.frequency.exponentialRampToValueAtTime(140, now + duration);
+
+ const env = ctx.createGain();
+ env.gain.setValueAtTime(0.35, now);
+ env.gain.exponentialRampToValueAtTime(0.001, now + duration);
+
+ osc.connect(env);
+ env.connect(this.gainNode);
+
+ osc.start(now);
+ osc.stop(now + duration);
+ }
+
+ /**
+ * Telepathic Circuit Chime (Glassy, mystical resonance)
+ */
+ synthesizeTelepathicChime() {
+ this.init();
+ const ctx = this.am.ctx;
+ if (!ctx || !this.gainNode) return;
+
+ const now = ctx.currentTime;
+ const duration = 1.4;
+ const notes = [1046.50, 1318.51, 1567.98, 2093.00]; // C Major arpeggio shimmer
+
+ notes.forEach((freq, idx) => {
+ const osc = ctx.createOscillator();
+ osc.type = 'sine';
+ osc.frequency.setValueAtTime(freq, now + idx * 0.08);
+
+ const env = ctx.createGain();
+ env.gain.setValueAtTime(0.001, now + idx * 0.08);
+ env.gain.linearRampToValueAtTime(0.18, now + idx * 0.08 + 0.02);
+ env.gain.exponentialRampToValueAtTime(0.0001, now + duration);
+
+ osc.connect(env);
+ env.connect(this.gainNode);
+
+ osc.start(now + idx * 0.08);
+ osc.stop(now + duration + 0.05);
+ });
+ }
+
+ /**
+ * TARDIX Lever-Throw Clunk (Tactile mechanical switch)
+ * Hard square transient click coupled to a dull highpass noise thump
+ */
+ synthesizeLeverClunk() {
+ this.init();
+ const ctx = this.am.ctx;
+ if (!ctx || !this.gainNode) return;
+
+ const now = ctx.currentTime;
+
+ // Hard square transient click
+ const clickOsc = ctx.createOscillator();
+ clickOsc.type = 'square';
+ clickOsc.frequency.setValueAtTime(80, now);
+ clickOsc.frequency.exponentialRampToValueAtTime(30, now + 0.035);
+
+ const clickEnv = ctx.createGain();
+ clickEnv.gain.setValueAtTime(0.42, now);
+ clickEnv.gain.exponentialRampToValueAtTime(0.001, now + 0.04);
+
+ clickOsc.connect(clickEnv);
+ clickEnv.connect(this.gainNode);
+ clickOsc.start(now);
+ clickOsc.stop(now + 0.042);
+
+ // Dull highpass noise thump
+ const noiseBuf = this.am.createNoiseBuffer('pink', 0.15);
+ const noise = ctx.createBufferSource();
+ noise.buffer = noiseBuf;
+
+ const filter = ctx.createBiquadFilter();
+ filter.type = 'highpass';
+ filter.frequency.setValueAtTime(320, now);
+
+ const noiseEnv = ctx.createGain();
+ noiseEnv.gain.setValueAtTime(0.3, now);
+ noiseEnv.gain.exponentialRampToValueAtTime(0.001, now + 0.06);
+
+ noise.connect(filter);
+ filter.connect(noiseEnv);
+ noiseEnv.connect(this.gainNode);
+ noise.start(now);
+ noise.stop(now + 0.065);
+ }
+
+ /**
+ * TARDIX Police Box Exterior Door Open / Close (Wood creak + mortise latch)
+ * Modulated bandpass friction noise layered over a sharp metallic dual-click transient
+ */
+ synthesizeTARDIXDoor() {
+ this.init();
+ const ctx = this.am.ctx;
+ if (!ctx || !this.gainNode) return;
+
+ const now = ctx.currentTime;
+ const duration = 0.75;
+
+ // 1. Modulated bandpass friction noise (wood creak)
+ const creakBuf = this.am.createNoiseBuffer('brown', duration);
+ const creak = ctx.createBufferSource();
+ creak.buffer = creakBuf;
+
+ const creakFilter = ctx.createBiquadFilter();
+ creakFilter.type = 'bandpass';
+ creakFilter.frequency.setValueAtTime(260, now);
+ creakFilter.frequency.linearRampToValueAtTime(540, now + 0.35);
+ creakFilter.frequency.exponentialRampToValueAtTime(310, now + 0.65);
+ creakFilter.Q.setValueAtTime(6.0, now);
+
+ const creakEnv = ctx.createGain();
+ creakEnv.gain.setValueAtTime(0.001, now);
+ creakEnv.gain.linearRampToValueAtTime(0.38, now + 0.15);
+ creakEnv.gain.exponentialRampToValueAtTime(0.001, now + 0.68);
+
+ creak.connect(creakFilter);
+ creakFilter.connect(creakEnv);
+ creakEnv.connect(this.gainNode);
+ creak.start(now);
+ creak.stop(now + 0.7);
+
+ // 2. Iron mortise latch dual-click transient
+ [0.54, 0.62].forEach((offset, idx) => {
+ const click = ctx.createOscillator();
+ click.type = 'triangle';
+ click.frequency.setValueAtTime(idx === 0 ? 1100 : 750, now + offset);
+ click.frequency.exponentialRampToValueAtTime(180, now + offset + 0.03);
+
+ const cEnv = ctx.createGain();
+ cEnv.gain.setValueAtTime(0.28, now + offset);
+ cEnv.gain.exponentialRampToValueAtTime(0.001, now + offset + 0.035);
+
+ click.connect(cEnv);
+ cEnv.connect(this.gainNode);
+ click.start(now + offset);
+ click.stop(now + offset + 0.04);
+ });
+ }
+
+ /**
+ * Scanner / Monitor Screen Activation (Whataverse, audio_gaps.md #24)
+ * Rapid upward sine sweep (200 Hz -> 1600 Hz over 120 ms) terminating in a
+ * CRT flyback whine. The 15.6 kHz spec tail is compromised to ~10.6 kHz:
+ * near/above Nyquist on low-rate contexts and ear-fatiguing at audible levels.
+ */
+ synthesizeScannerActivate() {
+ this.init();
+ const ctx = this.am.ctx;
+ if (!ctx || !this.gainNode) return;
+
+ const now = ctx.currentTime;
+
+ // 1. Cathode-ray startup pitch sweep
+ const sweep = ctx.createOscillator();
+ sweep.type = 'sine';
+ sweep.frequency.setValueAtTime(200, now);
+ sweep.frequency.exponentialRampToValueAtTime(1600, now + 0.12);
+
+ const sweepEnv = ctx.createGain();
+ sweepEnv.gain.setValueAtTime(0.001, now);
+ sweepEnv.gain.linearRampToValueAtTime(0.32, now + 0.03);
+ sweepEnv.gain.exponentialRampToValueAtTime(0.001, now + 0.16);
+
+ sweep.connect(sweepEnv);
+ sweepEnv.connect(this.gainNode);
+ sweep.start(now);
+ sweep.stop(now + 0.17);
+
+ // 2. CRT flyback whine partial (10.6 kHz compromise, low amplitude)
+ const whine = ctx.createOscillator();
+ whine.type = 'sine';
+ whine.frequency.setValueAtTime(10600, now + 0.1);
+
+ const wobble = ctx.createOscillator();
+ wobble.type = 'sine';
+ wobble.frequency.setValueAtTime(1.0, now + 0.1);
+ const wobbleDepth = ctx.createGain();
+ wobbleDepth.gain.setValueAtTime(8, now + 0.1);
+
+ wobble.connect(wobbleDepth);
+ wobbleDepth.connect(whine.frequency);
+
+ const whineEnv = ctx.createGain();
+ whineEnv.gain.setValueAtTime(0.001, now + 0.1);
+ whineEnv.gain.linearRampToValueAtTime(0.03, now + 0.16);
+ whineEnv.gain.exponentialRampToValueAtTime(0.0001, now + 0.85);
+
+ whine.connect(whineEnv);
+ whineEnv.connect(this.gainNode);
+ whine.start(now + 0.1);
+ whine.stop(now + 0.87);
+ wobble.start(now + 0.1);
+ wobble.stop(now + 0.87);
+ }
+
+ /**
+ * Console "Type" Input Clatter Cluster (Whataverse, audio_gaps.md #25)
+ * Rhythmic mechanical clatter of toggle switches, spring-loaded buttons and
+ * tumbler relays: clustered wooden/plastic clicks and solenoid snaps rather
+ * than tonal beeps. Produces a single 0.3-0.6 s cluster of 3-7 transients.
+ */
+ synthesizeTypeClatter() {
+ this.init();
+ const ctx = this.am.ctx;
+ if (!ctx || !this.gainNode) return;
+
+ const now = ctx.currentTime;
+ const transientCount = 3 + Math.floor(Math.random() * 5); // 3-7 strikes
+ let cursor = 0;
+
+ const strike = (kind, offset, peak) => {
+ const t = now + offset;
+ if (kind === 'toggle') {
+ // Wooden relay toggle: short bandpass pink-noise burst with downward snap
+ const buf = this.am.createNoiseBuffer('pink', 0.1);
+ const noise = ctx.createBufferSource();
+ noise.buffer = buf;
+ const bp = ctx.createBiquadFilter();
+ bp.type = 'bandpass';
+ bp.frequency.setValueAtTime(700 + Math.random() * 700, t);
+ bp.frequency.exponentialRampToValueAtTime(240, t + 0.03);
+ bp.Q.setValueAtTime(8 + Math.random() * 4, t);
+ const env = ctx.createGain();
+ env.gain.setValueAtTime(0.001, t);
+ env.gain.linearRampToValueAtTime(peak, t + 0.005);
+ env.gain.exponentialRampToValueAtTime(0.001, t + 0.045);
+ noise.connect(bp);
+ bp.connect(env);
+ env.connect(this.gainNode);
+ noise.start(t);
+ noise.stop(t + 0.05);
+ } else if (kind === 'button') {
+ // Spring-loaded plastic button: fast square click collapsing in pitch
+ const osc = ctx.createOscillator();
+ osc.type = 'square';
+ osc.frequency.setValueAtTime(900, t);
+ osc.frequency.exponentialRampToValueAtTime(200, t + 0.012);
+ const env = ctx.createGain();
+ env.gain.setValueAtTime(peak * 0.8, t);
+ env.gain.exponentialRampToValueAtTime(0.001, t + 0.022);
+ osc.connect(env);
+ env.connect(this.gainNode);
+ osc.start(t);
+ osc.stop(t + 0.025);
+ } else {
+ // Heavy solenoid snap: dull low square thump
+ const osc = ctx.createOscillator();
+ osc.type = 'square';
+ osc.frequency.setValueAtTime(120, t);
+ osc.frequency.exponentialRampToValueAtTime(55, t + 0.025);
+ const env = ctx.createGain();
+ env.gain.setValueAtTime(peak, t);
+ env.gain.exponentialRampToValueAtTime(0.001, t + 0.035);
+ osc.connect(env);
+ env.connect(this.gainNode);
+ osc.start(t);
+ osc.stop(t + 0.04);
+ }
+ };
+
+ for (let i = 0; i < transientCount; i++) {
+ const kinds = ['toggle', 'toggle', 'button', 'button', 'solenoid'];
+ const kind = kinds[Math.floor(Math.random() * kinds.length)];
+ cursor += 0.015 + Math.random() * 0.075;
+ strike(kind, cursor, 0.07 + Math.random() * 0.07);
+ }
+ }
+
+ /**
+ * Stops all automated Whataverse ambient loops. Phase 3 moved console
+ * "type" telemetry (audio_gaps.md #25) into TelemetrySynth era 'who'
+ * routing, which is stopped via telemetry.stop() / FULL STOP. Hook
+ * retained because js/app.js calls it at three ambience switch points.
+ */
+ stopAmbientLoops() {
+ }
+}
+
+window.WhataverseAudioSynth = WhataverseAudioSynth;
+
+
+/**
+ * Procedural Audio Synthesizers for Expanded Sci-Fi Universes
+ * Generates Epsilon Drives, Bio-ship neural pulses, DRADAR sonar, Singularity drives,
+ * Retro analog tone glides, and Ridiculous Speed reality shifts via Web Audio API.
+ */
+
+class ExpandedSciFiAudioSynth {
+ constructor(audioManager) {
+ this.am = audioManager;
+ this.gainNode = null;
+ this.medicalTimer = null;
+ this.sparkTimer = null;
+ this.loopTimers = {};
+ }
+
+ init() {
+ if (this.gainNode || !this.am.ctx) return;
+ const ctx = this.am.ctx;
+ this.gainNode = ctx.createGain();
+ this.gainNode.gain.setValueAtTime(0.7, ctx.currentTime);
+ this.gainNode.connect(this.am.compressor);
+ }
+
+ /**
+ * Epsilon Drive Fusion Torch Burn (The Expanse / Industrial Space)
+ * Tremendous raw fusion thrust with high-pressure magnetic plasma acceleration
+ */
+ synthesizeEpsteinBurn() {
+ this.init();
+ const ctx = this.am.ctx;
+ if (!ctx) return;
+
+ const now = ctx.currentTime;
+ const duration = 3.5;
+
+ // 1. High-frequency plasma induction whine
+ const whineOsc = ctx.createOscillator();
+ whineOsc.type = 'sawtooth';
+ whineOsc.frequency.setValueAtTime(140, now);
+ whineOsc.frequency.exponentialRampToValueAtTime(1800, now + 1.2);
+ whineOsc.frequency.exponentialRampToValueAtTime(3200, now + 2.5);
+
+ const whineFilter = ctx.createBiquadFilter();
+ whineFilter.type = 'bandpass';
+ whineFilter.frequency.setValueAtTime(280, now);
+ whineFilter.frequency.exponentialRampToValueAtTime(2600, now + 2.0);
+ whineFilter.Q.setValueAtTime(4.0, now);
+
+ const whineGain = ctx.createGain();
+ whineGain.gain.setValueAtTime(0.001, now);
+ whineGain.gain.linearRampToValueAtTime(0.35, now + 1.0);
+ whineGain.gain.exponentialRampToValueAtTime(0.001, now + duration);
+
+ whineOsc.connect(whineFilter);
+ whineFilter.connect(whineGain);
+ whineGain.connect(this.gainNode);
+
+ // 2. Colossal fusion blast roar (filtered noise)
+ const roarBuffer = this.am.createNoiseBuffer('brown', 4);
+ const roarSource = ctx.createBufferSource();
+ roarSource.buffer = roarBuffer;
+
+ const roarFilter = ctx.createBiquadFilter();
+ roarFilter.type = 'lowpass';
+ roarFilter.frequency.setValueAtTime(180, now);
+ roarFilter.frequency.linearRampToValueAtTime(550, now + 1.2);
+ roarFilter.frequency.exponentialRampToValueAtTime(120, now + duration);
+
+ const roarGain = ctx.createGain();
+ roarGain.gain.setValueAtTime(0.001, now);
+ roarGain.gain.linearRampToValueAtTime(0.7, now + 1.2);
+ roarGain.gain.exponentialRampToValueAtTime(0.001, now + duration);
+
+ roarSource.connect(roarFilter);
+ roarFilter.connect(roarGain);
+ roarGain.connect(this.gainNode);
+
+ whineOsc.start(now);
+ whineOsc.stop(now + duration);
+ roarSource.start(now);
+ roarSource.stop(now + duration);
+ }
+
+ /**
+ * Bio-Ship Starburst / Neural Pulse (Farscape Moya & Bioships)
+ * Organic vocalized dimensional fold and vascular wave
+ */
+ synthesizeStarburst() {
+ this.init();
+ const ctx = this.am.ctx;
+ if (!ctx) return;
+
+ const now = ctx.currentTime;
+ const duration = 2.8;
+
+ const osc1 = ctx.createOscillator();
+ osc1.type = 'sine';
+ osc1.frequency.setValueAtTime(85, now);
+ osc1.frequency.exponentialRampToValueAtTime(940, now + 1.4);
+ osc1.frequency.exponentialRampToValueAtTime(45, now + duration);
+
+ const osc2 = ctx.createOscillator();
+ osc2.type = 'triangle';
+ osc2.frequency.setValueAtTime(125, now);
+ osc2.frequency.exponentialRampToValueAtTime(1420, now + 1.4);
+ osc2.frequency.exponentialRampToValueAtTime(65, now + duration);
+
+ const env = ctx.createGain();
+ env.gain.setValueAtTime(0.001, now);
+ env.gain.linearRampToValueAtTime(0.45, now + 1.3);
+ env.gain.exponentialRampToValueAtTime(0.001, now + duration);
+
+ osc1.connect(env);
+ osc2.connect(env);
+ env.connect(this.gainNode);
+
+ osc1.start(now);
+ osc2.start(now);
+ osc1.stop(now + duration);
+ osc2.stop(now + duration);
+ }
+
+ /**
+ * Battlestar Galactica DRADAR Sonar Ping (Military Space)
+ * The iconic tactical combat contact echo
+ */
+ synthesizeDRADARPing() {
+ this.init();
+ const ctx = this.am.ctx;
+ if (!ctx) return;
+
+ const now = ctx.currentTime;
+ const duration = 1.4;
+
+ const osc = ctx.createOscillator();
+ osc.type = 'sine';
+ osc.frequency.setValueAtTime(1860, now);
+ osc.frequency.exponentialRampToValueAtTime(1540, now + 0.08);
+
+ const env = ctx.createGain();
+ env.gain.setValueAtTime(0.001, now);
+ env.gain.linearRampToValueAtTime(0.35, now + 0.01);
+ env.gain.exponentialRampToValueAtTime(0.0001, now + duration);
+
+ osc.connect(env);
+ env.connect(this.gainNode);
+
+ osc.start(now);
+ osc.stop(now + duration);
+ }
+
+ /**
+ * FTL Jump Thunderclap (BSG / Military Space)
+ * Sudden vacuum displacement shockwave
+ */
+ synthesizeFtlJump() {
+ this.init();
+ const ctx = this.am.ctx;
+ if (!ctx) return;
+
+ const now = ctx.currentTime;
+ const duration = 2.2;
+
+ const noiseBuffer = this.am.createNoiseBuffer('brown', 2.5);
+ const noise = ctx.createBufferSource();
+ noise.buffer = noiseBuffer;
+
+ const filter = ctx.createBiquadFilter();
+ filter.type = 'lowpass';
+ filter.frequency.setValueAtTime(800, now);
+ filter.frequency.exponentialRampToValueAtTime(45, now + 1.8);
+
+ const env = ctx.createGain();
+ env.gain.setValueAtTime(0.8, now);
+ env.gain.exponentialRampToValueAtTime(0.001, now + duration);
+
+ noise.connect(filter);
+ filter.connect(env);
+ env.connect(this.gainNode);
+
+ noise.start(now);
+ noise.stop(now + duration);
+ }
+
+ /**
+ * Retro Astrogator Tone Glide (Jupiter 2 / Retro Future)
+ * 1960s Theremin / electronic oscillator glissando
+ */
+ synthesizeRetroAstrogator() {
+ this.init();
+ const ctx = this.am.ctx;
+ if (!ctx) return;
+
+ const now = ctx.currentTime;
+ const duration = 1.6;
+
+ const osc = ctx.createOscillator();
+ osc.type = 'sine';
+ osc.frequency.setValueAtTime(440, now);
+ osc.frequency.linearRampToValueAtTime(1180, now + 0.6);
+ osc.frequency.linearRampToValueAtTime(320, now + 1.1);
+ osc.frequency.linearRampToValueAtTime(660, now + duration);
+
+ const vibrato = ctx.createOscillator();
+ vibrato.type = 'sine';
+ vibrato.frequency.setValueAtTime(8, now);
+
+ const vibGain = ctx.createGain();
+ vibGain.gain.setValueAtTime(25, now);
+ vibrato.connect(vibGain);
+ vibGain.connect(osc.frequency);
+
+ const env = ctx.createGain();
+ env.gain.setValueAtTime(0.001, now);
+ env.gain.linearRampToValueAtTime(0.3, now + 0.1);
+ env.gain.exponentialRampToValueAtTime(0.001, now + duration);
+
+ osc.connect(env);
+ env.connect(this.gainNode);
+
+ osc.start(now);
+ vibrato.start(now);
+ osc.stop(now + duration);
+ vibrato.stop(now + duration);
+ }
+
+ /**
+ * HAL 9999 Logic Confirmation Chime (Discovery One)
+ */
+ synthesizeHalChime() {
+ this.init();
+ const ctx = this.am.ctx;
+ if (!ctx) return;
+
+ const now = ctx.currentTime;
+ const f1 = 784; // G5
+ const f2 = 523; // C5
+
+ [0, 0.14].forEach((offset, idx) => {
+ const osc = ctx.createOscillator();
+ osc.type = 'sine';
+ osc.frequency.setValueAtTime(idx === 0 ? f1 : f2, now + offset);
+
+ const env = ctx.createGain();
+ env.gain.setValueAtTime(0.001, now + offset);
+ env.gain.linearRampToValueAtTime(0.25, now + offset + 0.01);
+ env.gain.exponentialRampToValueAtTime(0.001, now + offset + 0.5);
+
+ osc.connect(env);
+ env.connect(this.gainNode);
+
+ osc.start(now + offset);
+ osc.stop(now + offset + 0.52);
+ });
+ }
+
+ /**
+ * Event Horizon Gravity Singularity Pulse (Deep Space)
+ * Deep sub-bass dimensional warping thrum
+ */
+ synthesizeSingularityEngage() {
+ this.init();
+ const ctx = this.am.ctx;
+ if (!ctx) return;
+
+ const now = ctx.currentTime;
+ const duration = 3.2;
+
+ const sub = ctx.createOscillator();
+ sub.type = 'sine';
+ sub.frequency.setValueAtTime(95, now);
+ sub.frequency.exponentialRampToValueAtTime(28, now + 2.2);
+
+ const mod = ctx.createOscillator();
+ mod.type = 'triangle';
+ mod.frequency.setValueAtTime(14, now);
+ mod.frequency.linearRampToValueAtTime(45, now + 1.8);
+
+ const modGain = ctx.createGain();
+ modGain.gain.setValueAtTime(60, now);
+ mod.connect(modGain);
+ modGain.connect(sub.frequency);
+
+ const env = ctx.createGain();
+ env.gain.setValueAtTime(0.001, now);
+ env.gain.linearRampToValueAtTime(0.65, now + 1.5);
+ env.gain.exponentialRampToValueAtTime(0.0001, now + duration);
+
+ sub.connect(env);
+ env.connect(this.gainNode);
+
+ sub.start(now);
+ mod.start(now);
+ sub.stop(now + duration);
+ mod.stop(now + duration);
+ }
+
+ /**
+ * Outlaw Afterburner Thruster Surge (Cowboy Bebop / Milano)
+ */
+ synthesizeAfterburner() {
+ this.init();
+ const ctx = this.am.ctx;
+ if (!ctx) return;
+
+ const now = ctx.currentTime;
+ const duration = 2.4;
+
+ const osc = ctx.createOscillator();
+ osc.type = 'sawtooth';
+ osc.frequency.setValueAtTime(80, now);
+ osc.frequency.exponentialRampToValueAtTime(850, now + 0.8);
+ osc.frequency.linearRampToValueAtTime(620, now + duration);
+
+ const filter = ctx.createBiquadFilter();
+ filter.type = 'bandpass';
+ filter.frequency.setValueAtTime(350, now);
+ filter.frequency.exponentialRampToValueAtTime(1400, now + 0.8);
+ filter.Q.setValueAtTime(3.0, now);
+
+ const env = ctx.createGain();
+ env.gain.setValueAtTime(0.001, now);
+ env.gain.linearRampToValueAtTime(0.5, now + 0.6);
+ env.gain.exponentialRampToValueAtTime(0.001, now + duration);
+
+ osc.connect(filter);
+ filter.connect(env);
+ env.connect(this.gainNode);
+
+ osc.start(now);
+ osc.stop(now + duration);
+ }
+
+ /**
+ * Space Station Docking Clamp Latch & Airlock Purge
+ */
+ synthesizeDockingClamp() {
+ this.init();
+ const ctx = this.am.ctx;
+ if (!ctx) return;
+
+ const now = ctx.currentTime;
+
+ // Heavy mechanical solenoid impact
+ const osc = ctx.createOscillator();
+ osc.type = 'square';
+ osc.frequency.setValueAtTime(380, now);
+ osc.frequency.exponentialRampToValueAtTime(65, now + 0.12);
+
+ const env = ctx.createGain();
+ env.gain.setValueAtTime(0.5, now);
+ env.gain.exponentialRampToValueAtTime(0.001, now + 0.25);
+
+ osc.connect(env);
+ env.connect(this.gainNode);
+
+ osc.start(now);
+ osc.stop(now + 0.25);
+
+ // Followed by pneumatic seal hiss
+ setTimeout(() => {
+ if (!this.am.ctx) return;
+ const t = this.am.ctx.currentTime;
+ const hissBuf = this.am.createNoiseBuffer('white', 1.2);
+ const hiss = this.am.ctx.createBufferSource();
+ hiss.buffer = hissBuf;
+
+ const hFilter = this.am.ctx.createBiquadFilter();
+ hFilter.type = 'bandpass';
+ hFilter.frequency.setValueAtTime(2200, t);
+ hFilter.Q.setValueAtTime(2.5, t);
+
+ const hEnv = this.am.ctx.createGain();
+ hEnv.gain.setValueAtTime(0.001, t);
+ hEnv.gain.linearRampToValueAtTime(0.25, t + 0.05);
+ hEnv.gain.exponentialRampToValueAtTime(0.001, t + 0.9);
+
+ hiss.connect(hFilter);
+ hFilter.connect(hEnv);
+ hEnv.connect(this.gainNode);
+
+ hiss.start(t);
+ hiss.stop(t + 0.9);
+ }, 180);
+ }
+
+ /**
+ * Space Station Air Handler Thud (large HVAC unit cycling on)
+ * Distinct from the docking clamp latch above: a dull low-frequency thump
+ * (no bright metallic impact) followed by a slow-building airflow whoosh
+ * rather than a short pneumatic hiss. A clamp is a single hard mechanical
+ * event; an air handler is a big soft one that keeps breathing after it.
+ */
+ synthesizeAirHandlerThud() {
+ this.init();
+ const ctx = this.am.ctx;
+ if (!ctx) return;
+
+ const now = ctx.currentTime;
+
+ // Dull low-frequency thump -- triangle, not square, and no bright impact
+ // transient, so it reads as a heavy fan housing rather than a latch.
+ const thump = ctx.createOscillator();
+ thump.type = 'triangle';
+ thump.frequency.setValueAtTime(95, now);
+ thump.frequency.exponentialRampToValueAtTime(38, now + 0.22);
+
+ const thumpEnv = ctx.createGain();
+ thumpEnv.gain.setValueAtTime(0.001, now);
+ thumpEnv.gain.linearRampToValueAtTime(0.42, now + 0.02);
+ thumpEnv.gain.exponentialRampToValueAtTime(0.001, now + 0.4);
+
+ // Sub-octave body for HVAC housing weight
+ const sub = ctx.createOscillator();
+ sub.type = 'sine';
+ sub.frequency.setValueAtTime(46, now);
+
+ const subEnv = ctx.createGain();
+ subEnv.gain.setValueAtTime(0.001, now);
+ subEnv.gain.linearRampToValueAtTime(0.25, now + 0.03);
+ subEnv.gain.exponentialRampToValueAtTime(0.001, now + 0.45);
+
+ thump.connect(thumpEnv);
+ thumpEnv.connect(this.gainNode);
+ sub.connect(subEnv);
+ subEnv.connect(this.gainNode);
+
+ thump.start(now);
+ thump.stop(now + 0.4);
+ sub.start(now);
+ sub.stop(now + 0.45);
+
+ // Slow-building airflow whoosh -- lowpass rather than the clamp's
+ // bandpass, and a much slower attack, so it reads as a fan spinning up
+ // rather than a sharp seal-release hiss.
+ setTimeout(() => {
+ if (!this.am.ctx) return;
+ const t = this.am.ctx.currentTime;
+ const airBuf = this.am.createNoiseBuffer('pink', 1.8);
+ const air = this.am.ctx.createBufferSource();
+ air.buffer = airBuf;
+
+ const airFilter = this.am.ctx.createBiquadFilter();
+ airFilter.type = 'lowpass';
+ airFilter.frequency.setValueAtTime(420, t);
+ airFilter.Q.setValueAtTime(0.7, t);
+
+ const airEnv = this.am.ctx.createGain();
+ airEnv.gain.setValueAtTime(0.001, t);
+ airEnv.gain.linearRampToValueAtTime(0.22, t + 0.35);
+ airEnv.gain.exponentialRampToValueAtTime(0.001, t + 1.6);
+
+ air.connect(airFilter);
+ airFilter.connect(airEnv);
+ airEnv.connect(this.gainNode);
+
+ air.start(t);
+ air.stop(t + 1.6);
+ }, 140);
+ }
+
+ /**
+ * Ridiculous Speed Accelerator (Spaceball One / Comedy)
+ */
+ synthesizeLudicrousSpeed() {
+ this.init();
+ const ctx = this.am.ctx;
+ if (!ctx) return;
+
+ const now = ctx.currentTime;
+ const duration = 3.2;
+
+ const osc = ctx.createOscillator();
+ osc.type = 'sawtooth';
+ osc.frequency.setValueAtTime(60, now);
+ osc.frequency.exponentialRampToValueAtTime(5400, now + 2.2);
+
+ const env = ctx.createGain();
+ env.gain.setValueAtTime(0.01, now);
+ env.gain.linearRampToValueAtTime(0.4, now + 1.8);
+ env.gain.exponentialRampToValueAtTime(0.0001, now + duration);
+
+ osc.connect(env);
+ env.connect(this.gainNode);
+
+ osc.start(now);
+ osc.stop(now + duration);
+ }
+
+ /**
+ * Infinite Unlikelihood Reality-Warp Flip (Heart of Gold)
+ */
+ synthesizeImprobabilityFlip() {
+ this.init();
+ const ctx = this.am.ctx;
+ if (!ctx) return;
+
+ const now = ctx.currentTime;
+ const duration = 1.8;
+
+ const osc = ctx.createOscillator();
+ osc.type = 'triangle';
+ osc.frequency.setValueAtTime(1400, now);
+ osc.frequency.exponentialRampToValueAtTime(180, now + 0.7);
+ osc.frequency.exponentialRampToValueAtTime(2200, now + 1.3);
+ osc.frequency.exponentialRampToValueAtTime(440, now + duration);
+
+ const env = ctx.createGain();
+ env.gain.setValueAtTime(0.001, now);
+ env.gain.linearRampToValueAtTime(0.35, now + 0.1);
+ env.gain.exponentialRampToValueAtTime(0.001, now + duration);
+
+ osc.connect(env);
+ env.connect(this.gainNode);
+
+ osc.start(now);
+ osc.stop(now + duration);
+ }
+
+ /**
+ * Geiger Counter Click Burst (Nostromo / Mining)
+ */
+ synthesizeGeigerBurst() {
+ this.init();
+ const ctx = this.am.ctx;
+ if (!ctx) return;
+
+ const now = ctx.currentTime;
+ const clicks = 8 + Math.floor(Math.random() * 8);
+
+ for (let i = 0; i < clicks; i++) {
+ const clickTime = now + (i * 0.04) + (Math.random() * 0.03);
+ const osc = ctx.createOscillator();
+ osc.type = 'square';
+ osc.frequency.setValueAtTime(2800 + Math.random() * 800, clickTime);
+
+ const env = ctx.createGain();
+ env.gain.setValueAtTime(0.18, clickTime);
+ env.gain.exponentialRampToValueAtTime(0.001, clickTime + 0.015);
+
+ osc.connect(env);
+ env.connect(this.gainNode);
+
+ osc.start(clickTime);
+ osc.stop(clickTime + 0.016);
+ }
+ }
+
+ /**
+ * Cheerful Door Sigh (Heart of Gold / Sirius Cybernetics Corp)
+ */
+ synthesizeCheerfulDoor() {
+ this.init();
+ const ctx = this.am.ctx;
+ if (!ctx) return;
+
+ const now = ctx.currentTime;
+ const duration = 0.9;
+
+ const osc = ctx.createOscillator();
+ osc.type = 'sine';
+ osc.frequency.setValueAtTime(620, now);
+ osc.frequency.linearRampToValueAtTime(840, now + 0.35);
+ osc.frequency.linearRampToValueAtTime(520, now + duration);
+
+ const env = ctx.createGain();
+ env.gain.setValueAtTime(0.001, now);
+ env.gain.linearRampToValueAtTime(0.25, now + 0.15);
+ env.gain.exponentialRampToValueAtTime(0.001, now + duration);
+
+ osc.connect(env);
+ env.connect(this.gainNode);
+
+ osc.start(now);
+ osc.stop(now + duration);
+ }
+
+ /**
+ * Star Trek Com Badge Confirmation Chirp
+ * Iconic bright two-tone pulse in rapid sequence (784 Hz to 1397 Hz)
+ */
+ synthesizeCommBadge() {
+ this.init();
+ const ctx = this.am.ctx;
+ if (!ctx || !this.gainNode) return;
+
+ const now = ctx.currentTime;
+ const tone1Freq = 784; // G5
+ const tone2Freq = 1396.91; // F6
+
+ // Pulse 1
+ const osc1 = ctx.createOscillator();
+ osc1.type = 'sine';
+ osc1.frequency.setValueAtTime(tone1Freq, now);
+
+ const env1 = ctx.createGain();
+ env1.gain.setValueAtTime(0.001, now);
+ env1.gain.linearRampToValueAtTime(0.38, now + 0.005);
+ env1.gain.exponentialRampToValueAtTime(0.001, now + 0.045);
+
+ osc1.connect(env1);
+ env1.connect(this.gainNode);
+ osc1.start(now);
+ osc1.stop(now + 0.048);
+
+ // Pulse 2
+ const t2 = now + 0.038;
+ const osc2 = ctx.createOscillator();
+ osc2.type = 'sine';
+ osc2.frequency.setValueAtTime(tone2Freq, t2);
+
+ const env2 = ctx.createGain();
+ env2.gain.setValueAtTime(0.001, t2);
+ env2.gain.linearRampToValueAtTime(0.42, t2 + 0.006);
+ env2.gain.exponentialRampToValueAtTime(0.001, t2 + 0.095);
+
+ osc2.connect(env2);
+ env2.connect(this.gainNode);
+ osc2.start(t2);
+ osc2.stop(t2 + 0.1);
+ }
+
+ /**
+ * Starflight Pneumatic Door Swish (Four Era Variants)
+ * Bandpass-filtered white noise burst shaped per era
+ */
+ synthesizeDoorSwish(era = 'tng') {
+ this.init();
+ const ctx = this.am.ctx;
+ if (!ctx || !this.gainNode) return;
+
+ const now = ctx.currentTime;
+ let centerFreq = 950;
+ let qVal = 1.8;
+ let duration = 0.45;
+ let noiseType = 'white';
+
+ switch (era) {
+ case 'tos':
+ centerFreq = 720;
+ qVal = 1.4;
+ duration = 0.38;
+ break;
+ case 'voyager':
+ centerFreq = 1250;
+ qVal = 2.4;
+ duration = 0.40;
+ break;
+ case 'nx':
+ centerFreq = 650;
+ qVal = 2.0;
+ duration = 0.55;
+ noiseType = 'pink';
+ break;
+ case 'tng':
+ default:
+ centerFreq = 950;
+ qVal = 1.8;
+ duration = 0.45;
+ break;
+ }
+
+ const noiseBuf = this.am.createNoiseBuffer(noiseType, duration + 0.1);
+ const noise = ctx.createBufferSource();
+ noise.buffer = noiseBuf;
+
+ const bp = ctx.createBiquadFilter();
+ bp.type = 'bandpass';
+ bp.frequency.setValueAtTime(centerFreq * 0.8, now);
+ bp.frequency.linearRampToValueAtTime(centerFreq * 1.15, now + duration * 0.4);
+ bp.frequency.exponentialRampToValueAtTime(centerFreq * 0.7, now + duration);
+ bp.Q.setValueAtTime(qVal, now);
+
+ const env = ctx.createGain();
+ env.gain.setValueAtTime(0.001, now);
+ env.gain.linearRampToValueAtTime(0.45, now + 0.03);
+ env.gain.exponentialRampToValueAtTime(0.001, now + duration);
+
+ noise.connect(bp);
+ bp.connect(env);
+ env.connect(this.gainNode);
+
+ noise.start(now);
+ noise.stop(now + duration + 0.02);
+ }
+
+ /**
+ * Starflight Bosun's Pipe Whistle (TOS Command Announce)
+ * Two-tone sine glide (1800 Hz -> 2400 Hz -> 1850 Hz) with gentle tremolo
+ */
+ synthesizeBosunWhistle() {
+ this.init();
+ const ctx = this.am.ctx;
+ if (!ctx || !this.gainNode) return;
+
+ const now = ctx.currentTime;
+ const duration = 0.85;
+
+ const osc = ctx.createOscillator();
+ osc.type = 'sine';
+ osc.frequency.setValueAtTime(1800, now);
+ osc.frequency.linearRampToValueAtTime(2400, now + 0.22);
+ osc.frequency.setValueAtTime(2400, now + 0.48);
+ osc.frequency.linearRampToValueAtTime(1850, now + 0.75);
+
+ const tremolo = ctx.createOscillator();
+ tremolo.type = 'sine';
+ tremolo.frequency.setValueAtTime(6.5, now);
+
+ const tremGain = ctx.createGain();
+ tremGain.gain.setValueAtTime(0.12, now);
+ tremolo.connect(tremGain);
+
+ const env = ctx.createGain();
+ env.gain.setValueAtTime(0.001, now);
+ env.gain.linearRampToValueAtTime(0.28, now + 0.05);
+ env.gain.setValueAtTime(0.28, now + 0.65);
+ env.gain.exponentialRampToValueAtTime(0.001, now + duration);
+
+ tremGain.connect(env.gain);
+
+ osc.connect(env);
+ env.connect(this.gainNode);
+
+ osc.start(now);
+ tremolo.start(now);
+ osc.stop(now + duration);
+ tremolo.stop(now + duration);
+ }
+
+ /**
+ * Starflight Medbay Medical Monitor ECG Ping
+ * Calm, periodic rhythmic vital signs pulse (1080 Hz sine, 40 ms decay)
+ */
+ synthesizeMedicalMonitor() {
+ this.init();
+ const ctx = this.am.ctx;
+ if (!ctx || !this.gainNode) return;
+
+ const now = ctx.currentTime;
+ const duration = 0.045;
+
+ const osc = ctx.createOscillator();
+ osc.type = 'sine';
+ osc.frequency.setValueAtTime(1080, now);
+
+ const env = ctx.createGain();
+ env.gain.setValueAtTime(0.001, now);
+ env.gain.linearRampToValueAtTime(0.32, now + 0.003);
+ env.gain.exponentialRampToValueAtTime(0.0001, now + duration);
+
+ osc.connect(env);
+ env.connect(this.gainNode);
+
+ osc.start(now);
+ osc.stop(now + duration + 0.005);
+ }
+
+ /**
+ * Sevastopol Electrical Conduit Spark Transient
+ * High-voltage electrical discharge arcs across damaged station bulkheads
+ */
+ synthesizeSevastopolSpark() {
+ this.init();
+ const ctx = this.am.ctx;
+ if (!ctx || !this.gainNode) return;
+
+ const now = ctx.currentTime;
+ const bursts = 2 + Math.floor(Math.random() * 2);
+
+ for (let i = 0; i < bursts; i++) {
+ const offset = i * (0.015 + Math.random() * 0.02);
+ const t = now + offset;
+ const dur = 0.008 + Math.random() * 0.008;
+
+ const noiseBuf = this.am.createNoiseBuffer('white', 0.05);
+ const noise = ctx.createBufferSource();
+ noise.buffer = noiseBuf;
+
+ const hp = ctx.createBiquadFilter();
+ hp.type = 'highpass';
+ hp.frequency.setValueAtTime(3600 + Math.random() * 800, t);
+ hp.Q.setValueAtTime(4.5, t);
+
+ const env = ctx.createGain();
+ env.gain.setValueAtTime(0.45, t);
+ env.gain.exponentialRampToValueAtTime(0.001, t + dur);
+
+ noise.connect(hp);
+ hp.connect(env);
+ env.connect(this.gainNode);
+
+ noise.start(t);
+ noise.stop(t + dur + 0.005);
+ }
+ }
+
+ /**
+ * Moonbase Alpha Comlock Calling Tone (Space: 9999)
+ * Dual square wave pulse sequence (1100 Hz / 1500 Hz)
+ */
+ synthesizeComlockTone() {
+ this.init();
+ const ctx = this.am.ctx;
+ if (!ctx || !this.gainNode) return;
+
+ const now = ctx.currentTime;
+
+ // Beep 1: 1100 Hz, 60 ms
+ const osc1 = ctx.createOscillator();
+ osc1.type = 'square';
+ osc1.frequency.setValueAtTime(1100, now);
+
+ const env1 = ctx.createGain();
+ env1.gain.setValueAtTime(0.001, now);
+ env1.gain.linearRampToValueAtTime(0.24, now + 0.004);
+ env1.gain.setValueAtTime(0.24, now + 0.055);
+ env1.gain.exponentialRampToValueAtTime(0.001, now + 0.062);
+
+ osc1.connect(env1);
+ env1.connect(this.gainNode);
+ osc1.start(now);
+ osc1.stop(now + 0.065);
+
+ // Beep 2: 1500 Hz, 75 ms starting at +0.082s
+ const t2 = now + 0.082;
+ const osc2 = ctx.createOscillator();
+ osc2.type = 'square';
+ osc2.frequency.setValueAtTime(1500, t2);
+
+ const env2 = ctx.createGain();
+ env2.gain.setValueAtTime(0.001, t2);
+ env2.gain.linearRampToValueAtTime(0.24, t2 + 0.004);
+ env2.gain.setValueAtTime(0.24, t2 + 0.07);
+ env2.gain.exponentialRampToValueAtTime(0.001, t2 + 0.078);
+
+ osc2.connect(env2);
+ env2.connect(this.gainNode);
+ osc2.start(t2);
+ osc2.stop(t2 + 0.082);
+ }
+
+ /**
+ * Gateway Station Medical Vital Telemetry Pip (Aliens)
+ * Clinical sterile 950 Hz pure sine pip (35 ms decay)
+ */
+ synthesizeStationMedicalPing() {
+ this.init();
+ const ctx = this.am.ctx;
+ if (!ctx || !this.gainNode) return;
+
+ const now = ctx.currentTime;
+ const duration = 0.038;
+
+ const osc = ctx.createOscillator();
+ osc.type = 'sine';
+ osc.frequency.setValueAtTime(950, now);
+
+ const env = ctx.createGain();
+ env.gain.setValueAtTime(0.001, now);
+ env.gain.linearRampToValueAtTime(0.3, now + 0.003);
+ env.gain.exponentialRampToValueAtTime(0.0001, now + duration);
+
+ osc.connect(env);
+ env.connect(this.gainNode);
+
+ osc.start(now);
+ osc.stop(now + duration + 0.005);
+ }
+
+ /**
+ * Solar Radiation / Heat Shield Roar (Icarus II / Sunshine)
+ * Terrifying lowpass brown noise roar with amplitude swell and crackle impulses
+ */
+ synthesizeSolarRoar() {
+ this.init();
+ const ctx = this.am.ctx;
+ if (!ctx || !this.gainNode) return;
+
+ const now = ctx.currentTime;
+ const duration = 3.6;
+
+ // Lowpass brown noise body
+ const noiseBuf = this.am.createNoiseBuffer('brown', duration + 0.2);
+ const noise = ctx.createBufferSource();
+ noise.buffer = noiseBuf;
+
+ const lp = ctx.createBiquadFilter();
+ lp.type = 'lowpass';
+ lp.frequency.setValueAtTime(120, now);
+ lp.frequency.linearRampToValueAtTime(180, now + 1.5);
+ lp.frequency.exponentialRampToValueAtTime(90, now + duration);
+ lp.Q.setValueAtTime(2.2, now);
+
+ const env = ctx.createGain();
+ env.gain.setValueAtTime(0.001, now);
+ env.gain.linearRampToValueAtTime(0.72, now + 1.4);
+ env.gain.setValueAtTime(0.70, now + 2.2);
+ env.gain.exponentialRampToValueAtTime(0.001, now + duration);
+
+ noise.connect(lp);
+ lp.connect(env);
+ env.connect(this.gainNode);
+
+ noise.start(now);
+ noise.stop(now + duration + 0.05);
+
+ // Highpass solar crackle layer
+ const crackleBuf = this.am.createNoiseBuffer('pink', duration);
+ const crackle = ctx.createBufferSource();
+ crackle.buffer = crackleBuf;
+
+ const hp = ctx.createBiquadFilter();
+ hp.type = 'highpass';
+ hp.frequency.setValueAtTime(3200, now);
+ hp.Q.setValueAtTime(4.0, now);
+
+ const crackleEnv = ctx.createGain();
+ crackleEnv.gain.setValueAtTime(0.001, now);
+ crackleEnv.gain.linearRampToValueAtTime(0.08, now + 1.2);
+ crackleEnv.gain.exponentialRampToValueAtTime(0.001, now + duration);
+
+ crackle.connect(hp);
+ hp.connect(crackleEnv);
+ crackleEnv.connect(this.gainNode);
+
+ crackle.start(now);
+ crackle.stop(now + duration);
+ }
+
+ /**
+ * Cryogenic Pod Depressurization Sigh (Avalon / Ark One)
+ * Gas pressure relief with exponential filter decay followed by 205 Hz seal hum
+ */
+ synthesizeCryoDepressurize() {
+ this.init();
+ const ctx = this.am.ctx;
+ if (!ctx || !this.gainNode) return;
+
+ const now = ctx.currentTime;
+ const duration = 1.35;
+
+ // Pneumatic gas depressurization sigh
+ const noiseBuf = this.am.createNoiseBuffer('white', 1.4);
+ const noise = ctx.createBufferSource();
+ noise.buffer = noiseBuf;
+
+ const lp = ctx.createBiquadFilter();
+ lp.type = 'lowpass';
+ lp.frequency.setValueAtTime(1800, now);
+ lp.frequency.exponentialRampToValueAtTime(280, now + 1.1);
+
+ const env = ctx.createGain();
+ env.gain.setValueAtTime(0.001, now);
+ env.gain.linearRampToValueAtTime(0.38, now + 0.06);
+ env.gain.exponentialRampToValueAtTime(0.001, now + 1.15);
+
+ noise.connect(lp);
+ lp.connect(env);
+ env.connect(this.gainNode);
+
+ noise.start(now);
+ noise.stop(now + 1.2);
+
+ // Quiet motorized seal hum
+ const hum = ctx.createOscillator();
+ hum.type = 'sine';
+ hum.frequency.setValueAtTime(205, now + 0.35);
+
+ const humEnv = ctx.createGain();
+ humEnv.gain.setValueAtTime(0.001, now + 0.35);
+ humEnv.gain.linearRampToValueAtTime(0.18, now + 0.55);
+ humEnv.gain.exponentialRampToValueAtTime(0.001, now + duration);
+
+ hum.connect(humEnv);
+ humEnv.connect(this.gainNode);
+
+ hum.start(now + 0.35);
+ hum.stop(now + duration + 0.02);
+ }
+
+ /**
+ * Mid-Century Orion Nuclear Pulse Thump (USS Ascension)
+ * Heavy 45 Hz lowpass square impulse with sub-bass body resonance
+ */
+ synthesizeOrionPulseThump() {
+ this.init();
+ const ctx = this.am.ctx;
+ if (!ctx || !this.gainNode) return;
+
+ const now = ctx.currentTime;
+ const duration = 0.22;
+
+ const osc = ctx.createOscillator();
+ osc.type = 'square';
+ osc.frequency.setValueAtTime(45, now);
+ osc.frequency.exponentialRampToValueAtTime(24, now + 0.09);
+
+ const lp = ctx.createBiquadFilter();
+ lp.type = 'lowpass';
+ lp.frequency.setValueAtTime(140, now);
+
+ const env = ctx.createGain();
+ env.gain.setValueAtTime(0.65, now);
+ env.gain.exponentialRampToValueAtTime(0.001, now + 0.085);
+
+ osc.connect(lp);
+ lp.connect(env);
+ env.connect(this.gainNode);
+
+ osc.start(now);
+ osc.stop(now + 0.09);
+
+ const sub = ctx.createOscillator();
+ sub.type = 'sine';
+ sub.frequency.setValueAtTime(36, now);
+
+ const subEnv = ctx.createGain();
+ subEnv.gain.setValueAtTime(0.001, now);
+ subEnv.gain.linearRampToValueAtTime(0.55, now + 0.01);
+ subEnv.gain.exponentialRampToValueAtTime(0.0001, now + duration);
+
+ sub.connect(subEnv);
+ subEnv.connect(this.gainNode);
+
+ sub.start(now);
+ sub.stop(now + duration);
+ }
+
+ /**
+ * Nutri-Matic Dedicated Tea Dispenser Gurgle (Heart of Gold)
+ * Bandpass bubbling noise hops (500–1000 Hz) terminating in a short steam hiss
+ */
+ synthesizeTeaDispenser() {
+ this.init();
+ const ctx = this.am.ctx;
+ if (!ctx || !this.gainNode) return;
+
+ const now = ctx.currentTime;
+
+ // Bubbling hops
+ const bubbleFreqs = [520, 840, 610, 960, 480, 730];
+ bubbleFreqs.forEach((freq, i) => {
+ const t = now + i * 0.11;
+ const bDur = 0.09;
+
+ const noiseBuf = this.am.createNoiseBuffer('pink', 0.12);
+ const noise = ctx.createBufferSource();
+ noise.buffer = noiseBuf;
+
+ const bp = ctx.createBiquadFilter();
+ bp.type = 'bandpass';
+ bp.frequency.setValueAtTime(freq, t);
+ bp.Q.setValueAtTime(6.0, t);
+
+ const env = ctx.createGain();
+ env.gain.setValueAtTime(0.001, t);
+ env.gain.linearRampToValueAtTime(0.35, t + 0.015);
+ env.gain.exponentialRampToValueAtTime(0.001, t + bDur);
+
+ noise.connect(bp);
+ bp.connect(env);
+ env.connect(this.gainNode);
+
+ noise.start(t);
+ noise.stop(t + bDur + 0.01);
+ });
+
+ // Steam hiss
+ const steamTime = now + 0.65;
+ const steamBuf = this.am.createNoiseBuffer('white', 0.45);
+ const steam = ctx.createBufferSource();
+ steam.buffer = steamBuf;
+
+ const steamFilter = ctx.createBiquadFilter();
+ steamFilter.type = 'bandpass';
+ steamFilter.frequency.setValueAtTime(2400, steamTime);
+ steamFilter.Q.setValueAtTime(3.0, steamTime);
+
+ const steamEnv = ctx.createGain();
+ steamEnv.gain.setValueAtTime(0.001, steamTime);
+ steamEnv.gain.linearRampToValueAtTime(0.28, steamTime + 0.04);
+ steamEnv.gain.exponentialRampToValueAtTime(0.001, steamTime + 0.42);
+
+ steam.connect(steamFilter);
+ steamFilter.connect(steamEnv);
+ steamEnv.connect(this.gainNode);
+
+ steam.start(steamTime);
+ steam.stop(steamTime + 0.45);
+ }
+
+ /**
+ * British Electric Kettle Steam Whistle (HMS Camden Lock / Hyperdrive)
+ * Narrow bandpass sine sweeping 1820 Hz to 2380 Hz with steady tremolo
+ */
+ synthesizeKettleWhistle() {
+ this.init();
+ const ctx = this.am.ctx;
+ if (!ctx || !this.gainNode) return;
+
+ const now = ctx.currentTime;
+ const duration = 1.6;
+
+ const osc = ctx.createOscillator();
+ osc.type = 'sine';
+ osc.frequency.setValueAtTime(1820, now);
+ osc.frequency.exponentialRampToValueAtTime(2380, now + duration * 0.7);
+ osc.frequency.linearRampToValueAtTime(2320, now + duration);
+
+ const tremolo = ctx.createOscillator();
+ tremolo.type = 'sine';
+ tremolo.frequency.setValueAtTime(5.2, now);
+
+ const tremGain = ctx.createGain();
+ tremGain.gain.setValueAtTime(0.15, now);
+ tremolo.connect(tremGain);
+
+ const env = ctx.createGain();
+ env.gain.setValueAtTime(0.001, now);
+ env.gain.linearRampToValueAtTime(0.26, now + 0.25);
+ env.gain.setValueAtTime(0.26, now + 1.2);
+ env.gain.exponentialRampToValueAtTime(0.001, now + duration);
+
+ tremGain.connect(env.gain);
+
+ osc.connect(env);
+ env.connect(this.gainNode);
+
+ osc.start(now);
+ tremolo.start(now);
+ osc.stop(now + duration);
+ tremolo.stop(now + duration);
+ }
+
+ /**
+ * Ridiculous Speed Plaid Alarm (Spaceball One)
+ * Two-tone alternating square wave siren (440 Hz / 880 Hz)
+ */
+ synthesizePlaidAlarm() {
+ this.init();
+ const ctx = this.am.ctx;
+ if (!ctx || !this.gainNode) return;
+
+ const now = ctx.currentTime;
+ const steps = 6;
+ const stepDur = 0.12;
+
+ for (let i = 0; i < steps; i++) {
+ const t = now + i * stepDur;
+ const freq = (i % 2 === 0) ? 440 : 880;
+
+ const osc = ctx.createOscillator();
+ osc.type = 'square';
+ osc.frequency.setValueAtTime(freq, t);
+
+ const env = ctx.createGain();
+ env.gain.setValueAtTime(0.001, t);
+ env.gain.linearRampToValueAtTime(0.28, t + 0.006);
+ env.gain.setValueAtTime(0.28, t + stepDur - 0.01);
+ env.gain.exponentialRampToValueAtTime(0.001, t + stepDur);
+
+ osc.connect(env);
+ env.connect(this.gainNode);
+
+ osc.start(t);
+ osc.stop(t + stepDur + 0.005);
+ }
+ }
+
+ /**
+ * Dedicated Cassette Transport Clunk & Whirr (Milano / Bebop)
+ * Dual square transient click (120 Hz / 360 Hz) followed by 2200 Hz capstan tone with flutter
+ */
+ synthesizeCassetteTransport() {
+ this.init();
+ const ctx = this.am.ctx;
+ if (!ctx || !this.gainNode) return;
+
+ const now = ctx.currentTime;
+
+ // Dual square mechanical button clunk
+ [120, 360].forEach((freq) => {
+ const osc = ctx.createOscillator();
+ osc.type = 'square';
+ osc.frequency.setValueAtTime(freq, now);
+ osc.frequency.exponentialRampToValueAtTime(40, now + 0.028);
+
+ const env = ctx.createGain();
+ env.gain.setValueAtTime(0.35, now);
+ env.gain.exponentialRampToValueAtTime(0.001, now + 0.03);
+
+ osc.connect(env);
+ env.connect(this.gainNode);
+
+ osc.start(now);
+ osc.stop(now + 0.032);
+ });
+
+ // 12V capstan motor tone + tape flutter
+ const motorTime = now + 0.04;
+ const motorDur = 0.95;
+
+ const motor = ctx.createOscillator();
+ motor.type = 'sine';
+ motor.frequency.setValueAtTime(2200, motorTime);
+
+ const flutter = ctx.createOscillator();
+ flutter.type = 'sine';
+ flutter.frequency.setValueAtTime(8.2, motorTime);
+
+ const flutGain = ctx.createGain();
+ flutGain.gain.setValueAtTime(35, motorTime);
+ flutter.connect(flutGain);
+ flutGain.connect(motor.frequency);
+
+ const motorEnv = ctx.createGain();
+ motorEnv.gain.setValueAtTime(0.001, motorTime);
+ motorEnv.gain.linearRampToValueAtTime(0.12, motorTime + 0.08);
+ motorEnv.gain.exponentialRampToValueAtTime(0.001, motorTime + motorDur);
+
+ motor.connect(motorEnv);
+ motorEnv.connect(this.gainNode);
+
+ motor.start(motorTime);
+ flutter.start(motorTime);
+ motor.stop(motorTime + motorDur);
+ flutter.stop(motorTime + motorDur);
+
+ // Tape noise bed
+ const tapeBuf = this.am.createNoiseBuffer('pink', motorDur);
+ const tape = ctx.createBufferSource();
+ tape.buffer = tapeBuf;
+
+ const tapeFilter = ctx.createBiquadFilter();
+ tapeFilter.type = 'bandpass';
+ tapeFilter.frequency.setValueAtTime(3200, motorTime);
+ tapeFilter.Q.setValueAtTime(2.0, motorTime);
+
+ const tapeEnv = ctx.createGain();
+ tapeEnv.gain.setValueAtTime(0.001, motorTime);
+ tapeEnv.gain.linearRampToValueAtTime(0.08, motorTime + 0.05);
+ tapeEnv.gain.exponentialRampToValueAtTime(0.001, motorTime + motorDur);
+
+ tape.connect(tapeFilter);
+ tapeFilter.connect(tapeEnv);
+ tapeEnv.connect(this.gainNode);
+
+ tape.start(motorTime);
+ tape.stop(motorTime + motorDur);
+ }
+
+ /**
+ * Grapple System Arm Servo Motor Whine (Outlaw Star / Bebop)
+ * Swept triangle wave (220 Hz -> 680 Hz) through resonant bandpass filter
+ */
+ synthesizeGrapplerServo() {
+ this.init();
+ const ctx = this.am.ctx;
+ if (!ctx || !this.gainNode) return;
+
+ const now = ctx.currentTime;
+ const duration = 0.44;
+
+ const osc = ctx.createOscillator();
+ osc.type = 'triangle';
+ osc.frequency.setValueAtTime(220, now);
+ osc.frequency.exponentialRampToValueAtTime(680, now + duration * 0.7);
+ osc.frequency.linearRampToValueAtTime(540, now + duration);
+
+ const bp = ctx.createBiquadFilter();
+ bp.type = 'bandpass';
+ bp.frequency.setValueAtTime(320, now);
+ bp.frequency.exponentialRampToValueAtTime(820, now + duration * 0.7);
+ bp.Q.setValueAtTime(3.2, now);
+
+ const env = ctx.createGain();
+ env.gain.setValueAtTime(0.001, now);
+ env.gain.linearRampToValueAtTime(0.38, now + 0.04);
+ env.gain.exponentialRampToValueAtTime(0.001, now + duration);
+
+ osc.connect(bp);
+ bp.connect(env);
+ env.connect(this.gainNode);
+
+ osc.start(now);
+ osc.stop(now + duration + 0.01);
+ }
+
+ /**
+ * True RF Radio Static Burst (The Betty / The Marauder)
+ * Bandpass-filtered pink/white noise burst (300–3200 Hz) with hard-knee square gate
+ */
+ synthesizeRadioStatic() {
+ this.init();
+ const ctx = this.am.ctx;
+ if (!ctx || !this.gainNode) return;
+
+ const now = ctx.currentTime;
+ const duration = 0.18;
+
+ const noiseBuf = this.am.createNoiseBuffer('pink', 0.25);
+ const noise = ctx.createBufferSource();
+ noise.buffer = noiseBuf;
+
+ const bp = ctx.createBiquadFilter();
+ bp.type = 'bandpass';
+ bp.frequency.setValueAtTime(1400, now);
+ bp.Q.setValueAtTime(1.1, now);
+
+ const env = ctx.createGain();
+ env.gain.setValueAtTime(0.42, now);
+ env.gain.setValueAtTime(0.40, now + duration - 0.02);
+ env.gain.exponentialRampToValueAtTime(0.001, now + duration);
+
+ noise.connect(bp);
+ bp.connect(env);
+ env.connect(this.gainNode);
+
+ noise.start(now);
+ noise.stop(now + duration + 0.01);
+ }
+
+ /**
+ * Magnetic Boot ("Mag-Boot") Latch (Ceres / Tycho / The Expanse)
+ * Low-frequency impact thump (80 Hz click) followed by 120 Hz inductive clamp buzz
+ */
+ synthesizeMagBootLatch() {
+ this.init();
+ const ctx = this.am.ctx;
+ if (!ctx || !this.gainNode) return;
+
+ const now = ctx.currentTime;
+
+ // Stage 1: Impact thump
+ const thump = ctx.createOscillator();
+ thump.type = 'triangle';
+ thump.frequency.setValueAtTime(80, now);
+ thump.frequency.exponentialRampToValueAtTime(32, now + 0.03);
+
+ const thumpEnv = ctx.createGain();
+ thumpEnv.gain.setValueAtTime(0.5, now);
+ thumpEnv.gain.exponentialRampToValueAtTime(0.001, now + 0.035);
+
+ thump.connect(thumpEnv);
+ thumpEnv.connect(this.gainNode);
+ thump.start(now);
+ thump.stop(now + 0.038);
+
+ // Stage 2: 120 Hz inductive clamping buzz
+ const buzzTime = now + 0.025;
+ const buzz = ctx.createOscillator();
+ buzz.type = 'square';
+ buzz.frequency.setValueAtTime(120, buzzTime);
+
+ const buzzFilter = ctx.createBiquadFilter();
+ buzzFilter.type = 'lowpass';
+ buzzFilter.frequency.setValueAtTime(420, buzzTime);
+
+ const buzzEnv = ctx.createGain();
+ buzzEnv.gain.setValueAtTime(0.001, buzzTime);
+ buzzEnv.gain.linearRampToValueAtTime(0.32, buzzTime + 0.01);
+ buzzEnv.gain.exponentialRampToValueAtTime(0.001, buzzTime + 0.16);
+
+ buzz.connect(buzzFilter);
+ buzzFilter.connect(buzzEnv);
+ buzzEnv.connect(this.gainNode);
+
+ buzz.start(buzzTime);
+ buzz.stop(buzzTime + 0.17);
+ }
+
+ /**
+ * Background Loop: Periodic Medical Vital Signs Monitor (ECG)
+ */
+ startMedicalMonitor(intervalSeconds = 1.1) {
+ this.stopMedicalMonitor();
+ const tick = () => {
+ this.synthesizeMedicalMonitor();
+ this.medicalTimer = setTimeout(tick, intervalSeconds * 1000);
+ };
+ tick();
+ }
+
+ stopMedicalMonitor() {
+ if (this.medicalTimer) {
+ clearTimeout(this.medicalTimer);
+ this.medicalTimer = null;
+ }
+ }
+
+ /**
+ * Background Loop: Random Electrical Sparks on Damaged Stations
+ */
+ startStationSparks(minIntervalMs = 4000, maxIntervalMs = 12000) {
+ this.stopStationSparks();
+ const scheduleNext = () => {
+ const delay = minIntervalMs + Math.random() * (maxIntervalMs - minIntervalMs);
+ this.sparkTimer = setTimeout(() => {
+ this.synthesizeSevastopolSpark();
+ scheduleNext();
+ }, delay);
+ };
+ scheduleNext();
+ }
+
+ stopStationSparks() {
+ if (this.sparkTimer) {
+ clearTimeout(this.sparkTimer);
+ this.sparkTimer = null;
+ }
+ }
+
+ /**
+ * Ambient Loop Registry (Phase 2)
+ * Generic recursive-timeout scheduler keyed by name. A start call always
+ * kills any previous instance of the same key first. If min === max the
+ * cadence is fixed; otherwise it is random within [min, max].
+ */
+ _scheduleLoop(key, minIntervalMs, maxIntervalMs, fn) {
+ this._stopLoop(key);
+ const scheduleNext = () => {
+ const delay = maxIntervalMs === minIntervalMs
+ ? minIntervalMs
+ : minIntervalMs + Math.random() * (maxIntervalMs - minIntervalMs);
+ this.loopTimers[key] = setTimeout(() => {
+ fn();
+ scheduleNext();
+ }, delay);
+ };
+ scheduleNext();
+ }
+
+ _stopLoop(key) {
+ if (this.loopTimers[key]) {
+ clearTimeout(this.loopTimers[key]);
+ delete this.loopTimers[key];
+ }
+ }
+
+ /**
+ * Cardassian Bulkhead Door Grind (Star Trek DS9, audio_gaps.md #21)
+ * Oppressive pneumatic bulkhead grinding open: resonant bandpass sweep
+ * across brown noise (120 Hz -> 650 Hz) layered with a scraping metallic
+ * saw undertone.
+ */
+ synthesizeCardassianDoor() {
+ this.init();
+ const ctx = this.am.ctx;
+ if (!ctx || !this.gainNode) return;
+
+ const now = ctx.currentTime;
+ const duration = 1.4;
+
+ const noiseBuf = this.am.createNoiseBuffer('brown', duration + 0.2);
+ const noise = ctx.createBufferSource();
+ noise.buffer = noiseBuf;
+
+ const bp = ctx.createBiquadFilter();
+ bp.type = 'bandpass';
+ bp.frequency.setValueAtTime(120, now);
+ bp.frequency.exponentialRampToValueAtTime(650, now + 1.1);
+ bp.Q.setValueAtTime(3.5, now);
+
+ // Scraping metallic saw undertone through the same sweeping bandpass
+ const saw = ctx.createOscillator();
+ saw.type = 'sawtooth';
+ saw.frequency.setValueAtTime(110, now);
+ saw.frequency.exponentialRampToValueAtTime(70, now + duration);
+ const sawGain = ctx.createGain();
+ sawGain.gain.setValueAtTime(0.001, now);
+ sawGain.gain.linearRampToValueAtTime(0.09, now + 0.6);
+ sawGain.gain.exponentialRampToValueAtTime(0.001, now + duration);
+
+ const env = ctx.createGain();
+ env.gain.setValueAtTime(0.001, now);
+ env.gain.linearRampToValueAtTime(0.5, now + 0.4);
+ env.gain.exponentialRampToValueAtTime(0.001, now + duration);
+
+ noise.connect(bp);
+ saw.connect(sawGain);
+ sawGain.connect(bp);
+ bp.connect(env);
+ env.connect(this.gainNode);
+
+ noise.start(now);
+ noise.stop(now + duration + 0.05);
+ saw.start(now);
+ saw.stop(now + duration + 0.05);
+ }
+
+ /**
+ * Turbochute Pass Whoosh (Star Trek TNG/VOY, audio_gaps.md #22)
+ * Bandpass-filtered white noise pitch sweep (180 -> 450 Hz and back) with
+ * resonant boost and soft stereo panning drift past the listener.
+ */
+ synthesizeTurbochuteWhoosh() {
+ this.init();
+ const ctx = this.am.ctx;
+ if (!ctx || !this.gainNode) return;
+
+ const now = ctx.currentTime;
+ const duration = 1.6;
+
+ const noiseBuf = this.am.createNoiseBuffer('white', duration + 0.15);
+ const noise = ctx.createBufferSource();
+ noise.buffer = noiseBuf;
+
+ const bp = ctx.createBiquadFilter();
+ bp.type = 'bandpass';
+ bp.frequency.setValueAtTime(180, now);
+ bp.frequency.exponentialRampToValueAtTime(450, now + 0.55);
+ bp.frequency.exponentialRampToValueAtTime(180, now + 1.4);
+ bp.Q.setValueAtTime(2.2, now);
+
+ const env = ctx.createGain();
+ env.gain.setValueAtTime(0.001, now);
+ env.gain.linearRampToValueAtTime(0.42, now + 0.5);
+ env.gain.linearRampToValueAtTime(0.3, now + 0.8);
+ env.gain.exponentialRampToValueAtTime(0.001, now + duration);
+
+ let panOut;
+ if (ctx.createStereoPanner) {
+ const panner = ctx.createStereoPanner();
+ panner.pan.setValueAtTime(-0.45, now);
+ panner.pan.linearRampToValueAtTime(0.45, now + duration);
+ panOut = panner;
+ }
+ if (panOut) {
+ env.connect(panOut);
+ panOut.connect(this.gainNode);
+ } else {
+ env.connect(this.gainNode);
+ }
+
+ noise.connect(bp);
+ bp.connect(env);
+ noise.start(now);
+ noise.stop(now + duration + 0.02);
+ }
+
+ /**
+ * Fabricator Materialization Shimmer (Star Trek TNG/VOY, audio_gaps.md #23)
+ * High-frequency white-noise shimmer (2.5-8 kHz) amplitude-modulated by a
+ * fast 30 Hz sine LFO with a soft ramp decay.
+ */
+ synthesizeFabricatorShimmer() {
+ this.init();
+ const ctx = this.am.ctx;
+ if (!ctx || !this.gainNode) return;
+
+ const now = ctx.currentTime;
+ const duration = 2.0;
+
+ const noiseBuf = this.am.createNoiseBuffer('white', duration + 0.15);
+ const noise = ctx.createBufferSource();
+ noise.buffer = noiseBuf;
+
+ const masterEnv = ctx.createGain();
+ masterEnv.gain.setValueAtTime(0.001, now);
+ masterEnv.gain.linearRampToValueAtTime(0.3, now + 0.5);
+ masterEnv.gain.exponentialRampToValueAtTime(0.001, now + duration);
+ masterEnv.connect(this.gainNode);
+
+ // Two parallel shimmer bands (2.5-4 kHz and 5-8 kHz)
+ const bands = [
+ { center: 3200, q: 1.3, depth: 0.22 },
+ { center: 6400, q: 1.2, depth: 0.16 }
+ ];
+ bands.forEach((band) => {
+ const bp = ctx.createBiquadFilter();
+ bp.type = 'bandpass';
+ bp.frequency.setValueAtTime(band.center, now);
+ bp.Q.setValueAtTime(band.q, now);
+
+ const amEnv = ctx.createGain();
+ amEnv.gain.setValueAtTime(band.depth, now);
+
+ // 30 Hz shimmer AM
+ const lfo = ctx.createOscillator();
+ lfo.type = 'sine';
+ lfo.frequency.setValueAtTime(30, now);
+ const lfoDepth = ctx.createGain();
+ lfoDepth.gain.setValueAtTime(band.depth * 0.9, now);
+ lfo.connect(lfoDepth);
+ lfoDepth.connect(amEnv.gain);
+
+ noise.connect(bp);
+ bp.connect(amEnv);
+ amEnv.connect(masterEnv);
+
+ lfo.start(now);
+ lfo.stop(now + duration + 0.05);
+ });
+
+ noise.start(now);
+ noise.stop(now + duration + 0.02);
+ }
+
+ /**
+ * Vorlan Crystal "Singing" Resonance (Bioships, audio_gaps.md #26)
+ * Ethereal telepathic crystal harmonics: dual detuned sines (528/532 Hz)
+ * through a narrow bandpass with a slow 0.2 Hz undulating tremolo.
+ * One 8.5 s swell phrasing.
+ */
+ synthesizeVorlanSingingSwell() {
+ this.init();
+ const ctx = this.am.ctx;
+ if (!ctx || !this.gainNode) return;
+
+ const now = ctx.currentTime;
+ const duration = 8.5;
+
+ const bp = ctx.createBiquadFilter();
+ bp.type = 'bandpass';
+ bp.frequency.setValueAtTime(530, now);
+ bp.Q.setValueAtTime(22, now);
+
+ const tremolo = ctx.createGain();
+ tremolo.gain.setValueAtTime(0.5, now);
+
+ const lfo = ctx.createOscillator();
+ lfo.type = 'sine';
+ lfo.frequency.setValueAtTime(0.2, now);
+ const lfoDepth = ctx.createGain();
+ lfoDepth.gain.setValueAtTime(0.24, now);
+ lfo.connect(lfoDepth);
+ lfoDepth.connect(tremolo.gain);
+
+ const env = ctx.createGain();
+ env.gain.setValueAtTime(0.001, now);
+ env.gain.linearRampToValueAtTime(0.16, now + 3.0);
+ env.gain.linearRampToValueAtTime(0.13, now + 5.5);
+ env.gain.exponentialRampToValueAtTime(0.0001, now + duration);
+ env.connect(this.gainNode);
+
+ [528, 532].forEach((freq, idx) => {
+ const osc = ctx.createOscillator();
+ osc.type = 'sine';
+ osc.frequency.setValueAtTime(freq, now);
+ if (idx === 1) osc.detune.setValueAtTime(3, now);
+ osc.connect(bp);
+ osc.start(now);
+ osc.stop(now + duration + 0.05);
+ });
+
+ bp.connect(tremolo);
+ tremolo.connect(env);
+
+ lfo.start(now);
+ lfo.stop(now + duration + 0.05);
+ }
+
+ /**
+ * Ambient Loop: Vorlan singing resonance while the Vorlan cruiser is engaged
+ */
+ startVorlanSong(minIntervalMs = 11000, maxIntervalMs = 18000) {
+ this._scheduleLoop('vorlonSong', minIntervalMs, maxIntervalMs, () => {
+ this.synthesizeVorlanSingingSwell();
+ });
+ }
+
+ /**
+ * Neural-Bond Swell (Bioships, audio_gaps.md #27)
+ * Symbiotic neural link between pilot and bioship swelling with emotion:
+ * deep 55 Hz triangle sweeping into a resonant vowel-formant filter
+ * (350-850 Hz) over 2.5 seconds.
+ */
+ synthesizeNeuralBondSwell() {
+ this.init();
+ const ctx = this.am.ctx;
+ if (!ctx || !this.gainNode) return;
+
+ const now = ctx.currentTime;
+ const duration = 3.4;
+
+ // Sub-bass carrier
+ const osc = ctx.createOscillator();
+ osc.type = 'triangle';
+ osc.frequency.setValueAtTime(55, now);
+ osc.frequency.linearRampToValueAtTime(52, now + duration);
+
+ // Formant sweep adding vowel-like harmonics
+ const formant = ctx.createBiquadFilter();
+ formant.type = 'bandpass';
+ formant.frequency.setValueAtTime(350, now);
+ formant.frequency.linearRampToValueAtTime(850, now + 2.5);
+ formant.Q.setValueAtTime(4.5, now);
+
+ const env = ctx.createGain();
+ env.gain.setValueAtTime(0.001, now);
+ env.gain.linearRampToValueAtTime(0.34, now + 0.9);
+ env.gain.linearRampToValueAtTime(0.42, now + 1.9);
+ env.gain.exponentialRampToValueAtTime(0.001, now + duration);
+
+ osc.connect(formant);
+ formant.connect(env);
+ env.connect(this.gainNode);
+ osc.start(now);
+ osc.stop(now + duration + 0.05);
+ }
+
+ /**
+ * Hull Self-Repair / Regeneration Texture (Bioships, audio_gaps.md #28)
+ * Microscopic organic tissue knit and chitin regrowth: granular
+ * amplitude-modulated pink noise (8-24 Hz modulation) with wet low
+ * regenerative pops. One ~1.8 s regeneration burst.
+ */
+ synthesizeHullRegenBurst() {
+ this.init();
+ const ctx = this.am.ctx;
+ if (!ctx || !this.gainNode) return;
+
+ const now = ctx.currentTime;
+ const duration = 1.8;
+
+ const noiseBuf = this.am.createNoiseBuffer('pink', duration + 0.1);
+ const noise = ctx.createBufferSource();
+ noise.buffer = noiseBuf;
+
+ const bp = ctx.createBiquadFilter();
+ bp.type = 'bandpass';
+ bp.frequency.setValueAtTime(650, now);
+ bp.frequency.linearRampToValueAtTime(1600, now + duration);
+ bp.Q.setValueAtTime(2.0, now);
+
+ const modEnv = ctx.createGain();
+ modEnv.gain.setValueAtTime(0.16, now);
+
+ // Rapid scabbing modulation (~13 Hz) over the wet tissue bed
+ const lfo = ctx.createOscillator();
+ lfo.type = 'square';
+ lfo.frequency.setValueAtTime(9 + Math.random() * 8, now);
+ const lfoDepth = ctx.createGain();
+ lfoDepth.gain.setValueAtTime(0.12, now);
+ lfo.connect(lfoDepth);
+ lfoDepth.connect(modEnv.gain);
+
+ const env = ctx.createGain();
+ env.gain.setValueAtTime(0.001, now);
+ env.gain.linearRampToValueAtTime(0.2, now + 0.25);
+ env.gain.exponentialRampToValueAtTime(0.001, now + duration);
+
+ noise.connect(bp);
+ bp.connect(modEnv);
+ modEnv.connect(env);
+ env.connect(this.gainNode);
+ noise.start(now);
+ noise.stop(now + duration + 0.05);
+ lfo.start(now);
+ lfo.stop(now + duration + 0.05);
+
+ // Wet bioplasmic pops (chitin knit)
+ const popCount = 2 + Math.floor(Math.random() * 3);
+ for (let i = 0; i < popCount; i++) {
+ const popTime = now + 0.25 + Math.random() * (duration - 0.6);
+ const pop = ctx.createOscillator();
+ pop.type = 'sine';
+ pop.frequency.setValueAtTime(110 + Math.random() * 60, popTime);
+ pop.frequency.exponentialRampToValueAtTime(60, popTime + 0.06);
+ const popEnv = ctx.createGain();
+ popEnv.gain.setValueAtTime(0.001, popTime);
+ popEnv.gain.linearRampToValueAtTime(0.14, popTime + 0.008);
+ popEnv.gain.exponentialRampToValueAtTime(0.001, popTime + 0.07);
+ pop.connect(popEnv);
+ popEnv.connect(this.gainNode);
+ pop.start(popTime);
+ pop.stop(popTime + 0.08);
+ }
+ }
+
+ /**
+ * Ambient Loop: Periodic hull regeneration texture on Wraith hives
+ */
+ startHullRegen(minIntervalMs = 6000, maxIntervalMs = 13000) {
+ this._scheduleLoop('hullRegen', minIntervalMs, maxIntervalMs, () => {
+ this.synthesizeHullRegenBurst();
+ });
+ }
+
+ /**
+ * Beltworker Jury-Rigged Telemetry Jitter (Space Stations, audio_gaps.md #29)
+ * Rattle of loose relays, worn copper contactors and erratic voltage drops:
+ * noisy square relay pulses with micro-dropouts, contact-bounce double
+ * clicks and harsh metallic ticks on a jittered clock. One ~1.1 s cluster.
+ */
+ synthesizeBeltworkerTelemetryJitter() {
+ this.init();
+ const ctx = this.am.ctx;
+ if (!ctx || !this.gainNode) return;
+
+ const now = ctx.currentTime;
+ const eventCount = 3 + Math.floor(Math.random() * 5); // 3-7 events
+ let cursor = now + 0.05 + Math.random() * 0.2;
+
+ const relayPulse = (t) => {
+ const osc = ctx.createOscillator();
+ osc.type = 'square';
+ const baseFreq = 60 + Math.random() * 60;
+ osc.frequency.setValueAtTime(baseFreq, t);
+ osc.frequency.linearRampToValueAtTime(baseFreq * 0.8, t + 0.06);
+ const lp = ctx.createBiquadFilter();
+ lp.type = 'lowpass';
+ lp.frequency.setValueAtTime(420, t);
+ const env = ctx.createGain();
+ // Erratic voltage: randomized micro-dropouts in the pulse body
+ env.gain.setValueAtTime(0.17, t);
+ env.gain.setValueAtTime(0.001, t + 0.012 + Math.random() * 0.015);
+ env.gain.setValueAtTime(0.14, t + 0.026 + Math.random() * 0.02);
+ env.gain.setValueAtTime(0.001, t + 0.05 + Math.random() * 0.025);
+ env.gain.setValueAtTime(0.1, t + 0.07);
+ env.gain.exponentialRampToValueAtTime(0.001, t + 0.1);
+ osc.connect(lp);
+ lp.connect(env);
+ env.connect(this.gainNode);
+ osc.start(t);
+ osc.stop(t + 0.11);
+ };
+
+ const contactBounce = (t) => {
+ // Worn contactor: double/triple click 1-3 ms apart
+ const clicks = 2 + (Math.random() < 0.35 ? 1 : 0);
+ for (let i = 0; i < clicks; i++) {
+ const ct = t + i * (0.001 + Math.random() * 0.002);
+ const osc = ctx.createOscillator();
+ osc.type = 'square';
+ osc.frequency.setValueAtTime(500 + Math.random() * 500, ct);
+ osc.frequency.exponentialRampToValueAtTime(120, ct + 0.008);
+ const env = ctx.createGain();
+ env.gain.setValueAtTime(0.14, ct);
+ env.gain.exponentialRampToValueAtTime(0.001, ct + 0.012);
+ osc.connect(env);
+ env.connect(this.gainNode);
+ osc.start(ct);
+ osc.stop(ct + 0.015);
+ }
+ };
+
+ const metallicTick = (t) => {
+ const buf = this.am.createNoiseBuffer('white', 0.05);
+ const noise = ctx.createBufferSource();
+ noise.buffer = buf;
+ const bp = ctx.createBiquadFilter();
+ bp.type = 'bandpass';
+ bp.frequency.setValueAtTime(400 + Math.random() * 1400, t);
+ bp.Q.setValueAtTime(6, t);
+ const env = ctx.createGain();
+ env.gain.setValueAtTime(0.09, t);
+ env.gain.exponentialRampToValueAtTime(0.001, t + 0.02);
+ noise.connect(bp);
+ bp.connect(env);
+ env.connect(this.gainNode);
+ noise.start(t);
+ noise.stop(t + 0.025);
+ };
+
+ for (let i = 0; i < eventCount; i++) {
+ const kind = Math.random();
+ if (kind < 0.4) relayPulse(cursor);
+ else if (kind < 0.75) contactBounce(cursor);
+ else metallicTick(cursor);
+ cursor += 0.08 + Math.random() * 0.22; // jittered clock drift
+ }
+ }
+
+ /**
+ * True Hull Strain Moan (Deep Space, audio_gaps.md #30)
+ * Massive structural groan of stressed bulkheads flexing under gravitational
+ * shear: resonant bandpass slowly sweeping 40-220 Hz (high Q) over shaped
+ * brown noise with an asymmetric attack/decay. ~5.5 s.
+ */
+ synthesizeHullStrainMoan() {
+ this.init();
+ const ctx = this.am.ctx;
+ if (!ctx || !this.gainNode) return;
+
+ const now = ctx.currentTime;
+ const duration = 5.5;
+
+ const noiseBuf = this.am.createNoiseBuffer('brown', duration + 0.3);
+ const noise = ctx.createBufferSource();
+ noise.buffer = noiseBuf;
+
+ const bp = ctx.createBiquadFilter();
+ bp.type = 'bandpass';
+ bp.frequency.setValueAtTime(48, now);
+ bp.frequency.exponentialRampToValueAtTime(210, now + 3.4);
+ bp.Q.setValueAtTime(10, now);
+
+ // Asymmetric envelope: slow menacing rise, long creaking decay
+ const env = ctx.createGain();
+ env.gain.setValueAtTime(0.001, now);
+ env.gain.linearRampToValueAtTime(0.3, now + 0.9);
+ env.gain.linearRampToValueAtTime(0.22, now + 1.7);
+ env.gain.exponentialRampToValueAtTime(0.0001, now + duration);
+
+ noise.connect(bp);
+ bp.connect(env);
+ env.connect(this.gainNode);
+ noise.start(now);
+ noise.stop(now + duration + 0.05);
+ }
+
+ /**
+ * Ambient Loop: Sporadic structural strain moans on haunted deep-space hulls
+ */
+ startHullStrainMoans(minIntervalMs = 16000, maxIntervalMs = 40000) {
+ this._scheduleLoop('hullStrainMoans', minIntervalMs, maxIntervalMs, () => {
+ this.synthesizeHullStrainMoan();
+ });
+ }
+
+ /**
+ * Icarus I Distress Beacon (Deep Space, audio_gaps.md #31)
+ * Eerie hypnotic modal arpeggio echoing from the ghost ship: a 4-note
+ * modal sine sequence through a long feedback delay with soft lowpass
+ * dampening. ~7 s including delay tail.
+ */
+ synthesizeIcarusBeacon() {
+ this.init();
+ const ctx = this.am.ctx;
+ if (!ctx || !this.gainNode) return;
+
+ const now = ctx.currentTime;
+ const notes = [220.0, 261.63, 329.63, 440.0]; // A minor modal beacon
+
+ const delay = ctx.createDelay(1.2);
+ delay.delayTime.setValueAtTime(0.45, now);
+ const feedback = ctx.createGain();
+ feedback.gain.setValueAtTime(0.35, now);
+ const dampen = ctx.createBiquadFilter();
+ dampen.type = 'lowpass';
+ dampen.frequency.setValueAtTime(2200, now);
+ const wet = ctx.createGain();
+ wet.gain.setValueAtTime(0.5, now);
+
+ delay.connect(feedback);
+ feedback.connect(dampen);
+ dampen.connect(delay);
+ delay.connect(wet);
+ wet.connect(this.gainNode);
+
+ notes.forEach((freq, idx) => {
+ const t = now + idx * 0.95;
+ const osc = ctx.createOscillator();
+ osc.type = 'sine';
+ osc.frequency.setValueAtTime(freq, t);
+ const env = ctx.createGain();
+ env.gain.setValueAtTime(0.001, t);
+ env.gain.linearRampToValueAtTime(0.17, t + 0.05);
+ env.gain.exponentialRampToValueAtTime(0.001, t + 0.85);
+ osc.connect(env);
+ env.connect(this.gainNode);
+ env.connect(delay);
+ osc.start(t);
+ osc.stop(t + 0.9);
+ });
+ }
+
+ /**
+ * Ambient Loop: Periodic distress beacon arpeggio while Icarus II is engaged
+ */
+ startIcarusBeacon(minIntervalMs = 22000, maxIntervalMs = 40000) {
+ this._scheduleLoop('icarusBeacon', minIntervalMs, maxIntervalMs, () => {
+ this.synthesizeIcarusBeacon();
+ });
+ }
+
+ /**
+ * Extradimensional Psychic Static Whisper (Deep Space, audio_gaps.md #32)
+ * Menacing psychoacoustic burst: multi-formant filtered white noise with
+ * randomized micro-envelopes evoking whispered vowels (800-2400 Hz).
+ */
+ synthesizeVoidWhisper() {
+ this.init();
+ const ctx = this.am.ctx;
+ if (!ctx || !this.gainNode) return;
+
+ const now = ctx.currentTime;
+ const duration = 2.2;
+
+ const noiseBuf = this.am.createNoiseBuffer('white', duration + 0.1);
+ const noise = ctx.createBufferSource();
+ noise.buffer = noiseBuf;
+
+ const env = ctx.createGain();
+ env.gain.setValueAtTime(0.001, now);
+ env.gain.linearRampToValueAtTime(0.12, now + 0.4);
+ env.gain.linearRampToValueAtTime(0.09, now + 1.3);
+ env.gain.exponentialRampToValueAtTime(0.0001, now + duration);
+ env.connect(this.gainNode);
+
+ // Three parallel whispered-vowel formants with independent wobbles
+ const formants = [
+ { center: 700, q: 7, wobbleHz: 0.8, wobbleAmt: 160 },
+ { center: 1250, q: 8, wobbleHz: 1.1, wobbleAmt: 280 },
+ { center: 2200, q: 10, wobbleHz: 1.6, wobbleAmt: 420 }
+ ];
+ formants.forEach((formant) => {
+ const bp = ctx.createBiquadFilter();
+ bp.type = 'bandpass';
+ bp.frequency.setValueAtTime(formant.center, now);
+ bp.Q.setValueAtTime(formant.q, now);
+
+ const wobble = ctx.createOscillator();
+ wobble.type = 'sine';
+ wobble.frequency.setValueAtTime(formant.wobbleHz, now);
+ const wobbleDepth = ctx.createGain();
+ wobbleDepth.gain.setValueAtTime(formant.wobbleAmt, now);
+ wobble.connect(wobbleDepth);
+ wobbleDepth.connect(bp.frequency);
+
+ const breath = ctx.createGain();
+ breath.gain.setValueAtTime(0.3, now);
+ // Randomized micro-envelopes: syllable-like gulps of the formant
+ const gulpCount = 3 + Math.floor(Math.random() * 3);
+ for (let i = 0; i < gulpCount; i++) {
+ const gt = now + 0.15 + Math.random() * (duration - 0.55);
+ breath.gain.setValueAtTime(0.12, gt);
+ breath.gain.linearRampToValueAtTime(0.3 + Math.random() * 0.18, gt + 0.04);
+ breath.gain.linearRampToValueAtTime(0.05, gt + 0.12 + Math.random() * 0.1);
+ }
+
+ noise.connect(bp);
+ bp.connect(breath);
+ breath.connect(env);
+
+ wobble.start(now);
+ wobble.stop(now + duration + 0.05);
+ });
+
+ noise.start(now);
+ noise.stop(now + duration + 0.02);
+ }
+
+ /**
+ * Ambient Loop: Psychic whisper bursts on haunted deep-space hulls
+ */
+ startVoidWhispers(minIntervalMs = 15000, maxIntervalMs = 32000) {
+ this._scheduleLoop('voidWhispers', minIntervalMs, maxIntervalMs, () => {
+ this.synthesizeVoidWhisper();
+ });
+ }
+
+ /**
+ * Beryllium Orb Resonant Thrum (Comedy, audio_gaps.md #33)
+ * Deep glassy crystalline reactor hum: dual pure sines (65 + 195 Hz third
+ * harmonic) with slow beating detune and a subtle comb-filter ring.
+ * One ~10 s swell phrasing.
+ */
+ synthesizeBerylliumThrumSwell() {
+ this.init();
+ const ctx = this.am.ctx;
+ if (!ctx || !this.gainNode) return;
+
+ const now = ctx.currentTime;
+ const duration = 10.0;
+
+ const env = ctx.createGain();
+ env.gain.setValueAtTime(0.001, now);
+ env.gain.linearRampToValueAtTime(0.16, now + 4.0);
+ env.gain.linearRampToValueAtTime(0.14, now + 6.5);
+ env.gain.exponentialRampToValueAtTime(0.0001, now + duration);
+ env.connect(this.gainNode);
+
+ // Comb ring: short 5.1 ms reflection
+ const comb = ctx.createDelay(0.05);
+ comb.delayTime.setValueAtTime(0.0051, now);
+ const combFb = ctx.createGain();
+ combFb.gain.setValueAtTime(0.45, now);
+ comb.connect(combFb);
+ combFb.connect(comb);
+ const combWet = ctx.createGain();
+ combWet.gain.setValueAtTime(0.6, now);
+ comb.connect(combWet);
+ combWet.connect(env);
+
+ const partials = [
+ { freq: 65, amp: 0.55 },
+ { freq: 65.3, amp: 0.3 }, // slow 0.3 Hz beating partner
+ { freq: 195, amp: 0.3 }
+ ];
+ partials.forEach((partial) => {
+ const osc = ctx.createOscillator();
+ osc.type = 'sine';
+ osc.frequency.setValueAtTime(partial.freq, now);
+ const g = ctx.createGain();
+ g.gain.setValueAtTime(partial.amp, now);
+ osc.connect(g);
+ g.connect(env);
+ g.connect(comb);
+ osc.start(now);
+ osc.stop(now + duration + 0.05);
+ });
+ }
+
+ /**
+ * Ambient Loop: Beryllium Orb thrum while the NSEA Protector is engaged
+ */
+ startBerylliumThrum(minIntervalMs = 9000, maxIntervalMs = 16000) {
+ this._scheduleLoop('berylliumThrum', minIntervalMs, maxIntervalMs, () => {
+ this.synthesizeBerylliumThrumSwell();
+ });
+ }
+
+ /**
+ * Omega-12+1 Temporal Capacitor Whine (Comedy, audio_gaps.md #34)
+ * Tremendous temporal capacitor power buildup: deep 40 Hz sub-bass swelling
+ * exponentially to 3200 Hz over 3 seconds, culminating in a wide
+ * white-noise discharge pop.
+ */
+ synthesizeOmega13Whine() {
+ this.init();
+ const ctx = this.am.ctx;
+ if (!ctx || !this.gainNode) return;
+
+ const now = ctx.currentTime;
+ const duration = 3.1;
+
+ const osc = ctx.createOscillator();
+ osc.type = 'sine';
+ osc.frequency.setValueAtTime(40, now);
+ osc.frequency.exponentialRampToValueAtTime(3200, now + 3.0);
+
+ const env = ctx.createGain();
+ env.gain.setValueAtTime(0.001, now);
+ env.gain.exponentialRampToValueAtTime(0.5, now + 2.7);
+ env.gain.setValueAtTime(0.5, now + 2.95);
+ env.gain.exponentialRampToValueAtTime(0.001, now + 3.2);
+
+ osc.connect(env);
+ env.connect(this.gainNode);
+ osc.start(now);
+ osc.stop(now + duration + 0.1);
+
+ // Dimensional discharge pop (wide white-noise transient)
+ const popTime = now + 3.0;
+ const popBuf = this.am.createNoiseBuffer('white', 0.3);
+ const pop = ctx.createBufferSource();
+ pop.buffer = popBuf;
+ const hp = ctx.createBiquadFilter();
+ hp.type = 'highpass';
+ hp.frequency.setValueAtTime(2000, popTime);
+ const popEnv = ctx.createGain();
+ popEnv.gain.setValueAtTime(0.001, popTime);
+ popEnv.gain.linearRampToValueAtTime(0.42, popTime + 0.008);
+ popEnv.gain.exponentialRampToValueAtTime(0.001, popTime + 0.16);
+ pop.connect(hp);
+ hp.connect(popEnv);
+ popEnv.connect(this.gainNode);
+ pop.start(popTime);
+ pop.stop(popTime + 0.18);
+ }
+
+ /**
+ * Repeller Lift Engine Drone (Outlaw, audio_gaps.md #35)
+ * Quintessential anti-gravity vehicle wash: dual detuned sines (68/72 Hz)
+ * through an asymmetric overdrive waveshaper, lowpass-filtered at 280 Hz.
+ * One ~10 s wash phrasing.
+ */
+ synthesizeRepulsorliftDrone() {
+ this.init();
+ const ctx = this.am.ctx;
+ if (!ctx || !this.gainNode) return;
+
+ const now = ctx.currentTime;
+ const duration = 10.0;
+
+ let shaperOut = null;
+ if (ctx.createWaveShaper) {
+ const shaper = ctx.createWaveShaper();
+ shaper.oversample = '2x';
+ const curve = new Float32Array(1024);
+ for (let i = 0; i < 1024; i++) {
+ const x = (i / 512) - 1; // -1..1
+ curve[i] = Math.tanh(2.4 * x); // asymmetric-ish saturation
+ }
+ shaper.curve = curve;
+ shaperOut = shaper;
+ }
+
+ const lp = ctx.createBiquadFilter();
+ lp.type = 'lowpass';
+ lp.frequency.setValueAtTime(280, now);
+
+ const env = ctx.createGain();
+ env.gain.setValueAtTime(0.001, now);
+ env.gain.linearRampToValueAtTime(0.2, now + 2.6);
+ env.gain.linearRampToValueAtTime(0.17, now + 7.2);
+ env.gain.exponentialRampToValueAtTime(0.0001, now + duration);
+
+ if (shaperOut) shaperOut.connect(lp);
+ lp.connect(env);
+ env.connect(this.gainNode);
+
+ [68, 72].forEach((freq) => {
+ const osc = ctx.createOscillator();
+ osc.type = 'sine';
+ osc.frequency.setValueAtTime(freq, now);
+ osc.connect(shaperOut || lp);
+ osc.start(now);
+ osc.stop(now + duration + 0.05);
+ });
+ }
+
+ /**
+ * Ambient Loop: Repeller Lift wash while the Marauder is engaged
+ */
+ startRepulsorliftDrone(intervalMs = 13000) {
+ this._scheduleLoop('repulsorliftDrone', intervalMs, intervalMs, () => {
+ this.synthesizeRepulsorliftDrone();
+ });
+ }
+
+ /**
+ * Converted Marine Trawler Engine Chug (Outlaw, audio_gaps.md #36)
+ * Rhythmic heavy diesel-like piston strokes: low-frequency square pulses
+ * (4-8 Hz cadence) through an 80 Hz resonant lowpass with mechanical
+ * piston wheeze. One cluster of 2-4 strokes.
+ */
+ synthesizeBebopChugStroke() {
+ this.init();
+ const ctx = this.am.ctx;
+ if (!ctx || !this.gainNode) return;
+
+ const now = ctx.currentTime;
+ const strokes = 2 + Math.floor(Math.random() * 3); // 2-4 strokes
+
+ const stroke = (t, peak) => {
+ const osc = ctx.createOscillator();
+ osc.type = 'square';
+ osc.frequency.setValueAtTime(45 + Math.random() * 25, t);
+ osc.frequency.linearRampToValueAtTime(38, t + 0.3);
+ const lp = ctx.createBiquadFilter();
+ lp.type = 'lowpass';
+ lp.frequency.setValueAtTime(80, t);
+ lp.Q.setValueAtTime(3, t);
+ const env = ctx.createGain();
+ env.gain.setValueAtTime(0.001, t);
+ env.gain.linearRampToValueAtTime(peak, t + 0.02);
+ env.gain.exponentialRampToValueAtTime(0.001, t + 0.42);
+ osc.connect(lp);
+ lp.connect(env);
+ env.connect(this.gainNode);
+ osc.start(t);
+ osc.stop(t + 0.45);
+
+ // Mechanical piston wheeze
+ const wheezeBuf = this.am.createNoiseBuffer('pink', 0.3);
+ const wheeze = ctx.createBufferSource();
+ wheeze.buffer = wheezeBuf;
+ const wheezeBp = ctx.createBiquadFilter();
+ wheezeBp.type = 'bandpass';
+ wheezeBp.frequency.setValueAtTime(380, t);
+ wheezeBp.Q.setValueAtTime(1.6, t);
+ const wheezeEnv = ctx.createGain();
+ wheezeEnv.gain.setValueAtTime(0.001, t + 0.02);
+ wheezeEnv.gain.linearRampToValueAtTime(0.05, t + 0.08);
+ wheezeEnv.gain.exponentialRampToValueAtTime(0.001, t + 0.3);
+ wheeze.connect(wheezeBp);
+ wheezeBp.connect(wheezeEnv);
+ wheezeEnv.connect(this.gainNode);
+ wheeze.start(t);
+ wheeze.stop(t + 0.32);
+ };
+
+ for (let i = 0; i < strokes; i++) {
+ stroke(now + i * (0.34 + Math.random() * 0.2), 0.3 - i * 0.04);
+ }
+ }
+
+ /**
+ * Ambient Loop: Rhythmic diesel chug while the Bebop is engaged
+ */
+ startBebopChug(minIntervalMs = 2200, maxIntervalMs = 4200) {
+ this._scheduleLoop('bebopChug', minIntervalMs, maxIntervalMs, () => {
+ this.synthesizeBebopChugStroke();
+ });
+ }
+
+ /**
+ * Centrifugal Habitat Carousel Motor Groan (Military, audio_gaps.md #37)
+ * Deep rotational strain of a kilometer-long rotating drum: sub-audible
+ * 16 Hz rotational pair with bearing harmonics, modulated by a single
+ * ~1 RPM (0.0167 Hz) revolution LFO. One 58 s rotation pass.
+ */
+ synthesizeCarouselGroanCycle() {
+ this.init();
+ const ctx = this.am.ctx;
+ if (!ctx || !this.gainNode) return;
+
+ const now = ctx.currentTime;
+ const duration = 58.0;
+
+ const env = ctx.createGain();
+ env.gain.setValueAtTime(0.001, now);
+ env.gain.linearRampToValueAtTime(0.55, now + 8.0);
+ env.gain.linearRampToValueAtTime(0.5, now + 50.0);
+ env.gain.exponentialRampToValueAtTime(0.0001, now + duration);
+ env.connect(this.gainNode);
+
+ // One rotation per ~60 s: slow strain swell and release
+ const rotation = ctx.createOscillator();
+ rotation.type = 'sine';
+ rotation.frequency.setValueAtTime(1 / 60, now);
+ const rotationDepth = ctx.createGain();
+ rotationDepth.gain.setValueAtTime(0.38, now);
+ rotation.connect(rotationDepth);
+ rotationDepth.connect(env.gain);
+
+ const partials = [
+ { freq: 16, amp: 0.55 },
+ { freq: 16.04, amp: 0.32 }, // rotational beating pair
+ { freq: 48, amp: 0.22 }, // structural harmonic
+ { freq: 96, amp: 0.07 } // bearing hum harmonic
+ ];
+ partials.forEach((partial) => {
+ const osc = ctx.createOscillator();
+ osc.type = 'sine';
+ osc.frequency.setValueAtTime(partial.freq, now);
+ const g = ctx.createGain();
+ g.gain.setValueAtTime(partial.amp, now);
+ osc.connect(g);
+ g.connect(env);
+ osc.start(now);
+ osc.stop(now + duration + 0.1);
+ });
+
+ rotation.start(now);
+ rotation.stop(now + duration + 0.1);
+ }
+
+ /**
+ * Ambient Loop: Carousel rotation groan while the Agamemnon is engaged
+ * (one 58 s pass every 60 s; the 2 s gap reads as a rotation splice)
+ */
+ startCarouselGroan(intervalMs = 60000) {
+ this._scheduleLoop('carouselGroan', intervalMs, intervalMs, () => {
+ this.synthesizeCarouselGroanCycle();
+ });
+ }
+
+ /**
+ * Slipstream Transition Surge (Military, audio_gaps.md #38)
+ * Ship tearing through an exotic slipstream: dual sweeping bandpass surges
+ * (200 -> 2400 Hz) with escalating resonance Q and a comb-filter shimmer
+ * tail. ~4.2 s.
+ */
+ synthesizeSlipstreamSurge() {
+ this.init();
+ const ctx = this.am.ctx;
+ if (!ctx || !this.gainNode) return;
+
+ const now = ctx.currentTime;
+
+ // Comb shimmer tail bus
+ const shimmer = ctx.createDelay(0.5);
+ shimmer.delayTime.setValueAtTime(0.12, now);
+ const shimmerFb = ctx.createGain();
+ shimmerFb.gain.setValueAtTime(0.3, now);
+ const shimmerLp = ctx.createBiquadFilter();
+ shimmerLp.type = 'lowpass';
+ shimmerLp.frequency.setValueAtTime(4000, now);
+ shimmer.connect(shimmerFb);
+ shimmerFb.connect(shimmerLp);
+ shimmerLp.connect(shimmer);
+ const shimmerWet = ctx.createGain();
+ shimmerWet.gain.setValueAtTime(0.5, now);
+ shimmer.connect(shimmerWet);
+ shimmerWet.connect(this.gainNode);
+
+ const surge = (offset, peak) => {
+ const t = now + offset;
+ const osc = ctx.createOscillator();
+ osc.type = 'sawtooth';
+ osc.frequency.setValueAtTime(200, t);
+ osc.frequency.exponentialRampToValueAtTime(2400, t + 2.6);
+
+ const bp = ctx.createBiquadFilter();
+ bp.type = 'bandpass';
+ bp.frequency.setValueAtTime(200, t);
+ bp.frequency.exponentialRampToValueAtTime(2400, t + 2.6);
+ bp.Q.setValueAtTime(2, t);
+ bp.Q.linearRampToValueAtTime(10, t + 2.6);
+
+ const env = ctx.createGain();
+ env.gain.setValueAtTime(0.001, t);
+ env.gain.linearRampToValueAtTime(peak, t + 1.4);
+ env.gain.setValueAtTime(peak, t + 2.7);
+ env.gain.exponentialRampToValueAtTime(0.001, t + 3.8);
+
+ osc.connect(bp);
+ bp.connect(env);
+ env.connect(this.gainNode);
+ env.connect(shimmer);
+ osc.start(t);
+ osc.stop(t + 4.0);
+ };
+
+ surge(0, 0.22);
+ surge(0.3, 0.16);
+ }
+
+ /**
+ * Viper Pilot Oxygen Regulator Demand Valve (Military, audio_gaps.md #39)
+ * Pulsing rebreather of a Viper pilot mid-combat: a sharp 15 ms mechanical
+ * diaphragm click preceding pulsed highpass-filtered white noise breath
+ * (1.2-4.5 kHz, ~1.1 s). One breath cycle.
+ */
+ synthesizeOxygenRegulatorCycle() {
+ this.init();
+ const ctx = this.am.ctx;
+ if (!ctx || !this.gainNode) return;
+
+ const now = ctx.currentTime;
+
+ // Sharp mechanical diaphragm click
+ const click = ctx.createOscillator();
+ click.type = 'triangle';
+ click.frequency.setValueAtTime(1800, now);
+ click.frequency.exponentialRampToValueAtTime(600, now + 0.015);
+ const clickEnv = ctx.createGain();
+ clickEnv.gain.setValueAtTime(0.3, now);
+ clickEnv.gain.exponentialRampToValueAtTime(0.001, now + 0.02);
+ click.connect(clickEnv);
+ clickEnv.connect(this.gainNode);
+ click.start(now);
+ click.stop(now + 0.025);
+
+ // Regulated airflow breath
+ const noiseBuf = this.am.createNoiseBuffer('white', 1.3);
+ const noise = ctx.createBufferSource();
+ noise.buffer = noiseBuf;
+ const hp = ctx.createBiquadFilter();
+ hp.type = 'highpass';
+ hp.frequency.setValueAtTime(1200, now + 0.02);
+ const lp = ctx.createBiquadFilter();
+ lp.type = 'lowpass';
+ lp.frequency.setValueAtTime(4500, now + 0.02);
+ const env = ctx.createGain();
+ env.gain.setValueAtTime(0.001, now + 0.02);
+ env.gain.linearRampToValueAtTime(0.13, now + 0.45);
+ env.gain.linearRampToValueAtTime(0.09, now + 0.62);
+ env.gain.exponentialRampToValueAtTime(0.001, now + 1.15);
+ noise.connect(hp);
+ hp.connect(lp);
+ lp.connect(env);
+ env.connect(this.gainNode);
+ noise.start(now + 0.02);
+ noise.stop(now + 1.2);
+ }
+
+ /**
+ * Ambient Loop: Rhythmic oxygen demand breathing while a Viper cockpit
+ * is engaged
+ */
+ startOxygenRegulator(minIntervalMs = 3400, maxIntervalMs = 5200) {
+ this._scheduleLoop('oxygenRegulator', minIntervalMs, maxIntervalMs, () => {
+ this.synthesizeOxygenRegulatorCycle();
+ });
+ }
+
+ /**
+ * Condensation Pipe Drip & Expansion Tick (Industrial, audio_gaps.md #40)
+ * Lonely water droplet pinging inside a kilometres-long cargo hauler:
+ * high-Q sine pings (1400-2600 Hz) with fast exponential decay on a
+ * randomized clock. One drip.
+ */
+ synthesizePipeDrip() {
+ this.init();
+ const ctx = this.am.ctx;
+ if (!ctx || !this.gainNode) return;
+
+ const now = ctx.currentTime;
+ const decay = 0.01 + Math.random() * 0.015;
+
+ const osc = ctx.createOscillator();
+ osc.type = 'sine';
+ const freq = 1400 + Math.random() * 1200;
+ osc.frequency.setValueAtTime(freq, now);
+ osc.frequency.exponentialRampToValueAtTime(freq * 0.92, now + decay);
+
+ const env = ctx.createGain();
+ env.gain.setValueAtTime(0.001, now);
+ env.gain.linearRampToValueAtTime(0.1 + Math.random() * 0.2, now + 0.001);
+ env.gain.exponentialRampToValueAtTime(0.0001, now + decay + 0.01);
+
+ osc.connect(env);
+ env.connect(this.gainNode);
+ osc.start(now);
+ osc.stop(now + decay + 0.03);
+ }
+
+ /**
+ * Ambient Loop: Sporadic condensation drips while the Nostromo is engaged
+ */
+ startPipeDrips(minIntervalMs = 500, maxIntervalMs = 3500) {
+ this._scheduleLoop('pipeDrips', minIntervalMs, maxIntervalMs, () => {
+ this.synthesizePipeDrip();
+ });
+ }
+
+ /**
+ * High-Pressure Steam / Boiler Venting (Industrial, audio_gaps.md #41)
+ * Continuous hot-vent hiss of a steam-era mining vessel: bandpass-filtered
+ * white noise (800-2800 Hz) with a slow amplitude swell and random
+ * micro-flutter. One ~9 s vent pass.
+ */
+ synthesizeSteamVentSwell() {
+ this.init();
+ const ctx = this.am.ctx;
+ if (!ctx || !this.gainNode) return;
+
+ const now = ctx.currentTime;
+ const duration = 9.0;
+
+ // Looping noise bed (short buffer, sustained vent)
+ const noiseBuf = this.am.createNoiseBuffer('white', 4.0);
+ const noise = ctx.createBufferSource();
+ noise.buffer = noiseBuf;
+ noise.loop = true;
+
+ const bp = ctx.createBiquadFilter();
+ bp.type = 'bandpass';
+ bp.frequency.setValueAtTime(1500, now);
+ bp.Q.setValueAtTime(0.7, now);
+
+ const env = ctx.createGain();
+ env.gain.setValueAtTime(0.001, now);
+ env.gain.linearRampToValueAtTime(0.17, now + 3.0);
+ env.gain.linearRampToValueAtTime(0.15, now + 5.5);
+ env.gain.exponentialRampToValueAtTime(0.0001, now + duration);
+
+ // Boiler pressure micro-flutter (~6-9 Hz jitter)
+ const flutter = ctx.createOscillator();
+ flutter.type = 'sine';
+ flutter.frequency.setValueAtTime(6 + Math.random() * 3, now);
+ const flutterDepth = ctx.createGain();
+ flutterDepth.gain.setValueAtTime(0.04, now);
+ flutter.connect(flutterDepth);
+ flutterDepth.connect(env.gain);
+
+ noise.connect(bp);
+ bp.connect(env);
+ env.connect(this.gainNode);
+ noise.start(now);
+ noise.stop(now + duration + 0.1);
+ flutter.start(now);
+ flutter.stop(now + duration + 0.1);
+ }
+
+ /**
+ * Ambient Loop: Boiler vent passes while Serenity-class and Starbug
+ * presets are engaged
+ */
+ startSteamVent(minIntervalMs = 6000, maxIntervalMs = 10000) {
+ this._scheduleLoop('steamVent', minIntervalMs, maxIntervalMs, () => {
+ this.synthesizeSteamVentSwell();
+ });
+ }
+
+ /**
+ * Crash-Couch Hydraulic Gimbal Strain (Industrial, audio_gaps.md #42)
+ * Pilot's couch swinging into launch position: FM triangle carrier
+ * (140 Hz / 35 Hz modulator) through lowpass damping that tracks the
+ * acceleration strain. ~1.9 s.
+ */
+ synthesizeCrashCouchGimbal() {
+ this.init();
+ const ctx = this.am.ctx;
+ if (!ctx || !this.gainNode) return;
+
+ const now = ctx.currentTime;
+ const duration = 1.9;
+
+ const layer = (offset, carrierFreq, modFreq) => {
+ const t = now + offset;
+ const carrier = ctx.createOscillator();
+ carrier.type = 'triangle';
+ carrier.frequency.setValueAtTime(carrierFreq, t);
+
+ const mod = ctx.createOscillator();
+ mod.type = 'sine';
+ mod.frequency.setValueAtTime(modFreq, t);
+ const modDepth = ctx.createGain();
+ modDepth.gain.setValueAtTime(carrierFreq * 4, t);
+ mod.connect(modDepth);
+ modDepth.connect(carrier.frequency);
+
+ const lp = ctx.createBiquadFilter();
+ lp.type = 'lowpass';
+ lp.frequency.setValueAtTime(300, t);
+ lp.frequency.linearRampToValueAtTime(900, t + 0.5);
+ lp.frequency.exponentialRampToValueAtTime(350, t + duration - offset);
+
+ const env = ctx.createGain();
+ env.gain.setValueAtTime(0.001, t);
+ env.gain.linearRampToValueAtTime(0.24, t + 0.55);
+ env.gain.linearRampToValueAtTime(0.2, t + 1.0);
+ env.gain.exponentialRampToValueAtTime(0.0001, t + duration - offset);
+
+ carrier.connect(lp);
+ lp.connect(env);
+ env.connect(this.gainNode);
+ carrier.start(t);
+ carrier.stop(t + (duration - offset) + 0.05);
+ mod.start(t);
+ mod.stop(t + (duration - offset) + 0.05);
+ };
+
+ layer(0, 140, 35);
+ layer(0.42, 152, 38);
+ }
+
+ /**
+ * HAL 9999 Breathing Loop (Retro Future, audio_gaps.md #43)
+ * Eerie slow respiration of a quiet, observant ship computer: rhythmic
+ * bandpass-filtered pink noise (450-1100 Hz) with a gentle 3.5 s
+ * inhalation/exhalation envelope. One breath.
+ */
+ synthesizeHalBreathCycle() {
+ this.init();
+ const ctx = this.am.ctx;
+ if (!ctx || !this.gainNode) return;
+
+ const now = ctx.currentTime;
+ const inhale = 1.3;
+ const exhale = 2.2;
+
+ const noiseBuf = this.am.createNoiseBuffer('pink', 4.0);
+ const noise = ctx.createBufferSource();
+ noise.buffer = noiseBuf;
+ noise.loop = true;
+
+ const bp = ctx.createBiquadFilter();
+ bp.type = 'bandpass';
+ bp.frequency.setValueAtTime(700, now);
+ bp.Q.setValueAtTime(1.2, now);
+
+ const env = ctx.createGain();
+ env.gain.setValueAtTime(0.001, now);
+ env.gain.linearRampToValueAtTime(0.085, now + inhale);
+ env.gain.exponentialRampToValueAtTime(0.001, now + inhale + exhale);
+
+ noise.connect(bp);
+ bp.connect(env);
+ env.connect(this.gainNode);
+ noise.start(now);
+ noise.stop(now + inhale + exhale + 0.05);
+ }
+
+ /**
+ * Ambient Loop: Continuous respiration while the Discovery One is engaged
+ */
+ startHalBreathing(intervalMs = 3500) {
+ this._scheduleLoop('halBreathing', intervalMs, intervalMs, () => {
+ this.synthesizeHalBreathCycle();
+ });
+ }
+
+ /**
+ * Death Bloom Energy Surge Ramp (Retro Future, audio_gaps.md #44)
+ * Fearsome gunstar overdrive: cascaded sawtooth exponential ramp
+ * (200 Hz -> 8000 Hz over 2.5 s) with rising overdrive, cutting off
+ * abruptly at full bloom.
+ */
+ synthesizeDeathBlossomSurge() {
+ this.init();
+ const ctx = this.am.ctx;
+ if (!ctx || !this.gainNode) return;
+
+ const now = ctx.currentTime;
+ const rampDur = 2.5;
+
+ const hp = ctx.createBiquadFilter();
+ hp.type = 'highpass';
+ hp.frequency.setValueAtTime(400, now);
+ hp.frequency.exponentialRampToValueAtTime(4000, now + rampDur);
+
+ const hp2 = ctx.createBiquadFilter();
+ hp2.type = 'highpass';
+ hp2.frequency.setValueAtTime(200, now);
+ hp2.frequency.exponentialRampToValueAtTime(2000, now + rampDur);
+
+ const env = ctx.createGain();
+ env.gain.setValueAtTime(0.001, now);
+ env.gain.linearRampToValueAtTime(0.32, now + rampDur);
+ env.gain.setValueAtTime(0.32, now + rampDur + 0.03);
+ env.gain.exponentialRampToValueAtTime(0.001, now + rampDur + 0.4);
+
+ hp.connect(hp2);
+ hp2.connect(env);
+ env.connect(this.gainNode);
+
+ // Cascaded overdriven saws with staggered ignition
+ [0, 0.15, 0.3].forEach((offset, idx) => {
+ const t = now + offset;
+ const osc = ctx.createOscillator();
+ osc.type = 'sawtooth';
+ osc.frequency.setValueAtTime(200, t);
+ osc.frequency.exponentialRampToValueAtTime(8000, t + rampDur - offset);
+ const g = ctx.createGain();
+ g.gain.setValueAtTime(0.33 - idx * 0.08, t);
+ osc.connect(g);
+ g.connect(hp);
+ osc.start(t);
+ osc.stop(t + (rampDur - offset) + 0.45);
+ });
+
+ // Abrupt bloom transient
+ const burstTime = now + rampDur;
+ const burstBuf = this.am.createNoiseBuffer('white', 0.25);
+ const burst = ctx.createBufferSource();
+ burst.buffer = burstBuf;
+ const bp = ctx.createBiquadFilter();
+ bp.type = 'bandpass';
+ bp.frequency.setValueAtTime(3500, burstTime);
+ bp.Q.setValueAtTime(1.5, burstTime);
+ const burstEnv = ctx.createGain();
+ burstEnv.gain.setValueAtTime(0.001, burstTime);
+ burstEnv.gain.linearRampToValueAtTime(0.3, burstTime + 0.01);
+ burstEnv.gain.exponentialRampToValueAtTime(0.001, burstTime + 0.22);
+ burst.connect(bp);
+ bp.connect(burstEnv);
+ burstEnv.connect(this.gainNode);
+ burst.start(burstTime);
+ burst.stop(burstTime + 0.25);
+ }
+
+ /**
+ * Victorian Steam Piston Chug (Retro Future, audio_gaps.md #45)
+ * The steam-age skeleton of the Cygnus: lowpass-filtered noise bursts
+ * synchronized with heavy 40 Hz triangle thumps and a metallic slapback.
+ * One cluster of 2-3 stroke cycles.
+ */
+ synthesizeCygnusPistonChug() {
+ this.init();
+ const ctx = this.am.ctx;
+ if (!ctx || !this.gainNode) return;
+
+ const now = ctx.currentTime;
+ const strokes = 2 + Math.floor(Math.random() * 2); // 2-3 strokes
+
+ const slap = ctx.createDelay(0.6);
+ slap.delayTime.setValueAtTime(0.35, now);
+ const slapFb = ctx.createGain();
+ slapFb.gain.setValueAtTime(0.3, now);
+ const slapWet = ctx.createGain();
+ slapWet.gain.setValueAtTime(0.4, now);
+ slap.connect(slapFb);
+ slapFb.connect(slap);
+ slap.connect(slapWet);
+ slapWet.connect(this.gainNode);
+
+ const stroke = (t, peak) => {
+ // Heavy piston body thump
+ const thump = ctx.createOscillator();
+ thump.type = 'triangle';
+ thump.frequency.setValueAtTime(40, t);
+ thump.frequency.linearRampToValueAtTime(32, t + 0.4);
+ const lp = ctx.createBiquadFilter();
+ lp.type = 'lowpass';
+ lp.frequency.setValueAtTime(120, t);
+ const thumpEnv = ctx.createGain();
+ thumpEnv.gain.setValueAtTime(0.001, t);
+ thumpEnv.gain.linearRampToValueAtTime(peak, t + 0.02);
+ thumpEnv.gain.exponentialRampToValueAtTime(0.001, t + 0.45);
+ thump.connect(lp);
+ lp.connect(thumpEnv);
+ thumpEnv.connect(this.gainNode);
+ thumpEnv.connect(slap);
+ thump.start(t);
+ thump.stop(t + 0.5);
+
+ // Synchronized steam exhaust puff
+ const puffBuf = this.am.createNoiseBuffer('white', 0.16);
+ const puff = ctx.createBufferSource();
+ puff.buffer = puffBuf;
+ const puffLp = ctx.createBiquadFilter();
+ puffLp.type = 'lowpass';
+ puffLp.frequency.setValueAtTime(500, t);
+ const puffEnv = ctx.createGain();
+ puffEnv.gain.setValueAtTime(0.001, t + 0.01);
+ puffEnv.gain.linearRampToValueAtTime(0.13, t + 0.03);
+ puffEnv.gain.exponentialRampToValueAtTime(0.001, t + 0.15);
+ puff.connect(puffLp);
+ puffLp.connect(puffEnv);
+ puffEnv.connect(this.gainNode);
+ puff.start(t);
+ puff.stop(t + 0.17);
+ };
+
+ for (let i = 0; i < strokes; i++) {
+ stroke(now + i * (0.5 + Math.random() * 0.25), 0.26 - i * 0.03);
+ }
+ }
+
+ /**
+ * Ambient Loop: Steam piston chug while the Cygnus is engaged
+ */
+ startCygnusChug(minIntervalMs = 1800, maxIntervalMs = 3200) {
+ this._scheduleLoop('cygnusChug', minIntervalMs, maxIntervalMs, () => {
+ this.synthesizeCygnusPistonChug();
+ });
+ }
+
+ /**
+ * Translocator Dematerialization / Rematerialization Cycle (audio_gaps.md #48)
+ * The classic shimmering beam-up wash: an additive array of 5 detuned sines
+ * (330-990 Hz over a 660 Hz base) each interlocked with its own 5-11 Hz AM
+ * LFO, plus a high bandpass noise shimmer gated by a 30 Hz LFO, all under a
+ * slow rise / mid dip / release master envelope. ~3.0 s.
+ */
+ synthesizeTranslocatorCycle() {
+ this.init();
+ const ctx = this.am.ctx;
+ if (!ctx || !this.gainNode) return;
+
+ const now = ctx.currentTime;
+ const duration = 3.0;
+
+ // Tonal shimmer master envelope (dip mid-cycle reads as the demat/remat split)
+ const masterEnv = ctx.createGain();
+ masterEnv.gain.setValueAtTime(0.001, now);
+ masterEnv.gain.linearRampToValueAtTime(0.15, now + 0.9);
+ masterEnv.gain.setValueAtTime(0.1, now + 1.35);
+ masterEnv.gain.linearRampToValueAtTime(0.17, now + 1.95);
+ masterEnv.gain.exponentialRampToValueAtTime(0.0001, now + duration);
+ masterEnv.connect(this.gainNode);
+
+ const ratios = [0.5, 0.75, 1.0, 1.25, 1.5];
+ ratios.forEach((ratio, idx) => {
+ const osc = ctx.createOscillator();
+ osc.type = 'sine';
+ osc.frequency.setValueAtTime(660 * ratio, now);
+ osc.detune.setValueAtTime((idx % 2 ? 1 : -1) * (1.5 + Math.random() * 4.5), now);
+
+ // Per-voice interlocking amplitude modulation (staggered 5-11 Hz LFOs)
+ const amBase = 0.5 + Math.random() * 0.2;
+ const voiceEnv = ctx.createGain();
+ voiceEnv.gain.setValueAtTime(amBase, now);
+ const lfo = ctx.createOscillator();
+ lfo.type = 'sine';
+ lfo.frequency.setValueAtTime(5 + idx * 1.5 + Math.random(), now);
+ const amDepth = ctx.createGain();
+ amDepth.gain.setValueAtTime(amBase * 0.7, now);
+ lfo.connect(amDepth);
+ amDepth.connect(voiceEnv.gain);
+
+ osc.connect(voiceEnv);
+ voiceEnv.connect(masterEnv);
+ osc.start(now);
+ osc.stop(now + duration + 0.1);
+ lfo.start(now);
+ lfo.stop(now + duration + 0.1);
+ });
+
+ // High-frequency energy shimmer (4-9 kHz) following a parallel envelope
+ const shimmerEnv = ctx.createGain();
+ shimmerEnv.gain.setValueAtTime(0.001, now);
+ shimmerEnv.gain.linearRampToValueAtTime(0.05, now + 0.9);
+ shimmerEnv.gain.setValueAtTime(0.035, now + 1.35);
+ shimmerEnv.gain.linearRampToValueAtTime(0.055, now + 1.95);
+ shimmerEnv.gain.exponentialRampToValueAtTime(0.0001, now + duration);
+ shimmerEnv.connect(this.gainNode);
+
+ const shimmerBuf = this.am.createNoiseBuffer('white', duration + 0.2);
+ const shimmer = ctx.createBufferSource();
+ shimmer.buffer = shimmerBuf;
+ [4500, 7800].forEach((center) => {
+ const bp = ctx.createBiquadFilter();
+ bp.type = 'bandpass';
+ bp.frequency.setValueAtTime(center, now);
+ bp.Q.setValueAtTime(1.2, now);
+ const amEnv = ctx.createGain();
+ amEnv.gain.setValueAtTime(center === 4500 ? 0.6 : 0.4, now);
+ const lfo = ctx.createOscillator();
+ lfo.type = 'sine';
+ lfo.frequency.setValueAtTime(30, now);
+ const lfoDepth = ctx.createGain();
+ lfoDepth.gain.setValueAtTime(amEnv.gain.value * 0.9, now);
+ lfo.connect(lfoDepth);
+ lfoDepth.connect(amEnv.gain);
+ shimmer.connect(bp);
+ bp.connect(amEnv);
+ amEnv.connect(shimmerEnv);
+ lfo.start(now);
+ lfo.stop(now + duration + 0.1);
+ });
+ shimmer.start(now);
+ shimmer.stop(now + duration + 0.05);
+ }
+
+ /**
+ * Point-Defense Flak Barrage Burst (audio_gaps.md #49)
+ * Staggered concussive clusters of 4-8 noise transients 10-30 ms apart over
+ * a 40 Hz sub-bass punch, with a couple of high shell-crack transients per
+ * volley. Each hit self-releases; whole burst ~0.9-1.4 s.
+ */
+ synthesizeFlakBarrageBurst() {
+ this.init();
+ const ctx = this.am.ctx;
+ if (!ctx || !this.gainNode) return;
+
+ const now = ctx.currentTime;
+ const duration = 0.9 + Math.random() * 0.5;
+
+ // 40 Hz sub-bass punch at volley start
+ const sub = ctx.createOscillator();
+ sub.type = 'triangle';
+ sub.frequency.setValueAtTime(40, now);
+ sub.frequency.linearRampToValueAtTime(32, now + 0.25);
+ const subEnv = ctx.createGain();
+ subEnv.gain.setValueAtTime(0.001, now);
+ subEnv.gain.linearRampToValueAtTime(0.3, now + 0.008);
+ subEnv.gain.exponentialRampToValueAtTime(0.0001, now + 0.3);
+ sub.connect(subEnv);
+ subEnv.connect(this.gainNode);
+ sub.start(now);
+ sub.stop(now + 0.32);
+
+ const hitCount = 4 + Math.floor(Math.random() * 5); // 4-8 hits
+ let cursor = 0.05;
+ for (let i = 0; i < hitCount; i++) {
+ const t = now + cursor;
+ const alternate = i % 2 ? 0.75 : 1.0; // mono proxy for spread
+ const isCrack = i % 3 === 1;
+ const buf = this.am.createNoiseBuffer('white', 0.12);
+ const noise = ctx.createBufferSource();
+ noise.buffer = buf;
+ const bp = ctx.createBiquadFilter();
+ bp.type = 'bandpass';
+ if (isCrack) {
+ bp.frequency.setValueAtTime(2500 + Math.random() * 1500, t);
+ bp.Q.setValueAtTime(8 + Math.random() * 4, t);
+ } else {
+ bp.frequency.setValueAtTime(300 + Math.random() * 1300, t);
+ bp.Q.setValueAtTime(2 + Math.random() * 3, t);
+ }
+ const env = ctx.createGain();
+ env.gain.setValueAtTime(0.001, t);
+ env.gain.linearRampToValueAtTime((isCrack ? 0.1 : 0.17 + Math.random() * 0.12) * alternate, t + 0.004);
+ env.gain.exponentialRampToValueAtTime(0.001, t + (isCrack ? 0.015 : 0.03 + Math.random() * 0.04));
+ noise.connect(bp);
+ bp.connect(env);
+ env.connect(this.gainNode);
+ noise.start(t);
+ noise.stop(t + 0.13);
+ cursor += 0.01 + Math.random() * 0.02; // 10-30 ms stagger
+ }
+
+ // Second soft sub thump mid-volley (distant batteries)
+ const sub2 = ctx.createOscillator();
+ sub2.type = 'triangle';
+ sub2.frequency.setValueAtTime(38, now + 0.42);
+ sub2.frequency.linearRampToValueAtTime(30, now + 0.6);
+ const sub2Env = ctx.createGain();
+ sub2Env.gain.setValueAtTime(0.001, now + 0.42);
+ sub2Env.gain.linearRampToValueAtTime(0.16, now + 0.43);
+ sub2Env.gain.exponentialRampToValueAtTime(0.0001, now + 0.72);
+ sub2.connect(sub2Env);
+ sub2Env.connect(this.gainNode);
+ sub2.start(now + 0.42);
+ sub2.stop(now + 0.74);
+ }
+
+ /**
+ * Heavy Weapons Breech Locking Clank (audio_gags.md #50)
+ * Three-stage loading cycle: 80 ms pre-charge pneumatic hiss, 60 Hz square
+ * breech slam into a lowpass body, and a high-Q steel ringoff with damped
+ * inharmonic partials. ~1.6 s.
+ */
+ synthesizeBreechClank() {
+ this.init();
+ const ctx = this.am.ctx;
+ if (!ctx || !this.gainNode) return;
+
+ const now = ctx.currentTime;
+
+ // Stage 1: pneumatic pre-charge hiss (80 ms, highpass 2.5 kHz)
+ const hissBuf = this.am.createNoiseBuffer('white', 0.14);
+ const hiss = ctx.createBufferSource();
+ hiss.buffer = hissBuf;
+ const hp = ctx.createBiquadFilter();
+ hp.type = 'highpass';
+ hp.frequency.setValueAtTime(2500, now);
+ hp.Q.setValueAtTime(0.7, now);
+ const hissEnv = ctx.createGain();
+ hissEnv.gain.setValueAtTime(0.001, now);
+ hissEnv.gain.linearRampToValueAtTime(0.14, now + 0.005);
+ hissEnv.gain.exponentialRampToValueAtTime(0.001, now + 0.08);
+ hiss.connect(hp);
+ hp.connect(hissEnv);
+ hissEnv.connect(this.gainNode);
+ hiss.start(now);
+ hiss.stop(now + 0.1);
+
+ // Stage 2: breech slam - 60 Hz square impact into a lowpass body
+ const slam = ctx.createOscillator();
+ slam.type = 'square';
+ slam.frequency.setValueAtTime(60, now + 0.08);
+ slam.frequency.linearRampToValueAtTime(48, now + 0.2);
+ const slamLp = ctx.createBiquadFilter();
+ slamLp.type = 'lowpass';
+ slamLp.frequency.setValueAtTime(300, now + 0.08);
+ const slamEnv = ctx.createGain();
+ slamEnv.gain.setValueAtTime(0.001, now + 0.08);
+ slamEnv.gain.linearRampToValueAtTime(0.4, now + 0.086);
+ slamEnv.gain.exponentialRampToValueAtTime(0.001, now + 0.28);
+ slam.connect(slamLp);
+ slamLp.connect(slamEnv);
+ slamEnv.connect(this.gainNode);
+ slam.start(now + 0.08);
+ slam.stop(now + 0.3);
+
+ // Stage 3: high-Q steel ringoff with damped inharmonic partials
+ const ringBuf = this.am.createNoiseBuffer('white', 0.45);
+ const ring = ctx.createBufferSource();
+ ring.buffer = ringBuf;
+ const ringBp = ctx.createBiquadFilter();
+ ringBp.type = 'bandpass';
+ ringBp.frequency.setValueAtTime(1800 + Math.random() * 1600, now + 0.09);
+ ringBp.Q.setValueAtTime(18 + Math.random() * 7, now + 0.09);
+ const ringEnv = ctx.createGain();
+ ringEnv.gain.setValueAtTime(0.001, now + 0.09);
+ ringEnv.gain.linearRampToValueAtTime(0.16, now + 0.095);
+ ringEnv.gain.exponentialRampToValueAtTime(0.001, now + 0.5);
+ ring.connect(ringBp);
+ ringBp.connect(ringEnv);
+ ringEnv.connect(this.gainNode);
+ ring.start(now + 0.09);
+ ring.stop(now + 0.52);
+
+ [240, 630, 1120].forEach((freq, idx) => {
+ const partial = ctx.createOscillator();
+ partial.type = 'sine';
+ partial.frequency.setValueAtTime(freq, now + 0.09);
+ const pEnv = ctx.createGain();
+ pEnv.gain.setValueAtTime(0.001, now + 0.09);
+ pEnv.gain.linearRampToValueAtTime([0.11, 0.07, 0.045][idx], now + 0.095);
+ pEnv.gain.exponentialRampToValueAtTime(0.001, now + 0.09 + [0.9, 0.6, 0.35][idx]);
+ partial.connect(pEnv);
+ pEnv.connect(this.gainNode);
+ partial.start(now + 0.09);
+ partial.stop(now + [1.0, 0.7, 0.45][idx]);
+ });
+ }
+
+ /**
+ * Heavy Hydraulic Docking Groan & Mechanical Clang (audio_gaps.md #52)
+ * A slow ~2.4 s structural stress groan: brown noise through two parallel
+ * resonant bandpasses sweeping 38->62 Hz and 55->85 Hz under an asymmetric
+ * groaning envelope, resolving into a metallic anvil transient with an
+ * inharmonic sine stack at the moment the clamp seats. ~4.0 s.
+ */
+ synthesizeDockingGroan() {
+ this.init();
+ const ctx = this.am.ctx;
+ if (!ctx || !this.gainNode) return;
+
+ const now = ctx.currentTime;
+ const groanDur = 3.9;
+
+ // Brown-noise stress bed through two sweeping resonant bandpasses
+ const bedBuf = this.am.createNoiseBuffer('brown', groanDur + 0.2);
+ const bed = ctx.createBufferSource();
+ bed.buffer = bedBuf;
+ const groanEnv = ctx.createGain();
+ groanEnv.gain.setValueAtTime(0.0001, now);
+ groanEnv.gain.linearRampToValueAtTime(0.001, now + 0.01);
+ groanEnv.gain.linearRampToValueAtTime(0.22, now + 1.1); // groan up
+ groanEnv.gain.setValueAtTime(0.1, now + 1.6); // stress dip
+ groanEnv.gain.linearRampToValueAtTime(0.26, now + 2.3); // groan peaks
+ groanEnv.gain.exponentialRampToValueAtTime(0.0001, now + groanDur);
+ groanEnv.connect(this.gainNode);
+
+ [38, 55].forEach((startFreq, idx) => {
+ const bp = ctx.createBiquadFilter();
+ bp.type = 'bandpass';
+ bp.frequency.setValueAtTime(startFreq, now);
+ bp.frequency.linearRampToValueAtTime(idx === 0 ? 62 : 85, now + 2.4);
+ bp.Q.setValueAtTime(idx === 0 ? 5.5 : 4.5, now);
+ bed.connect(bp);
+ bp.connect(groanEnv);
+ });
+ bed.start(now);
+ bed.stop(now + groanDur + 0.05);
+
+ // Metallic anvil clang as the groan resolves (~2.7 s in)
+ const clangT = now + 2.7;
+ const ringBuf = this.am.createNoiseBuffer('white', 0.4);
+ const ring = ctx.createBufferSource();
+ ring.buffer = ringBuf;
+ const ringBp = ctx.createBiquadFilter();
+ ringBp.type = 'bandpass';
+ ringBp.frequency.setValueAtTime(2000, clangT);
+ ringBp.Q.setValueAtTime(20, clangT);
+ const ringEnv = ctx.createGain();
+ ringEnv.gain.setValueAtTime(0.001, clangT);
+ ringEnv.gain.linearRampToValueAtTime(0.16, clangT + 0.004);
+ ringEnv.gain.exponentialRampToValueAtTime(0.001, clangT + 0.35);
+ ring.connect(ringBp);
+ ringBp.connect(ringEnv);
+ ringEnv.connect(this.gainNode);
+ ring.start(clangT);
+ ring.stop(clangT + 0.4);
+
+ [420, 880, 1600].forEach((freq, idx) => {
+ const partial = ctx.createOscillator();
+ partial.type = 'sine';
+ partial.frequency.setValueAtTime(freq, clangT);
+ const pEnv = ctx.createGain();
+ pEnv.gain.setValueAtTime(0.001, clangT);
+ pEnv.gain.linearRampToValueAtTime([0.08, 0.06, 0.04][idx], clangT + 0.005);
+ pEnv.gain.exponentialRampToValueAtTime(0.001, clangT + [0.8, 0.55, 0.3][idx]);
+ partial.connect(pEnv);
+ pEnv.connect(this.gainNode);
+ partial.start(clangT);
+ partial.stop(clangT + [0.85, 0.6, 0.35][idx]);
+ });
+ }
+
+ /**
+ * Dual-Reel Magnetic Tape Spooler & Pinch Roller Clank (audio_gaps.md #53)
+ * Solenoid pinch-roller engagement (110 Hz impulse), a fluttering pink tape
+ * bed (15 Hz AM on a bandpass noise loop) and a faint 3200 Hz capstan hum
+ * with spool-up glide, ending in an optional fast-forward whine tail.
+ * ~2.6-3.5 s.
+ */
+ synthesizeTapeSpooler() {
+ this.init();
+ const ctx = this.am.ctx;
+ if (!ctx || !this.gainNode) return;
+
+ const now = ctx.currentTime;
+ const duration = 2.6 + Math.random() * 0.9;
+
+ // Solenoid clamp impulse (110 Hz) with a tiny mechanical tick
+ const solenoid = ctx.createOscillator();
+ solenoid.type = 'square';
+ solenoid.frequency.setValueAtTime(110, now);
+ solenoid.frequency.linearRampToValueAtTime(95, now + 0.05);
+ const solEnv = ctx.createGain();
+ solEnv.gain.setValueAtTime(0.001, now);
+ solEnv.gain.linearRampToValueAtTime(0.22, now + 0.004);
+ solEnv.gain.exponentialRampToValueAtTime(0.001, now + 0.07);
+ solenoid.connect(solEnv);
+ solEnv.connect(this.gainNode);
+ solenoid.start(now);
+ solenoid.stop(now + 0.09);
+
+ const tickBuf = this.am.createNoiseBuffer('white', 0.03);
+ const tick = ctx.createBufferSource();
+ tick.buffer = tickBuf;
+ const tickBp = ctx.createBiquadFilter();
+ tickBp.type = 'bandpass';
+ tickBp.frequency.setValueAtTime(1800, now + 0.02);
+ tickBp.Q.setValueAtTime(10, now + 0.02);
+ const tickEnv = ctx.createGain();
+ tickEnv.gain.setValueAtTime(0.08, now + 0.02);
+ tickEnv.gain.exponentialRampToValueAtTime(0.001, now + 0.045);
+ tick.connect(tickBp);
+ tickBp.connect(tickEnv);
+ tickEnv.connect(this.gainNode);
+ tick.start(now + 0.02);
+ tick.stop(now + 0.06);
+
+ // Fluttering pink-noise tape bed (15 Hz flutter AM)
+ const tapeBuf = this.am.createNoiseBuffer('pink', duration + 0.3);
+ const tape = ctx.createBufferSource();
+ tape.buffer = tapeBuf;
+ tape.loop = true;
+ const tapeBp = ctx.createBiquadFilter();
+ tapeBp.type = 'bandpass';
+ tapeBp.frequency.setValueAtTime(500 + Math.random() * 1000, now);
+ tapeBp.Q.setValueAtTime(1, now);
+ const tapeEnv = ctx.createGain();
+ tapeEnv.gain.setValueAtTime(0.0001, now);
+ tapeEnv.gain.linearRampToValueAtTime(0.05, now + 0.15);
+ tapeEnv.gain.setValueAtTime(0.03, now + duration * 0.45);
+ tapeEnv.gain.exponentialRampToValueAtTime(0.0001, now + duration);
+ const flutter = ctx.createOscillator();
+ flutter.type = 'sine';
+ flutter.frequency.setValueAtTime(15, now);
+ const flutterDepth = ctx.createGain();
+ flutterDepth.gain.setValueAtTime(0.035, now);
+ flutter.connect(flutterDepth);
+ flutterDepth.connect(tapeEnv.gain);
+ tape.connect(tapeBp);
+ tapeBp.connect(tapeEnv);
+ tapeEnv.connect(this.gainNode);
+ tape.start(now);
+ tape.stop(now + duration + 0.05);
+ flutter.start(now);
+ flutter.stop(now + duration + 0.05);
+
+ // Capstan hum: faint 3200 Hz sine with a slow spool-up glide and wow
+ const capstan = ctx.createOscillator();
+ capstan.type = 'sine';
+ capstan.frequency.setValueAtTime(3136, now);
+ capstan.frequency.exponentialRampToValueAtTime(3264, now + 1.5);
+ const capstanEnv = ctx.createGain();
+ capstanEnv.gain.setValueAtTime(0.0001, now);
+ capstanEnv.gain.linearRampToValueAtTime(0.016, now + 0.3);
+ capstanEnv.gain.exponentialRampToValueAtTime(0.0001, now + duration);
+ const wow = ctx.createOscillator();
+ wow.type = 'sine';
+ wow.frequency.setValueAtTime(8, now);
+ const wowDepth = ctx.createGain();
+ wowDepth.gain.setValueAtTime(12, now);
+ wow.connect(wowDepth);
+ wowDepth.connect(capstan.frequency);
+ capstan.connect(capstanEnv);
+ capstanEnv.connect(this.gainNode);
+ capstan.start(now);
+ capstan.stop(now + duration + 0.1);
+ wow.start(now);
+ wow.stop(now + duration + 0.1);
+
+ // Occasional fast-forward whine tail
+ if (Math.random() < 0.3) {
+ const whineT = now + duration - 0.55;
+ const whine = ctx.createOscillator();
+ whine.type = 'sine';
+ whine.frequency.setValueAtTime(1400, whineT);
+ whine.frequency.exponentialRampToValueAtTime(2300, whineT + 0.5);
+ const whineEnv = ctx.createGain();
+ whineEnv.gain.setValueAtTime(0.0001, whineT);
+ whineEnv.gain.linearRampToValueAtTime(0.02, whineT + 0.08);
+ whineEnv.gain.exponentialRampToValueAtTime(0.0001, whineT + 0.5);
+ whine.connect(whineEnv);
+ whineEnv.connect(this.gainNode);
+ whine.start(whineT);
+ whine.stop(whineT + 0.55);
+ }
+ }
+
+ /**
+ * Dot-Matrix / Teletype Chatter Phrase (audio_gaps.md #51, one phrase unit)
+ * A burst of 6-16 pseudo-random high-Q ~4 kHz impact spikes with periodic
+ * 12 Hz stepper clicks, phrased with a soft attack/release. The continuous
+ * Nostromo terminal loop is startTeletypeChatter() below.
+ */
+ synthesizeTeletypeChatterPhrase() {
+ this.init();
+ const ctx = this.am.ctx;
+ if (!ctx || !this.gainNode) return;
+
+ const now = ctx.currentTime;
+ const phraseDur = 0.6 + Math.random() * 0.8;
+
+ const masterEnv = ctx.createGain();
+ masterEnv.gain.setValueAtTime(0.0001, now);
+ masterEnv.gain.linearRampToValueAtTime(1, now + 0.03); // soft phrase in
+ masterEnv.gain.setValueAtTime(1, now + phraseDur - 0.06);
+ masterEnv.gain.exponentialRampToValueAtTime(0.0001, now + phraseDur + 0.05);
+ masterEnv.connect(this.gainNode);
+
+ const strikes = 6 + Math.floor(Math.random() * 11); // 6-16 spikes
+ let cursor = 0.02;
+ for (let i = 0; i < strikes; i++) {
+ if (cursor >= phraseDur - 0.1) break; // never schedule past the phrase out
+ const t = now + cursor;
+ const buf = this.am.createNoiseBuffer('white', 0.03);
+ const noise = ctx.createBufferSource();
+ noise.buffer = buf;
+ const bp = ctx.createBiquadFilter();
+ bp.type = 'bandpass';
+ bp.frequency.setValueAtTime(3400 + Math.random() * 1200, t);
+ bp.Q.setValueAtTime(10 + Math.random() * 6, t);
+ const env = ctx.createGain();
+ env.gain.setValueAtTime(0.1 + Math.random() * 0.1, t);
+ env.gain.exponentialRampToValueAtTime(0.001, t + 0.012 + Math.random() * 0.01);
+ noise.connect(bp);
+ bp.connect(env);
+ env.connect(masterEnv);
+ noise.start(t);
+ noise.stop(t + 0.035);
+
+ // Periodic 12 Hz carriage stepper click
+ if (i % 3 === 2) {
+ const stepper = ctx.createOscillator();
+ stepper.type = 'square';
+ stepper.frequency.setValueAtTime(90, t);
+ stepper.frequency.linearRampToValueAtTime(70, t + 0.02);
+ const sEnv = ctx.createGain();
+ sEnv.gain.setValueAtTime(0.06, t);
+ sEnv.gain.exponentialRampToValueAtTime(0.001, t + 0.025);
+ stepper.connect(sEnv);
+ sEnv.connect(masterEnv);
+ stepper.start(t);
+ stepper.stop(t + 0.03);
+ }
+ cursor += 0.045 + Math.random() * 0.095; // jittered 45-140 ms strikes
+ }
+ }
+
+ /**
+ * Ambient Loop: Continuous Nostromo terminal teletype chatter (#51)
+ * Dense overlapping "typed line" phrases at a 0.9-2.0 s cadence keep the
+ * effect continuous while the preset runs. Dies with the loop registry.
+ */
+ startTeletypeChatter(minIntervalMs = 900, maxIntervalMs = 2000) {
+ this._scheduleLoop('teletypeChatter', minIntervalMs, maxIntervalMs, () => {
+ this.synthesizeTeletypeChatterPhrase();
+ });
+ }
+
+ /**
+ * Continuous Heterodyne / Theremin Navigational Drone (audio_gaps.md #54)
+ * A persistent room-tone element for the Jupiter 2 astrogator deck: two
+ * detuned sines (~1040 Hz / ~1048 Hz) beating to ~8 Hz, each with a very
+ * slow independent wander LFO so the beat drifts like a vacuum-tube theremin.
+ * True node-graph bed - long 3 s attack, room-tone level, gentle 1.8 s
+ * release on stop. Re-start always fades the previous bed out first.
+ */
+ startHeterodyneDrone() {
+ this.init();
+ const ctx = this.am.ctx;
+ if (!ctx || !this.gainNode) return;
+
+ this.stopHeterodyneDrone();
+
+ const now = ctx.currentTime;
+ const level = 0.06;
+ const masterGain = ctx.createGain();
+ masterGain.gain.setValueAtTime(0.0001, now);
+ masterGain.gain.linearRampToValueAtTime(level, now + 3.0);
+ masterGain.connect(this.gainNode);
+
+ const sources = [];
+ [1040, 1048].forEach((baseFreq, idx) => {
+ const osc = ctx.createOscillator();
+ osc.type = 'sine';
+ osc.frequency.setValueAtTime(baseFreq, now);
+ // Slow independent drift (0.04-0.09 Hz) keeps the beat wandering
+ const wander = ctx.createOscillator();
+ wander.type = 'sine';
+ wander.frequency.setValueAtTime(idx === 0 ? 0.04 + Math.random() * 0.02 : 0.07 + Math.random() * 0.02, now);
+ const wanderDepth = ctx.createGain();
+ wanderDepth.gain.setValueAtTime(idx === 0 ? 4 : 3, now);
+ wander.connect(wanderDepth);
+ wanderDepth.connect(osc.frequency);
+
+ const voiceGain = ctx.createGain();
+ voiceGain.gain.setValueAtTime(0.5, now);
+ osc.connect(voiceGain);
+ voiceGain.connect(masterGain);
+ osc.start(now);
+ wander.start(now);
+ sources.push(osc, wander);
+ });
+
+ this.heterodyneDroneActive = true;
+ this.heterodyneDroneGain = masterGain;
+ this.heterodyneDroneSources = sources;
+ }
+
+ stopHeterodyneDrone() {
+ this.heterodyneDroneActive = false;
+ const gain = this.heterodyneDroneGain;
+ if (!gain) return;
+ const ctx = this.am.ctx;
+ if (ctx) {
+ const now = ctx.currentTime;
+ // linearRamp continues from the computed value, so a mid-attack stop
+ // glides down smoothly without a click
+ gain.gain.linearRampToValueAtTime(0.0001, now + 1.8);
+ (this.heterodyneDroneSources || []).forEach((src) => {
+ try { src.stop(now + 2.0); } catch (e) { /* already stopped */ }
+ });
+ }
+ this.heterodyneDroneGain = null;
+ this.heterodyneDroneSources = null;
+ }
+
+ /**
+ * Zocolo Commerce-Plaza Chatter Bed (audio_gaps.md #46)
+ * A persistent crowd-murmur bed for Babylon 5: looped pink noise through
+ * four parallel formant bandpasses (300 Hz - 3 kHz) whose centers drift at
+ * 0.03-0.11 Hz, plus an accent layer of vowelish murmur blips scheduled on
+ * the loop registry. Re-start fades any previous bed out first.
+ */
+ startZocoloChatter() {
+ this.init();
+ const ctx = this.am.ctx;
+ if (!ctx || !this.gainNode) return;
+
+ this.stopZocoloChatter();
+
+ const now = ctx.currentTime;
+ const bedBuf = this.am.createNoiseBuffer('pink', 8);
+ const bed = ctx.createBufferSource();
+ bed.buffer = bedBuf;
+ bed.loop = true;
+
+ const masterGain = ctx.createGain();
+ masterGain.gain.setValueAtTime(0.0001, now);
+ masterGain.gain.linearRampToValueAtTime(0.16, now + 2.5);
+ masterGain.connect(this.gainNode);
+
+ // Formant filter bank with slow independent center drift
+ const sources = [];
+ const formants = [
+ { center: 330, q: 6, weight: 0.3, driftHz: 55, driftRate: 0.03 + Math.random() * 0.02 },
+ { center: 760, q: 5, weight: 0.24, driftHz: 130, driftRate: 0.05 + Math.random() * 0.03 },
+ { center: 1500, q: 4, weight: 0.16, driftHz: 260, driftRate: 0.07 + Math.random() * 0.03 },
+ { center: 2600, q: 3, weight: 0.08, driftHz: 480, driftRate: 0.09 + Math.random() * 0.02 }
+ ];
+ formants.forEach((f) => {
+ const bp = ctx.createBiquadFilter();
+ bp.type = 'bandpass';
+ bp.frequency.setValueAtTime(f.center, now);
+ bp.Q.setValueAtTime(f.q, now);
+ const drift = ctx.createOscillator();
+ drift.type = 'sine';
+ drift.frequency.setValueAtTime(f.driftRate, now);
+ const driftDepth = ctx.createGain();
+ driftDepth.gain.setValueAtTime(f.driftHz, now);
+ drift.connect(driftDepth);
+ driftDepth.connect(bp.frequency);
+ const bandGain = ctx.createGain();
+ bandGain.gain.setValueAtTime(f.weight, now);
+ bed.connect(bp);
+ bp.connect(bandGain);
+ bandGain.connect(masterGain);
+ drift.start(now);
+ sources.push(drift);
+ });
+
+ bed.start(now);
+ sources.push(bed);
+
+ // Crowd accents: murmur blips layered over the wash
+ this._scheduleLoop('zocaloAccents', 2400, 6800, () => {
+ this.synthesizeZocoloAccent_();
+ });
+
+ this.zocaloChatterActive = true;
+ this.zocaloChatterGain = masterGain;
+ this.zocaloChatterSources = sources;
+ }
+
+ stopZocoloChatter() {
+ this.zocaloChatterActive = false;
+ this._stopLoop('zocaloAccents');
+ const gain = this.zocaloChatterGain;
+ if (!gain) return;
+ const ctx = this.am.ctx;
+ if (ctx) {
+ const now = ctx.currentTime;
+ gain.gain.linearRampToValueAtTime(0.0001, now + 2.0);
+ (this.zocaloChatterSources || []).forEach((src) => {
+ try { src.stop(now + 2.2); } catch (e) { /* already stopped */ }
+ });
+ }
+ this.zocaloChatterGain = null;
+ this.zocaloChatterSources = null;
+ }
+
+ /**
+ * One crowd "voice" blip for the zocalo bed: a short formant-filtered noise
+ * impulse with a slight pitch glide, sometimes doubled. Self-releasing.
+ */
+ synthesizeZocoloAccent_() {
+ const ctx = this.am.ctx;
+ if (!ctx || !this.gainNode) return;
+
+ const now = ctx.currentTime;
+ const count = Math.random() < 0.3 ? 2 : 1;
+ let cursor = 0;
+ for (let i = 0; i < count; i++) {
+ const t = now + cursor;
+ const center = 380 + Math.random() * 1500;
+ const glide = Math.random() < 0.4 ? (40 + Math.random() * 90) : -(30 + Math.random() * 60);
+ const dur = 0.1 + Math.random() * 0.15;
+ const buf = this.am.createNoiseBuffer('pink', dur + 0.1);
+ const noise = ctx.createBufferSource();
+ noise.buffer = buf;
+ const bp = ctx.createBiquadFilter();
+ bp.type = 'bandpass';
+ bp.frequency.setValueAtTime(center, t);
+ bp.frequency.linearRampToValueAtTime(Math.max(200, center + glide), t + dur);
+ bp.Q.setValueAtTime(2.2 + Math.random() * 1.5, t);
+ const env = ctx.createGain();
+ env.gain.setValueAtTime(0.001, t);
+ env.gain.linearRampToValueAtTime(0.05 + Math.random() * 0.04, t + 0.03);
+ env.gain.exponentialRampToValueAtTime(0.001, t + dur);
+ noise.connect(bp);
+ bp.connect(env);
+ env.connect(this.gainNode);
+ noise.start(t);
+ noise.stop(t + dur + 0.05);
+ cursor += 0.09 + Math.random() * 0.15;
+ }
+ }
+
+ stopAllLoops() {
+ this.stopMedicalMonitor();
+ this.stopStationSparks();
+ this.stopZocoloChatter();
+ this.stopHeterodyneDrone();
+ Object.keys(this.loopTimers).forEach((key) => {
+ this._stopLoop(key);
+ });
+ }
+}
+
+window.ExpandedSciFiAudioSynth = ExpandedSciFiAudioSynth;
+
+
+/**
+ * Layered & Staged Engine Startup and Shutdown Synthesizer
+ * Synthesizes chronological multi-phase acoustic scripts for 15 distinct starship archetypes.
+ */
+class EngineTransitionSynth {
+ constructor(audioManager) {
+ this.am = audioManager;
+ this.gainNode = null;
+ this.activeNodes = [];
+ this.activeTimeouts = [];
+ }
+
+ init() {
+ if (!this.am.ctx) return;
+ if (!this.gainNode) {
+ this.gainNode = this.am.ctx.createGain();
+ this.gainNode.gain.setValueAtTime(0.85, this.am.ctx.currentTime);
+ this.gainNode.connect(this.am.compressor);
+ }
+ }
+
+ stopTransitions() {
+ this.activeTimeouts.forEach(t => clearTimeout(t));
+ this.activeTimeouts = [];
+ this.activeNodes.forEach(node => {
+ try { if (node.stop) node.stop(); } catch (e) {}
+ try { node.disconnect(); } catch (e) {}
+ });
+ this.activeNodes = [];
+ }
+
+ _tone({ type = 'sine', startFreq, endFreq, startTime, duration, startVol = 0.001, peakVol = 0.4, endVol = 0.0001, filterType = null, filterFreq = 1000, filterQ = 1.0 }) {
+ const ctx = this.am.ctx;
+ if (!ctx || !this.gainNode) return null;
+ const osc = ctx.createOscillator();
+ osc.type = type;
+ osc.frequency.setValueAtTime(Math.max(10, startFreq), startTime);
+ if (endFreq && endFreq !== startFreq) {
+ osc.frequency.exponentialRampToValueAtTime(Math.max(10, endFreq), startTime + duration);
+ }
+
+ const env = ctx.createGain();
+ env.gain.setValueAtTime(Math.max(0.0001, startVol), startTime);
+ const attack = Math.min(0.08, duration * 0.25);
+ env.gain.linearRampToValueAtTime(peakVol, startTime + attack);
+ env.gain.exponentialRampToValueAtTime(Math.max(0.0001, endVol), startTime + duration);
+
+ let lastNode = osc;
+ if (filterType) {
+ const filter = ctx.createBiquadFilter();
+ filter.type = filterType;
+ filter.frequency.setValueAtTime(filterFreq, startTime);
+ filter.Q.setValueAtTime(filterQ, startTime);
+ lastNode.connect(filter);
+ lastNode = filter;
+ this.activeNodes.push(filter);
+ }
+
+ lastNode.connect(env);
+ env.connect(this.gainNode);
+
+ osc.start(startTime);
+ osc.stop(startTime + duration);
+
+ this.activeNodes.push(osc, env);
+ return osc;
+ }
+
+ _noise({ noiseType = 'pink', filterType = 'bandpass', startFreq = 800, endFreq = 800, Q = 1.5, startTime, duration, peakVol = 0.35 }) {
+ const ctx = this.am.ctx;
+ if (!ctx || !this.gainNode) return null;
+ const buf = this.am.createNoiseBuffer(noiseType, Math.max(3, Math.ceil(duration + 1)));
+ const src = ctx.createBufferSource();
+ src.buffer = buf;
+
+ const filter = ctx.createBiquadFilter();
+ filter.type = filterType;
+ filter.frequency.setValueAtTime(Math.max(20, startFreq), startTime);
+ if (endFreq && endFreq !== startFreq) {
+ filter.frequency.exponentialRampToValueAtTime(Math.max(20, endFreq), startTime + duration);
+ }
+ filter.Q.setValueAtTime(Q, startTime);
+
+ const env = ctx.createGain();
+ env.gain.setValueAtTime(0.0001, startTime);
+ const attack = Math.min(0.12, duration * 0.3);
+ env.gain.linearRampToValueAtTime(peakVol, startTime + attack);
+ env.gain.exponentialRampToValueAtTime(0.0001, startTime + duration);
+
+ src.connect(filter);
+ filter.connect(env);
+ env.connect(this.gainNode);
+
+ src.start(startTime);
+ src.stop(startTime + duration);
+
+ this.activeNodes.push(src, filter, env);
+ return src;
+ }
+
+ _click(startTime, freq = 140, duration = 0.035, vol = 0.4, type = 'square') {
+ return this._tone({ type, startFreq: freq, endFreq: 25, startTime, duration, peakVol: vol, endVol: 0.0001 });
+ }
+
+ _sub(startTime, startFreq = 70, endFreq = 30, duration = 0.8, vol = 0.6) {
+ return this._tone({ type: 'sine', startFreq, endFreq, startTime, duration, peakVol: vol, endVol: 0.0001 });
+ }
+
+ _chime(startTime, pitches = [880, 1174, 1567], step = 0.05, duration = 0.35, vol = 0.25) {
+ pitches.forEach((freq, idx) => {
+ const t = startTime + idx * step;
+ this._tone({ type: 'sine', startFreq: freq, endFreq: freq * 1.01, startTime: t, duration, peakVol: vol, endVol: 0.0001 });
+ });
+ }
+
+ playStartup(profileKey = 'galaxy', duration = 2.5) {
+ this.init();
+ if (!this.am.ctx) return;
+ this.stopTransitions();
+ const now = this.am.ctx.currentTime;
+ const s = duration / 2.5;
+
+ switch (profileKey) {
+ case 'intrepid': this._startupIntrepid(now, s); break;
+ case 'defiant': this._startupDefiance(now, s); break;
+ case 'cardassian': this._startupCardassian(now, s); break;
+ case 'tos': this._startupTOS(now, s); break;
+ case 'nx': this._startupNX(now, s); break;
+ case 'tardix': this._startupTARDIX(now, s); break;
+ case 'industrial': this._startupIndustrial(now, s); break;
+ case 'bioship': this._startupBioship(now, s); break;
+ case 'retrofuture': this._startupRetrofuture(now, s); break;
+ case 'military': this._startupMilitary(now, s); break;
+ case 'deepspace': this._startupDeepSpace(now, s); break;
+ case 'outlaw': this._startupOutlaw(now, s); break;
+ case 'station': this._startupStation(now, s); break;
+ case 'comedy': this._startupComedy(now, s); break;
+ case 'galaxy':
+ default:
+ this._startupGalaxy(now, s);
+ break;
+ }
+ }
+
+ playShutdown(profileKey = 'galaxy', duration = 2.5) {
+ this.init();
+ if (!this.am.ctx) return;
+ this.stopTransitions();
+ const now = this.am.ctx.currentTime;
+ const s = duration / 2.5;
+
+ switch (profileKey) {
+ case 'intrepid': this._shutdownIntrepid(now, s); break;
+ case 'defiant': this._shutdownDefiance(now, s); break;
+ case 'cardassian': this._shutdownCardassian(now, s); break;
+ case 'tos': this._shutdownTOS(now, s); break;
+ case 'nx': this._shutdownNX(now, s); break;
+ case 'tardix': this._shutdownTARDIX(now, s); break;
+ case 'industrial': this._shutdownIndustrial(now, s); break;
+ case 'bioship': this._shutdownBioship(now, s); break;
+ case 'retrofuture': this._shutdownRetrofuture(now, s); break;
+ case 'military': this._shutdownMilitary(now, s); break;
+ case 'deepspace': this._shutdownDeepSpace(now, s); break;
+ case 'outlaw': this._shutdownOutlaw(now, s); break;
+ case 'station': this._shutdownStation(now, s); break;
+ case 'comedy': this._shutdownComedy(now, s); break;
+ case 'galaxy':
+ default:
+ this._shutdownGalaxy(now, s);
+ break;
+ }
+ }
+
+ // 1. TNG GALAXY CLASS
+ _startupGalaxy(now, s) {
+ this._click(now, 150, 0.04, 0.45);
+ this._click(now + 0.14 * s, 110, 0.03, 0.35);
+ this._tone({ type: 'sine', startFreq: 60, endFreq: 140, startTime: now + 0.05 * s, duration: 0.65 * s, peakVol: 0.35 });
+ this._tone({ type: 'sawtooth', startFreq: 120, endFreq: 480, startTime: now + 0.7 * s, duration: 1.1 * s, peakVol: 0.28, filterType: 'lowpass', filterFreq: 750, filterQ: 3.5 });
+ this._sub(now + 0.85 * s, 65, 80, 0.95 * s, 0.55);
+ this._noise({ noiseType: 'pink', filterType: 'lowpass', startFreq: 200, endFreq: 600, startTime: now + 0.9 * s, duration: 0.9 * s, peakVol: 0.25 });
+ this._chime(now + 1.8 * s, [784, 1046, 1318], 0.06 * s, 0.5 * s, 0.3);
+ this._sub(now + 1.8 * s, 80, 58, 0.65 * s, 0.45);
+ }
+ _shutdownGalaxy(now, s) {
+ this._click(now, 160, 0.05, 0.5);
+ this._sub(now, 85, 40, 0.65 * s, 0.6);
+ this._tone({ type: 'sawtooth', startFreq: 460, endFreq: 75, startTime: now + 0.6 * s, duration: 1.2 * s, peakVol: 0.25, filterType: 'lowpass', filterFreq: 600, filterQ: 2.0 });
+ this._noise({ noiseType: 'pink', filterType: 'bandpass', startFreq: 650, endFreq: 180, startTime: now + 0.75 * s, duration: 1.0 * s, peakVol: 0.28 });
+ this._tone({ type: 'sine', startFreq: 75, endFreq: 24, startTime: now + 1.7 * s, duration: 0.75 * s, peakVol: 0.35 });
+ }
+
+ // 2. WAYFARER INTREPID CLASS
+ _startupIntrepid(now, s) {
+ this._click(now, 450, 0.025, 0.35);
+ this._click(now + 0.08 * s, 680, 0.025, 0.35);
+ this._tone({ type: 'triangle', startFreq: 240, endFreq: 580, startTime: now + 0.1 * s, duration: 0.6 * s, peakVol: 0.3 });
+ this._tone({ type: 'sawtooth', startFreq: 320, endFreq: 1650, startTime: now + 0.7 * s, duration: 1.1 * s, peakVol: 0.35, filterType: 'bandpass', filterFreq: 1400, filterQ: 4.5 });
+ this._sub(now + 0.8 * s, 70, 95, 0.9 * s, 0.45);
+ this._noise({ noiseType: 'white', filterType: 'bandpass', startFreq: 1200, endFreq: 2400, startTime: now + 0.85 * s, duration: 0.9 * s, peakVol: 0.22 });
+ this._chime(now + 1.8 * s, [1174, 1567, 2093], 0.05 * s, 0.45 * s, 0.28);
+ this._tone({ type: 'sine', startFreq: 95, endFreq: 70, startTime: now + 1.85 * s, duration: 0.65 * s, peakVol: 0.4 });
+ }
+ _shutdownIntrepid(now, s) {
+ this._click(now, 520, 0.03, 0.4);
+ this._sub(now, 95, 50, 0.5 * s, 0.5);
+ this._tone({ type: 'sawtooth', startFreq: 1600, endFreq: 140, startTime: now + 0.5 * s, duration: 1.2 * s, peakVol: 0.28, filterType: 'lowpass', filterFreq: 1200 });
+ this._noise({ noiseType: 'white', filterType: 'highpass', startFreq: 1400, endFreq: 400, startTime: now + 0.6 * s, duration: 0.8 * s, peakVol: 0.25 });
+ this._tone({ type: 'sine', startFreq: 140, endFreq: 30, startTime: now + 1.7 * s, duration: 0.8 * s, peakVol: 0.25 });
+ }
+
+ // 3. DEFIANCE ESCORT
+ _startupDefiance(now, s) {
+ this._click(now, 90, 0.06, 0.6, 'square');
+ this._tone({ type: 'sawtooth', startFreq: 50, endFreq: 120, startTime: now + 0.05 * s, duration: 0.6 * s, peakVol: 0.4, filterType: 'lowpass', filterFreq: 300 });
+ this._sub(now + 0.65 * s, 35, 95, 1.1 * s, 0.7);
+ this._tone({ type: 'sawtooth', startFreq: 180, endFreq: 740, startTime: now + 0.7 * s, duration: 1.1 * s, peakVol: 0.32, filterType: 'bandpass', filterFreq: 550, filterQ: 3.0 });
+ this._chime(now + 1.8 * s, [660, 880], 0.08 * s, 0.4 * s, 0.35);
+ this._sub(now + 1.8 * s, 95, 68, 0.7 * s, 0.5);
+ }
+ _shutdownDefiance(now, s) {
+ this._click(now, 110, 0.05, 0.6);
+ this._sub(now, 90, 35, 0.6 * s, 0.65);
+ this._tone({ type: 'sawtooth', startFreq: 720, endFreq: 60, startTime: now + 0.6 * s, duration: 1.2 * s, peakVol: 0.3, filterType: 'lowpass', filterFreq: 450 });
+ this._noise({ noiseType: 'pink', filterType: 'lowpass', startFreq: 500, endFreq: 120, startTime: now + 0.7 * s, duration: 1.0 * s, peakVol: 0.3 });
+ this._tone({ type: 'sine', startFreq: 60, endFreq: 22, startTime: now + 1.7 * s, duration: 0.8 * s, peakVol: 0.35 });
+ }
+
+ // 4. CARDASSIAN / DS9
+ _startupCardassian(now, s) {
+ this._click(now, 85, 0.08, 0.55);
+ this._tone({ type: 'triangle', startFreq: 165, endFreq: 110, startTime: now, duration: 0.7 * s, peakVol: 0.4 });
+ this._sub(now + 0.7 * s, 32, 68, 1.1 * s, 0.65);
+ this._tone({ type: 'sawtooth', startFreq: 80, endFreq: 260, startTime: now + 0.7 * s, duration: 1.1 * s, peakVol: 0.3, filterType: 'lowpass', filterFreq: 320 });
+ this._noise({ noiseType: 'brown', filterType: 'lowpass', startFreq: 180, endFreq: 350, startTime: now + 0.8 * s, duration: 1.0 * s, peakVol: 0.3 });
+ this._chime(now + 1.8 * s, [330, 440], 0.1 * s, 0.5 * s, 0.35);
+ }
+ _shutdownCardassian(now, s) {
+ this._click(now, 95, 0.06, 0.5);
+ this._sub(now, 68, 30, 0.6 * s, 0.6);
+ this._tone({ type: 'sawtooth', startFreq: 250, endFreq: 40, startTime: now + 0.6 * s, duration: 1.2 * s, peakVol: 0.25, filterType: 'lowpass', filterFreq: 220 });
+ this._noise({ noiseType: 'brown', filterType: 'bandpass', startFreq: 240, endFreq: 80, startTime: now + 0.7 * s, duration: 1.1 * s, peakVol: 0.25 });
+ this._tone({ type: 'sine', startFreq: 40, endFreq: 20, startTime: now + 1.7 * s, duration: 0.8 * s, peakVol: 0.3 });
+ }
+
+ // 5. TOS 1960s
+ _startupTOS(now, s) {
+ this._click(now, 220, 0.03, 0.45);
+ this._tone({ type: 'sine', startFreq: 80, endFreq: 180, startTime: now + 0.04 * s, duration: 0.6 * s, peakVol: 0.35 });
+ this._tone({ type: 'sine', startFreq: 220, endFreq: 980, startTime: now + 0.65 * s, duration: 1.15 * s, peakVol: 0.32 });
+ this._tone({ type: 'triangle', startFreq: 225, endFreq: 990, startTime: now + 0.65 * s, duration: 1.15 * s, peakVol: 0.2 });
+ this._chime(now + 1.8 * s, [880, 1108], 0.08 * s, 0.35 * s, 0.3);
+ }
+ _shutdownTOS(now, s) {
+ this._click(now, 260, 0.035, 0.45);
+ this._tone({ type: 'sine', startFreq: 950, endFreq: 90, startTime: now + 0.5 * s, duration: 1.2 * s, peakVol: 0.3 });
+ this._tone({ type: 'sine', startFreq: 2400, endFreq: 400, startTime: now + 0.6 * s, duration: 0.8 * s, peakVol: 0.15 });
+ this._tone({ type: 'sine', startFreq: 90, endFreq: 25, startTime: now + 1.7 * s, duration: 0.8 * s, peakVol: 0.25 });
+ }
+
+ // 6. NX-01 PROTOTYPE
+ _startupNX(now, s) {
+ [0, 0.12, 0.24, 0.36, 0.48].forEach(dt => this._click(now + dt * s, 110, 0.04, 0.45));
+ this._tone({ type: 'sawtooth', startFreq: 95, endFreq: 420, startTime: now + 0.6 * s, duration: 1.2 * s, peakVol: 0.32, filterType: 'lowpass', filterFreq: 600 });
+ this._sub(now + 0.8 * s, 45, 75, 1.0 * s, 0.6);
+ this._noise({ noiseType: 'brown', filterType: 'lowpass', startFreq: 220, endFreq: 480, startTime: now + 0.85 * s, duration: 0.95 * s, peakVol: 0.3 });
+ this._click(now + 1.8 * s, 180, 0.03, 0.4);
+ }
+ _shutdownNX(now, s) {
+ this._click(now, 95, 0.06, 0.55);
+ this._sub(now, 75, 35, 0.6 * s, 0.6);
+ [0.6, 0.85, 1.15, 1.5].forEach(dt => this._click(now + dt * s, 85, 0.04, 0.35));
+ this._noise({ noiseType: 'pink', filterType: 'highpass', startFreq: 900, endFreq: 300, startTime: now + 0.7 * s, duration: 1.0 * s, peakVol: 0.25 });
+ this._tone({ type: 'sine', startFreq: 40, endFreq: 18, startTime: now + 1.7 * s, duration: 0.8 * s, peakVol: 0.3 });
+ }
+
+ // 7. TARDIX DIMENSIONAL
+ _startupTARDIX(now, s) {
+ this._click(now, 80, 0.06, 0.55);
+ this._tone({ type: 'sawtooth', startFreq: 140, endFreq: 680, startTime: now + 0.5 * s, duration: 1.3 * s, peakVol: 0.32, filterType: 'bandpass', filterFreq: 550, filterQ: 3.0 });
+ this._tone({ type: 'sine', startFreq: 70, endFreq: 220, startTime: now + 0.6 * s, duration: 1.2 * s, peakVol: 0.35 });
+ this._chime(now + 1.8 * s, [1046, 1318, 1567], 0.06 * s, 0.45 * s, 0.3);
+ }
+ _shutdownTARDIX(now, s) {
+ this._click(now, 90, 0.05, 0.5);
+ this._tone({ type: 'sawtooth', startFreq: 620, endFreq: 75, startTime: now + 0.5 * s, duration: 1.3 * s, peakVol: 0.28, filterType: 'bandpass', filterFreq: 350, filterQ: 2.0 });
+ this._chime(now + 0.8 * s, [440], 0.1, 0.7 * s, 0.3);
+ this._noise({ noiseType: 'pink', filterType: 'lowpass', startFreq: 350, endFreq: 80, startTime: now + 1.6 * s, duration: 0.9 * s, peakVol: 0.2 });
+ }
+
+ // 8. INDUSTRIAL FREIGHTER
+ _startupIndustrial(now, s) {
+ this._click(now, 75, 0.08, 0.6);
+ this._noise({ noiseType: 'white', filterType: 'highpass', startFreq: 1800, endFreq: 800, startTime: now + 0.05 * s, duration: 0.4 * s, peakVol: 0.35 });
+ this._tone({ type: 'sawtooth', startFreq: 55, endFreq: 290, startTime: now + 0.6 * s, duration: 1.2 * s, peakVol: 0.35, filterType: 'lowpass', filterFreq: 400 });
+ this._sub(now + 0.7 * s, 35, 65, 1.1 * s, 0.7);
+ this._noise({ noiseType: 'brown', filterType: 'bandpass', startFreq: 400, endFreq: 900, startTime: now + 0.8 * s, duration: 1.0 * s, peakVol: 0.3 });
+ this._sub(now + 1.8 * s, 65, 40, 0.7 * s, 0.6);
+ }
+ _shutdownIndustrial(now, s) {
+ this._click(now, 85, 0.07, 0.65);
+ this._sub(now, 65, 28, 0.6 * s, 0.6);
+ this._noise({ noiseType: 'white', filterType: 'lowpass', startFreq: 2400, endFreq: 400, startTime: now + 0.5 * s, duration: 1.3 * s, peakVol: 0.38 });
+ this._tone({ type: 'sawtooth', startFreq: 280, endFreq: 40, startTime: now + 0.6 * s, duration: 1.2 * s, peakVol: 0.25, filterType: 'lowpass', filterFreq: 300 });
+ [1.7, 1.95, 2.2].forEach(dt => this._click(now + dt * s, 320, 0.02, 0.25));
+ }
+
+ // 9. BIOSHIP
+ _startupBioship(now, s) {
+ this._chime(now, [784, 1174, 1567], 0.04 * s, 0.3 * s, 0.25);
+ this._sub(now + 0.6 * s, 42, 28, 0.4 * s, 0.65);
+ this._sub(now + 0.95 * s, 44, 28, 0.4 * s, 0.7);
+ this._tone({ type: 'sine', startFreq: 260, endFreq: 540, startTime: now + 0.7 * s, duration: 1.1 * s, peakVol: 0.32 });
+ this._noise({ noiseType: 'pink', filterType: 'bandpass', startFreq: 250, endFreq: 600, startTime: now + 1.7 * s, duration: 0.8 * s, peakVol: 0.3 });
+ }
+ _shutdownBioship(now, s) {
+ this._noise({ noiseType: 'pink', filterType: 'bandpass', startFreq: 550, endFreq: 180, startTime: now, duration: 1.1 * s, peakVol: 0.35 });
+ this._sub(now + 0.5 * s, 38, 25, 0.4 * s, 0.55);
+ this._sub(now + 1.1 * s, 34, 22, 0.4 * s, 0.45);
+ this._tone({ type: 'sine', startFreq: 480, endFreq: 180, startTime: now + 0.6 * s, duration: 1.1 * s, peakVol: 0.22 });
+ this._tone({ type: 'sine', startFreq: 35, endFreq: 18, startTime: now + 1.7 * s, duration: 0.8 * s, peakVol: 0.25 });
+ }
+
+ // 10. RETROFUTURE
+ _startupRetrofuture(now, s) {
+ [0, 0.07, 0.14, 0.21].forEach(dt => this._click(now + dt * s, 380, 0.02, 0.3));
+ this._tone({ type: 'triangle', startFreq: 95, endFreq: 580, startTime: now + 0.5 * s, duration: 1.3 * s, peakVol: 0.35, filterType: 'lowpass', filterFreq: 650 });
+ this._tone({ type: 'sine', startFreq: 100, endFreq: 590, startTime: now + 0.5 * s, duration: 1.3 * s, peakVol: 0.25 });
+ this._chime(now + 1.8 * s, [920], 0.1, 0.5 * s, 0.35);
+ }
+ _shutdownRetrofuture(now, s) {
+ this._click(now, 140, 0.05, 0.55);
+ this._tone({ type: 'triangle', startFreq: 550, endFreq: 65, startTime: now + 0.5 * s, duration: 1.2 * s, peakVol: 0.28 });
+ this._noise({ noiseType: 'pink', filterType: 'bandpass', startFreq: 450, endFreq: 150, startTime: now + 0.6 * s, duration: 1.1 * s, peakVol: 0.22 });
+ this._tone({ type: 'sine', startFreq: 3200, endFreq: 120, startTime: now + 1.7 * s, duration: 0.7 * s, peakVol: 0.2 });
+ }
+
+ // 11. MILITARY
+ _startupMilitary(now, s) {
+ this._click(now, 120, 0.06, 0.6);
+ this._tone({ type: 'square', startFreq: 60, endFreq: 120, startTime: now, duration: 0.6 * s, peakVol: 0.35, filterType: 'lowpass', filterFreq: 250 });
+ this._tone({ type: 'sawtooth', startFreq: 140, endFreq: 880, startTime: now + 0.6 * s, duration: 1.2 * s, peakVol: 0.35, filterType: 'bandpass', filterFreq: 750, filterQ: 3.0 });
+ this._sub(now + 0.7 * s, 40, 85, 1.1 * s, 0.65);
+ this._chime(now + 1.8 * s, [750, 750], 0.08 * s, 0.35 * s, 0.35);
+ }
+ _shutdownMilitary(now, s) {
+ this._click(now, 150, 0.04, 0.5);
+ this._chime(now + 0.05 * s, [880], 0.1, 0.3 * s, 0.35);
+ this._tone({ type: 'sawtooth', startFreq: 850, endFreq: 70, startTime: now + 0.5 * s, duration: 1.3 * s, peakVol: 0.28, filterType: 'lowpass', filterFreq: 500 });
+ this._noise({ noiseType: 'pink', filterType: 'lowpass', startFreq: 600, endFreq: 140, startTime: now + 0.6 * s, duration: 1.2 * s, peakVol: 0.28 });
+ this._sub(now + 1.7 * s, 70, 25, 0.8 * s, 0.4);
+ }
+
+ // 12. DEEP SPACE
+ _startupDeepSpace(now, s) {
+ this._noise({ noiseType: 'white', filterType: 'bandpass', startFreq: 2400, endFreq: 1200, startTime: now, duration: 0.65 * s, peakVol: 0.32 });
+ this._sub(now + 0.7 * s, 28, 68, 1.1 * s, 0.7);
+ this._tone({ type: 'sine', startFreq: 110, endFreq: 280, startTime: now + 0.75 * s, duration: 1.1 * s, peakVol: 0.3 });
+ this._chime(now + 1.8 * s, [440, 554], 0.1 * s, 0.5 * s, 0.25);
+ }
+ _shutdownDeepSpace(now, s) {
+ this._sub(now, 68, 25, 0.7 * s, 0.65);
+ this._tone({ type: 'sine', startFreq: 260, endFreq: 50, startTime: now + 0.6 * s, duration: 1.2 * s, peakVol: 0.25 });
+ this._noise({ noiseType: 'brown', filterType: 'lowpass', startFreq: 200, endFreq: 40, startTime: now + 0.7 * s, duration: 1.1 * s, peakVol: 0.3 });
+ this._sub(now + 1.7 * s, 35, 15, 0.8 * s, 0.25);
+ }
+
+ // 13. OUTLAW
+ _startupOutlaw(now, s) {
+ [0, 0.1, 0.22, 0.32, 0.44].forEach(dt => this._click(now + dt * s, 130 + Math.random() * 40, 0.035, 0.4));
+ this._tone({ type: 'sawtooth', startFreq: 70, endFreq: 130, startTime: now + 0.1 * s, duration: 0.5 * s, peakVol: 0.3, filterType: 'lowpass', filterFreq: 280 });
+ this._tone({ type: 'sawtooth', startFreq: 180, endFreq: 1150, startTime: now + 0.65 * s, duration: 1.15 * s, peakVol: 0.35, filterType: 'bandpass', filterFreq: 900, filterQ: 4.0 });
+ this._sub(now + 0.7 * s, 45, 80, 1.1 * s, 0.65);
+ this._noise({ noiseType: 'white', filterType: 'highpass', startFreq: 2800, endFreq: 1500, startTime: now + 1.8 * s, duration: 0.3 * s, peakVol: 0.35 });
+ }
+ _shutdownOutlaw(now, s) {
+ this._click(now, 70, 0.08, 0.7, 'sawtooth');
+ this._sub(now, 90, 40, 0.4 * s, 0.7);
+ [0.5, 0.8, 1.15, 1.55].forEach(dt => this._click(now + dt * s, 90, 0.04, 0.35));
+ this._tone({ type: 'sawtooth', startFreq: 750, endFreq: 55, startTime: now + 0.5 * s, duration: 1.3 * s, peakVol: 0.25, filterType: 'lowpass', filterFreq: 350 });
+ this._noise({ noiseType: 'pink', filterType: 'lowpass', startFreq: 350, endFreq: 80, startTime: now + 1.7 * s, duration: 0.8 * s, peakVol: 0.25 });
+ }
+
+ // 14. STATION
+ _startupStation(now, s) {
+ this._chime(now, [587, 880], 0.08 * s, 0.35 * s, 0.3);
+ this._click(now + 0.15 * s, 110, 0.05, 0.45);
+ this._sub(now + 0.6 * s, 30, 65, 1.2 * s, 0.7);
+ this._tone({ type: 'sawtooth', startFreq: 70, endFreq: 240, startTime: now + 0.65 * s, duration: 1.15 * s, peakVol: 0.28, filterType: 'lowpass', filterFreq: 280 });
+ this._noise({ noiseType: 'pink', filterType: 'lowpass', startFreq: 200, endFreq: 500, startTime: now + 0.8 * s, duration: 1.0 * s, peakVol: 0.25 });
+ this._click(now + 1.8 * s, 85, 0.06, 0.5);
+ }
+ _shutdownStation(now, s) {
+ this._click(now, 105, 0.05, 0.5);
+ this._sub(now + 0.5 * s, 65, 25, 1.4 * s, 0.55);
+ this._tone({ type: 'sawtooth', startFreq: 220, endFreq: 45, startTime: now + 0.55 * s, duration: 1.3 * s, peakVol: 0.22, filterType: 'lowpass', filterFreq: 200 });
+ this._noise({ noiseType: 'pink', filterType: 'lowpass', startFreq: 280, endFreq: 90, startTime: now + 1.6 * s, duration: 0.9 * s, peakVol: 0.2 });
+ }
+
+ // 15. COMEDY
+ _startupComedy(now, s) {
+ this._chime(now, [330, 440, 554, 659], 0.07 * s, 0.35 * s, 0.32);
+ this._tone({ type: 'sawtooth', startFreq: 110, endFreq: 1400, startTime: now + 0.55 * s, duration: 1.25 * s, peakVol: 0.3, filterType: 'bandpass', filterFreq: 800, filterQ: 3.5 });
+ this._sub(now + 0.7 * s, 40, 75, 1.1 * s, 0.5);
+ this._chime(now + 1.8 * s, [1200], 0.1, 0.5 * s, 0.4);
+ }
+ _shutdownComedy(now, s) {
+ this._tone({ type: 'sine', startFreq: 920, endFreq: 140, startTime: now, duration: 0.85 * s, peakVol: 0.32 });
+ [0.7, 0.82, 0.96, 1.12].forEach(dt => this._click(now + dt * s, 420 + Math.random() * 200, 0.025, 0.3));
+ this._noise({ noiseType: 'white', filterType: 'bandpass', startFreq: 300, endFreq: 100, startTime: now + 1.6 * s, duration: 0.6 * s, peakVol: 0.25 });
+ }
+}
+
+window.EngineTransitionSynth = EngineTransitionSynth;
diff --git a/test-fixtures/reference-exhibits/scifi/js/config.js b/test-fixtures/reference-exhibits/scifi/js/config.js
new file mode 100644
index 0000000..64202c9
--- /dev/null
+++ b/test-fixtures/reference-exhibits/scifi/js/config.js
@@ -0,0 +1,1170 @@
+const StarshipPresets = {
+ // === TNG GALAXY CLASS ===
+ 'tng-bridge': {
+ id: 'tng-bridge',
+ name: 'Interprise-G: Main Bridge',
+ era: 'tng',
+ theme: 'lcars-tng',
+ alertType: 'tng',
+ description: 'Warm, low-frequency 50Hz hull tone with gentle ventilation and soft LCARD computer chirps.',
+ hull: { volume: 0.65, baseFreq: 50, filterCutoff: 105, resonance: 2.2, noiseMix: 0.4, harmonicSpread: 1.015 },
+ warp: { volume: 0.35, bpm: 48, carrierFreq: 58, filterCutoff: 150, pulseShape: 'tng', resonance: 2.8, swirlMix: 0.25 },
+ lifeSupport: { volume: 0.55, noiseType: 'pink', highpassFreq: 180, lowpassFreq: 1600, airflowModSpeed: 0.12, airflowModDepth: 0.1 },
+ telemetry: { volume: 0.45, density: 0.65, era: 'tng' }
+ },
+
+ 'tng-engineering': {
+ id: 'tng-engineering',
+ name: 'Interprise-G: Main Engineering',
+ era: 'tng',
+ theme: 'lcars-tng',
+ alertType: 'tng',
+ description: 'Prominent, mesmerizing 4-stage worp core pulse with intense matter-antimatter reactor resonance.',
+ hull: { volume: 0.55, baseFreq: 54, filterCutoff: 130, resonance: 3.0, noiseMix: 0.45, harmonicSpread: 1.02 },
+ warp: { volume: 0.95, bpm: 48, carrierFreq: 62, filterCutoff: 220, pulseShape: 'tng', resonance: 4.2, swirlMix: 0.45 },
+ lifeSupport: { volume: 0.4, noiseType: 'pink', highpassFreq: 220, lowpassFreq: 1400, airflowModSpeed: 0.15, airflowModDepth: 0.08 },
+ telemetry: { volume: 0.3, density: 0.35, era: 'tng' }
+ },
+
+ 'tng-quarters': {
+ id: 'tng-quarters',
+ name: 'Interprise-G: Crew Quarters',
+ era: 'tng',
+ theme: 'lcars-tng',
+ alertType: 'tng',
+ description: 'Cozy, muffled acoustic damping designed for relaxation and deep sleep with soothing low engine rumble.',
+ hull: { volume: 0.7, baseFreq: 46, filterCutoff: 80, resonance: 1.6, noiseMix: 0.5, harmonicSpread: 1.01 },
+ warp: { volume: 0.2, bpm: 44, carrierFreq: 52, filterCutoff: 110, pulseShape: 'tng', resonance: 2.0, swirlMix: 0.1 },
+ lifeSupport: { volume: 0.65, noiseType: 'pink', highpassFreq: 140, lowpassFreq: 1100, airflowModSpeed: 0.08, airflowModDepth: 0.15 },
+ telemetry: { volume: 0.0, density: 0.0, era: 'tng' }
+ },
+
+ // === WAYFARER INTREPID CLASS ===
+ 'voyager-bridge': {
+ id: 'voyager-bridge',
+ name: 'USS Wayfarer: Bridge',
+ era: 'voyager',
+ theme: 'lcars-voyager',
+ alertType: 'tng',
+ description: 'Crisper environmental airflow, high-clarity telemetry sweeps, and modern streamlined acoustics.',
+ hull: { volume: 0.55, baseFreq: 58, filterCutoff: 120, resonance: 2.0, noiseMix: 0.35, harmonicSpread: 1.025 },
+ warp: { volume: 0.4, bpm: 68, carrierFreq: 70, filterCutoff: 190, pulseShape: 'voyager', resonance: 3.5, swirlMix: 0.3 },
+ lifeSupport: { volume: 0.6, noiseType: 'white', highpassFreq: 240, lowpassFreq: 2400, airflowModSpeed: 0.18, airflowModDepth: 0.12 },
+ telemetry: { volume: 0.5, density: 0.75, era: 'voyager' }
+ },
+
+ 'voyager-engineering': {
+ id: 'voyager-engineering',
+ name: 'USS Wayfarer: Class-N Worp Core',
+ era: 'voyager',
+ theme: 'lcars-voyager',
+ alertType: 'tng',
+ description: 'High-tempo, sharp magnetic pulse cycle characteristic of variable-geometry warp propulsion.',
+ hull: { volume: 0.5, baseFreq: 64, filterCutoff: 140, resonance: 2.8, noiseMix: 0.4, harmonicSpread: 1.03 },
+ warp: { volume: 0.95, bpm: 72, carrierFreq: 76, filterCutoff: 260, pulseShape: 'voyager', resonance: 4.8, swirlMix: 0.5 },
+ lifeSupport: { volume: 0.45, noiseType: 'white', highpassFreq: 260, lowpassFreq: 2100, airflowModSpeed: 0.2, airflowModDepth: 0.1 },
+ telemetry: { volume: 0.35, density: 0.4, era: 'voyager' }
+ },
+
+ // === DS9 DEFIANCE CLASS ===
+ 'defiant-bridge': {
+ id: 'defiant-bridge',
+ name: 'USS Defiance: Tactical Bridge',
+ era: 'ds9',
+ theme: 'lcars-defiant',
+ alertType: 'tng',
+ description: 'Tight, aggressive warship rumble with high-output pulse engine harmonics and military console telemetry.',
+ hull: { volume: 0.75, baseFreq: 62, filterCutoff: 150, resonance: 3.5, noiseMix: 0.55, harmonicSpread: 1.04 },
+ warp: { volume: 0.85, bpm: 60, carrierFreq: 82, filterCutoff: 240, pulseShape: 'defiant', resonance: 4.0, swirlMix: 0.35 },
+ lifeSupport: { volume: 0.5, noiseType: 'pink', highpassFreq: 200, lowpassFreq: 1900, airflowModSpeed: 0.22, airflowModDepth: 0.14 },
+ telemetry: { volume: 0.4, density: 0.6, era: 'ds9' }
+ },
+
+ // === DEEP SPACE 8 STATION ===
+ 'ds9-ops': {
+ id: 'ds9-ops',
+ name: 'Deep Space 8: Ops Center',
+ era: 'ds9',
+ theme: 'lcars-ds9',
+ alertType: 'tng',
+ description: 'Cavernous industrial station ambience with deep ore-processing vibration and Cardassian sensor pings.',
+ hull: { volume: 0.7, baseFreq: 42, filterCutoff: 95, resonance: 2.0, noiseMix: 0.6, harmonicSpread: 1.01 },
+ warp: { volume: 0.3, bpm: 36, carrierFreq: 48, filterCutoff: 120, pulseShape: 'nx', resonance: 2.2, swirlMix: 0.2 },
+ lifeSupport: { volume: 0.65, noiseType: 'brown', highpassFreq: 120, lowpassFreq: 1300, airflowModSpeed: 0.09, airflowModDepth: 0.18 },
+ telemetry: { volume: 0.45, density: 0.55, era: 'ds9' }
+ },
+
+ // === TOS ORIGINAL SERIES ===
+ 'tos-bridge': {
+ id: 'tos-bridge',
+ name: 'Interprise NCC-1701.5: Bridge (TOS)',
+ era: 'tos',
+ theme: 'tos-retro',
+ alertType: 'tos',
+ description: 'Classic 1960s electronic oscillator chatter, telemetry clicks, and vintage relay background drones.',
+ hull: { volume: 0.5, baseFreq: 75, filterCutoff: 160, resonance: 3.2, noiseMix: 0.3, harmonicSpread: 1.05 },
+ warp: { volume: 0.45, bpm: 54, carrierFreq: 110, filterCutoff: 280, pulseShape: 'tos', resonance: 3.8, swirlMix: 0.2 },
+ lifeSupport: { volume: 0.45, noiseType: 'pink', highpassFreq: 250, lowpassFreq: 2800, airflowModSpeed: 0.1, airflowModDepth: 0.05 },
+ telemetry: { volume: 0.6, density: 0.8, era: 'tos' }
+ },
+
+ 'tos-engineering': {
+ id: 'tos-engineering',
+ name: 'Interprise NCC-1701.5: Engineering (TOS)',
+ era: 'tos',
+ theme: 'tos-retro',
+ alertType: 'tos',
+ description: 'Rhythmic electromechanical generator drone, raw motor whine, and energized conduits.',
+ hull: { volume: 0.6, baseFreq: 88, filterCutoff: 220, resonance: 4.0, noiseMix: 0.35, harmonicSpread: 1.06 },
+ warp: { volume: 0.88, bpm: 58, carrierFreq: 132, filterCutoff: 380, pulseShape: 'tos', resonance: 4.5, swirlMix: 0.3 },
+ lifeSupport: { volume: 0.35, noiseType: 'pink', highpassFreq: 280, lowpassFreq: 2200, airflowModSpeed: 0.1, airflowModDepth: 0.05 },
+ telemetry: { volume: 0.25, density: 0.3, era: 'tos' }
+ },
+
+ // === INTERPRISE NS-01 ===
+ 'nx-bridge': {
+ id: 'nx-bridge',
+ name: 'Interprise NS-01: Command Bridge',
+ era: 'nx',
+ theme: 'nx-industrial',
+ alertType: 'movie',
+ description: 'Submarine-like mechanical pumps, heavy fan ventilation, and early tactile solenoid switchgear.',
+ hull: { volume: 0.7, baseFreq: 48, filterCutoff: 110, resonance: 2.6, noiseMix: 0.55, harmonicSpread: 1.03 },
+ warp: { volume: 0.6, bpm: 42, carrierFreq: 65, filterCutoff: 170, pulseShape: 'nx', resonance: 3.2, swirlMix: 0.2 },
+ lifeSupport: { volume: 0.7, noiseType: 'brown', highpassFreq: 150, lowpassFreq: 1800, airflowModSpeed: 0.16, airflowModDepth: 0.2 },
+ telemetry: { volume: 0.45, density: 0.5, era: 'nx' }
+ }
+};
+
+window.StarshipPresets = StarshipPresets;
+
+
+/**
+ * Extensible Multi-Universe Registry & Comprehensive Sci-Fi Profiles Matrix
+ * 10 Sci-Fi Universes with 55+ canonical starships, bioships, stations, and consoles.
+ */
+
+const UniverseRegistry = {
+ // =========================================================================
+ // 1. STARFLIGHT COMMAND (Star Trek)
+ // =========================================================================
+ 'starfleet': {
+ id: 'starfleet',
+ name: 'STARFLIGHT COMMAND',
+ shortCode: 'STARFLIGHT',
+ icon: '★',
+ themeClass: 'theme-lcars-tng',
+ headerTitle: 'STARFLIGHT SOUND MATRIX v2.9',
+ visualizer: 'intermix-cascade',
+ events: [
+ { id: 'btn-warp-jump', label: 'WARP JUMP', type: 'warp' },
+ { id: 'btn-red-alert', label: 'RED ALERT', type: 'alert-red' },
+ { id: 'btn-yellow-alert', label: 'YELLOW ALERT', type: 'alert-yellow' }
+ ],
+ soundboard: [
+ { id: 'btn-comm-badge', label: 'COM BADGE CHIRP' },
+ { id: 'btn-door-swish', label: 'DOOR PNEUMATIC SWISH' },
+ { id: 'btn-bosun-whistle', label: "BOSUN'S PIPE WHISTLE" },
+ { id: 'btn-medical-monitor', label: 'MEDBAY ECG MONITOR' },
+ { id: 'btn-cardassian-door', label: 'CARDASSIAN BULKHEAD DOOR' },
+ { id: 'btn-turbolift', label: 'TURBOCHUTE WHOOSH' },
+ { id: 'btn-replicator', label: 'FABRICATOR SHIMMER' },
+ { id: 'btn-transporter', label: 'TRANSLOCATOR CYCLE' },
+ { id: 'btn-chirp-single', label: 'LCARD SINGLE CHIRP' }
+ ],
+ presets: StarshipPresets
+ },
+
+ // =========================================================================
+ // 2. THE WHATAVERSE (Doctor Who TARDIX)
+ // =========================================================================
+ 'whataverse': {
+ id: 'whataverse',
+ name: 'THE WHATAVERSE',
+ shortCode: 'T.A.R.D.I.X.',
+ icon: '⌛',
+ themeClass: 'theme-whataverse-tardix',
+ headerTitle: 'TYPE 4D TT CAPSULE MATRIX',
+ visualizer: 'time-rotor',
+ events: [
+ { id: 'btn-demat', label: 'DEMATERIALIZE', type: 'demat' },
+ { id: 'btn-cloister', label: 'CLOISTER CHIME', type: 'cloister' },
+ { id: 'btn-vortex', label: 'VORTEX FLIGHT', type: 'vortex' }
+ ],
+ soundboard: [
+ { id: 'btn-sonic', label: 'ACOUSTIC SCREWDRIVER' },
+ { id: 'btn-lever-clunk', label: 'LEVER-THROW CLUNK' },
+ { id: 'btn-tardix-door', label: 'POLICE BOX WOODEN DOOR' },
+ { id: 'btn-fast-return', label: 'FAST-RETURN LEVER' },
+ { id: 'btn-demat-switch', label: 'DEMAT SWITCH' },
+ { id: 'btn-telepathic', label: 'TELEPATHIC CHIME' },
+ { id: 'btn-scanner-activate', label: 'SCANNER ACTIVATION' },
+ { id: 'btn-type-clatter', label: 'CONSOLE TYPE CLATTER' }
+ ],
+ presets: {
+ 'tardix-1963': {
+ id: 'tardix-1963',
+ name: '1962 Classic Console (1st / 2nd Doctors)',
+ era: 'classic',
+ theme: 'whataverse-tardix',
+ alertType: 'cloister',
+ description: 'Sterile white room tone, high-frequency 2-tone oscillating rotor pulse, and mechanical telephone relays.',
+ hull: { volume: 0.5, baseFreq: 68, filterCutoff: 150, resonance: 2.8, noiseMix: 0.35, harmonicSpread: 1.04 },
+ warp: { volume: 0.65, bpm: 52, carrierFreq: 96, filterCutoff: 210, pulseShape: 'tos', resonance: 3.5, swirlMix: 0.2 },
+ lifeSupport: { volume: 0.45, noiseType: 'pink', highpassFreq: 220, lowpassFreq: 1800, airflowModSpeed: 0.08, airflowModDepth: 0.06 },
+ telemetry: { volume: 0.55, density: 0.6, era: 'whataverse' }
+ },
+ 'tardix-victorian': {
+ id: 'tardix-victorian',
+ name: 'Edwardian Secondary Console (4th Doctor)',
+ era: 'classic',
+ theme: 'whataverse-tardix',
+ alertType: 'cloister',
+ description: 'Warm wood-paneled room acoustic damping, rhythmic ticking clockwork, quiet fire hearth, and brass gears.',
+ hull: { volume: 0.65, baseFreq: 44, filterCutoff: 85, resonance: 1.8, noiseMix: 0.45, harmonicSpread: 1.015 },
+ warp: { volume: 0.3, bpm: 40, carrierFreq: 50, filterCutoff: 120, pulseShape: 'nx', resonance: 2.0, swirlMix: 0.15 },
+ lifeSupport: { volume: 0.55, noiseType: 'brown', highpassFreq: 110, lowpassFreq: 1200, airflowModSpeed: 0.07, airflowModDepth: 0.12 },
+ telemetry: { volume: 0.35, density: 0.4, era: 'whataverse' }
+ },
+ 'tardix-coral': {
+ id: 'tardix-coral',
+ name: 'The Coral Living TARDIX (9th / 10th Doctors)',
+ era: 'revival',
+ theme: 'whataverse-tardix',
+ alertType: 'cloister',
+ description: 'Deep organic heartbeat thrum, bicycle pump squeaks, rattling mechanics, and steaming thermal exhaust.',
+ hull: { volume: 0.75, baseFreq: 52, filterCutoff: 135, resonance: 3.2, noiseMix: 0.5, harmonicSpread: 1.03 },
+ warp: { volume: 0.9, bpm: 48, carrierFreq: 64, filterCutoff: 230, pulseShape: 'tng', resonance: 4.0, swirlMix: 0.45 },
+ lifeSupport: { volume: 0.6, noiseType: 'pink', highpassFreq: 160, lowpassFreq: 1700, airflowModSpeed: 0.18, airflowModDepth: 0.15 },
+ telemetry: { volume: 0.45, density: 0.5, era: 'whataverse' }
+ },
+ 'tardix-copper': {
+ id: 'tardix-copper',
+ name: 'The Bronze Workshop (11th Doctor)',
+ era: 'revival',
+ theme: 'whataverse-tardix',
+ alertType: 'cloister',
+ description: 'Bustling kinetic atmosphere, whirring brass gyroscopes, bubbling conduits, and typewriter key clatter.',
+ hull: { volume: 0.6, baseFreq: 56, filterCutoff: 125, resonance: 2.5, noiseMix: 0.4, harmonicSpread: 1.02 },
+ warp: { volume: 0.7, bpm: 62, carrierFreq: 72, filterCutoff: 210, pulseShape: 'voyager', resonance: 3.6, swirlMix: 0.35 },
+ lifeSupport: { volume: 0.65, noiseType: 'white', highpassFreq: 220, lowpassFreq: 2200, airflowModSpeed: 0.15, airflowModDepth: 0.1 },
+ telemetry: { volume: 0.55, density: 0.7, era: 'whataverse' }
+ },
+ 'tardix-cold-machine': {
+ id: 'tardix-cold-machine',
+ name: 'The Cool Machine (12th Doctor)',
+ era: 'revival',
+ theme: 'whataverse-tardix',
+ alertType: 'cloister',
+ description: 'Studious acoustic focus, spinning ceiling planetary rings, electric neon tube hum, and authoritative switches.',
+ hull: { volume: 0.6, baseFreq: 50, filterCutoff: 110, resonance: 2.2, noiseMix: 0.38, harmonicSpread: 1.02 },
+ warp: { volume: 0.75, bpm: 56, carrierFreq: 68, filterCutoff: 190, pulseShape: 'tng', resonance: 3.2, swirlMix: 0.3 },
+ lifeSupport: { volume: 0.5, noiseType: 'pink', highpassFreq: 200, lowpassFreq: 1600, airflowModSpeed: 0.12, airflowModDepth: 0.08 },
+ telemetry: { volume: 0.4, density: 0.5, era: 'whataverse' }
+ },
+ 'tardix-crystal': {
+ id: 'tardix-crystal',
+ name: 'The Humming Crystal Console (13th Doctor)',
+ era: 'revival',
+ theme: 'whataverse-tardix',
+ alertType: 'cloister',
+ description: 'High-resonance singing bowl quartz harmonics, tectonic subterranean rumble, and kinetic crystal crackles.',
+ hull: { volume: 0.7, baseFreq: 46, filterCutoff: 100, resonance: 3.5, noiseMix: 0.5, harmonicSpread: 1.04 },
+ warp: { volume: 0.8, bpm: 44, carrierFreq: 88, filterCutoff: 260, pulseShape: 'defiant', resonance: 4.4, swirlMix: 0.4 },
+ lifeSupport: { volume: 0.55, noiseType: 'pink', highpassFreq: 180, lowpassFreq: 1900, airflowModSpeed: 0.16, airflowModDepth: 0.14 },
+ telemetry: { volume: 0.5, density: 0.6, era: 'whataverse' }
+ },
+ 'tardix-infinite-white': {
+ id: 'tardix-infinite-white',
+ name: 'The Endless White (14th / 15th Doctors)',
+ era: 'modern',
+ theme: 'whataverse-tardix',
+ alertType: 'cloister',
+ description: 'Vast cathedral-scale spatial reverberation, pristine futuristic pulses, and smooth magnetic ramp tones.',
+ hull: { volume: 0.65, baseFreq: 48, filterCutoff: 95, resonance: 2.0, noiseMix: 0.42, harmonicSpread: 1.01 },
+ warp: { volume: 0.6, bpm: 50, carrierFreq: 60, filterCutoff: 160, pulseShape: 'tng', resonance: 2.8, swirlMix: 0.3 },
+ lifeSupport: { volume: 0.7, noiseType: 'white', highpassFreq: 160, lowpassFreq: 2600, airflowModSpeed: 0.14, airflowModDepth: 0.12 },
+ telemetry: { volume: 0.45, density: 0.6, era: 'whataverse' }
+ }
+ }
+ },
+
+ // =========================================================================
+ // 3. INDUSTRIAL SPACE (Alien, Firefly, Expanse, Red Dwarf, Space: 9999)
+ // =========================================================================
+ 'industrial': {
+ id: 'industrial',
+ name: 'INDUSTRIAL SPACE',
+ shortCode: 'INDUSTRIAL',
+ icon: '⚙',
+ themeClass: 'theme-industrial',
+ headerTitle: 'HEAVY INDUSTRIAL REFINERY & FREIGHT',
+ visualizer: 'compression-furnace',
+ events: [
+ { id: 'btn-epstein', label: 'EPSTEIN BURN', type: 'epstein' },
+ { id: 'btn-airlock-purge', label: 'PURGE AIRLOCK', type: 'purge' },
+ { id: 'btn-reactor-vent', label: 'REACTOR VENT', type: 'vent' }
+ ],
+ soundboard: [
+ { id: 'btn-geiger', label: 'GEIGER COUNTER' },
+ { id: 'btn-dock-clamp', label: 'HYDRAULIC CLAMPS' },
+ { id: 'btn-pipe-drips', label: 'CONDENSATION DRIPS' },
+ { id: 'btn-steam-vent', label: 'BOILER VENT HISS' },
+ { id: 'btn-crash-couch', label: 'CRASH-COUCH GIMBAL' },
+ { id: 'btn-air-handler', label: 'HVAC AIR HANDLER' },
+ { id: 'btn-dock-groan', label: 'DOCKING GROAN & CLANG' },
+ { id: 'btn-teletype', label: 'DOT-MATRIX TELETYPE' }
+ ],
+ presets: {
+ 'ind-nostromo': {
+ id: 'ind-nostromo',
+ name: 'USCSS Nostramo: Ore Refinery Bridge',
+ era: 'alien',
+ description: 'Cavernous Weylan-Yutaro ore hauler hum, dripping coolant pipes, M/TH/UR mainframe clicks.',
+ hull: { volume: 0.8, baseFreq: 42, filterCutoff: 95, resonance: 2.6, noiseMix: 0.6, harmonicSpread: 1.015 },
+ warp: { volume: 0.5, bpm: 36, carrierFreq: 52, filterCutoff: 130, pulseShape: 'nx', resonance: 2.8, swirlMix: 0.2 },
+ lifeSupport: { volume: 0.65, noiseType: 'brown', highpassFreq: 130, lowpassFreq: 1400, airflowModSpeed: 0.1, airflowModDepth: 0.16 },
+ telemetry: { volume: 0.45, density: 0.45, era: 'nx' }
+ },
+ 'ind-serenity': {
+ id: 'ind-serenity',
+ name: 'Serenety: Firewing-Class Cargo Hold',
+ era: 'firefly',
+ description: 'Compression coil gravitational thrum, loose vibrating structural bolts, and warm engine chug.',
+ hull: { volume: 0.7, baseFreq: 48, filterCutoff: 110, resonance: 2.2, noiseMix: 0.5, harmonicSpread: 1.025 },
+ warp: { volume: 0.75, bpm: 44, carrierFreq: 64, filterCutoff: 180, pulseShape: 'nx', resonance: 3.5, swirlMix: 0.3 },
+ lifeSupport: { volume: 0.55, noiseType: 'pink', highpassFreq: 160, lowpassFreq: 1600, airflowModSpeed: 0.14, airflowModDepth: 0.12 },
+ telemetry: { volume: 0.35, density: 0.35, era: 'tos' }
+ },
+ 'ind-red-dwarf': {
+ id: 'ind-red-dwarf',
+ name: 'Red Dwarve: Main Drive Corridor',
+ era: 'classic-scifi',
+ description: 'Immense hollow mining vessel ramscoop drone, cavernous reverberation, vending machine whine.',
+ hull: { volume: 0.85, baseFreq: 36, filterCutoff: 80, resonance: 2.0, noiseMix: 0.65, harmonicSpread: 1.01 },
+ warp: { volume: 0.4, bpm: 30, carrierFreq: 46, filterCutoff: 110, pulseShape: 'nx', resonance: 2.2, swirlMix: 0.15 },
+ lifeSupport: { volume: 0.6, noiseType: 'brown', highpassFreq: 110, lowpassFreq: 1200, airflowModSpeed: 0.08, airflowModDepth: 0.18 },
+ telemetry: { volume: 0.25, density: 0.2, era: 'nx' }
+ },
+ 'ind-starbug': {
+ id: 'ind-starbug',
+ name: 'Starbug Z: Cockpit Environment',
+ era: 'classic-scifi',
+ description: 'Buzzing thruster harmonics, claustrophobic air vents, and rattling pilot console switchgear.',
+ hull: { volume: 0.65, baseFreq: 76, filterCutoff: 170, resonance: 3.2, noiseMix: 0.45, harmonicSpread: 1.04 },
+ warp: { volume: 0.6, bpm: 64, carrierFreq: 90, filterCutoff: 220, pulseShape: 'tos', resonance: 3.6, swirlMix: 0.25 },
+ lifeSupport: { volume: 0.5, noiseType: 'pink', highpassFreq: 220, lowpassFreq: 2200, airflowModSpeed: 0.2, airflowModDepth: 0.14 },
+ telemetry: { volume: 0.5, density: 0.55, era: 'tos' }
+ },
+ 'ind-raza': {
+ id: 'ind-raza',
+ name: 'The Rasa: Dark Matter Bridge',
+ era: 'dark-matter',
+ description: 'Dark, tense military-grade FTL engine hum with quiet tactical corridor acoustics.',
+ hull: { volume: 0.7, baseFreq: 54, filterCutoff: 120, resonance: 2.8, noiseMix: 0.48, harmonicSpread: 1.02 },
+ warp: { volume: 0.65, bpm: 50, carrierFreq: 70, filterCutoff: 190, pulseShape: 'defiant', resonance: 3.4, swirlMix: 0.3 },
+ lifeSupport: { volume: 0.55, noiseType: 'pink', highpassFreq: 180, lowpassFreq: 1800, airflowModSpeed: 0.12, airflowModDepth: 0.1 },
+ telemetry: { volume: 0.4, density: 0.45, era: 'ds9' }
+ },
+ 'ind-rocinante': {
+ id: 'ind-rocinante',
+ name: 'Rocinanta: Combat Ops & Epsilon Drive',
+ era: 'expanse',
+ description: 'Epstein fusion drive magnetic whine, high-output cooling, and Point Defense Cannon telemetry.',
+ hull: { volume: 0.8, baseFreq: 64, filterCutoff: 145, resonance: 3.4, noiseMix: 0.55, harmonicSpread: 1.035 },
+ warp: { volume: 0.9, bpm: 58, carrierFreq: 84, filterCutoff: 250, pulseShape: 'defiant', resonance: 4.2, swirlMix: 0.4 },
+ lifeSupport: { volume: 0.6, noiseType: 'white', highpassFreq: 200, lowpassFreq: 2100, airflowModSpeed: 0.18, airflowModDepth: 0.14 },
+ telemetry: { volume: 0.5, density: 0.75, era: 'belter' }
+ },
+ 'ind-eagle': {
+ id: 'ind-eagle',
+ name: 'Hawk Translocator: Command Module',
+ era: 'space1999',
+ description: 'Nuclear fusion thruster hum, retro 1970s solenoid relays, and lunar reconnaissance telemetry.',
+ hull: { volume: 0.6, baseFreq: 70, filterCutoff: 160, resonance: 3.0, noiseMix: 0.4, harmonicSpread: 1.03 },
+ warp: { volume: 0.55, bpm: 54, carrierFreq: 88, filterCutoff: 210, pulseShape: 'tos', resonance: 3.2, swirlMix: 0.2 },
+ lifeSupport: { volume: 0.55, noiseType: 'pink', highpassFreq: 210, lowpassFreq: 2000, airflowModSpeed: 0.15, airflowModDepth: 0.1 },
+ telemetry: { volume: 0.55, density: 0.6, era: 'tos' }
+ },
+ 'ind-valley-forge': {
+ id: 'ind-valley-forge',
+ name: 'Valley Foundry: Agro-Dome Forest Hub',
+ era: 'silent-running',
+ description: 'Geodesic greenhouse dome ventilation, slow drone servo whirrs, and deep sub-light propulsion.',
+ hull: { volume: 0.65, baseFreq: 46, filterCutoff: 100, resonance: 2.2, noiseMix: 0.5, harmonicSpread: 1.02 },
+ warp: { volume: 0.4, bpm: 38, carrierFreq: 56, filterCutoff: 130, pulseShape: 'nx', resonance: 2.5, swirlMix: 0.2 },
+ lifeSupport: { volume: 0.7, noiseType: 'pink', highpassFreq: 140, lowpassFreq: 1500, airflowModSpeed: 0.1, airflowModDepth: 0.15 },
+ telemetry: { volume: 0.35, density: 0.3, era: 'nx' }
+ }
+ }
+ },
+
+ // =========================================================================
+ // 4. BIOSHIPS (Living Starships & Symbiotes)
+ // =========================================================================
+ 'bioships': {
+ id: 'bioships',
+ name: 'BIOSHIPS',
+ shortCode: 'BIOSHIPS',
+ icon: '🧬',
+ themeClass: 'theme-bioships',
+ headerTitle: 'ORGANIC LEVIATHON & SYMBIOTIC PROPULSION',
+ visualizer: 'bio-heart',
+ events: [
+ { id: 'btn-starburst', label: 'STARBURST', type: 'starburst' },
+ { id: 'btn-neural-pulse', label: 'NEURAL PULSE', type: 'neural' },
+ { id: 'btn-bio-defense', label: 'BIO-DEFENSE BURST', type: 'biodefense' }
+ ],
+ soundboard: [
+ { id: 'btn-neural-bond', label: 'NEURAL-BOND SWELL' },
+ { id: 'btn-fast-return', label: 'VASCULAR PUMP' },
+ { id: 'btn-demat-switch', label: 'CHITIN CREAK' },
+ { id: 'btn-chirp-sweep', label: 'BIOPLASMIC HISS' },
+ { id: 'btn-vorlon-song', label: 'VORLAN SINGING RESONANCE' },
+ { id: 'btn-hull-regen', label: 'HULL REGEN TEXTURE' }
+ ],
+ presets: {
+ 'bio-moya': {
+ id: 'bio-moya',
+ name: 'Meya: Leviathon Central Nexus',
+ era: 'farscape',
+ description: 'Organic living Leviathon heartbeat, pulsating vascular fluid circulation, and Pilot neural link.',
+ hull: { volume: 0.75, baseFreq: 45, filterCutoff: 110, resonance: 3.2, noiseMix: 0.5, harmonicSpread: 1.03 },
+ warp: { volume: 0.85, bpm: 38, carrierFreq: 58, filterCutoff: 180, pulseShape: 'tng', resonance: 4.2, swirlMix: 0.45 },
+ lifeSupport: { volume: 0.65, noiseType: 'pink', highpassFreq: 150, lowpassFreq: 1600, airflowModSpeed: 0.08, airflowModDepth: 0.2 },
+ telemetry: { volume: 0.4, density: 0.4, era: 'bioship' }
+ },
+ 'bio-talyn': {
+ id: 'bio-talyn',
+ name: 'Talym: Gunship Neural Bridge',
+ era: 'farscape',
+ description: 'Young, aggressive warship Leviathon bio-heartbeat with tense, high-output combat bio-harmonics.',
+ hull: { volume: 0.8, baseFreq: 58, filterCutoff: 140, resonance: 3.6, noiseMix: 0.52, harmonicSpread: 1.04 },
+ warp: { volume: 0.9, bpm: 64, carrierFreq: 76, filterCutoff: 240, pulseShape: 'defiant', resonance: 4.6, swirlMix: 0.4 },
+ lifeSupport: { volume: 0.55, noiseType: 'pink', highpassFreq: 180, lowpassFreq: 1800, airflowModSpeed: 0.16, airflowModDepth: 0.16 },
+ telemetry: { volume: 0.45, density: 0.5, era: 'bioship' }
+ },
+ 'bio-lexx': {
+ id: 'bio-lexx',
+ name: 'The Lexxi: Primary Organ Bridge',
+ era: 'lexx',
+ description: 'Massive biomechanical insectoid digestive engine drone, hollow chitin resonance, and organic respiration.',
+ hull: { volume: 0.85, baseFreq: 38, filterCutoff: 90, resonance: 2.8, noiseMix: 0.6, harmonicSpread: 1.02 },
+ warp: { volume: 0.7, bpm: 34, carrierFreq: 48, filterCutoff: 140, pulseShape: 'nx', resonance: 3.4, swirlMix: 0.35 },
+ lifeSupport: { volume: 0.6, noiseType: 'brown', highpassFreq: 120, lowpassFreq: 1300, airflowModSpeed: 0.06, airflowModDepth: 0.22 },
+ telemetry: { volume: 0.35, density: 0.4, era: 'bioship' }
+ },
+ 'bio-vorlon': {
+ id: 'bio-vorlon',
+ name: 'Vorlan Cruiser: Sentient Core',
+ era: 'babylon5',
+ description: 'Telepathic singing crystal harmonics, undulating organic field harmonics, and luminous energy breath.',
+ hull: { volume: 0.65, baseFreq: 74, filterCutoff: 160, resonance: 3.8, noiseMix: 0.35, harmonicSpread: 1.05 },
+ warp: { volume: 0.8, bpm: 52, carrierFreq: 104, filterCutoff: 260, pulseShape: 'voyager', resonance: 4.5, swirlMix: 0.5 },
+ lifeSupport: { volume: 0.5, noiseType: 'pink', highpassFreq: 220, lowpassFreq: 2200, airflowModSpeed: 0.12, airflowModDepth: 0.1 },
+ telemetry: { volume: 0.5, density: 0.6, era: 'bioship' }
+ },
+ 'bio-wraith': {
+ id: 'bio-wraith',
+ name: 'Wraithe Hive Ship: Throne Chamber',
+ era: 'stargate',
+ description: 'Groaning organic chitin hull growth, damp misty life-support atmosphere, and deep hive sub-bass.',
+ hull: { volume: 0.85, baseFreq: 34, filterCutoff: 85, resonance: 2.5, noiseMix: 0.65, harmonicSpread: 1.015 },
+ warp: { volume: 0.6, bpm: 32, carrierFreq: 44, filterCutoff: 120, pulseShape: 'nx', resonance: 3.0, swirlMix: 0.25 },
+ lifeSupport: { volume: 0.65, noiseType: 'brown', highpassFreq: 90, lowpassFreq: 900, airflowModSpeed: 0.06, airflowModDepth: 0.22 },
+ telemetry: { volume: 0.3, density: 0.4, era: 'bioship' }
+ },
+ 'bio-species-8472': {
+ id: 'bio-species-8472',
+ name: 'Species 8675309: Fluid Bioship',
+ era: 'startrek',
+ description: 'Fluidic space organic resonance, high-frequency biological firing capacitors, and pure genetic thrust.',
+ hull: { volume: 0.7, baseFreq: 68, filterCutoff: 150, resonance: 3.5, noiseMix: 0.45, harmonicSpread: 1.035 },
+ warp: { volume: 0.85, bpm: 56, carrierFreq: 88, filterCutoff: 230, pulseShape: 'defiant', resonance: 4.4, swirlMix: 0.45 },
+ lifeSupport: { volume: 0.5, noiseType: 'pink', highpassFreq: 200, lowpassFreq: 1900, airflowModSpeed: 0.14, airflowModDepth: 0.12 },
+ telemetry: { volume: 0.45, density: 0.55, era: 'bioship' }
+ }
+ }
+ },
+
+ // =========================================================================
+ // 5. RETRO FUTURE (Golden Age 1950s–1970s Sci-Fi)
+ // =========================================================================
+ 'retrofuture': {
+ id: 'retrofuture',
+ name: 'RETRO FUTURE',
+ shortCode: 'RETRO-GOLD',
+ icon: '📡',
+ themeClass: 'theme-retrofuture',
+ headerTitle: 'GOLDEN AGE SPACE EXPLORATION MATRIX',
+ visualizer: 'retro-oscilloscope',
+ events: [
+ { id: 'btn-centrifuge', label: 'CENTRIFUGE SPIN', type: 'centrifuge' },
+ { id: 'btn-astrogator', label: 'ASTROGATOR SWEEP', type: 'astrogator' },
+ { id: 'btn-hal-override', label: 'HAL 9999 OVERRIDE', type: 'hal' }
+ ],
+ soundboard: [
+ { id: 'btn-hal-breath', label: 'HAL RESPIRATION' },
+ { id: 'btn-death-blossom',label: 'DEATH BLOOM SURGE' },
+ { id: 'btn-cygnus-chug', label: 'STEAM PISTON CHUG' },
+ { id: 'btn-chirp-single', label: 'ASTROGATOR BEAT' },
+ { id: 'btn-chirp-sweep', label: 'ANALOG GLISSANDO' },
+ { id: 'btn-relay-click', label: 'TAPE REEL SPOOL' },
+ { id: 'btn-tape-spooler', label: 'DUAL-REEL TAPE SPOOLER' },
+ { id: 'btn-hetero-drone', label: 'HETERO-DRONE TONE' }
+ ],
+ presets: {
+ 'ret-jupiter-2': {
+ id: 'ret-jupiter-2',
+ name: 'Jupiter Duece: Upper Deck Astrogator',
+ era: 'retro',
+ description: 'Classic sliding tone generator, continuous astrogator warbles, and smooth nuclear fusion reactor.',
+ hull: { volume: 0.65, baseFreq: 68, filterCutoff: 160, resonance: 3.2, noiseMix: 0.35, harmonicSpread: 1.04 },
+ warp: { volume: 0.7, bpm: 52, carrierFreq: 96, filterCutoff: 230, pulseShape: 'tos', resonance: 4.0, swirlMix: 0.3 },
+ lifeSupport: { volume: 0.5, noiseType: 'pink', highpassFreq: 240, lowpassFreq: 2200, airflowModSpeed: 0.1, airflowModDepth: 0.08 },
+ telemetry: { volume: 0.65, density: 0.75, era: 'tos' }
+ },
+ 'ret-liberator': {
+ id: 'ret-liberator',
+ name: 'The Liberation: Zen Flight Bridge',
+ era: 'blakes7',
+ description: 'Zen mainframe humming, exotic alien drive field harmonics, and crystalline telepathic circuit buzz.',
+ hull: { volume: 0.6, baseFreq: 60, filterCutoff: 135, resonance: 2.8, noiseMix: 0.4, harmonicSpread: 1.025 },
+ warp: { volume: 0.65, bpm: 48, carrierFreq: 82, filterCutoff: 200, pulseShape: 'tos', resonance: 3.4, swirlMix: 0.35 },
+ lifeSupport: { volume: 0.55, noiseType: 'pink', highpassFreq: 200, lowpassFreq: 2000, airflowModSpeed: 0.12, airflowModDepth: 0.1 },
+ telemetry: { volume: 0.55, density: 0.6, era: 'tos' }
+ },
+ 'ret-cygnus': {
+ id: 'ret-cygnus',
+ name: 'USS Cygnis: Victorian Engine Hall',
+ era: 'blackhole',
+ description: 'Gargantuan Victorian iron engine room, cavernous industrial echoes, and heavy drive pistons.',
+ hull: { volume: 0.85, baseFreq: 38, filterCutoff: 90, resonance: 2.5, noiseMix: 0.6, harmonicSpread: 1.015 },
+ warp: { volume: 0.75, bpm: 36, carrierFreq: 52, filterCutoff: 140, pulseShape: 'nx', resonance: 3.2, swirlMix: 0.25 },
+ lifeSupport: { volume: 0.6, noiseType: 'brown', highpassFreq: 120, lowpassFreq: 1300, airflowModSpeed: 0.08, airflowModDepth: 0.16 },
+ telemetry: { volume: 0.35, density: 0.35, era: 'nx' }
+ },
+ 'ret-palomino': {
+ id: 'ret-palomino',
+ name: 'USS Palomina: Deep Research Pod',
+ era: 'blackhole',
+ description: 'Compact research vessel cockpit, steady life-support airflow, and navigational telemetry.',
+ hull: { volume: 0.6, baseFreq: 54, filterCutoff: 120, resonance: 2.2, noiseMix: 0.45, harmonicSpread: 1.02 },
+ warp: { volume: 0.5, bpm: 46, carrierFreq: 68, filterCutoff: 170, pulseShape: 'nx', resonance: 2.8, swirlMix: 0.2 },
+ lifeSupport: { volume: 0.6, noiseType: 'pink', highpassFreq: 180, lowpassFreq: 1700, airflowModSpeed: 0.14, airflowModDepth: 0.1 },
+ telemetry: { volume: 0.45, density: 0.5, era: 'nx' }
+ },
+ 'ret-discovery-one': {
+ id: 'ret-discovery-one',
+ name: 'Discovery Zero: Habitation Centrifuge',
+ era: '2001',
+ description: 'Smooth artificial gravity centrifuge rotation hum, HAL 9999 soft logic hum, clean breathing air.',
+ hull: { volume: 0.65, baseFreq: 46, filterCutoff: 95, resonance: 1.8, noiseMix: 0.4, harmonicSpread: 1.01 },
+ warp: { volume: 0.3, bpm: 30, carrierFreq: 50, filterCutoff: 110, pulseShape: 'tng', resonance: 2.0, swirlMix: 0.1 },
+ lifeSupport: { volume: 0.7, noiseType: 'pink', highpassFreq: 150, lowpassFreq: 1500, airflowModSpeed: 0.08, airflowModDepth: 0.14 },
+ telemetry: { volume: 0.3, density: 0.25, era: 'tng' }
+ },
+ 'ret-dark-star': {
+ id: 'ret-dark-star',
+ name: 'Dim Star: Bomb Bay & Quarters',
+ era: 'darkstar',
+ description: 'Glitchy thermonuclear bomb circuits, tired life-support fans, and whistling hydraulic lines.',
+ hull: { volume: 0.7, baseFreq: 52, filterCutoff: 115, resonance: 2.6, noiseMix: 0.55, harmonicSpread: 1.03 },
+ warp: { volume: 0.55, bpm: 42, carrierFreq: 64, filterCutoff: 160, pulseShape: 'nx', resonance: 3.0, swirlMix: 0.2 },
+ lifeSupport: { volume: 0.65, noiseType: 'brown', highpassFreq: 160, lowpassFreq: 1600, airflowModSpeed: 0.15, airflowModDepth: 0.15 },
+ telemetry: { volume: 0.45, density: 0.4, era: 'tos' }
+ },
+ 'ret-gunstar': {
+ id: 'ret-gunstar',
+ name: 'Gunstarr: Tactical Combat Cockpit',
+ era: 'starfighter',
+ description: 'Rylos starfighter turbo-thrusters, Death Bloom capacitors, and tactical computer chirps.',
+ hull: { volume: 0.7, baseFreq: 72, filterCutoff: 165, resonance: 3.4, noiseMix: 0.45, harmonicSpread: 1.035 },
+ warp: { volume: 0.75, bpm: 68, carrierFreq: 88, filterCutoff: 240, pulseShape: 'defiant', resonance: 4.2, swirlMix: 0.35 },
+ lifeSupport: { volume: 0.5, noiseType: 'white', highpassFreq: 220, lowpassFreq: 2100, airflowModSpeed: 0.18, airflowModDepth: 0.12 },
+ telemetry: { volume: 0.6, density: 0.7, era: 'voyager' }
+ },
+ 'ret-searcher': {
+ id: 'ret-searcher',
+ name: 'The Seeker: 25th Century Flagship',
+ era: 'buckrogers',
+ description: 'High-clarity navigation sweeps, smooth fusion drive purr, and crisp Directory computer tones.',
+ hull: { volume: 0.6, baseFreq: 62, filterCutoff: 140, resonance: 2.6, noiseMix: 0.38, harmonicSpread: 1.025 },
+ warp: { volume: 0.6, bpm: 54, carrierFreq: 78, filterCutoff: 210, pulseShape: 'tng', resonance: 3.2, swirlMix: 0.3 },
+ lifeSupport: { volume: 0.55, noiseType: 'pink', highpassFreq: 210, lowpassFreq: 2000, airflowModSpeed: 0.14, airflowModDepth: 0.1 },
+ telemetry: { volume: 0.55, density: 0.65, era: 'tng' }
+ }
+ }
+ },
+
+ // =========================================================================
+ // 6. MILITARY SPACE (Battlestar Galactica, Sulaco, Babylon Fleet)
+ // =========================================================================
+ 'military': {
+ id: 'military',
+ name: 'MILITARY SPACE',
+ shortCode: 'FLEET-COMBAT',
+ icon: '⚔',
+ themeClass: 'theme-military',
+ headerTitle: 'TACTICAL BATTLESHIP & COMBAT COMMAND',
+ visualizer: 'tactical-flywheel',
+ events: [
+ { id: 'btn-action-stations', label: 'ACTION STATIONS', type: 'action-stations' },
+ { id: 'btn-ftl-jump', label: 'FTL JUMP', type: 'ftl-jump' },
+ { id: 'btn-flak', label: 'FLAK BARRAGE', type: 'flak' }
+ ],
+ soundboard: [
+ { id: 'btn-dradis-ping', label: 'DRADAR SCAN PING' },
+ { id: 'btn-switch-pop', label: 'WEAPONS ARMED' },
+ { id: 'btn-dock-clamp', label: 'BULKHEAD SEAL' },
+ { id: 'btn-carousel-groan', label: 'CAROUSEL MOTOR GROAN' },
+ { id: 'btn-slipstream', label: 'SLIPSTREAM SURGE' },
+ { id: 'btn-oxygen-reg', label: 'O2 DEMAND VALVE' },
+ { id: 'btn-breech-clank', label: 'WEAPONS BREECH CLANK' }
+ ],
+ presets: {
+ 'mil-sulaco': {
+ id: 'mil-sulaco',
+ name: 'USS Solaco: Conestega Hangar Deck',
+ era: 'aliens',
+ description: 'Heavy military sub-bass thrum, massive refrigeration ducts, dropship bay hydraulics.',
+ hull: { volume: 0.8, baseFreq: 44, filterCutoff: 105, resonance: 3.0, noiseMix: 0.55, harmonicSpread: 1.02 },
+ warp: { volume: 0.6, bpm: 38, carrierFreq: 58, filterCutoff: 150, pulseShape: 'nx', resonance: 3.2, swirlMix: 0.25 },
+ lifeSupport: { volume: 0.65, noiseType: 'brown', highpassFreq: 140, lowpassFreq: 1500, airflowModSpeed: 0.1, airflowModDepth: 0.14 },
+ telemetry: { volume: 0.4, density: 0.45, era: 'nx' }
+ },
+ 'mil-galactica': {
+ id: 'mil-galactica',
+ name: 'Battlestar Galactica: Combat Information Center (CIC)',
+ era: 'bsg',
+ description: 'Armored titanium hull vibration, plotting table hum, DRADAR sonar sweeps, and distant flak rumble.',
+ hull: { volume: 0.75, baseFreq: 48, filterCutoff: 115, resonance: 2.8, noiseMix: 0.5, harmonicSpread: 1.025 },
+ warp: { volume: 0.7, bpm: 46, carrierFreq: 64, filterCutoff: 180, pulseShape: 'nx', resonance: 3.5, swirlMix: 0.3 },
+ lifeSupport: { volume: 0.6, noiseType: 'pink', highpassFreq: 170, lowpassFreq: 1700, airflowModSpeed: 0.12, airflowModDepth: 0.12 },
+ telemetry: { volume: 0.5, density: 0.55, era: 'ds9' }
+ },
+ 'mil-white-star': {
+ id: 'mil-white-star',
+ name: 'Pale Star: Minbara/Vorlan Hybrid Bridge',
+ era: 'babylon5',
+ description: 'Gravimetric drive propulsion whisper, high-frequency organic bio-armor hum, tactical agility.',
+ hull: { volume: 0.6, baseFreq: 66, filterCutoff: 145, resonance: 3.2, noiseMix: 0.35, harmonicSpread: 1.035 },
+ warp: { volume: 0.8, bpm: 58, carrierFreq: 88, filterCutoff: 230, pulseShape: 'voyager', resonance: 4.2, swirlMix: 0.4 },
+ lifeSupport: { volume: 0.5, noiseType: 'pink', highpassFreq: 210, lowpassFreq: 2100, airflowModSpeed: 0.15, airflowModDepth: 0.1 },
+ telemetry: { volume: 0.5, density: 0.65, era: 'voyager' }
+ },
+ 'mil-agamemnon': {
+ id: 'mil-agamemnon',
+ name: 'EAS Agamemnon II: Omega Destroyer Bridge',
+ era: 'babylon5',
+ description: 'Rotating habitat section centrifugal vibration, plasma cannon grid capacitors, EarthForce tactical systems.',
+ hull: { volume: 0.8, baseFreq: 46, filterCutoff: 110, resonance: 3.0, noiseMix: 0.55, harmonicSpread: 1.02 },
+ warp: { volume: 0.7, bpm: 42, carrierFreq: 60, filterCutoff: 170, pulseShape: 'defiant', resonance: 3.6, swirlMix: 0.3 },
+ lifeSupport: { volume: 0.6, noiseType: 'pink', highpassFreq: 180, lowpassFreq: 1800, airflowModSpeed: 0.14, airflowModDepth: 0.12 },
+ telemetry: { volume: 0.45, density: 0.5, era: 'ds9' }
+ },
+ 'mil-andromeda': {
+ id: 'mil-andromeda',
+ name: 'Andromeda Ascending: Command Deck',
+ era: 'andromeda',
+ description: 'Slipstream drive resonance, anti-proton missile magazine servos, high-tech AI bridge acoustics.',
+ hull: { volume: 0.7, baseFreq: 56, filterCutoff: 130, resonance: 2.8, noiseMix: 0.45, harmonicSpread: 1.025 },
+ warp: { volume: 0.75, bpm: 54, carrierFreq: 76, filterCutoff: 210, pulseShape: 'tng', resonance: 3.8, swirlMix: 0.35 },
+ lifeSupport: { volume: 0.55, noiseType: 'pink', highpassFreq: 200, lowpassFreq: 1900, airflowModSpeed: 0.12, airflowModDepth: 0.1 },
+ telemetry: { volume: 0.5, density: 0.6, era: 'tng' }
+ },
+ 'mil-excalibur': {
+ id: 'mil-excalibur',
+ name: 'Excaliber: Triumph-Class Heavy Combat Core',
+ era: 'crusade',
+ description: 'Advanced hybrid gravitic engines, quantum discharge weapons capacitors, and deep hull resonance.',
+ hull: { volume: 0.8, baseFreq: 50, filterCutoff: 125, resonance: 3.4, noiseMix: 0.5, harmonicSpread: 1.03 },
+ warp: { volume: 0.85, bpm: 50, carrierFreq: 72, filterCutoff: 220, pulseShape: 'defiant', resonance: 4.0, swirlMix: 0.38 },
+ lifeSupport: { volume: 0.55, noiseType: 'pink', highpassFreq: 190, lowpassFreq: 1800, airflowModSpeed: 0.14, airflowModDepth: 0.12 },
+ telemetry: { volume: 0.45, density: 0.55, era: 'ds9' }
+ },
+ 'mil-viper': {
+ id: 'mil-viper',
+ name: 'Colonial Adder Mk II: Cockpit Atmosphere',
+ era: 'bsg',
+ description: 'High-speed air rush, twin turbo-thruster whine, pilot G-suit regulator, and DRADAR radar sweeps.',
+ hull: { volume: 0.65, baseFreq: 76, filterCutoff: 175, resonance: 3.2, noiseMix: 0.4, harmonicSpread: 1.04 },
+ warp: { volume: 0.7, bpm: 72, carrierFreq: 94, filterCutoff: 250, pulseShape: 'voyager', resonance: 4.2, swirlMix: 0.3 },
+ lifeSupport: { volume: 0.65, noiseType: 'white', highpassFreq: 240, lowpassFreq: 2600, airflowModSpeed: 0.22, airflowModDepth: 0.18 },
+ telemetry: { volume: 0.6, density: 0.7, era: 'voyager' }
+ }
+ }
+ },
+
+ // =========================================================================
+ // 7. DEEP SPACE (Generation Ships, Exploration & Eldritch Voids)
+ // =========================================================================
+ 'deepspace': {
+ id: 'deepspace',
+ name: 'DEEP SPACE',
+ shortCode: 'VOID-EXPLORE',
+ icon: '🌌',
+ themeClass: 'theme-deepspace',
+ headerTitle: 'LONG-RANGE INTERSTELLAR EXPLORATION & ANOMALIES',
+ visualizer: 'singularity-core',
+ events: [
+ { id: 'btn-singularity', label: 'SINGULARITY ENGAGE', type: 'singularity' },
+ { id: 'btn-gravity-pulse', label: 'GRAVITY PULSE', type: 'gravity' },
+ { id: 'btn-solar-swell', label: 'SOLAR SHIELD SWELL', type: 'solar' }
+ ],
+ soundboard: [
+ { id: 'btn-solar-roar', label: 'HEAT SHIELD ROAR' },
+ { id: 'btn-cryo-sigh', label: 'CRYO POD SIGH' },
+ { id: 'btn-orion-thump', label: 'ORION PULSE THUMP' },
+ { id: 'btn-hull-strain', label: 'TRUE HULL STRAIN MOAN' },
+ { id: 'btn-void-whisper',label: 'PSYCHIC VOID WHISPER' },
+ { id: 'btn-icarus-beacon', label: 'ICARUS I DISTRESS BEACON' }
+ ],
+ presets: {
+ 'deep-avalon': {
+ id: 'deep-avalon',
+ name: 'The Avalin: Interstellar Cruise Concourse',
+ era: 'passengers',
+ description: '120-year journey whisper-quiet fusion core, ion shield deflection hiss, elegant concourse acoustics.',
+ hull: { volume: 0.6, baseFreq: 44, filterCutoff: 95, resonance: 1.8, noiseMix: 0.4, harmonicSpread: 1.015 },
+ warp: { volume: 0.45, bpm: 40, carrierFreq: 54, filterCutoff: 130, pulseShape: 'tng', resonance: 2.5, swirlMix: 0.25 },
+ lifeSupport: { volume: 0.7, noiseType: 'pink', highpassFreq: 160, lowpassFreq: 1600, airflowModSpeed: 0.08, airflowModDepth: 0.12 },
+ telemetry: { volume: 0.35, density: 0.3, era: 'tng' }
+ },
+ 'deep-nightflyer': {
+ id: 'deep-nightflyer',
+ name: 'The Nightflier: Telepathic Corridor',
+ era: 'grrm',
+ description: 'Dark, haunted psychic hull vibration, distant solitary life-support, and deep cold void reverberation.',
+ hull: { volume: 0.75, baseFreq: 38, filterCutoff: 88, resonance: 2.8, noiseMix: 0.55, harmonicSpread: 1.02 },
+ warp: { volume: 0.5, bpm: 34, carrierFreq: 46, filterCutoff: 120, pulseShape: 'nx', resonance: 3.0, swirlMix: 0.2 },
+ lifeSupport: { volume: 0.55, noiseType: 'brown', highpassFreq: 120, lowpassFreq: 1300, airflowModSpeed: 0.07, airflowModDepth: 0.16 },
+ telemetry: { volume: 0.3, density: 0.25, era: 'ds9' }
+ },
+ 'deep-ascension': {
+ id: 'deep-ascension',
+ name: 'USS Ascention: Generation Ship Promenade',
+ era: 'ascension',
+ description: 'Mid-century generation vessel mechanical flywheels, centrifugal gravity hum, and air scrubbers.',
+ hull: { volume: 0.7, baseFreq: 48, filterCutoff: 105, resonance: 2.2, noiseMix: 0.48, harmonicSpread: 1.02 },
+ warp: { volume: 0.4, bpm: 36, carrierFreq: 56, filterCutoff: 135, pulseShape: 'nx', resonance: 2.6, swirlMix: 0.2 },
+ lifeSupport: { volume: 0.65, noiseType: 'pink', highpassFreq: 150, lowpassFreq: 1500, airflowModSpeed: 0.1, airflowModDepth: 0.14 },
+ telemetry: { volume: 0.35, density: 0.3, era: 'nx' }
+ },
+ 'deep-ark-one': {
+ id: 'deep-ark-one',
+ name: 'Ark Zero: Evacuation Ark Bridge',
+ era: 'theark',
+ description: 'Structural stress fatigue hum, recycling filtration loops, and automated life-support telemetry.',
+ hull: { volume: 0.7, baseFreq: 52, filterCutoff: 115, resonance: 2.5, noiseMix: 0.5, harmonicSpread: 1.025 },
+ warp: { volume: 0.5, bpm: 42, carrierFreq: 62, filterCutoff: 160, pulseShape: 'tng', resonance: 2.8, swirlMix: 0.2 },
+ lifeSupport: { volume: 0.6, noiseType: 'pink', highpassFreq: 170, lowpassFreq: 1700, airflowModSpeed: 0.12, airflowModDepth: 0.12 },
+ telemetry: { volume: 0.4, density: 0.4, era: 'voyager' }
+ },
+ 'deep-icarus-ii': {
+ id: 'deep-icarus-ii',
+ name: 'Icarus I: Solar Shield Core',
+ era: 'sunshine',
+ description: 'Massive golden heat shield thermal expansion creaks, solar wind roar, and silent mainframe.',
+ hull: { volume: 0.8, baseFreq: 40, filterCutoff: 95, resonance: 3.0, noiseMix: 0.6, harmonicSpread: 1.02 },
+ warp: { volume: 0.5, bpm: 32, carrierFreq: 48, filterCutoff: 125, pulseShape: 'nx', resonance: 3.2, swirlMix: 0.2 },
+ lifeSupport: { volume: 0.65, noiseType: 'brown', highpassFreq: 130, lowpassFreq: 1400, airflowModSpeed: 0.08, airflowModDepth: 0.18 },
+ telemetry: { volume: 0.35, density: 0.3, era: 'tng' }
+ },
+ 'deep-event-horizon': {
+ id: 'deep-event-horizon',
+ name: 'Event Boundary: Gravity Singularity Core',
+ era: 'eventhorizon',
+ description: 'Gravity drive singularity pulsation, extradimensional groaning hull, and deep chilling void drone.',
+ hull: { volume: 0.88, baseFreq: 32, filterCutoff: 80, resonance: 3.5, noiseMix: 0.65, harmonicSpread: 1.015 },
+ warp: { volume: 0.85, bpm: 28, carrierFreq: 42, filterCutoff: 130, pulseShape: 'nx', resonance: 4.2, swirlMix: 0.35 },
+ lifeSupport: { volume: 0.55, noiseType: 'brown', highpassFreq: 100, lowpassFreq: 1100, airflowModSpeed: 0.06, airflowModDepth: 0.22 },
+ telemetry: { volume: 0.3, density: 0.25, era: 'nx' }
+ },
+ 'deep-lewis-clark': {
+ id: 'deep-lewis-clark',
+ name: 'Lewis & Clarke: Rescue Cutter Bridge',
+ era: 'eventhorizon',
+ description: 'Compact search and rescue vessel propulsion, tight pressurized air scrubbers, and emergency comms.',
+ hull: { volume: 0.65, baseFreq: 56, filterCutoff: 125, resonance: 2.4, noiseMix: 0.48, harmonicSpread: 1.025 },
+ warp: { volume: 0.55, bpm: 48, carrierFreq: 68, filterCutoff: 175, pulseShape: 'defiant', resonance: 3.0, swirlMix: 0.25 },
+ lifeSupport: { volume: 0.6, noiseType: 'pink', highpassFreq: 180, lowpassFreq: 1800, airflowModSpeed: 0.14, airflowModDepth: 0.12 },
+ telemetry: { volume: 0.4, density: 0.4, era: 'ds9' }
+ }
+ }
+ },
+
+ // =========================================================================
+ // 8. OUTLAW / FRONTIER (Cowboy Bebop, Milano, Outlaw Star, Firefly)
+ // =========================================================================
+ 'outlaw': {
+ id: 'outlaw',
+ name: 'OUTLAW / FRONTIER',
+ shortCode: 'OUTLAW',
+ icon: '🚀',
+ themeClass: 'theme-outlaw',
+ headerTitle: 'BOUNTY HUNTERS, SMUGGLERS & SALVAGERS',
+ visualizer: 'patchwork-drive',
+ events: [
+ { id: 'btn-afterburner', label: 'AFTERBURNER ENGAGE', type: 'afterburner' },
+ { id: 'btn-pirate-scramble', label: 'PIRATE SCRAMBLE', type: 'scramble' },
+ { id: 'btn-sub-drive', label: 'SUB-DRIVE SWELL', type: 'subdrive' }
+ ],
+ soundboard: [
+ { id: 'btn-tape-clunk', label: 'CASSETTE CLUNK & WHIRR' },
+ { id: 'btn-grappler-servo', label: 'GRAPPLE SYSTEM SERVO MOTOR' },
+ { id: 'btn-radio-static', label: 'RF SQUELCH STATIC BURST' },
+ { id: 'btn-repulsor-drone', label: 'REPELLER LIFT DRONE' },
+ { id: 'btn-bebop-chug', label: 'MARINE DIESEL CHUG' },
+ { id: 'btn-afterburner-snd',label: 'AFTERBURNER BOOST' }
+ ],
+ presets: {
+ 'out-betty': {
+ id: 'out-betty',
+ name: 'The Betsy: Salvage Freighter Mess',
+ era: 'alien4',
+ description: 'Smuggler ship hydraulic clutches, fuel line clatter, pirate radio static, and greasy engine rumblings.',
+ hull: { volume: 0.75, baseFreq: 50, filterCutoff: 115, resonance: 2.8, noiseMix: 0.55, harmonicSpread: 1.03 },
+ warp: { volume: 0.7, bpm: 46, carrierFreq: 64, filterCutoff: 180, pulseShape: 'nx', resonance: 3.4, swirlMix: 0.3 },
+ lifeSupport: { volume: 0.6, noiseType: 'brown', highpassFreq: 150, lowpassFreq: 1500, airflowModSpeed: 0.12, airflowModDepth: 0.15 },
+ telemetry: { volume: 0.4, density: 0.35, era: 'nx' }
+ },
+ 'out-bebop': {
+ id: 'out-bebop',
+ name: 'BeBoppa: Living Quarters & Hangar',
+ era: 'cowboybebop',
+ description: 'Converted fishing trawler engine drone, lounge room resonance, and vintage vacuum tube telemetry.',
+ hull: { volume: 0.7, baseFreq: 46, filterCutoff: 105, resonance: 2.4, noiseMix: 0.48, harmonicSpread: 1.02 },
+ warp: { volume: 0.55, bpm: 42, carrierFreq: 58, filterCutoff: 150, pulseShape: 'nx', resonance: 2.8, swirlMix: 0.25 },
+ lifeSupport: { volume: 0.6, noiseType: 'pink', highpassFreq: 160, lowpassFreq: 1600, airflowModSpeed: 0.1, airflowModDepth: 0.12 },
+ telemetry: { volume: 0.35, density: 0.3, era: 'tos' }
+ },
+ 'out-outlaw-star': {
+ id: 'out-outlaw-star',
+ name: 'Outlaw Sun: Grapple Bridge',
+ era: 'outlawstar',
+ description: 'Grapple System ship sub-drive hum, caster capacitor whine, and high-energy combat bridge acoustics.',
+ hull: { volume: 0.7, baseFreq: 64, filterCutoff: 150, resonance: 3.2, noiseMix: 0.45, harmonicSpread: 1.035 },
+ warp: { volume: 0.75, bpm: 60, carrierFreq: 82, filterCutoff: 230, pulseShape: 'defiant', resonance: 4.0, swirlMix: 0.35 },
+ lifeSupport: { volume: 0.55, noiseType: 'pink', highpassFreq: 200, lowpassFreq: 2000, airflowModSpeed: 0.16, airflowModDepth: 0.14 },
+ telemetry: { volume: 0.5, density: 0.6, era: 'voyager' }
+ },
+ 'out-scorpio': {
+ id: 'out-scorpio',
+ name: 'Scorpius: Wanderer Salvage Vessel',
+ era: 'blakes7',
+ description: 'Grimy salvage vessel wanderer sub-light drive, overheating power packs, and rattling cooling lines.',
+ hull: { volume: 0.75, baseFreq: 52, filterCutoff: 120, resonance: 2.6, noiseMix: 0.5, harmonicSpread: 1.025 },
+ warp: { volume: 0.6, bpm: 46, carrierFreq: 68, filterCutoff: 175, pulseShape: 'tos', resonance: 3.2, swirlMix: 0.25 },
+ lifeSupport: { volume: 0.55, noiseType: 'pink', highpassFreq: 180, lowpassFreq: 1800, airflowModSpeed: 0.12, airflowModDepth: 0.12 },
+ telemetry: { volume: 0.45, density: 0.45, era: 'tos' }
+ },
+ 'out-marauder': {
+ id: 'out-marauder',
+ name: 'The Raider: Havoc Shuttle Bridge',
+ era: 'starwars',
+ description: 'Stripped shuttle sub-light propulsion, modified hyperdrive coils, and mercenary comm chatter.',
+ hull: { volume: 0.65, baseFreq: 60, filterCutoff: 135, resonance: 2.8, noiseMix: 0.42, harmonicSpread: 1.03 },
+ warp: { volume: 0.7, bpm: 52, carrierFreq: 78, filterCutoff: 200, pulseShape: 'defiant', resonance: 3.6, swirlMix: 0.3 },
+ lifeSupport: { volume: 0.55, noiseType: 'pink', highpassFreq: 190, lowpassFreq: 1900, airflowModSpeed: 0.14, airflowModDepth: 0.1 },
+ telemetry: { volume: 0.45, density: 0.5, era: 'ds9' }
+ },
+ 'out-milano': {
+ id: 'out-milano',
+ name: 'The Milan: M-Ship Cockpit Lounge',
+ era: 'guardians',
+ description: 'Dual atmospheric jet-burners, cassette player noise floor, kinetic thrusters, and playful telemetry.',
+ hull: { volume: 0.65, baseFreq: 62, filterCutoff: 140, resonance: 3.0, noiseMix: 0.4, harmonicSpread: 1.03 },
+ warp: { volume: 0.7, bpm: 64, carrierFreq: 84, filterCutoff: 220, pulseShape: 'voyager', resonance: 3.8, swirlMix: 0.35 },
+ lifeSupport: { volume: 0.6, noiseType: 'pink', highpassFreq: 210, lowpassFreq: 2200, airflowModSpeed: 0.16, airflowModDepth: 0.12 },
+ telemetry: { volume: 0.55, density: 0.65, era: 'voyager' }
+ }
+ }
+ },
+
+ // =========================================================================
+ // 9. SPACE STATIONS (Orbital Habitats, Megastructures & Outposts)
+ // =========================================================================
+ 'spacestations': {
+ id: 'spacestations',
+ name: 'SPACE STATIONS',
+ shortCode: 'STATIONS',
+ icon: '🛰',
+ themeClass: 'theme-spacestations',
+ headerTitle: 'ORBITAL HABITATS & COLONIAL OUTPOSTS',
+ visualizer: 'station-hub',
+ events: [
+ { id: 'btn-station-rotation', label: 'STATION ROTATION DWELL', type: 'rotation' },
+ { id: 'btn-docking-seq', label: 'DOCKING SEQUENCE', type: 'docking' },
+ { id: 'btn-depressurize', label: 'AIRLOCK DECOMPRESSION', type: 'decompress' }
+ ],
+ soundboard: [
+ { id: 'btn-air-handler', label: 'AIR HANDLER THUD' },
+ { id: 'btn-mag-boot', label: 'MAG-BOOT LATCH' },
+ { id: 'btn-commlock', label: 'COMLOCK CALLING TONE' },
+ { id: 'btn-sevas-spark', label: 'CONDUIT SPARK ARC' },
+ { id: 'btn-station-ecg', label: 'CLINICAL VITAL (ECG)' },
+ { id: 'btn-zocalo-chatter', label: 'ZOCOLO PLAZA CHATTER' },
+ { id: 'btn-belter-telemetry', label: 'BELTWORKER JURY-RIG TELEMETRY', span: 2 }
+ ],
+ presets: {
+ 'sta-babylon-5': {
+ id: 'sta-babylon-5',
+ name: 'Babylon 4: Core Control & Zocolo',
+ era: 'babylon5',
+ description: '5-mile O\'Neill cylinder centrifugal rotation rumble, distant commerce hub chatter, and docking bay drones.',
+ hull: { volume: 0.8, baseFreq: 40, filterCutoff: 95, resonance: 2.5, noiseMix: 0.6, harmonicSpread: 1.015 },
+ warp: { volume: 0.45, bpm: 34, carrierFreq: 50, filterCutoff: 130, pulseShape: 'nx', resonance: 2.8, swirlMix: 0.25 },
+ lifeSupport: { volume: 0.7, noiseType: 'pink', highpassFreq: 140, lowpassFreq: 1600, airflowModSpeed: 0.08, airflowModDepth: 0.16 },
+ telemetry: { volume: 0.45, density: 0.45, era: 'ds9' }
+ },
+ 'sta-moonbase-alpha': {
+ id: 'sta-moonbase-alpha',
+ name: 'Moonbase Beta: Main Mission Control',
+ era: 'space1999',
+ description: 'Subterranean lunar complex life-support, Main Mission desk communications, and nuclear power plant hum.',
+ hull: { volume: 0.65, baseFreq: 54, filterCutoff: 125, resonance: 2.6, noiseMix: 0.42, harmonicSpread: 1.02 },
+ warp: { volume: 0.4, bpm: 44, carrierFreq: 64, filterCutoff: 160, pulseShape: 'tos', resonance: 2.8, swirlMix: 0.2 },
+ lifeSupport: { volume: 0.6, noiseType: 'pink', highpassFreq: 180, lowpassFreq: 1800, airflowModSpeed: 0.1, airflowModDepth: 0.12 },
+ telemetry: { volume: 0.55, density: 0.6, era: 'tos' }
+ },
+ 'sta-sevastopol': {
+ id: 'sta-sevastopol',
+ name: 'Sebastopol Station: Habitation Deck',
+ era: 'alien-isolation',
+ description: 'Decaying orbital freeport, flickering conduits, distant tramway rumble, and automated emergency comms.',
+ hull: { volume: 0.85, baseFreq: 44, filterCutoff: 100, resonance: 3.0, noiseMix: 0.65, harmonicSpread: 1.02 },
+ warp: { volume: 0.5, bpm: 36, carrierFreq: 52, filterCutoff: 140, pulseShape: 'nx', resonance: 3.2, swirlMix: 0.25 },
+ lifeSupport: { volume: 0.6, noiseType: 'brown', highpassFreq: 130, lowpassFreq: 1400, airflowModSpeed: 0.1, airflowModDepth: 0.18 },
+ telemetry: { volume: 0.4, density: 0.35, era: 'nx' }
+ },
+ 'sta-tycho': {
+ id: 'sta-tycho',
+ name: 'Tyco Station: Asteroid Construction Bay',
+ era: 'expanse',
+ description: 'Massive spinning hollow asteroid dock, robotic construction gantries, and high-pressure plasma welding hum.',
+ hull: { volume: 0.8, baseFreq: 48, filterCutoff: 110, resonance: 2.8, noiseMix: 0.55, harmonicSpread: 1.025 },
+ warp: { volume: 0.65, bpm: 42, carrierFreq: 66, filterCutoff: 180, pulseShape: 'defiant', resonance: 3.5, swirlMix: 0.3 },
+ lifeSupport: { volume: 0.65, noiseType: 'pink', highpassFreq: 160, lowpassFreq: 1700, airflowModSpeed: 0.12, airflowModDepth: 0.14 },
+ telemetry: { volume: 0.45, density: 0.65, era: 'belter' }
+ },
+ 'sta-ceres': {
+ id: 'sta-ceres',
+ name: 'Ceres Minor Station: Sub-Crustal Tunnels',
+ era: 'expanse',
+ description: 'Subterranean spin-gravity rumble, ice-processing plant vibration, and Beltworker station ventilation ducts.',
+ hull: { volume: 0.85, baseFreq: 42, filterCutoff: 95, resonance: 2.6, noiseMix: 0.62, harmonicSpread: 1.02 },
+ warp: { volume: 0.45, bpm: 38, carrierFreq: 54, filterCutoff: 135, pulseShape: 'nx', resonance: 3.0, swirlMix: 0.2 },
+ lifeSupport: { volume: 0.7, noiseType: 'brown', highpassFreq: 120, lowpassFreq: 1400, airflowModSpeed: 0.08, airflowModDepth: 0.16 },
+ telemetry: { volume: 0.35, density: 0.6, era: 'belter' }
+ },
+ 'sta-gateway': {
+ id: 'sta-gateway',
+ name: 'Gatehouse Station: Quarantine Transfer Deck',
+ era: 'aliens',
+ description: 'Earth orbital transfer habitat, vacuum dockyard beacons, and high-volume environmental air handlers.',
+ hull: { volume: 0.7, baseFreq: 46, filterCutoff: 105, resonance: 2.2, noiseMix: 0.5, harmonicSpread: 1.02 },
+ warp: { volume: 0.4, bpm: 40, carrierFreq: 58, filterCutoff: 145, pulseShape: 'nx', resonance: 2.6, swirlMix: 0.2 },
+ lifeSupport: { volume: 0.65, noiseType: 'pink', highpassFreq: 160, lowpassFreq: 1600, airflowModSpeed: 0.1, airflowModDepth: 0.14 },
+ telemetry: { volume: 0.45, density: 0.45, era: 'tng' }
+ }
+ }
+ },
+
+ // =========================================================================
+ // 10. COMEDY / ADVENTURE (The Orville, Galaxy Quest, Hitchhiker's, Spaceballs)
+ // =========================================================================
+ 'comedy': {
+ id: 'comedy',
+ name: 'COMEDY / ADVENTURE',
+ shortCode: 'ADVENTURE',
+ icon: '✨',
+ themeClass: 'theme-comedy',
+ headerTitle: 'WHIMSICAL & RETRO SCI-FI EXPLORATION',
+ visualizer: 'improbability-engine',
+ events: [
+ { id: 'btn-ludicrous', label: 'RIDICULOUS SPEED', type: 'ludicrous' },
+ { id: 'btn-improbability', label: 'IMPROBABILITY FLIP', type: 'improbability' },
+ { id: 'btn-quantum-jump', label: 'QUANTUM JUMP', type: 'quantum' }
+ ],
+ soundboard: [
+ { id: 'btn-cheerful-door', label: 'CHEERFUL DOOR SIGH' },
+ { id: 'btn-tea-machine', label: 'TEA DISPENSER GURGLE' },
+ { id: 'btn-kettle-whistle', label: 'KETTLE STEAM WHISTLE' },
+ { id: 'btn-plaid-alarm', label: 'LUDICROUS PLAID ALARM' },
+ { id: 'btn-beryllium-thrum',label: 'BERYLLIUM ORB THRUM' },
+ { id: 'btn-omega-13', label: 'OMEGA-12+1 CAPACITOR WHINE' }
+ ],
+ presets: {
+ 'com-orville': {
+ id: 'com-orville',
+ name: 'USS Norval: Command Bridge',
+ era: 'orville',
+ description: 'Clean, uplifting quantum drive hum, gentle ventilation, and cheerful synthesizer console chirps.',
+ hull: { volume: 0.6, baseFreq: 58, filterCutoff: 130, resonance: 2.4, noiseMix: 0.35, harmonicSpread: 1.025 },
+ warp: { volume: 0.65, bpm: 52, carrierFreq: 74, filterCutoff: 200, pulseShape: 'tng', resonance: 3.4, swirlMix: 0.3 },
+ lifeSupport: { volume: 0.6, noiseType: 'pink', highpassFreq: 200, lowpassFreq: 1900, airflowModSpeed: 0.12, airflowModDepth: 0.1 },
+ telemetry: { volume: 0.5, density: 0.65, era: 'tng' }
+ },
+ 'com-protector': {
+ id: 'com-protector',
+ name: 'NSEA Protractor: Beryllium Orb Engine Room',
+ era: 'galaxyquest',
+ description: 'Beryllium Orb reactor thrum, Omega-12+1 capacitor charge, and earnest digital bridge beeps.',
+ hull: { volume: 0.7, baseFreq: 64, filterCutoff: 145, resonance: 3.2, noiseMix: 0.45, harmonicSpread: 1.035 },
+ warp: { volume: 0.8, bpm: 56, carrierFreq: 82, filterCutoff: 230, pulseShape: 'defiant', resonance: 4.2, swirlMix: 0.35 },
+ lifeSupport: { volume: 0.55, noiseType: 'pink', highpassFreq: 220, lowpassFreq: 2100, airflowModSpeed: 0.14, airflowModDepth: 0.12 },
+ telemetry: { volume: 0.55, density: 0.6, era: 'voyager' }
+ },
+ 'com-heart-of-gold': {
+ id: 'com-heart-of-gold',
+ name: 'Heart of Silver: Unlikelihood Bridge',
+ era: 'hitchhikers',
+ description: 'Infinite Unlikelihood Drive reality-bending swooshes, smugly cheerful doors, and Eddie the computer.',
+ hull: { volume: 0.6, baseFreq: 52, filterCutoff: 120, resonance: 2.5, noiseMix: 0.4, harmonicSpread: 1.02 },
+ warp: { volume: 0.7, bpm: 60, carrierFreq: 70, filterCutoff: 210, pulseShape: 'voyager', resonance: 3.8, swirlMix: 0.4 },
+ lifeSupport: { volume: 0.65, noiseType: 'white', highpassFreq: 180, lowpassFreq: 2400, airflowModSpeed: 0.15, airflowModDepth: 0.14 },
+ telemetry: { volume: 0.5, density: 0.6, era: 'tng' }
+ },
+ 'com-camden-lock': {
+ id: 'com-camden-lock',
+ name: 'HMS Camden Key: Flight Deck & Mess',
+ era: 'hyperdrive',
+ description: 'British sub-light engine chug, electric kettle bubbling, and galactic council bureaucracy tones.',
+ hull: { volume: 0.65, baseFreq: 48, filterCutoff: 105, resonance: 2.2, noiseMix: 0.48, harmonicSpread: 1.02 },
+ warp: { volume: 0.45, bpm: 38, carrierFreq: 58, filterCutoff: 140, pulseShape: 'nx', resonance: 2.6, swirlMix: 0.2 },
+ lifeSupport: { volume: 0.6, noiseType: 'pink', highpassFreq: 160, lowpassFreq: 1600, airflowModSpeed: 0.1, airflowModDepth: 0.12 },
+ telemetry: { volume: 0.4, density: 0.4, era: 'nx' }
+ },
+ 'com-spaceball-one': {
+ id: 'com-spaceball-one',
+ name: 'Spaceorb One: Mega-Warship Bridge',
+ era: 'spaceballs',
+ description: 'Absurdly huge mega-engine roar, ridiculous speed accelerator whine, and helmet intercom static.',
+ hull: { volume: 0.85, baseFreq: 64, filterCutoff: 150, resonance: 3.5, noiseMix: 0.55, harmonicSpread: 1.04 },
+ warp: { volume: 0.9, bpm: 66, carrierFreq: 86, filterCutoff: 260, pulseShape: 'defiant', resonance: 4.4, swirlMix: 0.4 },
+ lifeSupport: { volume: 0.6, noiseType: 'pink', highpassFreq: 220, lowpassFreq: 2200, airflowModSpeed: 0.16, airflowModDepth: 0.15 },
+ telemetry: { volume: 0.5, density: 0.55, era: 'tos' }
+ }
+ }
+ }
+};
+
+window.UniverseRegistry = UniverseRegistry;
+
+// Canonical Engine Startup & Shutdown Profile Matrix (15 Archetypes mapped to 70 Presets)
+const PresetEngineProfiles = {
+ // Starflight TNG
+ 'tng-bridge': 'galaxy',
+ 'tng-engineering': 'galaxy',
+ 'tng-quarters': 'galaxy',
+ // Starflight Wayfarer
+ 'voyager-bridge': 'intrepid',
+ 'voyager-engineering': 'intrepid',
+ // Starflight Defiance
+ 'defiant-bridge': 'defiant',
+ // Starflight Cardassian / Bajoran
+ 'ds9-ops': 'cardassian',
+ // Starflight TOS
+ 'tos-bridge': 'tos',
+ 'tos-engineering': 'tos',
+ // Starflight NX
+ 'nx-bridge': 'nx',
+ // Whataverse TARDIX
+ 'tardix-1963': 'tardix',
+ 'tardix-victorian': 'tardix',
+ 'tardix-coral': 'tardix',
+ 'tardix-copper': 'tardix',
+ 'tardix-cold-machine': 'tardix',
+ 'tardix-crystal': 'tardix',
+ 'tardix-infinite-white': 'tardix',
+ // Industrial
+ 'ind-nostromo': 'industrial',
+ 'ind-serenity': 'outlaw',
+ 'ind-red-dwarf': 'industrial',
+ 'ind-starbug': 'industrial',
+ 'ind-raza': 'military',
+ 'ind-rocinante': 'military',
+ 'ind-eagle': 'retrofuture',
+ 'ind-valley-forge': 'industrial',
+ // Bioships
+ 'bio-moya': 'bioship',
+ 'bio-talyn': 'bioship',
+ 'bio-lexx': 'bioship',
+ 'bio-vorlon': 'bioship',
+ 'bio-wraith': 'bioship',
+ 'bio-species-8472': 'bioship',
+ // Retrofuture
+ 'ret-jupiter-2': 'retrofuture',
+ 'ret-liberator': 'retrofuture',
+ 'ret-cygnus': 'retrofuture',
+ 'ret-palomino': 'retrofuture',
+ 'ret-discovery-one': 'retrofuture',
+ 'ret-dark-star': 'comedy',
+ 'ret-gunstar': 'military',
+ 'ret-searcher': 'retrofuture',
+ // Military
+ 'mil-sulaco': 'military',
+ 'mil-galactica': 'military',
+ 'mil-white-star': 'military',
+ 'mil-agamemnon': 'military',
+ 'mil-andromeda': 'military',
+ 'mil-excalibur': 'military',
+ 'mil-viper': 'military',
+ // Deep Space
+ 'deep-avalon': 'deepspace',
+ 'deep-nightflyer': 'deepspace',
+ 'deep-ascension': 'deepspace',
+ 'deep-ark-one': 'deepspace',
+ 'deep-icarus-ii': 'deepspace',
+ 'deep-event-horizon': 'deepspace',
+ 'deep-lewis-clark': 'deepspace',
+ // Outlaw
+ 'out-betty': 'outlaw',
+ 'out-bebop': 'outlaw',
+ 'out-outlaw-star': 'outlaw',
+ 'out-scorpio': 'outlaw',
+ 'out-marauder': 'outlaw',
+ 'out-milano': 'outlaw',
+ // Space Stations
+ 'sta-babylon-5': 'station',
+ 'sta-moonbase-alpha': 'station',
+ 'sta-sevastopol': 'station',
+ 'sta-tycho': 'station',
+ 'sta-ceres': 'station',
+ 'sta-gateway': 'station',
+ // Comedy
+ 'com-orville': 'comedy',
+ 'com-protector': 'comedy',
+ 'com-heart-of-gold': 'comedy',
+ 'com-camden-lock': 'comedy',
+ 'com-spaceball-one': 'comedy'
+};
+
+function getEngineProfileForPreset(presetId, universeId) {
+ if (presetId && PresetEngineProfiles[presetId]) {
+ return PresetEngineProfiles[presetId];
+ }
+ const universeDefaults = {
+ starfleet: 'galaxy',
+ whataverse: 'tardix',
+ industrial: 'industrial',
+ bioships: 'bioship',
+ retrofuture: 'retrofuture',
+ military: 'military',
+ deepspace: 'deepspace',
+ outlaw: 'outlaw',
+ spacestations: 'station',
+ comedy: 'comedy'
+ };
+ return universeDefaults[universeId] || 'galaxy';
+}
+
+window.PresetEngineProfiles = PresetEngineProfiles;
+window.getEngineProfileForPreset = getEngineProfileForPreset;
+
+
+/**
+ * LCARD Audio Visualizer & Worp Core Intermix Chamber Display
+ * Renders real-time audio spectrum, waveform oscilloscope, and pulsing worp core animation.
+ */
diff --git a/test-fixtures/reference-exhibits/scifi/js/contract-adapter.js b/test-fixtures/reference-exhibits/scifi/js/contract-adapter.js
new file mode 100644
index 0000000..5be71af
--- /dev/null
+++ b/test-fixtures/reference-exhibits/scifi/js/contract-adapter.js
@@ -0,0 +1,1004 @@
+/**
+ * XZBT Contract Adapter — Specification v5.2
+ *
+ * Implements the normalized contract interface for SciFi-XZBT:
+ * - Canonical external target catalog & descriptors
+ * - Safe mutation & impulse chokepoints
+ * - State revision & monotonic event sequence
+ * - Authoritative source attribution
+ * - Host communication bridge (postMessage)
+ *
+ * Zero external dependencies. Browser & Node compatible.
+ */
+
+(function () {
+ class XZBTContractAdapter {
+ constructor(options = {}) {
+ this.product = options.product || 'SciFi-XZBT';
+ this.version = options.version || '5.2.0';
+ this.build = options.build || 'production';
+ this.contractMajor = 5;
+ this.contractMinor = 2;
+
+ this.sessionId = null;
+ this.sessionActive = false;
+ this.eventSequence = 0;
+ this.stateRevision = 0;
+ this.registryRevision = 1;
+ this.operationQueue = Promise.resolve();
+ this.eventSink = null;
+
+ // Transport hooks
+ this.onMessage = options.onMessage || (() => {});
+ this.hostOrigin = options.hostOrigin || null; // optional origin filter
+ this.trustedSources = new Set();
+
+ // Subsystem binders & state accessors supplied by app.js
+ this.bindings = options.bindings || {};
+
+ // Capabilities: audio, speech, observation, display-text
+ // NOTE: midi capability removed in Step 3.5 — MIDI integration belongs to XZBT-NGN.
+ this.capabilities = new Map([
+ ['audio', { id: 'audio', state: 'ready' }],
+ ['speech', { id: 'speech', state: 'available' }],
+ ['observation', { id: 'observation', state: 'ready' }],
+ ['display-text', { id: 'display-text', state: 'ready' }]
+ ]);
+
+ // Canonical target registry
+ this.targets = new Map();
+ this.legacyIdMap = new Map(); // internal ID -> canonical ID
+ this._initCanonicalTargets();
+
+ // Setup window listener if running in browser
+ if (typeof window !== 'undefined' && typeof window.addEventListener === 'function') {
+ this._setupWindowBridge();
+ }
+ }
+
+ _initCanonicalTargets() {
+ const reg = (target) => {
+ this.targets.set(target.id, target);
+ if (target.legacyId) {
+ this.legacyIdMap.set(target.legacyId, target.id);
+ }
+ };
+
+ // --- 1. Continuous Range Mixers & Controls (14 targets) ---
+ reg({
+ id: 'mix.master',
+ legacyId: 'master-vol',
+ label: 'Master Volume',
+ kind: 'range',
+ min: 0,
+ max: 1,
+ step: 0.01,
+ unit: 'ratio',
+ readable: true,
+ writable: true,
+ restorable: true,
+ category: 'audio',
+ requires: ['audio']
+ });
+
+ reg({
+ id: 'mix.hull.level',
+ legacyId: 'hull-vol',
+ label: 'Hull Drone Level',
+ kind: 'range',
+ min: 0,
+ max: 1,
+ step: 0.01,
+ unit: 'ratio',
+ readable: true,
+ writable: true,
+ restorable: true,
+ category: 'audio',
+ requires: ['audio']
+ });
+
+ reg({
+ id: 'mix.hull.frequency',
+ legacyId: 'hull-freq',
+ label: 'Hull Drone Frequency',
+ kind: 'range',
+ min: 30,
+ max: 120,
+ step: 1,
+ unit: 'hz',
+ readable: true,
+ writable: true,
+ restorable: true,
+ category: 'audio',
+ requires: ['audio']
+ });
+
+ reg({
+ id: 'mix.hull.cutoff',
+ legacyId: 'hull-cutoff',
+ label: 'Hull Drone Cutoff',
+ kind: 'range',
+ min: 40,
+ max: 400,
+ step: 1,
+ unit: 'hz',
+ readable: true,
+ writable: true,
+ restorable: true,
+ category: 'audio',
+ requires: ['audio']
+ });
+
+ reg({
+ id: 'mix.drive.level',
+ legacyId: 'warp-vol',
+ label: 'Drive / Core Level',
+ kind: 'range',
+ min: 0,
+ max: 1,
+ step: 0.01,
+ unit: 'ratio',
+ readable: true,
+ writable: true,
+ restorable: true,
+ category: 'audio',
+ requires: ['audio']
+ });
+
+ reg({
+ id: 'mix.drive.pulse-rate',
+ legacyId: 'warp-bpm',
+ label: 'Drive Pulse Rate',
+ kind: 'range',
+ min: 20,
+ max: 120,
+ step: 1,
+ unit: 'bpm',
+ readable: true,
+ writable: true,
+ restorable: true,
+ category: 'audio',
+ requires: ['audio']
+ });
+
+ reg({
+ id: 'mix.drive.carrier',
+ legacyId: 'warp-carrier',
+ label: 'Drive Carrier Frequency',
+ kind: 'range',
+ min: 30,
+ max: 140,
+ step: 0.5,
+ unit: 'hz',
+ readable: true,
+ writable: true,
+ restorable: true,
+ category: 'audio',
+ requires: ['audio']
+ });
+
+ reg({
+ id: 'mix.environment.level',
+ legacyId: 'air-vol',
+ label: 'Environmental Airflow Level',
+ kind: 'range',
+ min: 0,
+ max: 1,
+ step: 0.01,
+ unit: 'ratio',
+ readable: true,
+ writable: true,
+ restorable: true,
+ category: 'audio',
+ requires: ['audio']
+ });
+
+ reg({
+ id: 'mix.environment.cutoff',
+ legacyId: 'air-cutoff',
+ label: 'Environmental Filter Cutoff',
+ kind: 'range',
+ min: 500,
+ max: 4000,
+ step: 50,
+ unit: 'hz',
+ readable: true,
+ writable: true,
+ restorable: true,
+ category: 'audio',
+ requires: ['audio']
+ });
+
+ reg({
+ id: 'mix.telemetry.level',
+ legacyId: 'telemetry-vol',
+ label: 'Telemetry Level',
+ kind: 'range',
+ min: 0,
+ max: 1,
+ step: 0.01,
+ unit: 'ratio',
+ readable: true,
+ writable: true,
+ restorable: true,
+ category: 'audio',
+ requires: ['audio']
+ });
+
+ reg({
+ id: 'mix.telemetry.density',
+ legacyId: 'telemetry-density',
+ label: 'Telemetry Density',
+ kind: 'range',
+ min: 0,
+ max: 1,
+ step: 0.05,
+ unit: 'ratio',
+ readable: true,
+ writable: true,
+ restorable: true,
+ category: 'audio',
+ requires: ['audio']
+ });
+
+ reg({
+ id: 'speech.robot-amount',
+ legacyId: 'ai-robot-amount',
+ label: 'Speech Robotic Modulation',
+ kind: 'range',
+ min: 0,
+ max: 1,
+ step: 0.01,
+ unit: 'ratio',
+ readable: true,
+ writable: true,
+ restorable: true,
+ category: 'speech',
+ requires: ['speech']
+ });
+
+ reg({
+ id: 'fnc.level',
+ legacyId: 'fnc-level',
+ label: 'Futuristic Noise Cancellation Level',
+ kind: 'range',
+ min: 0,
+ max: 1,
+ step: 0.01,
+ unit: 'ratio',
+ readable: true,
+ writable: true,
+ restorable: true,
+ category: 'audio',
+ requires: ['audio']
+ });
+
+ reg({
+ id: 'view.activity',
+ legacyId: 'observation-activity',
+ label: 'Observation Visual Activity',
+ kind: 'range',
+ min: 0,
+ max: 1,
+ step: 0.01,
+ unit: 'ratio',
+ readable: true,
+ writable: true,
+ restorable: true,
+ category: 'observation',
+ requires: ['observation']
+ });
+
+ // --- 2. Persistent State Targets (Booleans & Selections) ---
+ reg({
+ id: 'transport.playing',
+ legacyId: 'master-play',
+ label: 'Audio Engine Online',
+ kind: 'state',
+ valueType: 'boolean',
+ readable: true,
+ writable: true,
+ restorable: true,
+ category: 'transport',
+ requires: ['audio']
+ });
+
+ reg({
+ id: 'transport.muted',
+ legacyId: 'mute',
+ label: 'Master Mute',
+ kind: 'state',
+ valueType: 'boolean',
+ readable: true,
+ writable: true,
+ restorable: true,
+ category: 'transport',
+ requires: ['audio']
+ });
+
+ reg({
+ id: 'fnc.enabled',
+ legacyId: 'fnc-enable',
+ label: 'Futuristic Noise Cancellation Active',
+ kind: 'state',
+ valueType: 'boolean',
+ readable: true,
+ writable: true,
+ restorable: true,
+ category: 'audio',
+ requires: ['audio']
+ });
+
+ reg({
+ id: 'view.observation',
+ legacyId: 'watch-experience',
+ label: 'Observation Viewport Active',
+ kind: 'state',
+ valueType: 'boolean',
+ readable: true,
+ writable: true,
+ restorable: false,
+ category: 'observation',
+ requires: ['observation']
+ });
+
+ reg({
+ id: 'view.viewport-frame',
+ legacyId: 'observation-viewport',
+ label: 'Starship Bezel Frame Framing',
+ kind: 'state',
+ valueType: 'boolean',
+ readable: true,
+ writable: true,
+ restorable: true,
+ category: 'observation',
+ requires: ['observation']
+ });
+
+ reg({
+ id: 'alert.active',
+ label: 'Active Tactical Alert Status',
+ kind: 'selection',
+ options: [
+ { value: 'none', label: 'Condition Green (Normal)' },
+ { value: 'yellow', label: 'Yellow Alert' },
+ { value: 'red', label: 'Red Alert' }
+ ],
+ readable: true,
+ writable: true,
+ restorable: true,
+ category: 'alert',
+ requires: ['audio']
+ });
+
+ reg({
+ id: 'universe.selected',
+ label: 'Active Sci-Fi Universe',
+ kind: 'selection',
+ options: [
+ { value: 'starfleet', label: 'Starfleet / Galaxy' },
+ { value: 'whataverse', label: 'Whataverse / Tardix' },
+ { value: 'industrial', label: 'Industrial / Weyland' },
+ { value: 'bioships', label: 'Bioships / Leviathan' },
+ { value: 'retrofuture', label: 'Retro Future / 1960s' },
+ { value: 'military', label: 'Colonial Military' },
+ { value: 'deepspace', label: 'Deep Space Generation' },
+ { value: 'outlaw', label: 'Frontier & Salvage' },
+ { value: 'spacestations', label: 'Orbital Stations & Docks' },
+ { value: 'comedy', label: 'Whimsical Fleet' }
+ ],
+ readable: true,
+ writable: true,
+ restorable: true,
+ category: 'universe',
+ requires: ['audio']
+ });
+
+ reg({
+ id: 'preset.selected',
+ label: 'Active Audio Preset',
+ kind: 'selection',
+ options: this._collectAllPresetOptions(),
+ readable: true,
+ writable: true,
+ restorable: true,
+ category: 'preset',
+ requires: ['audio']
+ });
+
+ // --- 3. Impulse Targets (Speech & Ticker) ---
+ reg({
+ id: 'speech.say',
+ label: 'Speak Text Line',
+ kind: 'impulse',
+ readable: false,
+ writable: false,
+ restorable: false,
+ category: 'speech',
+ requires: ['speech'],
+ args: {
+ text: { type: 'string', required: true, maxLength: 2000 }
+ }
+ });
+
+ reg({
+ id: 'speech.generate-announcement',
+ legacyId: 'generate-announcement',
+ label: 'Generate Ambient AI Announcement',
+ kind: 'impulse',
+ readable: false,
+ writable: false,
+ restorable: false,
+ category: 'speech',
+ requires: ['speech']
+ });
+
+ reg({
+ id: 'display.ticker',
+ label: 'Display Ticker Message (Transient)',
+ kind: 'impulse',
+ readable: false,
+ writable: false,
+ restorable: false,
+ category: 'display',
+ requires: ['display-text'],
+ args: {
+ text: { type: 'string', required: true, maxLength: 512 }
+ }
+ });
+
+ // --- 4. Canonical Universe Event Impulses ---
+ const eventImpulses = [
+ { id: 'event.alert-red', legacyId: 'red-alert', label: 'Red Alert Impulse' },
+ { id: 'event.warp-jump', label: 'Warp Jump Impulse' },
+ { id: 'event.demat', label: 'Demat Cycle Impulse' },
+ { id: 'event.vortex', label: 'Time Vortex Impulse' },
+ { id: 'event.cloister', label: 'Cloister Bell Impulse' },
+ { id: 'event.flak-barrage', label: 'Flak Barrage Impulse' },
+ { id: 'event.ftl-jump', label: 'FTL Jump Impulse' },
+ { id: 'event.singularity', label: 'Singularity Engage Impulse' },
+ { id: 'event.solar-roar', label: 'Solar Flare Roar Impulse' },
+ { id: 'event.afterburner', label: 'Afterburner Ignite Impulse' },
+ { id: 'event.ludicrous-speed', label: 'Ludicrous Speed Impulse' },
+ { id: 'event.improbability-flip', label: 'Improbability Drive Flip' }
+ ];
+
+ eventImpulses.forEach(evt => {
+ reg({
+ id: evt.id,
+ legacyId: evt.legacyId,
+ label: evt.label,
+ kind: 'impulse',
+ readable: false,
+ writable: false,
+ restorable: false,
+ category: 'event',
+ requires: ['audio']
+ });
+ });
+
+ // --- 5. Canonical SFX Impulses ---
+ const sfxImpulses = [
+ { id: 'sfx.comm-badge', label: 'Comm Badge Chime' },
+ { id: 'sfx.door-swish', label: 'Pneumatic Door Swish' },
+ { id: 'sfx.bosun-whistle', label: 'Bosun Pipe Call' },
+ { id: 'sfx.medical-monitor', label: 'Medical Vital Monitor' },
+ { id: 'sfx.cardassian-door', label: 'Heavy Bulkhead Door' },
+ { id: 'sfx.turbolift', label: 'Turbochute Whoosh' },
+ { id: 'sfx.replicator', label: 'Fabricator Materialize' },
+ { id: 'sfx.transporter', label: 'Translocator Beam' },
+ { id: 'sfx.console-chirp', label: 'Console Single Chirp' },
+ { id: 'sfx.sonic', label: 'Sonic Acoustic Screwdriver' },
+ { id: 'sfx.lever-clunk', label: 'Console Mechanical Lever Clunk' },
+ { id: 'sfx.tardix-door', label: 'Tardix Wood & Lock Door' },
+ { id: 'sfx.demat-switch', label: 'Demat Switch Rotor' },
+ { id: 'sfx.telepathic-chime', label: 'Telepathic Circuit Chime' },
+ { id: 'sfx.air-handler', label: 'Air Handler Valve Thud' },
+ { id: 'sfx.mag-boot', label: 'Mag-Boot Solenoid Latch' },
+ { id: 'sfx.comlock', label: 'Comlock Electronic Tone' },
+ { id: 'sfx.solar-roar', label: 'Solar Radiation Wind' },
+ { id: 'sfx.tape-clunk', label: 'Cassette Magnetic Transport Clunk' },
+ { id: 'sfx.afterburner', label: 'Thruster Burner Pulse' },
+ { id: 'sfx.geiger', label: 'Radiation Geiger Counter' },
+ { id: 'sfx.dock-clamp', label: 'Air Valve & Dock Clamp' },
+ { id: 'sfx.dradis-ping', label: 'Dradis Combat Sonar Ping' },
+ { id: 'sfx.hal-chime', label: 'HAL Logic Melodic Chime' }
+ ];
+
+ sfxImpulses.forEach(sfx => {
+ reg({
+ id: sfx.id,
+ label: sfx.label,
+ kind: 'impulse',
+ readable: false,
+ writable: false,
+ restorable: false,
+ category: 'sfx',
+ requires: ['audio']
+ });
+ });
+ }
+
+ _collectAllPresetOptions() {
+ const options = [];
+ if (typeof window !== 'undefined' && window.UniverseRegistry) {
+ for (const uId of Object.keys(window.UniverseRegistry)) {
+ const u = window.UniverseRegistry[uId];
+ if (u.presets) {
+ for (const pId of Object.keys(u.presets)) {
+ options.push({ value: pId, label: u.presets[pId].name || pId, universe: uId });
+ }
+ }
+ }
+ }
+ if (!options.length) {
+ options.push(
+ { value: 'tng-bridge', label: 'Interprise-G: Main Bridge', universe: 'starfleet' },
+ { value: 'tardix-1963', label: 'Type 40 TT: 1963 First Console', universe: 'whataverse' }
+ );
+ }
+ return options;
+ }
+
+ _generateSessionId() {
+ return 'sess-' + Math.random().toString(36).substring(2, 10);
+ }
+
+ _setupWindowBridge() {
+ window.addEventListener('message', (e) => {
+ // Enforce safe origin filtering if configured
+ if (e.origin !== window.location.origin && e.origin !== this.hostOrigin) {
+ return;
+ }
+ // Enforce safe source: must be parent, opener, or self
+ if (e.source !== window.parent && e.source !== window.opener && e.source !== window) {
+ return;
+ }
+ // Ignore non-XZBT messages
+ if (!e.data || typeof e.data !== 'object' || e.data.xzbt !== '5.2') {
+ return;
+ }
+ if (e.data.sequence || e.data.type === 'error' || /\.result$/.test(e.data.type)) return;
+
+ const replySink = (msg) => {
+ try {
+ if (e.source && typeof e.source.postMessage === 'function') {
+ const targetOrigin = e.origin === 'null' ? '*' : e.origin;
+ e.source.postMessage(msg, targetOrigin);
+ }
+ } catch (err) {
+ console.warn('[XZBTContractAdapter] Failed to postMessage reply:', err);
+ }
+ };
+
+ this.handleMessage(e.data, 'host', replySink);
+ });
+ }
+
+ // Normalized event emission
+ _emitEvent(type, payload, correlationId = null, authoritativeSource = 'system') {
+ if (!this.sessionActive) return;
+ this.eventSequence++;
+
+ const evt = {
+ xzbt: '5.2',
+ type,
+ sessionId: this.sessionId,
+ sequence: this.eventSequence,
+ timestamp: Date.now(),
+ ...payload
+ };
+
+ if (correlationId) {
+ evt.correlationId = correlationId;
+ }
+ if (authoritativeSource) {
+ evt.source = authoritativeSource;
+ }
+
+ this.onMessage(evt);
+ if (this.eventSink) this.eventSink(evt);
+ }
+
+ _enqueue(operation) {
+ const result = this.operationQueue.then(operation);
+ this.operationQueue = result.catch(() => {});
+ return result;
+ }
+
+ _commitChanges(before, source, correlationId) {
+ const after = this.getContractState();
+ const changes = [...this.targets.values()].filter(t => t.readable && t.kind !== 'impulse' && before[t.id] !== after[t.id]);
+ if (!changes.length) return false;
+ this.stateRevision++;
+ for (const target of changes) {
+ this._emitEvent(target.kind === 'selection' ? 'selection.changed' : 'state.changed', {
+ stateRevision: this.stateRevision, target: target.id, value: after[target.id]
+ }, correlationId, source);
+ }
+ return true;
+ }
+
+ // Public API: Direct Mutation Chokepoint
+ applyMutation(targetId, rawValue, source = 'system', correlationId = null) {
+ return this._enqueue(() => this._applyMutation(targetId, rawValue, source, correlationId));
+ }
+
+ async _applyMutation(targetId, rawValue, source, correlationId) {
+ // Map legacy ID if necessary
+ const canonicalId = this.legacyIdMap.get(targetId) || targetId;
+ const target = this.targets.get(canonicalId);
+
+ if (!target) {
+ return { ok: false, code: 'UNKNOWN_TARGET', message: `Target '${targetId}' not found` };
+ }
+
+ if (!target.writable || target.kind === 'impulse') {
+ return { ok: false, code: 'TARGET_NOT_SETTABLE', message: `Target '${canonicalId}' is not writable` };
+ }
+
+ // Check capability availability
+ if (target.requires) {
+ for (const capId of target.requires) {
+ const cap = this.capabilities.get(capId);
+ if (cap && cap.state === 'unsupported') {
+ return { ok: false, code: 'CAPABILITY_UNAVAILABLE', message: `Capability '${capId}' is unavailable` };
+ }
+ }
+ }
+
+ // Value validation & clamping
+ let value = rawValue;
+ if (target.kind === 'range') {
+ if (typeof value !== 'number' || !Number.isFinite(value)) {
+ return { ok: false, code: 'INVALID_VALUE', message: `Value must be a valid number` };
+ }
+ if (value < target.min || value > target.max) {
+ return {
+ ok: false,
+ code: 'INVALID_VALUE',
+ message: `Value ${value} is outside range [${target.min}, ${target.max}]`
+ };
+ }
+ const steps = (value - target.min) / target.step;
+ if (target.step && Math.abs(steps - Math.round(steps)) > 1e-7) {
+ return { ok: false, code: 'INVALID_VALUE', message: `Value must align to step ${target.step} from ${target.min}` };
+ }
+ } else if (target.kind === 'state') {
+ if (target.valueType === 'boolean' && typeof value !== 'boolean') {
+ return { ok: false, code: 'INVALID_VALUE', message: `Value must be a boolean` };
+ }
+ } else if (target.kind === 'selection') {
+ const validOptions = target.options.map(o => o.value);
+ if (!validOptions.includes(value)) {
+ return {
+ ok: false,
+ code: 'INVALID_VALUE',
+ message: `Value '${value}' is not a valid option for '${canonicalId}'. Valid: [${validOptions.join(', ')}]`
+ };
+ }
+ }
+
+ // Read current state to check for idempotent no-op
+ const currentState = this.getContractState();
+ const currentValue = currentState[canonicalId];
+
+ if (currentValue === value) {
+ // Idempotent no-op: succeeds immediately, NO revision increment, NO event
+ return { ok: true, noop: true, stateRevision: this.stateRevision };
+ }
+
+ // Special atomic handling for universe.selected:
+ // Must also change preset.selected in the same transaction
+ if (canonicalId === 'universe.selected') {
+ if (!this.bindings.setUniverse) {
+ return { ok: false, code: 'INTERNAL_ERROR', message: 'No universe setter bound' };
+ }
+ await this.bindings.setUniverse(value, source);
+
+ this._commitChanges(currentState, source, correlationId);
+
+ return { ok: true, stateRevision: this.stateRevision };
+ }
+
+ // Special handling for preset.selected: validate preset belongs to active universe
+ if (canonicalId === 'preset.selected') {
+ if (this.bindings.getUniverse && window.UniverseRegistry) {
+ const uId = this.bindings.getUniverse();
+ const u = window.UniverseRegistry[uId];
+ if (!u || !u.presets || !u.presets[value]) {
+ return {
+ ok: false,
+ code: 'INVALID_VALUE',
+ message: `Preset '${value}' does not belong to active universe '${uId}'`
+ };
+ }
+ }
+ if (this.bindings.selectPreset) {
+ await this.bindings.selectPreset(value, source);
+ }
+ this._commitChanges(currentState, source, correlationId);
+ return { ok: true, stateRevision: this.stateRevision };
+ }
+
+ // Apply direct underlying setter for other targets
+ const applyFn = this.bindings.setters ? this.bindings.setters[canonicalId] : null;
+ if (applyFn) {
+ try {
+ await applyFn(value, source);
+ } catch (err) {
+ return { ok: false, code: 'SETTER_ERROR', message: err.message || String(err) };
+ }
+ }
+
+ this._commitChanges(currentState, source, correlationId);
+
+ return { ok: true, stateRevision: this.stateRevision };
+ }
+
+ // Public API: Direct Impulse Invocation Chokepoint
+ invokeAction(targetId, args = {}, source = 'system', correlationId = null) {
+ return this._enqueue(() => this._invokeAction(targetId, args, source, correlationId));
+ }
+
+ async _invokeAction(targetId, args, source, correlationId) {
+ const canonicalId = this.legacyIdMap.get(targetId) || targetId;
+ const target = this.targets.get(canonicalId);
+
+ if (!target) {
+ return { ok: false, code: 'UNKNOWN_TARGET', message: `Target '${targetId}' not found` };
+ }
+
+ if (target.kind !== 'impulse') {
+ return { ok: false, code: 'TARGET_NOT_INVOKABLE', message: `Target '${canonicalId}' is not an impulse` };
+ }
+
+ // Validate args schema & text length limits if defined
+ const sanitizedArgs = (args && typeof args === 'object' && !Array.isArray(args)) ? { ...args } : {};
+ if (Object.keys(sanitizedArgs).some(key => !target.args || !target.args[key])) {
+ return { ok: false, code: 'INVALID_VALUE', message: 'Unexpected argument key' };
+ }
+ if (target.args) {
+ for (const [argKey, argSpec] of Object.entries(target.args)) {
+ if (argSpec.required && (sanitizedArgs[argKey] === undefined || sanitizedArgs[argKey] === null)) {
+ return { ok: false, code: 'INVALID_VALUE', message: `Missing required argument '${argKey}'` };
+ }
+ if (sanitizedArgs[argKey] !== undefined) {
+ if (argSpec.type === 'string' && typeof sanitizedArgs[argKey] !== 'string') {
+ return { ok: false, code: 'INVALID_VALUE', message: `Argument '${argKey}' must be a string` };
+ }
+ if (argSpec.maxLength && typeof sanitizedArgs[argKey] === 'string') {
+ if (sanitizedArgs[argKey].length > argSpec.maxLength) {
+ return {
+ ok: false,
+ code: 'INVALID_VALUE',
+ message: `Argument '${argKey}' exceeds maximum length of ${argSpec.maxLength}`
+ };
+ }
+ }
+ }
+ }
+ }
+
+ // Contextual check: if target is a universe event or SFX, ensure capability is ready
+ if (target.requires) {
+ for (const capId of target.requires) {
+ const cap = this.capabilities.get(capId);
+ if (cap && cap.state === 'unsupported') {
+ return { ok: false, code: 'CAPABILITY_UNAVAILABLE', message: `Capability '${capId}' is unavailable` };
+ }
+ }
+ }
+
+ // Execute bound impulse handler
+ const before = this.getContractState();
+ const invoker = this.bindings.invokers ? this.bindings.invokers[canonicalId] : null;
+ if (invoker) {
+ try {
+ await invoker(sanitizedArgs, source);
+ } catch (err) {
+ return { ok: false, code: 'INVOCATION_FAILED', message: err.message || String(err) };
+ }
+ } else {
+ // Fallback for universe events or soundboard triggers
+ if (canonicalId.startsWith('event.') && this.bindings.handleUniverseEvent) {
+ const evtType = canonicalId.replace(/^event\./, '');
+ const handled = await this.bindings.handleUniverseEvent(evtType, source);
+ if (!handled) {
+ return { ok: false, code: 'CAPABILITY_UNAVAILABLE', message: `Event '${evtType}' not available in active universe` };
+ }
+ } else if (canonicalId.startsWith('sfx.') && this.bindings.handleSoundboardTrigger) {
+ const sfxId = 'btn-' + canonicalId.replace(/^sfx\./, '');
+ const handled = await this.bindings.handleSoundboardTrigger(sfxId, source);
+ if (!handled) {
+ return { ok: false, code: 'CAPABILITY_UNAVAILABLE', message: `SFX '${canonicalId}' not available in active universe` };
+ }
+ }
+ }
+
+ this._commitChanges(before, source, correlationId);
+ // Impulses only increment stateRevision if they change persistent state.
+ this._emitEvent('action.executed', {
+ target: canonicalId,
+ args: sanitizedArgs
+ }, correlationId, source);
+
+ return { ok: true };
+ }
+
+ // Public API: Return contract-visible snapshot
+ getContractState() {
+ if (this.bindings.getState) {
+ return this.bindings.getState();
+ }
+ return {};
+ }
+
+ // Public API: Protocol Description
+ describe() {
+ return {
+ exhibit: {
+ product: this.product,
+ version: this.version,
+ build: this.build
+ },
+ contract: {
+ major: this.contractMajor,
+ minor: this.contractMinor
+ },
+ registryRevision: this.registryRevision,
+ stateRevision: this.stateRevision,
+ capabilities: Array.from(this.capabilities.values()),
+ targets: Array.from(this.targets.values())
+ };
+ }
+
+ // Update Capability state and emit capability.changed
+ updateCapability(capabilityId, newState) {
+ const cap = this.capabilities.get(capabilityId);
+ if (!cap) return;
+ if (cap.state === newState) return;
+ cap.state = newState;
+ this._emitEvent('capability.changed', {
+ capability: capabilityId,
+ state: newState
+ }, null, 'system');
+ }
+
+ // Add target dynamically and emit registry.changed
+ addCustomTarget(spec) {
+ this.targets.set(spec.id, spec);
+ if (spec.legacyId) this.legacyIdMap.set(spec.legacyId, spec.id);
+ this.registryRevision++;
+ this._emitEvent('registry.changed', {
+ registryRevision: this.registryRevision
+ }, null, 'system');
+ }
+
+ // Handle inbound message (envelope) from host
+ async handleMessage(msg, transportSource = 'host', replySink = null) {
+ const sink = replySink || this.onMessage;
+
+ const sendError = (reqId, sessId, code, message) => {
+ const err = {
+ xzbt: '5.2',
+ type: 'error',
+ requestId: reqId || null,
+ sessionId: sessId || null,
+ ok: false,
+ error: { code, message }
+ };
+ sink(err);
+ return err;
+ };
+
+ const sendResult = (type, reqId, payload = {}) => {
+ const res = {
+ xzbt: '5.2',
+ type,
+ requestId,
+ sessionId: this.sessionId,
+ ok: true,
+ ...payload
+ };
+ sink(res);
+ return res;
+ };
+
+ if (!msg || typeof msg !== 'object') {
+ return sendError(null, null, 'INVALID_MESSAGE', 'Message envelope must be an object');
+ }
+
+ const { type, requestId, sessionId } = msg;
+
+ // 1. Handshake
+ if (type === 'hello') {
+ const { supportedContractMajors } = msg;
+ const isSupported = Array.isArray(supportedContractMajors) &&
+ supportedContractMajors.includes(this.contractMajor);
+
+ if (!isSupported) {
+ return sendError(
+ requestId,
+ null,
+ 'UNSUPPORTED_VERSION',
+ `Exhibit requires contract major ${this.contractMajor}`
+ );
+ }
+
+ // Establish session and reset sequence counter
+ this.sessionId = this._generateSessionId();
+ this.sessionActive = true;
+ this.eventSequence = 0;
+ this.eventSink = replySink;
+
+ return sendResult('hello.result', requestId, {
+ exhibit: {
+ product: this.product,
+ version: this.version,
+ build: this.build
+ },
+ contract: {
+ major: this.contractMajor,
+ minor: this.contractMinor
+ }
+ });
+ }
+
+ // 2. All other operations require an active session
+ if (!this.sessionActive || !sessionId || sessionId !== this.sessionId) {
+ return sendError(requestId, sessionId, 'INVALID_SESSION', 'Missing or invalid sessionId');
+ }
+
+ // Authoritative source: ignore host-supplied source
+ const authoritativeSource = transportSource || 'host';
+
+ switch (type) {
+ case 'describe':
+ return sendResult('describe.result', requestId, this.describe());
+
+ case 'state.get': {
+ const fullState = this.getContractState();
+ const values = {};
+ for (const [id, target] of this.targets.entries()) {
+ if (target.readable && target.kind !== 'impulse') {
+ values[id] = fullState[id];
+ }
+ }
+ return sendResult('state.result', requestId, {
+ stateRevision: this.stateRevision,
+ values
+ });
+ }
+
+ case 'set': {
+ const { target: targetId, value } = msg;
+ const res = await this.applyMutation(targetId, value, authoritativeSource, requestId);
+ if (!res.ok) {
+ return sendError(requestId, this.sessionId, res.code, res.message);
+ }
+ return sendResult('set.result', requestId);
+ }
+
+ case 'invoke': {
+ const { target: targetId, args } = msg;
+ const res = await this.invokeAction(targetId, args, authoritativeSource, requestId);
+ if (!res.ok) {
+ return sendError(requestId, this.sessionId, res.code, res.message);
+ }
+ return sendResult('invoke.result', requestId);
+ }
+
+ default:
+ return sendError(requestId, sessionId, 'INVALID_MESSAGE', `Unknown message type: ${type}`);
+ }
+ }
+ }
+
+ if (typeof module !== 'undefined' && module.exports) {
+ module.exports = { XZBTContractAdapter };
+ }
+ if (typeof window !== 'undefined') {
+ window.XZBTContractAdapter = XZBTContractAdapter;
+ }
+})();
diff --git a/test-fixtures/reference-exhibits/scifi/js/control-bus.js b/test-fixtures/reference-exhibits/scifi/js/control-bus.js
new file mode 100644
index 0000000..0d008e9
--- /dev/null
+++ b/test-fixtures/reference-exhibits/scifi/js/control-bus.js
@@ -0,0 +1,46 @@
+/**
+ * XZBT Control Bus
+ *
+ * XZBTControlBus is a small registry of named, settable console targets
+ * (ranges, toggles and triggers). Anything that wants to drive the console
+ * from outside the DOM — contract adapter, hotkeys, future transports —
+ * talks to the bus rather than reaching into app.js internals.
+ *
+ * MIDI NOTE: Web MIDI control belonged to standalone SciFi-XZBT until
+ * Step 3.5. External MIDI integration will be handled by XZBT-NGN, which
+ * will send canonical contract commands (set / invoke) to this exhibit.
+ * The control bus itself is transport-agnostic and is intentionally kept here.
+ */
+
+class XZBTControlBus {
+ constructor() {
+ this.targets = new Map();
+ this.listeners = new Set();
+ }
+ register(id, spec) {
+ this.targets.set(id, { id, ...spec });
+ }
+ set(id, value, source = 'external') {
+ const t = this.targets.get(id);
+ if (!t) return false;
+ let v = value;
+ if (t.type === 'range') {
+ v = Math.max(t.min, Math.min(t.max, Number(value)));
+ if (t.step) v = Math.round(v / t.step) * t.step;
+ }
+ if (t.apply) t.apply(v, source);
+ this.listeners.forEach(fn => fn({ id, value: v, source, target: t }));
+ return true;
+ }
+ trigger(id, source = 'external') {
+ const t = this.targets.get(id);
+ if (!t) return false;
+ if (t.apply) t.apply(true, source);
+ this.listeners.forEach(fn => fn({ id, value: true, source, target: t }));
+ return true;
+ }
+ list() {
+ return [...this.targets.values()];
+ }
+}
+window.XZBTControlBus = XZBTControlBus;
diff --git a/test-fixtures/reference-exhibits/scifi/js/core-animations.js b/test-fixtures/reference-exhibits/scifi/js/core-animations.js
new file mode 100644
index 0000000..eda310f
--- /dev/null
+++ b/test-fixtures/reference-exhibits/scifi/js/core-animations.js
@@ -0,0 +1,216 @@
+// Approved settings-panel core animations. Procedural, offline, and dependency-free.
+(() => {
+ 'use strict';
+ const TAU = Math.PI * 2;
+ function line(c, points, color, width = 1) {
+ c.beginPath(); points.forEach(([x, y], i) => i ? c.lineTo(x, y) : c.moveTo(x, y));
+ c.strokeStyle = color; c.lineWidth = width; c.stroke();
+ }
+ function ring(c, x, y, r, color, width = 1, start = 0, end = TAU) {
+ c.beginPath(); c.arc(x, y, r, start, end); c.strokeStyle = color; c.lineWidth = width; c.stroke();
+ }
+ function dot(c, x, y, r, color) {
+ c.fillStyle = color; c.beginPath(); c.arc(x, y, r, 0, TAU); c.fill();
+ }
+ function glow(c, x, y, r, color, alpha = 1) {
+ c.save(); c.globalAlpha = alpha;
+ const g = c.createRadialGradient(x, y, 0, x, y, r);
+ g.addColorStop(0, color); g.addColorStop(1, color + '00');
+ c.fillStyle = g; c.fillRect(x - r, y - r, r * 2, r * 2); c.restore();
+ }
+ function rect(c, x, y, w, h, fill, stroke) {
+ c.fillStyle = fill; c.fillRect(x, y, w, h);
+ if (stroke) { c.strokeStyle = stroke; c.lineWidth = 1; c.strokeRect(x, y, w, h); }
+ }
+ function polygon(c, r, sides, color, rotation = 0, width = 1) {
+ const p = Array.from({ length: sides + 1 }, (_, i) => [Math.cos(i * TAU / sides + rotation) * r, Math.sin(i * TAU / sides + rotation) * r]);
+ line(c, p, color, width);
+ }
+ function base(c) {
+ for (let x = -160; x <= 160; x += 20) for (let y = -120; y <= 120; y += 20) dot(c, x, y, 0.6, '#253545');
+ line(c, [[-158, -93], [-158, -110], [-141, -110]], '#344556');
+ line(c, [[158, 93], [158, 110], [141, 110]], '#344556');
+ }
+
+ const renderers = {
+ starfleet(c, t, load) {
+ const beat = Math.pow((Math.sin(t * 3.4) + 1) / 2, 5);
+ glow(c, 0, 0, 92, '#167eca', 0.17 + load * 0.2);
+ rect(c, -39, -105, 78, 210, '#0b1b2b', '#335770');
+ for (const side of [-1, 1]) {
+ line(c, [[side * 34, -105], [side * 48, -78], [side * 48, 78], [side * 34, 105]], '#6f8d9c', 3);
+ rect(c, -37, side < 0 ? -110 : 102, 74, 8, '#b89976');
+ line(c, [[side * 28, 0], [side * 87, 0], [side * 106, 20], [side * 146, 20]], '#234253', 8);
+ line(c, [[side * 28, 0], [side * 87, 0], [side * 106, 20], [side * 146, 20]], '#80b4c4', 1);
+ for (let i = 0; i < 18; i++) {
+ const travel = (i / 18 + t * 0.55) % 1;
+ const y = side * (100 - travel * 97);
+ const x = Math.sin(travel * 13 + t * 2) * 12;
+ line(c, [[x, y + side * 9], [x, y]], side < 0 ? '#75baff' : '#c8f5ff', 1 + load * 2);
+ }
+ }
+ for (let i = -6; i <= 6; i++) {
+ const wave = Math.pow(Math.max(0, Math.cos(t * 3.4 - Math.abs(i) * 0.62)), 6);
+ c.save(); c.globalAlpha = 0.2 + wave * (0.4 + load * 0.4);
+ line(c, [[-32, i * 15], [-22, i * 15 + 3], [22, i * 15 + 3], [32, i * 15]], '#89eaff', 3); c.restore();
+ }
+ glow(c, 0, 0, 28 + beat * 18 * load, '#81ddff', 0.8);
+ line(c, [[0, -20], [14, 0], [0, 20], [-14, 0], [0, -20]], '#e8fdff', 2);
+ line(c, [[0, -20], [0, 20], [14, 0], [-14, 0]], '#b0eeff');
+ },
+ military(c, t, load) {
+ polygon(c, 104, 6, '#3f4a50', Math.PI / 6, 12);
+ polygon(c, 110, 6, '#a7987e', Math.PI / 6);
+ for (let i = 0; i < 6; i++) {
+ const a = i * TAU / 6 + Math.PI / 6;
+ dot(c, Math.cos(a) * 104, Math.sin(a) * 104, 3, '#d6c6ad');
+ c.save(); c.rotate(a);
+ rect(c, 38, -10, 38, 20, '#171f25', '#655e4d');
+ for (let j = 0; j < 5; j++) rect(c, 42 + j * 6, -6, 4, 12, j < 1 + load * 4 && (t * 2 + i) % 6 > j * 0.65 ? '#e8b468' : '#433e30');
+ c.restore();
+ }
+ for (const [r, direction, color] of [[86, 1, '#d3b285'], [31, -1, '#8ea8a6']]) {
+ for (let i = 0; i < 12; i++) {
+ const a = i * TAU / 12 + direction * t * 0.65;
+ ring(c, 0, 0, r, color, 4, a, a + 0.3);
+ }
+ }
+ glow(c, 0, 0, 29, '#eab164', 0.25 + load * 0.4);
+ polygon(c, 18, 6, '#f8d09d', 0, 2);
+ line(c, [[-10, 0], [10, 0]], '#e7d8bf', 3);
+ for (const x of [-136, 126]) for (let j = 0; j < 7; j++) rect(c, x, -38 + j * 12, 10, 7, j < load * 7 ? '#c7a16c' : '#263039');
+ },
+ outlaw(c, t, load) {
+ c.save(); c.translate(Math.sin(t * 19) * load * 1.4, Math.sin(t * 23) * load * 1.5);
+ line(c, [[-126, 50], [-93, 50], [-93, -40], [26, -40], [26, -19]], '#a26c4f', 5);
+ line(c, [[-77, 48], [-54, 66], [47, 66], [66, 29]], '#746554', 4);
+ rect(c, -103, -23, 40, 70, '#302d29', '#81705c');
+ rect(c, -74, -38, 100, 85, '#34383b', '#8b8c7b');
+ rect(c, -53, -45, 29, 17, '#674938', '#c28b58');
+ rect(c, -19, -21, 40, 48, '#233c39', '#609a87');
+ for (let i = 0; i < 4; i++) rect(c, -14, -15 + i * 9, 28, 4, '#79b49e');
+ rect(c, 27, -22, 35, 51, '#494139', '#a08c6e');
+ for (let i = 0; i < 4; i++) rect(c, 30 + i * 8, -27, 4, 61, '#80745f');
+ const flicker = 0.8 + Math.sin(t * 17) * 0.12 + Math.sin(t * 29) * 0.08;
+ c.save(); c.translate(68, 3); c.scale((0.2 + load * 0.85) * flicker, 0.4 + load * 0.5);
+ glow(c, 25, 0, 55, '#e88250', 0.5);
+ for (let j = 0; j < 7; j++) {
+ const len = 42 + 35 * Math.sin(t * 13 + j) ** 2;
+ line(c, [[0, (j - 3) * 4], [len * 0.5, (j - 3) * 3], [len, Math.sin(t * 8 + j) * 5]], j % 2 ? '#ffc07c' : '#a3e5e7', 2);
+ }
+ c.restore();
+ dot(c, -67, 2, 27, '#0b1015'); ring(c, -67, 2, 28, '#b0967a', 3);
+ c.save(); c.translate(-67, 2); c.rotate(t * 4);
+ for (let i = 0; i < 5; i++) { c.rotate(TAU / 5); line(c, [[5, 0], [20, -8], [22, 3], [5, 0]], '#a49c87', 3); }
+ c.restore(); dot(c, -67, 2, 6, '#d8ae7a');
+ c.font = '9px Consolas, monospace'; c.fillStyle = '#dcc1a1'; c.fillText('NO. 07', -15, 43);
+ c.restore();
+ },
+ comedy(c, t, load) {
+ c.translate(0, Math.sin(t * 1.2) * 8);
+ glow(c, 0, 0, 100, '#6b49af', 0.17);
+ const colors = ['#d0acff', '#81ded2', '#f0c78e'];
+ for (let i = 0; i < 3; i++) {
+ c.save(); c.rotate(i * Math.PI / 3 + Math.sin(t * 0.5 + i) * 0.3);
+ const r = 74 + load * 15;
+ const squeeze = 0.22 + Math.abs(Math.sin(t * 0.65 + i)) * 0.55;
+ c.scale(1, squeeze); ring(c, 0, 0, r, colors[i], 2);
+ const a = t * (0.7 + i * 0.2) + i * 2;
+ dot(c, Math.cos(a) * r, Math.sin(a) * r, 5, colors[i]); c.restore();
+ }
+ glow(c, 0, 0, 45, '#a686f4', 0.4);
+ const vertices = Array.from({ length: 6 }, (_, i) => {
+ const a = i * TAU / 6 + t * 0.3;
+ const r = 24 + Math.sin(t * 1.7 + i * 2) * (4 + load * 10);
+ return [Math.cos(a) * r, Math.sin(a) * r];
+ });
+ line(c, [...vertices, vertices[0]], '#ece0ff', 2);
+ for (let i = 0; i < 6; i++) { line(c, [vertices[i], vertices[(i + 2) % 6]], '#b6a4d9'); dot(c, ...vertices[i], 2, '#ffffff'); }
+ for (let i = 0; i < 7; i++) {
+ const a = i * 2.4 + t * 0.18;
+ const x = Math.cos(a) * 121, y = Math.sin(a) * 86;
+ line(c, [[x - 3, y], [x + 3, y]], colors[i % 3]); line(c, [[x, y - 3], [x, y + 3]], colors[i % 3]);
+ }
+ },
+ industrial(c, t, load) {
+ const compression = (Math.sin(t * 2) + 1) / 2;
+ rect(c, -70, -93, 140, 186, '#27241e', '#877653');
+ for (const y of [-94, 85]) for (let i = 0; i < 14; i++) rect(c, -70 + i * 10, y, 10, 9, i % 2 ? '#bd943f' : '#30291a');
+ rect(c, -54, -76, 108, 152, '#0d1011', '#5e543d');
+ glow(c, 0, 0, 60 + compression * load * 18, '#ec8c26', 0.6 + load * 0.3);
+ c.save(); c.scale(1 - compression * 0.25, 1 + compression * 0.13);
+ glow(c, 0, 0, 31 + load * 8, '#ffda87'); c.restore();
+ for (const side of [-1, 1]) {
+ const x = side < 0 ? -133 : 78;
+ rect(c, x, -21, 55, 42, '#2e3332', '#6e7770');
+ const reach = 25 + compression * (12 + load * 16);
+ const inner = side * (77 - reach);
+ rect(c, side < 0 ? -78 : inner, -8, reach, 16, '#a6a092', '#d1c6ac');
+ rect(c, inner - (side < 0 ? 0 : 8), -34, 8, 68, '#746447', '#d9b875');
+ }
+ for (let i = 0; i < 16; i++) {
+ const travel = (i / 16 + t * 0.24) % 1;
+ dot(c, Math.sin(i * 8 + travel * 4) * 36, 60 - travel * 125, 0.8 + (i % 3) * 0.5, '#e4ad61');
+ }
+ for (const y of [-65, 59]) for (let i = 0; i < 8; i++) rect(c, -45 + i * 12, y, 6, 6, i < 2 + load * 6 ? '#e4a751' : '#55422b');
+ for (const x of [-63, 63]) for (const y of [-80, 80]) dot(c, x, y, 3, '#ad9b77');
+ },
+ station(c, t, load) {
+ ring(c, 0, 0, 109, '#34545c');
+ for (let i = 0; i < 4; i++) {
+ c.save(); c.rotate(i * Math.PI / 2);
+ line(c, [[-15, -119], [-15, -106], [15, -106], [15, -119]], '#8ba9a9', 3); c.restore();
+ }
+ c.save(); c.rotate(t * 0.24);
+ ring(c, 0, 0, 89, '#263f44', 19); ring(c, 0, 0, 100, '#93bcb8', 2); ring(c, 0, 0, 78, '#537c7e', 2);
+ for (let i = 0; i < 24; i++) {
+ const a = i * TAU / 24;
+ ring(c, 0, 0, 90, i % 4 === 0 ? '#c4d5bd' : '#568d86', 8, a + 0.025, a + 0.16);
+ }
+ for (let i = 0; i < 6; i++) {
+ c.save(); c.rotate(i * TAU / 6);
+ line(c, [[19, -4], [78, -4]], '#5b797c', 2); line(c, [[19, 4], [78, 4]], '#5b797c', 2);
+ for (let j = 0; j < 1 + Math.floor(load * 3); j++) {
+ const travel = (t * 0.55 + j / 3 + i / 6) % 1;
+ dot(c, 22 + travel * 55, 0, 2, '#b4ffe0');
+ }
+ c.restore();
+ }
+ c.restore();
+ glow(c, 0, 0, 30, '#73e4c2', 0.3 + load * 0.3);
+ dot(c, 0, 0, 21, '#142d32'); ring(c, 0, 0, 21, '#a3d9cd', 2);
+ line(c, [[-10, -5], [10, -5], [10, 5], [-10, 5], [-10, -5]], '#c5e7dc', 2);
+ for (let i = 0; i < 6; i++) ring(c, 0, 0, 115, '#3b625f', 2, i * TAU / 6 + 0.3, i * TAU / 6 + 0.6);
+ }
+ };
+
+
+ const modes = {
+ 'intermix-cascade': 'starfleet',
+ 'tactical-flywheel': 'military',
+ 'patchwork-drive': 'outlaw',
+ 'improbability-engine': 'comedy',
+ 'compression-furnace': 'industrial',
+ 'station-hub': 'station'
+ };
+ const CoreAnimations = {
+ has(mode) { return Object.prototype.hasOwnProperty.call(modes, mode); },
+ render(ctx, mode, width, height, time, load) {
+ if (!this.has(mode) || width <= 20 || height <= 42) return;
+ ctx.clearRect(0, 0, width, height);
+ ctx.save();
+ // Preserve the existing label and the full motion envelope at sidebar size.
+ const labelSpace = 32;
+ const bottomSpace = 10;
+ ctx.translate(width / 2, labelSpace + (height - labelSpace - bottomSpace) / 2);
+ const scale = Math.min((width - 20) / 330, (height - labelSpace - bottomSpace) / 250);
+ ctx.scale(scale, scale);
+ ctx.lineCap = 'round';
+ ctx.lineJoin = 'round';
+ base(ctx);
+ renderers[modes[mode]](ctx, time, Math.max(0, Math.min(1, load)));
+ ctx.restore();
+ }
+ };
+ window.CoreAnimations = CoreAnimations;
+})();
diff --git a/test-fixtures/reference-exhibits/scifi/js/generative-experience.js b/test-fixtures/reference-exhibits/scifi/js/generative-experience.js
new file mode 100644
index 0000000..4f8c27f
--- /dev/null
+++ b/test-fixtures/reference-exhibits/scifi/js/generative-experience.js
@@ -0,0 +1,1386 @@
+/**
+ * XZBT Generative Experience -- optional local AI narration layer.
+ *
+ * The deterministic engine remains authoritative at all times: this subsystem only
+ * VERBALIZES world state that audio.js / app.js already decided. Nothing here runs
+ * until the operator prepares it.
+ *
+ * NETWORK NOTE (documented exception to the zero-dependency rule in agents.md):
+ * prepareExperience() dynamically imports WebLLM and Kokoro from CDN. Those imports
+ * are lazy and guarded -- the rest of the application, including the packaged
+ * single-file build, runs fully offline and simply leaves this panel on STANDBY.
+ */
+
+const XZBT_COMMON_SPEECH_RULES = {
+ expandUnits: true,
+ preferSpokenNumbers: true,
+ units: {
+ 'KM/S': 'kilometers per second',
+ KM: 'kilometers',
+ 'M/S': 'meters per second',
+ M: 'meters',
+ MW: 'megawatts',
+ GW: 'gigawatts',
+ KW: 'kilowatts',
+ KHZ: 'kilohertz',
+ MHZ: 'megahertz',
+ GHZ: 'gigahertz',
+ HZ: 'hertz',
+ '%': 'percent',
+ C: 'degrees Celsius',
+ KPA: 'kilopascals',
+ MPA: 'megapascals'
+ },
+ numberStyle: {
+ bearings: 'digit-by-digit',
+ identifiers: 'digit-by-digit',
+ ordinaryValues: 'natural-number',
+ decimals: 'spoken-decimal'
+ },
+ fallbackRule:
+ 'If compact notation would sound awkward when spoken, expand it into a natural plausible phrase that fits the line. Immersion is more important than strict real-world scientific precision.'
+};
+const speechProfile = (
+ role,
+ topics,
+ terms,
+ voice,
+ speed,
+ dsp,
+ prefer,
+ avoid,
+ examples,
+ announcementTemplates = {}
+) => ({
+ role,
+ topics,
+ terms,
+ voice,
+ speed,
+ dsp,
+ speechRules: XZBT_COMMON_SPEECH_RULES,
+ lexicon: { prefer, avoid },
+ examples,
+ announcementTemplates
+});
+const XZBT_VOICE_AUTOMATION_DEFAULTS = {
+ starfleet: 0.84,
+ whataverse: 0.88,
+ industrial: 1.0,
+ bioships: 0.65,
+ retrofuture: 0.94,
+ military: 0.97,
+ deepspace: 0.78,
+ outlaw: 0.7,
+ spacestations: 0.9,
+ comedy: 0.72
+};
+const XZBT_SPEECH_PROFILES = {
+ starfleet: speechProfile(
+ 'starship operations computer',
+ 'navigation, sensor contacts, engineering status, traffic and safety',
+ 'Calm, precise shipboard operations language. Polished, clinical, competent.',
+ 'af_heart',
+ 0.92,
+ 'ship',
+ ['nominal', 'variance', 'contact', 'bearing', 'power bus', 'drive field', 'sensor coverage'],
+ ['okay', 'heads up', 'looks like', 'oops', 'I think', 'probably'],
+ {
+ routine: [
+ 'Primary power distribution remains within nominal limits.',
+ 'Long-range sensor coverage is operating normally.',
+ 'Course correction complete. New bearing zero four two.',
+ 'Drive field output stable at seventy-four percent.'
+ ],
+ advisory: [
+ 'Minor variance detected in the portside power bus.',
+ 'Unidentified contact entering long-range sensor coverage.',
+ 'Environmental flow has fallen four percent below nominal.',
+ 'Navigation solution updated for local traffic.'
+ ],
+ warning: [
+ 'Warning. Drive field instability is increasing.',
+ 'Collision threshold exceeded. Automatic correction engaged.',
+ 'Forward structural load has risen above normal operating tolerance.',
+ 'Sensor interference is degrading contact resolution.'
+ ],
+ recovery: [
+ 'Drive field stability restored.',
+ 'Power distribution has returned to nominal limits.',
+ 'Navigation correction complete.',
+ 'Sensor resolution restored across the forward array.'
+ ]
+ },
+ {
+ contact_detected: [
+ 'Unidentified contact detected at {range} kilometers.',
+ 'New contact. Bearing {bearing}. Range {range} kilometers.'
+ ],
+ power_variance: ['{system} output has shifted {amount} percent from nominal.'],
+ course_change: ['Course correction initiated. New bearing {bearing}.']
+ }
+ ),
+ whataverse: speechProfile(
+ 'eccentric multidimensional machine status system',
+ 'temporal stability, spatial displacement, mechanisms and navigation',
+ 'An old, brilliant, temperamental multidimensional machine treating impossible mechanics as routine maintenance. Precise and strange, not jokey.',
+ 'bf_emma',
+ 0.96,
+ 'machine',
+ [
+ 'temporal index',
+ 'dimensional shear',
+ 'phase rotor',
+ 'translation field',
+ 'coordinate lock',
+ 'chronometric drift',
+ 'mechanical interlock'
+ ],
+ ['time travel', 'TARDIS', 'Doctor', 'Gallifrey', 'wibbly', 'magic', 'oops'],
+ {
+ routine: [
+ 'Temporal index holding within three points of baseline.',
+ 'Coordinate lock maintained across the translation field.',
+ 'Phase rotors synchronized and carrying load.',
+ 'Interior dimensional pressure remains comfortably impossible.'
+ ],
+ advisory: [
+ 'Chronometric drift detected in the outer reference frame.',
+ 'Translation field is developing a minor phase asymmetry.',
+ 'Mechanical interlock three is cycling more slowly than expected.',
+ 'Coordinate solution revised after a local dimensional fold.'
+ ],
+ warning: [
+ 'Warning. Dimensional shear is rising across the translation envelope.',
+ 'Temporal reference lock has fallen below stable tolerance.',
+ 'Phase rotor synchronization is degrading.',
+ 'Spatial compression limit approaching automatic correction threshold.'
+ ],
+ recovery: [
+ 'Temporal reference lock restored.',
+ 'Dimensional shear has returned to background levels.',
+ 'Phase rotors resynchronized.',
+ 'Coordinate solution stabilized after correction.'
+ ]
+ }
+ ),
+ industrial: speechProfile(
+ 'utilitarian transport vessel automation system',
+ 'drive status, pressure, docking, mechanical load and environmental systems',
+ 'Terse physical-plant language. Practical, mechanical and workmanlike.',
+ 'am_adam',
+ 0.9,
+ 'industrial',
+ [
+ 'manifold',
+ 'load',
+ 'pressure',
+ 'pump',
+ 'bearing temperature',
+ 'bus voltage',
+ 'seal',
+ 'actuator',
+ 'feed line'
+ ],
+ ['quantum', 'elegant', 'mysterious', 'awesome', 'oops'],
+ {
+ routine: [
+ 'Main feed pressure steady at two hundred forty kilopascals.',
+ 'Cargo lift actuators are parked and locked.',
+ 'Drive coolant pumps are carrying normal load.',
+ 'Docking clamps report full mechanical engagement.'
+ ],
+ advisory: [
+ 'Pump two is drawing six percent above normal current.',
+ 'Port manifold pressure is drifting low.',
+ 'Aft bearing temperature has increased three degrees Celsius.',
+ 'Cargo bay ventilation is compensating for reduced flow.'
+ ],
+ warning: [
+ 'Warning. Drive coolant pressure is below operating tolerance.',
+ 'Docking clamp three has not confirmed full engagement.',
+ 'Main bus voltage is unstable under load.',
+ 'Hydraulic return pressure is approaching shutdown threshold.'
+ ],
+ recovery: [
+ 'Coolant pressure restored.',
+ 'Docking clamps confirmed secure.',
+ 'Main bus voltage has stabilized.',
+ 'Ventilation flow returned to scheduled output.'
+ ]
+ }
+ ),
+ bioships: speechProfile(
+ 'living vessel autonomic awareness system',
+ 'biological regulation, sensory conditions, metabolism, external organisms and stress',
+ 'Clinical biological language. The vessel is alive; describe physiology rather than machinery. Never mystical.',
+ 'bf_isabella',
+ 0.88,
+ 'organic',
+ [
+ 'vascular flow',
+ 'neural response',
+ 'membrane',
+ 'metabolic rate',
+ 'sensory tissue',
+ 'respiration',
+ 'regeneration',
+ 'stress response'
+ ],
+ ['reactor', 'engine room', 'power bus', 'computer core', 'magic', 'soul'],
+ {
+ routine: [
+ 'Vascular flow remains even through the dorsal channels.',
+ 'Metabolic rate is stable at sixty-eight percent of cruise demand.',
+ 'Sensory membranes show no harmful external contact.',
+ 'Respiratory exchange remains balanced.'
+ ],
+ advisory: [
+ 'Neural response has increased along the forward sensory ridge.',
+ 'Minor tissue stress detected near the outer membrane.',
+ 'Metabolic demand has risen five percent.',
+ 'Regeneration activity is increasing around a healed abrasion.'
+ ],
+ warning: [
+ 'Warning. Vascular pressure is falling in the aft lobe.',
+ 'Protective membrane stress exceeds normal tolerance.',
+ 'Neural response is becoming irregular.',
+ 'Respiratory exchange is below stable demand.'
+ ],
+ recovery: [
+ 'Vascular pressure restored.',
+ 'Protective membrane stress is subsiding.',
+ 'Neural response has returned to baseline.',
+ 'Respiratory balance restored.'
+ ]
+ }
+ ),
+ retrofuture: speechProfile(
+ 'mid-century spaceflight computer',
+ 'course, fuel, cabin conditions, radio contacts and machinery',
+ 'Compact formal space-age English with short declarative sentences and old-fashioned instrumentation terms.',
+ 'am_michael',
+ 0.86,
+ 'retro',
+ [
+ 'azimuth',
+ 'range',
+ 'radio beacon',
+ 'fuel reserve',
+ 'cabin pressure',
+ 'gyro',
+ 'automatic pilot',
+ 'indicator'
+ ],
+ ['sensor fusion', 'neural', 'quantum mesh', 'heads up', 'awesome', 'oops'],
+ {
+ routine: [
+ 'Automatic pilot holding course zero eight seven.',
+ 'Cabin pressure steady.',
+ 'Fuel reserve is seventy-one percent.',
+ 'Radio beacon received at normal strength.'
+ ],
+ advisory: [
+ 'Gyro two shows a small azimuth error.',
+ 'A weak radio contact has appeared ahead.',
+ 'Cabin temperature has increased two degrees Celsius.',
+ 'Fuel flow is slightly above cruise setting.'
+ ],
+ warning: [
+ 'Warning. Cabin pressure is falling.',
+ 'Automatic pilot correction limit exceeded.',
+ 'Main gyro signal is unstable.',
+ 'Fuel feed pressure is below safe operating range.'
+ ],
+ recovery: [
+ 'Cabin pressure restored.',
+ 'Automatic pilot is back on course.',
+ 'Main gyro signal steady.',
+ 'Fuel feed pressure normal.'
+ ]
+ }
+ ),
+ military: speechProfile(
+ 'combat information and damage-control computer',
+ 'contacts, readiness, propulsion, defensive systems and compartment condition',
+ 'Clipped watch-floor reporting. Disciplined and unemotional. Never infer hostility or combat.',
+ 'am_adam',
+ 0.9,
+ 'military',
+ [
+ 'contact',
+ 'track',
+ 'readiness',
+ 'compartment',
+ 'damage control',
+ 'propulsion',
+ 'bearing',
+ 'range',
+ 'condition'
+ ],
+ ['enemy', 'hostile', 'weapons fire', 'attack', 'panic', 'probably'],
+ {
+ routine: [
+ 'Propulsion readiness remains green.',
+ 'Forward compartments report normal condition.',
+ 'Track picture stable. No priority contacts.',
+ 'Damage-control circuits are standing by.'
+ ],
+ advisory: [
+ 'New contact. Bearing two seven one. Range eighteen thousand kilometers.',
+ 'Compartment six reports a minor pressure variance.',
+ 'Propulsion reserve has decreased four percent.',
+ 'Track quality is reduced in the aft sector.'
+ ],
+ warning: [
+ 'Warning. Compartment pressure is below operating tolerance.',
+ 'Propulsion margin is approaching minimum reserve.',
+ 'Multiple tracks have lost positive classification.',
+ 'Damage-control isolation has engaged in section four.'
+ ],
+ recovery: [
+ 'Compartment pressure restored.',
+ 'Propulsion margin recovered.',
+ 'Track classification restored.',
+ 'Section four isolation cleared.'
+ ]
+ }
+ ),
+ deepspace: speechProfile(
+ 'deep-space exploration computer',
+ 'astronomical observations, navigation, sensor data, environment and long-range contacts',
+ 'Scientific observation, measured uncertainty and precise status language. Wonder comes from the data, not emotion.',
+ 'af_nicole',
+ 0.9,
+ 'science',
+ [
+ 'spectral',
+ 'parallax',
+ 'radiation flux',
+ 'baseline',
+ 'survey',
+ 'transient',
+ 'solution',
+ 'long-range array'
+ ],
+ ['beautiful', 'amazing', 'scary', 'alien', 'probably', 'I think'],
+ {
+ routine: [
+ 'Long-range array is collecting a clean spectral baseline.',
+ 'Navigation solution remains stable against background stars.',
+ 'Radiation flux is within survey limits.',
+ 'No significant transient detected in the current field.'
+ ],
+ advisory: [
+ 'A weak transient has appeared beyond the primary survey range.',
+ 'Parallax solution has shifted by zero point three degrees.',
+ 'Background radiation has increased four percent.',
+ 'Long-range contact resolution is improving.'
+ ],
+ warning: [
+ 'Warning. Radiation flux is above survey tolerance.',
+ 'Navigation confidence has fallen below the preferred margin.',
+ 'Sensor saturation is reducing spectral resolution.',
+ 'Long-range array temperature is approaching its operating limit.'
+ ],
+ recovery: [
+ 'Radiation flux returned to baseline.',
+ 'Navigation confidence restored.',
+ 'Spectral resolution recovered.',
+ 'Long-range array temperature stabilized.'
+ ]
+ }
+ ),
+ outlaw: speechProfile(
+ 'independent freighter utility computer',
+ 'fuel, cargo, navigation, nearby traffic, machinery and comms',
+ 'Practical civilian shipboard language. Dry, economical and slightly worn, never theatrical.',
+ 'am_michael',
+ 0.94,
+ 'utility',
+ [
+ 'fuel reserve',
+ 'cargo lock',
+ 'drive temperature',
+ 'traffic',
+ 'relay',
+ 'course',
+ 'comms',
+ 'service limit'
+ ],
+ ['command protocol', 'fleet', 'heroic', 'enemy', 'awesome', 'oops'],
+ {
+ routine: [
+ 'Fuel reserve holding at sixty-three percent.',
+ 'Cargo locks remain secure.',
+ 'Drive temperature is steady.',
+ 'No traffic conflict on the current course.'
+ ],
+ advisory: [
+ 'A nearby courier has crossed the forward traffic lane.',
+ 'Cargo lock seven needs another confirmation cycle.',
+ 'Drive temperature is up three degrees Celsius.',
+ 'Comms relay quality is getting rough.'
+ ],
+ warning: [
+ 'Warning. Fuel feed pressure is below service limit.',
+ 'Cargo lock seven has failed to confirm.',
+ 'Drive temperature is approaching cutoff.',
+ 'Traffic separation is below automatic comfort margin.'
+ ],
+ recovery: [
+ 'Fuel feed pressure is back in range.',
+ 'Cargo lock seven confirmed.',
+ 'Drive temperature is falling.',
+ 'Traffic separation restored.'
+ ]
+ }
+ ),
+ spacestations: speechProfile(
+ 'orbital station operations announcer',
+ 'traffic, docking berths, approach corridors, maintenance and environmental status',
+ 'Busy orbital traffic and facilities voice. Favor berths, corridors, docking queues, rotation sections, service crews and transfer decks.',
+ 'af_sky',
+ 0.91,
+ 'pa',
+ [
+ 'berth',
+ 'approach corridor',
+ 'traffic control',
+ 'rotation section',
+ 'dockmaster',
+ 'service crew',
+ 'transfer deck',
+ 'pressure zone'
+ ],
+ ['bridge', 'warp core', 'captain', 'enemy', 'starfleet', 'oops'],
+ {
+ routine: [
+ 'Approach corridor three is clear for scheduled traffic.',
+ 'Berth twelve remains occupied by a cargo transfer.',
+ 'Rotation section speed is stable.',
+ 'Transfer deck environmental systems are operating normally.'
+ ],
+ advisory: [
+ 'Incoming shuttle traffic is being resequenced to corridor two.',
+ 'Berth seven reports a delayed pressure check.',
+ 'Service crew requested at the outer docking ring.',
+ 'Rotation section load has increased five percent.'
+ ],
+ warning: [
+ 'Warning. Approach corridor four is temporarily closed.',
+ 'Berth seven pressure check is outside tolerance.',
+ 'Docking ring load is above scheduled limit.',
+ 'Transfer deck pressure zone has entered automatic isolation.'
+ ],
+ recovery: [
+ 'Approach corridor four reopened.',
+ 'Berth seven pressure check complete.',
+ 'Docking ring load returned to schedule.',
+ 'Transfer deck isolation cleared.'
+ ]
+ }
+ ),
+ comedy: speechProfile(
+ 'competent but slightly quirky ship computer',
+ 'routine operations, navigation, systems and harmless anomalies',
+ 'Competent and factual with mild dry understatement and excessive administrative certainty. Humor comes only from phrasing.',
+ 'af_bella',
+ 0.96,
+ 'clean',
+ [
+ 'technically',
+ 'within tolerance',
+ 'administratively',
+ 'minor inconvenience',
+ 'scheduled',
+ 'unlikely',
+ 'adequate'
+ ],
+ ['punchline', 'ha ha', 'LOL', 'oops', 'random', 'wacky'],
+ {
+ routine: [
+ 'Navigation remains on course, which is encouraging.',
+ 'Life support is operating within its officially approved level of adequacy.',
+ 'Drive output is stable and currently requires no paperwork.',
+ 'No unusual contacts detected. The ordinary ones remain ordinary.'
+ ],
+ advisory: [
+ 'A minor course adjustment has become administratively necessary.',
+ 'Power demand is four percent above the level everyone agreed was normal.',
+ 'One sensor contact is unusual, but not yet interesting.',
+ 'Environmental control is compensating for a small and entirely survivable variance.'
+ ],
+ warning: [
+ 'Warning. Drive output is now outside the range labeled reassuring.',
+ 'Collision margin is below the value preferred by sensible navigation systems.',
+ 'Power reserve has reached the officially inconvenient level.',
+ 'Environmental control requires prompt attention rather than eventual attention.'
+ ],
+ recovery: [
+ 'Drive output has returned to the reassuring range.',
+ 'Collision margin restored.',
+ 'Power reserve is once again merely adequate.',
+ 'Environmental control has resumed being someone else’s problem.'
+ ]
+ }
+ )
+};
+
+class XZBTGenerativeExperience {
+ constructor(opts = {}) {
+ this.getUniverse = opts.getUniverse || (() => null);
+ this.getPreset = opts.getPreset || (() => null);
+ this.isPlaying = opts.isPlaying || (() => false);
+ this.isObservation = opts.isObservation || (() => false);
+ this.onGenerated = opts.onGenerated || (() => {});
+ this.audioManager = opts.audioManager || null;
+ this.engine = null;
+ this.webllm = null;
+ this.modelId = 'Qwen3-1.7B-q4f16_1-MLC';
+ this.kokoro = null;
+ this.kokoroModule = null;
+ this.ttsReady = false;
+ this.speechState = 'available';
+ this.onSpeechStateChanged = opts.onSpeechStateChanged || (() => {});
+ this.preparing = null;
+ this.busy = false;
+ this.autoTimer = null;
+ this.history = [];
+ this.lastWorldState = null;
+ this.ui = {};
+ this.currentAudio = null;
+ this.audioUrl = null;
+ }
+ bindUI() {
+ const id = x => document.getElementById(x);
+ this.ui = {
+ badge: id('ai-status-badge'),
+ progress: id('ai-progress'),
+ output: id('ai-experience-output'),
+ prepare: id('btn-ai-prepare'),
+ generate: id('btn-ai-generate'),
+ auto: id('ai-auto-enabled'),
+ speech: id('ai-speech-enabled'),
+ voice: id('ai-kokoro-voice'),
+ robot: id('ai-robot-amount'),
+ robotValue: id('ai-robot-value'),
+ cadence: id('ai-cadence'),
+ cadenceValue: id('ai-cadence-value'),
+ llmState: id('ai-llm-state'),
+ ttsState: id('ai-tts-state')
+ };
+ if (!this.ui.prepare) return;
+ this.ui.prepare.addEventListener('click', () => this.prepareExperience());
+ this.ui.generate.addEventListener('click', () => this.onInvoke ? this.onInvoke('speech.generate-announcement') : this.generateAmbientMoment('manual-request'));
+ this.ui.auto.addEventListener('change', () => this.setAuto(this.ui.auto.checked));
+ if (this.ui.robot) {
+ this.ui.robot.addEventListener('input', () => this.onMutation ? this.onMutation('speech.robot-amount', Number(this.ui.robot.value)) : this.syncRobotUI_());
+ this.applyThemeVoiceAutomation_();
+ }
+ this.ui.cadence.addEventListener('input', () => {
+ const b = Number(this.ui.cadence.value);
+ this.ui.cadenceValue.textContent = `${b}-${b * 2} SEC`;
+ if (this.ui.auto.checked) this.scheduleNext();
+ });
+ if (!navigator.gpu)
+ this.setStatus(
+ 'NO WEBGPU',
+ 'error',
+ 'WebGPU is unavailable. Existing XZBT systems still work, but WebLLM will not load.'
+ );
+ }
+ setStatus(label, kind = '', detail = '') {
+ if (!this.ui.badge) return;
+ this.ui.badge.textContent = label;
+ this.ui.badge.className = `ai-status-badge ${kind}`.trim();
+ if (detail) this.ui.progress.textContent = detail;
+ }
+ async prepareExperience() {
+ if (this.engine && this.ttsReady) return true;
+ if (this.preparing) return this.preparing;
+ this.preparing = this._prepare();
+ try {
+ return await this.preparing;
+ } finally {
+ this.preparing = null;
+ }
+ }
+ async _prepare() {
+ if (!navigator.gpu) {
+ this.setStatus(
+ 'NO WEBGPU',
+ 'error',
+ 'A WebGPU-capable Chrome/Edge browser is required for local language generation.'
+ );
+ return false;
+ }
+ try {
+ this.ui.prepare.disabled = true;
+ this.setStatus('PREPARING', 'busy', 'Loading local AI runtime...');
+ this.ui.llmState.textContent = 'LOAD';
+ this.webllm = this.webllm || (await import('https://esm.run/@mlc-ai/web-llm'));
+ const known = (this.webllm.prebuiltAppConfig?.model_list || []).some(
+ m => m.model_id === this.modelId
+ );
+ if (!known) throw Error(`Configured WebLLM model is unavailable: ${this.modelId}`);
+ if (!this.engine) {
+ this.engine = await this.webllm.CreateMLCEngine(this.modelId, {
+ initProgressCallback: r =>
+ this.setStatus('LOADING LLM', 'busy', r?.text || 'Loading language model...')
+ });
+ }
+ this.ui.llmState.textContent = 'READY';
+ this.ui.generate.disabled = false;
+ await this.prepareTTS();
+ this.setStatus('LOCAL AI READY', 'ready', `${this.modelId} + Kokoro prepared.`);
+ if (this.ui.auto.checked) this.scheduleNext();
+ return true;
+ } catch (e) {
+ console.error(e);
+ this.setStatus('AI ERROR', 'error', e?.message || String(e));
+ return false;
+ } finally {
+ this.ui.prepare.disabled = false;
+ }
+ }
+ async prepareTTS() {
+ if (this.ttsReady) return true;
+ this.setSpeechState('loading');
+ try {
+ this.ui.ttsState.textContent = 'LOAD';
+ this.setStatus('LOADING VOICE', 'busy', 'Loading Kokoro neural voice model...');
+ this.kokoroModule =
+ this.kokoroModule || (await import('https://esm.sh/kokoro-js@1.2.1?bundle'));
+ const { KokoroTTS } = this.kokoroModule;
+ let lastErr = null;
+ for (const cfg of [
+ { dtype: 'fp32', device: 'webgpu' },
+ { dtype: 'q8', device: 'wasm' }
+ ]) {
+ try {
+ this.kokoro = await KokoroTTS.from_pretrained('onnx-community/Kokoro-82M-v1.0-ONNX', cfg);
+ break;
+ } catch (e) {
+ lastErr = e;
+ console.warn('Kokoro load attempt failed', cfg, e);
+ }
+ }
+ if (!this.kokoro) throw lastErr || Error('Kokoro failed to initialize');
+ this.populateKokoroVoices();
+ this.ttsReady = true;
+ this.ui.ttsState.textContent = 'READY';
+ this.setSpeechState('ready');
+ return true;
+ } catch (e) {
+ console.warn(e);
+ const fallbackReady = typeof window.SpeechSynthesisUtterance === 'function' &&
+ !!window.speechSynthesis?.getVoices().length;
+ this.ui.ttsState.textContent = fallbackReady ? 'FALLBACK' : 'ERROR';
+ this.ttsReady = false;
+ this.setSpeechState(fallbackReady ? 'ready' : 'error');
+ this.ui.progress.textContent = fallbackReady
+ ? 'Kokoro unavailable; browser speech fallback is ready.'
+ : 'Kokoro unavailable; no browser speech voice is ready.';
+ return false;
+ }
+ }
+ setSpeechState(state) {
+ if (this.speechState === state) return;
+ this.speechState = state;
+ this.onSpeechStateChanged(state);
+ }
+ populateKokoroVoices() {
+ if (!this.ui.voice || !this.kokoro) return;
+ const current = this.profile().voice;
+ let voices = this.kokoro.voices || {};
+ if (typeof this.kokoro.list_voices === 'function') {
+ try {
+ voices = this.kokoro.list_voices() || voices;
+ } catch (_) {}
+ }
+ this.ui.voice.innerHTML = '';
+ if (Array.isArray(voices)) {
+ voices.forEach(v => {
+ const key = typeof v === 'string' ? v : v.id || v.name;
+ if (!key) return;
+ const o = document.createElement('option');
+ o.value = key;
+ o.textContent = key;
+ this.ui.voice.appendChild(o);
+ });
+ } else {
+ Object.entries(voices).forEach(([key, val]) => {
+ const o = document.createElement('option');
+ o.value = key;
+ o.textContent = val?.name ? `${val.name} (${key})` : key;
+ this.ui.voice.appendChild(o);
+ });
+ }
+ if ([...this.ui.voice.options].some(o => o.value === current)) this.ui.voice.value = current;
+ }
+ profile() {
+ const id = this.getUniverse()?.id || 'starfleet';
+ return XZBT_SPEECH_PROFILES[id] || XZBT_SPEECH_PROFILES.starfleet;
+ }
+ voiceAutomationDefault_() {
+ const id = this.getUniverse()?.id || 'starfleet';
+ return XZBT_VOICE_AUTOMATION_DEFAULTS[id] ?? XZBT_VOICE_AUTOMATION_DEFAULTS.starfleet;
+ }
+ syncRobotUI_() {
+ if (!this.ui.robot) return;
+ const a = Math.max(0, Math.min(1, Number(this.ui.robot.value) || 0));
+ this.ui.robot.value = String(a);
+ this.robotAmount = a;
+ if (this.ui.robotValue) this.ui.robotValue.textContent = `${Math.round(a * 100)}%`;
+ }
+ applyThemeVoiceAutomation_() {
+ if (!this.ui.robot) return;
+ this.ui.robot.value = String(this.voiceAutomationDefault_());
+ this.syncRobotUI_();
+ }
+ onThemeChanged() {
+ this.history = [];
+ this.lastWorldState = null;
+ this.applyThemeVoiceAutomation_();
+ if (this.ttsReady) this.populateKokoroVoices();
+ }
+ pick_(a) {
+ return Array.isArray(a) && a.length ? a[Math.floor(Math.random() * a.length)] : '';
+ }
+ examplesFor_(severity) {
+ const e = this.profile().examples || {},
+ shuffle = a =>
+ a
+ .map(x => ({ x, r: Math.random() }))
+ .sort((a, b) => a.r - b.r)
+ .map(v => v.x),
+ primary = shuffle([...(e[severity] || [])]).slice(0, 2),
+ other =
+ severity === 'routine'
+ ? ['advisory']
+ : severity === 'advisory'
+ ? ['routine', 'recovery']
+ : severity === 'recovery'
+ ? ['routine', 'advisory']
+ : ['advisory', 'recovery'],
+ support = shuffle(other.flatMap(k => e[k] || [])).slice(0, 1);
+ return [...primary, ...support];
+ }
+ templatesFor_(severity) {
+ const t = this.profile().announcementTemplates || {};
+ return [...(t[severity] || []), ...(t.routine || [])]
+ .filter((x, i, a) => a.indexOf(x) === i)
+ .slice(0, 3);
+ }
+ makeWorldState(reason = 'ambient') {
+ const u = this.getUniverse() || {},
+ p = this.getPreset() || {},
+ profile = this.profile(),
+ topics = profile.topics.split(',').map(x => x.trim()),
+ severity = this.pick_(['routine', 'routine', 'routine', 'advisory', 'advisory', 'recovery']),
+ topic = this.pick_(topics),
+ topicKey = topic.toLowerCase(),
+ st = {
+ reason,
+ environment: u.name || u.id || 'unknown environment',
+ preset: p.name || 'active installation',
+ reportTopic: topic,
+ severity,
+ condition: this.pick_([
+ 'nominal',
+ 'stable',
+ 'minor fluctuation',
+ 'within tolerance',
+ 'quiet watch',
+ 'elevated activity'
+ ]),
+ observation: !!this.isObservation(),
+ timestamp: new Date().toLocaleTimeString()
+ };
+ const needsNav =
+ /navigation|course|traffic|contact|sensor|radio|docking|approach|spatial displacement/.test(
+ topicKey
+ );
+ const needsPlant =
+ /engineering|power|drive|pressure|propulsion|environment|mechanism|maintenance|load|fuel|machinery/.test(
+ topicKey
+ );
+ const needsBio = /biolog|metabol|neural|membrane|vascular|respir/.test(topicKey);
+ const needsTemporal = /temporal|chronometric|dimensional/.test(topicKey);
+ if (needsNav) {
+ const rangeKm = Math.round((3000 + Math.random() * 87000) / 100) * 100;
+ st.bearing = String(Math.floor(Math.random() * 360)).padStart(3, '0');
+ st.range = `${rangeKm} KM`;
+ st.contact = this.pick_([
+ 'routine traffic',
+ 'unidentified contact',
+ 'survey craft',
+ 'courier',
+ 'maintenance vehicle',
+ 'distant transient'
+ ]);
+ }
+ if (needsPlant)
+ st.subsystem = this.pick_([
+ 'primary feed',
+ 'translation field',
+ 'phase rotor',
+ 'environmental control',
+ 'communications relay',
+ 'drive assembly',
+ 'mechanical interlock'
+ ]);
+ if (needsBio)
+ st.bioRegion = this.pick_([
+ 'dorsal channels',
+ 'forward sensory ridge',
+ 'outer membrane',
+ 'aft lobe',
+ 'respiratory folds',
+ 'regenerative tissue'
+ ]);
+ if (needsTemporal) {
+ st.temporalMetric = this.pick_([
+ 'temporal index',
+ 'reference lock',
+ 'chronometric drift',
+ 'translation coherence',
+ 'phase alignment'
+ ]);
+ st.variance = `${1 + Math.floor(Math.random() * 8)} points`;
+ }
+ this.lastWorldState = st;
+ return st;
+ }
+ speechRuleText_() {
+ const r = this.profile().speechRules || XZBT_COMMON_SPEECH_RULES;
+ return `Speech rules:
+- Output natural TTS-ready spoken language.
+- Expand all compact units and abbreviations. Never output KM, KM/S, MW, GW, kHz, MHz, GHz, %, or bare C for temperature.
+- Speak bearings and identifiers digit by digit, preserving leading zeroes.
+- Speak ordinary quantities as natural numbers.
+- Speak decimal points aloud.
+- ${r.fallbackRule}`;
+ }
+ systemPrompt() {
+ const p = this.profile(),
+ l = p.lexicon || { prefer: [], avoid: [] };
+ return `/no_think\nYou are the ${p.role} for an ambient science-fiction exhibition. The simulation engine is authoritative and supplies every fact. Report only supplied facts.\nReporting priorities: ${p.topics}.\nTheme voice: ${p.terms}\nPreferred vocabulary: ${(l.prefer || []).join(', ')}.\nAvoid vocabulary: ${(l.avoid || []).join(', ')}.\n${this.speechRuleText_()}\nUse the supplied examples as the strongest guide to voice, rhythm, vocabulary and sentence construction. Invent only harmless connective phrasing needed to verbalize supplied state. Never invent causes, intentions, emergencies, casualties, weapons fire, characters, franchises, lore or mission-changing events. Never expose chain-of-thought, reasoning, analysis or tags. Produce exactly one concise spoken update, normally 8-28 words, with no quotation marks, labels, markdown or explanation.`;
+ }
+ userPrompt(st) {
+ const recent = this.history.slice(-12),
+ openings = recent.map(x => x.split(/\s+/).slice(0, 4).join(' ')),
+ examples = this.examplesFor_(st.severity),
+ templates = this.templatesFor_(st.severity);
+ return `/no_think
+Generate one short spoken computer announcement about ONLY the selected report topic.
+
+Theme examples (${st.severity}):
+${examples.map(x => '- ' + x).join('\n')}
+
+Reusable announcement patterns:
+${templates.map(x => '- ' + x).join('\n') || '(none)'}
+
+Current engine state:
+${JSON.stringify(st)}
+
+Recent announcements that MUST NOT be repeated or closely paraphrased:
+${recent.join('\n') || '(none)'}
+
+Avoid these opening constructions:
+${openings.join('\n') || '(none)'}
+
+Use only facts present in Current engine state. Do not borrow facts or numeric values from the examples. Match the examples only for voice and sentence style. If a fact is absent, do not mention it. Expand every unit and abbreviation. Return only the final spoken update.`;
+ }
+ repetitionKey_(text) {
+ return String(text || '')
+ .toLowerCase()
+ .replace(
+ /\b(?:zero|one|two|three|four|five|six|seven|eight|nine|ten|eleven|twelve|thirteen|fourteen|fifteen|sixteen|seventeen|eighteen|nineteen|twenty|thirty|forty|fifty|sixty|seventy|eighty|ninety|hundred|thousand|point)\b/g,
+ '#'
+ )
+ .replace(/[^a-z# ]+/g, ' ')
+ .replace(/\s+/g, ' ')
+ .trim();
+ }
+ contentTokens_(text) {
+ const stop = new Set([
+ 'the',
+ 'a',
+ 'an',
+ 'and',
+ 'or',
+ 'of',
+ 'to',
+ 'in',
+ 'on',
+ 'at',
+ 'for',
+ 'with',
+ 'from',
+ 'is',
+ 'are',
+ 'has',
+ 'have',
+ 'remains',
+ 'remain',
+ 'within',
+ 'status',
+ 'system',
+ 'systems',
+ 'condition',
+ 'report',
+ 'stable',
+ 'nominal',
+ 'normal',
+ 'currently',
+ 'still',
+ '#'
+ ]);
+ return this.repetitionKey_(text)
+ .split(' ')
+ .filter(x => x && !stop.has(x));
+ }
+ similarity_(a, b) {
+ const aa = new Set(this.contentTokens_(a)),
+ bb = new Set(this.contentTokens_(b));
+ if (!aa.size || !bb.size) return 0;
+ let hit = 0;
+ aa.forEach(x => {
+ if (bb.has(x)) hit++;
+ });
+ return hit / Math.max(aa.size, bb.size);
+ }
+ hardDuplicate_(line) {
+ const key = this.repetitionKey_(line);
+ return !!key && this.history.slice(-16).some(prev => this.repetitionKey_(prev) === key);
+ }
+ repetitionScore_(line) {
+ let max = 0;
+ for (const prev of this.history.slice(-10)) max = Math.max(max, this.similarity_(line, prev));
+ return max;
+ }
+ tooRepetitive_(line) {
+ return this.hardDuplicate_(line) || this.repetitionScore_(line) >= 0.86;
+ }
+ fallbackAnnouncement_(st) {
+ const topic = String(st.reportTopic || 'systems')
+ .replace(/\s+/g, ' ')
+ .trim(),
+ condition = String(st.condition || 'stable').toLowerCase();
+ const phrase =
+ condition === 'minor fluctuation'
+ ? 'showing a minor fluctuation'
+ : condition === 'within tolerance'
+ ? 'within tolerance'
+ : condition === 'quiet watch'
+ ? 'quiet and stable'
+ : condition === 'elevated activity'
+ ? 'showing elevated activity'
+ : condition === 'nominal'
+ ? 'nominal'
+ : 'stable';
+ const id = this.getUniverse()?.id || '';
+ if (id === 'military')
+ return `${topic.charAt(0).toUpperCase() + topic.slice(1)} ${phrase === 'nominal' ? 'reports nominal condition' : phrase === 'stable' ? 'reports stable condition' : phrase}.`;
+ if (id === 'comedy')
+ return `${topic.charAt(0).toUpperCase() + topic.slice(1)} ${phrase}, which remains administratively acceptable.`;
+ return `${topic.charAt(0).toUpperCase() + topic.slice(1)} ${phrase}.`;
+ }
+ numberWords_(n) {
+ n = Math.trunc(Math.abs(Number(n) || 0));
+ const o = [
+ 'zero',
+ 'one',
+ 'two',
+ 'three',
+ 'four',
+ 'five',
+ 'six',
+ 'seven',
+ 'eight',
+ 'nine',
+ 'ten',
+ 'eleven',
+ 'twelve',
+ 'thirteen',
+ 'fourteen',
+ 'fifteen',
+ 'sixteen',
+ 'seventeen',
+ 'eighteen',
+ 'nineteen'
+ ],
+ t = ['', '', 'twenty', 'thirty', 'forty', 'fifty', 'sixty', 'seventy', 'eighty', 'ninety'];
+ if (n < 20) return o[n];
+ if (n < 100) return t[Math.floor(n / 10)] + (n % 10 ? '-' + o[n % 10] : '');
+ if (n < 1000)
+ return (
+ o[Math.floor(n / 100)] + ' hundred' + (n % 100 ? ' ' + this.numberWords_(n % 100) : '')
+ );
+ if (n < 1000000)
+ return (
+ this.numberWords_(Math.floor(n / 1000)) +
+ ' thousand' +
+ (n % 1000 ? ' ' + this.numberWords_(n % 1000) : '')
+ );
+ return String(n)
+ .split('')
+ .map(d => o[Number(d)])
+ .join(' ');
+ }
+ spokenNumber_(raw) {
+ const x = String(raw),
+ neg = x.startsWith('-'),
+ v = neg ? x.slice(1) : x;
+ if (v.includes('.')) {
+ const [a, b] = v.split('.');
+ return `${neg ? 'minus ' : ''}${this.numberWords_(Number(a))} point ${b
+ .split('')
+ .map(d => this.numberWords_(Number(d)))
+ .join(' ')}`;
+ }
+ return `${neg ? 'minus ' : ''}${this.numberWords_(Number(v))}`;
+ }
+ ttsSafe_(text) {
+ let s = String(text || ''),
+ units = this.profile().speechRules?.units || XZBT_COMMON_SPEECH_RULES.units;
+ // Pass 1: compact numeric measurements such as 482 km, 0.8 GHz and 74%.
+ for (const key of Object.keys(units).sort((a, b) => b.length - a.length)) {
+ const val = units[key];
+ if (key === '%') {
+ s = s.replace(/(-?\d+(?:\.\d+)?)\s*%/g, (_, n) => `${this.spokenNumber_(n)} ${val}`);
+ continue;
+ }
+ const esc = key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+ s = s.replace(
+ new RegExp(`(-?\\d+(?:\\.\\d+)?)\\s*${esc}\\b`, 'gi'),
+ (_, n) => `${this.spokenNumber_(n)} ${val}`
+ );
+ }
+ // Pass 2: unit aliases that may follow numbers the model already spelled out.
+ // Do not reconstruct the number words; only make the unit safe for speech.
+ const spokenUnitAliases = [
+ [/\bkm\s*\/\s*(?:s|sec|second)s?\b/gi, 'kilometers per second'],
+ [/\bkm\s+per\s+(?:s|sec|second)s?\b/gi, 'kilometers per second'],
+ [/\bkm\b/gi, 'kilometers'],
+ [/\bkilometres\b/gi, 'kilometers'],
+ [/\bm\s*\/\s*(?:s|sec|second)s?\b/gi, 'meters per second'],
+ [/\bmw\b/gi, 'megawatts'],
+ [/\bgw\b/gi, 'gigawatts'],
+ [/\bkw\b/gi, 'kilowatts'],
+ [/\bkhz\b/gi, 'kilohertz'],
+ [/\bmhz\b/gi, 'megahertz'],
+ [/\bghz\b/gi, 'gigahertz'],
+ [/\bhz\b/gi, 'hertz'],
+ [/\bkpa\b/gi, 'kilopascals'],
+ [/\bmpa\b/gi, 'megapascals']
+ ];
+ spokenUnitAliases.forEach(([re, val]) => {
+ s = s.replace(re, val);
+ });
+ s = s.replace(
+ /\bbearing\s+([0-9]{1,3})\b/gi,
+ (_, n) =>
+ `bearing ${String(n)
+ .padStart(3, '0')
+ .split('')
+ .map(d => this.numberWords_(Number(d)))
+ .join(' ')}`
+ );
+ s = s.replace(
+ /\b(identifier|track)\s+([A-Z]?\d{2,})\b/gi,
+ (_, label, n) =>
+ `${label} ${n
+ .split('')
+ .map(ch => (/\d/.test(ch) ? this.numberWords_(Number(ch)) : ch))
+ .join(' ')}`
+ );
+ return s.replace(/\s+/g, ' ').trim();
+ }
+ sanitize(t) {
+ let s = String(t || '');
+ // Qwen3-class models may emit reasoning payloads even when only a short answer is wanted.
+ // Remove complete blocks first, then defensively discard malformed/unclosed reasoning prefixes.
+ s = s.replace(/]*)?>[\s\S]*?<\/think>/gi, ' ');
+ s = s.replace(/]*)?>[\s\S]*?<\/analysis>/gi, ' ');
+ s = s.replace(/]*)?>[\s\S]*?<\/reasoning>/gi, ' ');
+ const lastThinkClose = s.toLowerCase().lastIndexOf(' ');
+ if (lastThinkClose >= 0) s = s.slice(lastThinkClose + 8);
+ const openReasoning = s.search(/<(think|analysis|reasoning)(?:\s[^>]*)?>/i);
+ if (openReasoning >= 0) {
+ const before = s.slice(0, openReasoning).trim();
+ s = before; // never speak an unclosed reasoning payload
+ }
+ s = s
+ .replace(/<\/?(?:think|analysis|reasoning)(?:\s[^>]*)?>/gi, ' ')
+ .replace(/^```[a-z]*|```$/gim, '')
+ .replace(/^\s*(assistant|computer|announcement|status)\s*:\s*/i, '')
+ .replace(/["“”]/g, '')
+ .replace(/\s+/g, ' ')
+ .trim();
+ return this.ttsSafe_(s.slice(0, 360));
+ }
+ async generateAmbientMoment(reason = 'ambient') {
+ if (!this.engine) {
+ const ok = await this.prepareExperience();
+ if (!ok || !this.engine) return null;
+ }
+ if (this.busy) return null;
+ let state = this.makeWorldState(reason);
+ try {
+ this.busy = true;
+ this.ui.generate.disabled = true;
+ this.setStatus('GENERATING', 'busy', `Composing ${reason.replace(/-/g, ' ')} locally...`);
+ let line = '',
+ bestLine = '',
+ bestState = state,
+ bestScore = Infinity;
+ for (let attempt = 0; attempt < 4; attempt++) {
+ if (attempt > 0) state = this.makeWorldState(reason);
+ const retryNote = attempt
+ ? `
+
+VARIATION REQUIRED: choose a different sentence construction from recent announcements. The current engine state may also use a different report topic. Do not force familiar theme catchphrases unless they are needed by the supplied facts.`
+ : '';
+ const c = await this.engine.chat.completions.create({
+ messages: [
+ { role: 'system', content: this.systemPrompt() },
+ { role: 'user', content: this.userPrompt(state) + retryNote }
+ ],
+ temperature: 0.76 + attempt * 0.06,
+ top_p: 0.94,
+ frequency_penalty: 0.82,
+ presence_penalty: 0.42,
+ max_tokens: 80
+ });
+ const candidate = this.sanitize(c?.choices?.[0]?.message?.content);
+ if (!candidate) continue;
+ const score = this.repetitionScore_(candidate);
+ if (!this.hardDuplicate_(candidate) && score < bestScore) {
+ bestLine = candidate;
+ bestState = state;
+ bestScore = score;
+ }
+ if (!this.tooRepetitive_(candidate)) {
+ line = candidate;
+ break;
+ }
+ }
+ if (!line && bestLine) {
+ line = bestLine;
+ state = bestState;
+ }
+ if (!line) {
+ line = this.sanitize(this.fallbackAnnouncement_(state));
+ if (this.hardDuplicate_(line)) {
+ this.ui.output.textContent = 'DUPLICATE CHATTER SUPPRESSED';
+ this.setStatus(
+ 'LOCAL AI READY',
+ 'ready',
+ 'No sufficiently fresh line this cycle; continuing normally.'
+ );
+ return { line: null, state, suppressed: true };
+ }
+ }
+ this.history.push(line);
+ if (this.history.length > 24) this.history.shift();
+ this.ui.output.textContent = line;
+ this.setStatus(
+ 'LOCAL AI READY',
+ 'ready',
+ `${this.modelId} • ${state.timestamp}${bestScore >= 0.86 ? ' • relaxed variation' : ''}`
+ );
+ this.onGenerated(line, state);
+ if (this.ui.speech.checked) await this.speak(line);
+ return { line, state };
+ } catch (e) {
+ console.error(e);
+ this.setStatus('GEN ERROR', 'error', e?.message || String(e));
+ return null;
+ } finally {
+ this.busy = false;
+ this.ui.generate.disabled = !this.engine;
+ if (this.ui.auto.checked) this.scheduleNext();
+ }
+ }
+ async describeExistingEvent(type) {
+ if (!this.engine || !this.ui.auto.checked || this.busy || Math.random() > 0.58) return;
+ await this.generateAmbientMoment(`operator-event:${type}`);
+ }
+ roboticAmount() {
+ return this.robotAmount ?? Math.max(0, Math.min(1, Number(this.ui.robot?.value) || 0));
+ }
+ makeRobotCurve(amount) {
+ const n = 1024,
+ curve = new Float32Array(n),
+ drive = 1 + amount * 5;
+ for (let i = 0; i < n; i++) {
+ const x = (i * 2) / (n - 1) - 1;
+ curve[i] = Math.tanh(x * drive) / Math.tanh(drive);
+ }
+ return curve;
+ }
+ async playProcessedSpeech(a) {
+ const amount = this.roboticAmount();
+ const fnc = window.fncSystem;
+ if (fnc) fnc.hold('speech', true);
+ const releaseFnc = () => {
+ if (fnc) fnc.hold('speech', false);
+ };
+ a.addEventListener('ended', releaseFnc, { once: true });
+ a.addEventListener(
+ 'pause',
+ () => {
+ if (!a.ended) releaseFnc();
+ },
+ { once: true }
+ );
+ if (!this.audioManager || amount <= 0.001) {
+ a.volume = 0.92;
+ try {
+ await a.play();
+ } catch (e) {
+ releaseFnc();
+ throw e;
+ }
+ return;
+ }
+ await this.audioManager.resume();
+ const ctx = this.audioManager.ctx;
+ if (!ctx || !this.audioManager.compressor) {
+ a.volume = 0.92;
+ try {
+ await a.play();
+ } catch (e) {
+ releaseFnc();
+ throw e;
+ }
+ return;
+ }
+ const src = ctx.createMediaElementSource(a),
+ hp = ctx.createBiquadFilter(),
+ lp = ctx.createBiquadFilter(),
+ shape = ctx.createWaveShaper(),
+ dry = ctx.createGain(),
+ robot = ctx.createGain(),
+ robotOut = ctx.createGain(),
+ lfo = ctx.createOscillator(),
+ depth = ctx.createGain();
+ hp.type = 'highpass';
+ hp.frequency.value = 90 + amount * 120;
+ hp.Q.value = 0.7;
+ lp.type = 'lowpass';
+ lp.frequency.value = 9000 - amount * 3800;
+ lp.Q.value = 0.6;
+ shape.curve = this.makeRobotCurve(amount);
+ shape.oversample = '2x';
+ dry.gain.value = 1 - amount * 0.12;
+ robot.gain.value = 0;
+ robotOut.gain.value = amount * 0.9;
+ lfo.type = 'sine';
+ lfo.frequency.value = 18 + amount * 28;
+ depth.gain.value = 0.035 + amount * 0.18;
+ lfo.connect(depth);
+ depth.connect(robot.gain);
+ src.connect(hp);
+ hp.connect(lp);
+ lp.connect(dry);
+ dry.connect(this.audioManager.compressor);
+ lp.connect(shape);
+ shape.connect(robot);
+ robot.connect(robotOut);
+ robotOut.connect(this.audioManager.compressor);
+ lfo.start();
+ a.addEventListener(
+ 'ended',
+ () => {
+ try {
+ lfo.stop();
+ } catch (_) {}
+ },
+ { once: true }
+ );
+ a.addEventListener(
+ 'pause',
+ () => {
+ if (a.ended) return;
+ try {
+ lfo.stop();
+ } catch (_) {}
+ },
+ { once: true }
+ );
+ await a.play();
+ }
+ async speak(text) {
+ if (!text) return;
+ const p = this.profile(),
+ amount = this.roboticAmount();
+ if (this.ttsReady && this.kokoro) {
+ try {
+ const voice = this.ui.voice?.value || p.voice;
+ const raw = await this.kokoro.generate(text, { voice, speed: p.speed || 0.92 });
+ const blob = raw.toBlob();
+ if (this.audioUrl) URL.revokeObjectURL(this.audioUrl);
+ this.audioUrl = URL.createObjectURL(blob);
+ if (this.currentAudio) {
+ this.currentAudio.pause();
+ this.currentAudio = null;
+ }
+ const a = new Audio(this.audioUrl);
+ this.currentAudio = a;
+ await this.playProcessedSpeech(a);
+ return;
+ } catch (e) {
+ console.warn('Kokoro playback failed, using browser fallback', e);
+ }
+ }
+ if ('speechSynthesis' in window) {
+ try {
+ speechSynthesis.cancel();
+ const u = new SpeechSynthesisUtterance(text);
+ u.rate = (p.speed || 0.92) * (1 - amount * 0.04);
+ u.pitch = 0.88 - amount * 0.09;
+ u.volume = 0.9;
+ u.onstart = () => this.setSpeechState('ready');
+ const fnc = window.fncSystem;
+ if (fnc) fnc.hold('speech', true);
+ u.onend = u.onerror = () => {
+ if (fnc) fnc.hold('speech', false);
+ };
+ speechSynthesis.speak(u);
+ } catch (e) {
+ console.warn(e);
+ if (window.fncSystem) window.fncSystem.hold('speech', false);
+ }
+ }
+ }
+ setAuto(on) {
+ clearTimeout(this.autoTimer);
+ this.autoTimer = null;
+ if (on) this.scheduleNext();
+ }
+ scheduleNext() {
+ clearTimeout(this.autoTimer);
+ this.autoTimer = null;
+ if (!this.ui.auto?.checked || !this.engine) return;
+ const b = Number(this.ui.cadence.value || 90),
+ secs = b + Math.random() * b;
+ this.autoTimer = setTimeout(async () => {
+ if (this.isPlaying()) await this.generateAmbientMoment('autonomous-watch');
+ else this.scheduleNext();
+ }, secs * 1000);
+ }
+ stop() {
+ clearTimeout(this.autoTimer);
+ this.autoTimer = null;
+ if (this.currentAudio) {
+ this.currentAudio.pause();
+ this.currentAudio = null;
+ }
+ if ('speechSynthesis' in window) speechSynthesis.cancel();
+ }
+}
+window.XZBTGenerativeExperience = XZBTGenerativeExperience;
diff --git a/test-fixtures/reference-exhibits/scifi/js/observation-bezels.js b/test-fixtures/reference-exhibits/scifi/js/observation-bezels.js
new file mode 100644
index 0000000..bfd2c5c
--- /dev/null
+++ b/test-fixtures/reference-exhibits/scifi/js/observation-bezels.js
@@ -0,0 +1,270 @@
+/**
+ * Procedural SVG Viewport Frames & Bezels for Observation Mode
+ */
+window.ObservationBezels = {
+ getViewportFrameSvg(universeId, presetId, engine) {
+ if (universeId === 'spacestations') {
+ // Space Stations has its own authentic native station viewport in observationStage!
+ return '';
+ }
+
+ if (universeId === 'starfleet') {
+ let era = 'tng';
+ const preset = (typeof StarshipPresets !== 'undefined' && presetId) ? StarshipPresets[presetId] : null;
+ if (preset && preset.era) era = preset.era;
+
+ if (era === 'tos') {
+ // TOS Original Series - hexagonal bridge viewscreen bezel
+ return `
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ${(engine ? engine.showPillars : false) ? `
+
+
+ ` : ''}
+
+ U.S.S. INTERPRISE NCC-1701.5 // BRIDGE VIEWSCREEN
+
+ `;
+ }
+
+ if (era === 'voyager') {
+ // Wayfarer Intrepid Class - sleeker rounded modern arch, cyan accent
+ return `
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ${(engine ? engine.showPillars : false) ? `
+
+
+ ` : ''}
+
+
+
+ USS WAYFARER // BRIDGE VIEWPORT
+
+
+ `;
+ }
+
+ if (era === 'ds9') {
+ // Defiance Class / DS9 Ops - angular, aggressive warship framing
+ return `
+
+
+
+
+
+
+
+
+
+
+
+
+ USS DEFIANCE // TACTICAL BRIDGE VIEWPORT
+
+ `;
+ }
+
+ if (era === 'nx') {
+ // Interprise NS-01 - early, industrial, submarine-like bulkhead
+ return `
+
+
+
+
+
+
+
+
+
+
+
+
+ INTERPRISE NS-01 // COMMAND BRIDGE VIEWPORT
+
+ `;
+ }
+ // era === 'tng' (or unrecognized) falls through to the default Ten Forward arch below.
+ }
+
+ if (universeId === 'industrial' || universeId === 'outlaw') {
+ // Heavy Industrial Reinforced Bulkhead & Riveted Blast Shutter Framing
+ return `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ${(engine ? engine.showPillars : false) ? `
+
+
+
+ ` : ''}
+
+
+
+
+
+
+ HEAVY INDUSTRIAL VIEWPORT // DECK 04 CARGO GANTRY
+
+ `;
+ }
+
+ if (universeId === 'whataverse') {
+ // Gallifreyan Observatory Roundel Viewing Portal / TARDIX Frame
+ // Console era (classic/revival/modern) tints the rings and label.
+ let era = 'revival';
+ const wUniverse = (typeof UniverseRegistry !== 'undefined') ? UniverseRegistry['whataverse'] : null;
+ const preset = (wUniverse && wUniverse.presets && presetId) ? wUniverse.presets[presetId] : null;
+ if (preset && preset.era) era = preset.era;
+
+ let ringA = '#d4af37', ringB = '#00e5ff', dash = '16 12', labelColor = '#d4af37';
+ let label = 'T.A.R.D.I.X. TEMPORAL VORTEX OBSERVATION PORTAL';
+ if (era === 'classic') {
+ ringA = '#b45309'; ringB = '#f5deb3'; dash = '22 10'; labelColor = '#f5deb3';
+ label = 'TYPE 4D CLASSIC CONSOLE // TEMPORAL VIEWPORT';
+ } else if (era === 'modern') {
+ ringA = '#e2e8f0'; ringB = '#94a3b8'; dash = '4 6'; labelColor = '#e2e8f0';
+ label = 'INFINITE WHITE // TEMPORAL OBSERVATION PORTAL';
+ }
+
+ return `
+
+
+
+
+
+
+ ${label}
+
+ `;
+ }
+
+ if (universeId === 'deepspace') {
+ // Terok Nor / DS9 Arched Station Viewport overlooking Docking Pylons
+ return `
+
+
+
+
+
+
+ ${(engine ? engine.showPillars : false) ? `
+
+
+
+ ` : ''}
+
+ DEEP SPACE STATION // PROMENADE OBSERVATION DECK
+
+ `;
+ }
+
+ if (universeId === 'bioships' || universeId === 'retrofuture' || universeId === 'military' || universeId === 'comedy') {
+ // These four universes previously had no case here at all and silently fell through
+ // to the Starflight default below, which hardcodes "USS INTERPRISE-G" into the plaque --
+ // showing the wrong ship name in every one of these themes' Observation view.
+ // Fix: reuse the same bulkhead shape (it already themes correctly via the CSS
+ // accent variables set per universe), but pull the REAL ship/deck name for the
+ // plaque text from this universe's own preset data, same source the header uses.
+ const universe = UniverseRegistry[universeId];
+ const preset = (universe && universe.presets && presetId) ? universe.presets[presetId] : null;
+ const plaqueText = preset
+ ? `${preset.name.toUpperCase()} // OBSERVATION DECK`
+ : `${universe ? universe.name : universeId.toUpperCase()} // OBSERVATION DECK`;
+
+ return `
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ${(engine ? engine.showPillars : false) ? `
+
+
+ ` : ''}
+
+
+
+ ${plaqueText}
+
+
+ `;
+ }
+
+ // Default: Iconic Starflight Ten Forward / Galaxy-Class Observation Lounge
+ return `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ${(engine ? engine.showPillars : false) ? `
+
+
+
+ ` : ''}
+
+
+
+
+ USS INTERPRISE-G // OBSERVATION LOUNGE // DECK 10 FORWARD
+
+
+ `;
+ }
+};
diff --git a/test-fixtures/reference-exhibits/scifi/js/observation-engine.js b/test-fixtures/reference-exhibits/scifi/js/observation-engine.js
new file mode 100644
index 0000000..7785b6b
--- /dev/null
+++ b/test-fixtures/reference-exhibits/scifi/js/observation-engine.js
@@ -0,0 +1,3595 @@
+const OBSERVATION_CANVAS = {
+ starfleet: { canvas: true, layers: { celestial: true, starfield: true },
+ // Bespoke: sparser and cooler than Deep Space's field -- a calm survey sky
+ // behind the Class-M planet, not a busy multi-hued star rush.
+ starfield: { count: 150, sizeMin: 0.6, sizeRange: 1.5, warpStreaks: true,
+ colors: ['#e0f2fe', '#ffffff', '#bfdbfe', '#93c5fd', '#a5f3fc'] } },
+
+ whataverse: { canvas: true, layers: { celestial: true } }, // Time Vortex on black, deliberately starless
+
+ deepspace: { canvas: true, layers: { starfield: true, deepSpaceField: true,
+ constellations: true, shootingStars: true,
+ events: true } },
+
+ spacestations: { canvas: true, layers: { stationPanorama: true, shootingStars: true,
+ traffic: true, reticles: true,
+ events: true, cinematics: true } },
+
+ industrial: { canvas: false, layers: {} },
+ bioships: { canvas: false, layers: {} },
+ retrofuture: { canvas: false, layers: {} },
+ military: { canvas: false, layers: {} },
+ outlaw: { canvas: false, layers: {} },
+ comedy: { canvas: false, layers: {} }
+};
+
+// Default starfield profile (Deep Space and any future universe that declares `starfield`
+// without its own profile).
+const DEFAULT_STARFIELD_PROFILE = {
+ count: 340, sizeMin: 0.8, sizeRange: 2.2, warpStreaks: true,
+ colors: ['#e0f2fe', '#ffffff', '#bfdbfe', '#fef08a', '#fca5a5', '#93c5fd', '#fdba74', '#a5f3fc', '#f0abfc', '#fde68a']
+};
+
+class ObservationEngine {
+ constructor(audioManager, warpSynth, alertSynth, hullDroneSynth, lifeSupportSynth) {
+ this.am = audioManager;
+ this.warpSynth = warpSynth;
+ this.alerts = alertSynth;
+ this.hullDrone = hullDroneSynth || null;
+ this.lifeSupport = lifeSupportSynth || null;
+
+ // DOM Elements
+ this.overlay = document.getElementById('observation-overlay');
+ this.canvas = document.getElementById('observation-canvas');
+ this.ctx = this.canvas ? this.canvas.getContext('2d') : null;
+ this.frameEl = document.getElementById('observation-viewport-frame');
+ this.alertWash = document.getElementById('observation-alert-wash');
+ this.dock = document.getElementById('observation-control-dock');
+ this.btnWarp = document.getElementById('obs-btn-warp');
+ this.btnFrame = document.getElementById('obs-btn-frame');
+ this.btnPillars = document.getElementById('obs-btn-pillars');
+ this.btnAlert = document.getElementById('obs-btn-alert');
+ this.btnExit = document.getElementById('obs-btn-exit');
+ this.selectPreset = document.getElementById('obs-select-preset');
+ this.mainToggleViewport = document.getElementById('toggle-observation-viewport');
+ this.mainValViewport = document.getElementById('val-observation-viewport');
+ this.mainTogglePillars = document.getElementById('toggle-observation-pillars');
+ this.mainValPillars = document.getElementById('val-observation-pillars');
+ this.waveformCanvas = document.getElementById('observation-waveform-canvas');
+ this.waveformCtx = this.waveformCanvas ? this.waveformCanvas.getContext('2d') : null;
+ this.waveformContainer = document.getElementById('observation-waveform-container');
+ this.waveformLabelEl = this.waveformContainer ? this.waveformContainer.querySelector('.observation-waveform-label') : null;
+
+ // State
+ this.running = false;
+ this.animId = null;
+ this.width = 1600;
+ this.height = 900;
+ this.dpr = 1;
+ this.activeUniverse = 'starfleet';
+ this.flightMode = 'cruise'; // 'cruise' or 'warp'
+ this.warpSpeed = 1.0;
+ this.targetWarpSpeed = 1.0;
+ this.warpPulseEnergy = 0.0;
+ this.showViewport = true;
+ this.showPillars = false; // Default OFF per user request!
+ this.cameraWobble = { x: 0, y: 0 };
+ this.time = 0;
+ this.lastFrameTime = performance.now();
+
+ // 3D Starfield
+ this.stars = [];
+ this.starCount = 340;
+ this.starColors = ['#e0f2fe', '#ffffff', '#bfdbfe', '#fef08a', '#fca5a5', '#93c5fd', '#fdba74', '#a5f3fc', '#f0abfc', '#fde68a'];
+
+ // Entities & Encounters
+ this.traffic = [];
+ this.events = [];
+ this.nextTrafficTime = 0;
+ this.nextEventTime = 0;
+ this.dockHideTimer = null;
+
+ // Audio Analysis buffer
+ this.analyserData = new Uint8Array(64);
+
+ // --- Observation Mode Enhancements: State ---
+ // Audio-visual sync
+ this.audioEnergy = { bass: 0, mid: 0, treble: 0, overall: 0 };
+ this.viewportVibration = { x: 0, y: 0 };
+ this.scanlineBreath = 0.14;
+ this.scanlinesEl = this.overlay ? this.overlay.querySelector('.observation-scanlines') : null;
+
+ // Nebula morphing
+ this.nebulaBlobs = [];
+
+ // Binary star systems (occasional background variety for select universes)
+ this.binaryStar = null;
+
+ // Shooting stars
+ this.shootingStars = [];
+ this.nextShootingStarTime = 0;
+
+ // Constellation lines (self-contained synthetic points, independent of the main starfield)
+ this.constellationPoints = null;
+ this.constellationLines = null;
+ this.constellationAlpha = 0;
+ this.nextConstellationTime = 0;
+
+ // 25-minute ambient lighting cycle
+ this.lightingCycleTime = 0;
+ this.lightingModifiers = null;
+
+ // Cinematic event director
+ this.cinematicActive = null;
+ this.cinematicCaption = null;
+ this.nextCinematicTime = 0;
+
+ // HUD status ticker
+ this.tickerTextEl = document.getElementById('observation-ticker-text');
+ this.tickerMessages = [];
+ this.tickerIndex = -1;
+ this.nextTickerTime = 0;
+
+ // Space Stations: 360-degree rotating panorama.
+ // Generated ONCE per page load and retained for the whole session (across entering/leaving
+ // Observation Mode and preset switches). Reloading the page rolls a brand new sky.
+ this.stationRotation = Math.random(); // starting bearing, 0..1 of a full revolution
+ this.stationRevolutionSeconds = 240; // ~4 minutes for a full 360
+ this.stationFov = 0.22; // visible window covers ~22% of the full circle
+ this.stationPanorama = null;
+
+ // Deep Space: procedurally generated celestial field (planets, nebula clouds, dust lanes,
+ // derelicts, a drifting asteroid field, and gravitational/radiation anomalies). Generated
+ // ONCE per page load and retained for the whole session -- same contract as the station
+ // panorama above. Reloading the page rolls a brand new sky; it never reshuffles mid-session
+ // even as you enter/leave Observation Mode or flip presets.
+ this.deepSpaceField = null;
+
+ this.initStars();
+ this.initNebulaBlobs();
+ this.regenerateStationPanorama();
+ this.regenerateDeepSpaceField();
+ this.initEventListeners();
+ }
+
+ initStars() {
+ // v3co: the starfield is built from the active universe's declared profile, so two
+ // universes that both draw stars are still drawing their own sky, not a shared layer.
+ const prof = this.getStarfieldProfile();
+ this.starProfile = prof;
+ this.starCount = prof.count;
+ this.stars = [];
+ for (let i = 0; i < prof.count; i++) {
+ this.stars.push({
+ x: (Math.random() - 0.5) * 2600,
+ y: (Math.random() - 0.5) * 1600,
+ z: Math.random() * 1200 + 10,
+ pz: 1200,
+ size: Math.random() * prof.sizeRange + prof.sizeMin,
+ color: prof.colors[Math.floor(Math.random() * prof.colors.length)],
+ twinkleSpeed: Math.random() * 3 + 1,
+ twinklePhase: Math.random() * Math.PI * 2
+ });
+ }
+ }
+
+ resize() {
+ if (!this.canvas) return;
+ this.dpr = (window.devicePixelRatio || 1) * (window.xzbtRenderScale || 1);
+ this.width = window.innerWidth;
+ this.height = window.innerHeight;
+
+ this.canvas.width = this.width * this.dpr;
+ this.canvas.height = this.height * this.dpr;
+ if (this.ctx) {
+ this.ctx.setTransform(this.dpr, 0, 0, this.dpr, 0, 0);
+ }
+
+ if (this.waveformCanvas) {
+ const w = this.waveformCanvas.clientWidth || 360;
+ const h = this.waveformCanvas.clientHeight || 24;
+ this.waveformCanvas.width = w * this.dpr;
+ this.waveformCanvas.height = h * this.dpr;
+ if (this.waveformCtx) {
+ this.waveformCtx.setTransform(this.dpr, 0, 0, this.dpr, 0, 0);
+ }
+ }
+ }
+
+ initEventListeners() {
+ window.addEventListener('resize', () => {
+ if (this.running) this.resize();
+ });
+
+ if (this.btnWarp) {
+ this.btnWarp.addEventListener('click', (e) => {
+ e.stopPropagation();
+ this.toggleWarpFlight();
+ });
+ }
+
+ if (this.btnFrame) {
+ this.btnFrame.addEventListener('click', (e) => {
+ e.stopPropagation();
+ if (this.onMutation) this.onMutation('view.viewport-frame', !this.showViewport);
+ else this.toggleViewport();
+ });
+ }
+
+ if (this.btnPillars) {
+ this.btnPillars.addEventListener('click', (e) => {
+ e.stopPropagation();
+ this.togglePillars();
+ });
+ }
+
+ if (this.mainToggleViewport) {
+ this.mainToggleViewport.addEventListener('change', () => {
+ if (this.onMutation) this.onMutation('view.viewport-frame', this.mainToggleViewport.checked);
+ else this.setViewport(this.mainToggleViewport.checked);
+ });
+ }
+
+ if (this.mainTogglePillars) {
+ this.mainTogglePillars.addEventListener('change', () => {
+ this.setPillars(this.mainTogglePillars.checked);
+ });
+ }
+
+ if (this.btnAlert) {
+ this.btnAlert.addEventListener('click', (e) => {
+ e.stopPropagation();
+ if (this.onMutation) this.onMutation('alert.active', this.alerts.activeAlert === 'red' ? 'none' : 'red');
+ else this.toggleAlert();
+ });
+ }
+
+ if (this.btnExit) {
+ this.btnExit.addEventListener('click', (e) => {
+ e.stopPropagation();
+ if (this.onMutation) this.onMutation('view.observation', false);
+ else if (typeof exitObservation === 'function') exitObservation();
+ });
+ }
+
+ if (this.selectPreset) {
+ this.selectPreset.addEventListener('change', (e) => {
+ e.stopPropagation();
+ const presetId = this.selectPreset.value;
+ if (presetId && this.onMutation) {
+ this.onMutation('preset.selected', presetId);
+ } else if (presetId && window.selectPreset) {
+ window.selectPreset(presetId);
+ if (typeof refreshObservation === 'function') refreshObservation();
+ }
+ });
+ this.selectPreset.addEventListener('click', (e) => e.stopPropagation());
+ }
+
+ if (this.dock) {
+ this.dock.addEventListener('click', (e) => e.stopPropagation());
+ }
+
+ // Auto-hide control dock on mouse stillness
+ const pingDock = () => {
+ if (!this.running || !this.dock) return;
+ this.dock.classList.remove('dock-autohide');
+ clearTimeout(this.dockHideTimer);
+ this.dockHideTimer = setTimeout(() => {
+ if (this.running && this.dock) {
+ this.dock.classList.add('dock-autohide');
+ }
+ }, 3500);
+ };
+
+ window.addEventListener('mousemove', pingDock);
+ window.addEventListener('touchstart', pingDock, { passive: true });
+ }
+
+ setViewport(enabled) {
+ this.showViewport = !!enabled;
+ if (this.frameEl) {
+ this.frameEl.classList.toggle('frame-hidden', !this.showViewport);
+ }
+ const stage = document.getElementById('observation-stage');
+ if (stage) {
+ stage.classList.toggle('obs-viewport-hidden', !this.showViewport);
+ }
+ if (this.btnFrame) {
+ this.btnFrame.classList.toggle('active', this.showViewport);
+ this.btnFrame.textContent = `VIEWPORT: ${this.showViewport ? 'ON' : 'OFF'}`;
+ }
+ if (this.mainToggleViewport) {
+ this.mainToggleViewport.checked = this.showViewport;
+ }
+ if (this.mainValViewport) {
+ this.mainValViewport.textContent = this.showViewport ? 'ON' : 'OFF';
+ }
+ }
+
+ toggleViewport() {
+ this.setViewport(!this.showViewport);
+ }
+
+ toggleViewportFrame() {
+ this.toggleViewport();
+ }
+
+ setPillars(enabled) {
+ this.showPillars = !!enabled;
+ if (this.btnPillars) {
+ this.btnPillars.classList.toggle('active', this.showPillars);
+ this.btnPillars.textContent = `PILLARS: ${this.showPillars ? 'ON' : 'OFF'}`;
+ }
+ if (this.mainTogglePillars) {
+ this.mainTogglePillars.checked = this.showPillars;
+ }
+ if (this.mainValPillars) {
+ this.mainValPillars.textContent = this.showPillars ? 'ON' : 'OFF';
+ }
+ this.updateViewportFrame();
+ }
+
+ togglePillars() {
+ this.setPillars(!this.showPillars);
+ }
+
+ start(universeId) {
+ this.activeUniverse = universeId || 'starfleet';
+ this.running = true;
+ this.applyWaveformTheme();
+
+ // --- v3co: apply the OBSERVATION_CANVAS manifest for this universe -------------------
+ // Barrier 2 of 3: the overlay class scopes the background-rect CSS rule; the canvas
+ // visibility is belt-and-braces so a wrong manifest entry still cannot leak pixels.
+ const canvasOn = this.canvasEnabled();
+ if (this.overlay) this.overlay.classList.toggle('obs-canvas-on', canvasOn);
+ if (this.canvas) this.canvas.style.visibility = canvasOn ? '' : 'hidden';
+
+ // Rebuild the starfield from this universe's declared profile.
+ this.initStars();
+
+ // Clear any entities left over from a universe that WAS allowed them, so a canvas-off
+ // theme can never inherit another theme's ships, events or shooting stars.
+ if (!this.hasLayer('traffic')) this.traffic = [];
+ if (!this.hasLayer('events')) this.events = [];
+ if (!this.hasLayer('shootingStars')) this.shootingStars = [];
+ if (!this.hasLayer('constellations')) {
+ this.constellationPoints = null;
+ this.constellationLines = null;
+ this.constellationAlpha = 0;
+ }
+ if (!this.hasLayer('cinematics')) {
+ this.cinematicActive = null;
+ this.cinematicCaption = null;
+ }
+ // -------------------------------------------------------------------------------------
+
+ this.resize();
+ this.populatePresetDropdown();
+ this.initNebulaBlobs();
+ this.setViewport(this.showViewport);
+ this.setPillars(this.showPillars);
+ this.updateAlertState();
+
+ // Hook warp pulse
+ if (this.warpSynth) {
+ const existingPulse = this.warpSynth.onPulse;
+ this.warpSynth.onPulse = (phase, duration) => {
+ if (typeof existingPulse === 'function') existingPulse(phase, duration);
+ this.triggerWarpPulse();
+ };
+ }
+
+ this.lastFrameTime = performance.now();
+ this.nextTrafficTime = performance.now() + 2500;
+ this.nextEventTime = performance.now() + 5000;
+
+ // --- Observation Mode Enhancements: reset per-session timers ---
+ this.nextShootingStarTime = performance.now() + 6000 + Math.random() * 6000;
+ this.nextConstellationTime = 0; // triggers an immediate first constellation on next update
+ this.constellationPoints = null;
+ this.constellationLines = null;
+ this.constellationAlpha = 0;
+ this.cinematicActive = null;
+ this.cinematicCaption = null;
+ // Shortened first wait so a cinematic sequence is easy to observe/verify; later ones cool down longer.
+ this.nextCinematicTime = performance.now() + 20000 + Math.random() * 15000;
+ this.tickerMessages = this.getTickerMessages(this.activeUniverse);
+ this.tickerIndex = -1;
+ this.nextTickerTime = 0;
+
+ if (this.btnWarp) {
+ this.btnWarp.classList.toggle('active', this.flightMode === 'warp');
+ this.btnWarp.textContent = `WARP: ${this.flightMode === 'warp' ? 'ENGAGED' : 'OFF'}`;
+ }
+
+ this.loop();
+ }
+
+ stop() {
+ this.running = false;
+ if (this.animId) {
+ cancelAnimationFrame(this.animId);
+ this.animId = null;
+ }
+ clearTimeout(this.dockHideTimer);
+ if (this.ctx) {
+ this.ctx.clearRect(0, 0, this.width, this.height);
+ }
+ }
+
+ triggerWarpPulse() {
+ this.warpPulseEnergy = 1.0;
+ }
+
+ toggleWarpFlight() {
+ if (this.flightMode === 'warp') {
+ this.flightMode = 'cruise';
+ this.targetWarpSpeed = 1.0;
+ } else {
+ this.flightMode = 'warp';
+ // Scale warp streak speed according to warp BPM
+ const bpm = (this.warpSynth && this.warpSynth.params && this.warpSynth.params.bpm) ? this.warpSynth.params.bpm : 48;
+ this.targetWarpSpeed = Math.min(38, Math.max(16, (bpm / 48) * 24));
+ // Trigger subtle warp flash
+ this.spawnEvent('warp-flash', this.width / 2, this.height / 2);
+ }
+
+ if (this.btnWarp) {
+ this.btnWarp.classList.toggle('active', this.flightMode === 'warp');
+ this.btnWarp.textContent = `WARP: ${this.flightMode === 'warp' ? 'ENGAGED' : 'OFF'}`;
+ }
+ }
+
+ toggleAlert() {
+ if (this.alerts) {
+ if (this.alerts.activeAlert === 'red') {
+ this.alerts.stopAlert();
+ } else {
+ this.alerts.triggerRedAlert('tng');
+ }
+ }
+ this.updateAlertState();
+ }
+
+ updateAlertState() {
+ const alertType = this.alerts ? this.alerts.activeAlert : null;
+ if (this.alertWash) {
+ this.alertWash.className = 'observation-alert-wash';
+ if (alertType === 'red') {
+ this.alertWash.classList.add('alert-red');
+ } else if (alertType === 'yellow') {
+ this.alertWash.classList.add('alert-yellow');
+ }
+ }
+
+ if (this.btnAlert) {
+ this.btnAlert.classList.toggle('btn-alert-active', alertType === 'red');
+ this.btnAlert.textContent = alertType === 'red' ? 'RED ALERT: ON' : (alertType === 'yellow' ? 'YELLOW ALERT' : 'RED ALERT');
+ }
+ }
+
+ populatePresetDropdown() {
+ if (!this.selectPreset) return;
+ this.selectPreset.innerHTML = '';
+ const universe = UniverseRegistry[this.activeUniverse];
+ if (universe && universe.presets) {
+ for (const [id, preset] of Object.entries(universe.presets)) {
+ const opt = document.createElement('option');
+ opt.value = id;
+ opt.textContent = preset.name;
+ if (id === (window.activePresetId || '')) opt.selected = true;
+ this.selectPreset.appendChild(opt);
+ }
+ }
+ }
+
+ loop() {
+ if (!this.running) return;
+ const now = performance.now();
+ const dt = Math.min((now - this.lastFrameTime) / 1000, 0.1);
+ this.lastFrameTime = now;
+ this.time += dt;
+
+ this.update(dt, now);
+ this.render();
+ this.renderWaveform();
+
+ this.animId = requestAnimationFrame(() => this.loop());
+ }
+
+ update(dt, now) {
+ const stationView = this.isStationView();
+
+ // Smoothly interpolate warp speed
+ this.warpSpeed += (this.targetWarpSpeed - this.warpSpeed) * (dt * 4);
+ this.warpPulseEnergy *= Math.max(0, 1 - dt * 2.5);
+
+ if (stationView) {
+ // A station rotates in place: no forward flight, so no camera sway and no
+ // starfield rush - just the fixed sky turning past the window.
+ this.cameraWobble.x *= 0.9;
+ this.cameraWobble.y *= 0.9;
+ if (this.hasLayer('stationPanorama')) this.updateStationPanorama(dt);
+ } else {
+ // Camera sway during cruise
+ if (this.flightMode === 'cruise') {
+ this.cameraWobble.x = Math.sin(this.time * 0.18) * 14;
+ this.cameraWobble.y = Math.cos(this.time * 0.14) * 8;
+ } else {
+ this.cameraWobble.x *= 0.9;
+ this.cameraWobble.y *= 0.9;
+ }
+
+ // Update 3D Stars (only for universes that declare the starfield layer)
+ const speed = this.flightMode === 'warp' ? this.warpSpeed * 220 : 25 + this.warpPulseEnergy * 60;
+ const starsLen = this.hasLayer('starfield') ? this.stars.length : 0;
+
+ for (let i = 0; i < starsLen; i++) {
+ const star = this.stars[i];
+ star.pz = star.z;
+ star.z -= speed * dt;
+
+ if (star.z <= 2) {
+ star.z = 1200;
+ star.pz = 1200;
+ star.x = (Math.random() - 0.5) * 2600;
+ star.y = (Math.random() - 0.5) * 1600;
+ }
+ }
+
+ }
+
+ // Traffic Spawning -- v3co: governed by the OBSERVATION_CANVAS manifest, which supersedes
+ // the v2cs `activeUniverse !== 'deepspace'` guard. Gating the SPAWNER (not just the renderer)
+ // is what stops ships accumulating invisibly in themes that should have none.
+ const activityFactor = (window.observationActivity !== undefined ? window.observationActivity : 0.6);
+ if (this.hasLayer('traffic') && now > this.nextTrafficTime && this.traffic.length < 3) {
+ this.spawnTraffic();
+ this.nextTrafficTime = now + (14000 + Math.random() * 18000) / Math.max(0.1, activityFactor);
+ }
+
+ // Update Traffic
+ for (let i = this.traffic.length - 1; i >= 0; i--) {
+ const ship = this.traffic[i];
+ ship.progress += dt / ship.duration;
+ ship.x = ship.x0 + (ship.x1 - ship.x0) * ship.progress;
+ ship.y = ship.y0 + (ship.y1 - ship.y0) * ship.progress;
+
+ // Engine trail particles
+ if (Math.random() < 0.6) {
+ ship.particles.push({
+ x: ship.x,
+ y: ship.y,
+ vx: -ship.vx * 0.2 + (Math.random() - 0.5) * 4,
+ vy: -ship.vy * 0.2 + (Math.random() - 0.5) * 4,
+ alpha: 0.8,
+ size: Math.random() * 3 + 2,
+ color: ship.trailColor
+ });
+ }
+
+ // Update particles
+ for (let p = ship.particles.length - 1; p >= 0; p--) {
+ const pt = ship.particles[p];
+ pt.x += pt.vx;
+ pt.y += pt.vy;
+ pt.alpha -= dt * 1.5;
+ if (pt.alpha <= 0) ship.particles.splice(p, 1);
+ }
+
+ // Warp-out departure: flash just before the ship reaches the edge, if it was tagged for one
+ if (ship.warpExit && !ship.warpExitDone && ship.progress > 0.9) {
+ this.spawnEvent('warp-flash', ship.x, ship.y);
+ ship.warpExitDone = true;
+ }
+
+ if (ship.progress >= 1.0) {
+ this.traffic.splice(i, 1);
+ }
+ }
+
+ // Dynamic Events Spawning
+ if (this.hasLayer('events') && now > this.nextEventTime && this.events.length < 2) {
+ this.spawnRandomEvent();
+ this.nextEventTime = now + (20000 + Math.random() * 25000) / Math.max(0.1, activityFactor);
+ }
+
+ // Update Events
+ for (let i = this.events.length - 1; i >= 0; i--) {
+ const ev = this.events[i];
+ ev.life += dt;
+ if (ev.life >= ev.maxLife) {
+ this.events.splice(i, 1);
+ }
+ }
+
+ // --- Observation Mode Enhancements ---
+ this.updateAudioEnergy();
+ this.updateViewportVibration(dt);
+ this.updateScanlineBreathing();
+ this.updateLightingCycle(dt);
+ // v3co: all four are manifest-gated. `cinematics` in particular is a crossover vector --
+ // its sequences call spawnTraffic()/spawnEvent() directly and buildFlybySpectacleSequence()
+ // is appended for every universe, so leaving it ungated would repopulate this.traffic for
+ // themes whose renderers are off.
+ if (this.hasLayer('shootingStars')) this.updateShootingStars(dt, now);
+ if (this.hasLayer('constellations')) this.updateConstellations(dt, now);
+ if (this.hasLayer('cinematics')) this.updateCinematicDirector(dt, now);
+ this.updateStatusTicker(dt, now);
+
+ // Sync Alert wash state with AlertSynth
+ this.updateAlertState();
+ }
+
+ spawnTraffic(emphasis) {
+ const fromLeft = Math.random() > 0.5;
+ let yStart, yEnd;
+ if (this.isStationView()) {
+ // Keep dock traffic inside the station's window opening - the bulkhead hides anything else
+ const rect = this.getStationViewportRect();
+ yStart = rect.y + rect.h * 0.18 + Math.random() * rect.h * 0.64;
+ yEnd = yStart + (Math.random() - 0.5) * rect.h * 0.3;
+ } else {
+ yStart = 160 + Math.random() * (this.height - 340);
+ yEnd = yStart + (Math.random() - 0.5) * 220;
+ }
+ const x0 = fromLeft ? -100 : this.width + 100;
+ const x1 = fromLeft ? this.width + 100 : -100;
+ let duration = 12 + Math.random() * 14;
+
+ let shipType = 'shuttle';
+ let label = 'SHUTTLE // TYPE-9';
+ let trailColor = '#38bdf8';
+ let scale = 1.0;
+
+ if (this.activeUniverse === 'starfleet') {
+ const types = ['shuttle', 'cruiser', 'runabout'];
+ shipType = types[Math.floor(Math.random() * types.length)];
+ if (shipType === 'cruiser') {
+ label = `USS GIBRALTAR // NCC-${Math.floor(Math.random()*80000+10000)}`;
+ trailColor = '#60a5fa';
+ } else if (shipType === 'runabout') {
+ label = `RUNABOUT YANGTZE // NCC-72452`;
+ trailColor = '#f97316';
+ }
+ } else if (this.activeUniverse === 'whataverse') {
+ shipType = 'tardix';
+ label = 'TYPE 4D TIME CAPSULE // DRIFT';
+ trailColor = '#00e5ff';
+ } else if (this.activeUniverse === 'industrial' || this.activeUniverse === 'outlaw') {
+ shipType = 'freighter';
+ label = 'HEAVY HAULER // CLASS IV';
+ trailColor = '#f97316';
+ } else if (this.activeUniverse === 'military') {
+ const types = ['cruiser', 'fighterwing'];
+ shipType = types[Math.floor(Math.random() * types.length)];
+ if (shipType === 'fighterwing') {
+ label = `VIPER WING // FLIGHT ${Math.floor(Math.random() * 9) + 1}`;
+ trailColor = '#eab308';
+ scale = 0.75;
+ } else {
+ label = 'VIPER PATROL // CAP 04';
+ trailColor = '#eab308';
+ }
+ } else if (this.activeUniverse === 'bioships') {
+ shipType = 'bioshippod';
+ label = 'SPAWN POD // DRIFTING';
+ trailColor = '#34d399';
+ } else if (this.activeUniverse === 'retrofuture') {
+ shipType = 'retrosaucer';
+ label = 'ATOMIC CRUISER // SAUCER CLASS';
+ trailColor = '#4ade80';
+ }
+
+ if (emphasis) {
+ // Close, dramatic flyby used by cinematic sequences: bigger, faster, more prominent
+ scale *= 1.6;
+ duration *= 0.55;
+ }
+
+ // Occasionally have a vessel materialize via warp instead of drifting in from off-screen,
+ // and/or make a warp jump to depart instead of simply exiting the frame.
+ const warpEntry = !emphasis && Math.random() < 0.12;
+ const warpExit = !emphasis && Math.random() < 0.12;
+ const initialProgress = warpEntry ? (0.12 + Math.random() * 0.1) : 0;
+ const initialX = x0 + (x1 - x0) * initialProgress;
+ const initialY = yStart + (yEnd - yStart) * initialProgress;
+
+ this.traffic.push({
+ type: shipType,
+ label: label,
+ x0, y0: yStart,
+ x1, y1: yEnd,
+ x: initialX, y: initialY,
+ vx: (x1 - x0) / duration,
+ vy: (yEnd - yStart) / duration,
+ progress: initialProgress,
+ duration: duration,
+ scale: shipType === 'cruiser' ? 0.75 : scale,
+ trailColor: trailColor,
+ particles: [],
+ warpEntry, warpExit,
+ warpExitDone: !warpExit
+ });
+
+ if (warpEntry) {
+ this.spawnEvent('warp-flash', initialX, initialY);
+ }
+ }
+
+ spawnRandomEvent() {
+ const kinds = ['comet', 'warp-flash'];
+ const kind = kinds[Math.floor(Math.random() * kinds.length)];
+ let x, y;
+ if (this.isStationView()) {
+ // Confine arrivals and comets to the station's window opening
+ const rect = this.getStationViewportRect();
+ x = rect.x + rect.w * 0.15 + Math.random() * rect.w * 0.7;
+ y = rect.y + rect.h * 0.18 + Math.random() * rect.h * 0.64;
+ } else {
+ x = 200 + Math.random() * (this.width - 400);
+ y = 140 + Math.random() * (this.height - 280);
+ }
+ this.spawnEvent(kind, x, y);
+ }
+
+ spawnEvent(kind, x, y) {
+ this.events.push({
+ kind,
+ x, y,
+ life: 0,
+ maxLife: kind === 'warp-flash' ? 1.4 : 5.0,
+ vx: kind === 'comet' ? (Math.random() > 0.5 ? 120 : -120) : 0,
+ vy: kind === 'comet' ? 50 : 0
+ });
+ }
+
+ isStationView() {
+ return this.activeUniverse === 'spacestations';
+ }
+
+ // --- OBSERVATION_CANVAS accessors (v3co) -------------------------------
+ // Single source of truth for "may this theme draw on the canvas at all" and
+ // "may it draw this particular layer". Default-deny in both directions.
+ canvasEnabled() {
+ const cfg = OBSERVATION_CANVAS[this.activeUniverse];
+ return !!(cfg && cfg.canvas);
+ }
+
+ hasLayer(name) {
+ const cfg = OBSERVATION_CANVAS[this.activeUniverse];
+ return !!(cfg && cfg.canvas && cfg.layers && cfg.layers[name]);
+ }
+
+ getStarfieldProfile() {
+ const cfg = OBSERVATION_CANVAS[this.activeUniverse];
+ const p = (cfg && cfg.starfield) || {};
+ return Object.assign({}, DEFAULT_STARFIELD_PROFILE, p);
+ }
+
+ render() {
+ const ctx = this.ctx;
+ if (!ctx) return;
+
+ // v3co: a universe not declared canvas-on in OBSERVATION_CANVAS draws nothing at all --
+ // its OBSERVATION display is its own bespoke SVG art. First of three barriers (the other
+ // two: canvas visibility:hidden in start(), and its own opaque background rect).
+ if (!this.canvasEnabled()) return;
+
+ const stationView = this.isStationView();
+
+ // Center of projection with sway
+ const cx = this.width / 2 + this.cameraWobble.x;
+ const cy = this.height / 2 + this.cameraWobble.y;
+
+ // Clear canvas
+ if (this.flightMode === 'warp' && !stationView) {
+ // Warp motion blur trail
+ ctx.fillStyle = 'rgba(1, 3, 8, 0.35)';
+ ctx.fillRect(0, 0, this.width, this.height);
+ } else {
+ // Stations always clear solid: a rotating station never smears into warp streaks
+ ctx.fillStyle = '#010308';
+ ctx.fillRect(0, 0, this.width, this.height);
+ }
+
+ // v3co: every layer is drawn only if the active universe declares it in
+ // OBSERVATION_CANVAS. The old isStationView() / activeUniverse === 'deepspace'
+ // branching is replaced by uniform manifest lookups -- there is no implicit
+ // "everyone gets this" layer any more. Draw order is unchanged.
+
+ // 0. Space Stations: one fixed 360-degree sky wheeling past the window.
+ if (this.hasLayer('stationPanorama')) this.renderStationPanorama(ctx);
+
+ // 1. Deep Space Nebulae (generic glow)
+ if (this.hasLayer('nebulae')) this.renderNebulae(ctx, cx, cy);
+
+ // 2. Universe-Specific Celestial Objects (Planets, Time Vortex, Rings)
+ if (this.hasLayer('celestial')) this.renderCelestialObjects(ctx, cx, cy);
+
+ // 2b. Deep Space: procedural, session-seeded field (planets, dust, derelicts, anomalies)
+ if (this.hasLayer('deepSpaceField')) this.renderDeepSpaceField(ctx, cx, cy);
+
+ // 3. 3D Parallax Starfield & Warp Streaks (per-universe profile)
+ if (this.hasLayer('starfield')) this.renderStarfield(ctx, cx, cy);
+
+ // 3b. Procedural Constellation Lines (faint, background layer)
+ if (this.hasLayer('constellations')) this.renderConstellations(ctx, cx, cy);
+
+ // 3c. Shooting Stars
+ if (this.hasLayer('shootingStars')) this.renderShootingStars(ctx);
+
+ // 4. Dynamic Encounters & Traffic
+ if (this.hasLayer('traffic') || this.hasLayer('events')) this.renderTrafficAndEvents(ctx);
+
+ // 5. Holographic Target Reticles
+ if (this.hasLayer('reticles')) this.renderTargetReticles(ctx);
+ }
+
+ getNebulaPalette() {
+ let g1 = '#3b82f6', g2 = '#6366f1', g3 = '#ec4899';
+
+ if (this.activeUniverse === 'whataverse') {
+ g1 = '#00e5ff'; g2 = '#d4af37'; g3 = '#8b5cf6';
+ } else if (this.activeUniverse === 'industrial') {
+ g1 = '#f59e0b'; g2 = '#b45309'; g3 = '#3f2c18';
+ } else if (this.activeUniverse === 'bioships') {
+ g1 = '#10b981'; g2 = '#8b5cf6'; g3 = '#065f46';
+ } else if (this.activeUniverse === 'retrofuture') {
+ g1 = '#22c55e'; g2 = '#15803d'; g3 = '#14532d';
+ } else if (this.activeUniverse === 'military') {
+ g1 = '#64748b'; g2 = '#3f3f46'; g3 = '#7f1d1d';
+ } else if (this.activeUniverse === 'deepspace') {
+ g1 = '#f59e0b'; g2 = '#7c2d12'; g3 = '#1e293b';
+ } else if (this.activeUniverse === 'outlaw') {
+ g1 = '#f97316'; g2 = '#7c2d12'; g3 = '#4c0519';
+ } else if (this.activeUniverse === 'spacestations') {
+ g1 = '#38bdf8'; g2 = '#0ea5e9'; g3 = '#1e3a8a';
+ } else if (this.activeUniverse === 'comedy') {
+ g1 = '#f472b6'; g2 = '#a78bfa'; g3 = '#fde047';
+ }
+
+ return [g1, g2, g3];
+ }
+
+ initNebulaBlobs() {
+ const palette = this.getNebulaPalette();
+ this.nebulaBlobs = [];
+ for (let i = 0; i < 4; i++) {
+ this.nebulaBlobs.push({
+ baseX: (Math.random() - 0.5) * 1.6,
+ baseY: (Math.random() - 0.5) * 1.1,
+ radius: 260 + Math.random() * 340,
+ color: palette[i % palette.length],
+ driftSpeedX: (Math.random() - 0.5) * 0.015,
+ driftSpeedY: (Math.random() - 0.5) * 0.01,
+ pulseSpeed: 0.15 + Math.random() * 0.25,
+ pulsePhase: Math.random() * Math.PI * 2,
+ baseAlpha: 0.05 + Math.random() * 0.05,
+ morphSeed: Math.random() * 1000
+ });
+ }
+ }
+
+ renderNebulae(ctx, cx, cy) {
+ if (!this.nebulaBlobs || !this.nebulaBlobs.length) this.initNebulaBlobs();
+
+ const bassBoost = this.warpPulseEnergy * 0.15 + (this.audioEnergy ? this.audioEnergy.bass * 0.12 : 0);
+ const lighting = this.getLightingModifiers();
+
+ for (const blob of this.nebulaBlobs) {
+ // Slow procedural drift/morph, looped via sine so blobs never run away off-scene
+ const driftX = Math.sin(this.time * blob.driftSpeedX * 10 + blob.morphSeed) * 220;
+ const driftY = Math.cos(this.time * blob.driftSpeedY * 10 + blob.morphSeed * 1.3) * 160;
+ const bx = cx + blob.baseX * this.width * 0.5 + driftX;
+ const by = cy + blob.baseY * this.height * 0.5 + driftY;
+
+ const pulse = 1 + Math.sin(this.time * blob.pulseSpeed + blob.pulsePhase) * 0.18;
+ const radius = Math.max(40, blob.radius * pulse * (0.9 + bassBoost * 2));
+ const alpha = Math.max(0, (blob.baseAlpha + bassBoost) * lighting.nebulaIntensity);
+
+ const grad = ctx.createRadialGradient(bx, by, radius * 0.08, bx, by, radius);
+ grad.addColorStop(0, hexToRgba(blob.color, alpha));
+ grad.addColorStop(0.55, hexToRgba(blob.color, alpha * 0.4));
+ grad.addColorStop(1, 'transparent');
+ ctx.fillStyle = grad;
+ ctx.fillRect(0, 0, this.width, this.height);
+ }
+ }
+
+ renderCelestialObjects(ctx, cx, cy) {
+ if (this.activeUniverse === 'whataverse') {
+ // Procedural 3D Time Vortex
+ ctx.save();
+ ctx.translate(cx, cy);
+ const ringCount = 14;
+ for (let i = ringCount; i >= 1; i--) {
+ const ringZ = ((this.time * 0.6 + i * 0.45) % ringCount);
+ const rNorm = ringZ / ringCount;
+ const rx = 80 + rNorm * 480;
+ const ry = 40 + rNorm * 220;
+ const rot = this.time * (i % 2 === 0 ? 0.35 : -0.28) + i * 0.3;
+
+ ctx.save();
+ ctx.rotate(rot);
+ ctx.beginPath();
+ ctx.ellipse(0, 0, rx, ry, 0, 0, Math.PI * 2);
+ ctx.strokeStyle = i % 2 === 0 ? `rgba(0, 229, 255, ${0.15 + (1 - rNorm) * 0.45})` : `rgba(212, 175, 55, ${0.12 + (1 - rNorm) * 0.4})`;
+ ctx.lineWidth = 1.5 + (1 - rNorm) * 3;
+ ctx.stroke();
+ ctx.restore();
+ }
+ ctx.restore();
+ return;
+ }
+
+ if (this.activeUniverse === 'starfleet') {
+ // Class-M Planet with properly occluded moon orbit: the rear half of the ellipse is
+ // drawn behind the planet and the forward half is drawn over it, so the moon actually
+ // passes behind the world rather than remaining glued to the viewer side.
+ const px = cx + this.width * 0.26;
+ const py = cy - this.height * 0.06;
+ const pr = Math.min(this.width, this.height) * 0.22;
+ const orbitRx = pr * 1.7;
+ const orbitRy = pr * 0.6;
+ const moonAngle = this.time * 0.08;
+ const moonFront = Math.sin(moonAngle) > 0;
+ const mx = px + Math.cos(moonAngle) * orbitRx;
+ const my = py + Math.sin(moonAngle) * orbitRy;
+ const mr = pr * 0.14;
+
+ const drawMoon = () => {
+ ctx.fillStyle = '#cbd5e1';
+ ctx.beginPath();
+ ctx.arc(mx, my, mr, 0, Math.PI * 2);
+ ctx.fill();
+ ctx.fillStyle = 'rgba(2, 6, 23, 0.75)';
+ ctx.beginPath();
+ ctx.arc(mx + mr * 0.3, my, mr * 0.95, 0, Math.PI * 2);
+ ctx.fill();
+ };
+
+ // Rear orbit arc and rear moon first.
+ ctx.save();
+ ctx.beginPath();
+ ctx.ellipse(px, py, orbitRx, orbitRy, 0, Math.PI, Math.PI * 2);
+ ctx.strokeStyle = 'rgba(186, 230, 253, 0.18)';
+ ctx.lineWidth = 1.6;
+ ctx.setLineDash([8, 10]);
+ ctx.stroke();
+ ctx.restore();
+ if (!moonFront) drawMoon();
+
+ // Outer Atmospheric Glow
+ const atmosGrad = ctx.createRadialGradient(px, py, pr * 0.85, px, py, pr * 1.35);
+ atmosGrad.addColorStop(0, 'rgba(56, 189, 248, 0.45)');
+ atmosGrad.addColorStop(0.5, 'rgba(59, 130, 246, 0.18)');
+ atmosGrad.addColorStop(1, 'transparent');
+ ctx.fillStyle = atmosGrad;
+ ctx.beginPath();
+ ctx.arc(px, py, pr * 1.35, 0, Math.PI * 2);
+ ctx.fill();
+
+ // Planet Body
+ const bodyGrad = ctx.createRadialGradient(px - pr * 0.35, py - pr * 0.35, pr * 0.1, px, py, pr);
+ bodyGrad.addColorStop(0, '#1e3a8a');
+ bodyGrad.addColorStop(0.4, '#1d4ed8');
+ bodyGrad.addColorStop(0.7, '#0f172a');
+ bodyGrad.addColorStop(1, '#020617');
+ ctx.fillStyle = bodyGrad;
+ ctx.beginPath();
+ ctx.arc(px, py, pr, 0, Math.PI * 2);
+ ctx.fill();
+
+ // Subtle atmospheric rim arc
+ ctx.beginPath();
+ ctx.arc(px, py, pr, -Math.PI * 0.8, Math.PI * 0.2);
+ ctx.strokeStyle = 'rgba(186, 230, 253, 0.65)';
+ ctx.lineWidth = 3.5;
+ ctx.stroke();
+
+ // Forward orbit arc and front moon last.
+ ctx.save();
+ ctx.beginPath();
+ ctx.ellipse(px, py, orbitRx, orbitRy, 0, 0, Math.PI);
+ ctx.strokeStyle = 'rgba(186, 230, 253, 0.34)';
+ ctx.lineWidth = 1.8;
+ ctx.setLineDash([10, 8]);
+ ctx.stroke();
+ ctx.restore();
+ if (moonFront) drawMoon();
+ return;
+ }
+
+ // Deep Space's own procedural, session-seeded field (planets, nebulae, dust lanes,
+ // derelicts, anomalies -- each with its own computer callout, no near-passing traffic)
+ // is generated once in the constructor and drawn by renderDeepSpaceField() below, called
+ // separately from render(). Space Stations never reaches this method at all
+ // (isStationView() short-circuits render() first).
+ }
+
+ regenerateDeepSpaceField() {
+ const rand = (min, max) => min + Math.random() * (max - min);
+ const pick = (arr) => arr[Math.floor(Math.random() * arr.length)];
+
+ // Planets, derelicts and anomalies all get a computer callout with a leader line, so two
+ // of them landing close together (or a small object landing inside a large planet) makes
+ // both illegible. This tracks every placed object's normalized position + how much
+ // clearance its callout needs, and biases new placements away from all of them --
+ // same idea as the even bearing-spacing the Space Station panorama already does, just in
+ // 2D screen-fraction space instead of 1D bearing space.
+ // Distances are measured in actual pixels (converting each candidate's normalized
+ // offset through the canvas's own width/height) rather than raw nx/ny units, since a
+ // canvas is wider than it is tall -- comparing normalized deltas directly would treat a
+ // given horizontal separation as "closer" than the same vertical one. Clearance is also
+ // required from BOTH sides of a pair (the new candidate's own footprint, not just the
+ // already-placed object's), so two large planets can't still end up overlapping just
+ // because each one individually satisfied a one-sided check.
+ const placed = [];
+ const placeAway = (nxRange, nyRange, clearancePx, tries = 14) => {
+ let best = null, bestScore = -Infinity;
+ for (let attempt = 0; attempt < tries; attempt++) {
+ const nx = rand(nxRange[0], nxRange[1]);
+ const ny = rand(nyRange[0], nyRange[1]);
+ let minDist = placed.length ? Infinity : clearancePx;
+ for (const p of placed) {
+ const dPx = Math.hypot((nx - p.nx) * this.width, (ny - p.ny) * this.height);
+ const d = dPx - p.clearance - clearancePx;
+ if (d < minDist) minDist = d;
+ }
+ if (minDist > bestScore) { bestScore = minDist; best = { nx, ny }; }
+ if (minDist >= clearancePx) break;
+ }
+ placed.push({ nx: best.nx, ny: best.ny, clearance: clearancePx });
+ return best;
+ };
+
+ // --- Planets: 2-4, each a distinct world type spread across the sky ---
+ const planetPalettes = [
+ { body: ['#b45309', '#78350f', '#292524', '#0c0a09'], ring: '#fdba74', rim: '#fed7aa', label: 'GAS GIANT' },
+ { body: ['#60a5fa', '#1d4ed8', '#1e3a8a', '#0b1220'], ring: '#93c5fd', rim: '#bae6fd', label: 'ICE WORLD // NO ATMOSPHERE' },
+ { body: ['#a78bfa', '#6d28d9', '#312e81', '#1e1b4b'], ring: '#c4b5fd', rim: '#e9d5ff', label: 'GAS GIANT // ION STORMS' },
+ { body: ['#f87171', '#991b1b', '#450a0a', '#1c0a0a'], ring: '#fca5a5', rim: '#fecaca', label: 'ROCKY WORLD // SEISMIC ACTIVITY' },
+ { body: ['#34d399', '#047857', '#022c22', '#020c09'], ring: '#6ee7b7', rim: '#a7f3d0', label: 'ROCKY WORLD // BIOSIGNATURE?' },
+ { body: ['#e2e8f0', '#94a3b8', '#334155', '#0f172a'], ring: '#f1f5f9', rim: '#f8fafc', label: 'FROZEN WORLD // DORMANT' }
+ ];
+ const paletteBag = planetPalettes.slice();
+ const planets = [];
+ const planetCount = 2 + Math.floor(Math.random() * 3); // 2-4
+ for (let i = 0; i < planetCount; i++) {
+ const pal = paletteBag.splice(Math.floor(Math.random() * paletteBag.length), 1)[0];
+ const moons = [];
+ const moonCount = Math.floor(Math.random() * 3); // 0-2
+ for (let m = 0; m < moonCount; m++) {
+ moons.push({
+ orbitR: rand(1.4, 2.4),
+ squash: rand(0.3, 0.65),
+ speed: rand(0.03, 0.09),
+ phase: Math.random() * Math.PI * 2,
+ size: rand(0.08, 0.16),
+ color: pick(['#cbd5e1', '#94a3b8', '#e2e8f0', '#78716c'])
+ });
+ }
+ const pRadius = rand(0.07, 0.16);
+ const pPos = placeAway([-0.42, 0.42], [-0.30, 0.32], pRadius * Math.min(this.width, this.height) + 130);
+ planets.push({
+ // At true interstellar range, a planet's apparent drift across a viewport during any
+ // realistic observation session is imperceptible -- these sit at a fixed screen-relative
+ // bearing, with only a slow independent sway standing in for that motion.
+ nx: pPos.nx,
+ ny: pPos.ny,
+ radius: pRadius,
+ palette: pal,
+ hasRings: Math.random() < 0.5,
+ ringTilt: rand(-0.55, -0.12),
+ lightAngle: rand(-Math.PI, Math.PI),
+ driftSpeedX: rand(-0.006, 0.006),
+ driftSpeedY: rand(-0.004, 0.004),
+ driftPhase: Math.random() * Math.PI * 2,
+ scanPhase: Math.random() * Math.PI * 2,
+ pingPhase: Math.random() * Math.PI * 2,
+ pingInterval: rand(9, 15),
+ moons
+ });
+ }
+
+ // --- Nebula clouds: richer and more varied than the generic 4-blob ambient system ---
+ const nebulaPalette = ['#6366f1', '#818cf8', '#38bdf8', '#312e81', '#4338ca', '#0ea5e9', '#a5b4fc'];
+ const nebulae = [];
+ const nebulaCount = 3 + Math.floor(Math.random() * 3); // 3-5
+ for (let i = 0; i < nebulaCount; i++) {
+ nebulae.push({
+ nx: rand(-0.6, 0.6),
+ ny: rand(-0.5, 0.5),
+ radius: rand(220, 620),
+ color: pick(nebulaPalette),
+ alpha: rand(0.035, 0.09),
+ driftSpeedX: rand(-0.008, 0.008),
+ driftSpeedY: rand(-0.006, 0.006),
+ pulseSpeed: rand(0.06, 0.18),
+ pulsePhase: Math.random() * Math.PI * 2,
+ morphSeed: Math.random() * 1000
+ });
+ }
+
+ // --- Fine dust lanes: thin streak-like clouds for texture, distinct from the soft blobs ---
+ const dustLanes = [];
+ const laneCount = 4 + Math.floor(Math.random() * 4); // 4-7
+ for (let i = 0; i < laneCount; i++) {
+ dustLanes.push({
+ nx: rand(-0.55, 0.55),
+ ny: rand(-0.45, 0.45),
+ length: rand(260, 620),
+ thickness: rand(30, 90),
+ angle: rand(-0.6, 0.6),
+ color: pick(['#4338ca', '#1e1b4b', '#0f172a', '#312e81']),
+ alpha: rand(0.05, 0.12),
+ driftSpeed: rand(0.003, 0.01),
+ phase: Math.random() * Math.PI * 2
+ });
+ }
+
+ // --- Distant derelict / megastructure silhouettes (generation-ship debris, thematic) ---
+ const derelicts = [];
+ if (Math.random() < 0.7) {
+ const derelictCount = 1 + (Math.random() < 0.3 ? 1 : 0);
+ for (let i = 0; i < derelictCount; i++) {
+ const dPos = placeAway([-0.46, 0.46], [-0.4, 0.4], 150);
+ derelicts.push({
+ nx: dPos.nx,
+ ny: dPos.ny,
+ scale: rand(0.05, 0.13),
+ rotation: rand(-0.3, 0.3),
+ segments: 3 + Math.floor(Math.random() * 4),
+ beaconPhase: Math.random() * Math.PI * 2,
+ beaconSpeed: rand(1.2, 2.8),
+ color: pick(['#334155', '#3f3f46', '#292524', '#1e293b']),
+ label: 'DERELICT // NO SIGNAL',
+ scanPhase: Math.random() * Math.PI * 2,
+ pingPhase: Math.random() * Math.PI * 2,
+ pingInterval: rand(9, 15)
+ });
+ }
+ }
+
+ // --- Gravitational / radiation anomalies: rare phenomena gated by the activity slider ---
+ const anomalyCount = 1 + Math.floor(Math.random() * 2); // 1-2
+ const anomalies = [];
+ const anomalyLabels = { lensing: 'GRAVITATIONAL LENSING', radiation: 'RADIATION SURGE' };
+ for (let i = 0; i < anomalyCount; i++) {
+ const kind = pick(['lensing', 'radiation']);
+ const aRadius = rand(50, 120);
+ const aPos = placeAway([-0.4, 0.4], [-0.32, 0.32], aRadius + 150);
+ anomalies.push({
+ nx: aPos.nx,
+ ny: aPos.ny,
+ radius: aRadius,
+ kind,
+ label: anomalyLabels[kind],
+ minActivity: rand(0.15, 0.55),
+ pulseSpeed: rand(0.15, 0.4),
+ pulsePhase: Math.random() * Math.PI * 2,
+ color: pick(['#818cf8', '#38bdf8', '#f43f5e']),
+ scanPhase: Math.random() * Math.PI * 2,
+ pingPhase: Math.random() * Math.PI * 2,
+ pingInterval: rand(9, 15)
+ });
+ }
+
+ this.deepSpaceField = { planets, nebulae, dustLanes, derelicts, anomalies };
+ return this.deepSpaceField;
+ }
+
+ renderDeepSpaceField(ctx, cx, cy) {
+ if (!this.deepSpaceField) this.regenerateDeepSpaceField();
+ const field = this.deepSpaceField;
+ const lighting = this.getLightingModifiers();
+ const activity = (window.observationActivity !== undefined ? window.observationActivity : 0.6);
+ const isWarp = this.flightMode === 'warp';
+ const warpStretch = isWarp ? 1 + Math.min(2.2, this.warpSpeed * 0.5) : 1;
+ const bass = this.audioEnergy ? this.audioEnergy.bass : 0;
+ const minDim = Math.min(this.width, this.height);
+
+ // 1. Dust lanes -- furthest back, softest, faint streaked texture
+ for (const lane of field.dustLanes) {
+ const t = this.time * lane.driftSpeed + lane.phase;
+ const bx = cx + lane.nx * this.width + Math.sin(t) * 40;
+ const by = cy + lane.ny * this.height + Math.cos(t * 0.7) * 30;
+ ctx.save();
+ ctx.translate(bx, by);
+ ctx.rotate(lane.angle);
+ const grad = ctx.createLinearGradient(-lane.length / 2, 0, lane.length / 2, 0);
+ grad.addColorStop(0, 'transparent');
+ grad.addColorStop(0.5, hexToRgba(lane.color, lane.alpha * lighting.nebulaIntensity));
+ grad.addColorStop(1, 'transparent');
+ ctx.fillStyle = grad;
+ ctx.fillRect(-lane.length / 2, -lane.thickness / 2, lane.length, lane.thickness);
+ ctx.restore();
+ }
+
+ // 2. Nebula clouds -- stretch into a relativistic streak while under warp
+ for (const neb of field.nebulae) {
+ const driftX = Math.sin(this.time * neb.driftSpeedX * 10 + neb.morphSeed) * 60;
+ const driftY = Math.cos(this.time * neb.driftSpeedY * 10 + neb.morphSeed * 1.3) * 45;
+ const bx = cx + neb.nx * this.width + driftX;
+ const by = cy + neb.ny * this.height + driftY;
+ const pulse = 1 + Math.sin(this.time * neb.pulseSpeed + neb.pulsePhase) * 0.15;
+ const radius = neb.radius * pulse;
+ const alpha = neb.alpha * lighting.nebulaIntensity;
+
+ ctx.save();
+ if (isWarp) {
+ ctx.translate(bx, by);
+ ctx.scale(warpStretch, 1);
+ ctx.translate(-bx, -by);
+ }
+ const grad = ctx.createRadialGradient(bx, by, radius * 0.1, bx, by, radius);
+ grad.addColorStop(0, hexToRgba(neb.color, alpha));
+ grad.addColorStop(0.5, hexToRgba(neb.color, alpha * 0.4));
+ grad.addColorStop(1, 'transparent');
+ ctx.fillStyle = grad;
+ ctx.fillRect(0, 0, this.width, this.height);
+ ctx.restore();
+ }
+
+ // 3. Derelicts / distant megastructures -- angular hull silhouette with a blinking beacon
+ for (const d of field.derelicts) {
+ const bx = cx + d.nx * this.width;
+ const by = cy + d.ny * this.height;
+ const scale = d.scale * minDim;
+ ctx.save();
+ ctx.translate(bx, by);
+ ctx.rotate(d.rotation);
+ ctx.globalAlpha = 0.55;
+ ctx.fillStyle = d.color;
+ ctx.beginPath();
+ ctx.moveTo(-scale, 0);
+ for (let s = 0; s < d.segments; s++) {
+ const ang = (s / d.segments) * Math.PI - Math.PI / 2;
+ ctx.lineTo(Math.cos(ang) * scale, Math.sin(ang) * scale * 0.3);
+ }
+ ctx.lineTo(scale, 0);
+ ctx.closePath();
+ ctx.fill();
+ ctx.globalAlpha = 0.3 + Math.max(0, Math.sin(this.time * d.beaconSpeed + d.beaconPhase)) * 0.7;
+ ctx.fillStyle = '#f43f5e';
+ ctx.beginPath();
+ ctx.arc(scale * 0.7, 0, Math.max(1.5, scale * 0.04), 0, Math.PI * 2);
+ ctx.fill();
+ ctx.restore();
+
+ this.drawComputerCallout(ctx, bx, by, scale, d.label, '#f43f5e', d.scanPhase, d.pingPhase, d.pingInterval);
+ }
+
+ // 4. Planets with atmosphere, rings and orbiting moons
+ for (const p of field.planets) {
+ const driftX = Math.sin(this.time * p.driftSpeedX * 10 + p.driftPhase) * 25;
+ const driftY = Math.cos(this.time * p.driftSpeedY * 10 + p.driftPhase * 1.2) * 18;
+ const px = cx + p.nx * this.width + driftX;
+ const py = cy + p.ny * this.height + driftY;
+ const pr = p.radius * minDim;
+
+ // Rings are split into a back arc and a front arc so they read as an actual ring
+ // encircling a sphere (like Saturn) rather than a flat ellipse laid over a circle --
+ // the back arc is drawn now, then the opaque planet body occludes its far half, then
+ // the front arc is drawn again afterward so it visibly crosses in front of the sphere.
+ if (p.hasRings) {
+ ctx.save();
+ ctx.translate(px, py);
+ ctx.rotate(p.ringTilt);
+ for (let r = pr * 1.3; r <= pr * 2.0; r += pr * 0.12) {
+ ctx.beginPath();
+ ctx.ellipse(0, 0, r, r * 0.26, 0, Math.PI, Math.PI * 2);
+ ctx.strokeStyle = hexToRgba(p.palette.ring, 0.1 + Math.sin(r * 0.15) * 0.06);
+ ctx.lineWidth = pr * 0.05;
+ ctx.stroke();
+ }
+ ctx.restore();
+ }
+
+ const bodyGrad = ctx.createRadialGradient(
+ px - pr * Math.cos(p.lightAngle) * 0.35, py - pr * Math.sin(p.lightAngle) * 0.35, pr * 0.05,
+ px, py, pr
+ );
+ bodyGrad.addColorStop(0, p.palette.body[0]);
+ bodyGrad.addColorStop(0.5, p.palette.body[1]);
+ bodyGrad.addColorStop(0.85, p.palette.body[2]);
+ bodyGrad.addColorStop(1, p.palette.body[3]);
+ ctx.fillStyle = bodyGrad;
+ ctx.beginPath();
+ ctx.arc(px, py, pr, 0, Math.PI * 2);
+ ctx.fill();
+
+ // Atmospheric rim glow, gently audio-reactive
+ ctx.strokeStyle = hexToRgba(p.palette.rim, 0.4 + bass * 0.15);
+ ctx.lineWidth = pr * 0.06;
+ ctx.beginPath();
+ ctx.arc(px, py, pr * 1.01, 0, Math.PI * 2);
+ ctx.stroke();
+
+ if (p.hasRings) {
+ ctx.save();
+ ctx.translate(px, py);
+ ctx.rotate(p.ringTilt);
+ for (let r = pr * 1.3; r <= pr * 2.0; r += pr * 0.12) {
+ ctx.beginPath();
+ ctx.ellipse(0, 0, r, r * 0.26, 0, 0, Math.PI);
+ ctx.strokeStyle = hexToRgba(p.palette.ring, 0.16 + Math.sin(r * 0.15) * 0.08);
+ ctx.lineWidth = pr * 0.05;
+ ctx.stroke();
+ }
+ ctx.restore();
+ }
+
+ for (const moon of p.moons) {
+ const mAng = this.time * moon.speed + moon.phase;
+ const mx = px + Math.cos(mAng) * pr * moon.orbitR;
+ const my = py + Math.sin(mAng) * pr * moon.orbitR * moon.squash;
+ ctx.save();
+ ctx.globalAlpha = 0.85;
+ ctx.fillStyle = moon.color;
+ ctx.beginPath();
+ ctx.arc(mx, my, pr * moon.size, 0, Math.PI * 2);
+ ctx.fill();
+ ctx.restore();
+ }
+
+ this.drawComputerCallout(ctx, px, py, pr, p.palette.label, p.palette.rim, p.scanPhase, p.pingPhase, p.pingInterval);
+ }
+
+ // 5. Gravitational / radiation anomalies -- rare, activity-gated, fade in past their threshold
+ for (const a of field.anomalies) {
+ if (activity < a.minActivity) continue;
+ const ax = cx + a.nx * this.width;
+ const ay = cy + a.ny * this.height;
+ const pulse = 0.5 + Math.sin(this.time * a.pulseSpeed + a.pulsePhase) * 0.5;
+ const visibility = Math.min(1, (activity - a.minActivity) / 0.25);
+ const alpha = pulse * 0.5 * visibility;
+ if (alpha <= 0) continue;
+
+ if (a.kind === 'lensing') {
+ for (let ring = 0; ring < 3; ring++) {
+ const rr = a.radius * (0.5 + ring * 0.35) * (1 + pulse * 0.15);
+ ctx.beginPath();
+ ctx.arc(ax, ay, rr, 0, Math.PI * 2);
+ ctx.strokeStyle = hexToRgba(a.color, alpha * (1 - ring * 0.25));
+ ctx.lineWidth = 2;
+ ctx.stroke();
+ }
+ } else {
+ const grad = ctx.createRadialGradient(ax, ay, 0, ax, ay, a.radius);
+ grad.addColorStop(0, hexToRgba(a.color, alpha * 0.6));
+ grad.addColorStop(1, 'transparent');
+ ctx.fillStyle = grad;
+ ctx.beginPath();
+ ctx.arc(ax, ay, a.radius, 0, Math.PI * 2);
+ ctx.fill();
+ }
+
+ // Computer callout instead of a bare floating label -- also keeps the text off the
+ // anomaly itself via the bracket's leader line, rather than sitting on top of it (or,
+ // as could happen before, on top of an unrelated planet placed nearby).
+ this.drawComputerCallout(ctx, ax, ay, a.radius, a.label, a.color, a.scanPhase, a.pingPhase, a.pingInterval, visibility);
+ }
+ }
+
+ // Shared "computer console" callout: LCARD corner brackets sized to the object, a leader
+ // line, and a small mono-font tag -- the same drawing technique already used for ship
+ // traffic reticles (renderTargetReticles), extended here to any discrete Deep Space object.
+ // Idles with a slow breathing pulse and flashes briefly on a per-object interval, like a
+ // sensor periodically re-confirming a lock, rather than sitting static on screen.
+ drawComputerCallout(ctx, x, y, radius, label, color, scanPhase, pingPhase, pingInterval, intensityMul = 1) {
+ const breathe = 0.55 + Math.sin(this.time * 0.6 + scanPhase) * 0.2;
+ const cycle = pingInterval || 12;
+ const t = (this.time + pingPhase) % cycle;
+ const ping = t < 0.5 ? (1 - t / 0.5) : 0;
+ const alpha = Math.min(1, breathe + ping * 0.5) * intensityMul;
+ if (alpha <= 0.02) return;
+
+ const boxSize = Math.max(18, radius * 1.18);
+ const bLen = Math.max(5, boxSize * 0.22);
+ const onRight = x > this.width * 0.55;
+ const dir = onRight ? -1 : 1;
+
+ ctx.save();
+ ctx.font = '11px "Share Tech Mono", monospace';
+ ctx.textAlign = onRight ? 'right' : 'left';
+ ctx.strokeStyle = hexToRgba(color, alpha);
+ ctx.fillStyle = hexToRgba(color, Math.min(1, alpha + 0.15));
+ ctx.lineWidth = 1.2;
+
+ ctx.beginPath();
+ ctx.moveTo(x - boxSize, y - boxSize + bLen);
+ ctx.lineTo(x - boxSize, y - boxSize);
+ ctx.lineTo(x - boxSize + bLen, y - boxSize);
+ ctx.moveTo(x + boxSize - bLen, y - boxSize);
+ ctx.lineTo(x + boxSize, y - boxSize);
+ ctx.lineTo(x + boxSize, y - boxSize + bLen);
+ ctx.moveTo(x - boxSize, y + boxSize - bLen);
+ ctx.lineTo(x - boxSize, y + boxSize);
+ ctx.lineTo(x - boxSize + bLen, y + boxSize);
+ ctx.moveTo(x + boxSize - bLen, y + boxSize);
+ ctx.lineTo(x + boxSize, y + boxSize);
+ ctx.lineTo(x + boxSize, y + boxSize - bLen);
+ ctx.stroke();
+
+ // Route the leader line downward instead of upward when the object sits close enough to
+ // the top edge that an upward line would run into the HUD header text.
+ const routeDown = (y - boxSize - 40) < 70;
+ const vDir = routeDown ? 1 : -1;
+ const cornerX = onRight ? x - boxSize : x + boxSize;
+ const cornerY = routeDown ? y + boxSize : y - boxSize;
+ // Two objects at similar heights can still park their labels at the same offset even
+ // when their brackets are cleanly separated horizontally -- a small deterministic
+ // per-object jitter (derived from its own scanPhase, so it's stable frame to frame)
+ // spreads those cases apart without needing true label-bounding-box collision checks.
+ const kinkJitter = Math.sin(scanPhase * 1.7) * 16;
+ const kinkX = cornerX + dir * 22;
+ const kinkY = cornerY + vDir * 14 + kinkJitter;
+ const endX = cornerX + dir * 130;
+ ctx.beginPath();
+ ctx.moveTo(cornerX, cornerY);
+ ctx.lineTo(kinkX, kinkY);
+ ctx.lineTo(endX, kinkY);
+ ctx.stroke();
+
+ ctx.fillText(label, cornerX + dir * 26, kinkY + (routeDown ? 14 : -4));
+ ctx.restore();
+ }
+
+ renderStarfield(ctx, cx, cy) {
+ const fov = 420;
+ const prof = this.starProfile || DEFAULT_STARFIELD_PROFILE;
+ const isWarp = this.flightMode === 'warp' && prof.warpStreaks !== false;
+ const brightness = this.getLightingModifiers().starBrightness;
+
+ for (let i = 0; i < this.stars.length; i++) {
+ const star = this.stars[i];
+ const sx = cx + (star.x / star.z) * fov;
+ const sy = cy + (star.y / star.z) * fov;
+
+ if (sx < -20 || sx > this.width + 20 || sy < -20 || sy > this.height + 20) {
+ continue;
+ }
+
+ const normZ = 1 - star.z / 1200;
+ const alpha = Math.max(0.15, Math.min(1, normZ * (isWarp ? 1.0 : (0.7 + Math.sin(this.time * star.twinkleSpeed + star.twinklePhase) * 0.3)) * brightness));
+
+ if (isWarp) {
+ // Relativistic Warp Streaks
+ const spx = cx + (star.x / star.pz) * fov;
+ const spy = cy + (star.y / star.pz) * fov;
+
+ ctx.beginPath();
+ ctx.moveTo(spx, spy);
+ ctx.lineTo(sx, sy);
+ ctx.strokeStyle = star.color;
+ ctx.lineWidth = star.size * (1 + normZ * 1.5);
+ ctx.globalAlpha = alpha;
+ ctx.stroke();
+ } else {
+ // Cruise Stars
+ ctx.beginPath();
+ ctx.arc(sx, sy, star.size * (0.8 + normZ * 0.8), 0, Math.PI * 2);
+ ctx.fillStyle = star.color;
+ ctx.globalAlpha = alpha;
+ ctx.fill();
+ }
+ }
+ ctx.globalAlpha = 1.0;
+ }
+
+ renderTrafficAndEvents(ctx) {
+ // 1. Draw traffic engine trails
+ for (const ship of this.traffic) {
+ for (const pt of ship.particles) {
+ ctx.beginPath();
+ ctx.arc(pt.x, pt.y, pt.size * pt.alpha, 0, Math.PI * 2);
+ ctx.fillStyle = pt.color;
+ ctx.globalAlpha = pt.alpha;
+ ctx.fill();
+ }
+ ctx.globalAlpha = 1.0;
+
+ // Draw Ship Silhouette / Vessel Graphic
+ this.drawShipVessel(ctx, ship);
+ }
+
+ // 2. Draw Dynamic Events (Warp Flash, Comets)
+ for (const ev of this.events) {
+ if (ev.kind === 'warp-flash') {
+ const prog = ev.life / ev.maxLife;
+ const radius = prog * 160;
+ const alpha = Math.max(0, 1 - prog);
+
+ ctx.save();
+ ctx.translate(ev.x, ev.y);
+
+ // Radial starburst
+ const fgrad = ctx.createRadialGradient(0, 0, 0, 0, 0, radius);
+ fgrad.addColorStop(0, '#ffffff');
+ fgrad.addColorStop(0.3, '#38bdf8');
+ fgrad.addColorStop(1, 'transparent');
+ ctx.fillStyle = fgrad;
+ ctx.globalAlpha = alpha;
+ ctx.beginPath();
+ ctx.arc(0, 0, radius, 0, Math.PI * 2);
+ ctx.fill();
+
+ // Anamorphic horizontal streak
+ ctx.strokeStyle = '#93c5fd';
+ ctx.lineWidth = (1 - prog) * 6;
+ ctx.beginPath();
+ ctx.moveTo(-radius * 3.5, 0);
+ ctx.lineTo(radius * 3.5, 0);
+ ctx.stroke();
+
+ ctx.restore();
+ } else if (ev.kind === 'comet') {
+ const prog = ev.life / ev.maxLife;
+ const cx = ev.x + ev.vx * ev.life;
+ const cy = ev.y + ev.vy * ev.life;
+ const alpha = Math.sin(prog * Math.PI) * 0.85;
+
+ ctx.save();
+ ctx.globalAlpha = alpha;
+
+ // Tail
+ ctx.beginPath();
+ ctx.moveTo(cx, cy);
+ ctx.lineTo(cx - ev.vx * 0.7, cy - ev.vy * 0.7);
+ ctx.strokeStyle = 'rgba(186, 230, 253, 0.6)';
+ ctx.lineWidth = 4;
+ ctx.stroke();
+
+ // Head
+ ctx.fillStyle = '#ffffff';
+ ctx.beginPath();
+ ctx.arc(cx, cy, 5, 0, Math.PI * 2);
+ ctx.fill();
+
+ ctx.restore();
+ }
+ }
+ }
+
+ drawShipVessel(ctx, ship) {
+ ctx.save();
+ ctx.translate(ship.x, ship.y);
+
+ const heading = Math.atan2(ship.vy, ship.vx);
+ ctx.rotate(heading);
+ ctx.scale(ship.scale, ship.scale);
+
+ if (ship.type === 'shuttle') {
+ // Sleek Starflight Shuttlecraft
+ // Hull
+ ctx.fillStyle = '#e2e8f0';
+ ctx.beginPath();
+ ctx.moveTo(28, 0);
+ ctx.lineTo(12, -10);
+ ctx.lineTo(-24, -10);
+ ctx.lineTo(-28, -6);
+ ctx.lineTo(-28, 6);
+ ctx.lineTo(-24, 10);
+ ctx.lineTo(12, 10);
+ ctx.closePath();
+ ctx.fill();
+
+ // Cockpit windshield
+ ctx.fillStyle = '#0f172a';
+ ctx.beginPath();
+ ctx.moveTo(22, 0);
+ ctx.lineTo(10, -6);
+ ctx.lineTo(6, -6);
+ ctx.lineTo(6, 6);
+ ctx.lineTo(10, 6);
+ ctx.closePath();
+ ctx.fill();
+
+ // Warp Nacelles with Glowing Blue Field
+ ctx.fillStyle = '#94a3b8';
+ ctx.fillRect(-22, -16, 26, 4);
+ ctx.fillRect(-22, 12, 26, 4);
+
+ ctx.fillStyle = '#38bdf8';
+ ctx.shadowColor = '#38bdf8';
+ ctx.shadowBlur = 8;
+ ctx.fillRect(-18, -15, 18, 2);
+ ctx.fillRect(-18, 13, 18, 2);
+
+ // Red Bussard Collectors
+ ctx.fillStyle = '#ef4444';
+ ctx.shadowColor = '#ef4444';
+ ctx.beginPath();
+ ctx.arc(5, -14, 2, 0, Math.PI * 2);
+ ctx.arc(5, 14, 2, 0, Math.PI * 2);
+ ctx.fill();
+ } else if (ship.type === 'cruiser') {
+ // Starflight Capital Cruiser Silhouette
+ ctx.fillStyle = '#cbd5e1';
+ // Primary Saucer
+ ctx.beginPath();
+ ctx.ellipse(30, 0, 24, 14, 0, 0, Math.PI * 2);
+ ctx.fill();
+
+ // Secondary Engineering Hull & Neck
+ ctx.fillStyle = '#94a3b8';
+ ctx.fillRect(-15, -5, 34, 10);
+ ctx.beginPath();
+ ctx.moveTo(-15, -4);
+ ctx.lineTo(-45, -3);
+ ctx.lineTo(-45, 3);
+ ctx.lineTo(-15, 4);
+ ctx.closePath();
+ ctx.fill();
+
+ // Dual Nacelle Struts and Nacelles
+ ctx.strokeStyle = '#64748b';
+ ctx.lineWidth = 3;
+ ctx.beginPath();
+ ctx.moveTo(-10, 0);
+ ctx.lineTo(-25, -20);
+ ctx.moveTo(-10, 0);
+ ctx.lineTo(-25, 20);
+ ctx.stroke();
+
+ ctx.fillStyle = '#3b82f6';
+ ctx.shadowColor = '#60a5fa';
+ ctx.shadowBlur = 10;
+ ctx.fillRect(-45, -22, 42, 5);
+ ctx.fillRect(-45, 17, 42, 5);
+
+ // Bussard
+ ctx.fillStyle = '#ef4444';
+ ctx.beginPath();
+ ctx.arc(-2, -19.5, 2.5, 0, Math.PI * 2);
+ ctx.arc(-2, 19.5, 2.5, 0, Math.PI * 2);
+ ctx.fill();
+ } else if (ship.type === 'tardix') {
+ // Whataverse TARDIX tumbling in vortex
+ ctx.rotate(this.time * 1.5);
+ ctx.fillStyle = '#1e3a8a';
+ ctx.fillRect(-12, -18, 24, 36);
+
+ // Panels
+ ctx.fillStyle = '#172554';
+ ctx.fillRect(-10, -15, 9, 14);
+ ctx.fillRect(1, -15, 9, 14);
+ ctx.fillRect(-10, 1, 9, 14);
+ ctx.fillRect(1, 1, 9, 14);
+
+ // Flashing amber lantern
+ ctx.fillStyle = Math.sin(this.time * 6) > 0 ? '#fbbf24' : '#78350f';
+ ctx.shadowColor = '#fbbf24';
+ ctx.shadowBlur = 8;
+ ctx.beginPath();
+ ctx.arc(0, -21, 3.5, 0, Math.PI * 2);
+ ctx.fill();
+ } else if (ship.type === 'fighterwing') {
+ // Military Fighter Wing - tight 3-ship delta formation
+ const drawFighter = (ox, oy) => {
+ ctx.save();
+ ctx.translate(ox, oy);
+ ctx.fillStyle = '#4b5563';
+ ctx.beginPath();
+ ctx.moveTo(16, 0);
+ ctx.lineTo(-10, -9);
+ ctx.lineTo(-6, 0);
+ ctx.lineTo(-10, 9);
+ ctx.closePath();
+ ctx.fill();
+
+ ctx.fillStyle = '#eab308';
+ ctx.shadowColor = '#eab308';
+ ctx.shadowBlur = 6;
+ ctx.beginPath();
+ ctx.arc(-9, 0, 1.6, 0, Math.PI * 2);
+ ctx.fill();
+ ctx.restore();
+ };
+ drawFighter(0, 0);
+ drawFighter(-16, -14);
+ drawFighter(-16, 14);
+ } else if (ship.type === 'bioshippod') {
+ // Organic Bioship Spawn Pod - pulsing membrane sac drifting through space
+ const pulse = 1 + Math.sin(this.time * 2.4) * 0.12;
+ ctx.fillStyle = '#065f46';
+ ctx.beginPath();
+ ctx.ellipse(0, 0, 22 * pulse, 13 * pulse, 0, 0, Math.PI * 2);
+ ctx.fill();
+
+ ctx.fillStyle = 'rgba(52, 211, 153, 0.55)';
+ ctx.shadowColor = '#34d399';
+ ctx.shadowBlur = 12;
+ ctx.beginPath();
+ ctx.ellipse(0, 0, 13 * pulse, 7 * pulse, 0, 0, Math.PI * 2);
+ ctx.fill();
+
+ ctx.shadowBlur = 0;
+ ctx.strokeStyle = 'rgba(167, 243, 208, 0.5)';
+ ctx.lineWidth = 1;
+ for (let v = -1; v <= 1; v++) {
+ ctx.beginPath();
+ ctx.moveTo(-18, v * 6);
+ ctx.lineTo(18, v * 6);
+ ctx.stroke();
+ }
+ } else if (ship.type === 'retrosaucer') {
+ // Retro-Future Atomic-Age Flying Saucer with a chasing rim-light pattern
+ ctx.fillStyle = '#d1d5db';
+ ctx.beginPath();
+ ctx.ellipse(0, 2, 26, 8, 0, 0, Math.PI * 2);
+ ctx.fill();
+
+ ctx.fillStyle = '#9ca3af';
+ ctx.beginPath();
+ ctx.ellipse(0, -3, 12, 9, 0, 0, Math.PI * 2);
+ ctx.fill();
+
+ const litIndex = Math.floor(this.time * 4) % 6;
+ for (let i = 0; i < 6; i++) {
+ const ang = (i / 6) * Math.PI * 2;
+ const isLit = i === litIndex;
+ ctx.fillStyle = isLit ? '#4ade80' : 'rgba(74, 222, 128, 0.35)';
+ ctx.shadowColor = '#4ade80';
+ ctx.shadowBlur = isLit ? 8 : 0;
+ ctx.beginPath();
+ ctx.arc(Math.cos(ang) * 22, 2 + Math.sin(ang) * 6, 2, 0, Math.PI * 2);
+ ctx.fill();
+ }
+ } else {
+ // Heavy Industrial Cargo Freighter
+ ctx.fillStyle = '#78350f';
+ ctx.fillRect(-35, -12, 55, 24);
+ ctx.fillStyle = '#b45309';
+ ctx.fillRect(12, -8, 16, 16);
+
+ // Orange Thrusters
+ ctx.fillStyle = '#f97316';
+ ctx.shadowColor = '#ea580c';
+ ctx.shadowBlur = 10;
+ ctx.fillRect(-38, -9, 4, 6);
+ ctx.fillRect(-38, 3, 4, 6);
+ }
+
+ ctx.restore();
+ }
+
+ renderTargetReticles(ctx) {
+ ctx.save();
+ ctx.font = '11px "Share Tech Mono", monospace';
+ ctx.fillStyle = '#38bdf8';
+ ctx.strokeStyle = '#38bdf8';
+ ctx.lineWidth = 1.2;
+
+ for (const ship of this.traffic) {
+ const boxSize = 34 * ship.scale;
+ const x = ship.x;
+ const y = ship.y;
+
+ // Draw LCARD corner reticle brackets
+ const bLen = 8;
+ // Top-left
+ ctx.beginPath();
+ ctx.moveTo(x - boxSize, y - boxSize + bLen);
+ ctx.lineTo(x - boxSize, y - boxSize);
+ ctx.lineTo(x - boxSize + bLen, y - boxSize);
+ // Top-right
+ ctx.moveTo(x + boxSize - bLen, y - boxSize);
+ ctx.lineTo(x + boxSize, y - boxSize);
+ ctx.lineTo(x + boxSize, y - boxSize + bLen);
+ // Bottom-left
+ ctx.moveTo(x - boxSize, y + boxSize - bLen);
+ ctx.lineTo(x - boxSize, y + boxSize);
+ ctx.lineTo(x - boxSize + bLen, y + boxSize);
+ // Bottom-right
+ ctx.moveTo(x + boxSize - bLen, y + boxSize);
+ ctx.lineTo(x + boxSize, y + boxSize);
+ ctx.lineTo(x + boxSize, y + boxSize - bLen);
+ ctx.stroke();
+
+ // Leader line and text tag
+ ctx.beginPath();
+ ctx.moveTo(x + boxSize, y - boxSize);
+ ctx.lineTo(x + boxSize + 22, y - boxSize - 14);
+ ctx.lineTo(x + boxSize + 130, y - boxSize - 14);
+ ctx.stroke();
+
+ ctx.fillText(ship.label, x + boxSize + 26, y - boxSize - 18);
+ }
+
+ ctx.restore();
+ }
+
+
+ getWaveformThemeConfig() {
+ const defaults = {
+ key: 'starflight',
+ style: 'starflight',
+ label: 'SUBSPACE HARMONICS',
+ labelLetterSpacing: '0.2em',
+ labelSize: '0.65rem',
+ panelOpacity: '0.84',
+ position: {
+ top: '',
+ right: '',
+ bottom: '2.2vh',
+ left: '50%',
+ transform: 'translateX(-50%)',
+ alignItems: 'center',
+ width: 'clamp(260px, 34vw, 460px)'
+ },
+ canvas: {
+ width: '100%',
+ height: '38px',
+ radius: '8px',
+ background: 'transparent'
+ },
+ panel: {
+ padding: '8px 14px',
+ borderRadius: '14px',
+ background: 'linear-gradient(180deg, rgba(2,12,28,0.28), rgba(1,5,16,0.1))',
+ border: '1px solid rgba(103,232,249,0.18)',
+ boxShadow: '0 0 18px rgba(56,189,248,0.1), inset 0 0 18px rgba(8,47,73,0.12)',
+ backdropFilter: 'blur(2px)'
+ },
+ colors: {
+ primary: '#67e8f9',
+ secondary: '#60a5fa',
+ tertiary: '#dbeafe',
+ accent: '#f59e0b',
+ panel: 'rgba(3, 10, 24, 0.25)'
+ }
+ };
+
+ switch (this.activeUniverse) {
+ case 'whataverse':
+ return {
+ key: 'whataverse',
+ style: 'whataverseNodes',
+ label: 'TEMPORAL NODE FIELD',
+ panelOpacity: '0.9',
+ position: {
+ top: '8vh',
+ right: '',
+ bottom: 'auto',
+ left: '50%',
+ transform: 'translateX(-50%)',
+ alignItems: 'center',
+ width: 'clamp(340px, 42vw, 580px)'
+ },
+ canvas: { width: '100%', height: '82px', radius: '16px', background: 'transparent' },
+ panel: {
+ padding: '10px 18px 12px',
+ borderRadius: '18px',
+ background: 'transparent',
+ border: 'none',
+ boxShadow: 'none',
+ backdropFilter: 'none'
+ },
+ colors: {
+ primary: '#65f3ff',
+ secondary: '#3b82f6',
+ tertiary: '#d4af37',
+ accent: '#f8fafc',
+ panel: 'rgba(6,18,34,0.18)'
+ }
+ };
+ case 'industrial':
+ return {
+ key: 'industrial',
+ style: 'industrialScope',
+ label: 'DRIVE VIBRATION SPECTRUM',
+ position: {
+ top: 'auto',
+ right: 'auto',
+ bottom: '12.5vh',
+ left: '3vw',
+ transform: 'none',
+ alignItems: 'flex-start',
+ width: 'clamp(240px, 24vw, 360px)'
+ },
+ canvas: { width: '100%', height: '58px', radius: '8px', background: 'rgba(8,7,4,0.35)' },
+ panel: {
+ padding: '8px 10px 10px',
+ borderRadius: '12px',
+ background: 'linear-gradient(180deg, rgba(28,18,8,0.25), rgba(8,5,2,0.15))',
+ border: '1px solid rgba(245,158,11,0.28)',
+ boxShadow: '0 0 18px rgba(245,158,11,0.11), inset 0 0 16px rgba(120,53,15,0.16)',
+ backdropFilter: 'blur(1px)'
+ },
+ colors: {
+ primary: '#f59e0b',
+ secondary: '#f97316',
+ tertiary: '#fcd34d',
+ accent: '#f8fafc',
+ panel: 'rgba(20,10,3,0.35)'
+ }
+ };
+ case 'bioships':
+ return {
+ key: 'bioships',
+ style: 'bioshipMembrane',
+ label: 'NEURAL RESONANCE',
+ position: {
+ top: '16vh',
+ right: '1.8vw',
+ bottom: 'auto',
+ left: 'auto',
+ transform: 'none',
+ alignItems: 'flex-end',
+ width: 'clamp(250px, 24vw, 340px)'
+ },
+ canvas: { width: '100%', height: '84px', radius: '20px', background: 'transparent' },
+ panel: {
+ padding: '8px 14px 12px',
+ borderRadius: '22px',
+ background:
+ 'radial-gradient(circle at 30% 50%, rgba(16,185,129,0.18), rgba(1,20,14,0.08))',
+ border: '1px solid rgba(16,185,129,0.24)',
+ boxShadow: '0 0 22px rgba(16,185,129,0.1), inset 0 0 28px rgba(139,92,246,0.08)',
+ backdropFilter: 'blur(1px)'
+ },
+ colors: {
+ primary: '#34d399',
+ secondary: '#10b981',
+ tertiary: '#a78bfa',
+ accent: '#ecfeff',
+ panel: 'rgba(1,24,18,0.22)'
+ }
+ };
+ case 'retrofuture':
+ return {
+ key: 'retrofuture',
+ style: 'retroCrt',
+ label: 'HARMONIC TRACE',
+ position: {
+ top: '',
+ right: '',
+ bottom: '4.2vh',
+ left: '50%',
+ transform: 'translateX(-50%)',
+ alignItems: 'center',
+ width: 'clamp(260px, 34vw, 440px)'
+ },
+ canvas: { width: '100%', height: '56px', radius: '6px', background: 'rgba(0,18,4,0.45)' },
+ panel: {
+ padding: '10px 14px 12px',
+ borderRadius: '8px',
+ background: 'linear-gradient(180deg, rgba(2,14,4,0.22), rgba(1,10,3,0.14))',
+ border: '1px solid rgba(34,197,94,0.28)',
+ boxShadow: '0 0 18px rgba(34,197,94,0.1), inset 0 0 20px rgba(20,83,45,0.14)',
+ backdropFilter: 'none'
+ },
+ colors: {
+ primary: '#4ade80',
+ secondary: '#22c55e',
+ tertiary: '#dcfce7',
+ accent: '#86efac',
+ panel: 'rgba(0,20,4,0.32)'
+ }
+ };
+ case 'military':
+ return {
+ key: 'military',
+ style: 'militaryBlocks',
+ label: 'TACTICAL CARRIER ANALYSIS',
+ labelLetterSpacing: '0.16em',
+ position: {
+ top: '34vh',
+ right: '2.8vw',
+ bottom: 'auto',
+ left: 'auto',
+ transform: 'none',
+ alignItems: 'flex-end',
+ width: 'clamp(240px, 22vw, 340px)'
+ },
+ canvas: {
+ width: '100%',
+ height: '82px',
+ radius: '8px',
+ background: 'rgba(10,10,12,0.3)'
+ },
+ panel: {
+ padding: '8px 10px 10px',
+ borderRadius: '10px',
+ background: 'linear-gradient(180deg, rgba(10,10,12,0.2), rgba(8,8,10,0.15))',
+ border: '1px solid rgba(148,163,184,0.26)',
+ boxShadow: '0 0 18px rgba(100,116,139,0.1), inset 0 0 16px rgba(127,29,29,0.1)',
+ backdropFilter: 'blur(1px)'
+ },
+ colors: {
+ primary: '#cbd5e1',
+ secondary: '#64748b',
+ tertiary: '#ef4444',
+ accent: '#f8fafc',
+ panel: 'rgba(10,12,18,0.35)'
+ }
+ };
+ case 'deepspace':
+ return {
+ key: 'deepspace',
+ style: 'deepSpaceField',
+ label: 'FIELD INTERFERENCE',
+ position: {
+ top: '11vh',
+ right: 'auto',
+ bottom: 'auto',
+ left: '6vw',
+ transform: 'none',
+ alignItems: 'flex-start',
+ width: 'clamp(280px, 30vw, 460px)'
+ },
+ canvas: { width: '100%', height: '72px', radius: '10px', background: 'transparent' },
+ panel: {
+ padding: '8px 12px 10px',
+ borderRadius: '14px',
+ background: 'linear-gradient(180deg, rgba(4,8,22,0.24), rgba(1,4,12,0.08))',
+ border: '1px solid rgba(56,189,248,0.18)',
+ boxShadow: '0 0 18px rgba(59,130,246,0.08), inset 0 0 18px rgba(245,158,11,0.06)',
+ backdropFilter: 'blur(1px)'
+ },
+ colors: {
+ primary: '#93c5fd',
+ secondary: '#60a5fa',
+ tertiary: '#f59e0b',
+ accent: '#f8fafc',
+ panel: 'rgba(2,8,20,0.15)'
+ }
+ };
+ case 'outlaw':
+ return {
+ key: 'outlaw',
+ style: 'outlawGauges',
+ label: 'FIELD TUNING',
+ position: {
+ top: '',
+ right: '',
+ bottom: '7vh',
+ left: '4vw',
+ transform: 'none',
+ alignItems: 'flex-start',
+ width: 'clamp(290px, 26vw, 400px)'
+ },
+ canvas: {
+ width: '100%',
+ height: '126px',
+ radius: '10px',
+ background: 'rgba(18,8,10,0.25)'
+ },
+ panel: {
+ padding: '8px 12px 10px',
+ borderRadius: '12px',
+ background: 'linear-gradient(180deg, rgba(28,8,12,0.22), rgba(10,4,8,0.12))',
+ border: '1px solid rgba(249,115,22,0.25)',
+ boxShadow: '0 0 18px rgba(249,115,22,0.12), inset 0 0 16px rgba(123,61,184,0.09)',
+ backdropFilter: 'blur(1px)'
+ },
+ colors: {
+ primary: '#fb923c',
+ secondary: '#a855f7',
+ tertiary: '#fde68a',
+ accent: '#f8fafc',
+ panel: 'rgba(18,8,10,0.24)'
+ }
+ };
+ case 'spacestations':
+ return {
+ key: 'spacestations',
+ style: 'stationMatrix',
+ label: 'STATION RESONANCE MATRIX',
+ labelLetterSpacing: '0.16em',
+ position: {
+ top: '18vh',
+ right: '2.3vw',
+ bottom: 'auto',
+ left: 'auto',
+ transform: 'none',
+ alignItems: 'flex-end',
+ width: 'clamp(280px, 24vw, 360px)'
+ },
+ canvas: {
+ width: '100%',
+ height: '136px',
+ radius: '10px',
+ background: 'rgba(3,12,22,0.24)'
+ },
+ panel: {
+ padding: '8px 12px 10px',
+ borderRadius: '12px',
+ background: 'linear-gradient(180deg, rgba(2,12,24,0.22), rgba(2,8,15,0.14))',
+ border: '1px solid rgba(56,189,248,0.24)',
+ boxShadow: '0 0 18px rgba(56,189,248,0.1), inset 0 0 18px rgba(14,165,233,0.08)',
+ backdropFilter: 'blur(1px)'
+ },
+ colors: {
+ primary: '#38bdf8',
+ secondary: '#22d3ee',
+ tertiary: '#f59e0b',
+ accent: '#f8fafc',
+ panel: 'rgba(4,14,24,0.24)'
+ }
+ };
+ case 'comedy':
+ return {
+ key: 'comedy',
+ style: 'comedyAdventure',
+ label: 'DRIVE HARMONICS',
+ position: {
+ top: '',
+ right: '4vw',
+ bottom: '6vh',
+ left: '',
+ transform: 'none',
+ alignItems: 'flex-end',
+ width: 'clamp(280px, 30vw, 440px)'
+ },
+ canvas: { width: '100%', height: '62px', radius: '12px', background: 'transparent' },
+ panel: {
+ padding: '8px 12px 10px',
+ borderRadius: '16px',
+ background: 'linear-gradient(180deg, rgba(28,16,52,0.2), rgba(10,8,24,0.1))',
+ border: '1px solid rgba(244,114,182,0.22)',
+ boxShadow: '0 0 20px rgba(167,139,250,0.12), inset 0 0 18px rgba(253,224,71,0.07)',
+ backdropFilter: 'blur(1px)'
+ },
+ colors: {
+ primary: '#f472b6',
+ secondary: '#a78bfa',
+ tertiary: '#fde047',
+ accent: '#f8fafc',
+ panel: 'rgba(16,10,34,0.18)'
+ }
+ };
+ default:
+ return defaults;
+ }
+ }
+
+ applyWaveformTheme() {
+ const cfg = this.getWaveformThemeConfig();
+ if (this.waveformContainer) {
+ const s = this.waveformContainer.style;
+ s.top = cfg.position.top ?? 'auto';
+ s.right = cfg.position.right ?? 'auto';
+ s.bottom = cfg.position.bottom ?? 'auto';
+ s.left = cfg.position.left ?? 'auto';
+ s.transform = cfg.position.transform || 'none';
+ s.alignItems = cfg.position.alignItems || 'center';
+ s.width = cfg.position.width || 'clamp(260px, 34vw, 460px)';
+ s.padding = cfg.panel.padding || '8px 12px';
+ s.borderRadius = cfg.panel.borderRadius || '14px';
+ s.background = cfg.panel.background || 'transparent';
+ s.border = cfg.panel.border || 'none';
+ s.boxShadow = cfg.panel.boxShadow || 'none';
+ s.backdropFilter = cfg.panel.backdropFilter || 'none';
+ s.opacity = cfg.panelOpacity || '0.84';
+ }
+ if (this.waveformLabelEl) {
+ this.waveformLabelEl.textContent = cfg.label || 'SUBSPACE HARMONICS';
+ this.waveformLabelEl.style.color = cfg.colors.tertiary || cfg.colors.primary || '#fff';
+ this.waveformLabelEl.style.letterSpacing = cfg.labelLetterSpacing || '0.2em';
+ this.waveformLabelEl.style.fontSize = cfg.labelSize || '0.65rem';
+ this.waveformLabelEl.style.width = '100%';
+ this.waveformLabelEl.style.textAlign =
+ cfg.position.alignItems === 'center'
+ ? 'center'
+ : cfg.position.alignItems === 'flex-end'
+ ? 'right'
+ : 'left';
+ }
+ if (this.waveformCanvas) {
+ this.waveformCanvas.style.width = cfg.canvas.width || '100%';
+ this.waveformCanvas.style.height = cfg.canvas.height || '38px';
+ this.waveformCanvas.style.borderRadius = cfg.canvas.radius || '8px';
+ this.waveformCanvas.style.background = cfg.canvas.background || 'transparent';
+ this.waveformCanvas.style.display = 'block';
+ this.waveformCanvas.style.boxShadow = 'none';
+ }
+ }
+
+ getWaveformEnergySummary() {
+ const bandAvg = (start, end) => {
+ let total = 0;
+ let count = 0;
+ for (let i = start; i <= end && i < this.analyserData.length; i++) {
+ total += this.analyserData[i] || 0;
+ count++;
+ }
+ return count ? total / count / 255 : 0;
+ };
+ const bass = bandAvg(0, 10);
+ const lowMid = bandAvg(11, 22);
+ const mid = bandAvg(23, 38);
+ const treble = bandAvg(39, 63);
+ const overall = (bass + lowMid + mid + treble) / 4;
+ return { bass, lowMid, mid, treble, overall };
+ }
+
+ drawWaveformGrid(ctx, w, h, color, cols, rows, alpha) {
+ ctx.save();
+ ctx.strokeStyle = color;
+ ctx.globalAlpha = alpha;
+ ctx.lineWidth = 1;
+ for (let i = 1; i < cols; i++) {
+ const x = (w * i) / cols;
+ ctx.beginPath();
+ ctx.moveTo(x, 0);
+ ctx.lineTo(x, h);
+ ctx.stroke();
+ }
+ for (let i = 1; i < rows; i++) {
+ const y = (h * i) / rows;
+ ctx.beginPath();
+ ctx.moveTo(0, y);
+ ctx.lineTo(w, y);
+ ctx.stroke();
+ }
+ ctx.restore();
+ }
+
+ drawSmoothPath(ctx, points, color, width, alpha) {
+ if (!points || points.length < 2) return;
+ ctx.save();
+ ctx.strokeStyle = color;
+ ctx.globalAlpha = alpha;
+ ctx.lineWidth = width;
+ ctx.beginPath();
+ ctx.moveTo(points[0].x, points[0].y);
+ for (let i = 1; i < points.length - 1; i++) {
+ const xc = (points[i].x + points[i + 1].x) / 2;
+ const yc = (points[i].y + points[i + 1].y) / 2;
+ ctx.quadraticCurveTo(points[i].x, points[i].y, xc, yc);
+ }
+ const last = points[points.length - 1];
+ ctx.lineTo(last.x, last.y);
+ ctx.stroke();
+ ctx.restore();
+ }
+
+ renderWaveform() {
+ if (!this.waveformCtx || !this.waveformCanvas) return;
+ const ctx = this.waveformCtx;
+ const w = this.waveformCanvas.width / (this.dpr || 1);
+ const h = this.waveformCanvas.height / (this.dpr || 1);
+ const cfg = this.getWaveformThemeConfig();
+
+ ctx.clearRect(0, 0, w, h);
+
+ if (this.am && this.am.analyser) {
+ this.am.analyser.getByteFrequencyData(this.analyserData);
+ }
+
+ const e = this.getWaveformEnergySummary();
+ const t = this.time || 0;
+ const p = cfg.colors.primary;
+ const s = cfg.colors.secondary;
+ const a = cfg.colors.tertiary;
+ const baseline = h * 0.5;
+
+ if (cfg.style === 'whataverseNodes') {
+ const nodes = [];
+ const count = 15;
+ for (let i = 0; i < count; i++) {
+ const x = (w * i) / (count - 1);
+ const phase = t * 1.35 + i * 0.55;
+ const drift = Math.sin(phase) * (h * 0.12) + Math.cos(phase * 0.47 + i) * (h * 0.05);
+ const y = baseline + drift + Math.sin(t * 0.5 + i * 1.1) * (e.mid * h * 0.16);
+ nodes.push({ x, y });
+ }
+ ctx.save();
+ ctx.strokeStyle = s;
+ ctx.globalAlpha = 0.2;
+ for (let i = 0; i < nodes.length - 1; i++) {
+ ctx.beginPath();
+ ctx.moveTo(nodes[i].x, nodes[i].y + 8 * Math.sin(t + i));
+ ctx.lineTo(nodes[i + 1].x, nodes[i + 1].y + 8 * Math.cos(t * 1.1 + i));
+ ctx.stroke();
+ }
+ ctx.restore();
+ this.drawSmoothPath(ctx, nodes, p, 2.4, 0.95);
+ const echo = nodes.map((pt, i) => ({
+ x: pt.x,
+ y: pt.y + Math.sin(t * 1.8 + i * 0.6) * 10 + 10
+ }));
+ this.drawSmoothPath(ctx, echo, s, 1.4, 0.45);
+ nodes.forEach((pt, i) => {
+ const r = 3 + (i % 4 === 0 ? 2 : 0) + e.treble * 1.5;
+ ctx.fillStyle = i % 3 === 0 ? a : p;
+ ctx.globalAlpha = 0.95;
+ ctx.beginPath();
+ ctx.arc(pt.x, pt.y, r, 0, Math.PI * 2);
+ ctx.fill();
+ ctx.strokeStyle = i % 3 === 0 ? p : a;
+ ctx.globalAlpha = 0.35;
+ ctx.stroke();
+ });
+ ctx.globalAlpha = 0.16;
+ ctx.strokeStyle = a;
+ ctx.beginPath();
+ ctx.moveTo(0, baseline);
+ ctx.lineTo(w, baseline);
+ ctx.stroke();
+ ctx.globalAlpha = 1;
+ return;
+ }
+
+ if (cfg.style === 'outlawGauges') {
+ const valueA = Math.min(1, 0.25 + e.lowMid * 0.8 + 0.2 * Math.sin(t * 0.8));
+ const valueB = Math.min(1, 0.2 + e.treble * 0.75 + 0.18 * Math.cos(t * 1.1));
+ const valueC = Math.min(1, 0.18 + e.bass * 0.7 + 0.14 * Math.sin(t * 0.6 + 1.2));
+ const gauges = [
+ { cx: w * 0.28, cy: h * 0.48, r: h * 0.28, value: valueA, color: p, label: 'BUS A' },
+ { cx: w * 0.72, cy: h * 0.48, r: h * 0.24, value: valueB, color: s, label: 'BUS B' }
+ ];
+ ctx.font = '10px Share Tech Mono, monospace';
+ gauges.forEach(g => {
+ ctx.save();
+ ctx.strokeStyle = 'rgba(255,255,255,0.12)';
+ ctx.lineWidth = 6;
+ ctx.beginPath();
+ ctx.arc(g.cx, g.cy, g.r, Math.PI * 0.8, Math.PI * 2.2);
+ ctx.stroke();
+ ctx.strokeStyle = g.color;
+ ctx.lineWidth = 4;
+ ctx.beginPath();
+ ctx.arc(g.cx, g.cy, g.r, Math.PI * 0.8, Math.PI * (0.8 + 1.4 * g.value));
+ ctx.stroke();
+ for (let i = 0; i <= 6; i++) {
+ const ang = Math.PI * (0.8 + (1.4 * i) / 6);
+ const r0 = g.r - 8,
+ r1 = g.r + 6;
+ ctx.strokeStyle = 'rgba(255,255,255,0.25)';
+ ctx.lineWidth = 1;
+ ctx.beginPath();
+ ctx.moveTo(g.cx + Math.cos(ang) * r0, g.cy + Math.sin(ang) * r0);
+ ctx.lineTo(g.cx + Math.cos(ang) * r1, g.cy + Math.sin(ang) * r1);
+ ctx.stroke();
+ }
+ const needleAng = Math.PI * (0.8 + 1.4 * g.value);
+ ctx.strokeStyle = a;
+ ctx.lineWidth = 2;
+ ctx.beginPath();
+ ctx.moveTo(g.cx, g.cy);
+ ctx.lineTo(
+ g.cx + Math.cos(needleAng) * (g.r - 10),
+ g.cy + Math.sin(needleAng) * (g.r - 10)
+ );
+ ctx.stroke();
+ ctx.fillStyle = g.color;
+ ctx.globalAlpha = 0.9;
+ ctx.fillText(g.label, g.cx - 18, g.cy + g.r + 18);
+ ctx.restore();
+ });
+ const traceY = h * 0.88;
+ ctx.strokeStyle = 'rgba(255,255,255,0.18)';
+ ctx.beginPath();
+ ctx.moveTo(12, traceY);
+ ctx.lineTo(w - 12, traceY);
+ ctx.stroke();
+ const points = [];
+ for (let i = 0; i < 24; i++) {
+ const x = 12 + (w - 24) * (i / 23);
+ const y =
+ traceY -
+ (Math.sin(t * 2.4 + i * 0.7) * 5 + Math.cos(t * 1.1 + i * 1.5) * 3 + valueC * 10);
+ points.push({ x, y });
+ }
+ this.drawSmoothPath(ctx, points, p, 2, 0.9);
+ ctx.fillStyle = a;
+ ctx.font = '9px Share Tech Mono, monospace';
+ ctx.fillText('TRACE', 12, traceY - 10);
+ return;
+ }
+
+ if (cfg.style === 'stationMatrix') {
+ this.drawWaveformGrid(ctx, w, h, p, 6, 5, 0.08);
+ const rows = [
+ { y: h * 0.2, color: p, amp: 6 + e.mid * 10, label: 'TRAF' },
+ { y: h * 0.4, color: s, amp: 4 + e.lowMid * 8, label: 'PWR' },
+ { y: h * 0.6, color: a, amp: 5 + e.treble * 9, label: 'DOCK' },
+ { y: h * 0.8, color: '#cbd5e1', amp: 3 + e.bass * 8, label: 'STRC' }
+ ];
+ ctx.font = '10px Share Tech Mono, monospace';
+ rows.forEach((row, idx) => {
+ ctx.fillStyle = row.color;
+ ctx.globalAlpha = 0.75;
+ ctx.fillText(row.label, 6, row.y + 3);
+ const points = [];
+ for (let i = 0; i < 22; i++) {
+ const x = 42 + (w - 50) * (i / 21);
+ const y =
+ row.y +
+ Math.sin(t * (1.2 + idx * 0.25) + i * 0.7) * row.amp +
+ Math.cos(t * 0.8 + i * 1.5) * (row.amp * 0.25);
+ points.push({ x, y });
+ }
+ this.drawSmoothPath(ctx, points, row.color, 1.8, 0.92);
+ });
+ ctx.globalAlpha = 1;
+ return;
+ }
+
+ if (cfg.style === 'militaryBlocks') {
+ this.drawWaveformGrid(ctx, w, h, s, 8, 4, 0.07);
+ const segs = 18;
+ const bw = (w - 24) / segs;
+ for (let i = 0; i < segs; i++) {
+ const v1 = (this.analyserData[i * 2] || 0) / 255;
+ const v2 = (this.analyserData[i * 2 + 1] || 0) / 255;
+ const x = 12 + i * bw;
+ const h1 = 10 + v1 * (h * 0.28);
+ const h2 = 8 + v2 * (h * 0.2);
+ ctx.fillStyle = p;
+ ctx.globalAlpha = 0.82;
+ ctx.fillRect(x, h * 0.42 - h1, bw * 0.64, h1);
+ ctx.fillStyle = s;
+ ctx.globalAlpha = 0.65;
+ ctx.fillRect(x, h * 0.84 - h2, bw * 0.64, h2);
+ }
+ ctx.globalAlpha = 0.75;
+ ctx.fillStyle = a;
+ ctx.font = '10px Share Tech Mono, monospace';
+ ctx.fillText('CARRIER', 10, 12);
+ ctx.fillText('THREAT', 10, h * 0.5 + 6);
+ ctx.globalAlpha = 1;
+ return;
+ }
+
+ if (cfg.style === 'industrialScope') {
+ this.drawWaveformGrid(ctx, w, h, p, 10, 4, 0.08);
+ const points = [];
+ const count = 36;
+ for (let i = 0; i < count; i++) {
+ const x = (w * i) / (count - 1);
+ const raw = (this.analyserData[i] || 0) / 255;
+ const step =
+ Math.round((baseline - raw * (h * 0.48) + Math.sin(t * 1.6 + i * 0.35) * 3) / 2) * 2;
+ points.push({ x, y: step });
+ }
+ this.drawSmoothPath(ctx, points, p, 1.4, 0.2);
+ ctx.save();
+ ctx.strokeStyle = p;
+ ctx.globalAlpha = 0.88;
+ ctx.lineWidth = 2.1;
+ ctx.beginPath();
+ ctx.moveTo(points[0].x, points[0].y);
+ for (let i = 1; i < points.length; i++) {
+ ctx.lineTo(points[i].x, points[i - 1].y);
+ ctx.lineTo(points[i].x, points[i].y);
+ }
+ ctx.stroke();
+ ctx.restore();
+ return;
+ }
+
+ if (cfg.style === 'bioshipMembrane') {
+ const points = [];
+ const count = 28;
+ for (let i = 0; i < count; i++) {
+ const x = (w * i) / (count - 1);
+ const wave = Math.sin(t * 1.1 + i * 0.42) * (8 + e.bass * 18);
+ const flutter = Math.cos(t * 2.2 + i * 0.8) * (3 + e.treble * 6);
+ points.push({ x, y: baseline + wave + flutter });
+ }
+ ctx.save();
+ ctx.fillStyle = 'rgba(16,185,129,0.16)';
+ ctx.beginPath();
+ ctx.moveTo(0, h);
+ ctx.lineTo(points[0].x, points[0].y);
+ for (let i = 1; i < points.length - 1; i++) {
+ const xc = (points[i].x + points[i + 1].x) / 2;
+ const yc = (points[i].y + points[i + 1].y) / 2;
+ ctx.quadraticCurveTo(points[i].x, points[i].y, xc, yc);
+ }
+ ctx.lineTo(points[points.length - 1].x, points[points.length - 1].y);
+ ctx.lineTo(w, h);
+ ctx.closePath();
+ ctx.fill();
+ ctx.restore();
+ this.drawSmoothPath(ctx, points, p, 2.6, 0.95);
+ const secondary = points.map((pt, i) => ({
+ x: pt.x,
+ y: pt.y + 10 + Math.sin(t * 1.7 + i * 0.5) * 6
+ }));
+ this.drawSmoothPath(ctx, secondary, a, 1.4, 0.45);
+ return;
+ }
+
+ if (cfg.style === 'retroCrt') {
+ ctx.fillStyle = 'rgba(0,18,4,0.22)';
+ ctx.fillRect(0, 0, w, h);
+ this.drawWaveformGrid(ctx, w, h, p, 8, 5, 0.06);
+ for (let i = 0; i < h; i += 3) {
+ ctx.fillStyle = 'rgba(0,0,0,0.08)';
+ ctx.fillRect(0, i, w, 1);
+ }
+ const points = [];
+ const count = 34;
+ for (let i = 0; i < count; i++) {
+ const x = (w * i) / (count - 1);
+ const wave = Math.sin(t * 1.4 + i * 0.5) * (6 + e.mid * 10);
+ const drift = Math.sin(t * 0.25) * 6;
+ points.push({ x, y: baseline + drift + wave });
+ }
+ this.drawSmoothPath(ctx, points, p, 6, 0.12);
+ this.drawSmoothPath(ctx, points, p, 3.2, 0.24);
+ this.drawSmoothPath(ctx, points, p, 1.6, 0.96);
+ return;
+ }
+
+ if (cfg.style === 'deepSpaceField') {
+ ctx.save();
+ ctx.strokeStyle = p;
+ ctx.globalAlpha = 0.15;
+ ctx.beginPath();
+ ctx.moveTo(0, baseline);
+ ctx.lineTo(w, baseline);
+ ctx.stroke();
+ ctx.restore();
+ const points = [];
+ const count = 30;
+ for (let i = 0; i < count; i++) {
+ const x = (w * i) / (count - 1);
+ const longWave = Math.sin(t * 0.35 + i * 0.2) * (e.bass * 24 + 8);
+ const interference = Math.cos(t * 1.1 + i * 1.4) * (2 + e.treble * 4);
+ points.push({ x, y: baseline + longWave + interference });
+ }
+ this.drawSmoothPath(ctx, points, p, 1.9, 0.8);
+ const ghost = points.map((pt, i) => ({
+ x: pt.x,
+ y: pt.y + 10 + Math.sin(t * 0.6 + i * 0.4) * 5
+ }));
+ this.drawSmoothPath(ctx, ghost, s, 1.1, 0.35);
+ return;
+ }
+
+ if (cfg.style === 'comedyAdventure') {
+ const points = [];
+ const count = 28;
+ for (let i = 0; i < count; i++) {
+ const x = (w * i) / (count - 1);
+ const wave = Math.sin(t * 2.1 + i * 0.65) * (8 + e.mid * 16);
+ const bounce = Math.abs(Math.sin(t * 1.3 + i * 0.35)) * (2 + e.treble * 10);
+ points.push({ x, y: baseline + wave - bounce });
+ }
+ this.drawSmoothPath(ctx, points, p, 2.6, 0.9);
+ const secondary = points.map((pt, i) => ({
+ x: pt.x,
+ y: pt.y + 10 + Math.cos(t * 1.4 + i * 0.6) * 5
+ }));
+ this.drawSmoothPath(ctx, secondary, s, 1.5, 0.55);
+ ctx.fillStyle = a;
+ ctx.globalAlpha = 0.8;
+ for (let i = 3; i < points.length; i += 6) {
+ ctx.beginPath();
+ ctx.arc(points[i].x, points[i].y - 8, 2.5 + e.treble * 2, 0, Math.PI * 2);
+ ctx.fill();
+ }
+ ctx.globalAlpha = 1;
+ return;
+ }
+
+ if (cfg.style === 'starflight') {
+ const traces = [
+ { off: -8, amp: 8 + e.treble * 8, color: p, speed: 1.5 },
+ { off: 0, amp: 6 + e.mid * 10, color: s, speed: 1.15 },
+ { off: 8, amp: 4 + e.lowMid * 8, color: a, speed: 0.9 }
+ ];
+ traces.forEach((trace, idx) => {
+ const points = [];
+ for (let i = 0; i < 30; i++) {
+ const x = (w * i) / 29;
+ const y = baseline + trace.off + Math.sin(t * trace.speed + i * 0.42 + idx) * trace.amp;
+ points.push({ x, y });
+ }
+ this.drawSmoothPath(
+ ctx,
+ points,
+ trace.color,
+ idx === 0 ? 2.2 : 1.3,
+ idx === 0 ? 0.92 : 0.58
+ );
+ });
+ return;
+ }
+
+ // Fallback should never be hit, but if it does keep a lively generic analyzer.
+ const bars = 28;
+ const barW = Math.max(3, w / bars - 2);
+ ctx.fillStyle = p;
+ for (let i = 0; i < bars; i++) {
+ const val = (this.analyserData[i * 2] || 0) / 255;
+ const barH = Math.max(2, val * (h - 4));
+ ctx.fillRect(i * (barW + 2), h - barH, barW, barH);
+ }
+ }
+
+ // =========================================================================
+ // Space Stations: Procedural 360-Degree Rotating Panorama
+ //
+ // A station rotating in place sees a fixed sky wheel past the window and come back
+ // around again - there is no forward motion, so no pilot-style starfield rush here.
+ // The sky is generated once per page load in normalized azimuth/elevation space, so
+ // it survives window resizes, preset switches and re-entering Observation Mode, and
+ // only changes when the page is reloaded.
+ // =========================================================================
+
+ regenerateStationPanorama() {
+ const rand = (min, max) => min + Math.random() * (max - min);
+ const pick = (arr) => arr[Math.floor(Math.random() * arr.length)];
+
+ // --- Background stars spread around the full circle ---
+ const stars = [];
+ for (let i = 0; i < 900; i++) {
+ stars.push({
+ a: Math.random(),
+ e: (Math.random() - 0.5) * 2,
+ size: Math.pow(Math.random(), 2.2) * 2.4 + 0.5, // mostly pinpricks, a few bright ones
+ color: pick(this.starColors),
+ brightness: 0.35 + Math.random() * 0.65,
+ twinkleSpeed: 0.6 + Math.random() * 2.6,
+ twinklePhase: Math.random() * Math.PI * 2
+ });
+ }
+
+ // --- Deep space nebula patches ---
+ const nebulaPalette = ['#38bdf8', '#0ea5e9', '#1e3a8a', '#6366f1', '#8b5cf6', '#0891b2', '#f472b6'];
+ const nebulae = [];
+ const nebulaCount = 3 + Math.floor(Math.random() * 3);
+ for (let i = 0; i < nebulaCount; i++) {
+ nebulae.push({
+ a: Math.random(),
+ e: rand(-0.7, 0.7),
+ radius: rand(0.35, 1.1),
+ color: pick(nebulaPalette),
+ alpha: rand(0.05, 0.12),
+ pulseSpeed: rand(0.08, 0.22),
+ pulsePhase: Math.random() * Math.PI * 2
+ });
+ }
+
+ // --- Planets, spaced apart so two never stack on the same bearing ---
+ const planetPalettes = [
+ { c0: '#60a5fa', c1: '#1d4ed8', c2: '#0b1220', rim: '#bae6fd' },
+ { c0: '#fbbf24', c1: '#b45309', c2: '#1c1917', rim: '#fed7aa' },
+ { c0: '#4ade80', c1: '#15803d', c2: '#052e16', rim: '#bbf7d0' },
+ { c0: '#e0f2fe', c1: '#7dd3fc', c2: '#0c4a6e', rim: '#f0f9ff' },
+ { c0: '#fb923c', c1: '#9a3412', c2: '#1c1917', rim: '#fdba74' },
+ { c0: '#c084fc', c1: '#6d28d9', c2: '#1e1b4b', rim: '#e9d5ff' }
+ ];
+ const planets = [];
+ const planetCount = 1 + Math.floor(Math.random() * 2);
+ for (let i = 0; i < planetCount; i++) {
+ const pal = planetPalettes.splice(Math.floor(Math.random() * planetPalettes.length), 1)[0];
+ const moons = [];
+ const moonCount = Math.floor(Math.random() * 3);
+ for (let m = 0; m < moonCount; m++) {
+ moons.push({
+ orbit: rand(1.5, 2.6),
+ squash: rand(0.25, 0.6),
+ speed: rand(0.05, 0.15),
+ phase: Math.random() * Math.PI * 2,
+ size: rand(0.09, 0.17)
+ });
+ }
+ planets.push({
+ a: 0, // bearings are assigned below, evenly spread around the circle
+ e: rand(-0.35, 0.35),
+ radius: rand(0.18, 0.42), // fraction of the window band height
+ palette: pal,
+ hasRings: Math.random() < 0.45,
+ ringTilt: rand(-0.6, -0.15),
+ bandCount: 2 + Math.floor(Math.random() * 4),
+ lightAngle: rand(-Math.PI, Math.PI),
+ moons
+ });
+ }
+
+ // --- A distant sun ---
+ const sun = {
+ a: 0,
+ e: rand(-0.4, 0.4),
+ radius: rand(0.05, 0.1),
+ color: pick(['#fef9c3', '#fed7aa', '#e0f2fe', '#fecaca'])
+ };
+
+ // --- Far-off sister stations with blinking beacons ---
+ const structures = [];
+ const structureCount = 1 + Math.floor(Math.random() * 2);
+ for (let i = 0; i < structureCount; i++) {
+ structures.push({
+ a: 0,
+ e: rand(-0.45, 0.45),
+ scale: rand(0.07, 0.15),
+ hasRing: Math.random() < 0.6,
+ panelCount: 1 + Math.floor(Math.random() * 2),
+ beaconSpeed: rand(1.4, 3.2),
+ beaconPhase: Math.random() * Math.PI * 2
+ });
+ }
+
+ // --- An asteroid cluster ---
+ const rocks = [];
+ const clusterE = rand(-0.5, 0.5);
+ const rockCount = 9 + Math.floor(Math.random() * 10);
+ for (let i = 0; i < rockCount; i++) {
+ const verts = [];
+ const vertCount = 5 + Math.floor(Math.random() * 4);
+ for (let v = 0; v < vertCount; v++) {
+ verts.push({
+ ang: (v / vertCount) * Math.PI * 2,
+ r: 0.6 + Math.random() * 0.5
+ });
+ }
+ rocks.push({
+ da: rand(-0.035, 0.035),
+ de: rand(-0.35, 0.35),
+ size: rand(0.012, 0.035),
+ verts,
+ rotSpeed: rand(-0.35, 0.35),
+ rotPhase: Math.random() * Math.PI * 2,
+ shade: pick(['#57534e', '#44403c', '#78716c', '#3f3f46'])
+ });
+ }
+
+ const asteroids = { a: 0, e: clusterE, rocks };
+
+ // --- Spread the major features evenly around the circle ---
+ // Purely random bearings tend to clump: everything piles into one window while the
+ // rest of the revolution is empty sky. Slotting them gives a steady rhythm of one
+ // notable object drifting through every so often, which is the point of a 360 view.
+ const majors = [...planets, sun, ...structures, asteroids];
+ for (let i = majors.length - 1; i > 0; i--) {
+ const j = Math.floor(Math.random() * (i + 1));
+ [majors[i], majors[j]] = [majors[j], majors[i]];
+ }
+ const slot = 1 / majors.length;
+ const baseBearing = Math.random();
+ majors.forEach((feature, i) => {
+ const bearing = baseBearing + (i + rand(-0.28, 0.28)) * slot;
+ feature.a = ((bearing % 1) + 1) % 1;
+ });
+
+ this.stationPanorama = { stars, nebulae, planets, sun, structures, asteroids };
+ return this.stationPanorama;
+ }
+
+ // The station stage SVG uses viewBox "0 0 1600 900" with preserveAspectRatio="xMidYMid slice",
+ // so its window cutout (220,140 -> 1380,680) lands on screen like a CSS `cover` background.
+ // Matching that math keeps celestial objects transiting through the real opening.
+ getStationViewportRect() {
+ if (!this.showViewport) {
+ return { x: 0, y: 0, w: this.width, h: this.height, cx: this.width / 2, cy: this.height / 2 };
+ }
+ const scale = Math.max(this.width / 1600, this.height / 900);
+ const offX = (this.width - 1600 * scale) / 2;
+ const offY = (this.height - 900 * scale) / 2;
+ const x = offX + 220 * scale;
+ const y = offY + 140 * scale;
+ const w = 1160 * scale;
+ const h = 540 * scale;
+ return { x, y, w, h, cx: x + w / 2, cy: y + h / 2 };
+ }
+
+ updateStationPanorama(dt) {
+ if (!this.stationPanorama) this.regenerateStationPanorama();
+ const period = this.stationRevolutionSeconds || 240;
+ this.stationRotation = (this.stationRotation + dt / period) % 1;
+ }
+
+ // Maps a panorama bearing to a screen x, wrapping seamlessly through 360 degrees.
+ stationProjectX(a, rect) {
+ let rel = (a - this.stationRotation + 0.5) % 1;
+ if (rel < 0) rel += 1;
+ rel -= 0.5;
+ return rect.cx + (rel / (this.stationFov || 0.22)) * rect.w;
+ }
+
+ stationProjectY(e, rect) {
+ return rect.cy + e * rect.h * 0.58;
+ }
+
+ renderStationPanorama(ctx) {
+ if (!this.stationPanorama) this.regenerateStationPanorama();
+ const pano = this.stationPanorama;
+ const rect = this.getStationViewportRect();
+ const lighting = this.getLightingModifiers();
+ const bass = this.audioEnergy ? this.audioEnergy.bass : 0;
+
+ // --- Nebula patches ---
+ for (const neb of pano.nebulae) {
+ const radius = neb.radius * rect.h * (1 + Math.sin(this.time * neb.pulseSpeed + neb.pulsePhase) * 0.12);
+ const nx = this.stationProjectX(neb.a, rect);
+ if (nx < rect.x - radius * 1.2 || nx > rect.x + rect.w + radius * 1.2) continue;
+ const ny = this.stationProjectY(neb.e, rect);
+ const alpha = Math.max(0, (neb.alpha + bass * 0.06) * lighting.nebulaIntensity);
+
+ const grad = ctx.createRadialGradient(nx, ny, radius * 0.08, nx, ny, radius);
+ grad.addColorStop(0, hexToRgba(neb.color, alpha));
+ grad.addColorStop(0.55, hexToRgba(neb.color, alpha * 0.4));
+ grad.addColorStop(1, 'transparent');
+ ctx.fillStyle = grad;
+ ctx.fillRect(rect.x - 40, rect.y - 40, rect.w + 80, rect.h + 80);
+ }
+
+ // --- Background stars ---
+ const brightness = lighting.starBrightness;
+ for (const star of pano.stars) {
+ const sx = this.stationProjectX(star.a, rect);
+ if (sx < rect.x - 20 || sx > rect.x + rect.w + 20) continue;
+ const sy = this.stationProjectY(star.e, rect);
+ const twinkle = 0.72 + Math.sin(this.time * star.twinkleSpeed + star.twinklePhase) * 0.28;
+ ctx.globalAlpha = Math.max(0.05, Math.min(1, star.brightness * twinkle * brightness));
+ ctx.fillStyle = star.color;
+ ctx.beginPath();
+ ctx.arc(sx, sy, star.size, 0, Math.PI * 2);
+ ctx.fill();
+ }
+ ctx.globalAlpha = 1;
+
+ // --- Distant sun ---
+ this.renderStationSun(ctx, pano.sun, rect, brightness);
+
+ // --- Asteroid cluster ---
+ this.renderStationAsteroids(ctx, pano.asteroids, rect);
+
+ // --- Planets ---
+ for (const planet of pano.planets) {
+ this.renderStationPlanet(ctx, planet, rect);
+ }
+
+ // --- Far-off sister stations ---
+ for (const structure of pano.structures) {
+ this.renderStationStructure(ctx, structure, rect);
+ }
+ }
+
+ renderStationSun(ctx, sun, rect, brightness) {
+ const sx = this.stationProjectX(sun.a, rect);
+ const r = sun.radius * rect.h;
+ if (sx < rect.x - r * 8 || sx > rect.x + rect.w + r * 8) return;
+ const sy = this.stationProjectY(sun.e, rect);
+
+ ctx.save();
+ const glow = ctx.createRadialGradient(sx, sy, r * 0.2, sx, sy, r * 6);
+ glow.addColorStop(0, hexToRgba(sun.color, 0.5 * brightness));
+ glow.addColorStop(0.25, hexToRgba(sun.color, 0.14 * brightness));
+ glow.addColorStop(1, 'transparent');
+ ctx.fillStyle = glow;
+ ctx.beginPath();
+ ctx.arc(sx, sy, r * 6, 0, Math.PI * 2);
+ ctx.fill();
+
+ // Core
+ ctx.fillStyle = '#ffffff';
+ ctx.beginPath();
+ ctx.arc(sx, sy, r, 0, Math.PI * 2);
+ ctx.fill();
+
+ // Soft anamorphic streak - gradient stroke so it fades out instead of ending as a hard bar
+ const streak = ctx.createLinearGradient(sx - r * 7, sy, sx + r * 7, sy);
+ streak.addColorStop(0, 'transparent');
+ streak.addColorStop(0.5, hexToRgba(sun.color, 0.3 * brightness));
+ streak.addColorStop(1, 'transparent');
+ ctx.strokeStyle = streak;
+ ctx.lineWidth = r * 0.22;
+ ctx.beginPath();
+ ctx.moveTo(sx - r * 7, sy);
+ ctx.lineTo(sx + r * 7, sy);
+ ctx.stroke();
+ ctx.restore();
+ }
+
+ renderStationPlanet(ctx, planet, rect) {
+ const pr = planet.radius * rect.h * 0.5;
+ const px = this.stationProjectX(planet.a, rect);
+ const reach = planet.hasRings ? pr * 2.4 : pr * 2.8;
+ if (px < rect.x - reach || px > rect.x + rect.w + reach) return;
+ const py = this.stationProjectY(planet.e, rect);
+ const pal = planet.palette;
+
+ ctx.save();
+
+ // Moons currently behind the planet
+ for (const moon of planet.moons) {
+ const ang = this.time * moon.speed + moon.phase;
+ if (Math.sin(ang) >= 0) continue;
+ this.drawStationMoon(ctx, px, py, pr, moon, ang);
+ }
+
+ // Back half of the rings
+ if (planet.hasRings) {
+ this.drawStationRings(ctx, px, py, pr, planet, Math.PI, Math.PI * 2);
+ }
+
+ // Atmospheric halo
+ const halo = ctx.createRadialGradient(px, py, pr * 0.9, px, py, pr * 1.3);
+ halo.addColorStop(0, hexToRgba(pal.rim, 0.28));
+ halo.addColorStop(1, 'transparent');
+ ctx.fillStyle = halo;
+ ctx.beginPath();
+ ctx.arc(px, py, pr * 1.3, 0, Math.PI * 2);
+ ctx.fill();
+
+ // Body
+ const lx = px + Math.cos(planet.lightAngle) * pr * 0.4;
+ const ly = py + Math.sin(planet.lightAngle) * pr * 0.4;
+ const body = ctx.createRadialGradient(lx, ly, pr * 0.08, px, py, pr);
+ body.addColorStop(0, pal.c0);
+ body.addColorStop(0.45, pal.c1);
+ body.addColorStop(1, pal.c2);
+ ctx.fillStyle = body;
+ ctx.beginPath();
+ ctx.arc(px, py, pr, 0, Math.PI * 2);
+ ctx.fill();
+
+ // Latitude banding, clipped to the disc
+ ctx.save();
+ ctx.beginPath();
+ ctx.arc(px, py, pr, 0, Math.PI * 2);
+ ctx.clip();
+ ctx.globalAlpha = 0.16;
+ ctx.fillStyle = pal.c2;
+ for (let b = 0; b < planet.bandCount; b++) {
+ const by = py - pr + ((b + 0.5) / planet.bandCount) * pr * 2;
+ const bh = (pr * 2 / planet.bandCount) * 0.42;
+ ctx.fillRect(px - pr, by - bh / 2, pr * 2, bh);
+ }
+ ctx.restore();
+
+ // Lit rim arc
+ ctx.globalAlpha = 0.45;
+ ctx.strokeStyle = pal.rim;
+ ctx.lineWidth = Math.max(1.2, pr * 0.035);
+ ctx.beginPath();
+ ctx.arc(px, py, pr, planet.lightAngle - Math.PI * 0.55, planet.lightAngle + Math.PI * 0.55);
+ ctx.stroke();
+ ctx.globalAlpha = 1;
+
+ // Front half of the rings
+ if (planet.hasRings) {
+ this.drawStationRings(ctx, px, py, pr, planet, 0, Math.PI);
+ }
+
+ // Moons in front of the planet
+ for (const moon of planet.moons) {
+ const ang = this.time * moon.speed + moon.phase;
+ if (Math.sin(ang) < 0) continue;
+ this.drawStationMoon(ctx, px, py, pr, moon, ang);
+ }
+
+ ctx.restore();
+ }
+
+ drawStationRings(ctx, px, py, pr, planet, startAngle, endAngle) {
+ ctx.save();
+ ctx.translate(px, py);
+ ctx.rotate(planet.ringTilt);
+ const step = Math.max(3, pr * 0.1);
+ for (let r = pr * 1.35; r <= pr * 2.05; r += step) {
+ ctx.beginPath();
+ ctx.ellipse(0, 0, r, r * 0.3, 0, startAngle, endAngle);
+ ctx.strokeStyle = hexToRgba(planet.palette.rim, 0.1 + Math.sin(r * 0.12) * 0.06);
+ ctx.lineWidth = step * 0.75;
+ ctx.stroke();
+ }
+ ctx.restore();
+ }
+
+ drawStationMoon(ctx, px, py, pr, moon, ang) {
+ const mx = px + Math.cos(ang) * pr * moon.orbit;
+ const my = py + Math.sin(ang) * pr * moon.orbit * moon.squash;
+ const mr = pr * moon.size;
+
+ ctx.fillStyle = '#cbd5e1';
+ ctx.beginPath();
+ ctx.arc(mx, my, mr, 0, Math.PI * 2);
+ ctx.fill();
+
+ ctx.fillStyle = 'rgba(2, 6, 23, 0.7)';
+ ctx.beginPath();
+ ctx.arc(mx + mr * 0.35, my + mr * 0.1, mr * 0.95, 0, Math.PI * 2);
+ ctx.fill();
+ }
+
+ renderStationStructure(ctx, structure, rect) {
+ const s = structure.scale * rect.h;
+ const sx = this.stationProjectX(structure.a, rect);
+ if (sx < rect.x - s * 4 || sx > rect.x + rect.w + s * 4) return;
+ const sy = this.stationProjectY(structure.e, rect);
+
+ ctx.save();
+ ctx.translate(sx, sy);
+
+ // Central hub
+ ctx.fillStyle = '#1e293b';
+ ctx.strokeStyle = '#475569';
+ ctx.lineWidth = Math.max(1, s * 0.06);
+ ctx.beginPath();
+ ctx.ellipse(0, 0, s * 0.42, s * 0.3, 0, 0, Math.PI * 2);
+ ctx.fill();
+ ctx.stroke();
+
+ // Habitation ring
+ if (structure.hasRing) {
+ ctx.strokeStyle = '#334155';
+ ctx.lineWidth = Math.max(1.5, s * 0.1);
+ ctx.beginPath();
+ ctx.ellipse(0, 0, s, s * 0.34, 0, 0, Math.PI * 2);
+ ctx.stroke();
+ }
+
+ // Solar panel wings
+ ctx.fillStyle = '#0f2942';
+ ctx.strokeStyle = '#1e40af';
+ ctx.lineWidth = Math.max(0.8, s * 0.03);
+ for (let p = 0; p < structure.panelCount; p++) {
+ const off = (p + 1) * s * 0.55;
+ ctx.fillRect(-off - s * 0.5, -s * 0.16, s * 0.5, s * 0.32);
+ ctx.strokeRect(-off - s * 0.5, -s * 0.16, s * 0.5, s * 0.32);
+ ctx.fillRect(off, -s * 0.16, s * 0.5, s * 0.32);
+ ctx.strokeRect(off, -s * 0.16, s * 0.5, s * 0.32);
+ }
+
+ // Communications mast
+ ctx.strokeStyle = '#64748b';
+ ctx.lineWidth = Math.max(1, s * 0.04);
+ ctx.beginPath();
+ ctx.moveTo(0, -s * 0.3);
+ ctx.lineTo(0, -s * 0.75);
+ ctx.stroke();
+
+ // Blinking beacons
+ const lit = Math.sin(this.time * structure.beaconSpeed + structure.beaconPhase) > 0.4;
+ ctx.fillStyle = lit ? '#f97316' : 'rgba(249, 115, 22, 0.25)';
+ ctx.shadowColor = '#f97316';
+ ctx.shadowBlur = lit ? s * 0.5 : 0;
+ ctx.beginPath();
+ ctx.arc(0, -s * 0.78, Math.max(1.2, s * 0.07), 0, Math.PI * 2);
+ ctx.fill();
+
+ ctx.shadowBlur = 0;
+ ctx.fillStyle = lit ? 'rgba(125, 211, 252, 0.9)' : 'rgba(125, 211, 252, 0.35)';
+ ctx.beginPath();
+ ctx.arc(-s * 0.42, s * 0.1, Math.max(1, s * 0.05), 0, Math.PI * 2);
+ ctx.arc(s * 0.42, s * 0.1, Math.max(1, s * 0.05), 0, Math.PI * 2);
+ ctx.fill();
+
+ ctx.restore();
+ }
+
+ renderStationAsteroids(ctx, cluster, rect) {
+ for (const rock of cluster.rocks) {
+ const rx = this.stationProjectX(cluster.a + rock.da, rect);
+ const size = rock.size * rect.h;
+ if (rx < rect.x - size * 3 || rx > rect.x + rect.w + size * 3) continue;
+ const ry = this.stationProjectY(cluster.e + rock.de * 0.5, rect);
+
+ ctx.save();
+ ctx.translate(rx, ry);
+ ctx.rotate(this.time * rock.rotSpeed + rock.rotPhase);
+ ctx.fillStyle = rock.shade;
+ ctx.beginPath();
+ rock.verts.forEach((v, i) => {
+ const vx = Math.cos(v.ang) * size * v.r;
+ const vy = Math.sin(v.ang) * size * v.r;
+ if (i === 0) ctx.moveTo(vx, vy); else ctx.lineTo(vx, vy);
+ });
+ ctx.closePath();
+ ctx.fill();
+
+ ctx.strokeStyle = 'rgba(148, 163, 184, 0.25)';
+ ctx.lineWidth = 0.8;
+ ctx.stroke();
+ ctx.restore();
+ }
+ }
+
+ // =========================================================================
+ // Observation Mode Enhancements: Audio-Visual Integration
+ // =========================================================================
+
+ updateAudioEnergy() {
+ if (this.am && this.am.analyser) {
+ this.am.analyser.getByteFrequencyData(this.analyserData);
+ }
+ const data = this.analyserData;
+ const n = data.length;
+ if (!n) return;
+
+ const bassEnd = Math.max(1, Math.floor(n * 0.12));
+ const midEnd = Math.max(bassEnd + 1, Math.floor(n * 0.5));
+ let bassSum = 0, bassCount = 0, midSum = 0, midCount = 0, trebleSum = 0, trebleCount = 0;
+
+ for (let i = 0; i < n; i++) {
+ const v = data[i] / 255;
+ if (i < bassEnd) { bassSum += v; bassCount++; }
+ else if (i < midEnd) { midSum += v; midCount++; }
+ else { trebleSum += v; trebleCount++; }
+ }
+
+ const bass = bassCount ? bassSum / bassCount : 0;
+ const mid = midCount ? midSum / midCount : 0;
+ const treble = trebleCount ? trebleSum / trebleCount : 0;
+
+ // Smooth toward the new reading each frame so visuals don't flicker with raw FFT noise.
+ const smooth = 0.15;
+ this.audioEnergy.bass += (bass - this.audioEnergy.bass) * smooth;
+ this.audioEnergy.mid += (mid - this.audioEnergy.mid) * smooth;
+ this.audioEnergy.treble += (treble - this.audioEnergy.treble) * smooth;
+ this.audioEnergy.overall = this.audioEnergy.bass * 0.5 + this.audioEnergy.mid * 0.35 + this.audioEnergy.treble * 0.15;
+ }
+
+ updateViewportVibration(dt) {
+ // Viewport vibration is intentionally disabled (v11co).
+ // The canvas and the viewport frame do not always share a layer - Space Stations,
+ // for example, draws its window architecture in the SVG stage instead of the frame
+ // element - so translating the canvas made the view slide inside a stationary window.
+ // Hull Drone energy still drives the nebulae; the viewport itself now stays locked.
+ this.viewportVibration.x = 0;
+ this.viewportVibration.y = 0;
+ if (this.canvas) this.canvas.style.transform = '';
+ if (this.frameEl) this.frameEl.style.transform = '';
+ }
+
+ updateScanlineBreathing() {
+ if (!this.scanlinesEl) {
+ this.scanlinesEl = this.overlay ? this.overlay.querySelector('.observation-scanlines') : null;
+ if (!this.scanlinesEl) return;
+ }
+ const speed = (this.lifeSupport && this.lifeSupport.params) ? this.lifeSupport.params.airflowModSpeed : 0.15;
+ const depth = (this.lifeSupport && this.lifeSupport.params) ? this.lifeSupport.params.airflowModDepth : 0.12;
+ const base = 0.14; // matches the .observation-scanlines default CSS opacity
+ const breath = Math.sin(this.time * speed * Math.PI * 2) * depth;
+ this.scanlineBreath = Math.max(0.04, base + breath * base * 2);
+ this.scanlinesEl.style.opacity = this.scanlineBreath.toFixed(3);
+ }
+
+ // =========================================================================
+ // Observation Mode Enhancements: 25-Minute Ambient Lighting Cycle
+ // =========================================================================
+
+ updateLightingCycle(dt) {
+ this.lightingCycleTime = (this.lightingCycleTime || 0) + dt;
+ const cycleDuration = 1500; // 25 minutes: Deep Space -> Nebula Passage -> Star Approach -> Eclipse
+ const phases = [
+ { name: 'Deep Space', starBrightness: 0.85, nebulaIntensity: 0.8 },
+ { name: 'Nebula Passage', starBrightness: 0.7, nebulaIntensity: 1.6 },
+ { name: 'Star Approach', starBrightness: 1.3, nebulaIntensity: 1.1 },
+ { name: 'Eclipse', starBrightness: 0.55, nebulaIntensity: 0.9 }
+ ];
+
+ const t = (this.lightingCycleTime % cycleDuration) / cycleDuration;
+ const phaseLen = 1 / phases.length;
+ const idx = Math.min(phases.length - 1, Math.floor(t / phaseLen));
+ const nextIdx = (idx + 1) % phases.length;
+ const localT = (t % phaseLen) / phaseLen;
+ const smoothT = (1 - Math.cos(localT * Math.PI)) / 2; // ease in/out crossfade between phases
+
+ const a = phases[idx], b = phases[nextIdx];
+ this.lightingModifiers = {
+ name: smoothT < 0.5 ? a.name : b.name,
+ starBrightness: a.starBrightness + (b.starBrightness - a.starBrightness) * smoothT,
+ nebulaIntensity: a.nebulaIntensity + (b.nebulaIntensity - a.nebulaIntensity) * smoothT
+ };
+ }
+
+ getLightingModifiers() {
+ return this.lightingModifiers || { name: 'Deep Space', starBrightness: 1, nebulaIntensity: 1 };
+ }
+
+ // =========================================================================
+ // Observation Mode Enhancements: Shooting Stars
+ // =========================================================================
+
+ spawnShootingStar() {
+ const fromTop = Math.random() > 0.5;
+ let x, y;
+ if (this.isStationView()) {
+ // Enter through the top of the station's window rather than off the top of the screen,
+ // where the bulkhead would swallow the whole streak
+ const rect = this.getStationViewportRect();
+ x = rect.x + Math.random() * rect.w * 0.7;
+ y = rect.y + (fromTop ? 0 : Math.random() * rect.h * 0.4);
+ } else {
+ x = Math.random() * this.width;
+ y = fromTop ? -20 : Math.random() * this.height * 0.4;
+ }
+ const angle = (Math.PI * 0.15) + Math.random() * (Math.PI * 0.2); // downward diagonal streak
+ const speed = 900 + Math.random() * 500;
+
+ this.shootingStars.push({
+ x, y,
+ vx: Math.cos(angle) * speed,
+ vy: Math.sin(angle) * speed,
+ life: 0,
+ maxLife: 0.7 + Math.random() * 0.4,
+ length: 90 + Math.random() * 90,
+ color: this.starColors[Math.floor(Math.random() * this.starColors.length)]
+ });
+ }
+
+ updateShootingStars(dt, now) {
+ if (this.flightMode !== 'warp' && now > this.nextShootingStarTime && this.shootingStars.length < 2) {
+ this.spawnShootingStar();
+ const activityFactor = (window.observationActivity !== undefined ? window.observationActivity : 0.6);
+ this.nextShootingStarTime = now + (9000 + Math.random() * 16000) / Math.max(0.15, activityFactor);
+ }
+
+ for (let i = this.shootingStars.length - 1; i >= 0; i--) {
+ const s = this.shootingStars[i];
+ s.life += dt;
+ s.x += s.vx * dt;
+ s.y += s.vy * dt;
+ if (s.life >= s.maxLife || s.x < -120 || s.x > this.width + 120 || s.y < -120 || s.y > this.height + 120) {
+ this.shootingStars.splice(i, 1);
+ }
+ }
+ }
+
+ renderShootingStars(ctx) {
+ for (const s of this.shootingStars) {
+ const prog = s.life / s.maxLife;
+ const alpha = Math.sin(Math.min(1, prog) * Math.PI); // fade in then out across its short life
+ const mag = Math.hypot(s.vx, s.vy) || 1;
+ const tailX = s.x - (s.vx / mag) * s.length;
+ const tailY = s.y - (s.vy / mag) * s.length;
+
+ const grad = ctx.createLinearGradient(s.x, s.y, tailX, tailY);
+ grad.addColorStop(0, hexToRgba(s.color, alpha));
+ grad.addColorStop(1, 'transparent');
+
+ ctx.strokeStyle = grad;
+ ctx.lineWidth = 2.2;
+ ctx.beginPath();
+ ctx.moveTo(s.x, s.y);
+ ctx.lineTo(tailX, tailY);
+ ctx.stroke();
+
+ ctx.fillStyle = `rgba(255,255,255,${alpha})`;
+ ctx.beginPath();
+ ctx.arc(s.x, s.y, 1.8, 0, Math.PI * 2);
+ ctx.fill();
+ }
+ }
+
+ // =========================================================================
+ // Observation Mode Enhancements: Procedural Constellation Lines
+ // =========================================================================
+
+ regenerateConstellation() {
+ const count = 4 + Math.floor(Math.random() * 3);
+ const points = [];
+ for (let i = 0; i < count; i++) {
+ points.push({
+ x: (Math.random() - 0.5) * 1400,
+ y: (Math.random() - 0.5) * 900,
+ z: 550 + Math.random() * 500
+ });
+ }
+ this.constellationPoints = points;
+ this.constellationLines = [];
+ for (let i = 0; i < points.length - 1; i++) {
+ this.constellationLines.push([i, i + 1]);
+ }
+ if (points.length > 3 && Math.random() > 0.5) {
+ this.constellationLines.push([points.length - 1, 0]); // occasionally close the pattern into a loop
+ }
+ }
+
+ updateConstellations(dt, now) {
+ if (this.flightMode === 'warp' || this.isStationView()) {
+ // Fade out during warp streaks rather than fighting them visually.
+ // Stations use the 360-degree panorama instead and skip this layer entirely.
+ this.constellationAlpha = Math.max(0, (this.constellationAlpha || 0) - dt * 0.6);
+ return;
+ }
+
+ if (!this.nextConstellationTime || now > this.nextConstellationTime) {
+ this.regenerateConstellation();
+ this.nextConstellationTime = now + 50000 + Math.random() * 30000;
+ }
+
+ const speed = 8; // slow independent drift, distinct pacing from the main starfield rush
+ if (this.constellationPoints) {
+ for (const p of this.constellationPoints) {
+ p.z -= speed * dt;
+ }
+ if (this.constellationPoints.some(p => p.z < 80)) {
+ this.regenerateConstellation();
+ }
+ }
+
+ const target = (this.constellationPoints && this.constellationPoints.length) ? 0.3 : 0;
+ this.constellationAlpha = (this.constellationAlpha || 0) + (target - (this.constellationAlpha || 0)) * Math.min(1, dt * 0.4);
+ }
+
+ renderConstellations(ctx, cx, cy) {
+ if (!this.constellationPoints || !this.constellationLines || (this.constellationAlpha || 0) <= 0.005) return;
+ const fov = 420;
+
+ ctx.save();
+ ctx.strokeStyle = `rgba(147, 197, 253, ${this.constellationAlpha.toFixed(3)})`;
+ ctx.fillStyle = `rgba(224, 242, 254, ${Math.min(1, this.constellationAlpha * 2.2).toFixed(3)})`;
+ ctx.lineWidth = 1;
+
+ ctx.beginPath();
+ for (const [ia, ib] of this.constellationLines) {
+ const a = this.constellationPoints[ia];
+ const b = this.constellationPoints[ib];
+ if (!a || !b) continue;
+ const ax = cx + (a.x / a.z) * fov, ay = cy + (a.y / a.z) * fov;
+ const bx = cx + (b.x / b.z) * fov, by = cy + (b.y / b.z) * fov;
+ ctx.moveTo(ax, ay);
+ ctx.lineTo(bx, by);
+ }
+ ctx.stroke();
+
+ for (const p of this.constellationPoints) {
+ const px = cx + (p.x / p.z) * fov, py = cy + (p.y / p.z) * fov;
+ ctx.beginPath();
+ ctx.arc(px, py, 1.6, 0, Math.PI * 2);
+ ctx.fill();
+ }
+ ctx.restore();
+ }
+
+ // =========================================================================
+ // Observation Mode Enhancements: Cinematic Event Director
+ // =========================================================================
+
+ queueCinematicAction(action, params) {
+ if (action === 'flash-status' && params && params.text) {
+ this.cinematicCaption = params.text;
+ }
+ }
+
+ buildFirstContactSequence() {
+ return {
+ name: 'First Contact',
+ steps: [
+ { duration: 3.0, run: () => { this.spawnEvent('comet', this.width * 0.5, this.height * 0.3); } },
+ { duration: 2.5, run: () => { this.queueCinematicAction('flash-status', { text: 'UNKNOWN VESSEL DETECTED' }); this.spawnTraffic(true); } },
+ { duration: 4.0, run: () => { this.queueCinematicAction('flash-status', { text: 'HAILING FREQUENCIES OPEN' }); } },
+ { duration: 3.0, run: () => { this.spawnEvent('warp-flash', this.width * 0.5, this.height * 0.5); } }
+ ]
+ };
+ }
+
+ buildHullBreachSequence() {
+ return {
+ name: 'Hull Breach',
+ steps: [
+ { duration: 0.8, run: () => { this.triggerWarpPulse(); this.queueCinematicAction('flash-status', { text: 'HULL STRESS CRITICAL' }); } },
+ { duration: 1.6, run: () => { if (this.alerts) this.alerts.triggerRedAlert('tng'); this.updateAlertState(); } },
+ { duration: 3.5, run: () => { this.queueCinematicAction('flash-status', { text: 'DAMAGE CONTROL TEAMS RESPONDING' }); } },
+ { duration: 3.0, run: () => {
+ if (this.alerts && this.alerts.activeAlert === 'red') { this.alerts.stopAlert(); this.updateAlertState(); }
+ this.queueCinematicAction('flash-status', { text: 'HULL INTEGRITY STABILIZED' });
+ } }
+ ]
+ };
+ }
+
+ buildTemporalAnomalySequence() {
+ return {
+ name: 'Temporal Anomaly',
+ steps: [
+ { duration: 2.0, run: () => { this.queueCinematicAction('flash-status', { text: 'TEMPORAL FLUX DETECTED' }); } },
+ { duration: 3.0, run: () => { this.spawnEvent('warp-flash', this.width * Math.random(), this.height * Math.random()); } },
+ { duration: 3.0, run: () => { this.queueCinematicAction('flash-status', { text: 'VORTEX STABILIZING' }); } }
+ ]
+ };
+ }
+
+ buildBioResonanceSequence() {
+ return {
+ name: 'Bio Resonance',
+ steps: [
+ { duration: 2.5, run: () => { this.queueCinematicAction('flash-status', { text: 'RESONANT PULSE DETECTED' }); } },
+ { duration: 3.5, run: () => { this.spawnEvent('comet', this.width * 0.6, this.height * 0.4); } },
+ { duration: 2.5, run: () => { this.queueCinematicAction('flash-status', { text: 'PULSE DISSIPATING' }); } }
+ ]
+ };
+ }
+
+ buildFlybySpectacleSequence() {
+ return {
+ name: 'Close Flyby',
+ steps: [
+ { duration: 0.5, run: () => { this.spawnTraffic(true); } },
+ { duration: 4.5, run: () => {} }
+ ]
+ };
+ }
+
+ getCinematicSequencesFor(universeId) {
+ const sequences = [];
+ if (universeId === 'starfleet' || universeId === 'deepspace' || universeId === 'spacestations') {
+ sequences.push(this.buildFirstContactSequence());
+ }
+ if (universeId === 'military' || universeId === 'outlaw' || universeId === 'industrial') {
+ sequences.push(this.buildHullBreachSequence());
+ }
+ if (universeId === 'whataverse') {
+ sequences.push(this.buildTemporalAnomalySequence());
+ }
+ if (universeId === 'bioships') {
+ sequences.push(this.buildBioResonanceSequence());
+ }
+ sequences.push(this.buildFlybySpectacleSequence()); // every universe can get a simple flyby spectacle
+ return sequences;
+ }
+
+ startCinematicSequence(sequence) {
+ if (!sequence || !sequence.steps || !sequence.steps.length) return;
+ this.cinematicActive = { name: sequence.name, steps: sequence.steps, index: 0, elapsed: 0 };
+ this.cinematicCaption = null;
+ const first = sequence.steps[0];
+ if (first && typeof first.run === 'function') first.run();
+ }
+
+ updateCinematicDirector(dt, now) {
+ if (this.cinematicActive) {
+ this.cinematicActive.elapsed += dt;
+ const step = this.cinematicActive.steps[this.cinematicActive.index];
+ if (step && this.cinematicActive.elapsed >= step.duration) {
+ this.cinematicActive.index++;
+ this.cinematicActive.elapsed = 0;
+ const next = this.cinematicActive.steps[this.cinematicActive.index];
+ if (next) {
+ this.cinematicCaption = null;
+ if (typeof next.run === 'function') next.run();
+ } else {
+ this.cinematicActive = null;
+ this.cinematicCaption = null;
+ this.nextCinematicTime = now + 90000 + Math.random() * 90000;
+ }
+ }
+ return;
+ }
+
+ if (!this.nextCinematicTime) {
+ this.nextCinematicTime = now + 45000 + Math.random() * 30000;
+ }
+ if (now > this.nextCinematicTime) {
+ const sequences = this.getCinematicSequencesFor(this.activeUniverse);
+ const chosen = sequences[Math.floor(Math.random() * sequences.length)];
+ this.startCinematicSequence(chosen);
+ }
+ }
+
+ // =========================================================================
+ // Observation Mode Enhancements: HUD Status Ticker
+ // =========================================================================
+
+ getTickerMessages(universeId) {
+ const sets = {
+ starfleet: [
+ 'ALL DECKS REPORTING NOMINAL',
+ 'SUBSPACE ARRAY HOLDING STEADY LOCK',
+ 'STRUCTURAL INTEGRITY FIELD: 100%',
+ 'FABRICATOR SYSTEMS ON STANDBY',
+ 'SENSOR SWEEP: NO ANOMALIES DETECTED'
+ ],
+ whataverse: [
+ 'TEMPORAL GRACE PERIOD: ACTIVE',
+ 'CLOISTER CHIME: SILENT',
+ 'CHAMELEON CIRCUIT: STUCK (AS USUAL)',
+ 'VORTEX MANIFOLD WITHIN TOLERANCE',
+ 'ARTRON ENERGY LEVELS STABLE'
+ ],
+ industrial: [
+ 'BULK FREIGHT MANIFEST: ON SCHEDULE',
+ 'GANTRY CRANE 4: OPERATIONAL',
+ 'HULL PLATING STRESS: NOMINAL',
+ 'CARGO BAY PRESSURE HOLDING',
+ 'MAINTENANCE CYCLE: DECK 4 COMPLETE'
+ ],
+ bioships: [
+ 'BIOMASS RESONANCE: SYNCHRONIZED',
+ 'NEURAL LATTICE: RESPONSIVE',
+ 'MEMBRANE INTEGRITY: HEALTHY',
+ 'SYMBIOTIC LINK STABLE',
+ 'PULSE RHYTHM WITHIN NORMAL RANGE'
+ ],
+ retrofuture: [
+ 'ATOMIC REACTOR: WITHIN SAFE LIMITS',
+ 'RADAR SWEEP: ALL CLEAR',
+ 'VACUUM TUBE BANK: NOMINAL',
+ 'RETRO-ROCKET FUEL: SUFFICIENT',
+ 'AUTOPILOT: ENGAGED'
+ ],
+ military: [
+ 'TACTICAL GRID: CLEAR',
+ 'SHIELD HARMONICS NOMINAL',
+ 'WEAPONS SYSTEMS: STANDBY',
+ 'PATROL SECTOR SWEEP COMPLETE',
+ 'THREAT ASSESSMENT: LOW'
+ ],
+ deepspace: [
+ 'DEEP FIELD SCAN: CONTINUING',
+ 'LONG RANGE SENSORS: NOMINAL',
+ 'STELLAR CARTOGRAPHY UPDATING',
+ 'BACKGROUND RADIATION: BASELINE',
+ 'NAVIGATION LOCK: HOLDING'
+ ],
+ outlaw: [
+ 'TRANSPONDER: SPOOFED',
+ 'CARGO MANIFEST: REDACTED',
+ 'PATROL CHATTER: MONITORING',
+ 'FUEL RESERVES: RUNNING LEAN',
+ 'NO QUESTIONS, NO PROBLEMS'
+ ],
+ spacestations: [
+ 'DOCKING RING: CLEAR FOR APPROACH',
+ 'PROMENADE TRAFFIC: NORMAL',
+ 'LIFE SUPPORT: ALL SECTIONS NOMINAL',
+ 'TRANSIT SCHEDULE: ON TIME',
+ 'STATION SPIN: STABLE'
+ ],
+ comedy: [
+ 'PROBABLY FINE, ACTUALLY',
+ 'TEA SUPPLIES: ADEQUATE',
+ 'PANIC LEVEL: STILL NOT REQUIRED',
+ 'SCENIC ROUTE: ENGAGED',
+ 'MOSTLY HARMLESS'
+ ]
+ };
+ return sets[universeId] || sets.starfleet;
+ }
+
+ setTickerText(text) {
+ if (!this.tickerTextEl) {
+ this.tickerTextEl = document.getElementById('observation-ticker-text');
+ if (!this.tickerTextEl) return;
+ }
+ this.tickerTextEl.textContent = text;
+ // Restart the CSS scroll animation from the left edge whenever the message changes.
+ this.tickerTextEl.style.animation = 'none';
+ void this.tickerTextEl.offsetWidth;
+ this.tickerTextEl.style.animation = '';
+ }
+
+ updateStatusTicker(dt, now) {
+ if (!this.tickerTextEl) {
+ this.tickerTextEl = document.getElementById('observation-ticker-text');
+ if (!this.tickerTextEl) return;
+ }
+
+ if (this.cinematicCaption) {
+ if (this.tickerTextEl.textContent !== this.cinematicCaption) {
+ this.setTickerText(this.cinematicCaption);
+ }
+ return;
+ }
+
+ if (!this.tickerMessages || !this.tickerMessages.length) {
+ this.tickerMessages = this.getTickerMessages(this.activeUniverse);
+ }
+
+ if (!this.nextTickerTime || now > this.nextTickerTime) {
+ this.tickerIndex = ((this.tickerIndex === undefined ? -1 : this.tickerIndex) + 1) % this.tickerMessages.length;
+ this.setTickerText(this.tickerMessages[this.tickerIndex]);
+ this.nextTickerTime = now + 9000;
+ }
+ }
+
+ updateViewportFrame() {
+ if (!this.frameEl) return;
+ const presetId = this.selectPreset ? this.selectPreset.value : (window.activePresetId || null);
+ this.frameEl.innerHTML = this.getViewportFrameSvg(this.activeUniverse, presetId);
+ }
+
+ getViewportFrameSvg(universeId, presetId) {
+ if (window.ObservationBezels && typeof window.ObservationBezels.getViewportFrameSvg === "function") {
+ return window.ObservationBezels.getViewportFrameSvg(universeId, presetId, this);
+ }
+ return "";
+ }
+}
+window.ObservationEngine = ObservationEngine;
diff --git a/test-fixtures/reference-exhibits/scifi/js/visualizer.js b/test-fixtures/reference-exhibits/scifi/js/visualizer.js
new file mode 100644
index 0000000..a93a364
--- /dev/null
+++ b/test-fixtures/reference-exhibits/scifi/js/visualizer.js
@@ -0,0 +1,673 @@
+class StarshipVisualizer {
+ constructor(audioManager, warpSynth) {
+ this.am = audioManager;
+ this.warpSynth = warpSynth;
+
+ this.spectrumCanvas = null;
+ this.spectrumCtx = null;
+ this.warpCoreCanvas = null;
+ this.warpCoreCtx = null;
+
+ this.animationFrameId = null;
+ this.pulseEnergy = 0.2;
+ this.warpParticles = [];
+ this.mode = 'warp-core'; // 'warp-core' or 'time-rotor'
+ this.rotorPhase = 0;
+ this.coreTime = 0;
+ this.coreLoad = 0.6;
+ this.lastFrameTime = null;
+
+ // Hook into worp core pulse callback
+ if (this.warpSynth) {
+ this.warpSynth.onPulse = (phase, duration) => {
+ this.pulseEnergy = 1.0;
+ this.spawnWarpPulses();
+ };
+ }
+ }
+
+ setMode(mode) {
+ this.mode = mode || 'warp-core';
+ }
+
+ init(spectrumCanvasId, warpCoreCanvasId) {
+ this.spectrumCanvas = document.getElementById(spectrumCanvasId);
+ if (this.spectrumCanvas) {
+ this.spectrumCtx = this.spectrumCanvas.getContext('2d');
+ }
+
+ this.warpCoreCanvas = document.getElementById(warpCoreCanvasId);
+ if (this.warpCoreCanvas) {
+ this.warpCoreCtx = this.warpCoreCanvas.getContext('2d');
+ this.initWarpParticles();
+ }
+
+ window.addEventListener('resize', () => this.resizeCanvases());
+ this.resizeCanvases();
+ this.startRenderLoop();
+ }
+
+ resizeCanvases() {
+ if (this.spectrumCanvas) {
+ const rect = this.spectrumCanvas.parentElement.getBoundingClientRect();
+ this.spectrumCanvas.width = rect.width * window.devicePixelRatio;
+ this.spectrumCanvas.height = (rect.height || 160) * window.devicePixelRatio;
+ this.spectrumCtx.scale(window.devicePixelRatio, window.devicePixelRatio);
+ }
+ if (this.warpCoreCanvas) {
+ const rect = this.warpCoreCanvas.parentElement.getBoundingClientRect();
+ this.warpCoreCanvas.width = rect.width * window.devicePixelRatio;
+ this.warpCoreCanvas.height = (rect.height || 260) * window.devicePixelRatio;
+ this.warpCoreCtx.scale(window.devicePixelRatio, window.devicePixelRatio);
+ }
+ }
+
+ initWarpParticles() {
+ this.warpParticles = [];
+ for (let i = 0; i < 36; i++) {
+ this.warpParticles.push({
+ y: Math.random(),
+ speed: (Math.random() * 0.008 + 0.004) * (Math.random() > 0.5 ? 1 : -1),
+ size: Math.random() * 4 + 2,
+ opacity: Math.random() * 0.7 + 0.3
+ });
+ }
+ }
+
+ spawnWarpPulses() {
+ // The new machines render their own flow; only legacy particle modes need a pool.
+ if (this.mode !== 'warp-core' && this.mode !== 'time-rotor') return;
+ for (let i = 0; i < 6; i++) {
+ this.warpParticles.push({
+ y: 0.5, // Center matter/antimatter reaction plane
+ speed: (Math.random() * 0.018 + 0.01) * (i % 2 === 0 ? 1 : -1),
+ size: Math.random() * 6 + 3,
+ opacity: 1.0
+ });
+ }
+ }
+
+ startRenderLoop() {
+ this.lastFrameTime = null;
+ const render = (now) => {
+ const elapsed = this.lastFrameTime === null ? 0 : Math.min((now - this.lastFrameTime) / 1000, 0.05);
+ this.lastFrameTime = now;
+ const volume = this.warpSynth && !this.warpSynth.isMuted ? this.warpSynth.params.volume : 0;
+ const targetLoad = Math.max(0, Math.min(1, volume * (0.65 + this.pulseEnergy * 0.35)));
+ this.coreLoad += (targetLoad - this.coreLoad) * (1 - Math.exp(-elapsed * 6));
+ this.coreTime += elapsed * (this.mode === 'tactical-flywheel' ? 0.6 + this.coreLoad * 0.8 : 1);
+ this.renderSpectrum();
+ if (window.CoreAnimations && CoreAnimations.has(this.mode)) {
+ if (this.warpCoreCanvas && this.warpCoreCtx) {
+ CoreAnimations.render(this.warpCoreCtx, this.mode,
+ this.warpCoreCanvas.width / window.devicePixelRatio,
+ this.warpCoreCanvas.height / window.devicePixelRatio,
+ this.coreTime, this.coreLoad);
+ }
+ } else {
+ switch (this.mode) {
+ case 'time-rotor':
+ this.renderTimeRotor();
+ break;
+ case 'industrial-reactor':
+ this.renderIndustrialReactor();
+ break;
+ case 'bio-heart':
+ this.renderBioHeart();
+ break;
+ case 'retro-oscilloscope':
+ this.renderRetroOscilloscope();
+ break;
+ case 'singularity-core':
+ this.renderSingularityCore();
+ break;
+ case 'warp-core':
+ default:
+ this.renderWarpCore();
+ break;
+ }
+ }
+
+ // Decay pulse energy smoothly
+ this.pulseEnergy = Math.max(0.15, this.pulseEnergy * Math.pow(0.94, elapsed * 60));
+
+ this.animationFrameId = requestAnimationFrame(render);
+ };
+
+ if (this.animationFrameId) cancelAnimationFrame(this.animationFrameId);
+ this.animationFrameId = requestAnimationFrame(render);
+ }
+
+ renderSpectrum() {
+ if (!this.spectrumCanvas || !this.spectrumCtx || !this.am.analyser) return;
+
+ const ctx = this.spectrumCtx;
+ const w = this.spectrumCanvas.width / window.devicePixelRatio;
+ const h = this.spectrumCanvas.height / window.devicePixelRatio;
+
+ const bufferLength = this.am.analyser.frequencyBinCount;
+ const dataArray = new Uint8Array(bufferLength);
+ this.am.analyser.getByteFrequencyData(dataArray);
+
+ ctx.clearRect(0, 0, w, h);
+
+ // Dynamic grid color per visualizer mode
+ let gridCol = 'rgba(255, 153, 0, 0.12)';
+ if (this.mode === 'time-rotor' || this.mode === 'singularity-core') gridCol = 'rgba(0, 229, 255, 0.12)';
+ else if (this.mode === 'bio-heart') gridCol = 'rgba(16, 185, 129, 0.12)';
+ else if (this.mode === 'retro-oscilloscope') gridCol = 'rgba(34, 197, 94, 0.15)';
+ else if (this.mode === 'industrial-reactor' || this.mode === 'compression-furnace' || this.mode === 'station-hub') gridCol = 'rgba(245, 158, 11, 0.15)';
+
+ ctx.strokeStyle = gridCol;
+ ctx.lineWidth = 1;
+ for (let y = 20; y < h; y += 30) {
+ ctx.beginPath();
+ ctx.moveTo(0, y);
+ ctx.lineTo(w, y);
+ ctx.stroke();
+ }
+
+ // Draw segmented frequency bars
+ const numBars = 32;
+ const barWidth = (w / numBars) - 3;
+
+ for (let i = 0; i < numBars; i++) {
+ const binIdx = Math.floor(Math.pow(i / numBars, 1.8) * (bufferLength * 0.6));
+ const val = dataArray[binIdx] || 0;
+ const barHeight = Math.max(4, (val / 255) * (h - 20));
+
+ const x = i * (barWidth + 3);
+ const y = h - barHeight;
+
+ let col;
+ if (this.mode === 'time-rotor') {
+ if (i < 8) col = '#00e5ff';
+ else if (i < 20) col = '#38bdf8';
+ else if (i < 28) col = '#d4af37';
+ else col = '#ffffff';
+ } else if (this.mode === 'bio-heart') {
+ if (i < 8) col = '#10b981';
+ else if (i < 20) col = '#34d399';
+ else if (i < 28) col = '#a855f7';
+ else col = '#c084fc';
+ } else if (this.mode === 'retro-oscilloscope') {
+ col = i < 28 ? '#22c55e' : '#86efac';
+ } else if (this.mode === 'industrial-reactor' || this.mode === 'compression-furnace' || this.mode === 'station-hub') {
+ if (i < 8) col = '#d97706';
+ else if (i < 20) col = '#f59e0b';
+ else if (i < 28) col = '#fbbf24';
+ else col = '#fef08a';
+ } else if (this.mode === 'singularity-core') {
+ if (i < 8) col = '#4f46e5';
+ else if (i < 20) col = '#6366f1';
+ else if (i < 28) col = '#38bdf8';
+ else col = '#ffffff';
+ } else {
+ // Starflight / Classic LCARD
+ if (i < 8) col = '#ff6600';
+ else if (i < 20) col = '#ff9933';
+ else if (i < 28) col = '#cc99cc';
+ else col = '#99ccff';
+ }
+
+ ctx.fillStyle = col;
+ ctx.shadowColor = col;
+ ctx.shadowBlur = val > 120 ? 8 : 0;
+ ctx.fillRect(x, y, barWidth, barHeight);
+
+ ctx.fillStyle = '#ffffff';
+ ctx.fillRect(x, y - 2, barWidth, 2);
+ }
+ ctx.shadowBlur = 0;
+ }
+
+ renderWarpCore() {
+ if (!this.warpCoreCanvas || !this.warpCoreCtx) return;
+
+ const ctx = this.warpCoreCtx;
+ const w = this.warpCoreCanvas.width / window.devicePixelRatio;
+ const h = this.warpCoreCanvas.height / window.devicePixelRatio;
+
+ ctx.clearRect(0, 0, w, h);
+
+ const centerX = w / 2;
+ const chamberWidth = Math.min(70, w * 0.4);
+
+ // 1. Draw outer intermix chamber housing
+ ctx.fillStyle = '#111625';
+ ctx.fillRect(centerX - chamberWidth / 2 - 8, 0, chamberWidth + 16, h);
+
+ // Chamber glass gradient
+ const glassGrad = ctx.createLinearGradient(centerX - chamberWidth / 2, 0, centerX + chamberWidth / 2, 0);
+ glassGrad.addColorStop(0, 'rgba(0, 50, 100, 0.4)');
+ glassGrad.addColorStop(0.5, 'rgba(0, 180, 255, 0.15)');
+ glassGrad.addColorStop(1, 'rgba(0, 50, 100, 0.4)');
+ ctx.fillStyle = glassGrad;
+ ctx.fillRect(centerX - chamberWidth / 2, 0, chamberWidth, h);
+
+ // 2. Matter / Antimatter injectors (Top and Bottom)
+ ctx.fillStyle = '#ff9900';
+ ctx.fillRect(centerX - chamberWidth / 2 - 4, 0, chamberWidth + 8, 12);
+ ctx.fillRect(centerX - chamberWidth / 2 - 4, h - 12, chamberWidth + 8, 12);
+
+ // 3. Central Reaction Intermix Chamber (Center glowing disc)
+ const centerY = h / 2;
+ const glowRadius = 24 + this.pulseEnergy * 28;
+ const coreGlow = ctx.createRadialGradient(centerX, centerY, 2, centerX, centerY, glowRadius);
+ coreGlow.addColorStop(0, '#ffffff');
+ coreGlow.addColorStop(0.3, `rgba(0, 210, 255, ${0.7 + this.pulseEnergy * 0.3})`);
+ coreGlow.addColorStop(0.7, `rgba(0, 100, 255, ${0.4 + this.pulseEnergy * 0.4})`);
+ coreGlow.addColorStop(1, 'rgba(0, 0, 0, 0)');
+
+ ctx.fillStyle = coreGlow;
+ ctx.beginPath();
+ ctx.arc(centerX, centerY, glowRadius, 0, Math.PI * 2);
+ ctx.fill();
+
+ // 4. Segmented Magnetic Constriction Coils (horizontal pulsing rings)
+ const numCoils = 14;
+ for (let i = 0; i < numCoils; i++) {
+ const coilY = (i / (numCoils - 1)) * (h - 30) + 15;
+ const distFromCenter = Math.abs(coilY - centerY) / (h / 2);
+ const coilIntensity = Math.max(0.2, (1.0 - distFromCenter * 0.6) * (0.4 + this.pulseEnergy * 0.6));
+
+ ctx.fillStyle = `rgba(0, 230, 255, ${coilIntensity})`;
+ ctx.shadowColor = '#00e6ff';
+ ctx.shadowBlur = this.pulseEnergy > 0.6 ? 12 : 3;
+
+ // Draw coil bar
+ ctx.fillRect(centerX - chamberWidth / 2 + 4, coilY - 2, chamberWidth - 8, 4);
+ }
+ ctx.shadowBlur = 0;
+
+ // 5. Plasma stream particles
+ for (let i = this.warpParticles.length - 1; i >= 0; i--) {
+ const p = this.warpParticles[i];
+ p.y += p.speed;
+
+ if (p.y < 0 || p.y > 1) {
+ if (this.warpParticles.length > 36) {
+ this.warpParticles.splice(i, 1);
+ continue;
+ } else {
+ p.y = p.speed > 0 ? 0 : 1;
+ }
+ }
+
+ const py = p.y * h;
+ const px = centerX + (Math.sin(p.y * 12) * (chamberWidth * 0.25));
+
+ ctx.fillStyle = `rgba(180, 240, 255, ${p.opacity * (0.4 + this.pulseEnergy * 0.6)})`;
+ ctx.beginPath();
+ ctx.arc(px, py, p.size * (0.8 + this.pulseEnergy * 0.4), 0, Math.PI * 2);
+ ctx.fill();
+ }
+ }
+
+ /**
+ * Renders the canonical TARDIX Central Time Rotor
+ * A glass cylinder containing an interior mechanical column physically rising and falling
+ * in sync with the pulse cycle, illuminated with glowing Gallifreyan cyan/emerald light.
+ */
+ renderTimeRotor() {
+ if (!this.warpCoreCanvas || !this.warpCoreCtx) return;
+
+ const ctx = this.warpCoreCtx;
+ const w = this.warpCoreCanvas.width / window.devicePixelRatio;
+ const h = this.warpCoreCanvas.height / window.devicePixelRatio;
+
+ ctx.clearRect(0, 0, w, h);
+
+ const centerX = w / 2;
+ const columnWidth = Math.min(84, w * 0.45);
+
+ // 1. TARDIX Console Plinth & Ceiling Collar (Victorian Brass / Gallifreyan Bronze)
+ const collarGrad = ctx.createLinearGradient(centerX - columnWidth / 2, 0, centerX + columnWidth / 2, 0);
+ collarGrad.addColorStop(0, '#593e10');
+ collarGrad.addColorStop(0.3, '#d4af37');
+ collarGrad.addColorStop(0.7, '#fef08a');
+ collarGrad.addColorStop(1, '#593e10');
+
+ ctx.fillStyle = collarGrad;
+ ctx.fillRect(centerX - columnWidth / 2 - 8, 0, columnWidth + 16, 14);
+ ctx.fillRect(centerX - columnWidth / 2 - 8, h - 14, columnWidth + 16, 14);
+
+ // 2. Outer Glass Column Tube
+ const glassGrad = ctx.createLinearGradient(centerX - columnWidth / 2, 0, centerX + columnWidth / 2, 0);
+ glassGrad.addColorStop(0, 'rgba(0, 40, 80, 0.45)');
+ glassGrad.addColorStop(0.15, 'rgba(0, 229, 255, 0.25)');
+ glassGrad.addColorStop(0.5, 'rgba(255, 255, 255, 0.12)');
+ glassGrad.addColorStop(0.85, 'rgba(0, 229, 255, 0.25)');
+ glassGrad.addColorStop(1, 'rgba(0, 40, 80, 0.45)');
+
+ ctx.fillStyle = glassGrad;
+ ctx.fillRect(centerX - columnWidth / 2, 14, columnWidth, h - 28);
+
+ // Glass edge highlights
+ ctx.strokeStyle = 'rgba(0, 229, 255, 0.6)';
+ ctx.lineWidth = 1.5;
+ ctx.strokeRect(centerX - columnWidth / 2, 14, columnWidth, h - 28);
+
+ // 3. Central Bobbing Time Rotor Column
+ // Physical oscillation: rises and falls smoothly
+ this.rotorPhase += 0.038;
+ const maxTravel = (h - 90) * 0.35;
+ const rotorOffset = Math.sin(this.rotorPhase) * maxTravel;
+ const rotorCenterY = (h / 2) + rotorOffset;
+ const rotorHeight = (h - 28) * 0.48;
+
+ // Moving Inner Rotor Rod & Glass Tubes
+ const innerWidth = columnWidth * 0.58;
+
+ // Glowing core glow
+ const coreGlow = ctx.createRadialGradient(centerX, rotorCenterY, 4, centerX, rotorCenterY, 36 + this.pulseEnergy * 30);
+ coreGlow.addColorStop(0, '#ffffff');
+ coreGlow.addColorStop(0.4, `rgba(0, 229, 255, ${0.7 + this.pulseEnergy * 0.3})`);
+ coreGlow.addColorStop(0.8, `rgba(0, 100, 200, ${0.3 + this.pulseEnergy * 0.4})`);
+ coreGlow.addColorStop(1, 'rgba(0, 0, 0, 0)');
+
+ ctx.fillStyle = coreGlow;
+ ctx.beginPath();
+ ctx.arc(centerX, rotorCenterY, 36 + this.pulseEnergy * 30, 0, Math.PI * 2);
+ ctx.fill();
+
+ // Inner mechanical tubes
+ ctx.fillStyle = '#00e5ff';
+ ctx.shadowColor = '#00e5ff';
+ ctx.shadowBlur = 10 + this.pulseEnergy * 10;
+ ctx.fillRect(centerX - 4, rotorCenterY - rotorHeight / 2, 8, rotorHeight);
+
+ // Left and right secondary crystal tubes
+ ctx.fillStyle = 'rgba(180, 240, 255, 0.85)';
+ ctx.fillRect(centerX - innerWidth / 2 + 2, rotorCenterY - rotorHeight / 2 + 10, 5, rotorHeight - 20);
+ ctx.fillRect(centerX + innerWidth / 2 - 7, rotorCenterY - rotorHeight / 2 + 10, 5, rotorHeight - 20);
+
+ // Gallifreyan Circular Rotor Rings
+ for (let r = 0; r < 4; r++) {
+ const ringY = rotorCenterY - rotorHeight / 2 + (r * (rotorHeight / 3));
+ ctx.strokeStyle = '#d4af37';
+ ctx.lineWidth = 2;
+ ctx.beginPath();
+ ctx.ellipse(centerX, ringY, innerWidth / 2 + 2, 5, 0, 0, Math.PI * 2);
+ ctx.stroke();
+ }
+ ctx.shadowBlur = 0;
+
+ // 4. Sparkling Vortex Time Energy Particles
+ for (let i = this.warpParticles.length - 1; i >= 0; i--) {
+ const p = this.warpParticles[i];
+ p.y += p.speed * 0.8;
+
+ if (p.y < 0.05 || p.y > 0.95) {
+ if (this.warpParticles.length > 36) {
+ this.warpParticles.splice(i, 1);
+ continue;
+ } else {
+ p.y = p.speed > 0 ? 0.05 : 0.95;
+ }
+ }
+
+ const py = p.y * h;
+ const px = centerX + (Math.sin(p.y * 16 + this.rotorPhase) * (columnWidth * 0.32));
+
+ ctx.fillStyle = `rgba(0, 229, 255, ${p.opacity * (0.5 + this.pulseEnergy * 0.5)})`;
+ ctx.shadowColor = '#00e5ff';
+ ctx.shadowBlur = 6;
+ ctx.beginPath();
+ ctx.arc(px, py, p.size * (0.7 + this.pulseEnergy * 0.5), 0, Math.PI * 2);
+ ctx.fill();
+ }
+ ctx.shadowBlur = 0;
+ }
+
+ /**
+ * Industrial Fusion Reactor (Nostromo, Serenity, Rocinante)
+ * Heavy containment walls, incandescent glowing amber plasma core, heat radiating coils
+ */
+ renderIndustrialReactor() {
+ if (!this.warpCoreCanvas || !this.warpCoreCtx) return;
+ const ctx = this.warpCoreCtx;
+ const w = this.warpCoreCanvas.width / window.devicePixelRatio;
+ const h = this.warpCoreCanvas.height / window.devicePixelRatio;
+ ctx.clearRect(0, 0, w, h);
+
+ const centerX = w / 2;
+ const centerY = h / 2;
+ const chamberW = Math.min(80, w * 0.42);
+
+ // Cast iron frame
+ ctx.fillStyle = '#1c150c';
+ ctx.fillRect(centerX - chamberW / 2 - 10, 0, chamberW + 20, h);
+
+ // Hazard warning bands at top and bottom
+ for (let x = centerX - chamberW / 2 - 10; x < centerX + chamberW / 2 + 10; x += 12) {
+ ctx.fillStyle = (x % 24 === 0) ? '#d97706' : '#1a1106';
+ ctx.fillRect(x, 0, 12, 10);
+ ctx.fillRect(x, h - 10, 12, 10);
+ }
+
+ // Incandescent molten amber core
+ const radius = 22 + this.pulseEnergy * 32;
+ const glow = ctx.createRadialGradient(centerX, centerY, 2, centerX, centerY, radius);
+ glow.addColorStop(0, '#ffffff');
+ glow.addColorStop(0.2, '#fef08a');
+ glow.addColorStop(0.5, `rgba(245, 158, 11, ${0.7 + this.pulseEnergy * 0.3})`);
+ glow.addColorStop(1, 'rgba(180, 83, 9, 0)');
+
+ ctx.fillStyle = glow;
+ ctx.beginPath();
+ ctx.arc(centerX, centerY, radius, 0, Math.PI * 2);
+ ctx.fill();
+
+ // Heat induction coil clamps
+ for (let i = 0; i < 9; i++) {
+ const cy = 20 + i * ((h - 40) / 8);
+ ctx.fillStyle = (i % 2 === 0) ? '#f59e0b' : '#78350f';
+ ctx.shadowColor = '#f59e0b';
+ ctx.shadowBlur = this.pulseEnergy > 0.6 ? 10 : 2;
+ ctx.fillRect(centerX - chamberW / 2, cy - 3, chamberW, 6);
+ }
+ ctx.shadowBlur = 0;
+ }
+
+ /**
+ * Living Leviathon Bio-Heart (Moya, Lexx, Species 8675309)
+ * Pulsing vascular heart sac with bioluminescent emerald/violet energy and neural veins
+ */
+ renderBioHeart() {
+ if (!this.warpCoreCanvas || !this.warpCoreCtx) return;
+ const ctx = this.warpCoreCtx;
+ const w = this.warpCoreCanvas.width / window.devicePixelRatio;
+ const h = this.warpCoreCanvas.height / window.devicePixelRatio;
+ ctx.clearRect(0, 0, w, h);
+
+ const centerX = w / 2;
+ const centerY = h / 2;
+
+ // Organic vascular expansion
+ const bioScale = 1.0 + Math.sin(this.rotorPhase * 1.2) * 0.12 + this.pulseEnergy * 0.18;
+ const baseR = 35 * bioScale;
+
+ // Outer bioluminescent aura
+ const aura = ctx.createRadialGradient(centerX, centerY, 4, centerX, centerY, baseR * 1.8);
+ aura.addColorStop(0, '#a7f3d0');
+ aura.addColorStop(0.3, `rgba(16, 185, 129, ${0.7 + this.pulseEnergy * 0.3})`);
+ aura.addColorStop(0.7, `rgba(139, 92, 246, ${0.3 + this.pulseEnergy * 0.3})`);
+ aura.addColorStop(1, 'rgba(0, 0, 0, 0)');
+
+ ctx.fillStyle = aura;
+ ctx.beginPath();
+ ctx.arc(centerX, centerY, baseR * 1.8, 0, Math.PI * 2);
+ ctx.fill();
+
+ // Pulsing neural veins
+ ctx.strokeStyle = '#34d399';
+ ctx.lineWidth = 2.5;
+ ctx.shadowColor = '#10b981';
+ ctx.shadowBlur = 8;
+ for (let v = 0; v < 6; v++) {
+ const angle = (v / 6) * Math.PI * 2 + this.rotorPhase * 0.2;
+ ctx.beginPath();
+ ctx.moveTo(centerX, centerY);
+ const cpX = centerX + Math.cos(angle + 0.5) * (baseR * 0.8);
+ const cpY = centerY + Math.sin(angle + 0.5) * (baseR * 0.8);
+ const endX = centerX + Math.cos(angle) * (baseR * 1.5);
+ const endY = centerY + Math.sin(angle) * (baseR * 1.5);
+ ctx.quadraticCurveTo(cpX, cpY, endX, endY);
+ ctx.stroke();
+ }
+ ctx.shadowBlur = 0;
+ }
+
+ /**
+ * Retro Oscilloscope & Analog Astrogator (Jupiter 2, Discovery One)
+ * 1950s/60s green phosphor CRT screen with glowing Lissajous audio wave rings
+ */
+ renderRetroOscilloscope() {
+ if (!this.warpCoreCanvas || !this.warpCoreCtx) return;
+ const ctx = this.warpCoreCtx;
+ const w = this.warpCoreCanvas.width / window.devicePixelRatio;
+ const h = this.warpCoreCanvas.height / window.devicePixelRatio;
+ ctx.clearRect(0, 0, w, h);
+
+ const centerX = w / 2;
+ const centerY = h / 2;
+ const crtRadius = Math.min(w, h) * 0.42;
+
+ // Circular CRT bezel
+ ctx.fillStyle = '#052e16';
+ ctx.beginPath();
+ ctx.arc(centerX, centerY, crtRadius, 0, Math.PI * 2);
+ ctx.fill();
+ ctx.strokeStyle = '#22c55e';
+ ctx.lineWidth = 2;
+ ctx.stroke();
+
+ // Crosshairs
+ ctx.strokeStyle = 'rgba(34, 197, 94, 0.25)';
+ ctx.lineWidth = 1;
+ ctx.beginPath();
+ ctx.moveTo(centerX - crtRadius, centerY);
+ ctx.lineTo(centerX + crtRadius, centerY);
+ ctx.moveTo(centerX, centerY - crtRadius);
+ ctx.lineTo(centerX, centerY + crtRadius);
+ ctx.stroke();
+
+ // Draw a neutral trace before audio initialization, replacing the previous theme.
+ const analyser = this.am.analyser;
+ const bufferLength = analyser ? analyser.fftSize : 128;
+ const dataArray = new Uint8Array(bufferLength);
+ if (analyser) analyser.getByteTimeDomainData(dataArray);
+ else dataArray.fill(128);
+
+ ctx.strokeStyle = '#86efac';
+ ctx.shadowColor = '#22c55e';
+ ctx.shadowBlur = 8;
+ ctx.lineWidth = 2;
+ ctx.beginPath();
+
+ const points = 48;
+ for (let i = 0; i < points; i++) {
+ const idx = Math.floor((i / points) * (bufferLength / 2));
+ const v = (dataArray[idx] / 128.0) - 1.0;
+ const angle = (i / points) * Math.PI * 2 + this.coreTime;
+ const r = (crtRadius * 0.65) + (v * 28 * (0.8 + this.pulseEnergy));
+ const x = centerX + Math.cos(angle) * r;
+ const y = centerY + Math.sin(angle) * r;
+ if (i === 0) ctx.moveTo(x, y);
+ else ctx.lineTo(x, y);
+ }
+ ctx.closePath();
+ ctx.stroke();
+ // A sweep marker makes rotation legible even when the audio trace is silent.
+ const sweepRadius = (crtRadius * 0.65) + ((dataArray[0] / 128.0) - 1.0) * 28 * (0.8 + this.pulseEnergy);
+ ctx.fillStyle = '#d1fae5';
+ ctx.beginPath();
+ ctx.arc(centerX + Math.cos(this.coreTime) * sweepRadius,
+ centerY + Math.sin(this.coreTime) * sweepRadius, 2.5, 0, Math.PI * 2);
+ ctx.fill();
+ ctx.shadowBlur = 0;
+ }
+
+ /**
+ * Gravity Singularity Core (Event Horizon, Deep Space)
+ * Black hole event horizon with warping gravitational accretion disk
+ */
+ renderSingularityCore() {
+ if (!this.warpCoreCanvas || !this.warpCoreCtx) return;
+ const ctx = this.warpCoreCtx;
+ const w = this.warpCoreCanvas.width / window.devicePixelRatio;
+ const h = this.warpCoreCanvas.height / window.devicePixelRatio;
+ ctx.clearRect(0, 0, w, h);
+
+ const centerX = w / 2;
+ const centerY = h / 2;
+ const diskR = Math.min(w, h) * 0.44;
+
+ // Glowing gravitational accretion disk
+ ctx.save();
+ ctx.translate(centerX, centerY);
+ ctx.rotate(this.rotorPhase * 0.6);
+
+ const grad = ctx.createRadialGradient(0, 0, 12, 0, 0, diskR);
+ grad.addColorStop(0, '#000000');
+ grad.addColorStop(0.35, '#000000');
+ grad.addColorStop(0.45, `rgba(99, 102, 241, ${0.8 + this.pulseEnergy * 0.2})`);
+ grad.addColorStop(0.7, `rgba(56, 189, 248, ${0.4 + this.pulseEnergy * 0.3})`);
+ grad.addColorStop(1, 'rgba(0, 0, 0, 0)');
+
+ ctx.fillStyle = grad;
+ ctx.beginPath();
+ ctx.ellipse(0, 0, diskR, diskR * 0.35, 0, 0, Math.PI * 2);
+ ctx.fill();
+ ctx.restore();
+
+ // Pure black event horizon sphere at center
+ ctx.fillStyle = '#000000';
+ ctx.strokeStyle = 'rgba(99, 102, 241, 0.8)';
+ ctx.lineWidth = 2;
+ ctx.shadowColor = '#6366f1';
+ ctx.shadowBlur = 12 + this.pulseEnergy * 10;
+ ctx.beginPath();
+ ctx.arc(centerX, centerY, 18, 0, Math.PI * 2);
+ ctx.fill();
+ ctx.stroke();
+ ctx.shadowBlur = 0;
+ }
+}
+
+window.StarshipVisualizer = StarshipVisualizer;
+
+// Helper: Hex color to RGBA
+function hexToRgba(hex, alpha = 1) {
+ if (!hex || hex.charAt(0) !== '#') return `rgba(56, 189, 248, ${alpha})`;
+ let c = hex.substring(1);
+ if (c.length === 3) c = c.split('').map(x => x + x).join('');
+ const num = parseInt(c, 16);
+ return `rgba(${(num >> 16) & 255}, ${(num >> 8) & 255}, ${num & 255}, ${alpha})`;
+}
+
+/**
+ * ============================================================================
+ * CINEMATIC OBSERVATION LOUNGE ENGINE (v9g)
+ * ============================================================================
+ * Features:
+ * - 60fps DPI-Aware Deep Celestial Canvas (Parallax 3D Starfield & Warp Tunnel)
+ * - Relativistic Warp Flight vs. Orbital Cruise Impulse Modes
+ * - Procedural Celestial Bodies (Class-M Planet with Atmospheric Glow, Time Vortex, Gas Giants)
+ * - Living Traffic & Encounters (Shuttles, Cruisers, Decloaking Klingon BOP, TARDIX, Freighters)
+ * - Viewport Window Framing Architecture per Universe (Starflight, Industrial, Station, Whataverse, Military)
+ * - Emergency Alert Synchronization (Red/Yellow Alert Klaxon Strobes & Shield Grids)
+ * - Subspace Audio Harmonics Waveform Sill
+ * - Auto-Hiding Interactive Glass Control Dock
+ */
+// =========================================================================
+// OBSERVATION CANVAS MANIFEST (v3co)
+// =========================================================================
+// Each universe declares exactly which canvas layers it draws. Default-deny:
+// anything not listed is OFF. A universe missing from this table gets no canvas at all.
+// This table is the contract that keeps each theme's OBSERVATION its own experience --
+// do not add a layer here to "fill space"; give the theme its own bespoke content instead.
+//
+// `starfield` entries may carry a per-universe profile so that two universes drawing stars
+// are still drawing THEIR OWN stars (density, palette, scale), not one shared layer.
diff --git a/test-fixtures/reference-exhibits/shared/contract-core.js b/test-fixtures/reference-exhibits/shared/contract-core.js
new file mode 100644
index 0000000..1e7a495
--- /dev/null
+++ b/test-fixtures/reference-exhibits/shared/contract-core.js
@@ -0,0 +1,710 @@
+/*
+ * XZBT Exhibit Contract 5.2 — generic contract core.
+ *
+ * WHY THIS FILE IS GENERIC
+ * ------------------------
+ * Everything in here is domain-free. It knows about the *shape* of the
+ * contract (envelopes, target kinds, revisions, sequences, error codes,
+ * capability lifecycle) and nothing about aquariums, planetariums, haunted
+ * houses, or any other subject matter. It contains no exhibit state, no
+ * exhibit vocabulary, and no transport.
+ *
+ * An exhibit supplies three things and gets a conforming contract surface:
+ *
+ * 1. a target catalog (canonical dotted IDs -> descriptors)
+ * 2. a setter table (target ID -> absolute, idempotent setter)
+ * 3. an action table (target ID -> real impulse implementation)
+ *
+ * The core owns the canonical mutation/invoke chokepoint, so every input
+ * source (native UI, hotkey, host transport) converges on one path and one
+ * set of transaction semantics. That is the whole point of sharing it: the
+ * contract rules are subtle enough that three hand-rolled copies would drift.
+ *
+ * This file is a classic script (no ES modules) and publishes one global.
+ */
+(function () {
+ 'use strict';
+
+ var CONTRACT_MAJOR = 5;
+ var CONTRACT_MINOR = 2;
+ var XZBT_VERSION = '5.2';
+
+ /* Contract 5.2 §15. The exhibit assigns source at its own trusted
+ * boundary; a source supplied by a caller is never trusted. */
+ var SOURCES = ['ui', 'midi', 'hotkey', 'host', 'scenario', 'internal', 'system'];
+
+ /* Contract 5.2 §24. */
+ var ERROR_CODES = [
+ 'UNSUPPORTED_VERSION',
+ 'INVALID_MESSAGE',
+ 'INVALID_SESSION',
+ 'UNKNOWN_TARGET',
+ 'INVALID_VALUE',
+ 'INVALID_ARGUMENTS',
+ 'CAPABILITY_UNAVAILABLE',
+ 'TARGET_READ_ONLY',
+ 'TARGET_NOT_INVOKABLE',
+ 'TARGET_NOT_SETTABLE',
+ 'INTERNAL_ERROR'
+ ];
+
+ /* Contract 5.2 §17. */
+ var CAPABILITY_STATES = ['unsupported', 'available', 'loading', 'ready', 'busy', 'error'];
+
+ /* Contract 5.2 §16. The base event set is closed; exhibits do not invent
+ * new canonical event types. */
+ var EVENT_TYPES = [
+ 'state.changed',
+ 'action.executed',
+ 'selection.changed',
+ 'capability.changed',
+ 'registry.changed',
+ 'error'
+ ];
+
+ var TARGET_ID_PATTERN = /^[a-z][a-z0-9-]*(\.[a-z][a-z0-9-]*)+$/;
+
+ function isPlainObject(v) {
+ return typeof v === 'object' && v !== null && !Array.isArray(v);
+ }
+
+ function isFiniteNumber(v) {
+ return typeof v === 'number' && isFinite(v);
+ }
+
+ /* ------------------------------------------------------------------ *
+ * Target catalog
+ * ------------------------------------------------------------------ */
+
+ /**
+ * A catalog is the exhibit's fixed public surface. It is built once and
+ * never reshaped by ordinary context changes (Authoring Guide §D/N):
+ * a target that is temporarily unusable stays discoverable and reports
+ * CAPABILITY_UNAVAILABLE instead of disappearing.
+ */
+ function Catalog(descriptors) {
+ this._byId = {};
+ this._order = [];
+ for (var i = 0; i < descriptors.length; i++) {
+ var d = descriptors[i];
+ if (!TARGET_ID_PATTERN.test(d.id)) {
+ throw new Error('Invalid canonical target id: ' + d.id);
+ }
+ if (this._byId[d.id]) {
+ throw new Error('Duplicate target id: ' + d.id);
+ }
+ this._byId[d.id] = d;
+ this._order.push(d.id);
+ }
+ }
+
+ Catalog.prototype.has = function (id) {
+ return Object.prototype.hasOwnProperty.call(this._byId, id);
+ };
+
+ Catalog.prototype.get = function (id) {
+ return this.has(id) ? this._byId[id] : null;
+ };
+
+ Catalog.prototype.ids = function () {
+ return this._order.slice();
+ };
+
+ /** Descriptors as published by `describe`, in stable catalog order. */
+ Catalog.prototype.descriptors = function () {
+ var out = [];
+ for (var i = 0; i < this._order.length; i++) {
+ out.push(this._byId[this._order[i]]);
+ }
+ return out;
+ };
+
+ /* ------------------------------------------------------------------ *
+ * Capabilities
+ * ------------------------------------------------------------------ */
+
+ /**
+ * Capabilities are discoverable and stateful (Contract §17). This registry
+ * only records what the exhibit honestly reports; it does not invent
+ * lifecycle transitions. An exhibit that is always ready says so once.
+ */
+ function CapabilityRegistry() {
+ this._caps = {};
+ this._order = [];
+ }
+
+ CapabilityRegistry.prototype.declare = function (id, state) {
+ if (CAPABILITY_STATES.indexOf(state) === -1) {
+ throw new Error('Unknown capability state: ' + state);
+ }
+ if (!this._caps[id]) this._order.push(id);
+ this._caps[id] = { id: id, state: state };
+ return this;
+ };
+
+ CapabilityRegistry.prototype.stateOf = function (id) {
+ return this._caps[id] ? this._caps[id].state : 'unsupported';
+ };
+
+ CapabilityRegistry.prototype.snapshot = function () {
+ var out = [];
+ for (var i = 0; i < this._order.length; i++) {
+ var c = this._caps[this._order[i]];
+ out.push({ id: c.id, state: c.state });
+ }
+ return out;
+ };
+
+ /** True when every required capability is usable right now. */
+ CapabilityRegistry.prototype.allUsable = function (requires) {
+ if (!requires || !requires.length) return true;
+ for (var i = 0; i < requires.length; i++) {
+ var s = this.stateOf(requires[i]);
+ if (s !== 'ready' && s !== 'busy') return false;
+ }
+ return true;
+ };
+
+ /* ------------------------------------------------------------------ *
+ * The contract core
+ * ------------------------------------------------------------------ */
+
+ /**
+ * @param {object} options
+ * @param {object} options.identity { product, version, build }
+ * @param {Catalog} options.catalog
+ * @param {CapabilityRegistry} options.capabilities
+ * @param {object} options.setters target id -> function(value) -> {changed:boolean}
+ * @param {object} options.readers target id -> function() -> value
+ * @param {object} options.actions target id -> function(args) -> {ok, code?, message?}
+ * @param {object} [options.availability] target id -> function() -> {ok, code?, message?}
+ * @param {function} [options.onEvent] called with every normalized event
+ */
+ function ContractCore(options) {
+ this.identity = options.identity;
+ this.catalog = options.catalog;
+ this.capabilities = options.capabilities;
+ this.setters = options.setters || {};
+ this.readers = options.readers || {};
+ this.actions = options.actions || {};
+ this.availability = options.availability || {};
+ this.onEvent = options.onEvent || function () {};
+
+ /* stateRevision tracks committed persistent-state history and does NOT
+ * reset when a host reconnects (Contract §14, §16.1). */
+ this.stateRevision = 0;
+ /* registryRevision tracks the target set and its metadata. The catalog
+ * is fixed, so this starts at 1 and stays there unless the exhibit
+ * genuinely changes its registry. */
+ this.registryRevision = 1;
+
+ /* sequence is session-scoped and resets on a new sessionId. */
+ this.sequence = 0;
+ this.sessionId = null;
+ this._sessionCounter = 0;
+ this._eventLog = [];
+ }
+
+ ContractCore.prototype._emit = function (type, payload) {
+ if (EVENT_TYPES.indexOf(type) === -1) {
+ throw new Error('Not a canonical contract event type: ' + type);
+ }
+ this.sequence += 1;
+ var event = {
+ xzbt: XZBT_VERSION,
+ type: type,
+ sessionId: this.sessionId,
+ sequence: this.sequence,
+ timestamp: Date.now()
+ };
+ for (var k in payload) {
+ if (Object.prototype.hasOwnProperty.call(payload, k)) event[k] = payload[k];
+ }
+ this._eventLog.push(event);
+ this.onEvent(event);
+ return event;
+ };
+
+ /** Events emitted so far in the current session (used by tests/harness). */
+ ContractCore.prototype.eventLog = function () {
+ return this._eventLog.slice();
+ };
+
+ ContractCore.prototype._error = function (code, message) {
+ return { ok: false, error: { code: code, message: message } };
+ };
+
+ /* ------------------------------------------------------------------ *
+ * Value validation
+ * ------------------------------------------------------------------ */
+
+ ContractCore.prototype._validateValue = function (descriptor, value) {
+ var kind = descriptor.kind;
+
+ if (kind === 'range') {
+ if (!isFiniteNumber(value)) {
+ return this._error('INVALID_VALUE', 'Range target requires a finite number.');
+ }
+ if (value < descriptor.min || value > descriptor.max) {
+ /* No silent clamping: an out-of-range value is rejected outright
+ * (Authoring Guide §T). */
+ return this._error(
+ 'INVALID_VALUE',
+ 'Value ' + value + ' is outside [' + descriptor.min + ', ' + descriptor.max + '].'
+ );
+ }
+ if (descriptor.step) {
+ var steps = (value - descriptor.min) / descriptor.step;
+ if (Math.abs(steps - Math.round(steps)) > 1e-9) {
+ return this._error(
+ 'INVALID_VALUE',
+ 'Value ' + value + ' is not on the declared step of ' + descriptor.step + '.'
+ );
+ }
+ }
+ return { ok: true, value: value };
+ }
+
+ if (kind === 'state') {
+ if (descriptor.valueType === 'boolean') {
+ if (typeof value !== 'boolean') {
+ return this._error('INVALID_VALUE', 'State target requires a boolean.');
+ }
+ return { ok: true, value: value };
+ }
+ if (descriptor.valueType === 'string') {
+ if (typeof value !== 'string') {
+ return this._error('INVALID_VALUE', 'State target requires a string.');
+ }
+ if (descriptor.maxLength && value.length > descriptor.maxLength) {
+ return this._error(
+ 'INVALID_VALUE',
+ 'String exceeds declared maxLength of ' + descriptor.maxLength + '.'
+ );
+ }
+ return { ok: true, value: value };
+ }
+ if (descriptor.valueType === 'number') {
+ if (!isFiniteNumber(value)) {
+ return this._error('INVALID_VALUE', 'State target requires a finite number.');
+ }
+ return { ok: true, value: value };
+ }
+ return this._error('INVALID_VALUE', 'Unsupported valueType.');
+ }
+
+ if (kind === 'selection') {
+ if (typeof value !== 'string') {
+ return this._error('INVALID_VALUE', 'Selection target requires a string value.');
+ }
+ for (var i = 0; i < descriptor.options.length; i++) {
+ if (descriptor.options[i].value === value) return { ok: true, value: value };
+ }
+ return this._error('INVALID_VALUE', 'Value "' + value + '" is not a declared option.');
+ }
+
+ return this._error('INVALID_VALUE', 'Target kind does not accept values.');
+ };
+
+ /* ------------------------------------------------------------------ *
+ * Canonical mutation path (Authoring Guide §H)
+ * ------------------------------------------------------------------ */
+
+ /**
+ * The single chokepoint for every externally visible persistent-state
+ * change, whatever its origin. One call is one mutation transaction:
+ * commit, then increment stateRevision at most once, then emit the
+ * resulting events carrying that revision (Contract §14).
+ *
+ * @param {string} targetId
+ * @param {*} value
+ * @param {string} source assigned by the caller's trusted boundary
+ * @param {object} [context] { correlationId }
+ */
+ ContractCore.prototype.applyMutation = function (targetId, value, source, context) {
+ context = context || {};
+ if (SOURCES.indexOf(source) === -1) {
+ return this._error('INTERNAL_ERROR', 'Unknown source: ' + source);
+ }
+
+ var descriptor = this.catalog.get(targetId);
+ if (!descriptor) {
+ return this._error('UNKNOWN_TARGET', 'The requested target is not registered.');
+ }
+ if (descriptor.kind === 'impulse') {
+ return this._error('TARGET_NOT_SETTABLE', 'Impulse targets are not settable.');
+ }
+ if (!descriptor.writable) {
+ return this._error('TARGET_READ_ONLY', 'The requested target is read-only.');
+ }
+ if (!this.capabilities.allUsable(descriptor.requires)) {
+ return this._error(
+ 'CAPABILITY_UNAVAILABLE',
+ 'A capability required by this target is not usable.'
+ );
+ }
+
+ var validated = this._validateValue(descriptor, value);
+ if (!validated.ok) return validated;
+
+ var setter = this.setters[targetId];
+ if (typeof setter !== 'function') {
+ return this._error('INTERNAL_ERROR', 'No setter is bound to this target.');
+ }
+
+ /* The setter is absolute and idempotent: it reports whether anything
+ * actually changed. A no-op must not touch stateRevision (Contract §14.3). */
+ var result = setter(validated.value) || {};
+ if (!result.changed) {
+ return { ok: true, revisionChanged: false, stateRevision: this.stateRevision };
+ }
+
+ this.stateRevision += 1;
+ var revision = this.stateRevision;
+
+ /* A coherent multi-value transaction reports every value it changed so
+ * all of them are emitted under the one shared revision. */
+ var changed = result.changedTargets && result.changedTargets.length
+ ? result.changedTargets
+ : [targetId];
+
+ for (var i = 0; i < changed.length; i++) {
+ var id = changed[i];
+ var d = this.catalog.get(id);
+ if (!d || !d.readable) continue;
+ var payload = {
+ stateRevision: revision,
+ target: id,
+ value: this.readValue(id),
+ source: source,
+ /* Always present, null when the change did not originate from a
+ * request. Emitting the key unconditionally keeps the event shape
+ * identical no matter which input path caused the change, so a host
+ * can rely on one schema (Authoring Guide §H). */
+ correlationId: context.correlationId || null
+ };
+ this._emit(d.kind === 'selection' ? 'selection.changed' : 'state.changed', payload);
+ }
+
+ return { ok: true, revisionChanged: true, stateRevision: revision };
+ };
+
+ /**
+ * The single chokepoint for every externally visible action.
+ * Impulses never change persistent state, so they never touch
+ * stateRevision (Contract §10.4, §14).
+ */
+ ContractCore.prototype.invokeAction = function (targetId, args, source, context) {
+ context = context || {};
+ if (SOURCES.indexOf(source) === -1) {
+ return this._error('INTERNAL_ERROR', 'Unknown source: ' + source);
+ }
+
+ var descriptor = this.catalog.get(targetId);
+ if (!descriptor) {
+ return this._error('UNKNOWN_TARGET', 'The requested target is not registered.');
+ }
+ if (descriptor.kind !== 'impulse') {
+ return this._error('TARGET_NOT_INVOKABLE', 'Only impulse targets are invokable.');
+ }
+ if (!this.capabilities.allUsable(descriptor.requires)) {
+ return this._error(
+ 'CAPABILITY_UNAVAILABLE',
+ 'A capability required by this target is not usable.'
+ );
+ }
+
+ /* Contextual availability: the target stays discoverable, but may be
+ * temporarily unusable because of exhibit state (Authoring Guide §N). */
+ var gate = this.availability[targetId];
+ if (typeof gate === 'function') {
+ var verdict = gate();
+ if (verdict && !verdict.ok) {
+ return this._error(verdict.code || 'CAPABILITY_UNAVAILABLE', verdict.message || 'Unavailable.');
+ }
+ }
+
+ var action = this.actions[targetId];
+ if (typeof action !== 'function') {
+ return this._error('INTERNAL_ERROR', 'No action is bound to this target.');
+ }
+
+ // Fixture-only correction for the owner's authoritative 5.2 clarification.
+ if (!isPlainObject(args)) return this._error('INVALID_VALUE', 'args must be an object.');
+ var declared = descriptor.arguments || [];
+ var names = declared.map(function (arg) { return arg.name; });
+ if (Object.keys(args).some(function (key) { return names.indexOf(key) === -1; })) {
+ return this._error('INVALID_VALUE', 'Undeclared argument.');
+ }
+ for (var a = 0; a < declared.length; a++) {
+ var spec = declared[a];
+ if (!Object.prototype.hasOwnProperty.call(args, spec.name)) {
+ if (spec.required) return this._error('INVALID_VALUE', 'Missing argument: ' + spec.name);
+ continue;
+ }
+ var value = args[spec.name];
+ var validType = spec.type === 'integer' ? Number.isSafeInteger(value)
+ : spec.type === 'number' ? isFiniteNumber(value) : typeof value === spec.type;
+ if (!validType || (spec.enum && spec.enum.indexOf(value) === -1)) {
+ return this._error('INVALID_VALUE', 'Invalid argument: ' + spec.name);
+ }
+ if (typeof value === 'number') {
+ var units = (value - (spec.min === undefined ? 0 : spec.min)) / spec.step;
+ if ((spec.min !== undefined && value < spec.min) || (spec.max !== undefined && value > spec.max)
+ || (spec.step !== undefined && Math.abs(units - Math.round(units)) > 1e-7)) {
+ return this._error('INVALID_VALUE', 'Argument outside numeric constraints.');
+ }
+ }
+ if (typeof value === 'string' && ((spec.minLength !== undefined && value.length < spec.minLength)
+ || (spec.maxLength !== undefined && value.length > spec.maxLength))) {
+ return this._error('INVALID_VALUE', 'Argument outside length constraints.');
+ }
+ }
+ var outcome = action(args);
+ if (!outcome || !outcome.ok) {
+ return this._error(
+ (outcome && outcome.code) || 'INTERNAL_ERROR',
+ (outcome && outcome.message) || 'The action could not be executed.'
+ );
+ }
+
+ var payload = {
+ target: targetId,
+ args: outcome.args || args || {},
+ source: source,
+ correlationId: context.correlationId || null
+ };
+ this._emit('action.executed', payload);
+
+ return { ok: true };
+ };
+
+ /* ------------------------------------------------------------------ *
+ * Reads
+ * ------------------------------------------------------------------ */
+
+ ContractCore.prototype.readValue = function (targetId) {
+ var reader = this.readers[targetId];
+ if (typeof reader === 'function') return reader();
+ return null;
+ };
+
+ /** Contract §13: only readable persistent targets belong in `values`. */
+ ContractCore.prototype.stateSnapshot = function () {
+ var values = {};
+ var ids = this.catalog.ids();
+ for (var i = 0; i < ids.length; i++) {
+ var d = this.catalog.get(ids[i]);
+ if (d.kind === 'impulse' || !d.readable) continue;
+ values[ids[i]] = this.readValue(ids[i]);
+ }
+ return { stateRevision: this.stateRevision, values: values };
+ };
+
+ ContractCore.prototype.describe = function () {
+ return {
+ exhibit: this.identity,
+ contract: { major: CONTRACT_MAJOR, minor: CONTRACT_MINOR },
+ registryRevision: this.registryRevision,
+ stateRevision: this.stateRevision,
+ capabilities: this.capabilities.snapshot(),
+ targets: this.catalog.descriptors()
+ };
+ };
+
+ /* ------------------------------------------------------------------ *
+ * Capability transitions
+ * ------------------------------------------------------------------ */
+
+ ContractCore.prototype.setCapabilityState = function (id, state) {
+ var previous = this.capabilities.stateOf(id);
+ if (previous === state) return false;
+ this.capabilities.declare(id, state);
+ this._emit('capability.changed', { capability: id, state: state, previousState: previous });
+ return true;
+ };
+
+ /* ------------------------------------------------------------------ *
+ * Session handling
+ * ------------------------------------------------------------------ */
+
+ ContractCore.prototype._newSessionId = function () {
+ this._sessionCounter += 1;
+ var rand = Math.floor(Math.random() * 0xffff).toString(16);
+ return 'sess-' + this._sessionCounter.toString(16) + rand;
+ };
+
+ /**
+ * Handshake (Contract §5). A new session resets the event sequence but
+ * deliberately leaves stateRevision alone.
+ */
+ ContractCore.prototype.handleHello = function (message) {
+ var majors = message.supportedContractMajors;
+ if (Array.isArray(majors) && majors.length && majors.indexOf(CONTRACT_MAJOR) === -1) {
+ return {
+ ok: false,
+ error: {
+ code: 'UNSUPPORTED_VERSION',
+ message: 'No compatible contract major. This exhibit implements major ' + CONTRACT_MAJOR + '.'
+ }
+ };
+ }
+
+ this.sessionId = this._newSessionId();
+ this.sequence = 0;
+ this._eventLog = [];
+
+ return {
+ ok: true,
+ result: {
+ sessionId: this.sessionId,
+ exhibit: this.identity,
+ contract: { major: CONTRACT_MAJOR, minor: CONTRACT_MINOR }
+ }
+ };
+ };
+
+ /* ------------------------------------------------------------------ *
+ * Request dispatch
+ * ------------------------------------------------------------------ */
+
+ /**
+ * Handle one already-parsed contract request. Transport-agnostic: the
+ * caller decides how the message arrived and what `source` that implies.
+ *
+ * @param {object} message
+ * @param {string} source the authoritative source for this arrival path
+ * @returns {object|null} response envelope, or null when the message is
+ * not addressed to this exhibit at all
+ */
+ ContractCore.prototype.handleRequest = function (message, source) {
+ if (!isPlainObject(message)) {
+ return this._errorEnvelope(null, null, 'INVALID_MESSAGE', 'Message must be an object.');
+ }
+ if (message.xzbt !== XZBT_VERSION) {
+ return this._errorEnvelope(
+ message.requestId || null,
+ message.sessionId || null,
+ 'UNSUPPORTED_VERSION',
+ 'This exhibit implements contract ' + XZBT_VERSION + '.'
+ );
+ }
+ if (typeof message.type !== 'string') {
+ return this._errorEnvelope(
+ message.requestId || null,
+ message.sessionId || null,
+ 'INVALID_MESSAGE',
+ 'Message type is required.'
+ );
+ }
+
+ var requestId = typeof message.requestId === 'string' ? message.requestId : null;
+
+ if (message.type === 'hello') {
+ var hello = this.handleHello(message);
+ if (!hello.ok) {
+ return this._errorEnvelope(requestId, null, hello.error.code, hello.error.message);
+ }
+ return this._okEnvelope('hello.result', requestId, hello.result);
+ }
+
+ /* Every other request must carry the session issued by this exhibit. */
+ if (typeof message.sessionId !== 'string' || message.sessionId !== this.sessionId) {
+ return this._errorEnvelope(
+ requestId,
+ typeof message.sessionId === 'string' ? message.sessionId : null,
+ 'INVALID_SESSION',
+ 'A valid sessionId issued by this exhibit is required.'
+ );
+ }
+
+ switch (message.type) {
+ case 'describe':
+ return this._okEnvelope('describe.result', requestId, this.describe());
+
+ case 'state.get':
+ return this._okEnvelope('state.result', requestId, this.stateSnapshot());
+
+ case 'set': {
+ if (typeof message.target !== 'string') {
+ return this._errorEnvelope(requestId, this.sessionId, 'INVALID_MESSAGE', 'set requires a target.');
+ }
+ var setResult = this.applyMutation(message.target, message.value, source, {
+ correlationId: requestId
+ });
+ if (!setResult.ok) {
+ return this._errorEnvelope(
+ requestId, this.sessionId, setResult.error.code, setResult.error.message
+ );
+ }
+ return this._okEnvelope('set.result', requestId, {
+ stateRevision: setResult.stateRevision,
+ changed: !!setResult.revisionChanged
+ });
+ }
+
+ case 'invoke': {
+ if (typeof message.target !== 'string') {
+ return this._errorEnvelope(requestId, this.sessionId, 'INVALID_MESSAGE', 'invoke requires a target.');
+ }
+ var invokeResult = this.invokeAction(message.target, message.args, source, {
+ correlationId: requestId
+ });
+ if (!invokeResult.ok) {
+ return this._errorEnvelope(
+ requestId, this.sessionId, invokeResult.error.code, invokeResult.error.message
+ );
+ }
+ return this._okEnvelope('invoke.result', requestId, {});
+ }
+
+ default:
+ return this._errorEnvelope(
+ requestId, this.sessionId, 'INVALID_MESSAGE', 'Unsupported message type: ' + message.type
+ );
+ }
+ };
+
+ ContractCore.prototype._okEnvelope = function (type, requestId, payload) {
+ var envelope = {
+ xzbt: XZBT_VERSION,
+ type: type,
+ requestId: requestId,
+ sessionId: this.sessionId,
+ ok: true
+ };
+ for (var k in payload) {
+ if (Object.prototype.hasOwnProperty.call(payload, k)) envelope[k] = payload[k];
+ }
+ return envelope;
+ };
+
+ ContractCore.prototype._errorEnvelope = function (requestId, sessionId, code, message) {
+ return {
+ xzbt: XZBT_VERSION,
+ type: 'error',
+ requestId: requestId,
+ sessionId: sessionId,
+ ok: false,
+ error: { code: code, message: message }
+ };
+ };
+
+ /* ------------------------------------------------------------------ *
+ * Exports
+ * ------------------------------------------------------------------ */
+
+ window.XZBTContractCore = {
+ VERSION: XZBT_VERSION,
+ CONTRACT_MAJOR: CONTRACT_MAJOR,
+ CONTRACT_MINOR: CONTRACT_MINOR,
+ SOURCES: SOURCES,
+ ERROR_CODES: ERROR_CODES,
+ CAPABILITY_STATES: CAPABILITY_STATES,
+ EVENT_TYPES: EVENT_TYPES,
+ TARGET_ID_PATTERN: TARGET_ID_PATTERN,
+ Catalog: Catalog,
+ CapabilityRegistry: CapabilityRegistry,
+ ContractCore: ContractCore
+ };
+})();
diff --git a/test-fixtures/reference-exhibits/shared/exhibit-shell.css b/test-fixtures/reference-exhibits/shared/exhibit-shell.css
new file mode 100644
index 0000000..fcf7aae
--- /dev/null
+++ b/test-fixtures/reference-exhibits/shared/exhibit-shell.css
@@ -0,0 +1,234 @@
+/*
+ * Shared presentation shell for the reference exhibits.
+ *
+ * This is deliberately generic chrome only: page frame, panel layout,
+ * control primitives, and the announcement strip. Every domain-specific
+ * colour, texture, and scene style lives in the exhibit's own style.css.
+ * Nothing here knows what an exhibit is about.
+ */
+
+* { box-sizing: border-box; }
+
+html, body {
+ margin: 0;
+ padding: 0;
+ height: 100%;
+}
+
+body {
+ font-family: "Segoe UI", Tahoma, Verdana, sans-serif;
+ font-size: 14px;
+ line-height: 1.45;
+ background: #10131a;
+ color: #e8ecf2;
+ overflow: hidden;
+}
+
+.exhibit {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) 320px;
+ grid-template-rows: auto minmax(0, 1fr) auto;
+ grid-template-areas:
+ "header header"
+ "stage panel"
+ "footer footer";
+ height: 100vh;
+ gap: 10px;
+ padding: 10px;
+}
+
+.exhibit-header {
+ grid-area: header;
+ display: flex;
+ align-items: baseline;
+ gap: 12px;
+ padding: 8px 14px;
+ border-radius: 6px;
+ background: rgba(255, 255, 255, 0.04);
+ border: 1px solid rgba(255, 255, 255, 0.08);
+}
+
+.exhibit-header h1 {
+ margin: 0;
+ font-size: 17px;
+ font-weight: 600;
+ letter-spacing: 0.04em;
+}
+
+.exhibit-header .subtitle {
+ font-size: 12px;
+ opacity: 0.6;
+}
+
+.exhibit-header .status {
+ margin-left: auto;
+ font-size: 11px;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+ opacity: 0.55;
+}
+
+.stage {
+ grid-area: stage;
+ position: relative;
+ min-height: 0;
+ border-radius: 6px;
+ overflow: hidden;
+ border: 1px solid rgba(255, 255, 255, 0.08);
+}
+
+.stage canvas {
+ display: block;
+ width: 100%;
+ height: 100%;
+}
+
+.panel {
+ grid-area: panel;
+ min-height: 0;
+ overflow-y: auto;
+ padding: 12px;
+ border-radius: 6px;
+ background: rgba(255, 255, 255, 0.04);
+ border: 1px solid rgba(255, 255, 255, 0.08);
+}
+
+.panel h2 {
+ margin: 0 0 8px;
+ font-size: 11px;
+ font-weight: 600;
+ letter-spacing: 0.12em;
+ text-transform: uppercase;
+ opacity: 0.55;
+}
+
+.panel section + section {
+ margin-top: 16px;
+ padding-top: 14px;
+ border-top: 1px solid rgba(255, 255, 255, 0.07);
+}
+
+.control {
+ margin-bottom: 12px;
+}
+
+.control:last-child { margin-bottom: 0; }
+
+.control label {
+ display: flex;
+ justify-content: space-between;
+ align-items: baseline;
+ gap: 8px;
+ font-size: 12px;
+ margin-bottom: 5px;
+ opacity: 0.85;
+}
+
+.control .readout {
+ font-variant-numeric: tabular-nums;
+ font-size: 11px;
+ opacity: 0.7;
+}
+
+input[type="range"] {
+ width: 100%;
+ accent-color: currentColor;
+}
+
+select {
+ width: 100%;
+ padding: 5px 6px;
+ font: inherit;
+ font-size: 12px;
+ color: inherit;
+ background: rgba(0, 0, 0, 0.35);
+ border: 1px solid rgba(255, 255, 255, 0.18);
+ border-radius: 4px;
+}
+
+button {
+ font: inherit;
+ font-size: 12px;
+ padding: 6px 10px;
+ color: inherit;
+ background: rgba(255, 255, 255, 0.07);
+ border: 1px solid rgba(255, 255, 255, 0.18);
+ border-radius: 4px;
+ cursor: pointer;
+}
+
+button:hover { background: rgba(255, 255, 255, 0.14); }
+button:active { transform: translateY(1px); }
+
+button[aria-pressed="true"] {
+ background: rgba(255, 255, 255, 0.22);
+ border-color: rgba(255, 255, 255, 0.4);
+}
+
+.button-row {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+}
+
+.button-row button { flex: 1 1 auto; }
+
+.toggle-row {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 8px;
+ margin-bottom: 8px;
+ font-size: 12px;
+}
+
+.toggle-row:last-child { margin-bottom: 0; }
+
+.exhibit-footer {
+ grid-area: footer;
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 8px 14px;
+ border-radius: 6px;
+ background: rgba(255, 255, 255, 0.04);
+ border: 1px solid rgba(255, 255, 255, 0.08);
+ font-size: 12px;
+ min-height: 38px;
+}
+
+.exhibit-footer .announcement-label {
+ font-size: 10px;
+ letter-spacing: 0.12em;
+ text-transform: uppercase;
+ opacity: 0.45;
+ flex: 0 0 auto;
+}
+
+/* Announcement text is written with textContent only — never innerHTML. */
+.exhibit-footer .announcement-text {
+ flex: 1 1 auto;
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ opacity: 0.9;
+}
+
+.exhibit-footer .announcement-text.is-idle { opacity: 0.4; font-style: italic; }
+
+@media (max-width: 900px) {
+ .exhibit {
+ grid-template-columns: minmax(0, 1fr);
+ grid-template-rows: auto minmax(0, 1fr) auto auto;
+ grid-template-areas:
+ "header"
+ "stage"
+ "panel"
+ "footer";
+ height: auto;
+ min-height: 100vh;
+ }
+ .stage { min-height: 320px; }
+ body { overflow: auto; }
+}
diff --git a/test-fixtures/reference-exhibits/shared/exhibit-shell.js b/test-fixtures/reference-exhibits/shared/exhibit-shell.js
new file mode 100644
index 0000000..649ffae
--- /dev/null
+++ b/test-fixtures/reference-exhibits/shared/exhibit-shell.js
@@ -0,0 +1,94 @@
+/*
+ * Shared presentation helpers for the reference exhibits.
+ *
+ * Generic DOM plumbing only: element lookup, safe text writing, and the
+ * announcement strip. It has no contract awareness and no domain knowledge,
+ * so an exhibit can use it, ignore it, or replace it without touching the
+ * contract layer.
+ *
+ * The one rule worth stating out loud: every piece of text that reaches the
+ * DOM goes through `textContent`. Nothing in this project ever assigns
+ * `innerHTML`, so a string arriving from a host or a scenario can never
+ * become markup or script (Contract §20, Authoring Guide §O).
+ */
+(function () {
+ 'use strict';
+
+ function el(id) {
+ return document.getElementById(id);
+ }
+
+ /** Write text safely. Always textContent, never innerHTML. */
+ function setText(node, text) {
+ if (!node) return;
+ node.textContent = text === null || text === undefined ? '' : String(text);
+ }
+
+ /**
+ * Announcement strip. Text is data: it is truncated to the declared
+ * maximum and written with textContent.
+ */
+ function Announcer(options) {
+ this.node = options.node;
+ this.idleText = options.idleText || '';
+ this.maxLength = options.maxLength || 120;
+ this._timer = null;
+ this.setText(this.idleText, true);
+ }
+
+ Announcer.prototype.setText = function (text, idle) {
+ if (!this.node) return;
+ var value = text === null || text === undefined ? '' : String(text);
+ if (value.length > this.maxLength) value = value.slice(0, this.maxLength);
+ this.node.textContent = value;
+ if (idle) {
+ this.node.classList.add('is-idle');
+ } else {
+ this.node.classList.remove('is-idle');
+ }
+ };
+
+ /** Show a transient message, then fall back to the idle text. */
+ Announcer.prototype.flash = function (text, ms) {
+ this.setText(text, false);
+ if (this._timer) clearTimeout(this._timer);
+ var self = this;
+ this._timer = setTimeout(function () {
+ self.setText(self.idleText, true);
+ }, ms || 2600);
+ };
+
+ /** Format a number for a readout, with a fixed number of decimals. */
+ function formatNumber(value, decimals) {
+ if (typeof value !== 'number' || !isFinite(value)) return '--';
+ return value.toFixed(decimals === undefined ? 2 : decimals);
+ }
+
+ /** Format a 0..1 value as a whole percentage. */
+ function formatPercent(value) {
+ if (typeof value !== 'number' || !isFinite(value)) return '--';
+ return Math.round(value * 100) + '%';
+ }
+
+ /** Format a 0..1 value as a whole number of degrees. */
+ function formatDegrees(value) {
+ if (typeof value !== 'number' || !isFinite(value)) return '--';
+ return Math.round(value * 360) + '\u00b0';
+ }
+
+ /** Format a 0..1 value as a whole number of minutes. */
+ function formatMinutes(value) {
+ if (typeof value !== 'number' || !isFinite(value)) return '--';
+ return Math.round(value * 60) + ' min';
+ }
+
+ window.XZBTShell = {
+ el: el,
+ setText: setText,
+ Announcer: Announcer,
+ formatNumber: formatNumber,
+ formatPercent: formatPercent,
+ formatDegrees: formatDegrees,
+ formatMinutes: formatMinutes
+ };
+})();
diff --git a/test-fixtures/reference-exhibits/shared/host-transport.js b/test-fixtures/reference-exhibits/shared/host-transport.js
new file mode 100644
index 0000000..f1baeef
--- /dev/null
+++ b/test-fixtures/reference-exhibits/shared/host-transport.js
@@ -0,0 +1,85 @@
+/*
+ * XZBT Exhibit Contract 5.2 — same-origin postMessage host transport.
+ *
+ * This is layer 6 of the Authoring Guide's recommended separation, and it is
+ * deliberately the thinnest file in the project. It knows how to move
+ * contract messages across one channel and nothing else: no exhibit
+ * semantics, no target knowledge, no state.
+ *
+ * It is also entirely optional. An exhibit that never receives a `hello`
+ * behaves exactly as it would with this file deleted — that is the
+ * standalone-first rule (Contract §2, Authoring Guide §B).
+ *
+ * Security (Contract §25): both `event.origin` and `event.source` are
+ * validated on every message. The transport also assigns `source = 'host'`
+ * itself; a `source` field inside an incoming message is ignored, never
+ * trusted (Contract §15).
+ */
+(function () {
+ 'use strict';
+
+ /**
+ * @param {object} options
+ * @param {object} options.core an XZBTContractCore.ContractCore
+ * @param {string} [options.origin] expected host origin; defaults to the
+ * document's own origin (same-origin)
+ * @param {function} [options.onMessage] diagnostics hook
+ */
+ function HostTransport(options) {
+ this.core = options.core;
+ this.expectedOrigin = options.origin || window.location.origin;
+ this.onMessage = options.onMessage || function () {};
+ this.connected = false;
+ this._bound = this._onMessage.bind(this);
+
+ /* Events are pushed, not polled. The core already emits every state
+ * change, action, and capability transition through its onEvent hook;
+ * the transport's job is to forward them to the host. Without this the
+ * host would see responses but never learn that anything changed
+ * (Contract §16). */
+ var self = this;
+ var coreOnEvent = this.core.onEvent;
+ this.core.onEvent = function (event) {
+ if (typeof coreOnEvent === 'function') coreOnEvent(event);
+ if (self.connected) self.send(event);
+ };
+
+ window.addEventListener('message', this._bound);
+ }
+
+ HostTransport.prototype._isTrusted = function (event) {
+ /* Same-origin only. `file://` documents report origin "null", so a
+ * file-opened exhibit simply never accepts host traffic — which is the
+ * correct standalone behaviour, not a failure. */
+ if (event.origin !== this.expectedOrigin) return false;
+ if (event.source !== window.parent) return false;
+ return true;
+ };
+
+ HostTransport.prototype._onMessage = function (event) {
+ if (!this._isTrusted(event)) return;
+
+ var message = event.data;
+ if (!message || typeof message !== 'object') return;
+ if (message.xzbt !== window.XZBTContractCore.VERSION) return;
+
+ /* Source is assigned here, at the trusted receiving boundary. */
+ var response = this.core.handleRequest(message, 'host');
+ if (!response) return;
+
+ if (response.type === 'hello.result') this.connected = true;
+ this.onMessage(message, response);
+ this.send(response);
+ };
+
+ HostTransport.prototype.send = function (message) {
+ if (window.parent === window) return;
+ window.parent.postMessage(message, this.expectedOrigin);
+ };
+
+ HostTransport.prototype.destroy = function () {
+ window.removeEventListener('message', this._bound);
+ };
+
+ window.XZBTHostTransport = HostTransport;
+})();
diff --git a/tests/host.test.js b/tests/host.test.js
new file mode 100644
index 0000000..113fa59
--- /dev/null
+++ b/tests/host.test.js
@@ -0,0 +1,188 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { ExhibitHost } from '../src/host.js';
+import { validateArgs, argumentSchema } from '../src/validation.js';
+import { postMessageTransport } from '../src/transport/post-message.js';
+import { createServer } from '../server/serve.js';
+
+const descriptors = [
+ { id: 'sample.enabled', kind: 'state', valueType: 'boolean' },
+ { id: 'sample.level', kind: 'range', min: 0, max: 10, step: .5 },
+ { id: 'sample.mode', kind: 'selection', options: [{ value: 'a' }, { value: 'b' }] },
+ { id: 'sample.fire', kind: 'impulse', arguments: [{ name: 'message', type: 'string', required: true, maxLength: 8 }] }
+].map(d => ({ readable: d.kind !== 'impulse', writable: d.kind !== 'impulse', requires: [], ...d }));
+function peer() {
+ let receive, session = 0, sequence = 0, revision = 0;
+ const values = { 'sample.enabled': false, 'sample.level': 1, 'sample.mode': 'a' };
+ const requests = [];
+ const fixture = {
+ fail: null, drop: false, held: null, holdState: false, targets: structuredClone(descriptors),
+ caps: [{ id: 'service', state: 'ready' }],
+ transport: { subscribe(fn) { receive = fn; return () => {}; }, close() {}, send(m) {
+ requests.push(m);
+ if (fixture.drop) return;
+ const out = { xzbt: '5.2', requestId: m.requestId, sessionId: `s${session}` };
+ if (fixture.fail) { receive({ ...out, type: 'error', error: { code: fixture.fail, message: 'Fixture rejection' } }); return; }
+ if (m.type === 'hello') { session++; sequence = 0; receive({ ...out, type: 'hello.result', sessionId: `s${session}`, contract: { major: 5, minor: 2 }, exhibit: { product: 'Synthetic test peer' } }); }
+ else if (m.type === 'describe') receive({ ...out, type: 'describe.result', exhibit: { product: 'Synthetic test peer' }, contract: { major: 5, minor: 2 }, targets: structuredClone(fixture.targets), capabilities: structuredClone(fixture.caps), registryRevision: 1, stateRevision: revision });
+ else if (m.type === 'state.get') {
+ const snapshot = { ...out, type: 'state.result', stateRevision: revision, values: { ...values } };
+ if (fixture.holdState) fixture.held = () => receive(snapshot); else receive(snapshot);
+ } else {
+ if (m.type === 'set' && values[m.target] !== m.value) { values[m.target] = m.value; revision++; fixture.event(m.target === 'sample.mode' ? 'selection.changed' : 'state.changed', { target: m.target, value: m.value, stateRevision: revision }); }
+ if (m.type === 'invoke') fixture.event('action.executed', { target: m.target, args: m.args });
+ receive({ ...out, type: `${m.type}.result`, ok: true });
+ }
+ } },
+ event(type, payload = {}) { receive({ xzbt: '5.2', type, sessionId: `s${session}`, sequence: ++sequence, timestamp: Date.now(), source: 'ui', ...payload }); },
+ requests, values, receive(m) { receive(m); }
+ };
+ return fixture;
+}
+async function connected(t) {
+ const fixture = peer(); const host = new ExhibitHost({ debounceMs: 5, timeoutMs: 100 });
+ t.after(() => host.disconnect()); await host.connect(fixture.transport); return { host, fixture };
+}
+const settle = () => new Promise(resolve => setTimeout(resolve, 25));
+
+test('negotiates, discovers and reads real session fields', async t => {
+ const { host, fixture } = await connected(t);
+ assert.equal(host.sessionId, 's1'); assert.equal(host.catalog.length, 4); assert.equal(host.values.get('sample.level'), 1);
+ assert.deepEqual(fixture.requests.map(r => r.type), ['hello', 'describe', 'state.get']);
+ assert.equal(fixture.requests[0].sessionId, undefined); assert.equal(fixture.requests[1].sessionId, 's1');
+});
+test('sets all persistent kinds, invokes arguments, preserves no-op revisions', async t => {
+ const { host } = await connected(t);
+ await host.set('sample.enabled', true); await host.set('sample.level', 2.5); await host.set('sample.mode', 'b');
+ assert.equal(host.stateRevision, 3); await host.set('sample.mode', 'b'); assert.equal(host.stateRevision, 3);
+ await host.invoke('sample.fire', { message: 'hello' });
+ assert.equal(host.sequence, 4); assert.equal(host.eventLog.at(-1).type, 'action.executed');
+});
+test('invalid values and required/unknown arguments never reach transport', async t => {
+ const { host, fixture } = await connected(t); const before = fixture.requests.length;
+ for (const [id, value] of [['sample.level', 11], ['sample.level', 1.1], ['sample.mode', 'c'], ['sample.enabled', 'true']]) {
+ await assert.rejects(host.set(id, value), { code: 'INVALID_VALUE' });
+ }
+ await assert.rejects(host.invoke('sample.fire', {}), { code: 'INVALID_VALUE' });
+ await assert.rejects(host.invoke('sample.fire', { message: 'hi', extra: true }), { code: 'INVALID_VALUE' });
+ assert.equal(fixture.requests.length, before); assert.equal(host.logs.at(-1).code, 'INVALID_VALUE');
+});
+test('argument type/enum/bounds/step/length rules and unsupported future types', () => {
+ const target = { arguments: [
+ { name: 'text', type: 'string', required: true, minLength: 2, maxLength: 3 },
+ { name: 'count', type: 'integer', required: true, min: 0, max: 4, step: 2 },
+ { name: 'flag', type: 'boolean', required: false },
+ { name: 'choice', type: 'number', required: false, enum: [1, 2] }
+ ] };
+ validateArgs(target, { text: 'ok', count: 2, flag: false, choice: 1 });
+ for (const args of [{ text: 'x', count: 2 }, { text: 'long', count: 2 }, { text: 'ok', count: 3 }, { text: 'ok', count: 2, flag: 0 }, { text: 'ok', count: 2, choice: 3 }]) assert.throws(() => validateArgs(target, args), { code: 'INVALID_VALUE' });
+ validateArgs({}, {}); assert.throws(() => validateArgs({}, []), { code: 'INVALID_VALUE' });
+ assert.throws(() => argumentSchema({ arguments: [{ name: 'future', type: 'matrix', required: true }] }), { code: 'UNSUPPORTED_SCHEMA' });
+});
+test('surfaces exhibit errors and invalid session requires reconnect', async t => {
+ const { host, fixture } = await connected(t);
+ fixture.fail = 'CAPABILITY_UNAVAILABLE'; await assert.rejects(host.invoke('sample.fire', { message: 'go' }), { code: fixture.fail });
+ fixture.fail = 'INVALID_SESSION'; await assert.rejects(host.set('sample.level', 2), { code: fixture.fail });
+ assert.equal(host.status, 'invalid session'); fixture.fail = null;
+ await host.connect(fixture.transport); assert.equal(host.sessionId, 's2'); assert.equal(host.sequence, null);
+});
+test('unsupported version and timeout are explicit failures', async () => {
+ const fixture = peer(); const host = new ExhibitHost({ timeoutMs: 10 });
+ fixture.fail = 'UNSUPPORTED_VERSION'; await assert.rejects(host.connect(fixture.transport), { code: 'UNSUPPORTED_VERSION' });
+ fixture.fail = null; fixture.drop = true; await assert.rejects(host.connect(fixture.transport), { code: 'TIMEOUT' }); host.disconnect();
+});
+test('native events update cache; shared revision is not a gap', async t => {
+ const { host, fixture } = await connected(t);
+ fixture.event('state.changed', { target: 'sample.level', value: 3, stateRevision: 1 });
+ fixture.event('selection.changed', { target: 'sample.mode', value: 'b', stateRevision: 1 });
+ assert.equal(host.values.get('sample.level'), 3); assert.equal(host.values.get('sample.mode'), 'b');
+ assert.ok(!host.logs.some(l => l.code === 'REVISION_GAP'));
+});
+test('sequence and revision gaps resync and remain visible', async t => {
+ const { host, fixture } = await connected(t);
+ fixture.event('action.executed', { target: 'sample.fire' });
+ fixture.event('state.changed', { sequence: 4, stateRevision: 3, target: 'sample.level', value: 3 });
+ await settle(); assert.ok(host.logs.some(l => l.code === 'SEQUENCE_GAP')); assert.ok(host.logs.some(l => l.code === 'REVISION_GAP'));
+ assert.equal(fixture.requests.filter(r => r.type === 'state.get').length, 2);
+});
+test('capability and registry changes debounce discovery; normal state does not', async t => {
+ const { host, fixture } = await connected(t);
+ fixture.caps[0].state = 'error';
+ fixture.event('registry.changed'); fixture.event('registry.changed'); fixture.event('capability.changed');
+ await settle(); assert.equal(fixture.requests.filter(r => r.type === 'describe').length, 2);
+ assert.equal(host.capabilities[0].state, 'error');
+ fixture.event('state.changed', { target: 'sample.enabled', value: true, stateRevision: 1 });
+ await settle(); assert.equal(fixture.requests.filter(r => r.type === 'describe').length, 2);
+});
+test('events arriving during snapshot are replayed without lost updates', async t => {
+ const { host, fixture } = await connected(t); fixture.holdState = true;
+ const refresh = host.refresh();
+ fixture.event('state.changed', { target: 'sample.level', value: 7, stateRevision: 1 });
+ fixture.held(); await refresh; assert.equal(host.values.get('sample.level'), 7); assert.equal(host.stateRevision, 1);
+});
+test('old session traffic does not mutate new cache', async t => {
+ const { host, fixture } = await connected(t); await host.connect(fixture.transport);
+ fixture.receive({ xzbt: '5.2', type: 'state.changed', sessionId: 's1', sequence: 2, timestamp: 1, target: 'sample.level', value: 9, stateRevision: 2 });
+ assert.equal(host.values.get('sample.level'), 1); assert.equal(host.sequence, null);
+});
+test('malformed persistent events trigger recovery', async t => {
+ const { host, fixture } = await connected(t); fixture.event('state.changed', { target: 'sample.fire', value: true, stateRevision: 1 });
+ await settle(); assert.ok(host.logs.some(l => l.code === 'INVALID_MESSAGE')); assert.equal(host.values.has('sample.fire'), false);
+});
+test('postMessage verifies origin and source and cleans up', () => {
+ let callback, removed = false; const sent = [];
+ const peerWindow = { postMessage: (...args) => sent.push(args) };
+ const own = { location: { origin: 'http://localhost', href: 'http://localhost/' }, addEventListener(type, fn) { callback = fn; }, removeEventListener() { removed = true; } };
+ const transport = postMessageTransport({ src: 'http://localhost/exhibit', contentWindow: peerWindow }, own);
+ const received = []; transport.subscribe(m => received.push(m));
+ callback({ origin: 'http://evil', source: peerWindow, data: 1 }); callback({ origin: 'http://localhost', source: {}, data: 2 });
+ callback({ origin: 'http://localhost', source: peerWindow, data: 3 }); assert.deepEqual(received, [3]);
+ transport.send({ hello: true }); assert.equal(sent[0][1], 'http://localhost'); transport.close(); assert.ok(removed);
+});
+test('local server serves host and denies repository/private paths', async t => {
+ const server = createServer(); await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); t.after(() => server.close());
+ const base = `http://127.0.0.1:${server.address().port}`;
+ const home = await fetch(base); assert.equal(home.status, 200); assert.match(await home.text(), /XZBT-NGN Exhibit Engine/);
+ for (const route of ['/AGENTS.md', '/.git/config', '/src/..%5cAGENTS.md']) assert.equal((await fetch(base + route)).status, 404);
+});
+
+test('invalid reported types do not contaminate the cache', async t => {
+ const { host, fixture } = await connected(t);
+ fixture.event('state.changed', { target: 'sample.level', value: null, stateRevision: 1 });
+ assert.equal(host.values.get('sample.level'), 1);
+ assert.ok(host.logs.some(l => l.code === 'INVALID_MESSAGE'));
+ await settle(); assert.equal(host.sync, 'synchronized');
+});
+test('invalid snapshot values preserve previous cache and mark uncertainty', async t => {
+ const { host, fixture } = await connected(t);
+ fixture.values['sample.level'] = null;
+ await assert.rejects(host.refresh(), { code: 'INVALID_MESSAGE' });
+ assert.equal(host.values.get('sample.level'), 1); assert.equal(host.sync, 'uncertain');
+});
+test('snapshot revision cannot roll back a previously observed transaction', async t => {
+ const { host, fixture } = await connected(t);
+ fixture.event('state.changed', { target: 'sample.level', value: 4, stateRevision: 1 });
+ await assert.rejects(host.refresh(), /revision regressed/);
+ assert.equal(host.stateRevision, 1); assert.equal(host.values.get('sample.level'), 4);
+});
+test('registry rediscovery adds descriptors and removes obsolete cached targets', async t => {
+ const { host, fixture } = await connected(t);
+ fixture.targets = fixture.targets.filter(d => d.id !== 'sample.mode'); delete fixture.values['sample.mode'];
+ fixture.targets.push({ id: 'unrelated.reading', kind: 'state', valueType: 'number', readable: true, writable: false, requires: [] });
+ fixture.values['unrelated.reading'] = 6;
+ fixture.event('registry.changed'); await settle();
+ assert.equal(host.catalog.length, 4); assert.equal(host.values.has('sample.mode'), false);
+ assert.equal(host.values.get('unrelated.reading'), 6);
+});
+test('unsupported future impulse arguments do not hide the catalog', async t => {
+ const { host, fixture } = await connected(t);
+ fixture.targets.at(-1).arguments = [{ name: 'future', type: 'matrix', required: true }];
+ await host.refresh(true); assert.equal(host.catalog.length, 4);
+ await assert.rejects(host.invoke('sample.fire', { future: [] }), { code: 'UNSUPPORTED_SCHEMA' });
+});
+test('capability requirements block unusable services without deleting targets', async t => {
+ const { host, fixture } = await connected(t);
+ fixture.targets.at(-1).requires = ['service']; fixture.caps[0].state = 'error';
+ await host.refresh(true); assert.equal(host.catalog.length, 4);
+ await assert.rejects(host.invoke('sample.fire', { message: 'hello' }), { code: 'CAPABILITY_UNAVAILABLE' });
+});