From 5aa336961c3cafda854d579099b6d68e1cab61e4 Mon Sep 17 00:00:00 2001 From: Kris Anderson Date: Thu, 9 Jul 2026 12:24:29 -0400 Subject: [PATCH 01/31] feat(highways): per-splitscreen-panel Camera Director cameras MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make the three 3D highways read the Camera Director bridge per panel so each splitscreen panel renders its own camera (independent orbit/height/zoom/tilt/pan), instead of all panels sharing the focused camera. - Add a shared `_freeCamFor(canvas)` resolver to each highway: prefer this panel's entry in `window.__h3dCamCtlPanels[panelIndexFor(canvas)]`, fall back to the global `window.__h3dCamCtl`, else null (100% stock). Defensive on the splitscreen global name (feedBackSplitscreen || slopsmithSplitscreen), NaN-safe, allocation-free. - highway_3d (guitar): source `_freeCam` from the resolver (was global-only). - keys_highway_3d: adopt the bridge for the first time — layer dolly/height/orbit + pan/pitch offsets onto the pan/zoom follow rig at the camera write. - drum_highway_3d: adopt the bridge — new per-frame `applyCamera()` folds the static base pose + kick-pulse dip + free-cam offsets. - In a follower (popped-out) window there is one panel, so the resolver yields whatever camera the plugin set in that window; no highway change needed for pop-out. Camera Director absent → resolver returns null → renderers behave exactly as before. Bump each plugin patch version. Existing plugin tests pass (drum 15, keys 30); the keys "default look unchanged" test confirms the stock path is byte-identical. Co-Authored-By: Claude Fable 5 Signed-off-by: Kris Anderson --- plugins/drum_highway_3d/plugin.json | 2 +- plugins/drum_highway_3d/screen.js | 56 +++++++++++++++++++++++++++-- plugins/highway_3d/plugin.json | 2 +- plugins/highway_3d/screen.js | 27 ++++++++++++-- plugins/keys_highway_3d/plugin.json | 2 +- plugins/keys_highway_3d/screen.js | 47 +++++++++++++++++++++++- 6 files changed, 128 insertions(+), 8 deletions(-) diff --git a/plugins/drum_highway_3d/plugin.json b/plugins/drum_highway_3d/plugin.json index 3309b6e..576e9b9 100644 --- a/plugins/drum_highway_3d/plugin.json +++ b/plugins/drum_highway_3d/plugin.json @@ -1,7 +1,7 @@ { "id": "drum_highway_3d", "name": "3D Drum Highway", - "version": "0.3.1", + "version": "0.3.2", "type": "visualization", "bundled": true, "script": "screen.js", diff --git a/plugins/drum_highway_3d/screen.js b/plugins/drum_highway_3d/screen.js index c5c9821..8a694c9 100644 --- a/plugins/drum_highway_3d/screen.js +++ b/plugins/drum_highway_3d/screen.js @@ -2651,6 +2651,55 @@ cam.lookAt(0, 0, -AHEAD * TS * 0.45); } + // Camera Director bridge resolver. Prefers THIS panel's per-panel camera + // under splitscreen (window.__h3dCamCtlPanels[panelIndex]) and falls back + // to the single global (window.__h3dCamCtl); null when Camera Director is + // absent → 100% stock framing. Defensive on the splitscreen global name + // (rename in flight: feedBackSplitscreen vs slopsmithSplitscreen). + function _freeCamFor(canvas) { + const map = window.__h3dCamCtlPanels; + if (map) { + const ss = window.feedBackSplitscreen || window.slopsmithSplitscreen; + if (ss && typeof ss.panelIndexFor === 'function') { + try { + const i = ss.panelIndexFor(canvas); + if (i != null && map[i]) return map[i]; + } catch (e) { /* ignore */ } + } + } + return window.__h3dCamCtl || null; + } + + // Per-frame camera write: static base pose (positionCamera) + kick-pulse Y + // dip, then layer Camera Director free-cam offsets (dolly/height/orbit on + // the camera-from-target vector; pan/pitch on the look target). Runs every + // frame so a live free-cam drag is smooth; allocation-free; NaN-safe; a + // null/disabled bridge reproduces the stock static+pulse pose exactly. + function applyCamera() { + if (_camBaseH == null) return; // before first positionCamera() + const _dip = (_kickPulse > 0.001) ? (0.8 * K * _kickPulse * fx.hitFx) : 0; + let _cx = 0, _cy = _camBaseH - _dip, _cz = _camBaseD; + let _lx = 0, _ly = 0, _lz = -AHEAD * TS * 0.45; + const _fc = _freeCamFor(highwayCanvas); + if (_fc && _fc.enabled) { + const _dm = Number.isFinite(_fc.distMul) ? _fc.distMul : 1; + const _hm = Number.isFinite(_fc.heightMul) ? _fc.heightMul : 1; + const _yaw = Number.isFinite(_fc.yaw) ? _fc.yaw : 0; + let _vx = _cx - _lx, _vy = _cy - _ly, _vz = _cz - _lz; + _vx *= _dm; _vy *= _dm; _vz *= _dm; // dolly (zoom) + _vy *= _hm; // height + const _cyw = Math.cos(_yaw), _syw = Math.sin(_yaw); + const _rx = _vx * _cyw - _vz * _syw, _rz = _vx * _syw + _vz * _cyw; // orbit around Y + _cx = _lx + _rx; _cy = _ly + _vy; _cz = _lz + _rz; + const _px = Number.isFinite(_fc.panX) ? _fc.panX : 0; + const _py = Number.isFinite(_fc.panY) ? _fc.panY : 0; + const _pt = Number.isFinite(_fc.pitch) ? _fc.pitch : 0; + _lx += _px * K; _ly += (_pt + _py) * K; + } + cam.position.set(_cx, _cy, _cz); + cam.lookAt(_lx, _ly, _lz); + } + function buildLanes(_floorW, floorD) { laneGroup = new T.Group(); laneStripeMats = []; @@ -3431,15 +3480,18 @@ BG_STYLES[_bgState._style].update(_bgState.s, bands, fdt, nowMs / 1000); } catch (_) { /* visual-only */ } } + // Kick pulse decays each frame; it drives the floor flash and, + // via applyCamera(), the camera Y dip. if (_kickPulse > 0.001) { _kickPulse *= Math.exp(-fdt * 7); - cam.position.y = _camBaseH - 0.8 * K * _kickPulse * fx.hitFx; if (_floorFlash) _floorFlash.material.opacity = 0.25 * _kickPulse * fx.hitFx; } else if (_kickPulse !== 0) { _kickPulse = 0; - cam.position.y = _camBaseH; if (_floorFlash) _floorFlash.material.opacity = 0; } + // Write the camera every frame: static base pose + kick dip + + // Camera Director free-cam offsets (per-panel-aware). + applyCamera(); } // Approach highlight: raise each lane stripe toward its next // note (accumulated by the rebuildNotes walk above). diff --git a/plugins/highway_3d/plugin.json b/plugins/highway_3d/plugin.json index 9eed075..cea8168 100644 --- a/plugins/highway_3d/plugin.json +++ b/plugins/highway_3d/plugin.json @@ -1,7 +1,7 @@ { "id": "highway_3d", "name": "3D Highway", - "version": "3.31.3", + "version": "3.31.4", "type": "visualization", "bundled": true, "script": "screen.js", diff --git a/plugins/highway_3d/screen.js b/plugins/highway_3d/screen.js index 0f7fc1d..521ce97 100644 --- a/plugins/highway_3d/screen.js +++ b/plugins/highway_3d/screen.js @@ -2600,6 +2600,26 @@ const idx = (ss && typeof ss.panelIndexFor === 'function') ? ss.panelIndexFor(canvas) : null; return (idx == null) ? 'main' : 'panel' + idx; } + + // Camera Director bridge resolver. Prefers THIS panel's per-panel camera + // under splitscreen (window.__h3dCamCtlPanels[panelIndex]) and falls back to + // the single global (window.__h3dCamCtl); returns null when Camera Director + // is absent → 100% stock framing. Defensive on the splitscreen global name + // (rename in flight: feedBackSplitscreen vs slopsmithSplitscreen). Mirrors + // the panel resolution in _bgPanelKey. + function _freeCamFor(canvas) { + const map = window.__h3dCamCtlPanels; + if (map) { + const ss = window.feedBackSplitscreen || window.slopsmithSplitscreen; + if (ss && typeof ss.panelIndexFor === 'function') { + try { + const i = ss.panelIndexFor(canvas); + if (i != null && map[i]) return map[i]; + } catch (e) { /* ignore */ } + } + } + return window.__h3dCamCtl || null; + } // In-memory fallback for when localStorage is blocked (private mode, // sandboxed iframes, some test runners). _bgWriteGlobal stages the // value here unconditionally, so it always reflects the most recent @@ -14664,7 +14684,10 @@ // suppressed while the Camera Director owns the view (it wins). const _startAspect = (_tune && Number.isFinite(_tune.startAspect) && _tune.startAspect > 0) ? _tune.startAspect : HORPLUS_START_ASPECT; - const _dirActive = !!(window.__h3dCamCtl && window.__h3dCamCtl.enabled); + // Resolve the Camera Director bridge once (per-panel under splitscreen, + // else global). Used both for the wide-pane gate and the transforms below. + const _freeCam = _freeCamFor(highwayCanvas); + const _dirActive = !!(_freeCam && _freeCam.enabled); const _wide = !!(_tune && _paneAspect > _startAspect) && !_dirActive; const _poseHMul = (_wide && Number.isFinite(_tune.heightMul)) ? _tune.heightMul : 1; const _poseDMul = (_wide && Number.isFinite(_tune.distMul)) ? _tune.distMul : 1; @@ -14697,7 +14720,7 @@ // position and the look-at transforms; every field is coerced to a // finite number before use so a malformed object can never feed NaN // into cam.position / cam.lookAt. - const _freeCam = window.__h3dCamCtl; + // _freeCam resolved above (per-panel-aware Camera Director bridge). const _lookAtZ = -FOCUS_D * 0.35 * _poseLookZMul; if (_freeCam && _freeCam.enabled) { const _distMul = Number.isFinite(_freeCam.distMul) ? _freeCam.distMul : 1; diff --git a/plugins/keys_highway_3d/plugin.json b/plugins/keys_highway_3d/plugin.json index 80bb008..1a9fe30 100644 --- a/plugins/keys_highway_3d/plugin.json +++ b/plugins/keys_highway_3d/plugin.json @@ -1,7 +1,7 @@ { "id": "keys_highway_3d", "name": "Keys Highway 3D", - "version": "0.2.0", + "version": "0.2.1", "description": "RS+-style 3D falling-note piano highway fed by the Sloppak Notation Format, with Web MIDI input scoring.", "type": "visualization", "bundled": true, diff --git a/plugins/keys_highway_3d/screen.js b/plugins/keys_highway_3d/screen.js index 1871b32..0df538d 100644 --- a/plugins/keys_highway_3d/screen.js +++ b/plugins/keys_highway_3d/screen.js @@ -1849,6 +1849,25 @@ _rigOut.lookZ = _camPreset.lookZ; return _rigOut; } + + // Camera Director bridge resolver. Prefers THIS panel's per-panel camera + // under splitscreen (window.__h3dCamCtlPanels[panelIndex]) and falls back + // to the single global (window.__h3dCamCtl); null when Camera Director is + // absent → 100% stock framing. Defensive on the splitscreen global name + // (rename in flight: feedBackSplitscreen vs slopsmithSplitscreen). + function _freeCamFor(canvas) { + const map = window.__h3dCamCtlPanels; + if (map) { + const ss = window.feedBackSplitscreen || window.slopsmithSplitscreen; + if (ss && typeof ss.panelIndexFor === 'function') { + try { + const i = ss.panelIndexFor(canvas); + if (i != null && map[i]) return map[i]; + } catch (e) { /* ignore */ } + } + } + return window.__h3dCamCtl || null; + } // Per-key approach glow: a key lights in its pitch-class color ONLY while a // note is heading for it, ramping up the closer that note gets to the hit-line. const KEY_GLOW_AHEAD = 2.0; // seconds before the hit-line a key starts to light @@ -3259,7 +3278,33 @@ } _camX += (_camTargetX - _camX) * CAM_PAN_LERP; _camZoom += (_camTargetZoom - _camZoom) * CAM_ZOOM_LERP; - { const r = _rig(); cam.position.set(_camX, r.y * K * _camZoom, r.z * K * _camZoom); cam.lookAt(_camX, r.lookY * K * _camZoom, r.lookZ * K * _camZoom); } + { + const r = _rig(); + let _cx = _camX, _cy = r.y * K * _camZoom, _cz = r.z * K * _camZoom; + let _lx = _camX, _ly = r.lookY * K * _camZoom, _lz = r.lookZ * K * _camZoom; + // Camera Director free-cam offsets (per-panel-aware), layered on top + // of the auto-framing so pan/zoom-follow still works. Dolly/height/ + // orbit act on the camera-from-target vector; pan/pitch shift the + // look target. NaN-safe; null/disabled bridge → stock. + const _fc = _freeCamFor(highwayCanvas); + if (_fc && _fc.enabled) { + const _dm = Number.isFinite(_fc.distMul) ? _fc.distMul : 1; + const _hm = Number.isFinite(_fc.heightMul) ? _fc.heightMul : 1; + const _yaw = Number.isFinite(_fc.yaw) ? _fc.yaw : 0; + let _vx = _cx - _lx, _vy = _cy - _ly, _vz = _cz - _lz; + _vx *= _dm; _vy *= _dm; _vz *= _dm; // dolly (zoom) + _vy *= _hm; // height + const _cyw = Math.cos(_yaw), _syw = Math.sin(_yaw); + const _rx = _vx * _cyw - _vz * _syw, _rz = _vx * _syw + _vz * _cyw; // orbit around Y + _cx = _lx + _rx; _cy = _ly + _vy; _cz = _lz + _rz; + const _px = Number.isFinite(_fc.panX) ? _fc.panX : 0; + const _py = Number.isFinite(_fc.panY) ? _fc.panY : 0; + const _pt = Number.isFinite(_fc.pitch) ? _fc.pitch : 0; + _lx += _px * K; _ly += (_pt + _py) * K; + } + cam.position.set(_cx, _cy, _cz); + cam.lookAt(_lx, _ly, _lz); + } for (const km of keyMeshes.values()) km.userData.glow = 0; for (const { mesh, note, len, label } of noteMeshes) { From ff8a638d28de3b577facb04c97da247b356af948 Mon Sep 17 00:00:00 2001 From: Kris Anderson Date: Thu, 9 Jul 2026 15:58:12 -0400 Subject: [PATCH 02/31] docs(highway_3d): name the concrete camera-bridge globals in comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address a review note on the free-camera block: the comments described the bridge as "per-panel-aware" without naming the actual globals. Spell out that _freeCam comes from _freeCamFor(highwayCanvas) — window.__h3dCamCtlPanels[ panelIndexFor(canvas)] when split, else the global window.__h3dCamCtl, else null — and update the nearby comment that mentioned only __h3dCamCtl. Comment- only; no behavior change. Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: Kris Anderson --- plugins/highway_3d/screen.js | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/plugins/highway_3d/screen.js b/plugins/highway_3d/screen.js index 521ce97..65e6fef 100644 --- a/plugins/highway_3d/screen.js +++ b/plugins/highway_3d/screen.js @@ -14714,13 +14714,16 @@ if (_poseHMul !== 1) _camY *= _poseHMul; if (_poseDMul !== 1) _camZ *= _poseDMul; // ── Free-camera user tweaks (orbit / height / zoom / pan) ── - // Driven by the Camera Director plugin via window.__h3dCamCtl. + // Driven by the Camera Director plugin via the camera bridge: + // window.__h3dCamCtlPanels[panelIndexFor(canvas)] when split (this + // panel's own camera), falling back to the global window.__h3dCamCtl. // Layered ON TOP of the auto-framing so note tracking still works. // The bridge is read once into _freeCam and reused for both the // position and the look-at transforms; every field is coerced to a // finite number before use so a malformed object can never feed NaN // into cam.position / cam.lookAt. - // _freeCam resolved above (per-panel-aware Camera Director bridge). + // _freeCam resolved above via _freeCamFor(highwayCanvas): the + // per-panel __h3dCamCtlPanels entry, else global __h3dCamCtl, else null. const _lookAtZ = -FOCUS_D * 0.35 * _poseLookZMul; if (_freeCam && _freeCam.enabled) { const _distMul = Number.isFinite(_freeCam.distMul) ? _freeCam.distMul : 1; From 0d4d8229c7f2b1d3a92fa71b1a6206931b24cd10 Mon Sep 17 00:00:00 2001 From: Kris Anderson Date: Thu, 9 Jul 2026 16:15:29 -0400 Subject: [PATCH 03/31] =?UTF-8?q?fix(highways):=20address=20review=20?= =?UTF-8?q?=E2=80=94=20bg-key=20alias,=20drum=20cam=20guard,=20resolver=20?= =?UTF-8?q?tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three review findings on the per-panel camera work: - highway_3d: _bgPanelKey() resolved splitscreen via window.feedBackSplitscreen only, while _freeCamFor() uses the feedBackSplitscreen||slopsmithSplitscreen alias it claims to "mirror". If the rename lands, per-panel background settings would silently stop being per-panel while the camera stayed per-panel. Resolve the alias the same way in _bgPanelKey. - drum_highway_3d: applyCamera()'s "before first positionCamera()" guard tested `_camBaseH == null`, but _camBaseH/_camBaseD were initialized to 0, so the guard never fired (and could apply a base-0 pose for a frame). Initialize to null. - keys + drum: the PR claimed the Camera Director resolver was unit-checked, but nothing exercised it. Extract the resolver into pure, exported helpers (_resolveFreeCam + _ssApi), delegate the per-instance _freeCamFor to them, and add tests/camera_bridge.test.js covering per-panel select, global fallback, null-when-absent, throw-safety, and the slopsmith-alias resolution. Drum 15→21, keys 50→56, all pass; behavior unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: Kris Anderson --- plugins/drum_highway_3d/screen.js | 48 ++++++++----- .../tests/camera_bridge.test.js | 68 +++++++++++++++++++ plugins/highway_3d/screen.js | 4 +- plugins/keys_highway_3d/screen.js | 46 ++++++++----- .../tests/camera_bridge.test.js | 68 +++++++++++++++++++ 5 files changed, 200 insertions(+), 34 deletions(-) create mode 100644 plugins/drum_highway_3d/tests/camera_bridge.test.js create mode 100644 plugins/keys_highway_3d/tests/camera_bridge.test.js diff --git a/plugins/drum_highway_3d/screen.js b/plugins/drum_highway_3d/screen.js index 8a694c9..d2daac0 100644 --- a/plugins/drum_highway_3d/screen.js +++ b/plugins/drum_highway_3d/screen.js @@ -1361,6 +1361,29 @@ } catch (_) { /* dispatch unavailable — persisted value applies next init */ } }; + /* ====================================================================== + * Camera Director bridge resolver (pure — exported via createFactory.__test) + * ====================================================================== */ + + // Resolve the active splitscreen API, defensive on the global-name rename in + // flight (feedBackSplitscreen is canonical; slopsmithSplitscreen is the legacy + // alias). Returns null when splitscreen isn't present. + function _ssApi() { return window.feedBackSplitscreen || window.slopsmithSplitscreen || null; } + + // Given the splitscreen API, THIS window's per-panel camera map, and the + // global camera, return this panel's camera under splitscreen, else the + // global, else null (Camera Director absent → stock framing). Throw-safe on + // panelIndexFor so a misbehaving splitscreen build can't break framing. + function _resolveFreeCam(canvas, ss, panelsMap, globalCam) { + if (panelsMap && ss && typeof ss.panelIndexFor === 'function') { + try { + const i = ss.panelIndexFor(canvas); + if (i != null && panelsMap[i]) return panelsMap[i]; + } catch (e) { /* ignore */ } + } + return globalCam || null; + } + /* ====================================================================== * Renderer factory * ====================================================================== */ @@ -1414,7 +1437,7 @@ let _sparkPts = null, _sparkPos = null, _sparkCol = null, _sparkVel = null, _sparkLife = null; let _fxLastWall = 0; // wall clock for FX integration (sparks, pulse decay) let _kickPulse = 0; // kick-hit camera-dip + floor-wash envelope - let _camBaseH = 0, _camBaseD = 0; // positionCamera's unpulsed pose + let _camBaseH = null, _camBaseD = null; // positionCamera's unpulsed pose (null until it first runs; applyCamera's guard depends on this) let _gaussTex = null; // shared soft-falloff texture for flash quads let _laneFlashQuads = []; // pooled additive quad per hand lane (z=0) let _kickFlashQuad = null; // full-width flash quad for the kick bar @@ -2651,23 +2674,12 @@ cam.lookAt(0, 0, -AHEAD * TS * 0.45); } - // Camera Director bridge resolver. Prefers THIS panel's per-panel camera - // under splitscreen (window.__h3dCamCtlPanels[panelIndex]) and falls back - // to the single global (window.__h3dCamCtl); null when Camera Director is - // absent → 100% stock framing. Defensive on the splitscreen global name - // (rename in flight: feedBackSplitscreen vs slopsmithSplitscreen). + // Camera Director bridge for THIS panel — delegates to the pure, unit- + // tested _resolveFreeCam / _ssApi (see the resolver block above the + // factory). Reads the live globals: per-panel map __h3dCamCtlPanels → + // this panel's camera, else the global __h3dCamCtl, else null (stock). function _freeCamFor(canvas) { - const map = window.__h3dCamCtlPanels; - if (map) { - const ss = window.feedBackSplitscreen || window.slopsmithSplitscreen; - if (ss && typeof ss.panelIndexFor === 'function') { - try { - const i = ss.panelIndexFor(canvas); - if (i != null && map[i]) return map[i]; - } catch (e) { /* ignore */ } - } - } - return window.__h3dCamCtl || null; + return _resolveFreeCam(canvas, _ssApi(), window.__h3dCamCtlPanels, window.__h3dCamCtl); } // Per-frame camera write: static base pose (positionCamera) + kick-pulse Y @@ -3617,6 +3629,8 @@ // vm-loaded with no DOM/WebGL; everything here must stay side-effect // free to call). window.slopsmithViz_drum_highway_3d.__test = { + _resolveFreeCam, + _ssApi, _variantForHit, _classifyTiming, readFxSettings, diff --git a/plugins/drum_highway_3d/tests/camera_bridge.test.js b/plugins/drum_highway_3d/tests/camera_bridge.test.js new file mode 100644 index 0000000..5921849 --- /dev/null +++ b/plugins/drum_highway_3d/tests/camera_bridge.test.js @@ -0,0 +1,68 @@ +// Camera Director bridge resolver tests: per-panel select, global fallback, +// null-when-absent, throw-safety, and the splitscreen global-name alias. Loads +// screen.js in a bare vm window and exercises the __test exports (no DOM/WebGL). +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const vm = require('node:vm'); + +function load() { + const window = { + console, + location: { protocol: 'http:', host: 'localhost' }, + slopsmith: {}, + }; + window.window = window; + window.globalThis = window; + const context = vm.createContext(window); + const src = fs.readFileSync(path.join(__dirname, '..', 'screen.js'), 'utf8'); + vm.runInContext(src, context, { filename: 'screen.js' }); + return { window, __test: window.slopsmithViz_drum_highway_3d.__test }; +} + +test('_resolveFreeCam: per-panel camera under splitscreen', () => { + const { __test } = load(); + const c0 = {}, c1 = {}; + const ss = { panelIndexFor: (c) => (c === c0 ? 0 : 1) }; + const map = { 0: { id: 'p0' }, 1: { id: 'p1' } }; + assert.equal(__test._resolveFreeCam(c0, ss, map, { id: 'g' }).id, 'p0'); + assert.equal(__test._resolveFreeCam(c1, ss, map, { id: 'g' }).id, 'p1'); +}); + +test('_resolveFreeCam: falls back to global when there is no panel map', () => { + const { __test } = load(); + const g = { id: 'global' }; + assert.equal(__test._resolveFreeCam({}, { panelIndexFor: () => 0 }, null, g), g); +}); + +test('_resolveFreeCam: falls back to global when the panel has no map entry', () => { + const { __test } = load(); + const g = { id: 'global' }; + const ss = { panelIndexFor: () => 3 }; // index 3 absent from map + assert.equal(__test._resolveFreeCam({}, ss, { 0: {} }, g), g); +}); + +test('_resolveFreeCam: null when Camera Director is absent (no global)', () => { + const { __test } = load(); + assert.equal(__test._resolveFreeCam({}, null, null, null), null); + assert.equal(__test._resolveFreeCam({}, { panelIndexFor: () => 0 }, {}, undefined), null); +}); + +test('_resolveFreeCam: throw-safe on panelIndexFor → falls back to global', () => { + const { __test } = load(); + const g = { id: 'global' }; + const ss = { panelIndexFor: () => { throw new Error('boom'); } }; + assert.equal(__test._resolveFreeCam({}, ss, { 0: {} }, g), g); +}); + +test('_ssApi: null when neither global set; slopsmith alias; feedBack canonical wins', () => { + const { window, __test } = load(); + assert.equal(__test._ssApi(), null); + const legacy = { panelIndexFor: () => 0 }; + window.slopsmithSplitscreen = legacy; + assert.equal(__test._ssApi(), legacy); // legacy alias picked up + const current = { panelIndexFor: () => 1 }; + window.feedBackSplitscreen = current; + assert.equal(__test._ssApi(), current); // canonical name takes precedence +}); diff --git a/plugins/highway_3d/screen.js b/plugins/highway_3d/screen.js index 65e6fef..f5351a0 100644 --- a/plugins/highway_3d/screen.js +++ b/plugins/highway_3d/screen.js @@ -2596,7 +2596,9 @@ const FRET_NUMBER_GHOST_SCOPE_IDS = ['chords', 'all']; function _bgPanelKey(canvas) { - const ss = window.feedBackSplitscreen; + // Defensive on the splitscreen global name (rename in flight) so per-panel + // background settings keep resolving the same panel as _freeCamFor(). + const ss = window.feedBackSplitscreen || window.slopsmithSplitscreen; const idx = (ss && typeof ss.panelIndexFor === 'function') ? ss.panelIndexFor(canvas) : null; return (idx == null) ? 'main' : 'panel' + idx; } diff --git a/plugins/keys_highway_3d/screen.js b/plugins/keys_highway_3d/screen.js index 0df538d..238bc8f 100644 --- a/plugins/keys_highway_3d/screen.js +++ b/plugins/keys_highway_3d/screen.js @@ -1600,6 +1600,29 @@ _aiRegisteredCount = 0; } + /* ====================================================================== + * Camera Director bridge resolver (pure — exported via createFactory.__test) + * ====================================================================== */ + + // Resolve the active splitscreen API, defensive on the global-name rename in + // flight (feedBackSplitscreen is canonical; slopsmithSplitscreen is the legacy + // alias). Returns null when splitscreen isn't present. + function _ssApi() { return window.feedBackSplitscreen || window.slopsmithSplitscreen || null; } + + // Given the splitscreen API, THIS window's per-panel camera map, and the + // global camera, return this panel's camera under splitscreen, else the + // global, else null (Camera Director absent → 100% stock framing). Throw-safe + // on panelIndexFor so a misbehaving splitscreen build can't break framing. + function _resolveFreeCam(canvas, ss, panelsMap, globalCam) { + if (panelsMap && ss && typeof ss.panelIndexFor === 'function') { + try { + const i = ss.panelIndexFor(canvas); + if (i != null && panelsMap[i]) return panelsMap[i]; + } catch (e) { /* ignore */ } + } + return globalCam || null; + } + /* ====================================================================== * Renderer factory * ====================================================================== */ @@ -1850,23 +1873,12 @@ return _rigOut; } - // Camera Director bridge resolver. Prefers THIS panel's per-panel camera - // under splitscreen (window.__h3dCamCtlPanels[panelIndex]) and falls back - // to the single global (window.__h3dCamCtl); null when Camera Director is - // absent → 100% stock framing. Defensive on the splitscreen global name - // (rename in flight: feedBackSplitscreen vs slopsmithSplitscreen). + // Camera Director bridge for THIS panel — delegates to the pure, unit- + // tested _resolveFreeCam / _ssApi (see the resolver block above the + // factory). Reads the live globals: per-panel map __h3dCamCtlPanels → + // this panel's camera, else the global __h3dCamCtl, else null (stock). function _freeCamFor(canvas) { - const map = window.__h3dCamCtlPanels; - if (map) { - const ss = window.feedBackSplitscreen || window.slopsmithSplitscreen; - if (ss && typeof ss.panelIndexFor === 'function') { - try { - const i = ss.panelIndexFor(canvas); - if (i != null && map[i]) return map[i]; - } catch (e) { /* ignore */ } - } - } - return window.__h3dCamCtl || null; + return _resolveFreeCam(canvas, _ssApi(), window.__h3dCamCtlPanels, window.__h3dCamCtl); } // Per-key approach glow: a key lights in its pitch-class color ONLY while a // note is heading for it, ramping up the closer that note gets to the hit-line. @@ -4005,6 +4017,8 @@ }; // Pure data-layer + scoring hooks for headless tests. window.slopsmithViz_keys_highway_3d.__test = { + _resolveFreeCam, + _ssApi, beatDurSec, flattenNotation, keyRange, diff --git a/plugins/keys_highway_3d/tests/camera_bridge.test.js b/plugins/keys_highway_3d/tests/camera_bridge.test.js new file mode 100644 index 0000000..efaa0c9 --- /dev/null +++ b/plugins/keys_highway_3d/tests/camera_bridge.test.js @@ -0,0 +1,68 @@ +// Camera Director bridge resolver tests: per-panel select, global fallback, +// null-when-absent, throw-safety, and the splitscreen global-name alias. Loads +// screen.js in a bare vm window and exercises the __test exports (no DOM/WebGL). +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const vm = require('node:vm'); + +function load() { + const window = { + console, + location: { protocol: 'http:', host: 'localhost' }, + slopsmith: {}, + }; + window.window = window; + window.globalThis = window; + const context = vm.createContext(window); + const src = fs.readFileSync(path.join(__dirname, '..', 'screen.js'), 'utf8'); + vm.runInContext(src, context, { filename: 'screen.js' }); + return { window, __test: window.slopsmithViz_keys_highway_3d.__test }; +} + +test('_resolveFreeCam: per-panel camera under splitscreen', () => { + const { __test } = load(); + const c0 = {}, c1 = {}; + const ss = { panelIndexFor: (c) => (c === c0 ? 0 : 1) }; + const map = { 0: { id: 'p0' }, 1: { id: 'p1' } }; + assert.equal(__test._resolveFreeCam(c0, ss, map, { id: 'g' }).id, 'p0'); + assert.equal(__test._resolveFreeCam(c1, ss, map, { id: 'g' }).id, 'p1'); +}); + +test('_resolveFreeCam: falls back to global when there is no panel map', () => { + const { __test } = load(); + const g = { id: 'global' }; + assert.equal(__test._resolveFreeCam({}, { panelIndexFor: () => 0 }, null, g), g); +}); + +test('_resolveFreeCam: falls back to global when the panel has no map entry', () => { + const { __test } = load(); + const g = { id: 'global' }; + const ss = { panelIndexFor: () => 3 }; // index 3 absent from map + assert.equal(__test._resolveFreeCam({}, ss, { 0: {} }, g), g); +}); + +test('_resolveFreeCam: null when Camera Director is absent (no global)', () => { + const { __test } = load(); + assert.equal(__test._resolveFreeCam({}, null, null, null), null); + assert.equal(__test._resolveFreeCam({}, { panelIndexFor: () => 0 }, {}, undefined), null); +}); + +test('_resolveFreeCam: throw-safe on panelIndexFor → falls back to global', () => { + const { __test } = load(); + const g = { id: 'global' }; + const ss = { panelIndexFor: () => { throw new Error('boom'); } }; + assert.equal(__test._resolveFreeCam({}, ss, { 0: {} }, g), g); +}); + +test('_ssApi: null when neither global set; slopsmith alias; feedBack canonical wins', () => { + const { window, __test } = load(); + assert.equal(__test._ssApi(), null); + const legacy = { panelIndexFor: () => 0 }; + window.slopsmithSplitscreen = legacy; + assert.equal(__test._ssApi(), legacy); // legacy alias picked up + const current = { panelIndexFor: () => 1 }; + window.feedBackSplitscreen = current; + assert.equal(__test._ssApi(), current); // canonical name takes precedence +}); From a6a5186180fb85a2a9c791444c02ac91bfaa09fc Mon Sep 17 00:00:00 2001 From: Kris Anderson Date: Thu, 9 Jul 2026 16:21:14 -0400 Subject: [PATCH 04/31] fix(highway_3d): make _bgPanelKey throw-safe on panelIndexFor Follow-up to the _bgPanelKey alias fix: _freeCamFor already treats panelIndexFor as potentially throwy and catches to keep framing stable, but _bgPanelKey called it bare. A throwing splitscreen build would take down background-settings resolution (and the render path) even though the camera path falls back safely. Wrap the call in try/catch, falling back to 'main'. Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: Kris Anderson --- plugins/highway_3d/screen.js | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/plugins/highway_3d/screen.js b/plugins/highway_3d/screen.js index f5351a0..1533bc7 100644 --- a/plugins/highway_3d/screen.js +++ b/plugins/highway_3d/screen.js @@ -2596,10 +2596,14 @@ const FRET_NUMBER_GHOST_SCOPE_IDS = ['chords', 'all']; function _bgPanelKey(canvas) { - // Defensive on the splitscreen global name (rename in flight) so per-panel - // background settings keep resolving the same panel as _freeCamFor(). + // Defensive on the splitscreen global name (rename in flight) AND throw-safe + // on panelIndexFor — same as _freeCamFor — so a misbehaving splitscreen + // build can't take down background-settings resolution. Falls back to 'main'. const ss = window.feedBackSplitscreen || window.slopsmithSplitscreen; - const idx = (ss && typeof ss.panelIndexFor === 'function') ? ss.panelIndexFor(canvas) : null; + let idx = null; + if (ss && typeof ss.panelIndexFor === 'function') { + try { idx = ss.panelIndexFor(canvas); } catch (e) { idx = null; } + } return (idx == null) ? 'main' : 'panel' + idx; } From bcee2e8610ceda0f93ba16978fb61df39e7f149d Mon Sep 17 00:00:00 2001 From: Kris Anderson Date: Thu, 9 Jul 2026 16:27:56 -0400 Subject: [PATCH 05/31] fix(highway_3d): _bgPanelKey rejects non-integer panel index; JSDoc bridge fns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - _bgPanelKey() treated any non-null panelIndexFor() return as a valid panel id, so a NaN/non-finite index minted a bogus "panelNaN" localStorage key instead of falling back to "main". Gate on Number.isInteger(idx) && idx >= 0. (The camera path is already NaN-safe — panelsMap[NaN] misses and falls through.) - Add a NaN/negative-index case to the resolver tests (drum 22, keys 57, pass). - Convert the camera-bridge helpers' comments to JSDoc (_bgPanelKey, _freeCamFor, _resolveFreeCam, _ssApi across the three plugins) to lift docstring coverage on the changed surface. Comment/robustness only; no behavior change beyond the NaN guard. Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: Kris Anderson --- plugins/drum_highway_3d/screen.js | 23 +++++++++---- .../tests/camera_bridge.test.js | 7 ++++ plugins/highway_3d/screen.js | 32 +++++++++++++------ plugins/keys_highway_3d/screen.js | 23 +++++++++---- .../tests/camera_bridge.test.js | 7 ++++ 5 files changed, 68 insertions(+), 24 deletions(-) diff --git a/plugins/drum_highway_3d/screen.js b/plugins/drum_highway_3d/screen.js index d2daac0..43b948d 100644 --- a/plugins/drum_highway_3d/screen.js +++ b/plugins/drum_highway_3d/screen.js @@ -1365,15 +1365,24 @@ * Camera Director bridge resolver (pure — exported via createFactory.__test) * ====================================================================== */ - // Resolve the active splitscreen API, defensive on the global-name rename in - // flight (feedBackSplitscreen is canonical; slopsmithSplitscreen is the legacy - // alias). Returns null when splitscreen isn't present. + /** + * The active splitscreen API, defensive on the global-name rename in flight + * (feedBackSplitscreen is canonical; slopsmithSplitscreen is the legacy alias). + * @returns {object|null} the splitscreen API, or null when not present + */ function _ssApi() { return window.feedBackSplitscreen || window.slopsmithSplitscreen || null; } - // Given the splitscreen API, THIS window's per-panel camera map, and the - // global camera, return this panel's camera under splitscreen, else the - // global, else null (Camera Director absent → stock framing). Throw-safe on - // panelIndexFor so a misbehaving splitscreen build can't break framing. + /** + * Resolve the Camera Director camera for a canvas: this panel's camera under + * splitscreen, else the global, else null (Camera Director absent → stock + * framing). Throw-safe on panelIndexFor so a misbehaving splitscreen build + * can't break framing. + * @param {HTMLCanvasElement} canvas this renderer's highway canvas + * @param {object|null} ss the splitscreen API (see _ssApi) + * @param {object|null} panelsMap window.__h3dCamCtlPanels (per-panel cameras by index) + * @param {object|null} globalCam window.__h3dCamCtl (single global camera) + * @returns {object|null} the resolved free-camera bridge, or null + */ function _resolveFreeCam(canvas, ss, panelsMap, globalCam) { if (panelsMap && ss && typeof ss.panelIndexFor === 'function') { try { diff --git a/plugins/drum_highway_3d/tests/camera_bridge.test.js b/plugins/drum_highway_3d/tests/camera_bridge.test.js index 5921849..66b62bb 100644 --- a/plugins/drum_highway_3d/tests/camera_bridge.test.js +++ b/plugins/drum_highway_3d/tests/camera_bridge.test.js @@ -56,6 +56,13 @@ test('_resolveFreeCam: throw-safe on panelIndexFor → falls back to global', () assert.equal(__test._resolveFreeCam({}, ss, { 0: {} }, g), g); }); +test('_resolveFreeCam: NaN/invalid panel index → falls back to global', () => { + const { __test } = load(); + const g = { id: 'global' }; + assert.equal(__test._resolveFreeCam({}, { panelIndexFor: () => NaN }, { 0: {} }, g), g); + assert.equal(__test._resolveFreeCam({}, { panelIndexFor: () => -1 }, { 0: {} }, g), g); +}); + test('_ssApi: null when neither global set; slopsmith alias; feedBack canonical wins', () => { const { window, __test } = load(); assert.equal(__test._ssApi(), null); diff --git a/plugins/highway_3d/screen.js b/plugins/highway_3d/screen.js index 1533bc7..8c36baf 100644 --- a/plugins/highway_3d/screen.js +++ b/plugins/highway_3d/screen.js @@ -2595,24 +2595,36 @@ } const FRET_NUMBER_GHOST_SCOPE_IDS = ['chords', 'all']; + /** + * localStorage panel key for per-panel background settings ('main' or + * 'panel'). Defensive on the splitscreen global-name rename in flight, + * and throw-safe on panelIndexFor — same as _freeCamFor — so a misbehaving + * splitscreen build can't take down background-settings resolution. Only a + * non-negative integer index yields a 'panel' key; anything else (null, + * NaN, negative, non-integer) falls back to 'main' so a bad index can never + * mint a bogus "panelNaN"-style key. + * @param {HTMLCanvasElement} canvas this renderer's highway canvas + * @returns {string} 'main' or 'panel' + */ function _bgPanelKey(canvas) { - // Defensive on the splitscreen global name (rename in flight) AND throw-safe - // on panelIndexFor — same as _freeCamFor — so a misbehaving splitscreen - // build can't take down background-settings resolution. Falls back to 'main'. const ss = window.feedBackSplitscreen || window.slopsmithSplitscreen; let idx = null; if (ss && typeof ss.panelIndexFor === 'function') { try { idx = ss.panelIndexFor(canvas); } catch (e) { idx = null; } } - return (idx == null) ? 'main' : 'panel' + idx; + return (Number.isInteger(idx) && idx >= 0) ? 'panel' + idx : 'main'; } - // Camera Director bridge resolver. Prefers THIS panel's per-panel camera - // under splitscreen (window.__h3dCamCtlPanels[panelIndex]) and falls back to - // the single global (window.__h3dCamCtl); returns null when Camera Director - // is absent → 100% stock framing. Defensive on the splitscreen global name - // (rename in flight: feedBackSplitscreen vs slopsmithSplitscreen). Mirrors - // the panel resolution in _bgPanelKey. + /** + * Camera Director bridge resolver. Prefers THIS panel's per-panel camera under + * splitscreen (window.__h3dCamCtlPanels[panelIndex]) and falls back to the + * single global (window.__h3dCamCtl); returns null when Camera Director is + * absent → 100% stock framing. Defensive on the splitscreen global-name rename + * in flight (feedBackSplitscreen vs slopsmithSplitscreen); throw-safe on + * panelIndexFor. Mirrors the panel resolution in _bgPanelKey. + * @param {HTMLCanvasElement} canvas this renderer's highway canvas + * @returns {object|null} the resolved free-camera bridge, or null + */ function _freeCamFor(canvas) { const map = window.__h3dCamCtlPanels; if (map) { diff --git a/plugins/keys_highway_3d/screen.js b/plugins/keys_highway_3d/screen.js index 238bc8f..3c6d923 100644 --- a/plugins/keys_highway_3d/screen.js +++ b/plugins/keys_highway_3d/screen.js @@ -1604,15 +1604,24 @@ * Camera Director bridge resolver (pure — exported via createFactory.__test) * ====================================================================== */ - // Resolve the active splitscreen API, defensive on the global-name rename in - // flight (feedBackSplitscreen is canonical; slopsmithSplitscreen is the legacy - // alias). Returns null when splitscreen isn't present. + /** + * The active splitscreen API, defensive on the global-name rename in flight + * (feedBackSplitscreen is canonical; slopsmithSplitscreen is the legacy alias). + * @returns {object|null} the splitscreen API, or null when not present + */ function _ssApi() { return window.feedBackSplitscreen || window.slopsmithSplitscreen || null; } - // Given the splitscreen API, THIS window's per-panel camera map, and the - // global camera, return this panel's camera under splitscreen, else the - // global, else null (Camera Director absent → 100% stock framing). Throw-safe - // on panelIndexFor so a misbehaving splitscreen build can't break framing. + /** + * Resolve the Camera Director camera for a canvas: this panel's camera under + * splitscreen, else the global, else null (Camera Director absent → 100% stock + * framing). Throw-safe on panelIndexFor so a misbehaving splitscreen build + * can't break framing. + * @param {HTMLCanvasElement} canvas this renderer's highway canvas + * @param {object|null} ss the splitscreen API (see _ssApi) + * @param {object|null} panelsMap window.__h3dCamCtlPanels (per-panel cameras by index) + * @param {object|null} globalCam window.__h3dCamCtl (single global camera) + * @returns {object|null} the resolved free-camera bridge, or null + */ function _resolveFreeCam(canvas, ss, panelsMap, globalCam) { if (panelsMap && ss && typeof ss.panelIndexFor === 'function') { try { diff --git a/plugins/keys_highway_3d/tests/camera_bridge.test.js b/plugins/keys_highway_3d/tests/camera_bridge.test.js index efaa0c9..a27e8dd 100644 --- a/plugins/keys_highway_3d/tests/camera_bridge.test.js +++ b/plugins/keys_highway_3d/tests/camera_bridge.test.js @@ -56,6 +56,13 @@ test('_resolveFreeCam: throw-safe on panelIndexFor → falls back to global', () assert.equal(__test._resolveFreeCam({}, ss, { 0: {} }, g), g); }); +test('_resolveFreeCam: NaN/invalid panel index → falls back to global', () => { + const { __test } = load(); + const g = { id: 'global' }; + assert.equal(__test._resolveFreeCam({}, { panelIndexFor: () => NaN }, { 0: {} }, g), g); + assert.equal(__test._resolveFreeCam({}, { panelIndexFor: () => -1 }, { 0: {} }, g), g); +}); + test('_ssApi: null when neither global set; slopsmith alias; feedBack canonical wins', () => { const { window, __test } = load(); assert.equal(__test._ssApi(), null); From 54b5d2e426de7e38ba24f6b749eb987e7e36ef94 Mon Sep 17 00:00:00 2001 From: Kris Anderson Date: Thu, 9 Jul 2026 16:28:58 -0400 Subject: [PATCH 06/31] docs(highways): JSDoc the delegating _freeCamFor wrappers (keys, drum) Finish the docstring pass for the CamDir bridge functions changed in this PR: convert the two per-panel _freeCamFor delegating wrappers to JSDoc, matching the pure _resolveFreeCam / _ssApi helpers. Comment-only. Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: Kris Anderson --- plugins/drum_highway_3d/screen.js | 12 ++++++++---- plugins/keys_highway_3d/screen.js | 12 ++++++++---- 2 files changed, 16 insertions(+), 8 deletions(-) diff --git a/plugins/drum_highway_3d/screen.js b/plugins/drum_highway_3d/screen.js index 43b948d..4b77ef0 100644 --- a/plugins/drum_highway_3d/screen.js +++ b/plugins/drum_highway_3d/screen.js @@ -2683,10 +2683,14 @@ cam.lookAt(0, 0, -AHEAD * TS * 0.45); } - // Camera Director bridge for THIS panel — delegates to the pure, unit- - // tested _resolveFreeCam / _ssApi (see the resolver block above the - // factory). Reads the live globals: per-panel map __h3dCamCtlPanels → - // this panel's camera, else the global __h3dCamCtl, else null (stock). + /** + * Camera Director bridge for THIS panel — delegates to the pure, unit- + * tested _resolveFreeCam / _ssApi (resolver block above the factory). + * Reads the live globals: per-panel map __h3dCamCtlPanels → this panel's + * camera, else the global __h3dCamCtl, else null (stock framing). + * @param {HTMLCanvasElement} canvas this panel's highway canvas + * @returns {object|null} the resolved free-camera bridge, or null + */ function _freeCamFor(canvas) { return _resolveFreeCam(canvas, _ssApi(), window.__h3dCamCtlPanels, window.__h3dCamCtl); } diff --git a/plugins/keys_highway_3d/screen.js b/plugins/keys_highway_3d/screen.js index 3c6d923..ddd2bbb 100644 --- a/plugins/keys_highway_3d/screen.js +++ b/plugins/keys_highway_3d/screen.js @@ -1882,10 +1882,14 @@ return _rigOut; } - // Camera Director bridge for THIS panel — delegates to the pure, unit- - // tested _resolveFreeCam / _ssApi (see the resolver block above the - // factory). Reads the live globals: per-panel map __h3dCamCtlPanels → - // this panel's camera, else the global __h3dCamCtl, else null (stock). + /** + * Camera Director bridge for THIS panel — delegates to the pure, unit- + * tested _resolveFreeCam / _ssApi (resolver block above the factory). + * Reads the live globals: per-panel map __h3dCamCtlPanels → this panel's + * camera, else the global __h3dCamCtl, else null (stock framing). + * @param {HTMLCanvasElement} canvas this panel's highway canvas + * @returns {object|null} the resolved free-camera bridge, or null + */ function _freeCamFor(canvas) { return _resolveFreeCam(canvas, _ssApi(), window.__h3dCamCtlPanels, window.__h3dCamCtl); } From 14d116d8277f1ac5f2b4aecc74564d5e1ac8c771 Mon Sep 17 00:00:00 2001 From: Kris Anderson Date: Thu, 9 Jul 2026 16:33:57 -0400 Subject: [PATCH 07/31] fix(highways): validate panel index before indexing the camera map _resolveFreeCam() (keys/drum) and _freeCamFor() (highway_3d) guarded the panel map lookup with only `i != null`, so a non-integer / negative / string index from panelIndexFor() could resolve an unintended or inherited property (e.g. map['toString']) instead of cleanly falling back to the global camera. Gate the index on `Number.isInteger(i) && i >= 0` before `map[i]`, matching the hardening already applied in _bgPanelKey(). Extend the resolver tests with float/string (prototype-key) cases. Behavior change only for malformed indices. Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: Kris Anderson --- plugins/drum_highway_3d/screen.js | 5 ++++- plugins/drum_highway_3d/tests/camera_bridge.test.js | 5 ++++- plugins/highway_3d/screen.js | 5 ++++- plugins/keys_highway_3d/screen.js | 5 ++++- plugins/keys_highway_3d/tests/camera_bridge.test.js | 5 ++++- 5 files changed, 20 insertions(+), 5 deletions(-) diff --git a/plugins/drum_highway_3d/screen.js b/plugins/drum_highway_3d/screen.js index 4b77ef0..595ce06 100644 --- a/plugins/drum_highway_3d/screen.js +++ b/plugins/drum_highway_3d/screen.js @@ -1387,7 +1387,10 @@ if (panelsMap && ss && typeof ss.panelIndexFor === 'function') { try { const i = ss.panelIndexFor(canvas); - if (i != null && panelsMap[i]) return panelsMap[i]; + // Only a non-negative integer indexes the panel map — a non-int / + // negative / string index (or a prototype key) must not resolve an + // unintended/inherited property; fall through to the global then. + if (Number.isInteger(i) && i >= 0 && panelsMap[i]) return panelsMap[i]; } catch (e) { /* ignore */ } } return globalCam || null; diff --git a/plugins/drum_highway_3d/tests/camera_bridge.test.js b/plugins/drum_highway_3d/tests/camera_bridge.test.js index 66b62bb..0b2ec70 100644 --- a/plugins/drum_highway_3d/tests/camera_bridge.test.js +++ b/plugins/drum_highway_3d/tests/camera_bridge.test.js @@ -56,11 +56,14 @@ test('_resolveFreeCam: throw-safe on panelIndexFor → falls back to global', () assert.equal(__test._resolveFreeCam({}, ss, { 0: {} }, g), g); }); -test('_resolveFreeCam: NaN/invalid panel index → falls back to global', () => { +test('_resolveFreeCam: NaN/negative/float/string index → falls back to global', () => { const { __test } = load(); const g = { id: 'global' }; assert.equal(__test._resolveFreeCam({}, { panelIndexFor: () => NaN }, { 0: {} }, g), g); assert.equal(__test._resolveFreeCam({}, { panelIndexFor: () => -1 }, { 0: {} }, g), g); + assert.equal(__test._resolveFreeCam({}, { panelIndexFor: () => 0.5 }, { 0: {} }, g), g); + // A string/prototype key must not resolve an inherited property (e.g. toString). + assert.equal(__test._resolveFreeCam({}, { panelIndexFor: () => 'toString' }, {}, g), g); }); test('_ssApi: null when neither global set; slopsmith alias; feedBack canonical wins', () => { diff --git a/plugins/highway_3d/screen.js b/plugins/highway_3d/screen.js index 8c36baf..af5290d 100644 --- a/plugins/highway_3d/screen.js +++ b/plugins/highway_3d/screen.js @@ -2632,7 +2632,10 @@ if (ss && typeof ss.panelIndexFor === 'function') { try { const i = ss.panelIndexFor(canvas); - if (i != null && map[i]) return map[i]; + // Only a non-negative integer indexes the map (same hardening + // as _bgPanelKey) — a non-int / negative / string index must not + // resolve an unintended/inherited property; fall through then. + if (Number.isInteger(i) && i >= 0 && map[i]) return map[i]; } catch (e) { /* ignore */ } } } diff --git a/plugins/keys_highway_3d/screen.js b/plugins/keys_highway_3d/screen.js index ddd2bbb..ff8e3f5 100644 --- a/plugins/keys_highway_3d/screen.js +++ b/plugins/keys_highway_3d/screen.js @@ -1626,7 +1626,10 @@ if (panelsMap && ss && typeof ss.panelIndexFor === 'function') { try { const i = ss.panelIndexFor(canvas); - if (i != null && panelsMap[i]) return panelsMap[i]; + // Only a non-negative integer indexes the panel map — a non-int / + // negative / string index (or a prototype key) must not resolve an + // unintended/inherited property; fall through to the global then. + if (Number.isInteger(i) && i >= 0 && panelsMap[i]) return panelsMap[i]; } catch (e) { /* ignore */ } } return globalCam || null; diff --git a/plugins/keys_highway_3d/tests/camera_bridge.test.js b/plugins/keys_highway_3d/tests/camera_bridge.test.js index a27e8dd..e723f05 100644 --- a/plugins/keys_highway_3d/tests/camera_bridge.test.js +++ b/plugins/keys_highway_3d/tests/camera_bridge.test.js @@ -56,11 +56,14 @@ test('_resolveFreeCam: throw-safe on panelIndexFor → falls back to global', () assert.equal(__test._resolveFreeCam({}, ss, { 0: {} }, g), g); }); -test('_resolveFreeCam: NaN/invalid panel index → falls back to global', () => { +test('_resolveFreeCam: NaN/negative/float/string index → falls back to global', () => { const { __test } = load(); const g = { id: 'global' }; assert.equal(__test._resolveFreeCam({}, { panelIndexFor: () => NaN }, { 0: {} }, g), g); assert.equal(__test._resolveFreeCam({}, { panelIndexFor: () => -1 }, { 0: {} }, g), g); + assert.equal(__test._resolveFreeCam({}, { panelIndexFor: () => 0.5 }, { 0: {} }, g), g); + // A string/prototype key must not resolve an inherited property (e.g. toString). + assert.equal(__test._resolveFreeCam({}, { panelIndexFor: () => 'toString' }, {}, g), g); }); test('_ssApi: null when neither global set; slopsmith alias; feedBack canonical wins', () => { From 0a1601469837ab33eea1c95d95aca8d280f73c1d Mon Sep 17 00:00:00 2001 From: gionnibgud Date: Thu, 9 Jul 2026 22:40:33 +0200 Subject: [PATCH 08/31] fix(keys_highway_3d): stop auto-connect clobbering the global MIDI device (#825) Opening the keys highway could silently switch the user's configured MIDI device. Two coupled defects in the plugin's MIDI selection: 1. _midiAutoConnect only consulted the plugin's own localStorage pick (keys3d_midi_pick); with none saved it fell straight through to "first non-loopback device", ignoring the core midi-input domain's global selection (Settings -> Input Setup, window.slopsmith.midiInput.getSelected()). 2. _midiConnect unconditionally persisted every connect to BOTH the local pick and the shared domain selection (mi.select). So the first-device guess got frozen locally and overwrote the global default that other consumers (drums, Input Setup) rely on. Make the domain-wide selection the source of truth: _pickMidiTarget now resolves global -> legacy local pick (fallback + name-recovery for stale ids) -> first device, and gates the "don't grab a random device" recovery guard on any configured preference. Gate persistence behind an explicit `persist` flag so only a deliberate device selection writes the local pick and the shared global; auto-connect and programmatic (audio-input) opens open the resolved device for the session without touching either store. mi.select() is not needed to open (open takes the logicalSourceKey directly), so dropping it from the auto path costs nothing. Interim step toward instrument-scoped selection in the midi-input domain itself (the input_setup wizard is already per-instrument, but the domain stores a single selection); tracked as a separate core follow-up. Pure decision logic extracted to _pickMidiTarget and covered by unit tests in data_layer.test.js. Signed-off-by: gionnibgud Co-authored-by: Claude Sonnet 5 --- plugins/keys_highway_3d/screen.js | 119 +++++++++++++----- .../keys_highway_3d/tests/data_layer.test.js | 96 ++++++++++++++ 2 files changed, 182 insertions(+), 33 deletions(-) diff --git a/plugins/keys_highway_3d/screen.js b/plugins/keys_highway_3d/screen.js index ff8e3f5..6277994 100644 --- a/plugins/keys_highway_3d/screen.js +++ b/plugins/keys_highway_3d/screen.js @@ -813,20 +813,36 @@ _writeStore(STORE_KEYS.midiPick, JSON.stringify({ id: id || '', name: name || '', key: key || '' })); } - function _midiAutoConnect(allowFallback) { - // Recovery (sources-changed after unplug) passes false: never switch to a - // fallback input, because _midiConnect persists the pick and that would - // overwrite the user's saved device on a transient multi-device unplug - // (the original returns on replug and reconnects then). - if (allowFallback === undefined) allowFallback = true; - const inputs = _midiSources(); - if (!inputs.length) return; - const saved = _readSavedPick(); - // Explicit "None" opt-out. - if (saved && saved.id === '' && saved.name === '') return; - // Prefer the globally-unique logicalSourceKey, then the legacy bare - // sourceId, then case-insensitive name (Chrome on Linux regenerates ids - // per page load), then first non-loopback. + // Pure decision logic (exported via __test): pick which device to + // auto-connect to from the current source list, the domain-wide selection + // (`globalKey`, from Settings → Input Setup), and this plugin's own legacy + // saved pick. Returns null for "connect to nothing" (explicit None opt-out, + // or the configured device currently absent during hotplug recovery). + // + // The domain-wide selection is the SOURCE OF TRUTH (checked first): a device + // configured globally must never be overridden by a stale plugin-local pick + // or an arbitrary first-device fallback — that override was the bug. The + // local pick is retained only as a fallback BELOW the global (and for + // name-recovery when the global's logicalSourceKey went stale, e.g. a + // browser that regenerates MIDI port ids across reloads). Auto-connect no + // longer writes the local pick, so it only ever holds a value an explicit + // selection put there (or a stale one from a pre-fix build — the global + // still wins over it). + function _pickMidiTarget(inputs, saved, globalKey, allowFallback) { + if (!inputs.length) return null; + const notBlocked = (i) => !!i && !_MIDI_BLOCKLIST_RE.test(i.name || ''); + // Explicit "None" opt-out (set only via the device-select API). + if (saved && saved.id === '' && saved.name === '') return null; + + // 1. Domain-wide selection (Settings → Input Setup) — source of truth. + if (globalKey) { + const g = inputs.find(i => i.key === globalKey); + if (notBlocked(g)) return g; + } + + // 2. Legacy plugin-local pick, as a fallback below the global. Prefer the + // globally-unique logicalSourceKey, then the legacy bare sourceId, then + // case-insensitive name (Chrome on Linux regenerates ids per page load). let target = null; if (saved && saved.key) target = inputs.find(i => i.key === saved.key) || null; if (!target && saved && saved.id) target = inputs.find(i => i.id === saved.id) || null; @@ -834,23 +850,50 @@ const n = saved.name.toLowerCase(); target = inputs.find(i => (i.name || '').toLowerCase() === n) || null; } - // Never honour a saved pick that's a loopback / "Midi Through" port — it - // carries no device input, so a stale pick silently eats every note. The - // saved-pick lookups above bypass the block-list; re-apply it here. - if (target && _MIDI_BLOCKLIST_RE.test(target.name || '')) target = null; - if (!target) { - // Skip the substitute ONLY when a saved pick exists but is currently - // absent (recovery: preserve it, don't clobber on a transient unplug). - // With no saved pick at all, a fallback is the intended first-hotplug - // auto-connect — allow it even in recovery. - const hasSavedPick = !!(saved && (saved.key || saved.id || saved.name)); - if (!allowFallback && hasSavedPick) return; - target = inputs.find(i => !_MIDI_BLOCKLIST_RE.test(i.name || '')) || inputs[0]; - } + // Never honour a saved pick that resolves to a loopback / "Midi Through" + // port — it carries no device input, so it silently eats every note. + if (target && !notBlocked(target)) target = null; + if (target) return target; + + // 3. Nothing configured resolved to a present device. In recovery + // (allowFallback=false) with a configured preference — a global pick or a + // saved pick — that's currently absent, preserve it rather than switching + // to an arbitrary device on a transient multi-device unplug. With no + // preference at all, a first-device grab is the intended first-hotplug + // auto-connect, allowed even in recovery. + const hasPreference = !!(globalKey || (saved && (saved.key || saved.id || saved.name))); + if (!allowFallback && hasPreference) return null; + // Connect to nothing rather than a loopback: if every present device is + // blocklisted, a first-device grab would attach to a "Midi Through"/IAC + // port that carries no input and silently eats every note. + return inputs.find(notBlocked) || null; + } + + function _midiAutoConnect(allowFallback) { + // Recovery (sources-changed after unplug) passes false: never switch to a + // fallback input on a transient multi-device unplug (the configured + // device returns on replug and reconnects then). Auto-connect is + // non-persisting (persist omitted → false): it opens the resolved device + // for this session WITHOUT writing the plugin-local pick or the shared + // domain selection, so opening this highway can't clobber the user's + // globally-configured device. + if (allowFallback === undefined) allowFallback = true; + const inputs = _midiSources(); + const saved = _readSavedPick(); + const mi = _mi(); + const globalKey = mi && typeof mi.getSelected === 'function' ? mi.getSelected() : null; + const target = _pickMidiTarget(inputs, saved, globalKey, allowFallback); + if (!target) return; _midiConnect(target.id, target.name, target.key); } - async function _midiConnect(id, name, key) { + // `persist` gates the two preference writes. Only an EXPLICIT device + // selection (the device-select API) persists: it writes the plugin-local + // pick AND the shared domain selection (`mi.select`, so the user's choice + // becomes the global default). Auto-connect and programmatic opens pass + // falsy — they open the resolved device for this session only, never + // touching either store, so they can't clobber a globally-configured device. + async function _midiConnect(id, name, key, persist) { // Capture our generation AFTER _midiDetach()'s own bump, so a later // detach (device removal / new connect / opt-out) reliably supersedes us. _midiDetach(); @@ -861,7 +904,7 @@ for (const inst of _instances) { if (inst && typeof inst._releaseAllHeld === 'function') inst._releaseAllHeld(); } - _writeSavedPick(id || '', name || '', key || ''); + if (persist) _writeSavedPick(id || '', name || '', key || ''); const mi = _mi(); if ((id || key) && mi) { // Prefer the globally-unique logicalSourceKey so two providers that @@ -874,13 +917,19 @@ const lkey = src.key || ('web-midi::' + src.id); _midiInput = { id: src.id, name: src.name, key: lkey }; _midiJustConnected = true; + // Only an explicit selection writes the shared global default; + // open takes the logicalSourceKey directly, so select() is not + // needed to open — it exists purely to set the global. Persist it + // BEFORE the no-instance early return so a settings-panel pick with + // no live renderer still updates the shared default (best-effort: + // a select hiccup must not abort the connect). + if (persist) { try { await mi.select(lkey); } catch (_) { /* best-effort */ } } // No live renderer to consume OR release a session — don't hold one // open (settings-only ensure-init, or the last instance was torn - // down during async discovery). The pick is saved; a later renderer - // mount re-runs auto-connect and opens for real, releasing on destroy. + // down during async discovery). A later renderer mount re-runs + // auto-connect and opens for real, releasing on destroy. if (_instances.size === 0) { _midiNotifyDeviceListChanged(); return; } try { - await mi.select(lkey); const res = await mi.open({ requester: PLUGIN_ID, logicalSourceKey: lkey }); // A newer _midiConnect (device switch / None / replug) ran while // we awaited open — discard this stale session so we don't wire a @@ -1039,10 +1088,11 @@ window.keysH3dGetMidiInputId = function () { return _midiInput ? _midiInput.id : ''; }; window.keysH3dSetMidiInput = function (id) { // `id` may be a logicalSourceKey (new host calls) or a legacy sourceId. + // Explicit user selection → persist (local pick + shared global default). const src = id ? (_midiSources().find(s => s.key === id) || _midiSources().find(s => s.id === id)) : null; - _midiConnect(src ? src.id : (id || ''), src ? src.name : '', src ? src.key : ''); + _midiConnect(src ? src.id : (id || ''), src ? src.name : '', src ? src.key : '', true); return true; }; window.keysH3dGetMidiChannel = function () { return _cfg.midiChannel; }; @@ -1547,6 +1597,8 @@ function _aiOpen(req) { // Opening a MIDI source connects the corresponding Web MIDI input. + // Programmatic open (audio-input source.open) — non-persisting: it must + // not rewrite the user's saved pick or the shared global default. const idx = _aiIndexFor(req && (req.sourceId || req.logicalSourceKey)); const inputs = _midiSources(); // carries .key (logicalSourceKey), unlike _midiListInputs() if (idx == null || idx >= inputs.length) { @@ -4068,6 +4120,7 @@ FX_DEFAULTS, FX_RANGES, _classifyTiming, + _pickMidiTarget, }; // Headless verification hook: lets Playwright drive synthetic note-ons diff --git a/plugins/keys_highway_3d/tests/data_layer.test.js b/plugins/keys_highway_3d/tests/data_layer.test.js index 1609a8d..a3a8b1f 100644 --- a/plugins/keys_highway_3d/tests/data_layer.test.js +++ b/plugins/keys_highway_3d/tests/data_layer.test.js @@ -188,3 +188,99 @@ test('measureMarkers extracts idx/t pairs', () => { [{ idx: 1, t: 0 }, { idx: 2, t: 2.5 }], ); }); + +test('_pickMidiTarget: no plugin-local pick defers to the domain-wide selection, not "first device"', () => { + const { _pickMidiTarget } = load(); + const inputs = [ + { id: 'a', name: 'Device A', key: 'web-midi::a' }, + { id: 'b', name: 'Device B', key: 'web-midi::b' }, + ]; + // Fresh install / never picked here — must use the Input Setup global, + // NOT fall through to inputs[0]. + const target = _pickMidiTarget(inputs, null, 'web-midi::b', true); + assert.equal(target.id, 'b'); +}); + +test('_pickMidiTarget: the domain-wide selection is the source of truth — it wins over a stale plugin-local pick', () => { + const { _pickMidiTarget } = load(); + const inputs = [ + { id: 'a', name: 'Device A', key: 'web-midi::a' }, + { id: 'b', name: 'Device B', key: 'web-midi::b' }, + ]; + // A stale local pick (e.g. left by a pre-fix build's auto-connect) must + // NOT override the device the user configured in Settings → Input Setup. + const target = _pickMidiTarget(inputs, { id: 'a', name: 'Device A', key: 'web-midi::a' }, 'web-midi::b', true); + assert.equal(target.id, 'b'); +}); + +test('_pickMidiTarget: local pick is used as a fallback when no global is configured', () => { + const { _pickMidiTarget } = load(); + const inputs = [ + { id: 'a', name: 'Device A', key: 'web-midi::a' }, + { id: 'b', name: 'Device B', key: 'web-midi::b' }, + ]; + const target = _pickMidiTarget(inputs, { id: 'a', name: 'Device A', key: 'web-midi::a' }, null, true); + assert.equal(target.id, 'a'); +}); + +test('_pickMidiTarget: local pick name-recovers when its logicalSourceKey went stale (id regeneration)', () => { + const { _pickMidiTarget } = load(); + // Same physical device, new id/key across a reload; the saved key/id miss + // but the name still matches. + const inputs = [{ id: 'a2', name: 'Device A', key: 'web-midi::a2' }]; + const target = _pickMidiTarget(inputs, { id: 'a1', name: 'Device A', key: 'web-midi::a1' }, null, true); + assert.equal(target.id, 'a2'); +}); + +test('_pickMidiTarget: domain-wide selection is ignored if it names a blocklisted loopback port', () => { + const { _pickMidiTarget } = load(); + const inputs = [ + { id: 'thru', name: 'IAC Driver Bus 1', key: 'web-midi::thru' }, + { id: 'b', name: 'Device B', key: 'web-midi::b' }, + ]; + const target = _pickMidiTarget(inputs, null, 'web-midi::thru', true); + assert.equal(target.id, 'b'); // falls through to the first non-loopback device +}); + +test('_pickMidiTarget: when every present device is a loopback, connect to nothing (never a dead port)', () => { + const { _pickMidiTarget } = load(); + const inputs = [ + { id: 'thru', name: 'MIDI Through Port-0', key: 'web-midi::thru' }, + { id: 'iac', name: 'IAC Driver Bus 1', key: 'web-midi::iac' }, + ]; + // No non-loopback device exists — must NOT fall back to inputs[0] (a port + // that carries no input and would silently eat every note). + const target = _pickMidiTarget(inputs, null, null, true); + assert.equal(target, null); +}); + +test('_pickMidiTarget: explicit "None" opt-out still wins over any global default', () => { + const { _pickMidiTarget } = load(); + const inputs = [{ id: 'a', name: 'Device A', key: 'web-midi::a' }]; + const target = _pickMidiTarget(inputs, { id: '', name: '' }, 'web-midi::a', true); + assert.equal(target, null); +}); + +test('_pickMidiTarget: a present global wins even during hotplug recovery', () => { + const { _pickMidiTarget } = load(); + const inputs = [{ id: 'b', name: 'Device B', key: 'web-midi::b' }]; + // The configured global device is present — reconnect to it, don't bail. + const target = _pickMidiTarget(inputs, null, 'web-midi::b', false); + assert.equal(target.id, 'b'); +}); + +test('_pickMidiTarget: recovery (allowFallback=false) preserves an absent configured device instead of grabbing a random one', () => { + const { _pickMidiTarget } = load(); + const inputs = [{ id: 'b', name: 'Device B', key: 'web-midi::b' }]; + // The configured device ('x', global) is currently unplugged; a transient + // recovery must NOT switch to the unrelated device that is present. + const target = _pickMidiTarget(inputs, null, 'web-midi::x', false); + assert.equal(target, null); +}); + +test('_pickMidiTarget: recovery with no preference at all still allows a first-hotplug grab', () => { + const { _pickMidiTarget } = load(); + const inputs = [{ id: 'b', name: 'Device B', key: 'web-midi::b' }]; + const target = _pickMidiTarget(inputs, null, null, false); + assert.equal(target.id, 'b'); +}); From 1c1a0e02681d4f3cf12f46f8eb903030f2339b74 Mon Sep 17 00:00:00 2001 From: OmikronApex <45161725+OmikronApex@users.noreply.github.com> Date: Thu, 9 Jul 2026 22:51:09 +0200 Subject: [PATCH 09/31] =?UTF-8?q?feat(audio):=20renderer-bus=20feeder=20?= =?UTF-8?q?=E2=80=94=20song=20audio=20into=20engine=20output=20under=20exc?= =?UTF-8?q?lusive=20mode=20(Phase=202)=20(#828)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(audio): route feedpak full-mix natively under exclusive output Song playback runs through the renderer, which WASAPI-exclusive (and ASIO) output silences. Route single-mix feedpaks (stem-less original_audio packs AND single-stem packs) onto the engine's backing transport when the output device type is exclusive-style, and migrate back to HTML5 when it isn't. Extends /api/audio-local-path to resolve /api/sloppak/.../file/... URLs via the same containment guards as serve_sloppak_file. Multi-stem packs stay on the WebAudio path (Phase 2). Includes [feedpak-route] transition-gated diagnostics logging. Co-Authored-By: Claude Fable 5 * feat(audio): renderer-bus feeder — mix renderer song audio into engine output (Phase 2) Under exclusive-style output the native backing transport (Phase 1, #824) carries loose /audio/ songs and feedpak full-mixes, but not the stems plugin's multi-stem WebAudio graph or tracks JUCE rejected. The feeder taps the renderer-side master with an AudioWorklet, re-points the owning AudioContext at a null sink so it keeps rendering without a device, and pushes ~10 ms chunks over IPC into the desktop engine's renderer bus (feedBack-desktop#90 follow-up). Inert in the Docker sphere and in shared mode. Validated by the fix12 tester spike: null-sink rendering works, clocks hold, no overflow. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Claude Fable 5 --- static/app.js | 203 +++++++++++++++++++++++++++ tests/js/renderer_bus_feeder.test.js | 195 +++++++++++++++++++++++++ 2 files changed, 398 insertions(+) create mode 100644 tests/js/renderer_bus_feeder.test.js diff --git a/static/app.js b/static/app.js index e70eaad..3287bcb 100644 --- a/static/app.js +++ b/static/app.js @@ -5264,6 +5264,209 @@ window.jucePlayer = jucePlayer; }, 350); })(); +// Renderer-audio bus feeder (desktop Phase 2): when the engine holds the +// output endpoint in an exclusive-style mode, Chromium cannot reach the +// device, so any song audio still played by the renderer goes silent. The +// Phase 1 watcher above already migrates what a single-file transport can +// carry (loose /audio/ songs, feedpak full-mixes) onto the native backing +// transport. This feeder covers the rest — the stems plugin's multi-stem +// WebAudio graph, plus