diff --git a/plugins/highway_3d/plugin.json b/plugins/highway_3d/plugin.json index c42de08..e4718ff 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.33.0", + "version": "3.33.1", "type": "visualization", "bundled": true, "script": "screen.js", diff --git a/plugins/highway_3d/screen.js b/plugins/highway_3d/screen.js index c4f4c1d..a96a521 100644 --- a/plugins/highway_3d/screen.js +++ b/plugins/highway_3d/screen.js @@ -1308,6 +1308,56 @@ return lo; } + /** + * Return the chart time of the first fretted event that can still affect + * the camera at `now`, or the next fretted onset after it. + * + * This is intentionally a one-time full-chart scan. It runs only when a + * new song/arrangement's arrays first arrive, allowing the camera to frame + * the opening phrase during a silent intro instead of waiting for that + * phrase to enter the live targeting window. Open strings do not define a + * horizontal fret target, and malformed/out-of-range strings are ignored. + * + * Events already inside the behind-window, plus older sustains that are + * still ringing at `now`, return `now` so bootstrap framing matches the + * ordinary live path. Future events return their onset time. + */ + function hwyFirstRelevantFrettedTime(notes, chords, now, behind, stringCount) { + const nStrings = Number.isFinite(stringCount) ? Math.max(0, Math.floor(stringCount)) : 0; + const cameraFloor = now - Math.max(0, Number(behind) || 0); + let first = Infinity; + + const validFretted = n => n + && n.f > 0 + && Number.isInteger(n.s) + && n.s >= 0 + && n.s < nStrings; + const consider = (eventTime, sustain) => { + const t = Number(eventTime); + if (!Number.isFinite(t)) return; + const sus = Number(sustain); + const end = t + (Number.isFinite(sus) && sus > 0 ? sus : 0); + if (t < cameraFloor && end < now) return; + const relevantTime = t <= now ? now : t; + if (relevantTime < first) first = relevantTime; + }; + + if (notes) { + for (const n of notes) { + if (validFretted(n)) consider(n.t, n.sus); + } + } + if (chords) { + for (const ch of chords) { + if (!ch || !ch.notes) continue; + for (const cn of ch.notes) { + if (validFretted(cn)) consider(ch.t, cn.sus); + } + } + } + return Number.isFinite(first) ? first : null; + } + // Last arrangement at or before chart time `t` (sorted by .time). // Mirrors static/highway.js getAnchorAt — until t reaches the first anchor’s // time, the first anchor still defines fret/width. @@ -4904,29 +4954,31 @@ let prevLockActive = false; let tgtLookY = 0, curLookY = 0; // lerped look-at Y for self-correcting camera let aspectScale = 1; - // _camSnapped / _camPreScanned / _songKey: together they gate the first-data snap. + // _camSnapped / _camPreScanned / _songKey: together they gate the + // first-data bootstrap. // - // On the first update() frame where bundle.notes is available, - // _camPreScanned is set and the full notes array is scanned (O(N), once) - // to check whether ANY fretted note (f > 0) exists. If none do (e.g. an - // all-open-string bass arrangement), _camSnapped is set to true immediately - // so the per-frame pre-pass is disabled for the entire song. + // On the first update() frame where both chart arrays are available, + // they are scanned once (O(N)) for the first relevant fretted event. + // The normal camera-target calculation is sampled at the point where + // that event first enters its targeting window, and curX/curDist are + // initialized immediately. This makes silent intros start with the same + // base framing they would otherwise acquire just before the first notes. // - // For charts that do have fretted notes, a lightweight O(window) pre-pass - // runs before any drawNote() call on every frame until the first frame - // where fretted notes appear in the camera targeting window (preWSum > 0). - // At that point curX/curDist are snapped directly to the computed targets, - // eliminating the camera swoop for songs with long silent intros. + // _camBootstrapHolding keeps that initialized target stable through the + // empty intro. It is released as soon as the ordinary live window has + // fret bounds/data, producing a continuous hand-off with no second snap. + // If the camera mode changes during the hold, live framing takes over. // - // Once _camSnapped is true it is never cleared for the current song; the - // pre-pass is a permanent no-op thereafter and the camera reverts to - // normal lerp-based tracking for the rest of the song. + // All-open/empty charts have no horizontal fret target, so they keep the + // default base view and disable bootstrap work immediately. // - // _songKey tracks the active song/arrangement so the snap state resets + // _songKey tracks the active song/arrangement so the bootstrap state resets // automatically when the user switches songs or arrangements via // reconnect() (which does not call renderer.destroy/init). let _camSnapped = false; let _camPreScanned = false; + let _camBootstrapHolding = false; + let _camBootstrapMode = null; let _songKey = null; // Smooth lookahead camera: fused world-X and displayed fret-span. let _lookaheadCamX = xFretMid(CAM_LOCK_CENTER_FRET); @@ -9214,6 +9266,22 @@ return now + CAM_LOOKAHEAD_SEC; } + // Earliest future chart time whose lookahead end reaches eventTime. + // lookaheadEndTime() is monotonic but measure-stepped, so a small + // bounded binary search works for both measure grids and the seconds + // fallback without duplicating/inverting its edge-case logic. + function lookaheadBootstrapTime(now, eventTime) { + if (!(eventTime > now) || lookaheadEndTime(now) >= eventTime) return now; + let lo = now; + let hi = eventTime; + for (let i = 0; i < 32; i++) { + const mid = (lo + hi) * 0.5; + if (lookaheadEndTime(mid) >= eventTime) hi = mid; + else lo = mid; + } + return hi; + } + function lookaheadComputeFretBounds(now, anchors, notes, chords) { const tEnd = lookaheadEndTime(now); let minF = 99; @@ -11154,6 +11222,8 @@ _songKey = key; _camSnapped = false; _camPreScanned = false; + _camBootstrapHolding = false; + _camBootstrapMode = null; tgtX = curX = xFretMid(CAM_LOCK_CENTER_FRET); tgtDist = curDist = CAM_DIST_BASE; prevLowFretBonus = 0; @@ -11179,112 +11249,130 @@ } } - // ── Camera pre-pass (first-data snap) ──────────────────────────── - // Before any drawNote() call, iterate notes/chords to accumulate - // the camera targeting data for THIS frame. If this is the first - // frame where fretted notes appear in the targeting window, snap - // curX/curDist directly to the computed targets so open-string note - // placement (which reads curX) and the camera are consistent on the - // snap frame. After the snap _camSnapped is true and this block - // becomes a permanent no-op. Open-string notes (f === 0) do not - // contribute to preWX/preWSum and therefore do not trigger the snap. - if (!_camSnapped) { - // One-time full-chart scan (runs exactly once when both bundle.notes - // and bundle.chords are available). If no fretted note exists - // anywhere in either array the snap can never fire, so we disable - // the per-frame pre-pass immediately to avoid permanent overhead. - // Both arrays are checked because some arrangements have fretted - // notes only inside chords (chord-only charts, keys arrangements). - if (!_camPreScanned && notes && chords) { - _camPreScanned = true; - const hasFrettedNote = notes.some(n => n.f > 0 && validString(n.s)); - const hasFrettedChord = chords.some( - ch => ch.notes && ch.notes.some(cn => cn.f > 0 && validString(cn.s))); - if (!hasFrettedNote && !hasFrettedChord) _camSnapped = true; - } - if (!_camSnapped) { - if (cameraMode === 'lookahead') { - const bd = lookaheadBoundsNow; - if (bd) { - _lookaheadCamX = lookaheadTargetWorldX(bd.minF, bd.maxF); - _lookaheadFretSpan = Math.max(1, bd.maxF - bd.minF + 1); - const lockSnapEl = cameraLockLow && bd.maxF <= 12; - if (lockSnapEl) { - const lockedBaseU = camBaseDistU(12); - const lockedBonusU = camLowFretPullbackU(1); - const lockZoomMul = CAM_LOCK_ZOOM_MIN + - (CAM_LOCK_ZOOM_MAX - CAM_LOCK_ZOOM_MIN) * cameraLockZoom; - tgtX = xFretMid(CAM_LOCK_CENTER_FRET); - tgtDist = (lockedBaseU + lockedBonusU) * K * lockZoomMul; - prevLowFretBonus = lockedBonusU; - _lookaheadLowBonusU = lockedBonusU; - } else { - const baseDU = camBaseDistU(_lookaheadFretSpan); - const lowBU = camLowFretPullbackU(bd.minF); - tgtDist = (baseDU + lowBU) * K; - prevLowFretBonus = lowBU; - _lookaheadLowBonusU = lowBU; - tgtX = _lookaheadCamX; - } - curX = tgtX; - curDist = tgtDist; - _camSnapped = true; - _lookaheadCamPrevNow = now; + // ── Camera bootstrap (first chart data) ────────────────────────── + // Initialize against the first relevant fretted phrase as soon as + // the complete chart arrays arrive. For a future phrase, sample the + // same window state that live framing will have when the phrase + // first becomes relevant, then hold it through the silent intro. + // This is O(N) once per song/arrangement and a permanent no-op after. + if (!_camSnapped && !_camPreScanned && notes && chords) { + _camPreScanned = true; + const firstFrettedTime = hwyFirstRelevantFrettedTime( + notes, chords, now, CAM_TGT_BEHIND, nStr); + + if (cameraMode === 'lookahead' + && (lookaheadBoundsNow || firstFrettedTime !== null)) { + // Anchors can make the live lookahead valid before the first + // fretted event does. Prefer that already-current framing; + // only project forward when the live window is truly empty. + const bootstrapNow = lookaheadBoundsNow + ? now + : lookaheadBootstrapTime(now, firstFrettedTime); + const bd = lookaheadBoundsNow + || lookaheadComputeFretBounds(bootstrapNow, anchors, notes, chords); + if (bd) { + _lookaheadCamX = lookaheadTargetWorldX(bd.minF, bd.maxF); + _lookaheadFretSpan = Math.max(1, bd.maxF - bd.minF + 1); + const lockSnapEl = cameraLockLow && bd.maxF <= 12; + if (lockSnapEl) { + const lockedBaseU = camBaseDistU(12); + const lockedBonusU = camLowFretPullbackU(1); + const lockZoomMul = CAM_LOCK_ZOOM_MIN + + (CAM_LOCK_ZOOM_MAX - CAM_LOCK_ZOOM_MIN) * cameraLockZoom; + tgtX = xFretMid(CAM_LOCK_CENTER_FRET); + tgtDist = (lockedBaseU + lockedBonusU) * K * lockZoomMul; + prevLowFretBonus = lockedBonusU; + _lookaheadLowBonusU = lockedBonusU; + prevLockActive = true; + } else { + const baseDU = camBaseDistU(_lookaheadFretSpan); + const lowBU = camLowFretPullbackU(bd.minF); + tgtDist = (baseDU + lowBU) * K; + prevLowFretBonus = lowBU; + _lookaheadLowBonusU = lowBU; + tgtX = _lookaheadCamX; + prevLockActive = false; } + curX = tgtX; + curDist = tgtDist; + _camSnapped = true; + _lookaheadCamPrevNow = now; + _camBootstrapHolding = bootstrapNow > now && !lookaheadBoundsNow; + _camBootstrapMode = _camBootstrapHolding ? cameraMode : null; } else { - let preWX = 0, preWSum = 0, preDistMin = 99, preDistMax = 0, preDistGot = false; - if (notes) { - for (const n of notes) { - // bundle.notes is time-sorted: skip fully-expired sustains, - // break once the onset is beyond the camera window. - if (n.t + (n.sus || 0) < camT0) continue; - if (n.t > camT1) break; - if (!validString(n.s)) continue; - const nInWin = n.f > 0 && n.t >= camT0; - const nSusNow = n.f > 0 && n.t < camT0 && n.t + (n.sus || 0) >= now; - if (nInWin || nSusNow) { - const w = Math.exp(-Math.abs(n.t - now) / camTau); - preWX += xFretMid(n.f) * w; preWSum += w; - if (n.f < preDistMin) preDistMin = n.f; - if (n.f > preDistMax) preDistMax = n.f; + // Defensive fallback for malformed chart timing. The + // helper found a fretted event, so this should be + // unreachable; keeping the default is safer than a + // delayed mid-song snap. + _camSnapped = true; + } + } else if (firstFrettedTime === null) { + // Empty and all-open charts without lookahead anchor bounds + // have no horizontal fret target. + _camSnapped = true; + } else { + const bootstrapNow = Math.max(now, firstFrettedTime - camAhead); + const bootstrapT0 = bootstrapNow - CAM_TGT_BEHIND; + const bootstrapT1 = bootstrapNow + camAhead; + let preWX = 0, preWSum = 0; + let preDistMin = 99, preDistMax = 0, preDistGot = false; + + for (const n of notes) { + if (n.t + (n.sus || 0) < bootstrapT0) continue; + if (n.t > bootstrapT1) break; + if (!validString(n.s)) continue; + const nInWin = n.f > 0 && n.t >= bootstrapT0; + const nSusNow = n.f > 0 && n.t < bootstrapT0 + && n.t + (n.sus || 0) >= bootstrapNow; + if (nInWin || nSusNow) { + const w = Math.exp(-Math.abs(n.t - bootstrapNow) / camTau); + preWX += xFretMid(n.f) * w; + preWSum += w; + if (n.f < preDistMin) preDistMin = n.f; + if (n.f > preDistMax) preDistMax = n.f; + preDistGot = true; + } + } + for (const ch of chords) { + if (!ch.notes) continue; + if (ch.t > bootstrapT1) break; + const chNotes = filterValidNotes(ch.notes); + if (!chNotes.length) continue; + let maxSus = 0; + for (const n of chNotes) if ((n.sus || 0) > maxSus) maxSus = n.sus; + if (ch.t + maxSus < bootstrapT0) continue; + const chOnsetInWin = ch.t >= bootstrapT0; + const chSusNow = ch.t < bootstrapT0 + && ch.t + maxSus >= bootstrapNow; + if (!chOnsetInWin && !chSusNow) continue; + const chW = Math.exp(-Math.abs(ch.t - bootstrapNow) / camTau); + for (const cn of chNotes) { + const cnOk = chOnsetInWin + || (chSusNow && ch.t + (cn.sus || 0) >= bootstrapNow); + if (cn.f > 0 && cnOk) { + preWX += xFretMid(cn.f) * chW; + preWSum += chW; + if (cn.f < preDistMin) preDistMin = cn.f; + if (cn.f > preDistMax) preDistMax = cn.f; preDistGot = true; } } } - if (chords) { - for (const ch of chords) { - if (!ch.notes) continue; - // bundle.chords is time-sorted: break once onset is beyond window. - if (ch.t > camT1) break; - const chNotes = filterValidNotes(ch.notes); - if (!chNotes.length) continue; - let maxSus = 0; - for (const n of chNotes) if ((n.sus || 0) > maxSus) maxSus = n.sus; - if (ch.t + maxSus < camT0) continue; // fully expired - const chOnsetInWin = ch.t >= camT0; - const chSusNow = ch.t < camT0 && ch.t + maxSus >= now; - if (!chOnsetInWin && !chSusNow) continue; - const chW = Math.exp(-Math.abs(ch.t - now) / camTau); - for (const cn of chNotes) { - const cnOk = chOnsetInWin || (chSusNow && ch.t + (cn.sus || 0) >= now); - if (cn.f > 0 && cnOk) { - preWX += xFretMid(cn.f) * chW; preWSum += chW; - if (cn.f < preDistMin) preDistMin = cn.f; - if (cn.f > preDistMax) preDistMax = cn.f; - preDistGot = true; - } - } - } - } + if (preWSum > 0) { - _applyNoteCamTargets(preWX, preWSum, preDistMin, preDistMax, preDistGot, - camHystF, camDistHystF, /* skipDistHyst= */ true); - curX = tgtX; + prevLockActive = _applyNoteCamTargets( + preWX, preWSum, preDistMin, preDistMax, preDistGot, + camHystF, camDistHystF, /* skipDistHyst= */ true); + curX = tgtX; curDist = tgtDist; - _camSnapped = true; } - } // end steady-mode pre-pass branch - } // end !_camSnapped (post-prescan guard) + // The relevant-event helper and this accumulator share + // validity/window rules; still finish defensively if a + // malformed event could not produce a target. + _camSnapped = true; + _camBootstrapHolding = preWSum > 0 && bootstrapNow > now; + _camBootstrapMode = _camBootstrapHolding ? cameraMode : null; + } } pbBeg(4); @@ -13231,7 +13319,30 @@ // ── Camera target ───────────────────────────────────────────── let lockActive; - if (!(cameraMode === 'lookahead')) { + let bootstrapHoldActive = false; + if (_camBootstrapHolding) { + if (_camBootstrapMode !== cameraMode) { + _camBootstrapHolding = false; + _camBootstrapMode = null; + } else { + const liveFramingReady = cameraMode === 'lookahead' + ? lookaheadBoundsNow !== null + : camDistGot; + if (liveFramingReady) { + _camBootstrapHolding = false; + _camBootstrapMode = null; + } else { + bootstrapHoldActive = true; + } + } + } + + if (bootstrapHoldActive) { + // Keep the chart-load target intact until the ordinary live + // path can compute the same phrase. Camera Director still + // layers its free-camera transform in camUpdate(). + lockActive = prevLockActive; + } else if (!(cameraMode === 'lookahead')) { lockActive = _applyNoteCamTargets( camWX, camWSum, camDistMin, camDistMax, camDistGot, camHystF, camDistHystF, /* skipDistHyst= */ false); @@ -15515,6 +15626,8 @@ prevLockActive = false; _camSnapped = false; _camPreScanned = false; + _camBootstrapHolding = false; + _camBootstrapMode = null; _songKey = null; _slideTargetSet = null; _slideTargetNotesRef = null; diff --git a/tests/js/highway_3d_camera_bootstrap.test.js b/tests/js/highway_3d_camera_bootstrap.test.js new file mode 100644 index 0000000..bc12193 --- /dev/null +++ b/tests/js/highway_3d_camera_bootstrap.test.js @@ -0,0 +1,217 @@ +// Regression coverage for the first-chart-data camera bootstrap in +// plugins/highway_3d/screen.js. +// +// The event selector is pure and tested behaviourally. The renderer lifecycle +// wiring remains source-level, matching the existing highway_3d camera tests: +// constructing a full Three.js renderer in Node would test a large fake DOM/GL +// harness rather than the bootstrap contract itself. + +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const SCREEN_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'screen.js'); +const src = fs.readFileSync(SCREEN_JS, 'utf8'); + +function extractFn(source, name) { + const start = source.indexOf('function ' + name); + assert.ok(start >= 0, `function ${name} must exist`); + const open = source.indexOf('{', start); + let depth = 0; + for (let i = open; i < source.length; i++) { + if (source[i] === '{') depth++; + else if (source[i] === '}' && --depth === 0) return source.slice(start, i + 1); + } + throw new Error(`unbalanced braces extracting ${name}`); +} + +function sourceBetween(startText, endText) { + const start = src.indexOf(startText); + assert.ok(start >= 0, `missing source anchor: ${startText}`); + const end = src.indexOf(endText, start); + assert.ok(end > start, `missing source end anchor: ${endText}`); + return src.slice(start, end); +} + +const hwyFirstRelevantFrettedTime = new Function( + '"use strict";' + + extractFn(src, 'hwyFirstRelevantFrettedTime') + + '\nreturn hwyFirstRelevantFrettedTime;', +)(); + +test('long intros bootstrap from the earliest future fretted note', () => { + const notes = [ + { t: 13.22, s: 2, f: 7 }, + { t: 15.0, s: 1, f: 4 }, + ]; + const chords = [ + { t: 14.0, notes: [{ s: 0, f: 3 }, { s: 1, f: 5 }] }, + ]; + assert.equal(hwyFirstRelevantFrettedTime(notes, chords, 0.4, 0.2, 6), 13.22); +}); + +test('chord-only charts bootstrap from fretted chord members', () => { + const chords = [ + { t: 4.0, notes: [{ s: 0, f: 0 }, { s: 1, f: 0 }] }, + { t: 8.5, notes: [{ s: 0, f: 0 }, { s: 1, f: 9 }] }, + ]; + assert.equal(hwyFirstRelevantFrettedTime([], chords, 0, 0.2, 6), 8.5); +}); + +test('empty and all-open charts keep the default camera', () => { + assert.equal(hwyFirstRelevantFrettedTime([], [], 0, 0.2, 6), null); + assert.equal(hwyFirstRelevantFrettedTime( + [{ t: 2, s: 0, f: 0 }], + [{ t: 3, notes: [{ s: 1, f: 0 }, { s: 2, f: 0 }] }], + 0, + 0.2, + 6, + ), null); +}); + +test('bootstrap ignores malformed strings but supports extended-range charts', () => { + const notes = [ + { t: 1, s: -1, f: 4 }, + { t: 2, s: 7, f: 5 }, + { t: 3, s: 6, f: 8 }, + ]; + assert.equal(hwyFirstRelevantFrettedTime(notes, [], 0, 0.2, 6), null); + assert.equal(hwyFirstRelevantFrettedTime(notes, [], 0, 0.2, 7), 3); +}); + +test('active sustains bootstrap at now and fully expired events are skipped', () => { + const now = 10; + assert.equal(hwyFirstRelevantFrettedTime( + [{ t: 6, sus: 5, s: 2, f: 7 }], + [], + now, + 0.2, + 6, + ), now); + assert.equal(hwyFirstRelevantFrettedTime( + [{ t: 6, sus: 1, s: 2, f: 7 }, { t: 15, s: 2, f: 9 }], + [], + now, + 0.2, + 6, + ), 15); +}); + +test('recent onsets inside the behind-window bootstrap at now', () => { + assert.equal(hwyFirstRelevantFrettedTime( + [{ t: 9.9, s: 2, f: 7 }], + [], + 10, + 0.2, + 6, + ), 10); +}); + +test('bootstrap runs once when complete chart arrays arrive', () => { + const bootstrap = sourceBetween( + '// ── Camera bootstrap (first chart data)', + ' pbBeg(4);', + ); + assert.match( + bootstrap, + /if\s*\(\s*!_camSnapped\s*&&\s*!_camPreScanned\s*&&\s*notes\s*&&\s*chords\s*\)/, + 'chart bootstrap must be gated to one pass after both arrays arrive', + ); + assert.match( + bootstrap, + /hwyFirstRelevantFrettedTime\(\s*notes\s*,\s*chords\s*,\s*now\s*,\s*CAM_TGT_BEHIND\s*,\s*nStr\s*\)/, + 'bootstrap must select the first relevant event using the active string count', + ); + assert.match( + bootstrap, + /firstFrettedTime\s*===\s*null[\s\S]*?_camSnapped\s*=\s*true/, + 'all-open/empty charts without lookahead bounds must permanently disable bootstrap work', + ); +}); + +test('steady and lookahead modes initialize immediately from future chart data', () => { + const bootstrap = sourceBetween( + '// ── Camera bootstrap (first chart data)', + ' pbBeg(4);', + ); + assert.match( + bootstrap, + /cameraMode\s*===\s*'lookahead'[\s\S]*?lookaheadBoundsNow\s*\|\|\s*firstFrettedTime\s*!==\s*null/, + 'lookahead anchor bounds must bootstrap even on an all-open chart', + ); + assert.match( + bootstrap, + /lookaheadBootstrapTime\(\s*now\s*,\s*firstFrettedTime\s*\)/, + 'lookahead mode must project to the first window that reaches the phrase', + ); + assert.match( + bootstrap, + /lookaheadBoundsNow\s*\?\s*now\s*:\s*lookaheadBootstrapTime/, + 'already-live anchor/note bounds must win over a projected lookahead', + ); + assert.match( + bootstrap, + /Math\.max\(\s*now\s*,\s*firstFrettedTime\s*-\s*camAhead\s*\)/, + 'steady mode must sample when the first event enters its normal target window', + ); + assert.match( + bootstrap, + /curX\s*=\s*tgtX\s*;[\s\S]*?curDist\s*=\s*tgtDist\s*;/, + 'the initial base position must be applied before the note draw loop', + ); +}); + +test('silent-intro hold hands off only when live framing is ready', () => { + const target = sourceBetween( + '// ── Camera target', + '// ── Chord diagram:', + ); + assert.match( + target, + /cameraMode\s*===\s*'lookahead'\s*\?\s*lookaheadBoundsNow\s*!==\s*null\s*:\s*camDistGot/, + 'lookahead and steady modes must use their own live-ready signal', + ); + assert.match( + target, + /if\s*\(\s*bootstrapHoldActive\s*\)[\s\S]*?lockActive\s*=\s*prevLockActive/, + 'the bootstrap target must remain untouched while the live window is empty', + ); + assert.match( + target, + /_camBootstrapMode\s*!==\s*cameraMode[\s\S]*?_camBootstrapHolding\s*=\s*false/, + 'a live camera-mode change must safely release the old-mode hold', + ); +}); + +test('song changes and teardown reset every bootstrap state field', () => { + const resetAssignments = src.match( + /_camSnapped\s*=\s*false\s*;\s*\r?\n\s*_camPreScanned\s*=\s*false\s*;\s*\r?\n\s*_camBootstrapHolding\s*=\s*false\s*;\s*\r?\n\s*_camBootstrapMode\s*=\s*null\s*;/g, + ) || []; + assert.equal( + resetAssignments.length, + 2, + 'song-change and teardown paths must both reset bootstrap state', + ); +}); + +test('Camera Director still layers after the bootstrapped auto-framing base', () => { + const bootstrap = sourceBetween( + '// ── Camera bootstrap (first chart data)', + ' pbBeg(4);', + ); + assert.doesNotMatch( + bootstrap, + /_freeCam|__h3dCamCtl/, + 'bootstrap must only initialize base framing, never mutate Camera Director state', + ); + + const camUpdate = extractFn(src, 'camUpdate'); + const baseIndex = camUpdate.indexOf('curX += (tgtX - curX) * lerp'); + const directorIndex = camUpdate.indexOf('if (_freeCam && _freeCam.enabled)'); + const positionIndex = camUpdate.indexOf('cam.position.set(_camX, _camY, _camZ)'); + assert.ok( + baseIndex >= 0 && directorIndex > baseIndex && positionIndex > directorIndex, + 'Camera Director transforms must remain layered after base framing and before camera placement', + ); +});