From d66d41180484f08315e6e2683cdd3cd46068de06 Mon Sep 17 00:00:00 2001 From: "pullfrog[bot]" <226033991+pullfrog[bot]@users.noreply.github.com> Date: Tue, 6 Oct 2026 15:35:37 +0000 Subject: [PATCH] perf(keys_highway_3d): window updateScene to the visible note span (#95) updateScene walked every note mesh twice per frame (camera framing, positions/labels/key glow) plus every bar-number sprite, so its cost grew with the length of the chart however little of it was on screen. Both t-sorted arrays are now windowed to the drawable span via advanceTWindow: entries that fall out are parked hidden exactly once, and the loops walk only [lo, hi). Bounds derive from the runway geometry and the chart's longest note (shared NOTE_LINGER / MARKER_LINGER constants with the in-loop hides), so nothing drawable is skipped. --- CHANGELOG.md | 1 + plugins/keys_highway_3d/screen.js | 164 +++++++++++++++++- .../keys_highway_3d/tests/data_layer.test.js | 130 ++++++++++++++ .../tests/instance_invariants.test.js | 40 +++++ 4 files changed, 328 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 349f6e17..01063cb1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,6 +36,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Mastery slider maps through phrase tier numbers.** A phrase's `max_difficulty` now defines its tier scale and each level's `difficulty` the tier where its content starts: the slider picks tier `floor(mastery * (max_difficulty + 1))` and the phrase plays its last level at or below it (`phraseLevelIndexForMastery`, `static/highway.js`). Fully authored ladders (levels `0..n-1`) map exactly as before. `collapse_arrangement_phrases()` (`lib/song.py`) no longer renumbers a partially collapsed ladder to `0..k`; surviving levels keep the tier where their content first appears and the phrase keeps its `max_difficulty`, so a level still covers the slider band it was authored for instead of being stretched across the whole slider (a phrase collapsed to one level still reports `max_difficulty` 0). This lets difficulty_ladder's generated ladders share one song-wide scale, so a slider position means the same difficulty in every phrase. `highway.getPhrases()` additionally reports `top_difficulty`, the tier from which a phrase plays in full. - **highway_3d: chord diagram widget lingers longer before fading** (`plugins/highway_3d`). The floating chord-shape diagram was visible for only ~0.55s after a chord's onset, reported (against the wrong repo — `feedBack-plugin-fretboard`#2/#3) as "appears and disappears too quickly to read or form the shape." Roughly doubled to 1.1s. See #72 for the cross-repo mismatch this traces back to. +- **keys_highway_3d: `updateScene` now iterates only the notes on screen** (#95). The per-frame scene update walked every note mesh twice (camera pan/zoom framing, then positions/labels/key-glow) plus every bar-number sprite, so its cost grew with the length of the chart however little of it was visible. Both t-sorted arrays are now windowed to the span that can still be drawn (`advanceTWindow`: a per-frame lower-bound search over `[now - behind, now + ahead]`): entries that slide out of the window are parked hidden exactly once, the camera loop, the note loop and the marker loop each walk only their in-window slice, and the bounds are derived from the runway geometry and the longest note in the chart, so nothing drawable is skipped — the per-entry visibility checks are kept and remain exact for bar numbers. ### Security diff --git a/plugins/keys_highway_3d/screen.js b/plugins/keys_highway_3d/screen.js index 51afa206..cf221601 100644 --- a/plugins/keys_highway_3d/screen.js +++ b/plugins/keys_highway_3d/screen.js @@ -631,6 +631,91 @@ return hitZ - (eventT - now) * speed; } + /* ====================================================================== + * updateScene's draw window (feedBack#95) + * ====================================================================== */ + + // updateScene walks only the t-sorted entries whose onset sits inside + // [now - behind, now + ahead]: past `ahead` a note's front edge has + // scrolled off the far end of the runway, and past `behind` it has been + // fully consumed and its name label has finished fading. Per-frame cost + // then tracks the span on screen, not the chart's length. Both bounds + // are seconds relative to `now`, and at stock geometry the window is + // [~-0.62, ~8.67]s — wide enough to contain every span updateScene + // filters on inside it (the camera framing band [-0.4, CAM_ZOOM_AHEAD] + // and the key-approach glow [-0.05, KEY_GLOW_AHEAD]), so those filters + // keep their exact old behaviour. That containment is pinned against + // stock literals by the window-band test in data_layer.test.js. + + // Far end: the front edge (hitZ - dt*TS, see scrollZ) reaches -highwayLen + // exactly at dt = (highwayLen + hitZ) / TS — the time-domain equivalent + // of updateScene's `frontZ < -HIGHWAY_LEN` hide. hitZ sits slightly on + // the -Z side of the origin, so this lands just under highwayLen/TS. + function noteWindowAheadS(highwayLen, hitZ) { + return (highwayLen + hitZ) / TS; + } + + // Linger margins: how far past the hit-line a consumed note / bar number + // is still drawn (updateScene's `backZ > hitZ + NOTE_LINGER` and `z > + // hitZ + MARKER_LINGER` hides). The window's near bound is built from + // these SAME constants — a loop hide check that grew without its bound + // would let the window evict entries while they were still drawable. + const NOTE_LINGER = 20 * K; + const MARKER_LINGER = 6 * K; + + // Near end: whichever of the two lingerers outlasts the other — the + // note's own consumption (hidden once `len + NOTE_LINGER` world units + // have scrolled past the hit-line) or its name label (LABEL_FADE_DIST + // past the hit-line). Derived from the CHART's longest note, so the + // single shared bound covers every note's own exit; shorter notes simply + // leave the window later than they stop being drawn (the loop's + // per-entry geometry check hides them on the frames in between). + function noteWindowBehindS(maxNoteLen) { + return Math.max(LABEL_FADE_DIST, maxNoteLen + NOTE_LINGER) / TS; + } + + // Bar numbers have no label and no length: one leaves the window + // MARKER_LINGER/TS seconds past its onset — the time it takes to scroll + // past `hitZ + MARKER_LINGER` (updateScene's marker hide). + const MARKER_WIN_BEHIND_S = MARKER_LINGER / TS; + + // First index of the key-sorted `arr` at (`atOrAfter`) / past + // (`atOrAfter` false) `t`, by `keyOf`. Allocation-free binary search — + // the same shape as sweepStartIndex. + function _tBound(keyOf, arr, t, atOrAfter) { + let lo = 0, hi = arr.length; + while (lo < hi) { + const mid = (lo + hi) >> 1; + if (atOrAfter ? keyOf(arr[mid]) < t : keyOf(arr[mid]) <= t) lo = mid + 1; + else hi = mid; + } + return lo; + } + + // Advance the cached window `win` ({lo, hi}) over the key-sorted + // `entries` to cover [tLo, tHi]. Entries that just fell out of the + // PREVIOUS window are reported through `onHide(lo, hi)` — the only + // entries whose drawn state went stale this frame: a note that has been + // hidden inside the window for a while is simply skipped, and an entry + // that merely ENTERS the window needs no callback because the caller + // processes it immediately after. `win.lo < 0` marks a fresh or rebuilt + // array, where every entry starts hidden and nothing needs hiding. + // Pure; exported for tests. + function advanceTWindow(keyOf, entries, tLo, tHi, win, onHide) { + const lo = _tBound(keyOf, entries, tLo, true); + const hi = _tBound(keyOf, entries, tHi, false); + if (win.lo >= 0) { + if (lo > win.lo) onHide(win.lo, lo); + if (hi < win.hi) onHide(hi, win.hi); + } + win.lo = lo; + win.hi = hi; + } + + // Sort keys of the two t-sorted per-frame arrays updateScene windows. + const _noteMeshT = (entry) => entry.note.t; + const _markerT = (entry) => entry.t; + /* ====================================================================== * Pure scoring logic (exported via createFactory.__test) * ====================================================================== */ @@ -1914,8 +1999,15 @@ let _camX = 0, _camTargetX = 0; // camera x pan-follow state (updateScene) let _camZoom = 1, _camTargetZoom = 1; // adaptive dolly-zoom to the note span let notesGroup = null, keyboardGroup = null, markersGroup = null, hitLine = null; - let noteMeshes = []; // [{mesh, note, len}] + let noteMeshes = []; // [{mesh, note, len, label}] let markerSprites = []; // [{sprite, t}] + // Draw-window cursors over the two t-sorted arrays above, advanced + // once per frame by advanceTWindow (feedBack#95). `lo < 0` marks a + // fresh or rebuilt array, whose entries all start hidden — the first + // frame after that hides nothing. + const _noteWin = { lo: -1, hi: -1 }; + const _markerWin = { lo: -1, hi: -1 }; + let _maxNoteLen = 0; // longest `len` among noteMeshes — sets the note window's near edge let keyMeshes = new Map(); // midi → mesh let _isReady = false; // `masteryPlayable` excludes notes hidden by difficulty; `playable` @@ -3313,6 +3405,8 @@ _clearGroup(notesGroup); _clearNoteCaches(); noteMeshes = []; + _maxNoteLen = 0; + _noteWin.lo = _noteWin.hi = -1; const range = _notation.range; const { layout, whiteCount } = keyLayout(range); // Mastery-filtered subset (feedBack#67) — falls back to every @@ -3321,6 +3415,7 @@ const entry = layout.get(note.midi); if (!entry) continue; const len = Math.max(4 * K, note.durSec * TS); + if (len > _maxNoteLen) _maxNoteLen = len; // Non-floating layouts: notes ride the naturals' plane and take // their piano-shaped lane's width/centre. Floating (default): // original elevated sharps, key-centred bars. @@ -3381,6 +3476,7 @@ _clearGroup(markersGroup); _clearBarTextures(); markerSprites = []; + _markerWin.lo = _markerWin.hi = -1; const { whiteCount } = keyLayout(_notation.range); const totalW = whiteCount * WHITE_W; for (const marker of _notation.markers) { @@ -3397,6 +3493,11 @@ markersGroup.add(sprite); markerSprites.push({ sprite, t: marker.t }); } + // advanceTWindow binary-searches this array by t. Measures stream + // in measure order, but making the sort an explicit invariant + // here (one build-time pass) keeps the window's correctness + // independent of how the server chunks its `markers` message. + markerSprites.sort((a, b) => a.t - b.t); } /* ── MIDI event handlers (called by module _midiOnMessage) ─────── @@ -3661,18 +3762,53 @@ } } + // Entries that just slid out of the note window (feedBack#95): park + // them invisible. Their off-runway position/scale is left as it was + // — nothing reads it while hidden, and a re-entering note (a + // backward seek) is fully re-derived on its first processed frame. + function _hideNoteWindow(lo, hi) { + for (let i = lo; i < hi; i++) { + const entry = noteMeshes[i]; + entry.mesh.visible = false; + if (entry.label) entry.label.visible = false; + } + } + + // Same for the floating bar numbers, which have no label sprite. + function _hideMarkerWindow(lo, hi) { + for (let i = lo; i < hi; i++) markerSprites[i].sprite.visible = false; + } + function updateScene(now) { // Rest emissive with the Glow slider applied — computed once so // the per-note '!==' guards stay effective at any glow value. const _restEmissive = NOTE_EMISSIVE_BASE * _glowMul(); const hitZ = -WHITE_L / 2; + // Window both t-sorted arrays to what can still be drawn this + // frame (feedBack#95): entries that just fell out are hidden + // once, here, and the loops below walk only [lo, hi) — per-frame + // cost tracks the visible span, not the chart's length. The + // in-loop geometry checks stay (bounds are exact for markers and + // conservative for notes between the shared bound and each + // note's own exit). + advanceTWindow(_noteMeshT, noteMeshes, + now - noteWindowBehindS(_maxNoteLen), + now + noteWindowAheadS(HIGHWAY_LEN, hitZ), + _noteWin, _hideNoteWindow); + advanceTWindow(_markerT, markerSprites, + now - MARKER_WIN_BEHIND_S, + now + noteWindowAheadS(HIGHWAY_LEN, hitZ), + _markerWin, _hideMarkerWindow); + // Pan the camera x to follow the active hand: a hit-line-weighted // centroid of the notes around `now`. Keeps a ~2-octave window // framed and scrolls with the melody, RS+-style, instead of - // statically framing the whole range. + // statically framing the whole range. Windowed to [lo, hi): the + // camera band ([-0.4, CAM_ZOOM_AHEAD]) sits inside the window. let minX = Infinity, maxX = -Infinity; - for (const { mesh, note } of noteMeshes) { + for (let i = _noteWin.lo; i < _noteWin.hi; i++) { + const { mesh, note } = noteMeshes[i]; const dt = note.t - now; if (dt < -0.4 || dt > CAM_ZOOM_AHEAD) continue; const x = mesh.position.x; @@ -3718,7 +3854,8 @@ } for (const km of keyMeshes.values()) km.userData.glow = 0; - for (const { mesh, note, len, label } of noteMeshes) { + for (let i = _noteWin.lo; i < _noteWin.hi; i++) { + const { mesh, note, len, label } = noteMeshes[i]; const dt = note.t - now; // Key approach-glow: the nearest upcoming note lights its key, // stronger the closer it is to the hit-line. @@ -3758,7 +3895,7 @@ } } - if (frontZ < -HIGHWAY_LEN || backZ > hitZ + 20 * K) { + if (frontZ < -HIGHWAY_LEN || backZ > hitZ + NOTE_LINGER) { mesh.visible = false; continue; } @@ -3791,9 +3928,10 @@ const g = km.userData.glow || 0; km.material.emissiveIntensity = g * g * KEY_GLOW_STRENGTH * _glowMul(); // ease → pops near the hit-line } - for (const entry of markerSprites) { + for (let i = _markerWin.lo; i < _markerWin.hi; i++) { + const entry = markerSprites[i]; const z = scrollZ(entry.t, now, hitZ, TS); - if (z < -HIGHWAY_LEN || z > hitZ + 6 * K) { + if (z < -HIGHWAY_LEN || z > hitZ + MARKER_LINGER) { entry.sprite.visible = false; continue; } @@ -4242,6 +4380,11 @@ _flamesGroup = null; noteMeshes = []; markerSprites = []; + // Fresh arrays start fully hidden — the first advanceTWindow after + // a rebuild must not try to hide entries from the old arrays. + _noteWin.lo = _noteWin.hi = -1; + _markerWin.lo = _markerWin.hi = -1; + _maxNoteLen = 0; keyMeshes = new Map(); _layoutInfo = null; _keyAnim.clear(); @@ -4588,6 +4731,13 @@ filterNotationByHand, noteLetter, scrollZ, + noteWindowAheadS, + noteWindowBehindS, + advanceTWindow, + _noteMeshT, + _markerT, + TS, + LABEL_FADE_DIST, noteKey, accuracyOf, scoreOf, diff --git a/plugins/keys_highway_3d/tests/data_layer.test.js b/plugins/keys_highway_3d/tests/data_layer.test.js index 538e1a65..af2d7b19 100644 --- a/plugins/keys_highway_3d/tests/data_layer.test.js +++ b/plugins/keys_highway_3d/tests/data_layer.test.js @@ -370,3 +370,133 @@ test('_pickMidiTarget: recovery with no preference at all still allows a first-h const target = _pickMidiTarget(inputs, null, null, false); assert.equal(target.id, 'b'); }); + +/* ── updateScene's draw window (feedBack#95) ───────────────────────────── */ + +test('noteWindowAheadS: the bound is exactly where a note front edge leaves the runway', () => { + const { noteWindowAheadS, scrollZ, TS } = load(); + const highwayLen = 8.625, hitZ = -0.1725; + const ahead = noteWindowAheadS(highwayLen, hitZ); + // scrollZ positions the note front at its onset; the runway hides the + // note once frontZ < -highwayLen. The bound must sit exactly on that + // crossing (at the module's TS — the speed updateScene scrolls at). + assert.ok(Math.abs(scrollZ(ahead, 0, hitZ, TS) - (-highwayLen)) < 1e-9); + assert.ok(scrollZ(ahead - 1e-6, 0, hitZ, TS) > -highwayLen); // just inside: on the runway + assert.ok(scrollZ(ahead + 1e-6, 0, hitZ, TS) < -highwayLen); // just past: off it + assert.ok(ahead > 0); +}); + +test('noteWindowBehindS: covers the longest note\'s consumption AND the label fade', () => { + const { noteWindowBehindS, TS, LABEL_FADE_DIST } = load(); + // Long-note case binds: a 4-second note is still being consumed 4 s + // after its onset, so the window must reach at least that far back. + const maxLen = 4 * TS; + assert.ok(noteWindowBehindS(maxLen) * TS >= maxLen); + // Short-note case binds the label: a note consumed in one frame still + // shows its name for LABEL_FADE_DIST past the hit-line — a bound built + // from note length alone ((0 + 20K)/TS) would cut the label off early. + assert.ok(noteWindowBehindS(0) * TS >= LABEL_FADE_DIST); + // Tightness: beyond the label floor the bound tracks only the longest + // note — it must not grow with the chart's length or total entry count. + assert.ok(noteWindowBehindS(maxLen) * TS <= maxLen + LABEL_FADE_DIST); + // Monotone: a longer chart note only ever widens the window. + let prev = -Infinity; + for (let len = 0; len <= 60; len += 0.25) { + const behind = noteWindowBehindS(len); + assert.ok(behind >= prev); + assert.ok(behind > 0); + prev = behind; + } +}); + +test('the note window contains every filter band updateScene runs inside it', () => { + const { noteWindowAheadS, noteWindowBehindS } = load(); + // updateScene's camera framing keeps only dt ∈ [-0.4, CAM_ZOOM_AHEAD] + // and its key-approach glow dt ∈ [-0.05, KEY_GLOW_AHEAD]; both filters + // run inside the window slices, so a band edge that crept outside the + // window would silently truncate the filter while everything else stayed + // green. Stock literals mirror screen.js: 0.4 (camera lag, hardcoded at + // the call site), CAM_ZOOM_AHEAD = 3.5, KEY_GLOW_AHEAD = 2.0. + assert.ok(noteWindowBehindS(0) >= 0.4); + assert.ok(noteWindowBehindS(0) >= 0.05); + // Stock geometry: HIGHWAY_LEN = 1150*K = 8.625, hitZ = -WHITE_L/2 = -0.1725. + const ahead = noteWindowAheadS(8.625, -0.1725); + assert.ok(ahead >= 3.5); // CAM_ZOOM_AHEAD's far edge; also covers the glow's 2.0 +}); + +test('advanceTWindow: a fresh window claims its slice without hiding anything', () => { + const { advanceTWindow, _noteMeshT } = load(); + const entries = [0, 1, 2, 3, 4].map(t => ({ note: { t } })); + const win = { lo: -1, hi: -1 }; + const hidden = []; + advanceTWindow(_noteMeshT, entries, 1.5, 3.5, win, (lo, hi) => hidden.push([lo, hi])); + // Entries start hidden after a rebuild, so the first claim hides nothing. + assert.deepEqual(hidden, []); + assert.deepEqual([win.lo, win.hi], [2, 4]); // t ∈ [1.5, 3.5] → entries 2..3 +}); + +test('advanceTWindow: steady playback hides only the run that fell out behind', () => { + const { advanceTWindow, _noteMeshT } = load(); + const entries = [0, 1, 2, 3, 4, 5].map(t => ({ note: { t } })); + const win = { lo: -1, hi: -1 }; + const hidden = []; + advanceTWindow(_noteMeshT, entries, 1.5, 3.5, win, (lo, hi) => hidden.push([lo, hi])); + hidden.length = 0; + advanceTWindow(_noteMeshT, entries, 2.5, 4.5, win, (lo, hi) => hidden.push([lo, hi])); + assert.deepEqual(hidden, [[2, 3]]); // only t=2 fell out behind; nothing left ahead + assert.deepEqual([win.lo, win.hi], [3, 5]); // t ∈ [2.5, 4.5] → entries 3..4 +}); + +test('advanceTWindow: a forward seek hides its jumped-past window; a rewind re-claims it', () => { + const { advanceTWindow, _noteMeshT } = load(); + const entries = [0, 1, 2, 3, 4, 5].map(t => ({ note: { t } })); + const win = { lo: -1, hi: -1 }; + const hidden = []; + advanceTWindow(_noteMeshT, entries, 0.5, 2.5, win, (lo, hi) => hidden.push([lo, hi])); + hidden.length = 0; + // Forward seek past the chart end: window collapses to [6, 6) and the + // whole old front falls behind. The hide range runs to the new lo, which + // sweeps up entries that were already hidden (re-hiding is idempotent) + // — that keeps "everything below lo is hidden" a single enforced rule. + advanceTWindow(_noteMeshT, entries, 9.5, 11.5, win, (lo, hi) => hidden.push([lo, hi])); + assert.deepEqual(hidden, [[1, 6]]); + assert.deepEqual([win.lo, win.hi], [6, 6]); + // Rewind back over previously visited territory: the old window was + // empty so its lower edge hides nothing; the entries that re-enter + // below lo belong to the caller to process, never to onHide. Everything + // the shrunk upper edge outruns is (already-hidden) ahead-of-window + // entries — hidden again, harmlessly. + hidden.length = 0; + advanceTWindow(_noteMeshT, entries, 0.5, 2.5, win, (lo, hi) => hidden.push([lo, hi])); + assert.deepEqual(hidden, [[3, 6]]); + assert.deepEqual([win.lo, win.hi], [1, 3]); +}); + +test('advanceTWindow: same-onset entries (chords) enter and leave as one run', () => { + const { advanceTWindow, _noteMeshT } = load(); + const entries = [ + { note: { t: 0 } }, + { note: { t: 1 } }, { note: { t: 1 } }, { note: { t: 1 } }, + { note: { t: 2 } }, + ]; + const win = { lo: -1, hi: -1 }; + const hidden = []; + advanceTWindow(_noteMeshT, entries, 1, 1, win, (lo, hi) => hidden.push([lo, hi])); + assert.deepEqual([win.lo, win.hi], [1, 4]); // exact-boundary ties all claimed + advanceTWindow(_noteMeshT, entries, 1.001, 5, win, (lo, hi) => hidden.push([lo, hi])); + assert.deepEqual(hidden, [[1, 4]]); // all three leave together, never split +}); + +test('advanceTWindow: the marker keyOf reads entry.t; empty arrays collapse', () => { + const { advanceTWindow, _markerT } = load(); + const entries = [{ sprite: {}, t: 5 }, { sprite: {}, t: 7 }]; + const win = { lo: -1, hi: -1 }; + advanceTWindow(_markerT, entries, 4, 6, win, + () => { throw new Error('a fresh window must not hide'); }); + assert.deepEqual([win.lo, win.hi], [0, 1]); + const empty = { lo: -1, hi: -1 }; + advanceTWindow(_markerT, [], 0, 1, empty, () => { + throw new Error('an empty chart must not hide'); + }); + assert.deepEqual([empty.lo, empty.hi], [0, 0]); +}); diff --git a/plugins/keys_highway_3d/tests/instance_invariants.test.js b/plugins/keys_highway_3d/tests/instance_invariants.test.js index bbb8ea2c..92e3ba7e 100644 --- a/plugins/keys_highway_3d/tests/instance_invariants.test.js +++ b/plugins/keys_highway_3d/tests/instance_invariants.test.js @@ -74,3 +74,43 @@ test('a mastery change keeps the run totals instead of resetting scoring', () => assert.doesNotMatch(fn[0], /_resetScoring\(\)/); assert.match(fn[0], /_anchorMissSweep\(now\)/); }); + +test('updateScene windows its arrays instead of walking every note (feedBack#95)', () => { + // The window helpers are pure and unit-tested in data_layer.test.js, but + // nothing there can catch them being exported and never wired up — the + // per-frame cost fix lives entirely inside createFactory(). So pin the + // wiring: both t-sorted arrays advance a window once per frame, both + // per-frame loops walk [lo, hi), and neither still iterates the whole + // array (the camera loop's own dt filter stays inside the window). + const body = src.match(/function updateScene\(now\)\s*\{[\s\S]*?\n {8}\}\n/); + assert.ok(body, 'updateScene not found'); + // Pin the bound pairing too: behind-with-minus / ahead-with-plus, so a + // swapped sign or swapped helper can't slip through the weaker form. + assert.match(body[0], + /advanceTWindow\(_noteMeshT, noteMeshes,\s*\n\s*now - noteWindowBehindS\(_maxNoteLen\),\s*\n\s*now \+ noteWindowAheadS\(HIGHWAY_LEN, hitZ\),/); + assert.match(body[0], + /advanceTWindow\(_markerT, markerSprites,\s*\n\s*now - MARKER_WIN_BEHIND_S,\s*\n\s*now \+ noteWindowAheadS\(HIGHWAY_LEN, hitZ\),/); + assert.match(body[0], /for \(let i = _noteWin\.lo; i < _noteWin\.hi; i\+\+\)/); + assert.match(body[0], /for \(let i = _markerWin\.lo; i < _markerWin\.hi; i\+\+\)/); + assert.doesNotMatch(body[0], /of noteMeshes|of markerSprites/); + // Entries leaving the window are parked exactly once, here — not + // rediscovered per frame by the loops they just left. + assert.match(body[0], /_noteWin, _hideNoteWindow\)/); + assert.match(body[0], /_markerWin, _hideMarkerWindow\)/); +}); + +test('every noteMeshes/markerSprites reassignment resets its draw window', () => { + // The window's indices are only valid for the array they were taken + // from: a build or teardown that swaps the array without resetting the + // window would hand _hideNoteWindow stale indices from the previous one + // (TypeError on the first advance after the swap). The declaration lines + // (`let noteMeshes = []`) don't match — only reassignments do. + const stmts = [...src.matchAll(/^[ \t]+(noteMeshes|markerSprites) = \[\];$/gm)]; + assert.equal(stmts.length, 4, 'expected exactly the 4 known reassignments'); + for (const m of stmts) { + const win = m[1] === 'noteMeshes' ? '_noteWin' : '_markerWin'; + const after = src.slice(m.index, m.index + 400); + assert.match(after, new RegExp(win + '\\.lo = ' + win + '\\.hi = -1;'), + `${m[1]} reassignment (line ${src.slice(0, m.index).split('\n').length}) must reset ${win} beside it`); + } +});