Merge remote-tracking branch 'origin/main' into pr1008

Signed-off-by: Byron Gamatos <xasiklas@gmail.com>

# Conflicts:
#	plugins/highway_3d/plugin.json
This commit is contained in:
Byron Gamatos 2026-07-19 11:39:17 +02:00
commit 48eba5ef35
2 changed files with 443 additions and 113 deletions

View File

@ -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 <anchor> at or before chart time `t` (sorted by .time).
// Mirrors static/highway.js getAnchorAt — until t reaches the first anchors
// time, the first anchor still defines fret/width.
@ -5255,29 +5305,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);
@ -9565,6 +9617,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;
@ -11505,6 +11573,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;
@ -11530,112 +11600,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);
@ -13582,7 +13670,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);
@ -15866,6 +15977,8 @@
prevLockActive = false;
_camSnapped = false;
_camPreScanned = false;
_camBootstrapHolding = false;
_camBootstrapMode = null;
_songKey = null;
_slideTargetSet = null;
_slideTargetNotesRef = null;

View File

@ -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',
);
});