From 5aa336961c3cafda854d579099b6d68e1cab61e4 Mon Sep 17 00:00:00 2001 From: Kris Anderson Date: Thu, 9 Jul 2026 12:24:29 -0400 Subject: [PATCH 1/7] 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 2/7] 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 3/7] =?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 4/7] 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 5/7] 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 6/7] 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 7/7] 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', () => {