From 24ed85d9e3e9c8e6670700f8b6208cc0d1405a6f Mon Sep 17 00:00:00 2001 From: "pullfrog[bot]" <226033991+pullfrog[bot]@users.noreply.github.com> Date: Fri, 9 Oct 2026 01:59:33 +0000 Subject: [PATCH] core(docs): define required-vs-optional host capability-check convention MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sub-issue 2/5 of #102: document the plugin-side convention for probing host APIs — feature detection at the call site, one predicate per host API (exact-surface typeof/version/import/hasattr/data-shape checks, never version strings), a single per-session warning naming the plugin, the feature and the missing requirement (API + matrix commit), and the rule that a missing optional API disables only its feature — never the plugin, never its basic-mode floor. Docs-only: the companion docs/compatibility.md matrix (#131) already pins the 'which commit' side; this is the 'how to probe' side. Covers all seven matrix rows with probe + per-row degradation contract (dlc_paths, read_member_bytes, chart-transform, /ws/sync relay, setExternalFrameDriver/renderFrame/renderFrameAt, playerContexts, getPhrases().top_difficulty), a canonical JS helper, the routes.py/context[log] backend equivalent, and a plugin-PR checklist. minHost metapassthrough limits and capability-declaration-as-not-a-probe are explicitly called out. Accepted scope exclusions: no new core API (a helper only new hosts ship cannot serve a degrade-on-old-host convention), no minHost changes (sub-issue 3/5). --- CHANGELOG.md | 1 + CLAUDE.md | 11 ++ README.md | 4 +- docs/plugin-host-compat.md | 293 +++++++++++++++++++++++++++++++++++++ 4 files changed, 308 insertions(+), 1 deletion(-) create mode 100644 docs/plugin-host-compat.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 01063cb1..55ff9113 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Practice difficulty override capability.** New core-owned `practice-difficulty` domain (#136) lets a practice plugin play part of a chart at a different difficulty than the rest of the song — a slowed verse, an isolated tricky bar — via `activate` / `clear` against the player's current context. The override is a half-open `[start_time, end_time)` window installed on that player's highway, with `difficulty_pct` clamped to 0–100; it is complementary to the song-wide `player-difficulty.v1` slider and deliberately never rewrites it, so `getMastery()` keeps reporting the slider while the new `getEffectiveMastery(time)` / `getDifficultyOverride()` (and `bundle.difficultyOverride` for renderers) expose the value actually in force. One override is active per player context and conflicts resolve incumbent-wins, so competing practice surfaces can't stack or fight. State is released automatically on clear, explicit unregister, song replacement, and context replacement/departure; diagnostics are count-only and redact all identity. - **3D Highway: general per-panel zoom.** A new `cameraZoom` setting (Zoom In ↔ Out, 0.5 = unchanged) scales the camera distance in every camera mode and appears first in Split Screen's Viz ⚙ popover. Previously the only zoom control was "Locked zoom", which does nothing unless "Lock camera at frets 1–12" is on and the visible notes are at or below fret 12, so a small split panel could not be brought closer in the normal auto-framed view. Default is neutral; existing layouts are unchanged. `window.h3dBgSetCameraZoom(v)` sets the global value. - **Host compatibility matrix (`docs/compatibility.md`).** A maintained table of the host APIs current plugins depend on — `dlc_paths._resolve_dlc_path`, `sloppak.read_member_bytes`, the `chart-transform` capability, the `/ws/sync/{session_id}` relay, `renderFrameAt`/`renderFrame`/`setExternalFrameDriver`, `playerContexts`/`player-difficulty.v1`, and phrase-tier `getPhrases().top_difficulty` — each row pinned to an exact commit, the plugins and features that need it, whether the need is required or optional, and whether the commit is upstream or fork-only. Written because the version string cannot express any of it: `VERSION` has read `0.3.0-alpha.2` since 2026-08-10 across 83 commits, so a version comparison cannot separate a build that has `renderFrameAt` from one that does not. Documentation only — no plugin loading, capability, or `minHost` behaviour changes. Every row is source-audited, not runtime-certified; the document says so, and records the host and plugin revisions it was verified against (#131). +- **Host compatibility: required-vs-optional capability-check convention (`docs/plugin-host-compat.md`).** Follows the compatibility matrix (#131) with *how* to probe each row at runtime instead of *which* commit provides it: one predicate per host API at the call site (exact-surface checks — `typeof`/`version`/import/`hasattr`/data-shape — never version strings), a single per-session naming warning when an optional API is missing, and the rule that a missing optional API disables only its feature — never the plugin, never its basic-mode floor. Includes the canonical probe pattern plus the degradation contract for every matrix row (`dlc_paths`, `read_member_bytes`, `chart-transform`, the `/ws/sync` relay, `setExternalFrameDriver`/`renderFrame`/`renderFrameAt`, `playerContexts`, `getPhrases().top_difficulty`), the backend (`routes.py` + `context["log"]`) equivalent, and a checklist for plugin PRs. Documentation only — no plugin loading, capability, or `minHost` behaviour changes (#132). - **Keys Highway 3D: per-panel hand filter.** The plugin declares a `handFilter` setting (Both / LH / RH) in `capabilities.visualization.settings`, and each renderer instance implements `applySetting` / `getSetting`. Split Screen can now show a different hand in each panel (feedBack-plugin-splitscreen#66). An instance with no override follows the global Settings value as before. Re-saving the *same* global hand value no longer rebuilds the notes or resets the current score mid-song. - **Player identity capability for concurrent local play.** The Host now owns stable session/player contexts, profile readiness, redacted diagnostics, lifecycle events, private highway bindings, and player-scoped difficulty routing for Split Screen, karaoke, Note Detection, and Difficulty Ladder integrations. - **Coordinated deterministic frame rendering for Split Screen visualizations.** Highway instances can now opt into a host-owned frame driver, allowing every panel in a split layout to receive the same `requestAnimationFrame` timestamp and frame ID. Custom visualization renderers receive those values in their render bundle, so frame-dependent effects and pause/throttle decisions no longer vary with the browser's per-panel callback order. Existing single-player and plugin highways retain their self-scheduled rendering loop. diff --git a/CLAUDE.md b/CLAUDE.md index 24a5d37f..b7951dff 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -125,6 +125,17 @@ Notes: ## Plugin Best Practices +### Host compatibility — required vs optional capability checks + +Probe host APIs with feature detection at the call site — one predicate per +feature, exact-surface checks only, never a version-string comparison — and let +an **optional** feature degrade alone when its API is missing: warn once +naming the plugin, the feature, and the missing requirement (`console.warn` in +`screen.js`, `context["log"].warning` in `routes.py`), never throw, and never +block the plugin or raise its basic-mode floor. Full convention, the canonical +feature-probe pattern, and per-API degradation contracts for the +[compatibility matrix](docs/compatibility.md) rows: **[docs/plugin-host-compat.md](docs/plugin-host-compat.md)**. + ### v3 UI (fee[dB]ack v0.3.0) — player-chrome contract v0.3.0's redesigned UI is **the only UI** — the classic v2 shell and its diff --git a/README.md b/README.md index f9cc0ee3..73857d9f 100644 --- a/README.md +++ b/README.md @@ -133,7 +133,9 @@ The contracts are documented in [CLAUDE.md](CLAUDE.md), with deeper guides in [plugin-styles.md](docs/plugin-styles.md), [plugin-modules.md](docs/plugin-modules.md), [plugin-panes.md](docs/plugin-panes.md), -[capability-recipes.md](docs/capability-recipes.md), and +[capability-recipes.md](docs/capability-recipes.md), +[plugin-host-compat.md](docs/plugin-host-compat.md) (how to probe required vs +optional host APIs so a missing feature degrades alone, never the plugin), and [compatibility.md](docs/compatibility.md) (which host commit each plugin feature needs, pinned by hash rather than version string). diff --git a/docs/plugin-host-compat.md b/docs/plugin-host-compat.md new file mode 100644 index 00000000..ea520dba --- /dev/null +++ b/docs/plugin-host-compat.md @@ -0,0 +1,293 @@ +# Host compatibility checks — required vs optional (plugin convention) + +How a plugin decides what a host (`feedBack`) build offers, so that a missing +**optional** API disables only the feature that needs it and never the plugin. +This is sub-issue 2/5 of [#102](https://github.com/get-flashbacks/feedBack/issues/102); +it defines the *convention* plugins use. The companion matrix — +[`docs/compatibility.md`](compatibility.md) — says *which* commit provides each +API and classifies it required vs optional per plugin; this document says *how* +to probe it at runtime. + +Two things are deliberately out of scope here: making `minHost` actionable and +enforcing anything at load time are sub-issue 3/5's material, not a convention's. + +## The three rules + +1. **Probe at the call site, one predicate per feature.** Every host API a + plugin touches has exactly one boolean predicate that tests the *exact + surface the feature uses* — a `typeof` for functions, a property (plus + `version`) for objects, an import/`hasattr` for backend modules, a data-shape + check for return values. Never compare version strings, never parse + `VERSION`, never `git describe` at runtime. +2. **A missing optional API disables only its feature.** The feature's entry + point reports itself unavailable and returns; the plugin keeps loading and + everything else keeps working. Never `throw`, never `return null` from + `setup()`, never blanket-disable the plugin. +3. **One named warning per unavailable feature.** The warning names the plugin, + the feature, and the missing requirement — the API plus the exact commit from + the matrix. It fires once per session. This is what turns a confusing "the + button does nothing" into "the host build predates `f7c761c`, so offline + export is unavailable". + +Rule 2 has the corollary that guards the whole system: **an optional feature +must never raise the plugin's basic-mode floor.** The plugin's basic mode must +run against the oldest host the plugin claims to support (Piano documents +`0.3.0-alpha.1`; difficulty_ladder documents its host-requirements table in its +README). A fork-only API (rows 5–7 in the matrix, e.g. `renderFrameAt` or +`playerContexts`) can only gate the feature that uses it, never the plugin's +load or its core functions. + +## What "required" vs "optional" decides + +The matrix classifies each (API, plugin) pair. The classification maps onto the +convention like this: + +| Classification | Means | Plugin behavior when the probe fails | +|---|---|---| +| **Required for feature X** | Feature X *cannot* exist on a host without the API | Feature X is unavailable: its entry point reports the missing requirement (named warning) and the rest of the plugin keeps working. A plugin whose *entire* value is feature X is free to treat the whole plugin as unusable — but failing pollutes the host, so still report *why*. | +| **Optional for feature X** | Feature X exists and degrades; the plugin has a fallback path | Run the fallback. Emit the named warning once (`console.warn`, or `context["log"].warning` from `routes.py`) and continue. Nothing else changes. | + +The important, easy-to-get-wrong case is the second one. A fallback that +produces subtly wrong output is still "optional" for *loading* purposes — but +if the fallback cannot be proven correct (matrix row 7's `top_difficulty` +fallback), the probe should be treated as "optional but load-bearing": +keep the feature but surface the degradation loudly enough that a user or a +diagnostics bundle can tell the difference. + +## Probes for the matrix + +All seven rows of the `docs/compatibility.md` matrix, as plugin-side probes. +Each probe is one predicate per feature, exact-surface only. + +| # | Feature | Probe | Missing requirement to name | Required / optional | +|---|---|---|---|---| +| 1 | DLC-resolved song paths (`lib/dlc_paths._resolve_dlc_path`) | guarded import in `routes.py` | `lib/dlc_paths` (commit `0dcc913`) | Required for difficulty_ladder generation; optional for lyrics_karaoke (falls back to `safepath.safe_join`) | +| 2 | `sloppak.read_member_bytes` | `hasattr(sloppak_mod, "read_member_bytes")` *after* a guarded module import | `sloppak.read_member_bytes` (commit `d876ded`) | Required at call time for pack-reuse / upgrade / dedup | +| 3 | `chart-transform` capability | `window.feedBack.chartTransformDomain?.version === 1` | `chart-transform` domain (commit `05be9eb`) | Optional for chordr's enrichment tier | +| 4 | `/ws/sync/{session_id}` relay | connect-probe (below) | `/ws/sync/{session_id}` relay (commit `03e1c1d`) | Optional for splitscreen LAN sharing | +| 5a | Co-ordinated frame driver | `typeof highway.setExternalFrameDriver` + `typeof highway.renderFrame` | `setExternalFrameDriver`/`renderFrame` (commit `f7c761c`) | Optional for splitscreen's coordinated frames | +| 5b | Deterministic offline frames | `typeof highway.renderFrameAt` | `renderFrameAt` (commit `f7c761c`) | Required for visual_export | +| 6 | Player contexts | `window.feedBack.playerContexts.version === 1` | `playerContexts` (commit `7633211`) | Optional everywhere (always a fallback path) | +| 7 | Phrase-tier numbers | `getPhrases()` data shape (below) | `getPhrases().top_difficulty` (commit `e5339c0`) | Optional but load-bearing for difficulty_ladder's correctness | + +### Canonical JavaScript pattern + +```js +// plugins//screen.js — inside your IIFE. +const HOST_FEATURES = { + // Only the features THIS plugin uses. `has()` is the one predicate. + // Never register a data-shape probe (row 7) with `has()` that this cache + // would freeze before `ready` — probe those at the call site instead. + offlineFrames: { + label: 'Deterministic offline export frames', + requirement: 'highway.renderFrameAt (f7c761c, fork-only)', + has() { + return typeof (window.highway && window.highway.renderFrameAt) === 'function'; + }, + }, + playerContexts: { + label: 'Per-player identity contexts', + requirement: 'window.feedBack.playerContexts (7633211, fork-only)', + has() { + const pc = window.feedBack && window.feedBack.playerContexts; + return !!(pc && pc.version === 1); + }, + }, +}; + +const _warnedOnce = new Set(); + +// Truthy -> the feature is available. Never throw. +function hostFeature(feature) { + const probe = HOST_FEATURES[feature]; + if (!probe || !probe.has) return false; + if (probe._cached === undefined) probe._cached = probe.has() === true; + return probe._cached; +} + +// Claim a feature for use. If it is unavailable, warns ONCE with a message +// naming the plugin, the feature and the missing requirement, then returns +// false so the caller can degrade just that feature. +function assumeHostFeature(feature) { + if (hostFeature(feature)) return true; + const probe = HOST_FEATURES[feature] || {}; + if (!_warnedOnce.has(feature)) { + _warnedOnce.add(feature); + console.warn( + `[] ${probe.label || feature} unavailable: this host (` + + `${probe.requirement || 'unknown requirement'}). The plugin keeps ` + + `working; only this feature is disabled.` + ); + if (window.feedBack && window.feedBack.diagnostics) { + window.feedBack.diagnostics.contribute('_host_compat', { + schema: 'feedBack.host_compat.v1', + unavailable: [{ feature, requirement: probe.requirement }], + }); + } + } + return false; +} +``` + +The diagnostics contribution is optional but recommended — the bundle is shared +with maintainers, so the named requirement turns a support round-trip into a +one-line answer. Keep the payload small; it overwrites on repeat contributions. + +### Row-by-row degradation contract + +#### Row 1 — `lib/dlc_paths._resolve_dlc_path` + +Python, `routes.py`. Required for difficulty_ladder's generation route; +optional for lyrics_karaoke. + +```python +try: + from dlc_paths import _resolve_dlc_path # 0dcc913 (upstream) +except ImportError: + _resolve_dlc_path = None +``` + +Required consumers: skip registering the dependent route (difficulty_ladder +generation) and warn via `context["log"]`. Optional consumers (lyrics_karaoke) +fall back to `safepath.safe_join` and never weaken containment. + +#### Row 2 — `sloppak.read_member_bytes` + +Python. The common mistake is importing `lib/sloppak` behind a guarded import +and assuming that is enough. A host can have the *module* and still predate +`d876ded`; probe the member: + +```python +has_read_member = hasattr(sloppak_mod, "read_member_bytes") # d876ded (upstream) +``` + +Degrade: fall back to your standalone zip reader (feedpakr does) or skip the +pack-reuse feature — never crash a conversion. + +#### Row 3 — `chart-transform` + +Declaring the capability in the manifest is *not* a probe: the loader +validates a declaration's shape, never whether the host implements the domain. +Probe the runtime domain object: + +```js +function hasChartTransform() { + const d = window.feedBack && window.feedBack.chartTransformDomain; + return !!(d && d.version === 1); // 05be9eb (upstream) +} +``` + +Degrade: `highway.getChordTemplates()` returns no generated diagrams and +`window..getChartTransformStatus()` reports a no-owner state. Nothing +throws. + +#### Row 4 — `/ws/sync/{session_id}` + +Cannot be feature-detected from a page (there is no `typeof` for a route). The +degradation pattern is a connect-probe: attempt to open the share socket, count +failures *including* a constructor that throws outright, and past a threshold +warn once + toast naming the relay and commit `03e1c1d`. splitscreen's +`screen.js` is the canonical implementation (it already does exactly this). + +#### Row 5 — frame APIs + +Two features, two probes, both from `f7c761c`: + +```js +function hasCoordinatedFrames() { // 5a — optional for splitscreen + const h = window.highway; + return !!(h && typeof h.setExternalFrameDriver === 'function' + && typeof h.renderFrame === 'function'); +} +function hasOfflineFrames() { // 5b — required for visual_export + return typeof (window.highway && window.highway.renderFrameAt) === 'function'; +} +``` + +Optional consumers degrade silently to their own rAF loop. `renderFrameAt` is +all-or-nothing with a host *bridge:* Splitscreen's bridge `renderFrameAt()` +returns `false` unless offline rendering is active (core's own method only +returns `false` pre-`ready`), so a plugin that needs split-layout export must +also check the rendering host that provides `beginOfflineRender` (splitscreen +1.14.8, commit `2301dd5`) — cite that, not the side-branch `87e3622a`. + +#### Row 6 — `playerContexts` + +Probe the object, not the capability declaration: + +```js +const pc = window.feedBack && window.feedBack.playerContexts; +if (!(pc && pc.version === 1)) { // 7633211 (fork-only), optional + // warn once; fall back to the main highway / single-player path +} +``` + +Every current consumer guards and degrades to single-player behavior. + +#### Row 7 — `getPhrases().top_difficulty` + +Data-shape probe, and only *after* `ready`/at the call site (`getPhrases()` +returns `null` until phrase data has arrived): + +```js +function hasPhraseTierNumbers() { + const phrases = window.highway && window.highway.getPhrases(); + return Array.isArray(phrases) && phrases.length > 0 + && typeof phrases[0].top_difficulty === 'number'; // e5339c0 (fork-only) +} +``` + +Use `typeof … === 'number'`, not truthiness: an authored one-level phrase +reports `top_difficulty === 0`. This is "optional but load-bearing": +difficulty_ladder's fallback computes a tier the highway may not actually be +showing, so warn noticeably (console warning at a minimum) rather than +silently presenting output that can disagree with the rendered highway. + +## Backend rule + +The same convention applies to `routes.py`, using `context["log"]` for the +single named warning: + +```python +def setup(app, context): + log = context["log"] + try: + from dlc_paths import _resolve_dlc_path # 0dcc913 (upstream) + except ImportError: + _resolve_dlc_path = None + log.warning( + "ladder generation unavailable: host lacks lib/dlc_paths._resolve_dlc_path " + "(commit 0dcc913). The plugin keeps working; only this feature is disabled." + ) +``` + +Never `raise` from `setup()` for an optional API — that is how a missing +optional API becomes a plugin-wide outage. Prefer skipping the feature, and if +the whole plugin genuinely cannot function, fail with the named requirement in +the log rather than a bare traceback. + +## Where the host does NOT help today + +- `minHost` is metadata passthrough only (`plugins/__init__.py` copies it to + `/api/plugins`); nothing under `static/` reads it, and it is not + feature-scoped. Do not rely on it, and do not encode a *feature* floor in it — + that is sub-issue 3/5. +- Declaring a capability domain the host does not implement is not a load error. + The declaration is intent; the runtime probe is the domain object, an + `inspect()`, or the outcome of a dispatched command (any non-`handled` + outcome means "feature unavailable" — see the `practice-difficulty` recipe in + `docs/capability-recipes.md`). + +## Checklist for a plugin PR adding an optional feature + +- [ ] One `has()` predicate per host API the feature touches; exact-surface + checks only. +- [ ] Predicate probed at the call site (post-`ready` for data-shape probes), + result cached per session. +- [ ] On failure: warn **once**, naming the plugin, the feature and the + requirement (API + commit); disable only that feature. +- [ ] Basic mode and plugin load never depend on an optional probe. +- [ ] Backend equivalents use `context["log"].warning` and skip the feature, not + `setup()` failure. +- [ ] The degradation is recorded in the plugin's diagnostics contribution + (`window.feedBack.diagnostics.contribute`) when a bundle is produced.