From 930b492fa5d09a7723f168a1f476d1944d4e081a Mon Sep 17 00:00:00 2001 From: byrongamatos Date: Sat, 5 Sep 2026 17:51:54 +0200 Subject: [PATCH] h3d-carve-11: extract R-section (string glow) into src/string-glow.js MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit VERBATIM-MOVE of updateStringHighlights from screen.js into a new createStringGlow() factory. 7 DI-rewires at function entry (aliased locals), plus 1 plain const shorthand. 8 DI params total, 0 setter pairs, single export { updateStringHighlights }. screen.js changes: - Function definition (old 6610–6649) replaced with createStringGlow({…}) factory destructure - mStr confirmed absent from updateStringHighlights (plan row 11 stale; declared surprise in contract, accepted by god) - VENUE_GEM_EMISSIVE_MUL passed as plain const shorthand (not a getter) Tests (highway_3d_string_glow.test.js, 11 new, all green): - Module shape + DI rewire source-scans - Wiring-correspondence guard (createStringGlow, empty PINNED_RENAMES) - 3 behavioral tests: emissive/opacity writes, venue multiplier, null-mesh slot safety — behavioral kill: gut loop → assert RED Suite: 1317/1319 pass. Pre-existing failures #46 (legacy analyser) and #639 (nut-labels) unchanged. Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_014uZ169yfoFYArXz962g7KW --- plugins/highway_3d/plugin.json | 2 +- plugins/highway_3d/screen.js | 52 +---- plugins/highway_3d/src/string-glow.js | 78 +++++++ tests/js/highway_3d_string_glow.test.js | 293 ++++++++++++++++++++++++ 4 files changed, 384 insertions(+), 41 deletions(-) create mode 100644 plugins/highway_3d/src/string-glow.js create mode 100644 tests/js/highway_3d_string_glow.test.js diff --git a/plugins/highway_3d/plugin.json b/plugins/highway_3d/plugin.json index 437c2be..3e545f9 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.46.0", + "version": "3.47.0", "type": "visualization", "scriptType": "module", "bundled": true, diff --git a/plugins/highway_3d/screen.js b/plugins/highway_3d/screen.js index 8b0b23d..bffd723 100644 --- a/plugins/highway_3d/screen.js +++ b/plugins/highway_3d/screen.js @@ -16,6 +16,7 @@ import { createOverlay } from './src/overlay.js'; // h3d-carve-7 import { createFx } from './src/fx.js'; // h3d-carve-8 import { createCamera } from './src/camera.js'; // h3d-carve-9 import { createScoreFx } from './src/score-fx.js'; // h3d-carve-10 +import { createStringGlow } from './src/string-glow.js'; // h3d-carve-11 (function () { 'use strict'; @@ -6607,46 +6608,17 @@ import { createScoreFx } from './src/score-fx.js'; // h3d-carve-10 } /* ── String glow (called each frame) ────────────────────────────── */ - function updateStringHighlights(noteState) { - // Glow slider scales both the idle floor and anticipation peak, - // so glowMul=0 fully silences the per-string emissive pulse. - // Vibrancy controls the idle opacity floor — anticipation - // still rides on top regardless of vibrancy so play-feedback - // through the opacity channel survives even at glowMul=0. - // - // Folded with the post-noteState mGlow / mAccentCore writes - // (was a separate `for (s = 0; s < nStr)` loop in update()), - // so the per-string scratch arrays stay hot in L1 across all - // material writes for a given string. - const BASE_GLOW = 0.02 * glowMul; - const MAX_GLOW = 3.5 * glowMul; - const IDLE_OP = _vibrancyIdleOp; - const g = glowMul; - const venueGemMul = _venueSceneOverride ? VENUE_GEM_EMISSIVE_MUL : 1; - - for (let s = 0; s < nStr; s++) { - const mesh = stringLines[s]; - if (mesh) { - const intensity = Math.max( - noteState.stringSustain[s] ? 1 : 0, - noteState.stringAnticipation[s] || 0, - ); - mesh.material.emissiveIntensity = BASE_GLOW + intensity * MAX_GLOW; - mesh.material.opacity = IDLE_OP + intensity * (1 - IDLE_OP); - mesh.scale.set(1, 1 + intensity * 0.3, 1 + intensity * 0.3); - } - // Hit-note emissive — same write pattern as the standalone - // loop that previously lived at update()'s post-call site. - // The glow slider scales it here since this assignment - // stomps anything _applyGlow() set statically. - const bg = noteState.strGlow[s] * g; - if (mGlow[s]) mGlow[s].emissiveIntensity = bg * venueGemMul; - if (mAccentCore[s]) { - mAccentCore[s].emissiveIntensity = - (bg + noteState.accentFillBoost[s] * g) * venueGemMul; - } - } - } + /* ── h3d-carve-11: R-section (string glow) → src/string-glow.js ── */ + const { updateStringHighlights } = createStringGlow({ + VENUE_GEM_EMISSIVE_MUL, + getGlowMul: () => glowMul, + getVibrancyIdleOp: () => _vibrancyIdleOp, + getVenueSceneOverride: () => _venueSceneOverride, + getNStr: () => nStr, + getStringLines: () => stringLines, + getMGlow: () => mGlow, + getMAccentCore: () => mAccentCore, + }); /* ── Lookahead fret bounds + smooth camera ───────────────────────── */ // End time of the lookahead window = start of the measure that is diff --git a/plugins/highway_3d/src/string-glow.js b/plugins/highway_3d/src/string-glow.js new file mode 100644 index 0000000..968b66c --- /dev/null +++ b/plugins/highway_3d/src/string-glow.js @@ -0,0 +1,78 @@ +// h3d-carve-11: R-section (string glow) extracted from screen.js. +// VERBATIM-MOVE: updateStringHighlights body is byte-for-byte identical to +// screen.js except for 7 DI-rewires at function entry (aliased locals). +// No logic changes, no new guards. +// +// Beyond-subst changes (all mechanical DI rewires at function entry): +// 1. glowMul → aliased: const glowMul = getGlowMul() +// 2. _vibrancyIdleOp → aliased: const _vibrancyIdleOp = getVibrancyIdleOp() +// 3. _venueSceneOverride → aliased: const _venueSceneOverride = getVenueSceneOverride() +// 4. nStr → aliased: const nStr = getNStr() +// 5. stringLines → aliased: const stringLines = getStringLines() +// 6. mGlow → aliased: const mGlow = getMGlow() +// 7. mAccentCore → aliased: const mAccentCore = getMAccentCore() +// VENUE_GEM_EMISSIVE_MUL is a plain const shorthand (no aliasing needed). +// Total DI params: 8 (1 plain const + 7 live getters, 0 setter pairs). + +export function createStringGlow({ + VENUE_GEM_EMISSIVE_MUL, + getGlowMul, + getVibrancyIdleOp, + getVenueSceneOverride, + getNStr, + getStringLines, + getMGlow, + getMAccentCore, +}) { + function updateStringHighlights(noteState) { + // DI: all mutable IIFE-scope vars aliased here; body is verbatim. + const glowMul = getGlowMul(); // DI: glowMul + const _vibrancyIdleOp = getVibrancyIdleOp(); // DI: _vibrancyIdleOp + const _venueSceneOverride = getVenueSceneOverride(); // DI: _venueSceneOverride + const nStr = getNStr(); // DI: nStr + const stringLines = getStringLines(); // DI: stringLines + const mGlow = getMGlow(); // DI: mGlow + const mAccentCore = getMAccentCore(); // DI: mAccentCore + + // Glow slider scales both the idle floor and anticipation peak, + // so glowMul=0 fully silences the per-string emissive pulse. + // Vibrancy controls the idle opacity floor — anticipation + // still rides on top regardless of vibrancy so play-feedback + // through the opacity channel survives even at glowMul=0. + // + // Folded with the post-noteState mGlow / mAccentCore writes + // (was a separate `for (s = 0; s < nStr)` loop in update()), + // so the per-string scratch arrays stay hot in L1 across all + // material writes for a given string. + const BASE_GLOW = 0.02 * glowMul; + const MAX_GLOW = 3.5 * glowMul; + const IDLE_OP = _vibrancyIdleOp; + const g = glowMul; + const venueGemMul = _venueSceneOverride ? VENUE_GEM_EMISSIVE_MUL : 1; + + for (let s = 0; s < nStr; s++) { + const mesh = stringLines[s]; + if (mesh) { + const intensity = Math.max( + noteState.stringSustain[s] ? 1 : 0, + noteState.stringAnticipation[s] || 0, + ); + mesh.material.emissiveIntensity = BASE_GLOW + intensity * MAX_GLOW; + mesh.material.opacity = IDLE_OP + intensity * (1 - IDLE_OP); + mesh.scale.set(1, 1 + intensity * 0.3, 1 + intensity * 0.3); + } + // Hit-note emissive — same write pattern as the standalone + // loop that previously lived at update()'s post-call site. + // The glow slider scales it here since this assignment + // stomps anything _applyGlow() set statically. + const bg = noteState.strGlow[s] * g; + if (mGlow[s]) mGlow[s].emissiveIntensity = bg * venueGemMul; + if (mAccentCore[s]) { + mAccentCore[s].emissiveIntensity = + (bg + noteState.accentFillBoost[s] * g) * venueGemMul; + } + } + } + + return { updateStringHighlights }; +} diff --git a/tests/js/highway_3d_string_glow.test.js b/tests/js/highway_3d_string_glow.test.js new file mode 100644 index 0000000..5ed8d45 --- /dev/null +++ b/tests/js/highway_3d_string_glow.test.js @@ -0,0 +1,293 @@ +// h3d-carve-11: Regression coverage for updateStringHighlights extracted into +// plugins/highway_3d/src/string-glow.js. +// +// Two test classes: +// - Source-level: module shape, DI wiring in screen.js, wiring-correspondence guard +// - Behavioral: calls updateStringHighlights with fake mesh/material objects, +// asserts actual emissive + opacity writes (RED when loop body is gutted) + +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const { pathToFileURL } = require('node:url'); + +const SCREEN_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'screen.js'); +const STRING_GLOW_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'src', 'string-glow.js'); + +const src = fs.readFileSync(SCREEN_JS, 'utf8'); +const stringGlowSrc = fs.readFileSync(STRING_GLOW_JS, 'utf8'); + +// ── Module shape ───────────────────────────────────────────────────────────── + +test('string-glow.js exports createStringGlow', () => { + assert.match(stringGlowSrc, /export\s+function\s+createStringGlow\s*\(/, + 'string-glow.js must export createStringGlow'); +}); + +test('createStringGlow returns { updateStringHighlights }', () => { + assert.match( + stringGlowSrc, + /return\s*\{\s*updateStringHighlights\s*\}/, + 'factory must return { updateStringHighlights }', + ); +}); + +// ── DI rewires (source-level) ──────────────────────────────────────────────── + +test('all 7 getter DI params aliased at updateStringHighlights entry', () => { + for (const [alias, getter] of [ + ['glowMul', 'getGlowMul'], + ['_vibrancyIdleOp', 'getVibrancyIdleOp'], + ['_venueSceneOverride','getVenueSceneOverride'], + ['nStr', 'getNStr'], + ['stringLines', 'getStringLines'], + ['mGlow', 'getMGlow'], + ['mAccentCore', 'getMAccentCore'], + ]) { + assert.match( + stringGlowSrc, + new RegExp('const\\s+' + alias.replace('_', '\\_?') + '\\s*=\\s*' + getter + '\\(\\)'), + `${getter}() must be aliased to ${alias} at function entry`, + ); + } +}); + +test('VENUE_GEM_EMISSIVE_MUL is used as a plain const (no getter call)', () => { + assert.match( + stringGlowSrc, + /VENUE_GEM_EMISSIVE_MUL/, + 'VENUE_GEM_EMISSIVE_MUL must appear in the module', + ); + assert.doesNotMatch( + stringGlowSrc, + /getVenueGemEmissiveMul/, + 'VENUE_GEM_EMISSIVE_MUL must not be wrapped in a getter', + ); +}); + +// ── screen.js wiring ───────────────────────────────────────────────────────── + +test('screen.js imports createStringGlow from src/string-glow.js', () => { + assert.match( + src, + /import\s*\{\s*createStringGlow\s*\}\s*from\s*'\.\/src\/string-glow\.js'/, + 'screen.js must import createStringGlow', + ); +}); + +test('screen.js original updateStringHighlights body is gone (no bare glowMul const inside)', () => { + // After extraction the function definition no longer lives in screen.js. + // The clearest signal: `const BASE_GLOW = 0.02 * glowMul` was inside the + // function body and must not appear in screen.js post-extraction. + assert.doesNotMatch( + src, + /const\s+BASE_GLOW\s*=\s*0\.02\s*\*\s*glowMul/, + 'BASE_GLOW constant must not remain in screen.js after extraction', + ); +}); + +test('screen.js callsite uses createStringGlow factory destructure', () => { + assert.match( + src, + /const\s*\{\s*updateStringHighlights\s*\}\s*=\s*createStringGlow\s*\(/, + 'screen.js must destructure updateStringHighlights from createStringGlow()', + ); +}); + +// ── Wiring-correspondence guard (cut-9 pattern, empty PINNED_RENAMES) ──────── +// Verifies every entry in createStringGlow({…}) satisfies its naming class. +// Kills swaps like getMGlow: () => mAccentCore. + +test('createStringGlow({...}) wiring has correct naming correspondence (no param swaps)', () => { + const PINNED_RENAMES = {}; // all entries are plain shorthand or standard get-arrows + + const ANCHOR = 'const { updateStringHighlights } = createStringGlow({'; + const callStart = src.indexOf(ANCHOR); + assert.ok(callStart >= 0, 'createStringGlow call must be findable in screen.js'); + const blockStart = callStart + ANCHOR.length - 1; + assert.equal(src[blockStart], '{', 'expected { at computed blockStart'); + let depth = 0, blockEnd = -1; + for (let i = blockStart; i < src.length; i++) { + if (src[i] === '{') depth++; + else if (src[i] === '}' && --depth === 0) { blockEnd = i; break; } + } + assert.ok(blockEnd > blockStart, 'createStringGlow argument block must have balanced braces'); + const inner = src.slice(blockStart + 1, blockEnd); + + const rawEntries = []; + let current = '', d = 0; + for (let i = 0; i < inner.length; i++) { + const ch = inner[i]; + if (ch === '{') d++; + else if (ch === '}') d--; + if (ch === ',' && d === 0) { + const t = current.trim(); + if (t) rawEntries.push(t); + current = ''; + } else { + current += ch; + } + } + if (current.trim()) rawEntries.push(current.trim()); + + const entries = rawEntries + .map(e => e.replace(/\/\/[^\n]*/g, '').trim()) + .filter(Boolean); + + assert.ok(entries.length >= 8, `expected at least 8 entries, got ${entries.length}`); + + const violations = []; + for (const entry of entries) { + if (!entry.includes(':')) continue; // shorthand (VENUE_GEM_EMISSIVE_MUL, etc.) + + const colonIdx = entry.indexOf(':'); + const key = entry.slice(0, colonIdx).trim(); + const value = entry.slice(colonIdx + 1).trim(); + + if (key in PINNED_RENAMES) { + if (value !== PINNED_RENAMES[key]) { + violations.push(`${key}: pinned to '${PINNED_RENAMES[key]}' but got '${value}'`); + } + continue; + } + + if (key.startsWith('get')) { + const expectedStem = key[3].toLowerCase() + key.slice(4); + const m = value.match(/^\(\)\s*=>\s*_?(\w+)$/); + if (!m) { + violations.push(`${key}: getter value '${value}' does not match () => [_]var`); + continue; + } + if (m[1] !== expectedStem) { + violations.push(`${key}: getter body references var stem '${m[1]}' but expected '${expectedStem}'`); + } + continue; + } + + violations.push(`${key}: key:value entry not in PINNED_RENAMES and not a get/set arrow`); + } + + assert.deepEqual(violations, [], 'createStringGlow wiring violations found'); +}); + +// ── Behavioral kill test ───────────────────────────────────────────────────── +// Calls updateStringHighlights with fake mesh/material objects and asserts +// the emissive and opacity writes actually happened. RED when loop body is gutted. + +test('updateStringHighlights writes emissive intensity and opacity to string meshes (behavioral kill: gut loop → RED)', async () => { + const { createStringGlow } = await import(pathToFileURL(STRING_GLOW_JS).href); + + // Fake material that records writes. + const mat0 = { emissiveIntensity: 0, opacity: 0 }; + const mat1 = { emissiveIntensity: 0, opacity: 0 }; + const scaleSet = []; + const stringLines = [ + { material: mat0, scale: { set(...args) { scaleSet.push([0, ...args]); } } }, + { material: mat1, scale: { set(...args) { scaleSet.push([1, ...args]); } } }, + ]; + const mGlow = [{ emissiveIntensity: 0 }, { emissiveIntensity: 0 }]; + const mAccentCore = [{ emissiveIntensity: 0 }, { emissiveIntensity: 0 }]; + + const { updateStringHighlights } = createStringGlow({ + VENUE_GEM_EMISSIVE_MUL: 1.12, + getGlowMul: () => 1, + getVibrancyIdleOp: () => 0.4, + getVenueSceneOverride: () => false, + getNStr: () => 2, + getStringLines: () => stringLines, + getMGlow: () => mGlow, + getMAccentCore: () => mAccentCore, + }); + + // String 0: sustaining (stringSustain=true) + strGlow=0.8 + // String 1: anticipating (stringAnticipation=0.5) + strGlow=0.3 + const noteState = { + stringSustain: [true, false], + stringAnticipation: [0, 0.5], + strGlow: [0.8, 0.3], + accentFillBoost: [0, 0], + }; + + updateStringHighlights(noteState); + + // String 0 — sustain intensity=1: BASE_GLOW=0.02, MAX_GLOW=3.5 + const expectedEI0 = 0.02 + 1 * 3.5; // 3.52 + assert.strictEqual( + mat0.emissiveIntensity, + expectedEI0, + `string 0 emissiveIntensity must be BASE_GLOW + MAX_GLOW = ${expectedEI0}`, + ); + // IDLE_OP=0.4, intensity=1 → opacity = 0.4 + 1*(1-0.4) = 1.0 + assert.strictEqual(mat0.opacity, 1.0, 'string 0 opacity must be 1 when sustaining'); + + // String 1 — anticipation=0.5: emissive = 0.02 + 0.5*3.5 = 1.77 + const expectedEI1 = 0.02 + 0.5 * 3.5; + assert.strictEqual(mat1.emissiveIntensity, expectedEI1, + `string 1 emissiveIntensity must be BASE_GLOW + 0.5*MAX_GLOW = ${expectedEI1}`); + + // mGlow writes: bg = strGlow * glowMul; venueGemMul = 1 (no venue override) + assert.strictEqual(mGlow[0].emissiveIntensity, 0.8, 'mGlow[0] must receive strGlow[0] * glowMul'); + assert.strictEqual(mGlow[1].emissiveIntensity, 0.3, 'mGlow[1] must receive strGlow[1] * glowMul'); + + // scale.set was called for both strings (intensity > 0) + assert.ok(scaleSet.length === 2, 'scale.set must be called for both strings'); +}); + +test('updateStringHighlights respects venueSceneOverride multiplier on mGlow (behavioral)', async () => { + const { createStringGlow } = await import(pathToFileURL(STRING_GLOW_JS).href); + + const mGlow = [{ emissiveIntensity: 0 }]; + const mAccentCore = [{ emissiveIntensity: 0 }]; + + const { updateStringHighlights } = createStringGlow({ + VENUE_GEM_EMISSIVE_MUL: 1.12, + getGlowMul: () => 1, + getVibrancyIdleOp: () => 0.4, + getVenueSceneOverride: () => true, // venue override ON + getNStr: () => 1, + getStringLines: () => [null], // no mesh → only glow write + getMGlow: () => mGlow, + getMAccentCore: () => mAccentCore, + }); + + updateStringHighlights({ + stringSustain: [false], + stringAnticipation: [0], + strGlow: [1.0], + accentFillBoost: [0], + }); + + // bg=1.0, venueGemMul=1.12 → mGlow[0].emissiveIntensity = 1.12 + assert.ok( + Math.abs(mGlow[0].emissiveIntensity - 1.12) < 1e-9, + `mGlow emissiveIntensity must be bg * VENUE_GEM_EMISSIVE_MUL = 1.12, got ${mGlow[0].emissiveIntensity}`, + ); +}); + +test('updateStringHighlights skips null stringLines entries without throwing (behavioral)', async () => { + const { createStringGlow } = await import(pathToFileURL(STRING_GLOW_JS).href); + + const mGlow = [{ emissiveIntensity: 0 }, { emissiveIntensity: 0 }]; + const mAccentCore = [{ emissiveIntensity: 0 }, { emissiveIntensity: 0 }]; + + const { updateStringHighlights } = createStringGlow({ + VENUE_GEM_EMISSIVE_MUL: 1.12, + getGlowMul: () => 1, + getVibrancyIdleOp: () => 0.4, + getVenueSceneOverride: () => false, + getNStr: () => 2, + getStringLines: () => [null, null], // no meshes at all + getMGlow: () => mGlow, + getMAccentCore: () => mAccentCore, + }); + + // Must not throw; mGlow writes still happen + assert.doesNotThrow(() => updateStringHighlights({ + stringSustain: [true, true], + stringAnticipation: [0, 0], + strGlow: [0.5, 0.5], + accentFillBoost: [0, 0], + })); + assert.strictEqual(mGlow[0].emissiveIntensity, 0.5, 'mGlow writes must still happen for null mesh slots'); +});