h3d-carve-11: extract R-section (string glow) into src/string-glow.js

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014uZ169yfoFYArXz962g7KW
This commit is contained in:
byrongamatos
2026-09-05 17:51:54 +02:00
co-authored by Claude Sonnet 4.6
parent 4b2e4172c5
commit 930b492fa5
4 changed files with 384 additions and 41 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
{
"id": "highway_3d",
"name": "3D Highway",
"version": "3.46.0",
"version": "3.47.0",
"type": "visualization",
"scriptType": "module",
"bundled": true,
+12 -40
View File
@@ -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
+78
View File
@@ -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 };
}
+293
View File
@@ -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');
});