Files
feedBack/tests/js/highway_3d_note_renderer.test.js
T
byrongamatosandClaude Sonnet 4.6 7180eff05d fix(h3d-carve-15): resolve Toby r1 F1/F2/F3 + scope-check test
F1 (HIGH): remove 44 phantom constants + fireDrawHooks + fxSpawnPop from
createRenderer DI signature and screen.js wiring. None ever existed in
screen.js scope; the engine threw ReferenceError at plugin init before
any argument was passed to createRenderer.

F2 (HIGH): un-move _setLabelMap back to screen.js (declared before
createNoteRenderer wiring at line ~6631), pass as DI to both
createNoteRenderer and createRenderer. Removing it from screen.js scope
caused a second independent ReferenceError before F1 even fired.

F3 (MED): drop 16 confirmed-dead DI params (0 body occurrences in
renderer.js): S_COL, SLIDE_RIBBON_SAMPLES, drawNotedetectLabels,
drawScoreFx, _resetStringDependentCaches, getCurDist, getCurLookY,
getTgtLookY, getFretRowFitBoost, getNdHitMarks, getNdMissMarks,
getImPMXFillCount, getImPMXLinesCount, getImFHXFillCount,
getImFHXLinesCount, getMeasureStarts. getMeasureStartsRef retained
(1 body use at renderer.js:831).

Scope-check also caught 4 pre-existing phantoms:
- PROJ_WIN, PROJ_WIN_G in createNoteRenderer wiring: note-renderer.js
  body uses hardcoded 0.6 / _PROJ_WIN_ARP; these DI params only appear
  in comments. Removed from note-renderer.js signature and both wiring
  calls.
- camAhead, camTau in createRenderer wiring: update() re-declares both
  as local let vars that shadow any DI value; also not declared in
  screen.js. Removed from renderer.js signature and wiring.

New test (highway_3d_renderer.test.js #16): static wiring-scope check
blanks all four factory wiring blocks from the corpus, then asserts each
shorthand token appears in the remainder. RED at 7623ad8 on BEAT_HEAD_SEC
(44 phantoms), GREEN at this tip.

DI count: 241 → 177 (createRenderer), 130 → 128 (createNoteRenderer).
Suite: 1396/1397 (test 46 pre-existing).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014uZ169yfoFYArXz962g7KW
2026-09-05 23:37:57 +02:00

475 lines
21 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// h3d-carve-14: V-section (note renderer) pin tests.
//
// Guards:
// 1. Wiring: createNoteRenderer factory exists in note-renderer.js and the
// wiring in screen.js contains the exact expected DI param count (136).
// 2. Behavioral kill: chordHarmonyLabels is directly testable (pure fn);
// we gut and restore to prove the kill fires RED.
// 3. Export contract: all 4 exports exist and are functions.
// 4. Getter-aliasing: private helpers used by drawNote reference DI names.
'use strict';
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const NOTE_RENDERER_JS = path.join(
__dirname, '..', '..', 'plugins', 'highway_3d', 'src', 'note-renderer.js'
);
const SCREEN_JS = path.join(
__dirname, '..', '..', 'plugins', 'highway_3d', 'screen.js'
);
const src = fs.readFileSync(NOTE_RENDERER_JS, 'utf8');
const screenSrc = fs.readFileSync(SCREEN_JS, 'utf8');
// ── 1. Wiring guard ──────────────────────────────────────────────────────────
test('createNoteRenderer is exported from note-renderer.js', () => {
assert.match(src, /export function createNoteRenderer/,
'note-renderer.js must export createNoteRenderer');
});
test('screen.js imports createNoteRenderer from note-renderer.js', () => {
assert.match(screenSrc, /import.*createNoteRenderer.*from.*note-renderer\.js/,
'screen.js must import createNoteRenderer');
});
test('screen.js wiring block contains all 128 DI params', () => {
// Locate the wiring call; count getter arrows, setter arrows, and
// shorthand entries. Each property in the object literal is one entry.
// Strategy: extract the createNoteRenderer({...}) call text and count.
const wiringMatch = screenSrc.match(
/createNoteRenderer\(\{([\s\S]*?)\}\)/
);
assert.ok(wiringMatch, 'screen.js must contain createNoteRenderer({...}) call');
const wiringBody = wiringMatch[1];
// Count getter arrows getX: () => _x,
const getterCount = (wiringBody.match(/\bget[A-Z]\w+\s*:/g) || []).length;
// Count setter arrows setX: (v) => { ... },
const setterCount = (wiringBody.match(/\bset[A-Z]\w+\s*:/g) || []).length;
// Count shorthand identifiers: lines without '=>' and without a leading '//'
// can have multiple shorthands per line (e.g. "K, NFRETS, NW, NH, AHEAD,").
// Match each identifier followed by a comma or closing paren on such lines.
const shorthandCount = wiringBody.split('\n').reduce((acc, line) => {
const t = line.trim();
if (!t || t.startsWith('//') || t.includes('=>')) return acc;
const ids = t.match(/\b[A-Za-z_][A-Za-z0-9_]*\b(?=\s*,)/g) || [];
return acc + ids.length;
}, 0);
const total = getterCount + setterCount + shorthandCount;
// 128 = 56 shorthands + 69 getters + 3 setters
// 130 → 128: removed PROJ_WIN + PROJ_WIN_G (scope-check phantoms — never
// declared in screen.js; note-renderer.js body uses hardcoded 0.6 /
// _PROJ_WIN_ARP, not these DI params; only appeared in comments).
// Caught by r2 scope-check test in highway_3d_renderer.test.js.
assert.strictEqual(total, 128,
`DI param count must be exactly 128 (got getters:${getterCount} setters:${setterCount} shorthands:${shorthandCount} = ${total})`);
});
// ── 2. Factory returns all 4 exports ────────────────────────────────────────
test('createNoteRenderer returns drawNote', () => {
assert.match(src, /return\s*\{[\s\S]*?\bdrawNote\b[\s\S]*?\}/,
'factory must return drawNote');
});
test('createNoteRenderer returns drawArpBrackets', () => {
assert.match(src, /return\s*\{[\s\S]*?\bdrawArpBrackets\b[\s\S]*?\}/,
'factory must return drawArpBrackets');
});
test('createNoteRenderer returns drawNotedetectLabels', () => {
assert.match(src, /return\s*\{[\s\S]*?\bdrawNotedetectLabels\b[\s\S]*?\}/,
'factory must return drawNotedetectLabels');
});
test('createNoteRenderer returns chordHarmonyLabels', () => {
assert.match(src, /return\s*\{[\s\S]*?\bchordHarmonyLabels\b[\s\S]*?\}/,
'factory must return chordHarmonyLabels');
});
// ── 3. Behavioral kill — chordHarmonyLabels (pure fn, testable directly) ────
// Extract and eval chordHarmonyLabels from the source for node testing.
// The function is defined inside createNoteRenderer; we pull it out as-is.
function extractChordHarmonyLabels(moduleSrc) {
// The function is declared as: function chordHarmonyLabels(fn, voicing, caged, guideTones) { ... }
// Find the opening and use bracket-depth to find closing.
const start = moduleSrc.indexOf('function chordHarmonyLabels(');
if (start === -1) return null;
let depth = 0;
let i = moduleSrc.indexOf('{', start);
const open = i;
for (; i < moduleSrc.length; i++) {
if (moduleSrc[i] === '{') depth++;
else if (moduleSrc[i] === '}') {
depth--;
if (depth === 0) break;
}
}
const fnSrc = moduleSrc.slice(start, i + 1);
// Wrap in a closure to evaluate
// eslint-disable-next-line no-new-func
return new Function(`return (${fnSrc})`)();
}
const chordHarmonyLabels = extractChordHarmonyLabels(src);
test('chordHarmonyLabels extracted from source is a function', () => {
assert.strictEqual(typeof chordHarmonyLabels, 'function',
'chordHarmonyLabels must be extractable and be a function');
});
test('chordHarmonyLabels — valid RN + voicing', () => {
const fn = { rn: 'IV' };
const r = chordHarmonyLabels(fn, 'drop2', null, null);
assert.strictEqual(r.rn, 'IV');
assert.strictEqual(r.voicing, 'drop2');
assert.strictEqual(r.caged, '');
assert.strictEqual(r.guideTones, '');
});
test('chordHarmonyLabels — valid CAGED shape', () => {
const r = chordHarmonyLabels(null, null, 'E', null);
assert.strictEqual(r.caged, 'CAGED: E');
});
test('chordHarmonyLabels — invalid CAGED shape rejected', () => {
const r = chordHarmonyLabels(null, null, 'X', null);
assert.strictEqual(r.caged, '');
});
test('chordHarmonyLabels — guideTones array', () => {
const r = chordHarmonyLabels(null, null, null, [4, 10]);
assert.strictEqual(r.guideTones, 'gt 4,10');
});
test('chordHarmonyLabels — out-of-range guideTone filtered', () => {
const r = chordHarmonyLabels(null, null, null, [4, 12]);
assert.strictEqual(r.guideTones, 'gt 4');
});
test('chordHarmonyLabels — all null → all empty', () => {
const r = chordHarmonyLabels(null, null, null, null);
assert.strictEqual(r.rn, '');
assert.strictEqual(r.voicing, '');
assert.strictEqual(r.caged, '');
assert.strictEqual(r.guideTones, '');
});
// ── 4. Getter-aliasing discipline ────────────────────────────────────────────
test('drawNote aliases getLeftyCached at function entry', () => {
assert.match(src,
/function drawNote[\s\S]*?const _leftyCached\s*=\s*getLeftyCached\(\)/,
'drawNote must alias getLeftyCached() once at entry');
});
test('drawNote aliases getPNote pool at entry', () => {
assert.match(src,
/function drawNote[\s\S]*?const pNote\s*=\s*getPNote\(\)/,
'drawNote must alias getPNote() pool getter once at entry');
});
test('drawNote aliases getMStr material at entry', () => {
assert.match(src,
/function drawNote[\s\S]*?const mStr\s*=\s*getMStr\(\)/,
'drawNote must alias getMStr() material getter once at entry');
});
// ── 5. Beyond-subst rewires present ──────────────────────────────────────────
test('setNdVerdictSawAlpha beyond-subst: setter called, not direct assignment', () => {
// Strip single-line comments so comment-docs don't trigger the check
const codeOnly = src.replace(/\/\/[^\n]*/g, '');
assert.doesNotMatch(codeOnly, /_ndVerdictSawAlpha\s*=\s*(true|false)/,
'V-section code must not directly assign _ndVerdictSawAlpha (beyond-subst: use setter)');
assert.match(src, /setNdVerdictSawAlpha\(true\)/,
'V-section must call setNdVerdictSawAlpha(true)');
});
test('setStreakHits beyond-subst: setter called, not direct assignment', () => {
const codeOnly = src.replace(/\/\/[^\n]*/g, '');
assert.doesNotMatch(codeOnly, /_streakHits\s*=\s*0/,
'V-section code must not directly assign _streakHits = 0 (beyond-subst: use setStreakHits)');
assert.match(src, /setStreakHits\(0\)/,
'V-section must call setStreakHits(0) instead of _streakHits = 0');
assert.match(src, /setStreakHits\(getStreakHits\(\)\s*\+\s*1\)/,
'V-section must call setStreakHits(getStreakHits() + 1) for increment');
});
// ── 6. Tombstone present in screen.js ────────────────────────────────────────
test('screen.js V-section tombstone is present', () => {
assert.match(screenSrc,
/h3d-carve-14.*V-section.*note-renderer/,
'screen.js must have the h3d-carve-14 tombstone comment');
});
test('screen.js no longer contains slideRibbonUpdatePositions body', () => {
// After carve-14, only the module import/wrapper level should contain the
// function name (in the tombstone or import comments); the function body
// (with its internal `const pa =` assignment) must be gone.
assert.doesNotMatch(screenSrc, /function slideRibbonUpdatePositions/,
'screen.js must not contain the original slideRibbonUpdatePositions body after carve-14');
});
test('screen.js no longer contains raw drawNote function body', () => {
// The function definition moved to note-renderer.js; screen.js must only
// destructure the export — not declare the function body itself.
const drawNoteBodyMatches = [
...screenSrc.matchAll(/function drawNote\b/g)
];
assert.strictEqual(drawNoteBodyMatches.length, 0,
'screen.js must not declare function drawNote after carve-14');
});
// ── 7. Behavioral kill — drawNote early-exit vs gem path ────────────────────
// Loads createNoteRenderer via new Function (strips ESM import/export so it
// runs in a CJS context) with full DI stubs, then calls drawNote directly.
// Tracks pool.get() calls on the pNote pool to distinguish the early-exit
// path (no gem emitted) from the in-window gem path (pNote.get() × 2).
//
// Kill proof:
// Gut line 452 (`return` in the smart-cull block) → negative test RED
// Gut pNote.get() at lines 826+873 → positive test RED
const _nrSrcStripped = (() => {
const raw = src; // already read above as fs.readFileSync(NOTE_RENDERER_JS)
return raw
// Strip the single ESM import line (including trailing comment); geometry stubs come from outer fn params
.replace(/^import\s+\{[^}]+\}\s+from\s+['"][^'"]+['"][^\n]*/m, '')
.replace('export function createNoteRenderer', 'function createNoteRenderer');
})();
// Build the factory via new Function so geometry imports come from params (closure).
// new Function executes in global scope → Math/Map/Set/Array/… are all available.
const _createNoteRendererFn = new Function(
'dZ', 'slideTrailEnd', 'renderOrderForLayerAtZ',
_nrSrcStripped + '\nreturn createNoteRenderer;',
)(() => 0, () => null, () => 0);
function _buildDrawNote(overrides) {
let pNoteGetCount = 0;
const fakeMat = { opacity: 1, depthTest: true };
const fakeMesh = {
position: { set: () => {} },
rotation: { set: () => {}, z: 0 },
scale: { set: () => {}, multiplyScalar: () => {} },
renderOrder: 0, visible: true, material: fakeMat, geometry: null,
};
const pNotePool = { get: () => { pNoteGetCount++; return fakeMesh; }, release: () => {} };
const noopPool = { get: () => fakeMesh, release: () => {} };
const A6 = (v) => [v, v, v, v, v, v];
// Creed F2 fix: distinguishable materials so getMStr/getMGlow swap is visible.
const mStrMats = [0,1,2,3,4,5].map(i => ({ opacity: 1, depthTest: true, name: `mStr[${i}]` }));
const mGlowMats = [0,1,2,3,4,5].map(i => ({ opacity: 1, depthTest: true, name: `mGlow[${i}]` }));
const di = Object.assign({
// Constants
K: 1, NFRETS: 24, NW: 1, NH: 0.1, AHEAD: 1,
GHOST_HOLD_AFTER_ONSET: 0.1, NEXT_ON_STRING_T_EPS: 0.001,
NOTEDETECT_GEM_VERDICT_WINDOW: 0.3,
SLIDE_RIBBON_SAMPLES: 8, S_GAP: 1,
BEND_HALFSTEP_WORLD_Y: 0.1, PROJ_WIN: 0.6, PROJ_WIN_G: 0.3, PROJ_GROW_MIN: 0,
GHOST_FRET_LBL_FADE_S: 0.1,
BEND_ENV_RISE_FRAC: 0.3, BEND_ENV_RELEASE_FRAC: 0.7,
VIBRATO_HALF_WAVE_S: 0.1, TREMOLO_BUMP_S: 0.1,
ACCENT_RIM_XY_SCALE_MUL: 1, ACCENT_RIM_Z_SCALE_MUL: 1,
CHORD_FRAME_RIM_FRAC_H: 0.1, CHORD_FRAME_RIM_MIN: 0.01,
FRET_LABEL_GOLD_HEX: '#e8c040', SINGLE_SUS_OFFSETS: [0],
TS: 1, _ND_TIME_EPS: 0.001,
// Function refs (after r1 fix: 24 live fn-refs)
slideOffsetWorldX: () => 0,
hwyPostHitTailFadeMul: () => 1,
anchorLaneBoundsAt: () => null,
validString: (s) => s >= 0 && s < 6,
sY: () => 0,
xFretMid: () => 0,
_firstEventTimeGreaterThan: () => Infinity,
_setLabelMap: () => {},
_spriteMat2MeshMat: () => fakeMat,
_meshMatForGhostFretDigit: () => fakeMat,
fretLabelScaleForFret: () => 1,
fretMid: () => 0,
txtMat: () => fakeMat,
darkenHex: (h) => h,
palmMuteXSpriteMat: () => fakeMat,
fretHandMuteXSpriteMat: () => fakeMat,
triMat: () => fakeMat,
bendChevronMat: () => fakeMat,
slideArrowMat: () => fakeMat,
pinchHarmonicMat: () => fakeMat,
naturalHarmonicMat: () => fakeMat,
_timingHex: () => '#ffffff',
_sparkBurst: () => {},
_fxSpawnPop: () => {},
// Frame-state getters
getLeftyCached: () => false,
getInvertedCached: () => false,
getDrawNextByString: () => null,
getDrawRecentByString: () => null,
getDrawAnchors: () => null,
getDrawChordTemplates: () => null,
getDrawTeachingMarks: () => false,
getShowFingerHints: () => false,
getTextSizeMul: () => 1,
getNdGetNoteState: () => null,
getNdHasProvider: () => true,
getNdHitMarks: () => [],
getNdMissMarks: () => [],
getNdLabels: () => [],
getCam: () => ({}),
getProbe: () => null,
getCurX: () => 0,
getNStr: () => 6,
getAccentShellsByString: () => A6([]),
getNdVerdictMaxAlpha: () => 0,
setNdVerdictSawAlpha: () => {},
setNdVerdictMaxAlpha: () => {},
getStreakHits: () => 0,
setStreakHits: () => {},
getGNote: () => ({}),
getGNoteGrad: () => A6(null),
getActivePalette: () => A6(null),
getHitFx: () => 0,
getSparks: () => null,
getVerdictMarks: () => false,
getStreakFx: () => false,
getStreakHeat: () => 0,
getSlideArrowApproachVisible: () => false,
getSlideArrowNeckVisible: () => false,
getSlideArrowChainPreviewVisible: () => false,
getVibrancyProjOp: () => 0.15,
getFretLabelAllowed: () => new Set(),
getProjMeshArr: () => null,
getProjectionVisible: () => false,
getGlowMul: () => 1,
getShowFretOnNote: () => false,
getFretNumberGhostScope: () => null,
// Pool getters — pNote uses the tracking pool; others use noopPool
getPNote: () => pNotePool,
getPNoteEdge: () => noopPool,
getPSus: () => noopPool,
getPSusOutline: () => noopPool,
getPSusRibbon: () => noopPool,
getPSusRibbonOl: () => noopPool,
getPTapChevron: () => noopPool,
getPAccentHalo: () => noopPool,
getPArpBracket: () => noopPool,
getPConnectorLine: () => noopPool,
getPDropLine: () => noopPool,
getPGhostFretLbl: () => noopPool,
getPNoteFretLabel: () => noopPool,
getPTeachMarkLbl: () => noopPool,
getPTechPlane: () => noopPool,
// Material getters
getMStr: () => mStrMats,
getMGlow: () => mGlowMats,
getMSus: () => fakeMat,
getMSusOutline: () => fakeMat,
getMHitBright: () => A6(fakeMat),
getMHitBrightArrays: () => A6(null),
getMmissOutline: () => fakeMat,
getMmissEdgeArrays: () => [],
getMRimFlash: () => A6(fakeMat),
getMAccentHaloNear: () => A6(null),
getMAccentOutline: () => A6(fakeMat),
getMAccentCore: () => A6(fakeMat),
getMStrHitOutline: () => A6(fakeMat),
getMHitSusOutline: () => fakeMat,
getMWhiteOutline: () => fakeMat,
// Stable refs
_susVerdictLatch: new Map(),
_fwHitIn: new Array(26).fill(0),
_fwChordAcc: new Map(),
_scrGhostUpcomingCount: new Array(6).fill(0),
_rimFlashIn: new Array(6).fill(0),
_sparkSeen: new Map(),
_frameLabeledKeys: new Set(),
}, overrides || {});
const { drawNote } = _createNoteRendererFn(di);
return { drawNote, getPNoteGetCount: () => pNoteGetCount, mStrMats, mGlowMats, getFakeMesh: () => fakeMesh };
}
test('drawNote: past-linger note exits before pNote.get() (early-exit kill)', () => {
// note.t=0, now=999 → dt = -999 << -NOTEDETECT_GEM_VERDICT_WINDOW (0.3)
// → _overLinger=true, enters smart-cull block, exits at line 452 before any pNote.get().
// Kill proof: gut line 452 `return` → code falls through into gem body → pNoteGetCount > 0 → RED.
const { drawNote, getPNoteGetCount } = _buildDrawNote();
drawNote({ s: 0, f: 5, t: 0, sus: 0 }, /*now=*/999, 0, false, false, 0.10);
assert.strictEqual(getPNoteGetCount(), 0,
'pNote.get() must NOT be called when dt is far past the verdict window (early exit)');
});
test('drawNote: in-window note reaches pNote.get() × 2 (gem-path kill)', () => {
// note.t=5, now=5 → dt=0 → _overLinger=false (linger=0.10, deadline=5.10)
// → skips smart-cull block entirely → enters gem body → pNote.get() for outline + core.
// getNdHasProvider=false so smart-cull block is also bypassed (not _overLinger path).
// Kill proof: gut pNote.get() at line 826 or 873 → count drops below 2 → RED.
const { drawNote, getPNoteGetCount } = _buildDrawNote({ getNdHasProvider: () => false });
drawNote({ s: 0, f: 5, t: 5, sus: 0 }, /*now=*/5, 0, false, false, 0.10);
assert.ok(getPNoteGetCount() >= 2,
`pNote.get() must be called at least twice (outline + core) for an in-window note (got ${getPNoteGetCount()})`);
});
test('drawNote: gem core.material is mStr[s], not mGlow[s] (material-identity kill)', () => {
// Creed F2: fake materials were identical; swapping getMStr/getMGlow in the wiring
// stayed green. Now mStrMats/mGlowMats are distinct objects.
// Kill: swap getMStr/getMGlow in _buildDrawNote overrides → core.material === mGlowMats[0] → RED.
const s = 0;
const { drawNote, mStrMats, mGlowMats, getFakeMesh } = _buildDrawNote({ getNdHasProvider: () => false });
drawNote({ s, f: 5, t: 5, sus: 0 }, /*now=*/5, 0, false, false, 0.10);
const mesh = getFakeMesh();
assert.strictEqual(mesh.material, mStrMats[s],
`gem core.material must be mStr[${s}] (got: ${mesh.material && mesh.material.name})`);
assert.notStrictEqual(mesh.material, mGlowMats[s],
'gem core.material must NOT be mGlow (getMStr/getMGlow swap must be visible)');
});
test('slideRibbonUpdatePositions: all vertex positions finite for n.tr sustain (NaN-arg kill)', () => {
// Creed F1: tremoloOffsetWorldX(n, Tk) dropped tw → undefined*…=NaN for all ribbon vertices.
// Fix: tremoloOffsetWorldX(n, Tk, tw). Kill: drop tw arg again → NaN in posArray → RED.
const S = 8; // matches DI SLIDE_RIBBON_SAMPLES
const posArray = new Float32Array((S + 1) * 4 * 3);
posArray.fill(NaN); // pre-fill NaN: if path not taken, assertion catches it (test setup bug)
let geoWritten = false;
const makeRibbonMesh = () => ({
position: { set: () => {} },
rotation: { set: () => {}, z: 0 },
scale: { set: () => {} },
renderOrder: 0, visible: true, material: null,
geometry: {
attributes: {
position: {
array: posArray,
set needsUpdate(v) { if (v) geoWritten = true; },
},
},
},
});
const ribbonPool = { get: makeRibbonMesh, release: () => {} };
const { drawNote } = _buildDrawNote({
getNdHasProvider: () => false,
getPSusRibbon: () => ribbonPool,
getPSusRibbonOl: () => ribbonPool,
SLIDE_RIBBON_SAMPLES: S,
});
// sus=0.5 (remSus=0.5>0.01), tr=1 → ribbonSusTrail=true → slideRibbonUpdatePositions called
drawNote({ s: 0, f: 5, t: 5, sus: 0.5, tr: 1 }, /*now=*/5, 0, false, false, 0.10);
assert.ok(geoWritten, 'geometry.needsUpdate must be set — ribbon path must be reached');
for (let i = 0; i < posArray.length; i++) {
assert.ok(Number.isFinite(posArray[i]),
`posArray[${i}] must be finite; NaN = dropped tw arg in tremoloOffsetWorldX`);
}
});